2026-07-29 22:07:14 +00:00
|
|
|
# ocd design
|
|
|
|
|
|
|
|
|
|
`ocd` is a console application. It reads a config file. The file lists opencode
|
|
|
|
|
accounts and auth cookies. `ocd` scrapes the opencode Zen and Go pages. It
|
|
|
|
|
returns limits and balance data. Limits cover three time frames: 5 hours, week,
|
2026-07-29 22:42:10 +00:00
|
|
|
and month. The opencode app is React Router v7 with streaming SSR. The route
|
|
|
|
|
loader data is inlined in the HTML as JS resource objects. There is no
|
|
|
|
|
separate JSON HTTP endpoint. `ocd` parses these embedded records first — they
|
|
|
|
|
give an exact integer `resetInSec` and a `status` field — and falls back to the
|
|
|
|
|
rendered `data-slot` DOM when the embedded path finds nothing. `ocd` stores
|
|
|
|
|
the percentage and computes an absolute reset time from `resetInSec`. The Zen
|
|
|
|
|
page reports remaining balance the same way. `ocd` prints all of it.
|
2026-07-29 22:07:14 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
## Goals
|
|
|
|
|
|
|
|
|
|
1. Speed.
|
|
|
|
|
2. Polite defaults. We do not hurt opencode.
|
|
|
|
|
3. Ease of use.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
## Stack
|
|
|
|
|
|
|
|
|
|
The program is written in Rust.
|
|
|
|
|
|
|
|
|
|
| Concern | Crate | Reason |
|
|
|
|
|
|---|---|---|
|
|
|
|
|
| HTTP and cookies | `reqwest`, rustls, no openssl | One client reuses connections. One TLS handshake serves all workspaces. |
|
|
|
|
|
| Async runtime | `tokio` | Paced sequential pipeline. Two requests run at once per workspace. |
|
|
|
|
|
| HTML parse | `scraper` on `html5ever` | Fast CSS selectors. This is the only opencode-coupled module. |
|
|
|
|
|
| Config | `serde` and `toml` | Human-editable account list. |
|
|
|
|
|
| CLI | `clap` derive | Subcommands and global flags. |
|
|
|
|
|
| Pretty output | Custom renderer and `comfy-table` | Unicode blitter bars. The program degrades to ASCII. |
|
|
|
|
|
| Color | `anstyle` | Color turns off when output is not a terminal. |
|
|
|
|
|
| Raw output | `serde_json` | Stable schema. One `schema_version` field. |
|
|
|
|
|
| Cache path | `directories` | XDG cache dir. |
|
|
|
|
|
| Time | `chrono` now, `jiff` later | Reset times and relative strings. |
|
|
|
|
|
| Errors | `anyhow` and `thiserror` | Typed fetch and parse errors. |
|
|
|
|
|
| Diagnostics | `tracing` | `RUST_LOG` controls verbosity. |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
## Workspaces
|
|
|
|
|
|
|
|
|
|
An opencode account holds multiple workspaces. The Go subscription is
|
|
|
|
|
per-workspace. So config carries one `default_workspace` plus an
|
|
|
|
|
`extra_workspaces` list. Reports key by account and workspace.
|
|
|
|
|
|
|
|
|
|
`default_workspace` is a required id. The program fetches it by default.
|
|
|
|
|
`extra_workspaces` is optional. The program fetches all listed workspaces.
|
|
|
|
|
|
2026-07-29 22:16:36 +00:00
|
|
|
The program does not auto-discover the default workspace. That path is not
|
|
|
|
|
reliable. The user supplies the id. Workspace ids look like
|
|
|
|
|
`wrk_01KYNTSGXF7GFXMAMT36N3GH21`. A future `ocd workspace list` command
|
2026-07-29 22:07:14 +00:00
|
|
|
can add discovery later.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
## Two pages per workspace
|
|
|
|
|
|
2026-07-29 22:16:36 +00:00
|
|
|
Each workspace needs two HTTP requests. Each request puts the workspace id
|
|
|
|
|
in the path.
|
|
|
|
|
|
|
|
|
|
1. The Zen page is `https://opencode.ai/workspace/{id}`. It returns balance.
|
|
|
|
|
2. The Go page is `https://opencode.ai/workspace/{id}/go`. It returns limits.
|
2026-07-29 22:07:14 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
The program fires both at once per workspace. It uses `tokio::join`. Then it
|
|
|
|
|
waits about 500 ms. Then it moves to the next workspace. The workspace walk is
|
|
|
|
|
sequential. Per-workspace concurrency is two. Cross-workspace concurrency is
|
|
|
|
|
one. This pacing keeps opencode load low.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
## Reset times
|
|
|
|
|
|
|
|
|
|
Each time frame resets on its own rule.
|
|
|
|
|
|
|
|
|
|
- 5 hours: window based. The window starts on activity.
|
|
|
|
|
- Week: calendar based. It resets every Monday.
|
|
|
|
|
- Month: calendar based. It resets 31 days from the subscription date.
|
|
|
|
|
|
2026-07-29 22:16:36 +00:00
|
|
|
The Go page gives a relative reset string such as "Resets in 30 days 7
|
|
|
|
|
hours". The scrape step parses that into seconds and adds it to the fetch
|
|
|
|
|
time. The result is an absolute `resets_at` datetime per frame. This is how a
|
|
|
|
|
widget gets a sortable time even though the page only states a duration.
|
2026-07-29 22:07:14 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
## Config format
|
|
|
|
|
|
|
|
|
|
```toml
|
|
|
|
|
# ~/.config/ocd/config.toml
|
|
|
|
|
default_concurrency = 1
|
|
|
|
|
default_ttl_secs = 180
|
|
|
|
|
|
|
|
|
|
[[account]]
|
|
|
|
|
name = "work"
|
|
|
|
|
cookie = "${OCD_COOKIE_WORK}"
|
2026-07-29 22:16:36 +00:00
|
|
|
default_workspace = "wrk_01KYNTSGXF7GFXMAMT36N3GH21"
|
|
|
|
|
extra_workspaces = ["wrk_02KYNTSGXF7GFXMAMT36N3GH21"]
|
2026-07-29 22:07:14 +00:00
|
|
|
|
|
|
|
|
[[account]]
|
|
|
|
|
name = "personal"
|
|
|
|
|
cookie = "ocd_session=...."
|
2026-07-29 22:16:36 +00:00
|
|
|
default_workspace = "wrk_03KYNTSGXF7GFXMAMT36N3GH21"
|
2026-07-29 22:07:14 +00:00
|
|
|
```
|
|
|
|
|
|
|
|
|
|
Config load order: `-c` flag, then `$OCD_CONFIG`, then `./ocd.toml`, then
|
|
|
|
|
`$XDG_CONFIG_HOME/ocd/config.toml`.
|
|
|
|
|
|
|
|
|
|
`${VAR}` expands in string values. This keeps cookies out of the file.
|
|
|
|
|
|
|
|
|
|
Commands:
|
|
|
|
|
|
|
|
|
|
- `ocd config init` writes a template.
|
|
|
|
|
- `ocd config check` parses the file and lists accounts. It does no network.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
## CLI surface
|
|
|
|
|
|
|
|
|
|
```
|
|
|
|
|
ocd pretty default; bare `ocd` is an alias
|
|
|
|
|
ocd raw json one stable JSON object to stdout
|
|
|
|
|
ocd raw plain TSV-ish lines for shell and awk widgets
|
|
|
|
|
ocd fetch run network fetch and update cache; print nothing; for cron
|
2026-07-29 23:04:53 +00:00
|
|
|
ocd health probe each auth cookie; report dead cookies and a count
|
2026-07-29 22:07:14 +00:00
|
|
|
ocd cache ls|clear inspect or clear the cache
|
|
|
|
|
ocd config init|check
|
|
|
|
|
|
|
|
|
|
Global flags:
|
|
|
|
|
|
|
|
|
|
- `-c`, `--config PATH`
|
|
|
|
|
- `--no-cache`
|
|
|
|
|
- `--ttl SECS`
|
|
|
|
|
- `--force-refresh`
|
|
|
|
|
- `-a`, `--account NAME...`
|
|
|
|
|
- `-w`, `--workspace ID...`
|
|
|
|
|
- `-j`, `--concurrency N` (default 1; workspace walk stays sequential)
|
|
|
|
|
- `--timeout SECS`
|
|
|
|
|
- `--retries N`
|
|
|
|
|
- `--user-agent`
|
|
|
|
|
- `-v`, `--verbose` (repeat for more)
|
|
|
|
|
- `--no-color`
|
|
|
|
|
- `--ascii`
|
|
|
|
|
- `-q`, `--quiet`
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
## Data model
|
|
|
|
|
|
|
|
|
|
```rust
|
|
|
|
|
struct Envelope {
|
|
|
|
|
schema_version: u32, // 1
|
|
|
|
|
generated_at: String, // ISO 8601 UTC
|
|
|
|
|
accounts: Vec<AccountReport>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
struct AccountReport {
|
|
|
|
|
account: String, // config name
|
|
|
|
|
workspace: String, // workspace id
|
|
|
|
|
fetched_at: Option<String>, // ISO 8601 UTC
|
|
|
|
|
status: ReportStatus, // Ok | Cached | RateLimited | Timeout | Error(msg)
|
|
|
|
|
limits: Option<Limits>,
|
|
|
|
|
balance: Option<Balance>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
struct Limits {
|
|
|
|
|
usage_5h: UsageFrame,
|
|
|
|
|
usage_week: UsageFrame,
|
|
|
|
|
usage_month: UsageFrame,
|
|
|
|
|
next_update: Option<String>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
struct UsageFrame {
|
|
|
|
|
used: f64,
|
|
|
|
|
total: f64,
|
|
|
|
|
unit: String, // "credits" | "requests" | "$"
|
|
|
|
|
resets_at: Option<String>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
struct Balance {
|
|
|
|
|
remaining: f64,
|
|
|
|
|
unit: String,
|
|
|
|
|
}
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
## Run flow
|
|
|
|
|
|
|
|
|
|
1. Parse flags and subcommand.
|
|
|
|
|
2. Load config. Expand `${VAR}`. Filter by `--account`.
|
|
|
|
|
3. Flatten targets. Each target is one account and one workspace.
|
|
|
|
|
4. Filter targets by `--workspace`.
|
|
|
|
|
5. Open the cache store.
|
|
|
|
|
6. Walk targets in order.
|
|
|
|
|
- Cache fresh and present. Emit `Cached`. No network.
|
|
|
|
|
- Else fire Zen and Go at once. Use conditional GET headers when cached.
|
|
|
|
|
On 304, reuse cache. On 200, parse both pages in `scrape.rs`, build the
|
|
|
|
|
report, and write cache. On 429, back off or honor `Retry-After`. On 5xx
|
|
|
|
|
or timeout, retry with backoff and jitter. On hard fail, emit `Error`
|
|
|
|
|
and continue other workspaces.
|
2026-07-29 23:04:53 +00:00
|
|
|
- A dead auth cookie sends 302 to `/auth/authorize` (or 401/403). The
|
|
|
|
|
HTTP client does not follow redirects, so this surfaces as the
|
|
|
|
|
`CookieDead` status, not a parse error. The run continues with the
|
|
|
|
|
valid accounts and reports the dead ones in the output.
|
2026-07-29 22:07:14 +00:00
|
|
|
- Sleep about 500 ms before the next workspace.
|
|
|
|
|
7. Collect reports in config order.
|
|
|
|
|
8. Detect terminal width. Render.
|
|
|
|
|
9. Exit 0 when any account is Ok or Cached. Exit 1 only when all fail.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
## Politeness defaults
|
|
|
|
|
|
|
|
|
|
| Knob | Default |
|
|
|
|
|
|---|---|
|
|
|
|
|
| per-workspace requests | 2, run at once |
|
|
|
|
|
| cross-workspace concurrency | 1 |
|
|
|
|
|
| workspace pacing | 500 ms between workspaces |
|
|
|
|
|
| request timeout | 15 s |
|
|
|
|
|
| retries | 3, transient only |
|
|
|
|
|
| backoff | 500 ms, double per try, plus jitter |
|
|
|
|
|
| cache TTL | 180 s |
|
|
|
|
|
| fetches per workspace per run | 1, no loop |
|
|
|
|
|
| 429 | honor `Retry-After`, else back off; mark `RateLimited` |
|
|
|
|
|
|
|
|
|
|
Conditional GET sends `If-Modified-Since` and `If-None-Match` when cached. A 304
|
|
|
|
|
reuses cache at zero bandwidth. This is the cheapest hit for us and opencode.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
## bleak project layout
|
|
|
|
|
|
|
|
|
|
```
|
|
|
|
|
src/
|
|
|
|
|
main.rs clap entry and dispatch
|
|
|
|
|
cli.rs clap definitions
|
|
|
|
|
config.rs TOML load, ${VAR} expansion, Account
|
|
|
|
|
model.rs Envelope, AccountReport, Limits, UsageFrame, Balance
|
|
|
|
|
client.rs reqwest client, get with retry and backoff
|
|
|
|
|
fetch.rs paced workspace walk, join Zen and Go, cache read and write
|
|
|
|
|
cache.rs XDG file store, TTL, etag and last-modified
|
|
|
|
|
scrape.rs HTML to model; the only opencode-coupled module
|
|
|
|
|
render/pretty.rs Unicode blitter-bar renderer and table
|
|
|
|
|
render/raw.rs json and plain serializers
|
|
|
|
|
timefmt.rs relative and absolute time strings
|
|
|
|
|
error.rs typed fetch and parse errors
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
`scrape.rs` holds the only opencode HTML selectors. When the page changes, swap
|
|
|
|
|
this file. Transport, cache, and render stay untouched.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
## Pretty renderer
|
|
|
|
|
|
|
|
|
|
Blitter blocks fill a bar.
|
|
|
|
|
|
|
|
|
|
- Full: `█`.
|
|
|
|
|
- Eighths: `▏▎▍▌▋▊▉`.
|
|
|
|
|
- Shaded: `▒`, `▓`.
|
|
|
|
|
- Empty: `░`.
|
|
|
|
|
|
|
|
|
|
Bar width comes from terminal width. Filled cells are `floor(pct * W)`. The
|
|
|
|
|
trailing cell uses the matching eighths block. The remainder is `░`.
|
|
|
|
|
|
|
|
|
|
Color zones. Color is off when output is not a terminal.
|
|
|
|
|
|
|
|
|
|
- Under 70 per cent: green.
|
|
|
|
|
- 70 to 90 per cent: amber.
|
|
|
|
|
- Over 90 per cent: red.
|
|
|
|
|
|
2026-07-29 22:16:36 +00:00
|
|
|
Each row shows percentage and `resets in 3h12m`. The opencode Go page exposes
|
|
|
|
|
only a percentage per frame, not an absolute count, so the bar is the percentage.
|
2026-07-29 22:07:14 +00:00
|
|
|
|
2026-07-29 22:16:36 +00:00
|
|
|
The next-update row shows `2025-08-30 17:00 UTC (in 47m)` when the page ever
|
|
|
|
|
reports one. Today the opencode page has no such banner, so the field stays
|
|
|
|
|
empty in the model.
|
2026-07-29 22:07:14 +00:00
|
|
|
|
|
|
|
|
`--ascii` or a non-UTF-8 locale prints `#` repeats.
|
|
|
|
|
|
|
|
|
|
|
2026-07-29 23:04:53 +00:00
|
|
|
## Cookie health
|
|
|
|
|
|
|
|
|
|
`ocd health` probes each account's auth cookie against that account's default
|
|
|
|
|
workspace Zen page. A live cookie gets HTTP 200. A dead or logged-out cookie gets
|
|
|
|
|
HTTP 302 to `/auth/authorize` (or 401/403). The probe sends one request per
|
|
|
|
|
account, paced by `--pace-ms`.
|
|
|
|
|
|
|
|
|
|
The summary counts `alive`, `dead`, `errored`, and `misconfigured` (empty cookie
|
|
|
|
|
or workspace still a placeholder). Exit is 0 only when every account is alive.
|
|
|
|
|
|
|
|
|
|
The main walk (`pretty`, `raw`, `fetch`) also detects a dead cookie and labels the
|
|
|
|
|
report `CookieDead`. It does not abort the run; it continues with the valid
|
|
|
|
|
accounts and only reports the dead ones in the output.
|
|
|
|
|
|
|
|
|
|
|
2026-07-29 22:07:14 +00:00
|
|
|
## Open items
|
|
|
|
|
|
|
|
|
|
- Crate name: `ocd`.
|
|
|
|
|
- Live watch mode for `htop`-like refresh. A later task.
|
2026-07-29 22:27:30 +00:00
|
|
|
- Conditional GET: `get_text` sends `If-None-Match` and `If-Modified-Since`
|
|
|
|
|
from cached validators and handles a 304 by reusing the cached parse.
|
|
|
|
|
OpenCode (via Cloudflare) currently emits no `ETag` or `Last-Modified` on
|
|
|
|
|
either page, so the slots stay empty and no headers go out. The machinery
|
2026-07-29 22:53:06 +00:00
|
|
|
is idle but correct; it works the day OpenCode adds validators.
|
|
|
|
|
- Extra billing fields: the Zen page embedded record also streams
|
|
|
|
|
`reloadAmount`, `reloadTrigger`, `subscriptionPlan`, `monthlyLimit`, and
|
|
|
|
|
`monthlyUsage`. These do not appear in the rendered DOM. A future `ocd
|
|
|
|
|
billing` command could expose auto-reload config. Store them by extending
|
|
|
|
|
the model; keep the scrape module the only place that touches this.
|