diff --git a/.env.example b/.env.example
index 7177ac0..20e3ac8 100644
--- a/.env.example
+++ b/.env.example
@@ -29,6 +29,17 @@ ANTHROPIC_TOKEN=
# but setting CODEX_TOKEN in .env ensures Codex-only startup works reliably.
CODEX_TOKEN=
+# Optional: use ChatGPT credentials stored by OpenCode for the Codex provider
+# OPENCODE_ENABLED=true
+
+# --- OpenCode Go Configuration ---
+# Track OpenCode Go subscription quotas via authenticated dashboard scrape.
+# Both values are required. Full walkthrough: docs/OPENCODE_SETUP.md
+# Workspace ID from https://opencode.ai/workspace/wrk_.../go
+OPENCODE_GO_WORKSPACE_ID=
+# Browser cookie value named "auth" from opencode.ai (value only, no auth= prefix)
+OPENCODE_GO_AUTH_COOKIE=
+
# --- GitHub Copilot Configuration (Beta) ---
# GitHub Personal Access Token with `copilot` scope
# Required for tracking Copilot premium request usage
diff --git a/docs/OPENCODE_SETUP.md b/docs/OPENCODE_SETUP.md
new file mode 100644
index 0000000..e6eb775
--- /dev/null
+++ b/docs/OPENCODE_SETUP.md
@@ -0,0 +1,176 @@
+# OpenCode Go Setup Guide
+
+Track OpenCode Go subscription quotas in onWatch.
+
+OpenCode Go does not expose a public quota API. onWatch scrapes the authenticated OpenCode Go dashboard the same way other tooling does: with your workspace ID and browser `auth` cookie.
+
+---
+
+## Prerequisites
+
+- An active [OpenCode Go](https://opencode.ai) subscription
+- Access to the OpenCode Go dashboard in a browser
+- onWatch installed ([Quick Start](../README.md#quick-start))
+
+---
+
+## How It Works
+
+onWatch polls:
+
+```text
+https://opencode.ai/workspace/{workspaceId}/go
+```
+
+using your session cookie, then extracts utilization and reset countdowns for:
+
+- **5-Hour** (rolling / session window)
+- **Weekly**
+- **Monthly** (when present on the dashboard)
+
+Parsing tries SolidJS SSR hydration data first, then falls back to the newer `data-slot="usage-item"` HTML layout. Snapshots are stored locally in SQLite like every other provider.
+
+This is separate from `OPENCODE_ENABLED`, which only feeds ChatGPT credentials from OpenCode into the **Codex** provider.
+
+---
+
+## 1. Find Your Workspace ID
+
+1. Open https://opencode.ai and sign in
+2. Open your OpenCode Go usage page. The URL looks like:
+
+```text
+https://opencode.ai/workspace/wrk_xxxxxxxx/go
+```
+
+3. Copy the `wrk_...` segment. That is your `OPENCODE_GO_WORKSPACE_ID`.
+
+---
+
+## 2. Copy the Auth Cookie
+
+1. While signed in on opencode.ai, open your browser Developer Tools
+2. Go to **Application** / **Storage** → **Cookies** → `https://opencode.ai`
+3. Find the `auth` cookie
+4. Copy its **value** (not the `auth=` name prefix)
+
+Treat this cookie like a password. Logging out of OpenCode, rotating sessions, or clearing cookies will invalidate it.
+
+---
+
+## 3. Configure onWatch
+
+Add both values to `~/.onwatch/.env` (or your project `.env`):
+
+```bash
+OPENCODE_GO_WORKSPACE_ID=wrk_xxxxxxxx
+OPENCODE_GO_AUTH_COOKIE=your_auth_cookie_value
+```
+
+Both are required. If either is missing, the OpenCode Go provider stays disabled.
+
+You can also set them in the dashboard:
+
+1. Open **Settings → Providers → OpenCode Go**
+2. Paste **Workspace ID** and **Auth Cookie**
+3. Save
+
+Dashboard values override `.env` for the running process. A daemon restart may still be needed depending on how the agent was started.
+
+---
+
+## 4. Reload / Restart
+
+Reload providers from Settings if available, or restart onWatch:
+
+```bash
+onwatch stop
+onwatch
+```
+
+Or verify in the foreground:
+
+```bash
+onwatch --debug
+```
+
+You should see the OpenCode agent start when both credentials are present.
+
+---
+
+## 5. Verify
+
+- Open http://localhost:9211
+- Switch to the **OpenCode** tab
+- Confirm 5-Hour / Weekly cards populate (Monthly appears when OpenCode returns it)
+- Charts, cycle overview, and insights begin filling after a few polls
+
+---
+
+## Dashboard
+
+The OpenCode Go tab shows:
+
+- Quota cards with utilization, remaining countdown, and status
+- Historical chart across tracked windows
+- Billing-cycle / usage-sample tables
+- Burn-rate insights for the active windows
+
+---
+
+## Security Notes
+
+- Never commit `.env` or paste the cookie into issue reports / logs
+- onWatch redacts `auth_cookie` from `/api/settings` responses
+- Scraped HTML is not written to logs
+- All processing stays local on your machine
+
+---
+
+## Limitations & Notes
+
+- This integration depends on undocumented dashboard HTML. OpenCode UI changes can break parsing until onWatch is updated.
+- Auth failures and parse failures are surfaced as errors. onWatch does **not** invent fake currency quotas when scraping fails.
+- Cookie lifetime is controlled by OpenCode. Expect to refresh the cookie after logout or session rotation.
+- Workspace ID is required; onWatch does not auto-discover workspaces.
+
+---
+
+## Troubleshooting
+
+### No OpenCode tab
+
+- Confirm both `OPENCODE_GO_WORKSPACE_ID` and `OPENCODE_GO_AUTH_COOKIE` are set
+- Restart onWatch and check `--debug` logs for missing-config messages
+- In Settings → Providers, confirm OpenCode Go shows as configured / polling
+
+### Unauthorized / forbidden / empty data
+
+1. Re-copy a fresh `auth` cookie while signed in
+2. Confirm the workspace ID matches the `/go` URL
+3. Restart onWatch
+4. Open the Go dashboard in your browser and verify the page still loads
+
+### Parse failed / response format changed
+
+OpenCode likely changed the dashboard markup. File an issue with:
+
+- Approximate time of failure
+- Whether the browser dashboard still shows 5h / weekly / monthly
+- **Do not** attach cookies or full HTML dumps with session data
+
+### Docker / headless
+
+Pass both env vars into the container. There is no local cookie auto-detection path for OpenCode Go.
+
+```bash
+OPENCODE_GO_WORKSPACE_ID=wrk_xxxxxxxx
+OPENCODE_GO_AUTH_COOKIE=your_auth_cookie_value
+```
+
+---
+
+## Related
+
+- Main README environment variable reference
+- Codex + OpenCode ChatGPT auth (`OPENCODE_ENABLED`) is documented in [CODEX_SETUP.md](CODEX_SETUP.md)
diff --git a/internal/agent/opencode_agent.go b/internal/agent/opencode_agent.go
new file mode 100644
index 0000000..ea0175b
--- /dev/null
+++ b/internal/agent/opencode_agent.go
@@ -0,0 +1,133 @@
+package agent
+
+import (
+ "context"
+ "log/slog"
+ "time"
+
+ "github.com/onllm-dev/onwatch/v2/internal/api"
+ "github.com/onllm-dev/onwatch/v2/internal/config"
+ "github.com/onllm-dev/onwatch/v2/internal/notify"
+ "github.com/onllm-dev/onwatch/v2/internal/store"
+ "github.com/onllm-dev/onwatch/v2/internal/tracker"
+)
+
+type openCodeFetcher interface {
+ FetchSnapshot(ctx context.Context, workspaceID, authCookie string) (*api.OpenCodeSnapshot, error)
+}
+
+type OpenCodeAgent struct {
+ client openCodeFetcher
+ store *store.Store
+ tracker *tracker.OpenCodeTracker
+ interval time.Duration
+ logger *slog.Logger
+ sm *SessionManager
+ notifier *notify.NotificationEngine
+ pollingCheck func() bool
+ cfg *config.Config
+}
+
+func (a *OpenCodeAgent) SetPollingCheck(fn func() bool) {
+ a.pollingCheck = fn
+}
+
+func (a *OpenCodeAgent) SetNotifier(n *notify.NotificationEngine) {
+ a.notifier = n
+}
+
+func NewOpenCodeAgent(client openCodeFetcher, store *store.Store, tr *tracker.OpenCodeTracker, cfg *config.Config, interval time.Duration, logger *slog.Logger, sm *SessionManager) *OpenCodeAgent {
+ if logger == nil {
+ logger = slog.Default()
+ }
+ return &OpenCodeAgent{
+ client: client,
+ store: store,
+ tracker: tr,
+ cfg: cfg,
+ interval: interval,
+ logger: logger,
+ sm: sm,
+ }
+}
+
+func (a *OpenCodeAgent) Run(ctx context.Context) error {
+ a.logger.Info("OpenCode agent started", "interval", a.interval)
+
+ defer func() {
+ if a.sm != nil {
+ a.sm.Close()
+ }
+ a.logger.Info("OpenCode agent stopped")
+ }()
+
+ a.poll(ctx)
+
+ ticker := time.NewTicker(a.interval)
+ defer ticker.Stop()
+
+ for {
+ select {
+ case <-ticker.C:
+ a.poll(ctx)
+ case <-ctx.Done():
+ return nil
+ }
+ }
+}
+
+func (a *OpenCodeAgent) poll(ctx context.Context) {
+ if a.client == nil || a.cfg == nil {
+ return
+ }
+ if a.pollingCheck != nil && !a.pollingCheck() {
+ return
+ }
+
+ workspaceID := a.cfg.OpenCodeGoWorkspaceID
+ authCookie := a.cfg.OpenCodeGoAuthCookie
+
+ snapshot, err := a.client.FetchSnapshot(ctx, workspaceID, authCookie)
+ if err != nil {
+ if ctx.Err() != nil {
+ return
+ }
+ a.logger.Error("Failed to fetch OpenCode quotas", "error", err)
+ return
+ }
+
+ if _, err := a.store.InsertOpenCodeSnapshot(snapshot); err != nil {
+ a.logger.Error("Failed to insert OpenCode snapshot", "error", err)
+ return
+ }
+
+ if a.tracker != nil {
+ if err := a.tracker.Process(snapshot); err != nil {
+ a.logger.Error("OpenCode tracker processing failed", "error", err)
+ }
+ }
+
+ if a.notifier != nil {
+ for _, q := range snapshot.Quotas {
+ a.notifier.Check(notify.QuotaStatus{
+ Provider: "opencode",
+ QuotaKey: q.Name,
+ Utilization: q.Utilization,
+ Limit: q.Limit,
+ })
+ }
+ }
+
+ if a.sm != nil {
+ var values []float64
+ for _, q := range snapshot.Quotas {
+ values = append(values, q.Utilization)
+ }
+ a.sm.ReportPoll(values)
+ }
+
+ a.logger.Info("OpenCode poll complete",
+ "plan_name", snapshot.PlanName,
+ "quota_count", len(snapshot.Quotas),
+ )
+}
diff --git a/internal/agent/opencode_agent_test.go b/internal/agent/opencode_agent_test.go
new file mode 100644
index 0000000..eff9f9f
--- /dev/null
+++ b/internal/agent/opencode_agent_test.go
@@ -0,0 +1,185 @@
+package agent
+
+import (
+ "context"
+ "errors"
+ "log/slog"
+ "testing"
+ "time"
+
+ "github.com/onllm-dev/onwatch/v2/internal/api"
+ "github.com/onllm-dev/onwatch/v2/internal/config"
+ "github.com/onllm-dev/onwatch/v2/internal/store"
+ "github.com/onllm-dev/onwatch/v2/internal/tracker"
+)
+
+func TestNewOpenCodeAgent_Basic(t *testing.T) {
+ a := NewOpenCodeAgent(nil, nil, nil, nil, 60*time.Second, nil, nil)
+ if a == nil {
+ t.Fatal("nil agent")
+ }
+ a.SetPollingCheck(func() bool { return true })
+ a.SetNotifier(nil)
+}
+
+func TestOpenCodeAgent_Poll_NoClientSafe(t *testing.T) {
+ st, err := store.New(":memory:")
+ if err != nil {
+ t.Fatalf("store.New: %v", err)
+ }
+ defer st.Close()
+
+ tr := tracker.NewOpenCodeTracker(st, nil)
+ ag := NewOpenCodeAgent(nil, st, tr, nil, time.Second, nil, NewSessionManager(st, "opencode", 60*time.Second, nil))
+ ag.poll(context.Background())
+}
+
+func TestOpenCodeAgent_Poll_FetchErrorNoInsert(t *testing.T) {
+ st, err := store.New(":memory:")
+ if err != nil {
+ t.Fatalf("store.New: %v", err)
+ }
+ defer st.Close()
+
+ cfg := &config.Config{
+ OpenCodeGoWorkspaceID: "ws",
+ OpenCodeGoAuthCookie: "cookie",
+ }
+ client := &stubOpenCodeClient{err: errors.New("fetch failed")}
+ tr := tracker.NewOpenCodeTracker(st, nil)
+ ag := NewOpenCodeAgent(client, st, tr, cfg, time.Second, slog.Default(), NewSessionManager(st, "opencode", 60*time.Second, nil))
+
+ ag.poll(context.Background())
+
+ latest, err := st.QueryLatestOpenCode()
+ if err != nil {
+ t.Fatalf("QueryLatestOpenCode: %v", err)
+ }
+ if latest != nil {
+ t.Fatal("expected no snapshot after fetch failure")
+ }
+}
+
+func TestOpenCodeAgent_Poll_SuccessInsertsAndTracks(t *testing.T) {
+ st, err := store.New(":memory:")
+ if err != nil {
+ t.Fatalf("store.New: %v", err)
+ }
+ defer st.Close()
+
+ now := time.Now().UTC()
+ reset := now.Add(2 * time.Hour)
+ snapshot := &api.OpenCodeSnapshot{
+ CapturedAt: now,
+ AccountType: api.OpenCodeAccountTypePro,
+ PlanName: "OpenCode Go",
+ Quotas: []api.OpenCodeQuota{
+ {Name: "five_hour", Utilization: 10, Format: api.OpenCodeQuotaFormatPercent, ResetsAt: &reset},
+ },
+ }
+
+ client := &stubOpenCodeClient{snapshot: snapshot}
+ tr := tracker.NewOpenCodeTracker(st, slog.Default())
+ ag := NewOpenCodeAgent(client, st, tr, &config.Config{
+ OpenCodeGoWorkspaceID: "ws",
+ OpenCodeGoAuthCookie: "cookie",
+ }, time.Second, slog.Default(), NewSessionManager(st, "opencode", 60*time.Second, nil))
+
+ ag.poll(context.Background())
+
+ latest, err := st.QueryLatestOpenCode()
+ if err != nil {
+ t.Fatalf("QueryLatestOpenCode: %v", err)
+ }
+ if latest == nil || len(latest.Quotas) != 1 {
+ t.Fatalf("expected inserted snapshot, got %+v", latest)
+ }
+
+ cycle, err := st.QueryActiveOpenCodeCycle("five_hour")
+ if err != nil {
+ t.Fatalf("QueryActiveOpenCodeCycle: %v", err)
+ }
+ if cycle == nil {
+ t.Fatal("expected active cycle after tracker.Process")
+ }
+}
+
+type stubOpenCodeClient struct {
+ snapshot *api.OpenCodeSnapshot
+ err error
+}
+
+func (s *stubOpenCodeClient) FetchSnapshot(_ context.Context, _, _ string) (*api.OpenCodeSnapshot, error) {
+ if s.err != nil {
+ return nil, s.err
+ }
+ return s.snapshot, nil
+}
+
+func TestOpenCodeAgent_Poll_MissingConfig(t *testing.T) {
+ st, err := store.New(":memory:")
+ if err != nil {
+ t.Fatalf("store.New: %v", err)
+ }
+ defer st.Close()
+
+ client := api.NewOpenCodeClient(nil)
+ tr := tracker.NewOpenCodeTracker(st, nil)
+ ag := NewOpenCodeAgent(client, st, tr, &config.Config{}, time.Second, slog.Default(), nil)
+
+ ag.poll(context.Background())
+
+ latest, err := st.QueryLatestOpenCode()
+ if err != nil {
+ t.Fatalf("QueryLatestOpenCode: %v", err)
+ }
+ if latest != nil {
+ t.Fatal("expected no snapshot when config missing")
+ }
+}
+
+func TestOpenCodeAgent_Poll_AuthError(t *testing.T) {
+ st, err := store.New(":memory:")
+ if err != nil {
+ t.Fatalf("store.New: %v", err)
+ }
+ defer st.Close()
+
+ client := &stubOpenCodeClient{err: api.ErrOpenCodeUnauthorized}
+ tr := tracker.NewOpenCodeTracker(st, nil)
+ ag := NewOpenCodeAgent(client, st, tr, &config.Config{
+ OpenCodeGoWorkspaceID: "ws",
+ OpenCodeGoAuthCookie: "cookie",
+ }, time.Second, slog.Default(), nil)
+
+ ag.poll(context.Background())
+
+ latest, err := st.QueryLatestOpenCode()
+ if err != nil {
+ t.Fatalf("QueryLatestOpenCode: %v", err)
+ }
+ if latest != nil {
+ t.Fatal("expected no snapshot on auth error")
+ }
+}
+
+var _ interface {
+ FetchSnapshot(context.Context, string, string) (*api.OpenCodeSnapshot, error)
+} = (*stubOpenCodeClient)(nil)
+
+func TestOpenCodeAgent_Poll_FetchErrorTyped(t *testing.T) {
+ st, err := store.New(":memory:")
+ if err != nil {
+ t.Fatalf("store.New: %v", err)
+ }
+ defer st.Close()
+
+ client := &stubOpenCodeClient{err: errors.Join(api.ErrOpenCodeParseFailed, errors.New("details"))}
+ tr := tracker.NewOpenCodeTracker(st, nil)
+ ag := NewOpenCodeAgent(client, st, tr, &config.Config{
+ OpenCodeGoWorkspaceID: "ws",
+ OpenCodeGoAuthCookie: "cookie",
+ }, time.Second, slog.Default(), nil)
+
+ ag.poll(context.Background())
+}
diff --git a/internal/api/codex_credentials_test.go b/internal/api/codex_credentials_test.go
index e3466aa..e430df5 100644
--- a/internal/api/codex_credentials_test.go
+++ b/internal/api/codex_credentials_test.go
@@ -18,13 +18,13 @@ func discardLoggerCredentials() *slog.Logger {
// isolateOpenCodeEnv prevents codex-credential detection from reading the real
// developer/CI OpenCode auth file. DetectCodexCredentials falls back to
-// OPENCODE_HOME, then XDG_DATA_HOME/opencode, then HOME/.local/share/opencode;
-// clearing the first two pins resolution under the (already temp) HOME so tests
-// are hermetic regardless of the host's real ChatGPT/OpenCode login state.
+// OPENCODE_HOME, then XDG_DATA_HOME/opencode, then os.UserHomeDir()/.local/share/opencode.
+// Clearing the first two is NOT enough on macOS: UserHomeDir ignores $HOME and
+// still resolves the host auth.json. Pin both to empty temp dirs instead.
func isolateOpenCodeEnv(t *testing.T) {
t.Helper()
- t.Setenv("OPENCODE_HOME", "")
- t.Setenv("XDG_DATA_HOME", "")
+ t.Setenv("OPENCODE_HOME", t.TempDir())
+ t.Setenv("XDG_DATA_HOME", t.TempDir())
}
func TestDetectCodexCredentials_ParsesOAuthTokens(t *testing.T) {
diff --git a/internal/api/extra_coverage_test.go b/internal/api/extra_coverage_test.go
index 6ccd207..b2f9fbd 100644
--- a/internal/api/extra_coverage_test.go
+++ b/internal/api/extra_coverage_test.go
@@ -2787,8 +2787,10 @@ func TestWriteAnthropicCredentials_CreatesBackup(t *testing.T) {
// ---------------------------------------------------------------------------
func TestDetectCodexCredentials_EmptyAuthFile(t *testing.T) {
+ isolateOpenCodeEnv(t)
dir := t.TempDir()
t.Setenv("CODEX_HOME", dir)
+ t.Setenv("HOME", t.TempDir())
// Write an auth file with all empty fields
authData := `{"OPENAI_API_KEY":"","tokens":{"access_token":"","refresh_token":"","id_token":"","account_id":""}}`
diff --git a/internal/api/opencode.go b/internal/api/opencode.go
new file mode 100644
index 0000000..fa32974
--- /dev/null
+++ b/internal/api/opencode.go
@@ -0,0 +1,34 @@
+package api
+
+import "time"
+
+type OpenCodeQuotaFormat string
+
+const (
+ OpenCodeQuotaFormatCurrency OpenCodeQuotaFormat = "currency"
+ OpenCodeQuotaFormatPercent OpenCodeQuotaFormat = "percent"
+)
+
+type OpenCodeQuota struct {
+ Name string
+ Used float64
+ Limit float64
+ Utilization float64
+ Format OpenCodeQuotaFormat
+ ResetsAt *time.Time
+}
+
+type OpenCodeAccountType string
+
+const (
+ OpenCodeAccountTypePro OpenCodeAccountType = "pro"
+)
+
+type OpenCodeSnapshot struct {
+ ID int64
+ CapturedAt time.Time
+ RawJSON string
+ AccountType OpenCodeAccountType
+ PlanName string
+ Quotas []OpenCodeQuota
+}
diff --git a/internal/api/opencode_client.go b/internal/api/opencode_client.go
new file mode 100644
index 0000000..f8ff192
--- /dev/null
+++ b/internal/api/opencode_client.go
@@ -0,0 +1,373 @@
+package api
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "log/slog"
+ "net/http"
+ "net/url"
+ "regexp"
+ "strconv"
+ "strings"
+ "time"
+)
+
+const (
+ openCodeDashboardURLPrefix = "https://opencode.ai/workspace/"
+ openCodeDashboardURLSuffix = "/go"
+ openCodeUserAgent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Gecko/20100101 Firefox/148.0"
+ openCodeScrapeTimeout = 10 * time.Second
+ openCodeMaxBodyBytes = 2 << 20 // 2 MiB
+)
+
+var (
+ ErrOpenCodeUnauthorized = errors.New("opencode: unauthorized")
+ ErrOpenCodeForbidden = errors.New("opencode: forbidden")
+ ErrOpenCodeServerError = errors.New("opencode: server error")
+ ErrOpenCodeNetworkError = errors.New("opencode: network error")
+ ErrOpenCodeInvalidResponse = errors.New("opencode: invalid response")
+ ErrOpenCodeParseFailed = errors.New("opencode: parse failed")
+ ErrOpenCodeMissingConfig = errors.New("opencode: missing workspace id or auth cookie")
+)
+
+type OpenCodeClient struct {
+ httpClient *http.Client
+ logger *slog.Logger
+ dashboardURLPrefix string
+}
+
+type OpenCodeClientOption func(*OpenCodeClient)
+
+func WithOpenCodeHTTPTransport(rt http.RoundTripper) OpenCodeClientOption {
+ return func(c *OpenCodeClient) {
+ c.httpClient.Transport = rt
+ }
+}
+
+func WithOpenCodeTimeout(timeout time.Duration) OpenCodeClientOption {
+ return func(c *OpenCodeClient) {
+ c.httpClient.Timeout = timeout
+ }
+}
+
+func WithOpenCodeBaseURL(baseURL string) OpenCodeClientOption {
+ return func(c *OpenCodeClient) {
+ baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/")
+ c.dashboardURLPrefix = baseURL + "/workspace/"
+ }
+}
+
+func NewOpenCodeClient(logger *slog.Logger, opts ...OpenCodeClientOption) *OpenCodeClient {
+ if logger == nil {
+ logger = slog.Default()
+ }
+ c := &OpenCodeClient{
+ httpClient: &http.Client{
+ Timeout: openCodeScrapeTimeout,
+ CheckRedirect: func(_ *http.Request, _ []*http.Request) error {
+ return http.ErrUseLastResponse
+ },
+ Transport: &http.Transport{
+ MaxIdleConns: 1,
+ MaxIdleConnsPerHost: 1,
+ ResponseHeaderTimeout: openCodeScrapeTimeout,
+ IdleConnTimeout: 30 * time.Second,
+ TLSHandshakeTimeout: 10 * time.Second,
+ ForceAttemptHTTP2: true,
+ },
+ },
+ logger: logger,
+ dashboardURLPrefix: openCodeDashboardURLPrefix,
+ }
+ for _, o := range opts {
+ o(c)
+ }
+ return c
+}
+
+type scrapedWindowUsage struct {
+ usagePercent float64
+ resetInSec float64
+}
+
+func (c *OpenCodeClient) FetchSnapshot(ctx context.Context, workspaceID, authCookie string) (*OpenCodeSnapshot, error) {
+ workspaceID = strings.TrimSpace(workspaceID)
+ authCookie = strings.TrimSpace(authCookie)
+ if workspaceID == "" || authCookie == "" {
+ return nil, ErrOpenCodeMissingConfig
+ }
+
+ capturedAt := time.Now().UTC()
+ html, err := c.fetchDashboardHTML(ctx, workspaceID, authCookie)
+ if err != nil {
+ return nil, err
+ }
+
+ quotas, err := parseOpenCodeQuotas(html, capturedAt)
+ if err != nil {
+ return nil, err
+ }
+
+ return &OpenCodeSnapshot{
+ CapturedAt: capturedAt,
+ AccountType: OpenCodeAccountTypePro,
+ PlanName: "OpenCode Go",
+ Quotas: quotas,
+ }, nil
+}
+
+func (c *OpenCodeClient) fetchDashboardHTML(ctx context.Context, workspaceID, authCookie string) (string, error) {
+ dashboardURL := c.dashboardURLPrefix + url.PathEscape(workspaceID) + openCodeDashboardURLSuffix
+
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, dashboardURL, nil)
+ if err != nil {
+ return "", fmt.Errorf("%w: build request: %v", ErrOpenCodeNetworkError, err)
+ }
+ req.Header.Set("User-Agent", openCodeUserAgent)
+ req.Header.Set("Accept", "text/html")
+ req.Header.Set("Cookie", openCodeAuthCookieHeader(authCookie))
+
+ resp, err := c.httpClient.Do(req)
+ if err != nil {
+ if ctx.Err() != nil {
+ return "", ctx.Err()
+ }
+ return "", fmt.Errorf("%w: %v", ErrOpenCodeNetworkError, err)
+ }
+ defer resp.Body.Close()
+
+ body, err := io.ReadAll(io.LimitReader(resp.Body, openCodeMaxBodyBytes))
+ if err != nil {
+ return "", fmt.Errorf("%w: read body: %v", ErrOpenCodeNetworkError, err)
+ }
+ if resp.StatusCode >= http.StatusMultipleChoices && resp.StatusCode < http.StatusBadRequest {
+ return "", ErrOpenCodeUnauthorized
+ }
+
+ switch resp.StatusCode {
+ case http.StatusOK:
+ return string(body), nil
+ case http.StatusUnauthorized:
+ return "", ErrOpenCodeUnauthorized
+ case http.StatusForbidden:
+ return "", ErrOpenCodeForbidden
+ default:
+ if resp.StatusCode >= 500 {
+ return "", fmt.Errorf("%w: http %d", ErrOpenCodeServerError, resp.StatusCode)
+ }
+ return "", fmt.Errorf("%w: http %d: %s", ErrOpenCodeInvalidResponse, resp.StatusCode, sanitizeOpenCodeMessage(string(body)))
+ }
+}
+
+func openCodeAuthCookieHeader(authCookie string) string {
+ if strings.HasPrefix(authCookie, "auth=") {
+ return authCookie
+ }
+ return "auth=" + authCookie
+}
+
+func sanitizeOpenCodeMessage(text string) string {
+ s := strings.TrimSpace(text)
+ if s == "" {
+ return "unknown"
+ }
+ s = strings.Join(strings.Fields(s), " ")
+ if len(s) > 120 {
+ s = s[:120]
+ }
+ return s
+}
+
+func parseOpenCodeQuotas(html string, capturedAt time.Time) ([]OpenCodeQuota, error) {
+ rolling := parseSSRWindowUsage(html, "rollingUsage")
+ weekly := parseSSRWindowUsage(html, "weeklyUsage")
+ monthly := parseSSRWindowUsage(html, "monthlyUsage")
+
+ if rolling == nil && weekly == nil && monthly == nil {
+ dataSlot := parseDataSlotFormat(html)
+ rolling = dataSlot["rolling"]
+ weekly = dataSlot["weekly"]
+ monthly = dataSlot["monthly"]
+ }
+
+ var quotas []OpenCodeQuota
+ if rolling != nil {
+ quotas = append(quotas, windowToQuota("five_hour", *rolling, capturedAt))
+ }
+ if weekly != nil {
+ quotas = append(quotas, windowToQuota("weekly", *weekly, capturedAt))
+ }
+ if monthly != nil {
+ quotas = append(quotas, windowToQuota("monthly", *monthly, capturedAt))
+ }
+
+ if len(quotas) == 0 {
+ return nil, fmt.Errorf("%w: could not parse rollingUsage, weeklyUsage, or monthlyUsage", ErrOpenCodeParseFailed)
+ }
+ return quotas, nil
+}
+
+func windowToQuota(name string, window scrapedWindowUsage, capturedAt time.Time) OpenCodeQuota {
+ pct := window.usagePercent
+ if pct < 0 {
+ pct = 0
+ }
+ resetSec := window.resetInSec
+ if resetSec < 0 {
+ resetSec = 0
+ }
+ resetsAt := capturedAt.Add(time.Duration(resetSec) * time.Second)
+ return OpenCodeQuota{
+ Name: name,
+ Used: pct,
+ Limit: 100,
+ Utilization: pct,
+ Format: OpenCodeQuotaFormatPercent,
+ ResetsAt: &resetsAt,
+ }
+}
+
+var openCodeScrapedNumberPattern = `(-?\d+(?:\.\d+)?)`
+
+func parseSSRWindowUsage(html, prefix string) *scrapedWindowUsage {
+ pattern1 := prefix + `:\$R\[\d+\]=\{[^}]*usagePercent:` + openCodeScrapedNumberPattern + `[^}]*resetInSec:` + openCodeScrapedNumberPattern
+ pattern2 := prefix + `:\$R\[\d+\]=\{[^}]*resetInSec:` + openCodeScrapedNumberPattern + `[^}]*usagePercent:` + openCodeScrapedNumberPattern
+
+ re1 := regexp.MustCompile(pattern1)
+ re2 := regexp.MustCompile(pattern2)
+
+ if m := re1.FindStringSubmatch(html); len(m) >= 3 {
+ if pct, reset, ok := parseScrapedNumbers(m[1], m[2]); ok {
+ return &scrapedWindowUsage{usagePercent: pct, resetInSec: reset}
+ }
+ }
+ if m := re2.FindStringSubmatch(html); len(m) >= 3 {
+ if reset, pct, ok := parseScrapedNumbers(m[1], m[2]); ok {
+ return &scrapedWindowUsage{usagePercent: pct, resetInSec: reset}
+ }
+ }
+ return nil
+}
+
+func parseScrapedNumbers(a, b string) (float64, float64, bool) {
+ first, err1 := strconv.ParseFloat(a, 64)
+ second, err2 := strconv.ParseFloat(b, 64)
+ if err1 != nil || err2 != nil {
+ return 0, 0, false
+ }
+ return first, second, true
+}
+
+func parseDataSlotFormat(html string) map[string]*scrapedWindowUsage {
+ result := make(map[string]*scrapedWindowUsage)
+ parts := strings.Split(html, `data-slot="usage-item"`)
+ for i := 1; i < len(parts); i++ {
+ content := parts[i]
+
+ labelRe := regexp.MustCompile(`data-slot="usage-label">([^<]+)<`)
+ labelMatch := labelRe.FindStringSubmatch(content)
+ if len(labelMatch) < 2 {
+ continue
+ }
+ label := strings.ToLower(strings.TrimSpace(labelMatch[1]))
+
+ usageRe := regexp.MustCompile(`data-slot="usage-value">[^0-9]*(\d+(?:\.\d+)?)`)
+ usageMatch := usageRe.FindStringSubmatch(content)
+ if len(usageMatch) < 2 {
+ continue
+ }
+ usagePercent, err := strconv.ParseFloat(usageMatch[1], 64)
+ if err != nil {
+ continue
+ }
+
+ resetRe := regexp.MustCompile(`data-slot="(reset-time|reset-now)">([\s\S]*?)`)
+ resetMatch := resetRe.FindStringSubmatch(content)
+ if len(resetMatch) < 3 {
+ continue
+ }
+
+ var resetInSec float64
+ if resetMatch[1] == "reset-now" {
+ resetInSec = 0
+ } else {
+ resetContent := resetMatch[2]
+ resetContent = regexp.MustCompile(``).ReplaceAllString(resetContent, "")
+ resetContent = strings.TrimSpace(resetContent)
+ resetContent = regexp.MustCompile(`(?i)Resets?\s*in\s*`).ReplaceAllString(resetContent, "")
+ parsed, ok := parseHumanReadableTime(resetContent)
+ if !ok {
+ continue
+ }
+ resetInSec = parsed
+ }
+
+ var windowKey string
+ switch {
+ case strings.Contains(label, "rolling"):
+ windowKey = "rolling"
+ case strings.Contains(label, "weekly"):
+ windowKey = "weekly"
+ case strings.Contains(label, "monthly"):
+ windowKey = "monthly"
+ default:
+ continue
+ }
+
+ result[windowKey] = &scrapedWindowUsage{
+ usagePercent: usagePercent,
+ resetInSec: resetInSec,
+ }
+ }
+ return result
+}
+
+func parseHumanReadableTime(timeStr string) (float64, bool) {
+ normalized := strings.ToLower(strings.TrimSpace(timeStr))
+ normalized = strings.Join(strings.Fields(normalized), " ")
+ switch normalized {
+ case "reset-now", "reset now", "now", "resets now":
+ return 0, true
+ }
+
+ var totalSeconds float64
+ hasDuration := false
+
+ dayRe := regexp.MustCompile(`(\d+(?:\.\d+)?)\s*days?`)
+ hourRe := regexp.MustCompile(`(\d+(?:\.\d+)?)\s*hours?`)
+ minuteRe := regexp.MustCompile(`(\d+(?:\.\d+)?)\s*minutes?`)
+ secondRe := regexp.MustCompile(`(\d+(?:\.\d+)?)\s*seconds?`)
+
+ if m := dayRe.FindStringSubmatch(normalized); len(m) >= 2 {
+ if v, err := strconv.ParseFloat(m[1], 64); err == nil {
+ totalSeconds += v * 86400
+ hasDuration = true
+ }
+ }
+ if m := hourRe.FindStringSubmatch(normalized); len(m) >= 2 {
+ if v, err := strconv.ParseFloat(m[1], 64); err == nil {
+ totalSeconds += v * 3600
+ hasDuration = true
+ }
+ }
+ if m := minuteRe.FindStringSubmatch(normalized); len(m) >= 2 {
+ if v, err := strconv.ParseFloat(m[1], 64); err == nil {
+ totalSeconds += v * 60
+ hasDuration = true
+ }
+ }
+ if m := secondRe.FindStringSubmatch(normalized); len(m) >= 2 {
+ if v, err := strconv.ParseFloat(m[1], 64); err == nil {
+ totalSeconds += v
+ hasDuration = true
+ }
+ }
+
+ return totalSeconds, hasDuration
+}
+
+func IsOpenCodeAuthError(err error) bool {
+ return errors.Is(err, ErrOpenCodeUnauthorized) || errors.Is(err, ErrOpenCodeForbidden)
+}
diff --git a/internal/api/opencode_client_test.go b/internal/api/opencode_client_test.go
new file mode 100644
index 0000000..8cdaa5c
--- /dev/null
+++ b/internal/api/opencode_client_test.go
@@ -0,0 +1,273 @@
+package api
+
+import (
+ "context"
+ "errors"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+ "time"
+)
+
+const ssrFixtureHTML = `
+rollingUsage:$R[123]={usagePercent:4.5,resetInSec:6000}
+weeklyUsage:$R[124]={resetInSec:1209600,usagePercent:12.3}
+monthlyUsage:$R[125]={usagePercent:25.0,resetInSec:2592000}
+`
+
+const dataSlotFixtureHTML = `
+
+ Rolling Usage
+ 15%
+ Resets in 1 hour 30 minutes
+
+
+ Weekly Usage
+ 22.5%
+ Reset now
+
+
+ Monthly Usage
+ 40%
+ Resets in 6 days 2 hours
+
+`
+
+func TestOpenCodeClient_FetchSnapshot_SSR(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/workspace/ws-123/go" {
+ t.Errorf("unexpected path: %s", r.URL.Path)
+ }
+ if cookie := r.Header.Get("Cookie"); cookie != "auth=secret-cookie" {
+ t.Errorf("unexpected cookie: %q", cookie)
+ }
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write([]byte(ssrFixtureHTML))
+ }))
+ defer srv.Close()
+
+ client := newTestOpenCodeClient(t, srv)
+ snap, err := client.FetchSnapshot(context.Background(), "ws-123", "secret-cookie")
+ if err != nil {
+ t.Fatalf("FetchSnapshot: %v", err)
+ }
+ if len(snap.Quotas) != 3 {
+ t.Fatalf("quotas = %d, want 3", len(snap.Quotas))
+ }
+
+ byName := mapQuotasByName(snap.Quotas)
+ if byName["five_hour"].Utilization != 4.5 {
+ t.Errorf("five_hour util = %v, want 4.5", byName["five_hour"].Utilization)
+ }
+ if byName["weekly"].Utilization != 12.3 {
+ t.Errorf("weekly util = %v, want 12.3", byName["weekly"].Utilization)
+ }
+ if byName["monthly"].Utilization != 25.0 {
+ t.Errorf("monthly util = %v, want 25.0", byName["monthly"].Utilization)
+ }
+ for _, q := range snap.Quotas {
+ if q.Format != OpenCodeQuotaFormatPercent {
+ t.Errorf("quota %s format = %q, want percent", q.Name, q.Format)
+ }
+ if q.ResetsAt == nil {
+ t.Errorf("quota %s missing resetsAt", q.Name)
+ }
+ }
+}
+
+func TestOpenCodeClient_FetchSnapshot_DataSlotFallback(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write([]byte(dataSlotFixtureHTML))
+ }))
+ defer srv.Close()
+
+ client := newTestOpenCodeClient(t, srv)
+ snap, err := client.FetchSnapshot(context.Background(), "ws-abc", "tok")
+ if err != nil {
+ t.Fatalf("FetchSnapshot: %v", err)
+ }
+ if len(snap.Quotas) != 3 {
+ t.Fatalf("quotas = %d, want 3", len(snap.Quotas))
+ }
+ byName := mapQuotasByName(snap.Quotas)
+ if byName["five_hour"].Utilization != 15 {
+ t.Errorf("five_hour util = %v, want 15", byName["five_hour"].Utilization)
+ }
+ if byName["weekly"].Utilization != 22.5 {
+ t.Errorf("weekly util = %v, want 22.5", byName["weekly"].Utilization)
+ }
+ if byName["monthly"].Utilization != 40 {
+ t.Errorf("monthly util = %v, want 40", byName["monthly"].Utilization)
+ }
+
+ rollingReset := byName["five_hour"].ResetsAt.Sub(snap.CapturedAt)
+ if rollingReset < 89*time.Minute || rollingReset > 91*time.Minute {
+ t.Errorf("five_hour reset offset = %v, want ~90m", rollingReset)
+ }
+}
+
+func TestOpenCodeClient_FetchSnapshot_401(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusUnauthorized)
+ _, _ = w.Write([]byte("login required secret"))
+ }))
+ defer srv.Close()
+
+ client := newTestOpenCodeClient(t, srv)
+ _, err := client.FetchSnapshot(context.Background(), "ws", "cookie")
+ if !errors.Is(err, ErrOpenCodeUnauthorized) {
+ t.Fatalf("err = %v, want ErrOpenCodeUnauthorized", err)
+ }
+ if strings.Contains(err.Error(), "secret") {
+ t.Fatalf("error leaked response body: %v", err)
+ }
+}
+
+func TestOpenCodeClient_FetchSnapshot_RedirectIsUnauthorizedAndNotFollowed(t *testing.T) {
+ var redirectTargetHits int
+ target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ redirectTargetHits++
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write([]byte(ssrFixtureHTML))
+ }))
+ defer target.Close()
+
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ http.Redirect(w, r, target.URL+"/login", http.StatusFound)
+ }))
+ defer srv.Close()
+
+ client := newTestOpenCodeClient(t, srv)
+ _, err := client.FetchSnapshot(context.Background(), "ws", "secret-cookie")
+ if !errors.Is(err, ErrOpenCodeUnauthorized) {
+ t.Fatalf("err = %v, want ErrOpenCodeUnauthorized", err)
+ }
+ if redirectTargetHits != 0 {
+ t.Fatalf("redirect target received %d request(s), want 0", redirectTargetHits)
+ }
+}
+
+func TestOpenCodeClient_FetchSnapshot_Malformed(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write([]byte("no usage data"))
+ }))
+ defer srv.Close()
+
+ client := newTestOpenCodeClient(t, srv)
+ _, err := client.FetchSnapshot(context.Background(), "ws", "cookie")
+ if !errors.Is(err, ErrOpenCodeParseFailed) {
+ t.Fatalf("err = %v, want ErrOpenCodeParseFailed", err)
+ }
+}
+
+func TestOpenCodeClient_FetchSnapshot_MissingConfig(t *testing.T) {
+ client := NewOpenCodeClient(nil)
+ _, err := client.FetchSnapshot(context.Background(), "", "cookie")
+ if !errors.Is(err, ErrOpenCodeMissingConfig) {
+ t.Fatalf("empty workspace err = %v", err)
+ }
+ _, err = client.FetchSnapshot(context.Background(), "ws", "")
+ if !errors.Is(err, ErrOpenCodeMissingConfig) {
+ t.Fatalf("empty cookie err = %v", err)
+ }
+}
+
+func TestOpenCodeClient_FetchSnapshot_CookieHeader(t *testing.T) {
+ var gotCookies []string
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ gotCookies = append(gotCookies, r.Header.Get("Cookie"))
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write([]byte(ssrFixtureHTML))
+ }))
+ defer srv.Close()
+
+ client := newTestOpenCodeClient(t, srv)
+ for _, tt := range []struct {
+ name string
+ value string
+ want string
+ }{
+ {name: "prefixed", value: "auth=already-set", want: "auth=already-set"},
+ {name: "raw padded", value: "token==", want: "auth=token=="},
+ } {
+ t.Run(tt.name, func(t *testing.T) {
+ _, err := client.FetchSnapshot(context.Background(), "ws", tt.value)
+ if err != nil {
+ t.Fatalf("FetchSnapshot: %v", err)
+ }
+ if got := gotCookies[len(gotCookies)-1]; got != tt.want {
+ t.Errorf("cookie = %q, want %q", got, tt.want)
+ }
+ })
+ }
+}
+
+func TestOpenCodeClient_FetchSnapshot_WorkspaceURLEncoded(t *testing.T) {
+ var gotRequestURI string
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ gotRequestURI = r.RequestURI
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write([]byte(ssrFixtureHTML))
+ }))
+ defer srv.Close()
+
+ client := newTestOpenCodeClient(t, srv)
+ _, err := client.FetchSnapshot(context.Background(), "ws/special id", "cookie")
+ if err != nil {
+ t.Fatalf("FetchSnapshot: %v", err)
+ }
+ if !strings.Contains(gotRequestURI, "ws%2Fspecial%20id") {
+ t.Errorf("request URI = %q, want encoded workspace id", gotRequestURI)
+ }
+}
+
+func TestParseHumanReadableTime(t *testing.T) {
+ tests := []struct {
+ in string
+ want float64
+ ok bool
+ }{
+ {"1 hour 56 minutes", 6960, true},
+ {"6 days 2 hours", 525600, true},
+ {"reset now", 0, true},
+ {"not a duration", 0, false},
+ }
+ for _, tc := range tests {
+ got, ok := parseHumanReadableTime(tc.in)
+ if ok != tc.ok {
+ t.Errorf("parseHumanReadableTime(%q) ok = %v, want %v", tc.in, ok, tc.ok)
+ continue
+ }
+ if ok && got != tc.want {
+ t.Errorf("parseHumanReadableTime(%q) = %v, want %v", tc.in, got, tc.want)
+ }
+ }
+}
+
+func TestIsOpenCodeAuthError(t *testing.T) {
+ if !IsOpenCodeAuthError(ErrOpenCodeUnauthorized) {
+ t.Error("expected unauthorized")
+ }
+ if !IsOpenCodeAuthError(ErrOpenCodeForbidden) {
+ t.Error("expected forbidden")
+ }
+ if IsOpenCodeAuthError(ErrOpenCodeParseFailed) {
+ t.Error("parse failed should not be auth error")
+ }
+}
+
+func newTestOpenCodeClient(t *testing.T, srv *httptest.Server) *OpenCodeClient {
+ t.Helper()
+ return NewOpenCodeClient(nil, WithOpenCodeBaseURL(srv.URL))
+}
+
+func mapQuotasByName(quotas []OpenCodeQuota) map[string]OpenCodeQuota {
+ out := make(map[string]OpenCodeQuota, len(quotas))
+ for _, q := range quotas {
+ out[q.Name] = q
+ }
+ return out
+}
diff --git a/internal/api/test_main_test.go b/internal/api/test_main_test.go
index 0d79c8f..214f302 100644
--- a/internal/api/test_main_test.go
+++ b/internal/api/test_main_test.go
@@ -11,10 +11,10 @@ import (
// WriteAnthropicCredentials or DetectAnthropicToken can overwrite the user's
// real Claude Code OAuth tokens, causing Claude Code to be logged out.
//
-// It also unsets OPENCODE_HOME/XDG_DATA_HOME so DetectCodexCredentials never
-// resolves to the host's real ~/.local/share/opencode/auth.json; detection
-// then follows each test's temp HOME, keeping the suite hermetic regardless of
-// whether the developer/CI has a ChatGPT-via-OpenCode login.
+// It also clears OPENCODE_HOME/XDG_DATA_HOME so package-level detection does
+// not inherit a developer's override. Individual tests that must stay hermetic
+// should call isolateOpenCodeEnv (pins both to empty temp dirs) because
+// clearing alone can still fall through to the real UserHomeDir path.
func TestMain(m *testing.M) {
SetTestMode(true)
os.Unsetenv("OPENCODE_HOME")
diff --git a/internal/config/config.go b/internal/config/config.go
index 9205e16..cd2064e 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -43,6 +43,9 @@ type Config struct {
CodexAutoSource string // "codex" | "opencode" when auto-detected (display/logging)
CodexHasProfiles bool // true if saved profiles exist (enables bootstrap without token)
OpenCodeEnabled bool // OPENCODE_ENABLED=true: track ChatGPT via OpenCode auth.json (feeds Codex)
+ // OpenCode Go provider configuration
+ OpenCodeGoWorkspaceID string // OPENCODE_GO_WORKSPACE_ID
+ OpenCodeGoAuthCookie string // OPENCODE_GO_AUTH_COOKIE
CodexShowAvailable string // CODEX_SHOW_AVAILABLE: "usage" | "available", default "usage" (Codex-specific override)
CodexAutoStart5h bool // CODEX_AUTO_START_5H: auto-send a starter ping when the 5h window resets (Beta, default off)
CodexAutoStart7d bool // CODEX_AUTO_START_7D: auto-send a starter ping when the weekly window resets (Beta, default off)
@@ -218,6 +221,8 @@ var onwatchEnvKeys = []string{
"COPILOT_TOKEN",
"CODEX_TOKEN",
"OPENCODE_ENABLED",
+ "OPENCODE_GO_WORKSPACE_ID",
+ "OPENCODE_GO_AUTH_COOKIE",
"OPENCODE_HOME",
"ANTIGRAVITY_ENABLED",
"MINIMAX_API_KEY",
@@ -329,6 +334,8 @@ func loadFromEnvAndFlags(flags *flagValues) (*Config, error) {
}
// OpenCode feeds the Codex provider using ChatGPT OAuth stored by OpenCode.
cfg.OpenCodeEnabled = os.Getenv("OPENCODE_ENABLED") == "true"
+ cfg.OpenCodeGoWorkspaceID = strings.TrimSpace(os.Getenv("OPENCODE_GO_WORKSPACE_ID"))
+ cfg.OpenCodeGoAuthCookie = strings.TrimSpace(os.Getenv("OPENCODE_GO_AUTH_COOKIE"))
// Codex auto quota-starter (Beta): default off; the dashboard toggle in
// provider_settings overrides these env-provided defaults at runtime.
cfg.CodexAutoStart5h = os.Getenv("CODEX_AUTO_START_5H") == "true"
@@ -647,6 +654,9 @@ func (c *Config) AvailableProviders() []string {
if c.KimiToken != "" || c.KimiEnabled {
providers = append(providers, "kimi")
}
+ if c.OpenCodeGoWorkspaceID != "" && c.OpenCodeGoAuthCookie != "" {
+ providers = append(providers, "opencode")
+ }
return providers
}
@@ -681,6 +691,8 @@ func (c *Config) HasProvider(name string) bool {
return c.GrokToken != "" || c.GrokEnabled
case "kimi":
return c.KimiToken != "" || c.KimiEnabled
+ case "opencode":
+ return c.OpenCodeGoWorkspaceID != "" && c.OpenCodeGoAuthCookie != ""
}
return false
}
@@ -730,6 +742,9 @@ func (c *Config) HasMultipleProviders() bool {
if c.KimiToken != "" || c.KimiEnabled {
count++
}
+ if c.OpenCodeGoWorkspaceID != "" && c.OpenCodeGoAuthCookie != "" {
+ count++
+ }
return count > 1
}
@@ -802,6 +817,9 @@ func (c *Config) String() string {
// Redact Kimi token
kimiDisplay := redactAPIKey(c.KimiToken, "")
fmt.Fprintf(&sb, " KimiToken: %s,\n", kimiDisplay)
+ opencodeDisplay := redactAPIKey(c.OpenCodeGoAuthCookie, "")
+ fmt.Fprintf(&sb, " OpenCodeGoWorkspaceID: %s,\n", c.OpenCodeGoWorkspaceID)
+ fmt.Fprintf(&sb, " OpenCodeGoAuthCookie: %s,\n", opencodeDisplay)
if c.KimiAutoToken {
fmt.Fprintf(&sb, " KimiAutoToken: true,\n")
}
diff --git a/internal/store/opencode_store.go b/internal/store/opencode_store.go
new file mode 100644
index 0000000..786a85c
--- /dev/null
+++ b/internal/store/opencode_store.go
@@ -0,0 +1,523 @@
+package store
+
+import (
+ "database/sql"
+ "fmt"
+ "time"
+
+ "github.com/onllm-dev/onwatch/v2/internal/api"
+)
+
+type OpenCodeResetCycle struct {
+ ID int64
+ QuotaName string
+ CycleStart time.Time
+ CycleEnd *time.Time
+ ResetsAt *time.Time
+ PeakUtilization float64
+ TotalDelta float64
+}
+
+type OpenCodeLatestQuota struct {
+ Name string
+ Used float64
+ Limit float64
+ Utilization float64
+ Format string
+ ResetsAt *time.Time
+ CapturedAt time.Time
+ AccountType string
+ PlanName string
+}
+
+func (s *Store) InsertOpenCodeSnapshot(snapshot *api.OpenCodeSnapshot) (int64, error) {
+ tx, err := s.db.Begin()
+ if err != nil {
+ return 0, fmt.Errorf("failed to begin transaction: %w", err)
+ }
+ defer tx.Rollback()
+
+ result, err := tx.Exec(
+ `INSERT INTO opencode_snapshots (captured_at, raw_json, account_type, plan_name, quota_count) VALUES (?, ?, ?, ?, ?)`,
+ snapshot.CapturedAt.Format(time.RFC3339Nano),
+ snapshot.RawJSON,
+ string(snapshot.AccountType),
+ snapshot.PlanName,
+ len(snapshot.Quotas),
+ )
+ if err != nil {
+ return 0, fmt.Errorf("failed to insert opencode snapshot: %w", err)
+ }
+
+ snapshotID, err := result.LastInsertId()
+ if err != nil {
+ return 0, fmt.Errorf("failed to get snapshot ID: %w", err)
+ }
+
+ for _, q := range snapshot.Quotas {
+ var resetsAt interface{}
+ if q.ResetsAt != nil {
+ resetsAt = q.ResetsAt.Format(time.RFC3339Nano)
+ }
+ _, err := tx.Exec(
+ `INSERT INTO opencode_quota_values (snapshot_id, quota_name, used, limit_value, utilization, format, resets_at) VALUES (?, ?, ?, ?, ?, ?, ?)`,
+ snapshotID, q.Name, q.Used, q.Limit, q.Utilization, string(q.Format), resetsAt,
+ )
+ if err != nil {
+ return 0, fmt.Errorf("failed to insert quota value %s: %w", q.Name, err)
+ }
+ }
+
+ if err := tx.Commit(); err != nil {
+ return 0, fmt.Errorf("failed to commit: %w", err)
+ }
+
+ return snapshotID, nil
+}
+
+func (s *Store) QueryLatestOpenCode() (*api.OpenCodeSnapshot, error) {
+ var snapshot api.OpenCodeSnapshot
+ var capturedAt, accountType, planName string
+
+ err := s.db.QueryRow(
+ `SELECT id, captured_at, account_type, plan_name, quota_count FROM opencode_snapshots ORDER BY captured_at DESC LIMIT 1`,
+ ).Scan(&snapshot.ID, &capturedAt, &accountType, &planName, new(int))
+
+ if err == sql.ErrNoRows {
+ return nil, nil
+ }
+ if err != nil {
+ return nil, fmt.Errorf("failed to query latest opencode: %w", err)
+ }
+
+ snapshot.CapturedAt, _ = time.Parse(time.RFC3339Nano, capturedAt)
+ snapshot.AccountType = api.OpenCodeAccountType(accountType)
+ snapshot.PlanName = planName
+
+ rows, err := s.db.Query(
+ `SELECT quota_name, used, limit_value, utilization, format, resets_at FROM opencode_quota_values WHERE snapshot_id = ? ORDER BY quota_name`,
+ snapshot.ID,
+ )
+ if err != nil {
+ return nil, fmt.Errorf("failed to query quota values: %w", err)
+ }
+ defer rows.Close()
+
+ for rows.Next() {
+ var q api.OpenCodeQuota
+ var format string
+ var resetsAt sql.NullString
+ if err := rows.Scan(&q.Name, &q.Used, &q.Limit, &q.Utilization, &format, &resetsAt); err != nil {
+ return nil, fmt.Errorf("failed to scan quota value: %w", err)
+ }
+ q.Format = api.OpenCodeQuotaFormat(format)
+ if resetsAt.Valid && resetsAt.String != "" {
+ t, _ := time.Parse(time.RFC3339Nano, resetsAt.String)
+ q.ResetsAt = &t
+ }
+ snapshot.Quotas = append(snapshot.Quotas, q)
+ }
+
+ return &snapshot, rows.Err()
+}
+
+func (s *Store) QueryOpenCodeRange(start, end time.Time, limit ...int) ([]*api.OpenCodeSnapshot, error) {
+ query := `SELECT id, captured_at, account_type, plan_name, quota_count FROM opencode_snapshots
+ WHERE captured_at BETWEEN ? AND ? ORDER BY captured_at ASC`
+ args := []interface{}{start.UTC().Format(time.RFC3339Nano), end.UTC().Format(time.RFC3339Nano)}
+ if len(limit) > 0 && limit[0] > 0 {
+ query = `SELECT id, captured_at, account_type, plan_name, quota_count
+ FROM (
+ SELECT id, captured_at, account_type, plan_name, quota_count
+ FROM opencode_snapshots
+ WHERE captured_at BETWEEN ? AND ?
+ ORDER BY captured_at DESC
+ LIMIT ?
+ ) recent
+ ORDER BY captured_at ASC`
+ args = append(args, limit[0])
+ }
+
+ rows, err := s.db.Query(query, args...)
+ if err != nil {
+ return nil, fmt.Errorf("failed to query opencode range: %w", err)
+ }
+ defer rows.Close()
+
+ var snapshots []*api.OpenCodeSnapshot
+ for rows.Next() {
+ var snap api.OpenCodeSnapshot
+ var capturedAt, accountType, planName string
+ if err := rows.Scan(&snap.ID, &capturedAt, &accountType, &planName, new(int)); err != nil {
+ return nil, fmt.Errorf("failed to scan opencode snapshot: %w", err)
+ }
+ snap.CapturedAt, _ = time.Parse(time.RFC3339Nano, capturedAt)
+ snap.AccountType = api.OpenCodeAccountType(accountType)
+ snap.PlanName = planName
+ snapshots = append(snapshots, &snap)
+ }
+
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+
+ for _, snap := range snapshots {
+ qRows, err := s.db.Query(
+ `SELECT quota_name, used, limit_value, utilization, format, resets_at FROM opencode_quota_values WHERE snapshot_id = ? ORDER BY quota_name`,
+ snap.ID,
+ )
+ if err != nil {
+ return nil, fmt.Errorf("failed to query quota values for snapshot %d: %w", snap.ID, err)
+ }
+ for qRows.Next() {
+ var q api.OpenCodeQuota
+ var format string
+ var resetsAt sql.NullString
+ if err := qRows.Scan(&q.Name, &q.Used, &q.Limit, &q.Utilization, &format, &resetsAt); err != nil {
+ qRows.Close()
+ return nil, fmt.Errorf("failed to scan quota value: %w", err)
+ }
+ q.Format = api.OpenCodeQuotaFormat(format)
+ if resetsAt.Valid && resetsAt.String != "" {
+ t, _ := time.Parse(time.RFC3339Nano, resetsAt.String)
+ q.ResetsAt = &t
+ }
+ snap.Quotas = append(snap.Quotas, q)
+ }
+ qRows.Close()
+ }
+
+ return snapshots, nil
+}
+
+func (s *Store) CreateOpenCodeCycle(quotaName string, cycleStart time.Time, resetsAt *time.Time) (int64, error) {
+ var resetsAtVal interface{}
+ if resetsAt != nil {
+ resetsAtVal = resetsAt.Format(time.RFC3339Nano)
+ }
+
+ result, err := s.db.Exec(
+ `INSERT INTO opencode_reset_cycles (quota_name, cycle_start, resets_at) VALUES (?, ?, ?)`,
+ quotaName, cycleStart.Format(time.RFC3339Nano), resetsAtVal,
+ )
+ if err != nil {
+ return 0, fmt.Errorf("failed to create opencode cycle: %w", err)
+ }
+
+ id, err := result.LastInsertId()
+ if err != nil {
+ return 0, fmt.Errorf("failed to get cycle ID: %w", err)
+ }
+ return id, nil
+}
+
+func (s *Store) CloseOpenCodeCycle(quotaName string, cycleEnd time.Time, peak, delta float64) error {
+ _, err := s.db.Exec(
+ `UPDATE opencode_reset_cycles SET cycle_end = ?, peak_utilization = ?, total_delta = ?
+ WHERE quota_name = ? AND cycle_end IS NULL`,
+ cycleEnd.Format(time.RFC3339Nano), peak, delta, quotaName,
+ )
+ if err != nil {
+ return fmt.Errorf("failed to close opencode cycle: %w", err)
+ }
+ return nil
+}
+
+func (s *Store) UpdateOpenCodeCycle(quotaName string, peak, delta float64) error {
+ _, err := s.db.Exec(
+ `UPDATE opencode_reset_cycles SET peak_utilization = ?, total_delta = ?
+ WHERE quota_name = ? AND cycle_end IS NULL`,
+ peak, delta, quotaName,
+ )
+ if err != nil {
+ return fmt.Errorf("failed to update opencode cycle: %w", err)
+ }
+ return nil
+}
+
+func (s *Store) QueryActiveOpenCodeCycle(quotaName string) (*OpenCodeResetCycle, error) {
+ var cycle OpenCodeResetCycle
+ var cycleStart string
+ var cycleEnd, resetsAt sql.NullString
+
+ err := s.db.QueryRow(
+ `SELECT id, quota_name, cycle_start, cycle_end, resets_at, peak_utilization, total_delta
+ FROM opencode_reset_cycles WHERE quota_name = ? AND cycle_end IS NULL`,
+ quotaName,
+ ).Scan(
+ &cycle.ID, &cycle.QuotaName, &cycleStart, &cycleEnd, &resetsAt,
+ &cycle.PeakUtilization, &cycle.TotalDelta,
+ )
+
+ if err == sql.ErrNoRows {
+ return nil, nil
+ }
+ if err != nil {
+ return nil, fmt.Errorf("failed to query active opencode cycle: %w", err)
+ }
+
+ cycle.CycleStart, _ = time.Parse(time.RFC3339Nano, cycleStart)
+ if cycleEnd.Valid {
+ t, _ := time.Parse(time.RFC3339Nano, cycleEnd.String)
+ cycle.CycleEnd = &t
+ }
+ if resetsAt.Valid {
+ t, _ := time.Parse(time.RFC3339Nano, resetsAt.String)
+ cycle.ResetsAt = &t
+ }
+
+ return &cycle, nil
+}
+
+func (s *Store) QueryOpenCodeCycleHistory(quotaName string, limit ...int) ([]*OpenCodeResetCycle, error) {
+ query := `SELECT id, quota_name, cycle_start, cycle_end, resets_at, peak_utilization, total_delta
+ FROM opencode_reset_cycles WHERE quota_name = ? AND cycle_end IS NOT NULL ORDER BY cycle_start DESC`
+ args := []interface{}{quotaName}
+ if len(limit) > 0 && limit[0] > 0 {
+ query += ` LIMIT ?`
+ args = append(args, limit[0])
+ }
+
+ rows, err := s.db.Query(query, args...)
+ if err != nil {
+ return nil, fmt.Errorf("failed to query opencode cycles: %w", err)
+ }
+ defer rows.Close()
+
+ var cycles []*OpenCodeResetCycle
+ for rows.Next() {
+ var cycle OpenCodeResetCycle
+ var cycleStart, cycleEnd string
+ var resetsAt sql.NullString
+
+ if err := rows.Scan(&cycle.ID, &cycle.QuotaName, &cycleStart, &cycleEnd, &resetsAt,
+ &cycle.PeakUtilization, &cycle.TotalDelta); err != nil {
+ return nil, fmt.Errorf("failed to scan opencode cycle: %w", err)
+ }
+
+ cycle.CycleStart, _ = time.Parse(time.RFC3339Nano, cycleStart)
+ t, _ := time.Parse(time.RFC3339Nano, cycleEnd)
+ cycle.CycleEnd = &t
+ if resetsAt.Valid {
+ rt, _ := time.Parse(time.RFC3339Nano, resetsAt.String)
+ cycle.ResetsAt = &rt
+ }
+
+ cycles = append(cycles, &cycle)
+ }
+
+ return cycles, rows.Err()
+}
+
+func (s *Store) QueryOpenCodeCyclesSince(quotaName string, since time.Time) ([]*OpenCodeResetCycle, error) {
+ rows, err := s.db.Query(
+ `SELECT id, quota_name, cycle_start, cycle_end, resets_at, peak_utilization, total_delta
+ FROM opencode_reset_cycles WHERE quota_name = ? AND cycle_end IS NOT NULL AND cycle_start >= ?
+ ORDER BY cycle_start DESC`,
+ quotaName, since.UTC().Format(time.RFC3339Nano),
+ )
+ if err != nil {
+ return nil, fmt.Errorf("failed to query opencode cycles since: %w", err)
+ }
+ defer rows.Close()
+
+ var cycles []*OpenCodeResetCycle
+ for rows.Next() {
+ var cycle OpenCodeResetCycle
+ var cycleStart, cycleEnd string
+ var resetsAt sql.NullString
+
+ if err := rows.Scan(&cycle.ID, &cycle.QuotaName, &cycleStart, &cycleEnd, &resetsAt,
+ &cycle.PeakUtilization, &cycle.TotalDelta); err != nil {
+ return nil, fmt.Errorf("failed to scan opencode cycle: %w", err)
+ }
+
+ cycle.CycleStart, _ = time.Parse(time.RFC3339Nano, cycleStart)
+ t, _ := time.Parse(time.RFC3339Nano, cycleEnd)
+ cycle.CycleEnd = &t
+ if resetsAt.Valid {
+ rt, _ := time.Parse(time.RFC3339Nano, resetsAt.String)
+ cycle.ResetsAt = &rt
+ }
+
+ cycles = append(cycles, &cycle)
+ }
+
+ return cycles, rows.Err()
+}
+
+func (s *Store) QueryOpenCodeUtilizationSeries(quotaName string, since time.Time) ([]UtilizationPoint, error) {
+ rows, err := s.db.Query(
+ `SELECT s.captured_at, qv.utilization
+ FROM opencode_quota_values qv
+ JOIN opencode_snapshots s ON s.id = qv.snapshot_id
+ WHERE qv.quota_name = ? AND s.captured_at >= ?
+ ORDER BY s.captured_at ASC`,
+ quotaName, since.UTC().Format(time.RFC3339Nano),
+ )
+ if err != nil {
+ return nil, fmt.Errorf("failed to query utilization series: %w", err)
+ }
+ defer rows.Close()
+
+ var points []UtilizationPoint
+ for rows.Next() {
+ var capturedAt string
+ var util float64
+ if err := rows.Scan(&capturedAt, &util); err != nil {
+ return nil, fmt.Errorf("failed to scan utilization point: %w", err)
+ }
+ t, _ := time.Parse(time.RFC3339Nano, capturedAt)
+ points = append(points, UtilizationPoint{CapturedAt: t, Utilization: util})
+ }
+
+ return points, rows.Err()
+}
+
+func (s *Store) QueryOpenCodeLatestPerQuota() ([]OpenCodeLatestQuota, error) {
+ rows, err := s.db.Query(`
+ SELECT qv.quota_name, qv.used, qv.limit_value, qv.utilization, qv.format, qv.resets_at,
+ s.captured_at, s.account_type, s.plan_name
+ FROM opencode_quota_values qv
+ JOIN opencode_snapshots s ON s.id = qv.snapshot_id
+ WHERE s.id = (SELECT MAX(id) FROM opencode_snapshots)
+ ORDER BY qv.quota_name ASC`)
+ if err != nil {
+ return nil, fmt.Errorf("failed to query latest per-quota: %w", err)
+ }
+ defer rows.Close()
+
+ var results []OpenCodeLatestQuota
+ for rows.Next() {
+ var name, format, accountType, planName string
+ var used, limitValue, utilization float64
+ var resetsAt sql.NullString
+ var capturedAt string
+
+ if err := rows.Scan(&name, &used, &limitValue, &utilization, &format, &resetsAt, &capturedAt, &accountType, &planName); err != nil {
+ return nil, fmt.Errorf("failed to scan latest quota: %w", err)
+ }
+
+ q := OpenCodeLatestQuota{
+ Name: name,
+ Used: used,
+ Limit: limitValue,
+ Utilization: utilization,
+ Format: format,
+ AccountType: accountType,
+ PlanName: planName,
+ }
+ q.CapturedAt, _ = time.Parse(time.RFC3339Nano, capturedAt)
+ if resetsAt.Valid && resetsAt.String != "" {
+ t, _ := time.Parse(time.RFC3339Nano, resetsAt.String)
+ q.ResetsAt = &t
+ }
+ results = append(results, q)
+ }
+ return results, rows.Err()
+}
+
+func (s *Store) QueryAllOpenCodeQuotaNames() ([]string, error) {
+ rows, err := s.db.Query(
+ `SELECT DISTINCT quota_name FROM opencode_reset_cycles ORDER BY quota_name`,
+ )
+ if err != nil {
+ return nil, fmt.Errorf("failed to query opencode quota names: %w", err)
+ }
+ defer rows.Close()
+
+ var names []string
+ for rows.Next() {
+ var name string
+ if err := rows.Scan(&name); err != nil {
+ return nil, fmt.Errorf("failed to scan quota name: %w", err)
+ }
+ names = append(names, name)
+ }
+
+ return names, rows.Err()
+}
+
+func (s *Store) QueryOpenCodeCycleOverview(groupBy string, limit int) ([]CycleOverviewRow, error) {
+ if limit <= 0 {
+ limit = 50
+ }
+
+ var cycles []*OpenCodeResetCycle
+ activeCycle, err := s.QueryActiveOpenCodeCycle(groupBy)
+ if err != nil {
+ return nil, fmt.Errorf("store.QueryOpenCodeCycleOverview: active: %w", err)
+ }
+ if activeCycle != nil {
+ cycles = append(cycles, activeCycle)
+ limit--
+ }
+
+ completedCycles, err := s.QueryOpenCodeCycleHistory(groupBy, limit)
+ if err != nil {
+ return nil, fmt.Errorf("store.QueryOpenCodeCycleOverview: %w", err)
+ }
+ cycles = append(cycles, completedCycles...)
+
+ var overviewRows []CycleOverviewRow
+ for _, c := range cycles {
+ row := CycleOverviewRow{
+ CycleID: c.ID,
+ QuotaType: c.QuotaName,
+ CycleStart: c.CycleStart,
+ CycleEnd: c.CycleEnd,
+ PeakValue: c.PeakUtilization,
+ TotalDelta: c.TotalDelta,
+ }
+
+ var endBoundary time.Time
+ if c.CycleEnd != nil {
+ endBoundary = *c.CycleEnd
+ } else {
+ endBoundary = time.Now().Add(time.Minute)
+ }
+
+ var snapshotID int64
+ var capturedAt string
+ err := s.db.QueryRow(
+ `SELECT s.id, s.captured_at FROM opencode_snapshots s
+ JOIN opencode_quota_values qv ON qv.snapshot_id = s.id
+ WHERE qv.quota_name = ? AND s.captured_at >= ? AND s.captured_at < ?
+ ORDER BY qv.utilization DESC LIMIT 1`,
+ groupBy,
+ c.CycleStart.Format(time.RFC3339Nano),
+ endBoundary.Format(time.RFC3339Nano),
+ ).Scan(&snapshotID, &capturedAt)
+
+ if err == sql.ErrNoRows {
+ overviewRows = append(overviewRows, row)
+ continue
+ }
+ if err != nil {
+ return nil, fmt.Errorf("store.QueryOpenCodeCycleOverview: peak snapshot: %w", err)
+ }
+
+ row.PeakTime, _ = time.Parse(time.RFC3339Nano, capturedAt)
+
+ qRows, err := s.db.Query(
+ `SELECT quota_name, utilization, used, limit_value FROM opencode_quota_values WHERE snapshot_id = ? ORDER BY quota_name`,
+ snapshotID,
+ )
+ if err != nil {
+ return nil, fmt.Errorf("store.QueryOpenCodeCycleOverview: quota values: %w", err)
+ }
+ for qRows.Next() {
+ var entry CrossQuotaEntry
+ if err := qRows.Scan(&entry.Name, &entry.Percent, &entry.Value, new(float64)); err != nil {
+ qRows.Close()
+ return nil, fmt.Errorf("store.QueryOpenCodeCycleOverview: scan quota: %w", err)
+ }
+ row.CrossQuotas = append(row.CrossQuotas, entry)
+ }
+ qRows.Close()
+
+ overviewRows = append(overviewRows, row)
+ }
+
+ return overviewRows, nil
+}
diff --git a/internal/store/opencode_store_test.go b/internal/store/opencode_store_test.go
new file mode 100644
index 0000000..a706d48
--- /dev/null
+++ b/internal/store/opencode_store_test.go
@@ -0,0 +1,116 @@
+package store
+
+import (
+ "testing"
+ "time"
+
+ "github.com/onllm-dev/onwatch/v2/internal/api"
+)
+
+func TestOpenCodeStore_InsertAndQueryLatest(t *testing.T) {
+ s, err := New(":memory:")
+ if err != nil {
+ t.Fatalf("new store: %v", err)
+ }
+ defer s.Close()
+
+ now := time.Now().UTC()
+ reset := now.Add(5 * time.Hour)
+ snap := &api.OpenCodeSnapshot{
+ CapturedAt: now,
+ AccountType: api.OpenCodeAccountTypePro,
+ PlanName: "OpenCode Go",
+ Quotas: []api.OpenCodeQuota{
+ {Name: "five_hour", Used: 10, Limit: 100, Utilization: 10, Format: api.OpenCodeQuotaFormatPercent, ResetsAt: &reset},
+ {Name: "weekly", Used: 20, Limit: 100, Utilization: 20, Format: api.OpenCodeQuotaFormatPercent, ResetsAt: &reset},
+ },
+ }
+ id, err := s.InsertOpenCodeSnapshot(snap)
+ if err != nil {
+ t.Fatalf("insert: %v", err)
+ }
+ if id == 0 {
+ t.Error("expected id > 0")
+ }
+
+ latest, err := s.QueryLatestOpenCode()
+ if err != nil {
+ t.Fatalf("query latest: %v", err)
+ }
+ if latest == nil || latest.PlanName != "OpenCode Go" || len(latest.Quotas) != 2 {
+ t.Errorf("latest mismatch: %+v", latest)
+ }
+}
+
+func TestOpenCodeStore_QueryRangeLoadsQuotas(t *testing.T) {
+ s, err := New(":memory:")
+ if err != nil {
+ t.Fatalf("new store: %v", err)
+ }
+ defer s.Close()
+
+ base := time.Now().UTC().Add(-time.Hour)
+ for i := 0; i < 3; i++ {
+ snap := &api.OpenCodeSnapshot{
+ CapturedAt: base.Add(time.Duration(i) * time.Minute),
+ Quotas: []api.OpenCodeQuota{
+ {Name: "five_hour", Utilization: float64(i+1)*10, Format: api.OpenCodeQuotaFormatPercent},
+ },
+ }
+ if _, err := s.InsertOpenCodeSnapshot(snap); err != nil {
+ t.Fatalf("insert %d: %v", i, err)
+ }
+ }
+
+ snaps, err := s.QueryOpenCodeRange(base.Add(-time.Minute), time.Now().UTC())
+ if err != nil {
+ t.Fatalf("query range: %v", err)
+ }
+ if len(snaps) != 3 {
+ t.Fatalf("snapshots = %d, want 3", len(snaps))
+ }
+ if len(snaps[0].Quotas) != 1 {
+ t.Fatalf("first snapshot quotas = %d, want 1", len(snaps[0].Quotas))
+ }
+}
+
+func TestOpenCodeStore_CycleLifecycle(t *testing.T) {
+ s, err := New(":memory:")
+ if err != nil {
+ t.Fatalf("new store: %v", err)
+ }
+ defer s.Close()
+
+ now := time.Now().UTC()
+ reset := now.Add(5 * time.Hour)
+ id, err := s.CreateOpenCodeCycle("five_hour", now, &reset)
+ if err != nil {
+ t.Fatalf("CreateOpenCodeCycle: %v", err)
+ }
+ if id == 0 {
+ t.Fatal("expected cycle id")
+ }
+
+ active, err := s.QueryActiveOpenCodeCycle("five_hour")
+ if err != nil {
+ t.Fatalf("QueryActiveOpenCodeCycle: %v", err)
+ }
+ if active == nil || active.QuotaName != "five_hour" {
+ t.Fatalf("active cycle mismatch: %+v", active)
+ }
+
+ if err := s.UpdateOpenCodeCycle("five_hour", 15, 5); err != nil {
+ t.Fatalf("UpdateOpenCodeCycle: %v", err)
+ }
+ if err := s.CloseOpenCodeCycle("five_hour", now.Add(time.Hour), 15, 5); err != nil {
+ t.Fatalf("CloseOpenCodeCycle: %v", err)
+ }
+
+ history, err := s.QueryOpenCodeCycleHistory("five_hour")
+ if err != nil {
+ t.Fatalf("QueryOpenCodeCycleHistory: %v", err)
+ }
+ if len(history) != 1 {
+ t.Fatalf("history = %d, want 1", len(history))
+ }
+}
diff --git a/internal/store/store.go b/internal/store/store.go
index 68bf4b0..1a13614 100644
--- a/internal/store/store.go
+++ b/internal/store/store.go
@@ -778,6 +778,43 @@ func (s *Store) createTables() error {
CREATE INDEX IF NOT EXISTS idx_kimi_cycles_name_active ON kimi_reset_cycles(quota_name, cycle_end) WHERE cycle_end IS NULL;
CREATE INDEX IF NOT EXISTS idx_kimi_snapshots_account ON kimi_snapshots(account_id, captured_at);
+ -- OpenCode Go tables
+ CREATE TABLE IF NOT EXISTS opencode_snapshots (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ captured_at TEXT NOT NULL,
+ raw_json TEXT NOT NULL DEFAULT '',
+ account_type TEXT NOT NULL DEFAULT '',
+ plan_name TEXT NOT NULL DEFAULT '',
+ quota_count INTEGER NOT NULL DEFAULT 0
+ );
+
+ CREATE TABLE IF NOT EXISTS opencode_quota_values (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ snapshot_id INTEGER NOT NULL,
+ quota_name TEXT NOT NULL,
+ used REAL NOT NULL DEFAULT 0,
+ limit_value REAL NOT NULL DEFAULT 0,
+ utilization REAL NOT NULL DEFAULT 0,
+ format TEXT NOT NULL DEFAULT 'percent',
+ resets_at TEXT,
+ FOREIGN KEY (snapshot_id) REFERENCES opencode_snapshots(id)
+ );
+
+ CREATE TABLE IF NOT EXISTS opencode_reset_cycles (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ quota_name TEXT NOT NULL,
+ cycle_start TEXT NOT NULL,
+ cycle_end TEXT,
+ resets_at TEXT,
+ peak_utilization REAL NOT NULL DEFAULT 0,
+ total_delta REAL NOT NULL DEFAULT 0
+ );
+
+ CREATE INDEX IF NOT EXISTS idx_opencode_snapshots_captured ON opencode_snapshots(captured_at);
+ CREATE INDEX IF NOT EXISTS idx_opencode_quota_values_snapshot ON opencode_quota_values(snapshot_id);
+ CREATE INDEX IF NOT EXISTS idx_opencode_cycles_name_start ON opencode_reset_cycles(quota_name, cycle_start);
+ CREATE INDEX IF NOT EXISTS idx_opencode_cycles_name_active ON opencode_reset_cycles(quota_name, cycle_end) WHERE cycle_end IS NULL;
+
-- API integrations telemetry ingestion tables
CREATE TABLE IF NOT EXISTS api_integration_usage_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
diff --git a/internal/tracker/opencode_tracker.go b/internal/tracker/opencode_tracker.go
new file mode 100644
index 0000000..f0630c9
--- /dev/null
+++ b/internal/tracker/opencode_tracker.go
@@ -0,0 +1,276 @@
+package tracker
+
+import (
+ "fmt"
+ "log/slog"
+ "time"
+
+ "github.com/onllm-dev/onwatch/v2/internal/api"
+ "github.com/onllm-dev/onwatch/v2/internal/store"
+)
+
+const openCodeResetDriftTolerance = 90 * time.Minute
+
+type OpenCodeTracker struct {
+ store *store.Store
+ logger *slog.Logger
+ lastValues map[string]float64
+ lastResets map[string]string
+ hasLast bool
+ onReset func(quotaName string)
+}
+
+func (t *OpenCodeTracker) SetOnReset(fn func(string)) {
+ t.onReset = fn
+}
+
+type OpenCodeSummary struct {
+ QuotaName string
+ CurrentUtil float64
+ ResetsAt *time.Time
+ TimeUntilReset time.Duration
+ CurrentRate float64
+ ProjectedUtil float64
+ CompletedCycles int
+ AvgPerCycle float64
+ PeakCycle float64
+ TotalTracked float64
+ TrackingSince time.Time
+}
+
+func NewOpenCodeTracker(store *store.Store, logger *slog.Logger) *OpenCodeTracker {
+ if logger == nil {
+ logger = slog.Default()
+ }
+ return &OpenCodeTracker{
+ store: store,
+ logger: logger,
+ lastValues: make(map[string]float64),
+ lastResets: make(map[string]string),
+ }
+}
+
+func (t *OpenCodeTracker) Process(snapshot *api.OpenCodeSnapshot) error {
+ for _, quota := range snapshot.Quotas {
+ if err := t.processQuota(quota, snapshot.CapturedAt); err != nil {
+ return fmt.Errorf("opencode tracker: %s: %w", quota.Name, err)
+ }
+ }
+
+ t.hasLast = true
+ return nil
+}
+
+func (t *OpenCodeTracker) processQuota(quota api.OpenCodeQuota, capturedAt time.Time) error {
+ quotaName := quota.Name
+ currentUtil := quota.Utilization
+
+ cycle, err := t.store.QueryActiveOpenCodeCycle(quotaName)
+ if err != nil {
+ return fmt.Errorf("failed to query active cycle: %w", err)
+ }
+
+ if cycle == nil {
+ _, err := t.store.CreateOpenCodeCycle(quotaName, capturedAt, quota.ResetsAt)
+ if err != nil {
+ return fmt.Errorf("failed to create cycle: %w", err)
+ }
+ if err := t.store.UpdateOpenCodeCycle(quotaName, currentUtil, 0); err != nil {
+ return fmt.Errorf("failed to set initial peak: %w", err)
+ }
+ t.lastValues[quotaName] = currentUtil
+ if quota.ResetsAt != nil {
+ t.lastResets[quotaName] = quota.ResetsAt.Format(time.RFC3339Nano)
+ }
+ t.logger.Info("Created new OpenCode cycle",
+ "quota", quotaName,
+ "resetsAt", quota.ResetsAt,
+ "initialUtil", currentUtil,
+ )
+ return nil
+ }
+
+ resetDetected := false
+ resetReason := ""
+ storedResetPassed := cycle.ResetsAt != nil && capturedAt.After(cycle.ResetsAt.Add(2*time.Minute))
+ currentResetIsFuture := quota.ResetsAt != nil && quota.ResetsAt.After(capturedAt)
+ if storedResetPassed && !currentResetIsFuture {
+ resetDetected = true
+ resetReason = "time-based (stored ResetsAt passed)"
+ }
+
+ if !resetDetected {
+ if quota.ResetsAt != nil && cycle.ResetsAt != nil {
+ diff := quota.ResetsAt.Sub(*cycle.ResetsAt)
+ if diff < 0 {
+ diff = -diff
+ }
+ if diff > openCodeResetDriftTolerance {
+ resetDetected = true
+ resetReason = "api-based (ResetsAt changed)"
+ }
+ } else if quota.ResetsAt != nil && cycle.ResetsAt == nil {
+ resetDetected = true
+ resetReason = "api-based (new ResetsAt appeared)"
+ }
+ }
+
+ if resetDetected {
+ cycleEndTime := capturedAt
+ if cycle.ResetsAt != nil && capturedAt.After(*cycle.ResetsAt) {
+ cycleEndTime = *cycle.ResetsAt
+ }
+
+ if t.hasLast {
+ if lastUtil, ok := t.lastValues[quotaName]; ok {
+ delta := currentUtil - lastUtil
+ if delta > 0 {
+ cycle.TotalDelta += delta
+ }
+ if currentUtil > cycle.PeakUtilization {
+ cycle.PeakUtilization = currentUtil
+ }
+ }
+ }
+
+ if err := t.store.CloseOpenCodeCycle(quotaName, cycleEndTime, cycle.PeakUtilization, cycle.TotalDelta); err != nil {
+ return fmt.Errorf("failed to close cycle: %w", err)
+ }
+
+ if _, err := t.store.CreateOpenCodeCycle(quotaName, capturedAt, quota.ResetsAt); err != nil {
+ return fmt.Errorf("failed to create new cycle: %w", err)
+ }
+ if err := t.store.UpdateOpenCodeCycle(quotaName, currentUtil, 0); err != nil {
+ return fmt.Errorf("failed to set initial peak: %w", err)
+ }
+
+ t.lastValues[quotaName] = currentUtil
+ if quota.ResetsAt != nil {
+ t.lastResets[quotaName] = quota.ResetsAt.Format(time.RFC3339Nano)
+ }
+ t.logger.Info("Detected OpenCode quota reset",
+ "quota", quotaName,
+ "reason", resetReason,
+ "oldResetsAt", cycle.ResetsAt,
+ "newResetsAt", quota.ResetsAt,
+ "cycleEndTime", cycleEndTime,
+ "totalDelta", cycle.TotalDelta,
+ )
+ if t.onReset != nil {
+ t.onReset(quotaName)
+ }
+ return nil
+ }
+
+ if t.hasLast {
+ if lastUtil, ok := t.lastValues[quotaName]; ok {
+ delta := currentUtil - lastUtil
+ if delta > 0 {
+ cycle.TotalDelta += delta
+ }
+ if currentUtil > cycle.PeakUtilization {
+ cycle.PeakUtilization = currentUtil
+ }
+ if err := t.store.UpdateOpenCodeCycle(quotaName, cycle.PeakUtilization, cycle.TotalDelta); err != nil {
+ return fmt.Errorf("failed to update cycle: %w", err)
+ }
+ } else {
+ if currentUtil > cycle.PeakUtilization {
+ cycle.PeakUtilization = currentUtil
+ if err := t.store.UpdateOpenCodeCycle(quotaName, cycle.PeakUtilization, cycle.TotalDelta); err != nil {
+ return fmt.Errorf("failed to update cycle: %w", err)
+ }
+ }
+ }
+ } else {
+ if currentUtil > cycle.PeakUtilization {
+ cycle.PeakUtilization = currentUtil
+ if err := t.store.UpdateOpenCodeCycle(quotaName, cycle.PeakUtilization, cycle.TotalDelta); err != nil {
+ return fmt.Errorf("failed to update cycle: %w", err)
+ }
+ }
+ }
+
+ t.lastValues[quotaName] = currentUtil
+ if quota.ResetsAt != nil {
+ t.lastResets[quotaName] = quota.ResetsAt.Format(time.RFC3339Nano)
+ }
+ return nil
+}
+
+func (t *OpenCodeTracker) UsageSummary(quotaName string) (*OpenCodeSummary, error) {
+ activeCycle, err := t.store.QueryActiveOpenCodeCycle(quotaName)
+ if err != nil {
+ return nil, fmt.Errorf("failed to query active cycle: %w", err)
+ }
+
+ history, err := t.store.QueryOpenCodeCycleHistory(quotaName)
+ if err != nil {
+ return nil, fmt.Errorf("failed to query cycle history: %w", err)
+ }
+
+ summary := &OpenCodeSummary{
+ QuotaName: quotaName,
+ CompletedCycles: len(history),
+ }
+
+ if len(history) > 0 {
+ var totalDelta float64
+ summary.TrackingSince = history[len(history)-1].CycleStart
+
+ for _, cycle := range history {
+ totalDelta += cycle.TotalDelta
+ if cycle.PeakUtilization > summary.PeakCycle {
+ summary.PeakCycle = cycle.PeakUtilization
+ }
+ }
+ summary.AvgPerCycle = totalDelta / float64(len(history))
+ summary.TotalTracked = totalDelta
+ }
+
+ if activeCycle != nil {
+ summary.TotalTracked += activeCycle.TotalDelta
+ if activeCycle.PeakUtilization > summary.PeakCycle {
+ summary.PeakCycle = activeCycle.PeakUtilization
+ }
+ if activeCycle.ResetsAt != nil {
+ summary.ResetsAt = activeCycle.ResetsAt
+ summary.TimeUntilReset = time.Until(*activeCycle.ResetsAt)
+ }
+
+ latest, err := t.store.QueryLatestOpenCode()
+ if err != nil {
+ return nil, fmt.Errorf("failed to query latest: %w", err)
+ }
+
+ if latest != nil {
+ for _, q := range latest.Quotas {
+ if q.Name == quotaName {
+ summary.CurrentUtil = q.Utilization
+ if summary.ResetsAt == nil && q.ResetsAt != nil {
+ summary.ResetsAt = q.ResetsAt
+ summary.TimeUntilReset = time.Until(*q.ResetsAt)
+ }
+ break
+ }
+ }
+
+ elapsed := time.Since(activeCycle.CycleStart)
+ if elapsed.Minutes() >= 30 && activeCycle.TotalDelta > 0 {
+ summary.CurrentRate = activeCycle.TotalDelta / elapsed.Hours()
+ if summary.ResetsAt != nil {
+ hoursLeft := time.Until(*summary.ResetsAt).Hours()
+ if hoursLeft > 0 {
+ projected := summary.CurrentUtil + (summary.CurrentRate * hoursLeft)
+ if projected > 100 {
+ projected = 100
+ }
+ summary.ProjectedUtil = projected
+ }
+ }
+ }
+ }
+ }
+
+ return summary, nil
+}
diff --git a/internal/tracker/opencode_tracker_test.go b/internal/tracker/opencode_tracker_test.go
new file mode 100644
index 0000000..f61ffd5
--- /dev/null
+++ b/internal/tracker/opencode_tracker_test.go
@@ -0,0 +1,244 @@
+package tracker
+
+import (
+ "log/slog"
+ "testing"
+ "time"
+
+ "github.com/onllm-dev/onwatch/v2/internal/api"
+ "github.com/onllm-dev/onwatch/v2/internal/store"
+)
+
+func newTestOpenCodeStore(t *testing.T) *store.Store {
+ t.Helper()
+ s, err := store.New(":memory:")
+ if err != nil {
+ t.Fatalf("store.New: %v", err)
+ }
+ t.Cleanup(func() { s.Close() })
+ return s
+}
+
+func TestOpenCodeTracker_Process_FirstSnapshot(t *testing.T) {
+ s := newTestOpenCodeStore(t)
+ tr := NewOpenCodeTracker(s, slog.Default())
+
+ now := time.Now().UTC()
+ resetsAt := now.Add(5 * time.Hour)
+
+ snapshot := &api.OpenCodeSnapshot{
+ CapturedAt: now,
+ AccountType: api.OpenCodeAccountTypePro,
+ PlanName: "OpenCode Go",
+ Quotas: []api.OpenCodeQuota{
+ {Name: "five_hour", Utilization: 12.5, Format: api.OpenCodeQuotaFormatPercent, ResetsAt: &resetsAt},
+ },
+ }
+
+ if err := tr.Process(snapshot); err != nil {
+ t.Fatalf("Process: %v", err)
+ }
+
+ cycle, err := s.QueryActiveOpenCodeCycle("five_hour")
+ if err != nil {
+ t.Fatalf("QueryActiveOpenCodeCycle: %v", err)
+ }
+ if cycle == nil {
+ t.Fatal("expected active cycle after first snapshot")
+ }
+ if cycle.PeakUtilization != 12.5 {
+ t.Errorf("PeakUtilization = %f, want 12.5", cycle.PeakUtilization)
+ }
+}
+
+func TestOpenCodeTracker_Process_UsageIncrease(t *testing.T) {
+ s := newTestOpenCodeStore(t)
+ tr := NewOpenCodeTracker(s, slog.Default())
+
+ now := time.Now().UTC()
+ resetsAt := now.Add(5 * time.Hour)
+
+ snap1 := &api.OpenCodeSnapshot{
+ CapturedAt: now,
+ Quotas: []api.OpenCodeQuota{
+ {Name: "five_hour", Utilization: 12.5, Format: api.OpenCodeQuotaFormatPercent, ResetsAt: &resetsAt},
+ },
+ }
+ if err := tr.Process(snap1); err != nil {
+ t.Fatalf("Process snap1: %v", err)
+ }
+
+ snap2 := &api.OpenCodeSnapshot{
+ CapturedAt: now.Add(time.Minute),
+ Quotas: []api.OpenCodeQuota{
+ {Name: "five_hour", Utilization: 25.0, Format: api.OpenCodeQuotaFormatPercent, ResetsAt: &resetsAt},
+ },
+ }
+ if err := tr.Process(snap2); err != nil {
+ t.Fatalf("Process snap2: %v", err)
+ }
+
+ cycle, err := s.QueryActiveOpenCodeCycle("five_hour")
+ if err != nil {
+ t.Fatalf("QueryActiveOpenCodeCycle: %v", err)
+ }
+ if cycle == nil {
+ t.Fatal("expected active cycle")
+ }
+ if cycle.PeakUtilization != 25.0 {
+ t.Errorf("PeakUtilization = %f, want 25.0", cycle.PeakUtilization)
+ }
+ if cycle.TotalDelta != 12.5 {
+ t.Errorf("TotalDelta = %f, want 12.5", cycle.TotalDelta)
+ }
+}
+
+func TestOpenCodeTracker_Process_ResetDetection(t *testing.T) {
+ s := newTestOpenCodeStore(t)
+ tr := NewOpenCodeTracker(s, slog.Default())
+
+ now := time.Now().UTC()
+ oldReset := now.Add(1 * time.Hour)
+ newReset := now.Add(6 * time.Hour)
+
+ snap1 := &api.OpenCodeSnapshot{
+ CapturedAt: now,
+ Quotas: []api.OpenCodeQuota{
+ {Name: "weekly", Utilization: 40, Format: api.OpenCodeQuotaFormatPercent, ResetsAt: &oldReset},
+ },
+ }
+ if err := tr.Process(snap1); err != nil {
+ t.Fatalf("Process snap1: %v", err)
+ }
+
+ snap2 := &api.OpenCodeSnapshot{
+ CapturedAt: now.Add(2 * time.Hour),
+ Quotas: []api.OpenCodeQuota{
+ {Name: "weekly", Utilization: 5, Format: api.OpenCodeQuotaFormatPercent, ResetsAt: &newReset},
+ },
+ }
+ if err := tr.Process(snap2); err != nil {
+ t.Fatalf("Process snap2: %v", err)
+ }
+
+ history, err := s.QueryOpenCodeCycleHistory("weekly")
+ if err != nil {
+ t.Fatalf("QueryOpenCodeCycleHistory: %v", err)
+ }
+ if len(history) != 1 {
+ t.Fatalf("completed cycles = %d, want 1", len(history))
+ }
+
+ active, err := s.QueryActiveOpenCodeCycle("weekly")
+ if err != nil {
+ t.Fatalf("QueryActiveOpenCodeCycle: %v", err)
+ }
+ if active == nil {
+ t.Fatal("expected new active cycle after reset")
+ }
+}
+
+func TestOpenCodeTracker_Process_IgnoresFallbackResetTimeDrift(t *testing.T) {
+ s := newTestOpenCodeStore(t)
+ tr := NewOpenCodeTracker(s, slog.Default())
+
+ now := time.Now().UTC()
+ resetsAt := now.Add(7 * 24 * time.Hour)
+ snap1 := &api.OpenCodeSnapshot{
+ CapturedAt: now,
+ Quotas: []api.OpenCodeQuota{
+ {Name: "weekly", Utilization: 30, Format: api.OpenCodeQuotaFormatPercent, ResetsAt: &resetsAt},
+ },
+ }
+ if err := tr.Process(snap1); err != nil {
+ t.Fatalf("Process snap1: %v", err)
+ }
+
+ driftedReset := resetsAt.Add(-59 * time.Minute)
+ snap2 := &api.OpenCodeSnapshot{
+ CapturedAt: now.Add(time.Minute),
+ Quotas: []api.OpenCodeQuota{
+ {Name: "weekly", Utilization: 31, Format: api.OpenCodeQuotaFormatPercent, ResetsAt: &driftedReset},
+ },
+ }
+ if err := tr.Process(snap2); err != nil {
+ t.Fatalf("Process snap2: %v", err)
+ }
+
+ history, err := s.QueryOpenCodeCycleHistory("weekly")
+ if err != nil {
+ t.Fatalf("QueryOpenCodeCycleHistory: %v", err)
+ }
+ if len(history) != 0 {
+ t.Fatalf("completed cycles = %d, want 0", len(history))
+ }
+}
+
+func TestOpenCodeTracker_Process_IgnoresExpiredStoredResetWithinDriftTolerance(t *testing.T) {
+ s := newTestOpenCodeStore(t)
+ tr := NewOpenCodeTracker(s, slog.Default())
+
+ now := time.Now().UTC()
+ storedReset := now.Add(time.Hour)
+ snap1 := &api.OpenCodeSnapshot{
+ CapturedAt: now,
+ Quotas: []api.OpenCodeQuota{
+ {Name: "weekly", Utilization: 30, Format: api.OpenCodeQuotaFormatPercent, ResetsAt: &storedReset},
+ },
+ }
+ if err := tr.Process(snap1); err != nil {
+ t.Fatalf("Process snap1: %v", err)
+ }
+
+ currentReset := storedReset.Add(59 * time.Minute)
+ snap2 := &api.OpenCodeSnapshot{
+ CapturedAt: storedReset.Add(3 * time.Minute),
+ Quotas: []api.OpenCodeQuota{
+ {Name: "weekly", Utilization: 31, Format: api.OpenCodeQuotaFormatPercent, ResetsAt: ¤tReset},
+ },
+ }
+ if err := tr.Process(snap2); err != nil {
+ t.Fatalf("Process snap2: %v", err)
+ }
+
+ history, err := s.QueryOpenCodeCycleHistory("weekly")
+ if err != nil {
+ t.Fatalf("QueryOpenCodeCycleHistory: %v", err)
+ }
+ if len(history) != 0 {
+ t.Fatalf("completed cycles = %d, want 0", len(history))
+ }
+}
+
+func TestOpenCodeTracker_UsageSummary(t *testing.T) {
+ s := newTestOpenCodeStore(t)
+ tr := NewOpenCodeTracker(s, slog.Default())
+
+ now := time.Now().UTC()
+ resetsAt := now.Add(5 * time.Hour)
+ snap := &api.OpenCodeSnapshot{
+ CapturedAt: now,
+ AccountType: api.OpenCodeAccountTypePro,
+ PlanName: "OpenCode Go",
+ Quotas: []api.OpenCodeQuota{
+ {Name: "five_hour", Utilization: 20, Format: api.OpenCodeQuotaFormatPercent, ResetsAt: &resetsAt},
+ },
+ }
+ if _, err := s.InsertOpenCodeSnapshot(snap); err != nil {
+ t.Fatalf("InsertOpenCodeSnapshot: %v", err)
+ }
+ if err := tr.Process(snap); err != nil {
+ t.Fatalf("Process: %v", err)
+ }
+
+ summary, err := tr.UsageSummary("five_hour")
+ if err != nil {
+ t.Fatalf("UsageSummary: %v", err)
+ }
+ if summary == nil {
+ t.Fatal("expected summary")
+ }
+ if summary.CurrentUtil != 20 {
+ t.Errorf("CurrentUtil = %f, want 20", summary.CurrentUtil)
+ }
+}
diff --git a/internal/web/dashboard_tabs.go b/internal/web/dashboard_tabs.go
index 403f880..b7a3093 100644
--- a/internal/web/dashboard_tabs.go
+++ b/internal/web/dashboard_tabs.go
@@ -40,6 +40,8 @@ func defaultProviderTabLabel(key string) string {
return "Grok"
case "kimi":
return "Kimi"
+ case "opencode":
+ return "OpenCode"
case "moonshot":
return "Moonshot"
case "deepseek":
diff --git a/internal/web/dashboard_tabs_test.go b/internal/web/dashboard_tabs_test.go
index acf27de..73b1326 100644
--- a/internal/web/dashboard_tabs_test.go
+++ b/internal/web/dashboard_tabs_test.go
@@ -117,4 +117,7 @@ func TestDefaultProviderTabLabel(t *testing.T) {
if defaultProviderTabLabel("api-integrations") != "API Integrations" {
t.Fatal(defaultProviderTabLabel("api-integrations"))
}
+ if defaultProviderTabLabel("opencode") != "OpenCode" {
+ t.Fatal(defaultProviderTabLabel("opencode"))
+ }
}
diff --git a/internal/web/handlers.go b/internal/web/handlers.go
index 726354d..a914e17 100644
--- a/internal/web/handlers.go
+++ b/internal/web/handlers.go
@@ -96,6 +96,7 @@ type Handler struct {
cursorTracker *tracker.CursorTracker
grokTracker *tracker.GrokTracker
kimiTracker *tracker.KimiTracker
+ opencodeTracker *tracker.OpenCodeTracker
updater *update.Updater
notifier Notifier
agentManager ProviderAgentController
@@ -1121,6 +1122,7 @@ func providerCatalog() []providerCatalogItem {
{Key: "cursor", Name: "Cursor", Description: "Cursor usage and quota tracking", AutoDetectable: true},
{Key: "grok", Name: "Grok", Description: "Grok (xAI) usage tracking", AutoDetectable: true},
{Key: "kimi", Name: "Kimi Code", Description: "Kimi Code CLI OAuth quota tracking", AutoDetectable: true},
+ {Key: "opencode", Name: "OpenCode Go", Description: "OpenCode Go quota tracking", AutoDetectable: false},
}
}
@@ -1197,6 +1199,8 @@ func (h *Handler) isProviderConfigured(provider string) bool {
return true
}
return api.DetectKimiCredentials(h.logger) != nil
+ case "opencode":
+ return h.config != nil && strings.TrimSpace(h.config.OpenCodeGoWorkspaceID) != "" && strings.TrimSpace(h.config.OpenCodeGoAuthCookie) != ""
default:
return false
}
@@ -1345,6 +1349,8 @@ func applyProviderConfig(dst, src *config.Config) {
dst.CodexAutoToken = src.CodexAutoToken
dst.CodexAutoSource = src.CodexAutoSource
dst.OpenCodeEnabled = src.OpenCodeEnabled
+ dst.OpenCodeGoWorkspaceID = src.OpenCodeGoWorkspaceID
+ dst.OpenCodeGoAuthCookie = src.OpenCodeGoAuthCookie
dst.AntigravityBaseURL = src.AntigravityBaseURL
dst.AntigravityCSRFToken = src.AntigravityCSRFToken
dst.AntigravityEnabled = src.AntigravityEnabled
@@ -1369,9 +1375,10 @@ func applyProviderConfig(dst, src *config.Config) {
// and replaced with a "{key}_set: true" flag so the UI can show status
// without exposing the actual values.
var providerSecretKeys = map[string]bool{
- "api_key": true,
- "token": true,
- "csrf_token": true,
+ "api_key": true,
+ "token": true,
+ "csrf_token": true,
+ "auth_cookie": true,
}
// stripProviderSecrets removes sensitive field values from provider_settings
@@ -1434,6 +1441,9 @@ var providerEnumFields = map[string]map[string][]string{
"cursor": {
"display_mode": {"usage", "available"},
},
+ "opencode": {
+ "display_mode": {"usage", "available"},
+ },
}
// sanitizeProviderSettings validates enum fields and resets invalid values
@@ -1541,6 +1551,12 @@ func ApplyProviderSettingsFromDB(st *store.Store, cfg *config.Config, logger *sl
if enabled, ok := s["enabled"].(bool); ok {
cfg.OpenCodeEnabled = enabled
}
+ if id, _ := s["workspace_id"].(string); id != "" {
+ cfg.OpenCodeGoWorkspaceID = id
+ }
+ if cookie, _ := s["auth_cookie"].(string); cookie != "" {
+ cfg.OpenCodeGoAuthCookie = cookie
+ }
}
if logger != nil {
@@ -1849,6 +1865,8 @@ func (h *Handler) Current(w http.ResponseWriter, r *http.Request) {
h.currentGrok(w, r)
case "kimi":
h.currentKimi(w, r)
+ case "opencode":
+ h.currentOpenCode(w, r)
default:
respondError(w, http.StatusBadRequest, fmt.Sprintf("unknown provider: %s", provider))
}
@@ -2302,6 +2320,9 @@ func (h *Handler) currentBoth(w http.ResponseWriter, r *http.Request) {
if h.config.HasProvider("kimi") && providerTelemetryEnabled(visibility, "kimi") {
response["kimi"] = h.buildKimiCurrent()
}
+ if h.config.HasProvider("opencode") && providerTelemetryEnabled(visibility, "opencode") {
+ response["opencode"] = h.buildOpenCodeCurrent()
+ }
respondJSON(w, http.StatusOK, response)
}
@@ -2652,6 +2673,8 @@ func (h *Handler) History(w http.ResponseWriter, r *http.Request) {
h.historyGrok(w, r)
case "kimi":
h.historyKimi(w, r)
+ case "opencode":
+ h.historyOpenCode(w, r)
default:
respondError(w, http.StatusBadRequest, fmt.Sprintf("unknown provider: %s", provider))
}
@@ -3067,6 +3090,28 @@ func (h *Handler) historyBoth(w http.ResponseWriter, r *http.Request) {
}
}
+ if h.config.HasProvider("opencode") && providerTelemetryEnabled(visibility, "opencode") && h.store != nil {
+ snapshots, err := h.store.QueryOpenCodeRange(start, now, 200)
+ if err == nil {
+ step := downsampleStep(len(snapshots), maxChartPoints)
+ last := len(snapshots) - 1
+ opencodeData := make([]map[string]interface{}, 0, min(len(snapshots), maxChartPoints))
+ for i, snap := range snapshots {
+ if step > 1 && i != 0 && i != last && i%step != 0 {
+ continue
+ }
+ entry := map[string]interface{}{
+ "capturedAt": snap.CapturedAt.Format(time.RFC3339),
+ }
+ for _, q := range snap.Quotas {
+ entry[q.Name] = q.Utilization
+ }
+ opencodeData = append(opencodeData, entry)
+ }
+ response["opencode"] = opencodeData
+ }
+ }
+
respondJSON(w, http.StatusOK, response)
}
@@ -3673,6 +3718,8 @@ func (h *Handler) Cycles(w http.ResponseWriter, r *http.Request) {
respondJSON(w, http.StatusOK, map[string]interface{}{"cycles": []interface{}{}})
case "kimi":
respondJSON(w, http.StatusOK, map[string]interface{}{"cycles": []interface{}{}})
+ case "opencode":
+ h.cyclesOpenCode(w, r)
default:
respondError(w, http.StatusBadRequest, fmt.Sprintf("unknown provider: %s", provider))
}
@@ -4044,6 +4091,8 @@ func (h *Handler) Summary(w http.ResponseWriter, r *http.Request) {
respondJSON(w, http.StatusOK, map[string]interface{}{"summaries": []interface{}{}})
case "kimi":
respondJSON(w, http.StatusOK, map[string]interface{}{"summaries": []interface{}{}})
+ case "opencode":
+ h.summaryOpenCode(w, r)
default:
respondError(w, http.StatusBadRequest, fmt.Sprintf("unknown provider: %s", provider))
}
@@ -4861,6 +4910,8 @@ func (h *Handler) Insights(w http.ResponseWriter, r *http.Request) {
h.insightsGrok(w, r, rangeDur)
case "kimi":
h.insightsKimi(w, r, rangeDur)
+ case "opencode":
+ h.insightsOpenCode(w, r, rangeDur)
default:
respondError(w, http.StatusBadRequest, fmt.Sprintf("unknown provider: %s", provider))
}
@@ -4955,6 +5006,9 @@ func (h *Handler) insightsBoth(w http.ResponseWriter, r *http.Request, rangeDur
if h.config.HasProvider("kimi") && providerTelemetryEnabled(visibility, "kimi") {
response["kimi"] = h.buildKimiInsights(hidden)
}
+ if h.config.HasProvider("opencode") && providerTelemetryEnabled(visibility, "opencode") {
+ response["opencode"] = h.buildOpenCodeInsights(hidden, rangeDur)
+ }
respondJSON(w, http.StatusOK, response)
}
@@ -7363,6 +7417,8 @@ func (h *Handler) CycleOverview(w http.ResponseWriter, r *http.Request) {
h.cycleOverviewGrok(w, r)
case "kimi":
h.cycleOverviewKimi(w, r)
+ case "opencode":
+ h.cycleOverviewOpenCode(w, r)
default:
respondError(w, http.StatusBadRequest, fmt.Sprintf("unknown provider: %s", provider))
}
@@ -11004,6 +11060,8 @@ func (h *Handler) LoggingHistory(w http.ResponseWriter, r *http.Request) {
h.loggingHistoryGrok(w, r)
case "kimi":
h.loggingHistoryKimi(w, r)
+ case "opencode":
+ h.loggingHistoryOpenCode(w, r)
default:
respondError(w, http.StatusBadRequest, fmt.Sprintf("unknown provider: %s", provider))
}
diff --git a/internal/web/menubar.go b/internal/web/menubar.go
index 3d1563a..1a8d5d4 100644
--- a/internal/web/menubar.go
+++ b/internal/web/menubar.go
@@ -408,6 +408,15 @@ func (h *Handler) buildMenubarProviders(settings *menubar.Settings, includeHidde
}
}
}
+ if h.config != nil && h.config.HasProvider("opencode") && h.providerDashboardVisible("opencode", visibility) {
+ payload := h.buildOpenCodeCurrent()
+ if card := normalizeProviderCard("opencode", resolveProviderTabLabel("opencode", labels), "", payload, normalized.WarningPercent, normalized.CriticalPercent); card != nil {
+ providers = append(providers, *card)
+ if captured := parseCapturedAt(payload); captured.After(latest) {
+ latest = captured
+ }
+ }
+ }
if h.config != nil && h.config.HasProvider("cursor") && h.providerDashboardVisible("cursor", visibility) {
payload := h.buildCursorCurrent()
if card := normalizeProviderCard("cursor", resolveProviderTabLabel("cursor", labels), "", payload, normalized.WarningPercent, normalized.CriticalPercent); card != nil {
diff --git a/internal/web/opencode_handlers.go b/internal/web/opencode_handlers.go
new file mode 100644
index 0000000..47412be
--- /dev/null
+++ b/internal/web/opencode_handlers.go
@@ -0,0 +1,628 @@
+package web
+
+import (
+ "fmt"
+ "net/http"
+ "sort"
+ "time"
+
+ "github.com/onllm-dev/onwatch/v2/internal/api"
+ "github.com/onllm-dev/onwatch/v2/internal/tracker"
+)
+
+// SetOpenCodeTracker sets the OpenCode tracker for usage summary enrichment.
+func (h *Handler) SetOpenCodeTracker(t *tracker.OpenCodeTracker) {
+ h.opencodeTracker = t
+}
+
+func (h *Handler) currentOpenCode(w http.ResponseWriter, _ *http.Request) {
+ respondJSON(w, http.StatusOK, h.buildOpenCodeCurrent())
+}
+
+// opencodeInsightsResponse is the JSON payload for OpenCode deep insights.
+type opencodeInsightsResponse struct {
+ Stats []opencodeInsightStat `json:"stats"`
+ Insights []insightItem `json:"insights"`
+}
+
+// opencodeInsightStat is a stats-row shape that carries linked forecast metadata for the OpenCode dashboard.
+type opencodeInsightStat struct {
+ Value string `json:"value"`
+ Label string `json:"label"`
+ Sublabel string `json:"sublabel,omitempty"`
+ Key string `json:"key,omitempty"`
+ Metric string `json:"metric,omitempty"`
+ Severity string `json:"severity,omitempty"`
+ Desc string `json:"desc,omitempty"`
+}
+
+var opencodeQuotaDisplayOrder = map[string]int{
+ "five_hour": 1,
+ "weekly": 2,
+ "monthly": 3,
+}
+
+var opencodeDisplayNames = map[string]string{
+ "five_hour": "5-Hour",
+ "weekly": "Weekly",
+ "monthly": "Monthly",
+}
+
+func opencodeDisplayName(name string) string {
+ if dn, ok := opencodeDisplayNames[name]; ok {
+ return dn
+ }
+ return name
+}
+
+func opencodeQuotaOrder(name string) int {
+ if order, ok := opencodeQuotaDisplayOrder[name]; ok {
+ return order
+ }
+ return 99
+}
+
+type opencodeQuotaRate struct {
+ Rate float64
+ HasRate bool
+ TimeToReset time.Duration
+ TimeToExhaust time.Duration
+ ExhaustsFirst bool
+ ProjectedPct float64
+}
+
+func (h *Handler) computeOpenCodeRate(quotaName string, currentUtil float64, summary *tracker.OpenCodeSummary) opencodeQuotaRate {
+ var result opencodeQuotaRate
+
+ if summary != nil && summary.ResetsAt != nil {
+ result.TimeToReset = time.Until(*summary.ResetsAt)
+ }
+
+ if h.store != nil {
+ points, err := h.store.QueryOpenCodeUtilizationSeries(quotaName, time.Now().Add(-30*time.Minute))
+ if err == nil && len(points) >= 2 {
+ first := points[0]
+ last := points[len(points)-1]
+ elapsed := last.CapturedAt.Sub(first.CapturedAt)
+ if elapsed >= 5*time.Minute {
+ delta := last.Utilization - first.Utilization
+ if delta > 0 {
+ result.Rate = delta / elapsed.Hours()
+ result.HasRate = true
+ } else {
+ result.HasRate = true
+ }
+ }
+ }
+ }
+
+ if !result.HasRate && summary != nil && summary.CurrentRate > 0 {
+ result.Rate = summary.CurrentRate
+ result.HasRate = true
+ }
+
+ if result.HasRate && result.Rate > 0 {
+ remaining := 100 - currentUtil
+ if remaining > 0 {
+ result.TimeToExhaust = time.Duration(remaining / result.Rate * float64(time.Hour))
+ }
+ if result.TimeToReset > 0 {
+ result.ProjectedPct = currentUtil + (result.Rate * result.TimeToReset.Hours())
+ if result.ProjectedPct > 100 {
+ result.ProjectedPct = 100
+ }
+ result.ExhaustsFirst = result.TimeToExhaust > 0 && result.TimeToExhaust < result.TimeToReset
+ }
+ }
+
+ return result
+}
+
+func buildOpenCodeBurnRateInsight(quota api.OpenCodeQuota, rate opencodeQuotaRate) insightItem {
+ item := insightItem{
+ Key: fmt.Sprintf("forecast_%s", quota.Name),
+ Title: fmt.Sprintf("%s Burn Rate", opencodeDisplayName(quota.Name)),
+ }
+
+ resetStr := ""
+ if rate.TimeToReset > 0 {
+ resetStr = formatDuration(rate.TimeToReset)
+ }
+ projected := quota.Utilization
+ if rate.ProjectedPct > projected {
+ projected = rate.ProjectedPct
+ }
+ sublabel := fmt.Sprintf("~%.0f%% by reset", projected)
+ if resetStr != "" {
+ sublabel = fmt.Sprintf("~%.0f%% by reset in %s", projected, resetStr)
+ }
+
+ if !rate.HasRate {
+ item.Type = "forecast"
+ item.Severity = "info"
+ item.Metric = "Analyzing..."
+ item.Sublabel = sublabel
+ item.Desc = fmt.Sprintf("Currently at %.0f%%. Collecting more snapshots to estimate burn rate and refine reset projection.", quota.Utilization)
+ return item
+ }
+
+ if rate.Rate < 0.01 {
+ item.Type = "forecast"
+ item.Severity = "positive"
+ item.Metric = "Idle"
+ item.Sublabel = sublabel
+ item.Desc = fmt.Sprintf("Currently at %.0f%%. No meaningful burn detected recently, so this quota looks stable through the rest of the cycle.", quota.Utilization)
+ return item
+ }
+
+ item.Type = "forecast"
+ item.Metric = fmt.Sprintf("%.1f%%/hr", rate.Rate)
+ if rate.ExhaustsFirst {
+ exhaustStr := formatDuration(rate.TimeToExhaust)
+ item.Severity = "negative"
+ item.Sublabel = sublabel
+ item.Desc = fmt.Sprintf("Currently at %.0f%%. At this rate, projected %.0f%% by reset and likely to exhaust in %s before reset.", quota.Utilization, projected, exhaustStr)
+ return item
+ }
+
+ if rate.ProjectedPct >= 80 {
+ item.Severity = "warning"
+ item.Sublabel = sublabel
+ item.Desc = fmt.Sprintf("Currently at %.0f%%. At this rate, projected %.0f%% by reset.", quota.Utilization, projected)
+ return item
+ }
+
+ item.Severity = "positive"
+ item.Sublabel = sublabel
+ item.Desc = fmt.Sprintf("Currently at %.0f%%. At this rate, projected %.0f%% by reset.", quota.Utilization, projected)
+ return item
+}
+
+func (h *Handler) buildOpenCodeCurrent() map[string]interface{} {
+ now := time.Now().UTC()
+ response := map[string]interface{}{
+ "capturedAt": now.Format(time.RFC3339),
+ "quotas": []interface{}{},
+ }
+
+ if h.store == nil {
+ return response
+ }
+
+ latest, err := h.store.QueryLatestOpenCode()
+ if err != nil || latest == nil {
+ return response
+ }
+
+ response["capturedAt"] = latest.CapturedAt.Format(time.RFC3339)
+ response["accountType"] = string(latest.AccountType)
+ response["planName"] = latest.PlanName
+
+ latestPerQuota, err := h.store.QueryOpenCodeLatestPerQuota()
+ if err != nil || len(latestPerQuota) == 0 {
+ for _, q := range latest.Quotas {
+ quotaMap := map[string]interface{}{
+ "name": q.Name,
+ "displayName": opencodeDisplayName(q.Name),
+ "utilization": q.Utilization,
+ "used": q.Used,
+ "limit": q.Limit,
+ "format": string(q.Format),
+ "status": utilStatus(q.Utilization),
+ "lastUpdatedAt": latest.CapturedAt.Format(time.RFC3339),
+ "ageSeconds": int64(now.Sub(latest.CapturedAt).Seconds()),
+ }
+ if q.ResetsAt != nil {
+ timeUntilReset := time.Until(*q.ResetsAt)
+ quotaMap["resetsAt"] = q.ResetsAt.Format(time.RFC3339)
+ quotaMap["timeUntilReset"] = formatDuration(timeUntilReset)
+ quotaMap["timeUntilResetSeconds"] = int64(timeUntilReset.Seconds())
+ }
+ if h.opencodeTracker != nil {
+ if summary, sErr := h.opencodeTracker.UsageSummary(q.Name); sErr == nil && summary != nil {
+ quotaMap["currentRate"] = summary.CurrentRate
+ quotaMap["projectedUtil"] = summary.ProjectedUtil
+ }
+ }
+ response["quotas"] = append(response["quotas"].([]interface{}), quotaMap)
+ }
+ applyDisplayModeToResponse(response, h.getDisplayMode("opencode"))
+ return response
+ }
+
+ sort.SliceStable(latestPerQuota, func(i, j int) bool {
+ left := opencodeQuotaOrder(latestPerQuota[i].Name)
+ right := opencodeQuotaOrder(latestPerQuota[j].Name)
+ if left != right {
+ return left < right
+ }
+ return latestPerQuota[i].Name < latestPerQuota[j].Name
+ })
+
+ var quotas []interface{}
+ for _, q := range latestPerQuota {
+ age := now.Sub(q.CapturedAt)
+ qMap := map[string]interface{}{
+ "name": q.Name,
+ "displayName": opencodeDisplayName(q.Name),
+ "utilization": q.Utilization,
+ "used": q.Used,
+ "limit": q.Limit,
+ "format": q.Format,
+ "status": utilStatus(q.Utilization),
+ "lastUpdatedAt": q.CapturedAt.Format(time.RFC3339),
+ "ageSeconds": int64(age.Seconds()),
+ "isStale": age > 30*time.Minute,
+ }
+ if q.ResetsAt != nil {
+ timeUntilReset := time.Until(*q.ResetsAt)
+ qMap["resetsAt"] = q.ResetsAt.Format(time.RFC3339)
+ qMap["timeUntilReset"] = formatDuration(timeUntilReset)
+ qMap["timeUntilResetSeconds"] = int64(timeUntilReset.Seconds())
+ }
+ if h.opencodeTracker != nil {
+ if summary, sErr := h.opencodeTracker.UsageSummary(q.Name); sErr == nil && summary != nil {
+ qMap["currentRate"] = summary.CurrentRate
+ qMap["projectedUtil"] = summary.ProjectedUtil
+ }
+ }
+ quotas = append(quotas, qMap)
+ }
+ response["quotas"] = quotas
+ applyDisplayModeToResponse(response, h.getDisplayMode("opencode"))
+ return response
+}
+
+func (h *Handler) historyOpenCode(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ if h.store == nil {
+ respondJSON(w, http.StatusOK, []interface{}{})
+ return
+ }
+
+ rangeParam := r.URL.Query().Get("range")
+ if rangeParam == "" {
+ rangeParam = "7d"
+ }
+
+ now := time.Now().UTC()
+ var start time.Time
+ switch rangeParam {
+ case "1h":
+ start = now.Add(-1 * time.Hour)
+ case "6h":
+ start = now.Add(-6 * time.Hour)
+ case "24h", "1d":
+ start = now.Add(-24 * time.Hour)
+ case "3d":
+ start = now.Add(-3 * 24 * time.Hour)
+ case "30d":
+ start = now.Add(-30 * 24 * time.Hour)
+ case "7d":
+ start = now.Add(-7 * 24 * time.Hour)
+ default:
+ start = now.Add(-7 * 24 * time.Hour)
+ }
+
+ snapshots, err := h.store.QueryOpenCodeRange(start, now, 200)
+ if err != nil {
+ h.logger.Error("failed to query OpenCode history", "error", err)
+ respondError(w, http.StatusInternalServerError, "failed to query history")
+ return
+ }
+
+ type historyEntry struct {
+ CapturedAt string `json:"capturedAt"`
+ Quotas []map[string]interface{} `json:"quotas"`
+ }
+
+ result := make([]historyEntry, 0, len(snapshots))
+ for _, snap := range snapshots {
+ entry := historyEntry{
+ CapturedAt: snap.CapturedAt.Format(time.RFC3339),
+ }
+ for _, q := range snap.Quotas {
+ qMap := map[string]interface{}{
+ "name": q.Name,
+ "utilization": q.Utilization,
+ "used": q.Used,
+ "limit": q.Limit,
+ "format": string(q.Format),
+ }
+ if q.ResetsAt != nil {
+ qMap["resetsAt"] = q.ResetsAt.Format(time.RFC3339)
+ }
+ entry.Quotas = append(entry.Quotas, qMap)
+ }
+ result = append(result, entry)
+ }
+
+ respondJSON(w, http.StatusOK, result)
+}
+
+func (h *Handler) cyclesOpenCode(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ if h.store == nil {
+ respondJSON(w, http.StatusOK, []interface{}{})
+ return
+ }
+
+ quotaName := r.URL.Query().Get("type")
+ if quotaName == "" {
+ quotaName = "five_hour"
+ }
+
+ active, err := h.store.QueryActiveOpenCodeCycle(quotaName)
+ if err != nil {
+ h.logger.Error("failed to query active OpenCode cycle", "error", err)
+ respondError(w, http.StatusInternalServerError, "failed to query cycles")
+ return
+ }
+
+ history, err := h.store.QueryOpenCodeCycleHistory(quotaName, 50)
+ if err != nil {
+ h.logger.Error("failed to query OpenCode cycle history", "error", err)
+ respondError(w, http.StatusInternalServerError, "failed to query cycles")
+ return
+ }
+
+ var cycles []map[string]interface{}
+ if active != nil {
+ cycleMap := map[string]interface{}{
+ "id": active.ID,
+ "quotaName": active.QuotaName,
+ "cycleStart": active.CycleStart.Format(time.RFC3339),
+ "cycleEnd": nil,
+ "peakUtilization": active.PeakUtilization,
+ "totalDelta": active.TotalDelta,
+ "isActive": true,
+ }
+ if active.ResetsAt != nil {
+ cycleMap["resetsAt"] = active.ResetsAt.Format(time.RFC3339)
+ cycleMap["timeUntilReset"] = formatDuration(time.Until(*active.ResetsAt))
+ }
+ cycles = append(cycles, cycleMap)
+ }
+
+ for _, c := range history {
+ cycleMap := map[string]interface{}{
+ "id": c.ID,
+ "quotaName": c.QuotaName,
+ "cycleStart": c.CycleStart.Format(time.RFC3339),
+ "cycleEnd": c.CycleEnd.Format(time.RFC3339),
+ "peakUtilization": c.PeakUtilization,
+ "totalDelta": c.TotalDelta,
+ "isActive": false,
+ }
+ if c.ResetsAt != nil {
+ cycleMap["resetsAt"] = c.ResetsAt.Format(time.RFC3339)
+ }
+ cycles = append(cycles, cycleMap)
+ }
+
+ respondJSON(w, http.StatusOK, cycles)
+}
+
+func (h *Handler) cycleOverviewOpenCode(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ if h.store == nil {
+ respondJSON(w, http.StatusOK, []interface{}{})
+ return
+ }
+
+ groupBy := r.URL.Query().Get("group_by")
+ if groupBy == "" {
+ groupBy = "five_hour"
+ }
+
+ overview, err := h.store.QueryOpenCodeCycleOverview(groupBy, 50)
+ if err != nil {
+ h.logger.Error("failed to query OpenCode cycle overview", "error", err)
+ respondError(w, http.StatusInternalServerError, "failed to query cycle overview")
+ return
+ }
+
+ respondJSON(w, http.StatusOK, overview)
+}
+
+func (h *Handler) summaryOpenCode(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ respondJSON(w, http.StatusOK, h.buildOpenCodeSummaryMap())
+}
+
+func (h *Handler) insightsOpenCode(w http.ResponseWriter, _ *http.Request, rangeDur time.Duration) {
+ hidden := h.getHiddenInsightKeys()
+ respondJSON(w, http.StatusOK, h.buildOpenCodeInsights(hidden, rangeDur))
+}
+
+func (h *Handler) opencodeQuotaNames() []string {
+ if h.store == nil {
+ return nil
+ }
+ names, err := h.store.QueryAllOpenCodeQuotaNames()
+ if err != nil {
+ return nil
+ }
+ return names
+}
+
+func (h *Handler) buildOpenCodeSummaryMap() map[string]interface{} {
+ if h.store == nil || h.opencodeTracker == nil {
+ return map[string]interface{}{}
+ }
+
+ quotaNames, err := h.store.QueryAllOpenCodeQuotaNames()
+ if err != nil {
+ h.logger.Error("failed to query OpenCode quota names", "error", err)
+ return map[string]interface{}{}
+ }
+
+ result := make(map[string]interface{})
+ for _, name := range quotaNames {
+ summary, err := h.opencodeTracker.UsageSummary(name)
+ if err != nil || summary == nil {
+ continue
+ }
+ entry := map[string]interface{}{
+ "currentUtil": summary.CurrentUtil,
+ "completedCycles": summary.CompletedCycles,
+ "peakCycle": summary.PeakCycle,
+ "avgPerCycle": summary.AvgPerCycle,
+ "totalTracked": summary.TotalTracked,
+ }
+ if summary.ResetsAt != nil {
+ entry["resetsAt"] = summary.ResetsAt.Format(time.RFC3339)
+ entry["timeUntilReset"] = formatDuration(summary.TimeUntilReset)
+ }
+ result[name] = entry
+ }
+ return result
+}
+
+func (h *Handler) buildOpenCodeInsights(hidden map[string]bool, _ time.Duration) opencodeInsightsResponse {
+ resp := opencodeInsightsResponse{Stats: []opencodeInsightStat{}, Insights: []insightItem{}}
+
+ if h.store == nil {
+ return resp
+ }
+
+ latest, err := h.store.QueryLatestOpenCode()
+ if err != nil || latest == nil || len(latest.Quotas) == 0 {
+ return resp
+ }
+
+ planLabel := latest.PlanName
+ if planLabel == "" {
+ planLabel = string(latest.AccountType)
+ }
+ if planLabel != "" {
+ resp.Stats = append(resp.Stats, opencodeInsightStat{
+ Label: "Plan",
+ Value: planLabel,
+ })
+ }
+
+ quotas := append([]api.OpenCodeQuota(nil), latest.Quotas...)
+ sort.SliceStable(quotas, func(i, j int) bool {
+ left := opencodeQuotaOrder(quotas[i].Name)
+ right := opencodeQuotaOrder(quotas[j].Name)
+ if left != right {
+ return left < right
+ }
+ return quotas[i].Name < quotas[j].Name
+ })
+
+ summaries := map[string]*tracker.OpenCodeSummary{}
+ if h.opencodeTracker != nil {
+ for _, quota := range quotas {
+ summary, err := h.opencodeTracker.UsageSummary(quota.Name)
+ if err == nil && summary != nil {
+ summaries[quota.Name] = summary
+ }
+ }
+ }
+
+ preferredQuotas := []string{"five_hour", "weekly", "monthly"}
+ selected := make([]api.OpenCodeQuota, 0, len(preferredQuotas))
+ for _, name := range preferredQuotas {
+ for _, quota := range quotas {
+ if quota.Name == name {
+ selected = append(selected, quota)
+ break
+ }
+ }
+ }
+ if len(selected) == 0 {
+ selected = quotas
+ }
+
+ for _, quota := range selected {
+ rate := h.computeOpenCodeRate(quota.Name, quota.Utilization, summaries[quota.Name])
+ insightKey := fmt.Sprintf("forecast_%s", quota.Name)
+ if hidden[insightKey] {
+ continue
+ }
+ value := "Analyzing..."
+ if rate.HasRate {
+ value = fmt.Sprintf("%.1f%%/hr", rate.Rate)
+ }
+ insight := buildOpenCodeBurnRateInsight(quota, rate)
+ resp.Stats = append(resp.Stats, opencodeInsightStat{
+ Key: insightKey,
+ Label: fmt.Sprintf("%s Burn Rate", opencodeDisplayName(quota.Name)),
+ Value: value,
+ Sublabel: insight.Sublabel,
+ Metric: insight.Metric,
+ Severity: insight.Severity,
+ Desc: insight.Desc,
+ })
+ }
+
+ return resp
+}
+
+func (h *Handler) loggingHistoryOpenCode(w http.ResponseWriter, r *http.Request) {
+ if h.store == nil {
+ respondJSON(w, http.StatusOK, map[string]interface{}{"logs": []interface{}{}})
+ return
+ }
+
+ start, end, limit := h.loggingHistoryRangeAndLimit(r)
+ snapshots, err := h.store.QueryOpenCodeRange(start, end, limit)
+ if err != nil {
+ h.logger.Error("failed to query OpenCode snapshots", "error", err)
+ respondError(w, http.StatusInternalServerError, "failed to query logging history")
+ return
+ }
+
+ quotaSet := map[string]bool{}
+ for _, snap := range snapshots {
+ for _, q := range snap.Quotas {
+ quotaSet[q.Name] = true
+ }
+ }
+
+ quotaNames := make([]string, 0, len(quotaSet))
+ for qn := range quotaSet {
+ quotaNames = append(quotaNames, qn)
+ }
+ if len(quotaNames) == 0 {
+ quotaNames = []string{"five_hour", "weekly", "monthly"}
+ } else {
+ sort.SliceStable(quotaNames, func(i, j int) bool {
+ left := opencodeQuotaOrder(quotaNames[i])
+ right := opencodeQuotaOrder(quotaNames[j])
+ if left != right {
+ return left < right
+ }
+ return quotaNames[i] < quotaNames[j]
+ })
+ }
+
+ capturedAt := make([]time.Time, 0, len(snapshots))
+ ids := make([]int64, 0, len(snapshots))
+ series := make([]map[string]loggingHistoryCrossQuota, 0, len(snapshots))
+
+ for _, snap := range snapshots {
+ capturedAt = append(capturedAt, snap.CapturedAt)
+ ids = append(ids, snap.ID)
+ row := make(map[string]loggingHistoryCrossQuota, len(snap.Quotas))
+ for _, q := range snap.Quotas {
+ row[q.Name] = loggingHistoryCrossQuota{
+ Name: q.Name,
+ Value: q.Used,
+ Limit: q.Limit,
+ Percent: q.Utilization,
+ HasValue: q.Used > 0 || q.Limit > 0,
+ HasLimit: q.Limit > 0,
+ }
+ }
+ series = append(series, row)
+ }
+
+ respondJSON(w, http.StatusOK, map[string]interface{}{
+ "provider": "opencode",
+ "quotaNames": quotaNames,
+ "logs": loggingHistoryRowsFromSnapshots(capturedAt, ids, quotaNames, series),
+ })
+}
diff --git a/internal/web/opencode_handlers_test.go b/internal/web/opencode_handlers_test.go
new file mode 100644
index 0000000..b80ca2d
--- /dev/null
+++ b/internal/web/opencode_handlers_test.go
@@ -0,0 +1,209 @@
+package web
+
+import (
+ "encoding/json"
+ "log/slog"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+ "time"
+
+ "github.com/onllm-dev/onwatch/v2/internal/api"
+ "github.com/onllm-dev/onwatch/v2/internal/config"
+ "github.com/onllm-dev/onwatch/v2/internal/store"
+ "github.com/onllm-dev/onwatch/v2/internal/tracker"
+)
+
+func createTestConfigWithOpenCode() *config.Config {
+ return &config.Config{
+ OpenCodeGoWorkspaceID: "ws-test",
+ OpenCodeGoAuthCookie: "cookie-test",
+ PollInterval: 60 * time.Second,
+ Port: 9211,
+ AdminUser: "admin",
+ AdminPass: "test",
+ DBPath: "./test.db",
+ }
+}
+
+func insertTestOpenCodeSnapshot(t *testing.T, s *store.Store, capturedAt time.Time, quotas []api.OpenCodeQuota) {
+ t.Helper()
+ snap := &api.OpenCodeSnapshot{
+ CapturedAt: capturedAt,
+ AccountType: api.OpenCodeAccountTypePro,
+ PlanName: "OpenCode Go",
+ Quotas: quotas,
+ }
+ if _, err := s.InsertOpenCodeSnapshot(snap); err != nil {
+ t.Fatalf("failed to insert test OpenCode snapshot: %v", err)
+ }
+}
+
+func TestBuildOpenCodeCurrent_UsesLatestSnapshot(t *testing.T) {
+ s, err := store.New(":memory:")
+ if err != nil {
+ t.Fatalf("store.New: %v", err)
+ }
+ defer s.Close()
+
+ now := time.Now().UTC()
+ reset := now.Add(5 * time.Hour)
+ insertTestOpenCodeSnapshot(t, s, now, []api.OpenCodeQuota{
+ {Name: "five_hour", Utilization: 15, Used: 15, Limit: 100, Format: api.OpenCodeQuotaFormatPercent, ResetsAt: &reset},
+ })
+
+ h := NewHandler(s, nil, nil, nil, createTestConfigWithOpenCode())
+ current := h.buildOpenCodeCurrent()
+
+ if got := current["planName"]; got != "OpenCode Go" {
+ t.Fatalf("planName = %v, want OpenCode Go", got)
+ }
+ quotas, ok := current["quotas"].([]interface{})
+ if !ok || len(quotas) != 1 {
+ t.Fatalf("quotas = %#v, want one quota", current["quotas"])
+ }
+}
+
+func TestCyclesOpenCode_DefaultsToFiveHour(t *testing.T) {
+ s, err := store.New(":memory:")
+ if err != nil {
+ t.Fatalf("store.New: %v", err)
+ }
+ defer s.Close()
+
+ now := time.Now().UTC()
+ reset := now.Add(5 * time.Hour)
+ if _, err := s.CreateOpenCodeCycle("five_hour", now, &reset); err != nil {
+ t.Fatalf("CreateOpenCodeCycle: %v", err)
+ }
+
+ h := NewHandler(s, nil, nil, nil, createTestConfigWithOpenCode())
+ req := httptest.NewRequest(http.MethodGet, "/api/opencode/cycles", nil)
+ rec := httptest.NewRecorder()
+ h.cyclesOpenCode(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want 200", rec.Code)
+ }
+
+ var cycles []map[string]interface{}
+ if err := json.Unmarshal(rec.Body.Bytes(), &cycles); err != nil {
+ t.Fatalf("unmarshal: %v", err)
+ }
+ if len(cycles) != 1 {
+ t.Fatalf("cycles = %d, want 1", len(cycles))
+ }
+ if got := cycles[0]["quotaName"]; got != "five_hour" {
+ t.Fatalf("quotaName = %v, want five_hour", got)
+ }
+}
+
+func TestCycleOverviewOpenCode_DefaultsToFiveHour(t *testing.T) {
+ s, err := store.New(":memory:")
+ if err != nil {
+ t.Fatalf("store.New: %v", err)
+ }
+ defer s.Close()
+
+ now := time.Now().UTC()
+ reset := now.Add(5 * time.Hour)
+ if _, err := s.CreateOpenCodeCycle("five_hour", now, &reset); err != nil {
+ t.Fatalf("CreateOpenCodeCycle: %v", err)
+ }
+
+ h := NewHandler(s, nil, nil, nil, createTestConfigWithOpenCode())
+ req := httptest.NewRequest(http.MethodGet, "/api/opencode/cycle-overview", nil)
+ rec := httptest.NewRecorder()
+ h.cycleOverviewOpenCode(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want 200", rec.Code)
+ }
+
+ var overview []map[string]interface{}
+ if err := json.Unmarshal(rec.Body.Bytes(), &overview); err != nil {
+ t.Fatalf("unmarshal: %v", err)
+ }
+ if len(overview) != 1 {
+ t.Fatalf("overview = %d, want 1", len(overview))
+ }
+ if got := overview[0]["QuotaType"]; got != "five_hour" {
+ t.Fatalf("QuotaType = %v, want five_hour", got)
+ }
+}
+
+func TestBuildOpenCodeSummary_WithTracker(t *testing.T) {
+ s, err := store.New(":memory:")
+ if err != nil {
+ t.Fatalf("store.New: %v", err)
+ }
+ defer s.Close()
+
+ now := time.Now().UTC()
+ reset := now.Add(5 * time.Hour)
+ snap := &api.OpenCodeSnapshot{
+ CapturedAt: now,
+ AccountType: api.OpenCodeAccountTypePro,
+ PlanName: "OpenCode Go",
+ Quotas: []api.OpenCodeQuota{
+ {Name: "five_hour", Utilization: 30, Format: api.OpenCodeQuotaFormatPercent, ResetsAt: &reset},
+ },
+ }
+ if _, err := s.InsertOpenCodeSnapshot(snap); err != nil {
+ t.Fatalf("insert: %v", err)
+ }
+
+ tr := tracker.NewOpenCodeTracker(s, slog.Default())
+ if err := tr.Process(snap); err != nil {
+ t.Fatalf("Process: %v", err)
+ }
+
+ h := NewHandler(s, nil, nil, nil, createTestConfigWithOpenCode())
+ h.SetOpenCodeTracker(tr)
+
+ summary := h.buildOpenCodeSummaryMap()
+ entry, ok := summary["five_hour"].(map[string]interface{})
+ if !ok {
+ t.Fatalf("summary missing five_hour: %#v", summary)
+ }
+ if got := entry["currentUtil"]; got != 30.0 {
+ t.Fatalf("currentUtil = %v, want 30", got)
+ }
+}
+
+func TestBuildOpenCodeCurrent_QuotaUsesLimitKey(t *testing.T) {
+ // The dashboard JS reads `limit` (not `limitValue`). Lock that contract so a
+ // future rename does not silently break the cards (regression: PR #102 frontend
+ // showed "/ 0" because JS read a non-existent limitValue key).
+ s, err := store.New(":memory:")
+ if err != nil {
+ t.Fatalf("store.New: %v", err)
+ }
+ defer s.Close()
+
+ now := time.Now().UTC()
+ reset := now.Add(5 * time.Hour)
+ insertTestOpenCodeSnapshot(t, s, now, []api.OpenCodeQuota{
+ {Name: "five_hour", Utilization: 15, Used: 15, Limit: 100, Format: api.OpenCodeQuotaFormatPercent, ResetsAt: &reset},
+ })
+
+ h := NewHandler(s, nil, nil, nil, createTestConfigWithOpenCode())
+ current := h.buildOpenCodeCurrent()
+
+ quotas, ok := current["quotas"].([]interface{})
+ if !ok || len(quotas) == 0 {
+ t.Fatalf("quotas = %#v, want at least one quota", current["quotas"])
+ }
+ for _, raw := range quotas {
+ q, ok := raw.(map[string]interface{})
+ if !ok {
+ t.Fatalf("quota entry is not a map: %#v", raw)
+ }
+ if _, hasLimit := q["limit"]; !hasLimit {
+ t.Fatalf("quota map missing 'limit' key: %v", q)
+ }
+ if _, hasLimitValue := q["limitValue"]; hasLimitValue {
+ t.Fatalf("quota map must not use 'limitValue' key: %v", q)
+ }
+ }
+}
diff --git a/internal/web/opencode_static_test.go b/internal/web/opencode_static_test.go
new file mode 100644
index 0000000..6cbcd58
--- /dev/null
+++ b/internal/web/opencode_static_test.go
@@ -0,0 +1,29 @@
+package web
+
+import (
+ "strings"
+ "testing"
+)
+
+func TestAppJS_OpenCodeQuotaCardsRerenderWhenQuotaSetChanges(t *testing.T) {
+ t.Parallel()
+
+ appJS := readStaticAppJS(t)
+ if !strings.Contains(appJS, "function openCodeQuotaSetsMatch(container, quotas)") {
+ t.Fatal("OpenCode cards must compare rendered and incoming quota-name sets")
+ }
+
+ updateIdx := strings.Index(appJS, "} else if (provider === 'opencode') {")
+ if updateIdx < 0 {
+ t.Fatal("OpenCode current-usage update branch not found")
+ }
+ updateBody := appJS[updateIdx:]
+ setMatchIdx := strings.Index(updateBody, "!openCodeQuotaSetsMatch(container, data.quotas)")
+ updateCardsIdx := strings.Index(updateBody, "data.quotas.forEach(q => updateOpenCodeCard(q));")
+ if setMatchIdx < 0 {
+ t.Fatal("OpenCode cards must re-render when quota-name sets differ")
+ }
+ if updateCardsIdx < 0 || setMatchIdx > updateCardsIdx {
+ t.Fatal("OpenCode quota set comparison must happen before in-place card updates")
+ }
+}
diff --git a/internal/web/static/app.js b/internal/web/static/app.js
index 93605b9..022b553 100644
--- a/internal/web/static/app.js
+++ b/internal/web/static/app.js
@@ -72,6 +72,8 @@ function getCurrentProvider() {
if (grokGrid) return 'grok';
const kimiGrid = document.getElementById('quota-grid-kimi');
if (kimiGrid) return 'kimi';
+ const opencodeGrid = document.getElementById('quota-grid-opencode');
+ if (opencodeGrid) return 'opencode';
const grid = document.getElementById('quota-grid');
return (grid && grid.dataset.provider) || 'synthetic';
}
@@ -940,6 +942,7 @@ function quotaOrderForProvider(provider) {
if (provider === 'anthropic') return anthropicQuotaOrder;
if (provider === 'codex') return codexQuotaOrder;
if (provider === 'cursor') return cursorQuotaOrder;
+ if (provider === 'opencode') return opencodeQuotaOrder;
return [];
}
@@ -1155,6 +1158,11 @@ const renewalCategories = {
{ label: 'API Usage', groupBy: 'api_usage' },
{ label: 'Credits', groupBy: 'credits' },
{ label: 'On-Demand', groupBy: 'on_demand' }
+ ],
+ opencode: [
+ { label: '5-Hour', groupBy: 'five_hour' },
+ { label: 'Weekly', groupBy: 'weekly' },
+ { label: 'Monthly', groupBy: 'monthly' }
]
};
@@ -3772,6 +3780,158 @@ function updateKimiQuotaCards(quotas, containerId) {
}
});
}
+const opencodeQuotaOrder = ['five_hour', 'weekly', 'monthly'];
+const opencodeDisplayNames = {
+ five_hour: '5-Hour Quota',
+ weekly: 'Weekly Quota',
+ monthly: 'Monthly Quota'
+};
+const opencodeChartColorMap = {
+ five_hour: { border: '#10a37f', bg: 'rgba(16, 163, 127, 0.08)' },
+ weekly: { border: '#2563eb', bg: 'rgba(37, 99, 235, 0.08)' },
+ monthly: { border: '#9333ea', bg: 'rgba(147, 51, 234, 0.08)' }
+};
+const opencodeChartColorFallback = [
+ { border: '#f59e0b', bg: 'rgba(245, 158, 11, 0.08)' },
+ { border: '#ec4899', bg: 'rgba(236, 72, 153, 0.08)' }
+];
+
+function renderOpenCodeQuotaCards(quotas, containerId) {
+ const container = document.getElementById(containerId);
+ if (!container) return;
+
+ if (!Array.isArray(quotas) || quotas.length === 0) {
+ container.innerHTML = 'No OpenCode data available
';
+ return;
+ }
+
+ container.innerHTML = quotas.map((q, i) => {
+ const icon = ' ';
+ const displayName = q.displayName || opencodeDisplayNames[q.name] || q.name;
+ const displayPct = q.cardPercent != null ? q.cardPercent : (q.utilization || 0);
+ const usagePct = displayPct.toFixed(1);
+ const status = q.status || 'healthy';
+ const statusCfg = statusConfig[status] || statusConfig.healthy;
+ const progressId = `progress-opencode-${q.name}`;
+ const percentId = `percent-opencode-${q.name}`;
+ const fractionId = `fraction-opencode-${q.name}`;
+ const statusId = `status-opencode-${q.name}`;
+ const resetId = `reset-opencode-${q.name}`;
+ const countdownId = `countdown-opencode-${q.name}`;
+
+ const cardLabel = q.format === 'currency' ? '$' + (q.used || 0).toFixed(2) + ' / $' + (q.limit || 0).toFixed(2) : (q.used || 0) + ' / ' + (q.limit || 0);
+
+ return `
+
+
+ ${usagePct}%
+ ${cardLabel}
+
+
+
+ `;
+ }).join('');
+}
+
+function openCodeQuotaSetsMatch(container, quotas) {
+ if (!container || !Array.isArray(quotas)) return false;
+
+ const renderedCards = Array.from(container.querySelectorAll('.opencode-card[data-quota]'));
+ const hasRenderedState = renderedCards.length > 0 || container.querySelector('.empty-state') !== null;
+ if (!hasRenderedState) return false;
+
+ const renderedNames = new Set(renderedCards.map(card => card.dataset.quota));
+ const incomingNames = new Set(quotas.map(quota => quota && quota.name).filter(Boolean));
+ if (renderedCards.length !== incomingNames.size || renderedNames.size !== incomingNames.size) return false;
+
+ return Array.from(incomingNames).every(name => renderedNames.has(name));
+}
+
+function updateOpenCodeCard(quota) {
+ const key = `opencode-${quota.name}`;
+ const prev = State.currentQuotas[key];
+ State.currentQuotas[key] = {
+ percent: quota.utilization || 0,
+ used: quota.used || 0,
+ limit: quota.limit || 0,
+ status: quota.status || 'healthy',
+ renewsAt: quota.resetsAt,
+ timeUntilResetSeconds: quota.timeUntilResetSeconds || 0,
+ name: quota.name,
+ displayName: quota.displayName
+ };
+
+ const displayPct = quota.cardPercent != null ? quota.cardPercent : (quota.utilization || 0);
+ const usagePct = displayPct.toFixed(1);
+ const status = quota.status || 'healthy';
+
+ const progressEl = document.getElementById(`progress-opencode-${quota.name}`);
+ const percentEl = document.getElementById(`percent-opencode-${quota.name}`);
+ const fractionEl = document.getElementById(`fraction-opencode-${quota.name}`);
+ const statusEl = document.getElementById(`status-opencode-${quota.name}`);
+ const resetEl = document.getElementById(`reset-opencode-${quota.name}`);
+ const countdownEl = document.getElementById(`countdown-opencode-${quota.name}`);
+
+ if (progressEl) {
+ progressEl.style.width = `${usagePct}%`;
+ progressEl.setAttribute('data-status', status);
+ const bar = progressEl.parentElement;
+ if (bar) bar.setAttribute('aria-valuenow', Math.round(displayPct));
+ }
+ if (percentEl) {
+ const oldVal = prev ? prev.percent : 0;
+ if (Math.abs(oldVal - displayPct) > 0.2) {
+ animateValue(percentEl, oldVal, displayPct, 400, v => `${v.toFixed(1)}%`);
+ } else {
+ percentEl.textContent = `${usagePct}%`;
+ }
+ }
+ if (fractionEl) {
+ const cardLabel = quota.format === 'currency' ? '$' + (quota.used || 0).toFixed(2) + ' / $' + (quota.limit || 0).toFixed(2) : (quota.used || 0) + ' / ' + (quota.limit || 0);
+ fractionEl.textContent = cardLabel;
+ }
+ if (statusEl) {
+ const config = statusConfig[status] || statusConfig.healthy;
+ statusEl.setAttribute('data-status', status);
+ statusEl.innerHTML = ` ${config.label}`;
+ }
+ if (resetEl) {
+ if (quota.resetsAt) {
+ resetEl.setAttribute('data-reset-at', quota.resetsAt);
+ resetEl.textContent = formatResetTime(quota.resetsAt);
+ } else {
+ resetEl.removeAttribute('data-reset-at');
+ resetEl.textContent = '';
+ }
+ }
+ if (countdownEl) {
+ if (quota.timeUntilResetSeconds > 0) {
+ countdownEl.textContent = formatDuration(quota.timeUntilResetSeconds);
+ countdownEl.classList.toggle('imminent', quota.timeUntilResetSeconds < 1800);
+ countdownEl.style.display = '';
+ } else {
+ countdownEl.style.display = 'none';
+ }
+ }
+}
+
+
async function fetchCurrent() {
const requestProvider = getCurrentProvider();
@@ -3935,6 +4095,17 @@ async function fetchCurrent() {
updateKimiQuotaCards(data.quotas || [], 'quota-grid-kimi');
}
}
+ } else if (provider === 'opencode') {
+ if (data.quotas) {
+ const container = document.getElementById('quota-grid-opencode');
+ if (container && !openCodeQuotaSetsMatch(container, data.quotas)) {
+ renderOpenCodeQuotaCards(data.quotas, 'quota-grid-opencode');
+ }
+ if (Array.isArray(data.quotas) && data.quotas.length > 0) {
+ data.quotas.forEach(q => updateOpenCodeCard(q));
+ }
+ }
+
} else if (provider === 'zai') {
updateCard('tokensLimit', data.tokensLimit);
updateCard('timeLimit', data.timeLimit);
@@ -4937,6 +5108,8 @@ function initChart() {
defaultDatasets = []; // Grok datasets are dynamic - populated when history data arrives
} else if (provider === 'kimi') {
defaultDatasets = []; // Kimi datasets are dynamic - populated when history data arrives
+ } else if (provider === 'opencode') {
+ defaultDatasets = []; // OpenCode datasets are dynamic
} else if (provider === 'zai') {
defaultDatasets = [
{ label: 'Tokens Limit', data: [], borderColor: getComputedStyle(document.documentElement).getPropertyValue('--chart-subscription').trim() || '#0D9488', backgroundColor: 'rgba(13, 148, 136, 0.06)', fill: true, tension: 0.4, borderWidth: 2, pointRadius: 0, pointHoverRadius: 4, hidden: State.hiddenQuotas.has('tokensLimit') },
@@ -4964,6 +5137,8 @@ function initChart() {
? []
: provider === 'kimi'
? []
+ : provider === 'opencode'
+ ? []
: provider === 'api-integrations'
? []
: ['subscription', 'search', 'toolCalls'];
@@ -5492,6 +5667,34 @@ async function fetchHistory(range) {
State.chart.update();
return;
}
+ if (provider === 'opencode') {
+ const flattenedRows = historyRows.map(row => {
+ const flat = { capturedAt: row.capturedAt };
+ if (Array.isArray(row.quotas)) {
+ row.quotas.forEach(q => { flat[q.name] = q.utilization; });
+ }
+ return flat;
+ });
+ const style = getComputedStyle(document.documentElement);
+ const datasets = [];
+ const configs = [
+ { label: '5-Hour Limit', key: 'five_hour', hiddenKey: 'five_hour', color: style.getPropertyValue('--chart-subscription').trim() || '#0D9488', bg: 'rgba(13, 148, 136, 0.06)' },
+ { label: 'Weekly Limit', key: 'weekly', hiddenKey: 'weekly', color: style.getPropertyValue('--chart-search').trim() || '#F59E0B', bg: 'rgba(245, 158, 11, 0.06)' },
+ { label: 'Monthly Limit', key: 'monthly', hiddenKey: 'monthly', color: style.getPropertyValue('--chart-toolcalls').trim() || '#3B82F6', bg: 'rgba(59, 130, 246, 0.06)' }
+ ];
+ configs.forEach(cfg => {
+ const rawData = flattenedRows.map(d => ({ x: new Date(d.capturedAt), y: d[cfg.key] || 0 }));
+ const { data, gapSegments, pointRadii } = processDataWithGaps(rawData, range);
+ datasets.push({ label: cfg.label, data: data, borderColor: cfg.color, backgroundColor: cfg.bg, fill: true, tension: 0.4, borderWidth: 2, pointRadius: pointRadii, pointHoverRadius: 4, hidden: State.hiddenQuotas.has(cfg.hiddenKey), spanGaps: true, segment: getSegmentStyle(gapSegments, cfg.color) });
+ });
+ State.chart.data.datasets = datasets;
+ updateTimeScale(State.chart, range);
+ State.chartYMax = computeYMax(State.chart.data.datasets, State.chart);
+ State.chart.options.scales.y.max = State.chartYMax;
+ State.chart.update();
+ return;
+ }
+
if (provider === 'codex') {
// Codex history: array of { capturedAt, five_hour, seven_day, ... }
@@ -5580,6 +5783,7 @@ const bothProviderNames = {
cursor: 'Cursor',
grok: 'Grok',
kimi: 'Kimi Code',
+ opencode: 'OpenCode',
'api-integrations': 'API Integrations',
};
@@ -5915,7 +6119,7 @@ function buildAllProviderEntries() {
title: bothProviderNames[provider] || toTitleCase(provider),
badge: provider === 'copilot'
? 'Beta'
- : (provider === 'cursor'
+ : (provider === 'cursor' || provider === 'opencode'
? (payload.planName || toTitleCase(payload.accountType || ''))
: toTitleCase(payload.planType || '')),
promoHtml: provider === 'anthropic' && payload.promo ? promoTagHTML() : '',
@@ -6452,7 +6656,7 @@ function buildProviderCardDatasets(provider, rows, range) {
if (provider === 'gemini') {
return buildDynamicDatasetsForRows(rows, range, geminiDisplayNames, geminiChartColorMap, geminiChartColorFallback, 'gemini');
}
- if (provider === 'cursor') {
+ if (provider === 'cursor' || provider === 'opencode') {
const normalizedRows = rows.map((row) => {
if (!Array.isArray(row.quotas)) return row;
const entry = { capturedAt: row.capturedAt };
@@ -6461,7 +6665,9 @@ function buildProviderCardDatasets(provider, rows, range) {
});
return entry;
});
- return buildDynamicDatasetsForRows(normalizedRows, range, cursorDisplayNames, cursorChartColorMap, cursorChartColorFallback, 'cursor');
+ return provider === 'cursor'
+ ? buildDynamicDatasetsForRows(normalizedRows, range, cursorDisplayNames, cursorChartColorMap, cursorChartColorFallback, 'cursor')
+ : buildDynamicDatasetsForRows(normalizedRows, range, opencodeDisplayNames, opencodeChartColorMap, opencodeChartColorFallback, 'opencode');
}
if (provider === 'openrouter') {
const orDisplayNames = { usage: 'Total Usage', usageDaily: 'Daily Usage', percent: 'Usage %' };
@@ -6979,7 +7185,7 @@ async function fetchCycles() {
const requestSeq = (State.cyclesRequestSeq || 0) + 1;
State.cyclesRequestSeq = requestSeq;
const provider = requestProvider;
- const loggingHistoryProviders = new Set(['synthetic', 'zai', 'anthropic', 'copilot', 'codex', 'antigravity', 'minimax', 'gemini', 'cursor', 'grok', 'kimi']);
+ const loggingHistoryProviders = new Set(['synthetic', 'zai', 'anthropic', 'copilot', 'codex', 'antigravity', 'minimax', 'gemini', 'cursor', 'grok', 'kimi', 'opencode']);
// All-accounts overview: fetch each account's logging history and merge,
// tagging every row with its account name for the combined table.
@@ -7173,7 +7379,7 @@ function renderCyclesTable() {
const provider = getCurrentProvider();
const quotaNames = State.cyclesQuotaNames;
- const usePercent = provider === 'anthropic' || provider === 'copilot' || provider === 'codex' || provider === 'antigravity' || provider === 'minimax' || provider === 'gemini' || provider === 'openrouter' || provider === 'cursor' || provider === 'grok' || provider === 'kimi' || provider === 'moonshot' || provider === 'deepseek';
+ const usePercent = provider === 'anthropic' || provider === 'copilot' || provider === 'codex' || provider === 'antigravity' || provider === 'minimax' || provider === 'gemini' || provider === 'openrouter' || provider === 'cursor' || provider === 'grok' || provider === 'kimi' || provider === 'moonshot' || provider === 'deepseek' || provider === 'opencode';
const deltaUsesPercent = usePercent && provider !== 'minimax' && provider !== 'moonshot' && provider !== 'deepseek';
const isLoggingHistory = State.isLoggingHistory === true;
const showAccount = isAccountsOverviewMode(provider);
@@ -8535,7 +8741,7 @@ function renderOverviewTable() {
const quotaNames = State.overviewQuotaNames;
const overviewProv = getOverviewProvider();
- const usePercent = overviewProv === 'anthropic' || overviewProv === 'codex' || overviewProv === 'antigravity' || overviewProv === 'minimax' || overviewProv === 'gemini' || overviewProv === 'openrouter' || overviewProv === 'cursor' || overviewProv === 'grok' || overviewProv === 'kimi';
+ const usePercent = overviewProv === 'anthropic' || overviewProv === 'codex' || overviewProv === 'antigravity' || overviewProv === 'minimax' || overviewProv === 'gemini' || overviewProv === 'openrouter' || overviewProv === 'cursor' || overviewProv === 'grok' || overviewProv === 'kimi' || overviewProv === 'opencode';
const deltaUsesPercent = usePercent && overviewProv !== 'minimax';
// MiniMax reports a percentage-based quota; the Duration and Total Delta
// columns add no signal there, so omit them for this provider.
@@ -9642,6 +9848,7 @@ const DEFAULT_PROVIDER_TAB_LABELS = {
cursor: 'Cursor',
grok: 'Grok',
kimi: 'Kimi',
+ opencode: 'OpenCode',
'api-integrations': 'API Integrations',
both: 'All',
};
@@ -10353,6 +10560,14 @@ const providerSettingsConfig = {
desc: 'Gemini is auto-detected from your local credentials. Use the telemetry toggle to enable or disable tracking.',
fields: [],
},
+ opencode: {
+ title: 'OpenCode Go',
+ desc: 'Configure OpenCode Go quota tracking. Changes take effect after daemon restart.',
+ fields: [
+ { id: 'workspace_id', label: 'Workspace ID', type: 'text', placeholder: 'wrk_...', hint: 'Your OpenCode Go workspace ID. Overrides OPENCODE_GO_WORKSPACE_ID from .env.' },
+ { id: 'auth_cookie', label: 'Auth Cookie', type: 'password', placeholder: 'Not configured', hint: 'The auth cookie value required for scraping the dashboard. Overrides OPENCODE_GO_AUTH_COOKIE from .env.', sensitive: true },
+ ],
+ },
};
async function openProviderSettingsModal(providerKey) {
@@ -11363,6 +11578,11 @@ const _overrideQuotasByProvider = {
{ key: 'tokens', label: 'Tokens Limit' },
{ key: 'time', label: 'Time Limit' },
],
+ opencode: [
+ { key: 'five_hour', label: '5-Hour Limit' },
+ { key: 'weekly', label: 'Weekly Limit' },
+ { key: 'monthly', label: 'Monthly Limit' },
+ ],
};
function _isAbsoluteProvider(provider) {
diff --git a/internal/web/static/icons/opencode.svg b/internal/web/static/icons/opencode.svg
new file mode 100644
index 0000000..8456d44
--- /dev/null
+++ b/internal/web/static/icons/opencode.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/internal/web/static/menubar.html b/internal/web/static/menubar.html
index b65329b..78271f0 100644
--- a/internal/web/static/menubar.html
+++ b/internal/web/static/menubar.html
@@ -381,6 +381,11 @@
mask-image: url("/static/icons/kimi.svg");
}
+ .provider-icon-opencode {
+ -webkit-mask-image: url("/static/icons/opencode.svg");
+ mask-image: url("/static/icons/opencode.svg");
+ }
+
.provider-icon-moonshot {
-webkit-mask-image: url("/static/icons/moonshot.svg");
mask-image: url("/static/icons/moonshot.svg");
@@ -2722,6 +2727,8 @@ Provider Order
return "provider-icon-grok";
case "kimi":
return "provider-icon-kimi";
+ case "opencode":
+ return "provider-icon-opencode";
case "moonshot":
return "provider-icon-moonshot";
case "deepseek":
diff --git a/internal/web/templates/dashboard.html b/internal/web/templates/dashboard.html
index 982006a..a9ab2c0 100644
--- a/internal/web/templates/dashboard.html
+++ b/internal/web/templates/dashboard.html
@@ -137,6 +137,9 @@ Dashboard
{{else if eq .CurrentProvider "kimi"}}
+ {{else if eq .CurrentProvider "opencode"}}
+
+
{{else if eq .CurrentProvider "api-integrations"}}
@@ -491,7 +494,7 @@
{{end}}
- {{if and (ne .CurrentProvider "both") (ne .CurrentProvider "api-integrations") (ne .CurrentProvider "cursor")}}
+ {{if and (ne .CurrentProvider "both") (ne .CurrentProvider "api-integrations") (ne .CurrentProvider "cursor") (ne .CurrentProvider "opencode")}}
@@ -665,11 +668,11 @@
- {{if eq .CurrentProvider "cursor"}}Billing Cycle Overview{{else}}Cycle Overview{{end}}
+ {{if or (eq .CurrentProvider "cursor") (eq .CurrentProvider "opencode")}}Billing Cycle Overview{{else}}Cycle Overview{{end}}
-
{{if eq .CurrentProvider "cursor"}}Quota{{else}}Period{{end}}
+
{{if or (eq .CurrentProvider "cursor") (eq .CurrentProvider "opencode")}}Quota{{else}}Period{{end}}
@@ -684,13 +687,13 @@
Loading...
- {{if eq .CurrentProvider "cursor"}}Select a quota to compare monthly billing cycles.{{else}}Select a renewal period to view cross-quota data.{{end}}
+ {{if or (eq .CurrentProvider "cursor") (eq .CurrentProvider "opencode")}}Select a quota to compare monthly billing cycles.{{else}}Select a renewal period to view cross-quota data.{{end}}
diff --git a/main.go b/main.go
index d5f67d0..bef365c 100644
--- a/main.go
+++ b/main.go
@@ -1088,6 +1088,10 @@ func run() error {
if cfg.HasProvider("kimi") {
kimiTr = tracker.NewKimiTracker(db, logger)
}
+ var opencodeTr *tracker.OpenCodeTracker
+ if cfg.HasProvider("opencode") {
+ opencodeTr = tracker.NewOpenCodeTracker(db, logger)
+ }
var antigravityAg *agent.AntigravityAgent
if antigravityClient != nil {
@@ -1170,6 +1174,12 @@ func run() error {
kimiSm := agent.NewSessionManager(db, "kimi", idleTimeout, logger)
kimiAg = agent.NewKimiAgent(kimiClient, db, kimiTr, cfg.PollInterval, logger, kimiSm)
}
+ var opencodeAg *agent.OpenCodeAgent
+ if cfg.HasProvider("opencode") {
+ opencodeClient := api.NewOpenCodeClient(logger)
+ opencodeSm := agent.NewSessionManager(db, "opencode", idleTimeout, logger)
+ opencodeAg = agent.NewOpenCodeAgent(opencodeClient, db, opencodeTr, cfg, cfg.PollInterval, logger, opencodeSm)
+ }
var apiIntegrationsAg *agent.APIIntegrationsIngestAgent
if cfg.APIIntegrationsEnabled {
@@ -1227,6 +1237,9 @@ func run() error {
if kimiAg != nil {
kimiAg.SetNotifier(notifier)
}
+ if opencodeAg != nil {
+ opencodeAg.SetNotifier(notifier)
+ }
// Wire polling checks - agents skip poll when telemetry disabled
isPollingEnabled := func(providerKey string) bool {
@@ -1387,6 +1400,9 @@ func run() error {
if kimiAg != nil {
kimiAg.SetPollingCheck(func() bool { return isPollingEnabled("kimi") })
}
+ if opencodeAg != nil {
+ opencodeAg.SetPollingCheck(func() bool { return isPollingEnabled("opencode") })
+ }
// Wire reset callbacks to trackers
tr.SetOnReset(func(quotaName string) {
@@ -1457,6 +1473,11 @@ func run() error {
notifier.Check(notify.QuotaStatus{Provider: "kimi", QuotaKey: quotaName, ResetOccurred: true})
})
}
+ if opencodeTr != nil {
+ opencodeTr.SetOnReset(func(quotaName string) {
+ notifier.Check(notify.QuotaStatus{Provider: "opencode", QuotaKey: quotaName, ResetOccurred: true})
+ })
+ }
handler := web.NewHandler(db, tr, logger, nil, cfg, zaiTr)
handler.SetVersion(version)
@@ -1497,6 +1518,9 @@ func run() error {
if kimiTr != nil {
handler.SetKimiTracker(kimiTr)
}
+ if opencodeTr != nil {
+ handler.SetOpenCodeTracker(opencodeTr)
+ }
agentMgr := agent.NewAgentManager(logger)
if ag != nil {
agentMgr.RegisterFactory("synthetic", func() (agent.AgentRunner, error) { return ag, nil })
@@ -1540,6 +1564,9 @@ func run() error {
if kimiAg != nil {
agentMgr.RegisterFactory("kimi", func() (agent.AgentRunner, error) { return kimiAg, nil })
}
+ if opencodeAg != nil {
+ agentMgr.RegisterFactory("opencode", func() (agent.AgentRunner, error) { return opencodeAg, nil })
+ }
if apiIntegrationsAg != nil {
agentMgr.RegisterFactory("api_integrations", func() (agent.AgentRunner, error) { return apiIntegrationsAg, nil })
@@ -1566,7 +1593,7 @@ func run() error {
// Start configured agents through the manager.
startedAny := false
- for _, providerKey := range []string{"synthetic", "zai", "anthropic", "copilot", "codex", "antigravity", "minimax", "openrouter", "gemini", "cursor", "grok", "kimi", "moonshot", "deepseek"} {
+ for _, providerKey := range []string{"synthetic", "zai", "anthropic", "copilot", "codex", "antigravity", "minimax", "openrouter", "gemini", "cursor", "grok", "kimi", "moonshot", "deepseek", "opencode"} {
if !isPollingEnabled(providerKey) {
continue
}