Skip to content

Add OpenCode Go agent usage provider - #11167

Closed
tedwardd wants to merge 14 commits into
omacom:quattrofrom
tedwardd:opencode-go-provider
Closed

tedwardd wants to merge 14 commits into
omacom:quattrofrom
tedwardd:opencode-go-provider

Conversation

@tedwardd

@tedwardd tedwardd commented Sep 10, 2026 •

Copy link
Copy Markdown

Summary

Adds the OpenCode Go provider to the agents panel: a new collector (omarchy-agent-usage-opencode-go) plus the small panel-side plumbing it needs (stale-data marker, assets, manifest entry, docs).

OpenCode Go exposes no usage API, so the collector reads the opencode.ai workspace console, using the auth session cookie any browser on the machine stores for opencode.ai:

  • Limits — the rolling 5-hour / weekly / monthly percent meters with reset times from /workspace/<id>/go. Meters the console has flipped to rate-limited are reported at full percentage with their reset countdown (each window travels with an explicit Rolling/Weekly/Monthly title), and the status card explains the consequence: requests block until spend ages out, or — with the workspace's "Use balance" option on and Zen credits remaining — the overage bills the balance, with the amount left.
  • Usage stats — per-request token records from the workspace usage list, paged newest-first through the SPA's server function so the trailing 7 local days survive later refreshes (with a page cap and a wall-clock deadline). "Tokens by day" and "tokens by model" both describe that week. Session counts are not exposed, so the panel hides the prompt/session line.
  • Session cookies from every browser — Firefox and Zen keep cookies in plaintext sqlite. Chrome-family profiles (Chrome, Chromium, Brave, Edge, Vivaldi, Opera — Flatpak and snap layouts included) encrypt their values, so the collector deciphers them with the browser's own SafeStorage password from the Secret Service: PBKDF2-derived AES-128-CBC for the modern v11 fixed-IV layout and the legacy v10 layout, with the keyringless "peanuts" fallback. App-bound (v20+) rows, locked keyrings, and machines without cryptography/secret-tool are skipped, never fatal; when several browsers hold a session, the one used most recently wins.
  • Failure behavior — last known meters are cached and kept visible with a status card when the sign-in is stale or the console is unreachable; transport failures advise an earlier retry. Opening the panel runs a limits-only refresh: meters fresh, usage walk skipped, and the previous record's stats stay on the chart.
  • Panel — a stale-data marker (record age past two refresh intervals → hero detail pill) so a collector that stops running is visible.

Notes for reviewers

  • The two SolidStart server-function IDs (WORKSPACES_SERVER_FN_ID, USAGE_SERVER_FN_ID) and the seroval argument encoding are pinned to the current opencode.ai SPA; a console redesign needs those constants refreshed, as documented in the collector.
  • The console's meters are workspace-level; the per-model dollar allowances behind them are not exposed on the page, so limits report what the page shows.
  • Stats are scope: "account", so cross-device sync aggregation takes the widest value rather than summing the same account twice.
  • A workspace id is required when the session has more than one (workspaceId in ~/.config/omarchy/agents/opencode-go.json or OPENCODE_WORKSPACE).
  • The branch went through three review rounds; every finding is addressed — limits-only freshness keeps the panel chart alive (the one regression the second round caught), explicit window titles, pagination deadline, bounded balance parsing, cookie freshness across profiles, interrupt-safe cache writes, and the Chrome-family cookie support added after the first round.

Tests

  • Collector suite: 22 checks — meters and window titles, paging including interior short pages, the page cap, mid-walk and unparsable-page failures, rate-limited meters, the Zen-balance fallback, limits-only freshness, cache fallback, cookie extraction and profile discovery, and Chrome-family coverage (v11/v10 decryption, plaintext rows, subdomain scoping, app-bound skipping, FILETIME timestamp normalization, cross-browser freshest-wins merge).
  • agents-panel-test.sh (11) and agent-usage-update-test.sh (7) extended and passing; ./test/cli green.
  • Live-validated against a genuinely rate-limited account and against the machine's own Chromium cookie store (the auth token deciphers to its Django-signed value).

… regex, improve error handling

The collector was falsely reporting 'Sign-in expired' for valid sessions.
This was caused by multiple interacting bugs:

1. Cookie selection was broken:
   - The expiry filter compared milliseconds (Firefox storage) against
     seconds (strftime('%s')), making it useless.
   - We picked the single 'auth' cookie with the highest expiry, but
     opencode.ai's Iron-encrypted cookies don't correlate browser expiry
     with session freshness.
   - We only sent 'auth=...', missing 'oc_locale' and other cookies the
     server might need.
   - We included subdomain cookies (auth.opencode.ai) in requests to the
     main domain, which browsers don't do.

   Fixed by replacing extract_auth_cookie with extract_all_cookies, which
   reads every opencode.ai cookie ordered by lastAccessed (when Firefox
   last sent it). build_cookie_header formats them exactly like a browser
   would: all domain-matched cookies in one header, deduplicated by name
   with the most recently accessed value winning.  Subdomain-scoped
   cookies are excluded to match real browser behavior.

2. The meter regex failed on the current opencode.ai page format:
   The server now sends extra fields (usage, limit) inside meter objects.
   The old regex expected usagePercent to be immediately followed by }.
   Added [^}]* to tolerate any trailing fields.

3. Every HTTP error was called 'Sign-in expired':
   404, 429, 500, etc. all produced the same misleading message.
   Added FetchError with the real status code; only 401/403 get the auth
   label. Everything else gets 'Server error' with retryAdvised.

4. Added retry logic for transient failures:
   fetch_page retries once on transport errors (timeout, no route) and
   on 5xx responses, with a 1-second pause. 4xx errors are not retried.

5. Added format-change detection:
   When the Go page loads but carries no meter data, we now check for
   'OpenAuth' or '/signin' to distinguish a genuine auth redirect from
   an unexpected page layout.  The latter gets 'Page format changed'
   with retryAdvised, making future breakages easier to diagnose.

6. Added --debug flag for diagnostics.

7. Hardened cookie DB access:
   OSError/PermissionError during the sqlite copy are caught and treated
   as a missing cookie instead of crashing the collector.

Tests updated for the new cookie API, meter fixture format, and format-
change detection path.
The usage page SSR embeds only the newest 50 per-request records and the
SPA fetches older pages through its usage-list server function. The
collector stopped at that page, so any day that scrolled past the newest
50 vanished from the record on the next refresh: yesterday read 0 the
moment today filled the page.

Walk the server-function pages newest-first until a page comes back
empty or its oldest record predates the trailing 7 local days, then
bound the per-request stats (today*, recentDays, modelUsage) to that
window. Page size is not a stop signal — records land while the walk
runs and an interior page can come back short — and the walk keeps the
pages already parsed if one fails mid-way.
The collector silently dropped every meter whose status was not 'ok'. A
meter flipped to 'rate-limited' is the binding constraint, not a gap: the
panel went quiet exactly when requests stopped. Now:

- build_limits keeps every reported meter, clamping the percentage at
  full and carrying the server's status on throttled entries.
- the record announces the exhausted window in usageStatusText and
  explains the consequence in authHelpText: requests block until spend
  ages out, or — when the workspace's 'Use balance' option is on and Zen
  credits remain — the overage bills the Zen balance, with the amount
  left (balance field, 1e-8-dollar units like the meter limits).
- parse_go_config reads useBalance and the preceding balance off the go
  page; an absent block parses as defaults (hard block).
Code review of the earlier provider work surfaced a dozen items; all are
addressed here:

- --limits-only now skips the usage walk (the panel's refreshLimits() call
  opens with a cheap limits-only refresh instead of paging the history).
- build_limits emits an explicit title per window so the panel stops
  rendering the rolling 5-hour allowance as a generic session window.
- the usage walk gets a 60-second wall-clock deadline on top of its page
  cap, and a page that refuses to parse stops the walk while keeping the
  pages already read (previously one bad record discarded everything).
- number() maps non-finite values (inf, 1e999) to zero instead of raising
  OverflowError and aborting the parse.
- cookie de-duplication sorts merged databases by lastAccessed so the
  freshest value wins across profiles, not whichever DB sorts first.
- parse_go_config bounds its balance search to the enclosing workspace
  object instead of the whole page prefix.
- write_limits_cache cleans up its temp file on the way out without
  swallowing KeyboardInterrupt/SystemExit, and limit_window_open keeps a
  cached window through its reset instant instead of dropping it exactly
  at reset time.
- the fetch retry ladder is deduplicated into one _request_with_retries,
  and Panel.qml's formatDuration gains the compact form that used to live
  in a duplicated formatAge.

Tests: collector suite grows to 22 (titles, limits-only walk skip, bounded
balance search, cache-write path, profile discovery + cross-profile
freshness, unparsable-page walk, non-finite counts).
Honoring --limits-only (skip the usage walk) had a side effect: the record
it printed carried zeroed stats, and the update runner writes that record
verbatim to the state file, so every panel open blanked the opencode-go tab
— today's line, the week chart, and the model rows — until the next full
refresh (up to 15 minutes). The sibling collectors never do this: Claude and
Codex reuse a recent scan under --limits-only, and Fireworks has no separate
walk to skip.

main() now reuses the stats from the record the last run left in the state
file when the walk is skipped (previous_stats), merging them with the fresh
meters — the chart keeps the last numbers instead of zeroing out, and the
walk still never runs. A record that is missing, foreign, or partial falls
back to the zero shape exactly as if no run had written yet, and a full
refresh still tallies stats from the walk as before.

Tests: limits-only with no prior record still yields zero-shaped stats
(hermetic XDG_STATE_HOME pinned so the suite never reads a real user's
state), and a limits-only run with a prior stats-bearing record keeps
today's counts, the chart, and the model rows while refreshing the meters.
The collector only read Firefox/Zen cookie stores, whose sqlite keeps
values in plaintext. Chrome-family profiles (Chrome, Chromium, Brave,
Edge, Vivaldi, Opera \u2014 classic ~/.config, Flatpak, and snap layouts)
encrypt them, so signing in with a Chromium-based browser never produced a
session. Now:

- find_chrome_cookie_dbs discovers every profile's Cookies database
  (top-level or Network/Cookies) for all known Chrome-family config dirs.
- extract_chrome_cookies mirrors the Firefox reader: WAL-safe temp copy,
  host scoping to the main opencode.ai domain, (name, value, lastAccessed)
  tuples, and FILETIME timestamps normalized to Unix microseconds so the
  cross-browser freshness sort stays honest.
- Values are deciphered with the browser's own SafeStorage password from
  the Secret Service (secret-tool lookup by the application attribute), the
  same item the browser reads: PBKDF2-HMAC-SHA1("saltysalt", 1) key,
  AES-128-CBC over the b"v11" blob with its fixed all-space IV, the
  SHA256(host_key) integrity prefix stripped as the store does. The legacy
  b"v10" random-IV layout and the keyringless "peanuts" (and empty)
  fallback keys cover older builds. App-bound b"v20+" rows, rows with no
  candidate key, and stores on machines without the cryptography wheel or
  secret-tool are skipped, never fatal; the password is only fetched when a
  store actually holds encrypted opencode.ai rows.

Live-validated against the machine's own Chromium store (auth token
deciphers to its Django-signed value, oc_locale to "en"; the collector
authenticates and fetches meters through the Chrome cookie), with new
tests for v11 and v10 decryption, plaintext rows, subdomain scoping,
app-bound skipping, FILETIME normalization, profile discovery, and the
cross-browser freshest-wins merge.
- Panel section: the hero now names the Go plan, exhausted rate-limit
  windows repeat in the status card like auth failures, and a record that
  outlives a refresh cycle earns a stale detail pill.
- Collectors table: opencode-go limits note rate-limited windows are
  reported at full with a status card.
- OpenCode Go section: the glued-in Chrome sentence is restructured into a
  cookie-source breakdown (Firefox/Zen plaintext, Chrome-family SafeStorage
  with the v11/v10 layouts and the peanuts fallback, app-bound v20+ and
  missing-cryptography machines skipped), plus the facts that the most
  recently used browser's session wins when several hold one, and that
  opening the panel is a limits-only refresh that keeps the previous
  record's stats on the chart.
- The account-scope paragraph now names OpenCode Go alongside Fireworks'
  billing API as an account-global stats source.
The limits bullet named only the session and weekly windows the claude and
codex collectors report; the branch adds a monthly window via the
opencode-go meter. The providers settings example also predated the
opencode-go manifest default.
@bjarneo

bjarneo commented Sep 20, 2026

Copy link
Copy Markdown
Member

Automated duplication check: this pull request looks similar to #8065, which covers the same OpenCode usage collector. I keep that one open and close this one to consolidate review.

If you feel this is the wrong decision, please open the PR again with a note on the difference.

@bjarneo bjarneo closed this Sep 20, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants