Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
# onWatch

Go CLI for AI quota tracking. Polls 18 providers → SQLite → Material Design 3 dashboard.
Go CLI for AI quota tracking. Polls 19 providers → SQLite → Material Design 3 dashboard.

## Task

Background daemon (<50MB RAM) tracking: Anthropic, Synthetic, Z.ai, Copilot, Codex, MiniMax, Antigravity, Gemini, Cursor, Kimi Code, Grok, Moonshot, DeepSeek, OpenRouter, OpenCode Go, Ollama Cloud, Muse, Command Code.
Background daemon (<50MB RAM) tracking: Anthropic, Synthetic, Z.ai, Copilot, Codex, MiniMax, Antigravity, Gemini, Cursor, Kimi Code, Grok, Moonshot, DeepSeek, OpenRouter, OpenCode Go, Ollama Cloud, Muse, Command Code, Mistral.

## Code Map

Expand Down Expand Up @@ -74,7 +74,7 @@ On `go.sum` changes, update `vendorHash` in `flake.nix` (run `nix build .#onwatc
5. Add to `internal/web/handlers.go` endpoints
6. Update dashboard JS in `internal/web/static/app.js`

**API Docs:** See `docs/` for provider-specific setup (COPILOT_SETUP.md, CODEX_SETUP.md, ANTIGRAVITY_SETUP.md, GEMINI_SETUP.md, CURSOR_SETUP.md, KIMI_SETUP.md, GROK_SETUP.md, MOONSHOT_SETUP.md, DEEPSEEK_SETUP.md, OPENCODE_SETUP.md, OLLAMA_SETUP.md, MUSE_SETUP.md, COMMANDCODE_SETUP.md) and WEBHOOK_SETUP.md for outbound HTTP notifications
**API Docs:** See `docs/` for provider-specific setup (COPILOT_SETUP.md, CODEX_SETUP.md, ANTIGRAVITY_SETUP.md, GEMINI_SETUP.md, CURSOR_SETUP.md, KIMI_SETUP.md, GROK_SETUP.md, MOONSHOT_SETUP.md, DEEPSEEK_SETUP.md, OPENCODE_SETUP.md, OLLAMA_SETUP.md, MUSE_SETUP.md, COMMANDCODE_SETUP.md, MISTRAL_SETUP.md) and WEBHOOK_SETUP.md for outbound HTTP notifications

**Containers:** `IsDockerEnvironment()` in `config.go` detects Docker/K8s. Containers run foreground only.

Expand Down
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,7 @@ It is a thin client: it needs the onWatch daemon running (steps above) and finds
- **Moonshot** -- Balance-based tracking for the Moonshot (Kimi) open-platform API. Available, Voucher, and Cash balance cards with drop-rate trends. Set `MOONSHOT_API_KEY`. See [Moonshot Setup](docs/MOONSHOT_SETUP.md).
- **DeepSeek** -- Balance-based tracking for the DeepSeek platform API. Total, Granted, and Topped-Up balance cards with drop-rate trends. Set `DEEPSEEK_API_KEY`. See [DeepSeek Setup](docs/DEEPSEEK_SETUP.md).
- **OpenCode Go** -- Subscription quota cards (5-Hour, Weekly, and Monthly when present) read from the plan's own meters via the OpenCode console API (set `OPENCODE_GO_API_KEY`, a service-account key) or, as a legacy fallback, scraped from the authenticated dashboard (`OPENCODE_GO_WORKSPACE_ID` + `OPENCODE_GO_AUTH_COOKIE`), with cycle history and deep insights. Separate from `OPENCODE_ENABLED`, which only feeds ChatGPT credentials into the Codex provider. See [OpenCode Setup](docs/OPENCODE_SETUP.md).
- **Mistral** (beta) - Separate included API and Vibe Code allowances plus pay-as-you-go charges, with browser-cookie import or manual authentication. See [Mistral Setup](docs/MISTRAL_SETUP.md).
- **Ollama Cloud** (beta) -- Included monthly usage in USD from the ollama.com API with plan-derived caps, per-model request counts, extra-usage spend, cycle history and insights. Set `OLLAMA_API_KEY`. See [Ollama Setup](docs/OLLAMA_SETUP.md).
- **Muse** -- Meta Muse coding-plan quota tracking (5-hour prompts + weekly usage) from the same subscription snapshot `muse /usage` shows, via one minimal probe per poll. Opt-in: set `MUSE_ENABLED=true` and onWatch uses the key `muse login` stored (macOS Keychain / login file), or set `META_API_KEY` directly. Tracking stays off until you opt in, because each poll spends a prompt from your own 5h window. See [Muse Setup](docs/MUSE_SETUP.md).
- **Command Code** -- Credit balance plus the 5-hour and weekly rate-limit windows from the Command Code API, with billing-period cost, request and token totals. Auto-detected from the `cmd` CLI login (or the pi / OMP auth store); set `COMMAND_CODE_API_KEY` for Docker and headless hosts. Polling reads billing endpoints only, so it spends no credits. See [Command Code Setup](docs/COMMANDCODE_SETUP.md).
Expand Down Expand Up @@ -366,6 +367,11 @@ Additional environment variables:
| `OPENCODE_GO_API_KEY` | OpenCode console service-account key with usage read access (enables quota tracking; preferred)|
| `OPENCODE_GO_WORKSPACE_ID` | Legacy scrape mode: OpenCode Go workspace ID (`wrk_...`) from the dashboard URL|
| `OPENCODE_GO_AUTH_COOKIE` | Legacy scrape mode: OpenCode Go `auth` cookie value|
| `MISTRAL_ENABLED` | Enable Mistral subscription and pay-as-you-go tracking (default: false) |
| `MISTRAL_AUTH_COOKIE` | Manual Mistral Cookie header; keep private |
| `MISTRAL_BROWSER` | auto, chrome, firefox, safari (macOS), or edge |
| `MISTRAL_BROWSER_PROFILE` | Browser profile path/name; Firefox supports `::container=ID` |
| `MISTRAL_RETENTION` | How long Mistral rows are kept in SQLite (default: `2160h` = 90 days, `0` disables pruning) |
| `OLLAMA_API_KEY` | Ollama Cloud API key from ollama.com/settings/keys (enables usage tracking)|
| `OLLAMA_MONTHLY_LIMIT` | Ollama included usage cap in USD (0 = derive from plan)|
| `OLLAMA_RESET_DAY` | Ollama reset day of month, 1-31 (0 = learn from the first observed reset, starting from the account anniversary)|
Expand Down
15 changes: 14 additions & 1 deletion cmd/onwatch/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -1946,6 +1946,14 @@ func run() error {
if kimiAg != nil {
agentMgr.RegisterFactory("kimi", func() (agent.AgentRunner, error) { return kimiAg, nil })
}
if cfg.HasProvider("mistral") {
agentMgr.RegisterFactory("mistral", func() (agent.AgentRunner, error) {
mistralAg := agent.NewMistralAgent(db, cfg, logger)
mistralAg.SetNotifier(notifier)
mistralAg.SetPollingCheck(func() bool { return isPollingEnabled("mistral") })
return mistralAg, nil
})
}
if opencodeAg != nil {
agentMgr.RegisterFactory("opencode", func() (agent.AgentRunner, error) { return opencodeAg, nil })
}
Expand Down Expand Up @@ -2003,7 +2011,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", "opencode", "ollama", "muse", "commandcode"} {
for _, providerKey := range []string{"synthetic", "zai", "anthropic", "copilot", "codex", "antigravity", "minimax", "openrouter", "gemini", "cursor", "grok", "kimi", "moonshot", "deepseek", "opencode", "mistral", "ollama", "muse", "commandcode"} {
if !isPollingEnabled(providerKey) {
continue
}
Expand Down Expand Up @@ -2615,6 +2623,11 @@ func printHelp() {
fmt.Println(" MINIMAX_API_KEY MiniMax API key")
fmt.Println(" MINIMAX_REGION MiniMax region: global or cn (default: global)")
fmt.Println(" OPENROUTER_API_KEY OpenRouter API key")
fmt.Println(" MISTRAL_ENABLED Enable Mistral subscription and pay-as-you-go tracking")
fmt.Println(" MISTRAL_AUTH_COOKIE Manual Mistral Cookie header (keep private)")
fmt.Println(" MISTRAL_BROWSER auto, chrome, firefox, safari, or edge")
fmt.Println(" MISTRAL_BROWSER_PROFILE Browser profile path/name; optional ::container=ID")
fmt.Println(" MISTRAL_RETENTION How long Mistral history is kept (default 2160h; 0 disables)")
fmt.Println(" OLLAMA_API_KEY Ollama Cloud API key (ollama.com/settings/keys)")
fmt.Println(" OLLAMA_MONTHLY_LIMIT Ollama included usage cap in USD (0 = derive from plan)")
fmt.Println(" OLLAMA_RESET_DAY Ollama reset day of month (1-31; 0 = account anniversary)")
Expand Down
70 changes: 70 additions & 0 deletions docs/MISTRAL_SETUP.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
# Mistral Setup Guide

Track your Mistral subscription allowance (Included API + Included Vibe Code) and pay-as-you-go spend in onWatch.

Mistral has no public usage API, so onWatch reads your usage the way the browser does: it imports your signed-in session cookie and reads your subscription page directly. That's different from most other providers here, which just need an API key - Mistral needs a browser session instead.

## Enable tracking

1. Sign in at https://admin.mistral.ai/subscription in a supported browser.
2. In onWatch, go to Settings > Providers > Mistral and turn it on.
3. Pick an authentication method (below) and restart onWatch.

### Automatic browser import (recommended)

onWatch reads your session cookie straight from Chrome, Firefox, or another supported browser - no copy/pasting, and it re-imports automatically as your session refreshes. Set `MISTRAL_BROWSER` to your browser if it doesn't detect one automatically.

### Manual cookie

Settings > Providers > Mistral > Manual, then paste your Mistral Cookie header. No browser or keychain access needed - useful for Docker, headless servers, or if automatic import doesn't work for you. You'll need to re-paste it when the session expires. Never commit a cookie header to source control.

## macOS permissions

Automatic import needs two one-time permissions on macOS:

1. **Browser folder access.** macOS blocks background apps from reading another app's data folder. Right-click the onWatch tray icon and choose **Grant Browser Access...**, then pick your browser in the folder panel. A locally rebuilt binary will need re-granting.
2. **Keychain access.** You'll see a prompt that `security` wants to use **Chrome Safe Storage** - this decrypts the cookie file, it's not asking for your Mistral password. Enter your Mac login password and choose **Allow** (or **Allow Once** if you'd rather be asked again next time).

Prefer to skip both prompts? Sign into Mistral in Firefox instead and select Firefox as your browser, or use manual cookie mode.

## Other platforms

- **Linux:** Chromium import needs an unlocked desktop keyring (D-Bus/GNOME/KWallet), which headless services usually don't have. Use Firefox or manual cookies instead.
- **Windows:** Chromium cookies are supported via DPAPI; app-bound encryption on newer Chrome builds can block this. Use Firefox or manual cookies if it fails.
- **Safari:** may require Full Disk Access, or point `MISTRAL_BROWSER_PROFILE` at a specific `Cookies.binarycookies` file.
- **Firefox containers:** append `::container=<id>` to `MISTRAL_BROWSER_PROFILE` to pick a specific container.
- **Docker:** use manual cookies (`MISTRAL_AUTH_COOKIE`) rather than mounting a browser profile into the container.

## Configuration reference

```dotenv
MISTRAL_ENABLED=true
MISTRAL_BROWSER=chrome
# Optional profile directory/name; Firefox containers use ::container=ID
MISTRAL_BROWSER_PROFILE=Default
# Manual mode can instead supply MISTRAL_AUTH_COOKIE privately.
# How long stored Mistral history is kept; 0 disables pruning.
MISTRAL_RETENTION=2160h
```

Settings saved in onWatch override these environment variables. The cookie field is masked in settings responses.

## What's tracked

**Included API** and **Included Vibe Code** are tracked as separate allowances, each with currency, used amount, limit, remaining amount, percentage, and reset date.

**Pay-as-you-go spend** is tracked separately from your subscription allowances. If you're not on PAYG, hide it from the dashboard: Settings > Providers > Mistral > **Show pay-as-you-go spend** > **Hide**.

## Refresh behaviour

onWatch polls every 120 seconds by default. If your session is rejected, it retries the import once immediately; after that it pauses polling until a fresh login is detected, without switching to a different account on its own. Usage history is kept for 90 days by default - change this with `MISTRAL_RETENTION` (a Go duration like `720h`, or `0` to keep everything).

## Testing

Opt-in live check against your own signed-in session (may prompt for browser/keychain access):

```sh
ONWATCH_MISTRAL_LIVE=1 GOFLAGS='-run=TestMistralLive -v' ./app.sh --test
```

The default test suite uses synthetic data only. A synthetic dashboard preview is also available with `ONWATCH_MISTRAL_PREVIEW=1 GOFLAGS='-run=TestMistralPreview -v' ./app.sh --test`.
15 changes: 8 additions & 7 deletions extensions/vscode/media/codepoints.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,12 @@
"grok": 57353,
"kimi": 57354,
"minimax": 57355,
"moonshot": 57356,
"ollama": 57357,
"openai": 57358,
"opencode": 57359,
"openrouter": 57360,
"synthetic": 57361,
"zai": 57362
"mistral": 57356,
"moonshot": 57357,
"ollama": 57358,
"openai": 57359,
"opencode": 57360,
"openrouter": 57361,
"synthetic": 57362,
"zai": 57363
}
1 change: 1 addition & 0 deletions extensions/vscode/media/icons/mistral.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified extensions/vscode/media/onwatch-icons.woff
Binary file not shown.
21 changes: 14 additions & 7 deletions extensions/vscode/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -316,53 +316,60 @@
"fontCharacter": "\\E00B"
}
},
"onwatch-mistral": {
"description": "onWatch mistral mark",
"default": {
"fontPath": "./media/onwatch-icons.woff",
"fontCharacter": "\\E00C"
}
},
"onwatch-moonshot": {
"description": "onWatch Moonshot mark",
"default": {
"fontPath": "./media/onwatch-icons.woff",
"fontCharacter": "\\E00C"
"fontCharacter": "\\E00D"
}
},
"onwatch-ollama": {
"description": "onWatch ollama mark",
"default": {
"fontPath": "./media/onwatch-icons.woff",
"fontCharacter": "\\E00D"
"fontCharacter": "\\E00E"
}
},
"onwatch-openai": {
"description": "onWatch OpenAI mark",
"default": {
"fontPath": "./media/onwatch-icons.woff",
"fontCharacter": "\\E00E"
"fontCharacter": "\\E00F"
}
},
"onwatch-opencode": {
"description": "onWatch OpenCode mark",
"default": {
"fontPath": "./media/onwatch-icons.woff",
"fontCharacter": "\\E00F"
"fontCharacter": "\\E010"
}
},
"onwatch-openrouter": {
"description": "onWatch OpenRouter mark",
"default": {
"fontPath": "./media/onwatch-icons.woff",
"fontCharacter": "\\E010"
"fontCharacter": "\\E011"
}
},
"onwatch-synthetic": {
"description": "onWatch Synthetic mark",
"default": {
"fontPath": "./media/onwatch-icons.woff",
"fontCharacter": "\\E011"
"fontCharacter": "\\E012"
}
},
"onwatch-zai": {
"description": "onWatch Z.ai mark",
"default": {
"fontPath": "./media/onwatch-icons.woff",
"fontCharacter": "\\E012"
"fontCharacter": "\\E013"
}
}
}
Expand Down
1 change: 1 addition & 0 deletions extensions/vscode/src/icons.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export const GLYPHS: ReadonlySet<string> = new Set([
"ollama",
"openai",
"opencode",
"mistral",
"openrouter",
"synthetic",
"zai",
Expand Down
10 changes: 7 additions & 3 deletions extensions/vscode/src/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ export interface QuotaMeter {
status: Severity;
used?: number;
limit?: number;
/** "currency" when used/limit are US dollars. */
/** "currency" when used/limit are monetary values. */
currency?: string;
format?: string;
reset_at?: string;
time_until_reset?: string;
Expand Down Expand Up @@ -342,7 +343,10 @@ function formatCount(value: number): string {
return Number.isInteger(value) ? String(value) : value.toFixed(1);
}

function formatUsd(value: number): string {
function formatUsd(value: number, currency?: string): string {
if (currency && currency !== "USD") {
try { return new Intl.NumberFormat(undefined, {style: "currency", currency}).format(value); } catch { return `${value.toFixed(2)} ${currency}`; }
}
return `$${value > 0 && value < 0.01 ? value.toFixed(3) : value.toFixed(2)}`;
}

Expand All @@ -363,7 +367,7 @@ function usedCell(quota: QuotaMeter): string {
if (hasLimit) {
cell +=
quota.format === "currency"
? ` (${formatUsd(used)}/${formatUsd(limit)})`
? ` (${formatUsd(used, quota.currency)}/${formatUsd(limit, quota.currency)})`
: ` (${formatCount(used)}/${formatCount(limit)})`;
}
}
Expand Down
7 changes: 7 additions & 0 deletions extensions/vscode/test/model.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,13 @@ describe("tooltip sections", () => {
expect(formatProviderSection(fractional, NOW)).toContain("| 23% (2.3/10) |");
});

it("preserves the currency reported by Mistral", () => {
const card = provider({id:"mistral",base_provider:"mistral",quotas:[quota({used:1.25,limit:12.75,format:"currency",currency:"EUR",status:"healthy"})]});
const text = formatProviderSection(card, NOW);
expect(text).toContain("€1.25");
expect(text).not.toContain("$1.25");
});

it("shows dollars for currency quotas and the daemon's reading when the cap is unknown", () => {
const paid = provider({
id: "ollama",
Expand Down
2 changes: 1 addition & 1 deletion flake.nix
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
inherit version;
src = ./.;
subPackages = [ "cmd/onwatch" ];
vendorHash = "sha256-zagPclPZItTTUaMh+8Ph7k5ESqc3vETPNkhMQ493MoY=";
vendorHash = "sha256-/WAnFpl4zdWzrF1KL0xb0WZOwIGm5jLhHvZSYEbDlrQ=";
ldflags = [
"-s"
"-w"
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
18 changes: 10 additions & 8 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -7,33 +7,35 @@ require (
github.com/aymanbagabas/go-pty v0.2.3
github.com/google/uuid v1.6.0
github.com/joho/godotenv v1.5.1
github.com/mattn/go-isatty v0.0.20
github.com/mattn/go-isatty v0.0.22
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c
github.com/prometheus/client_golang v1.23.2
github.com/prometheus/client_model v0.6.2
golang.org/x/crypto v0.51.0
modernc.org/sqlite v1.44.3
github.com/steipete/sweetcookie v0.0.2
golang.org/x/crypto v0.53.0
gopkg.in/ini.v1 v1.67.3
modernc.org/sqlite v1.53.0
)

require (
github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/creack/pty v1.1.24 // indirect
github.com/danieljoos/wincred v1.2.3 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/godbus/dbus/v5 v5.1.0 // indirect
github.com/godbus/dbus/v5 v5.2.2 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/prometheus/common v0.66.1 // indirect
github.com/prometheus/procfs v0.16.1 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/u-root/u-root v0.16.0 // indirect
github.com/zalando/go-keyring v0.2.8 // indirect
go.yaml.in/yaml/v2 v2.4.2 // indirect
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
golang.org/x/sys v0.44.0 // indirect
golang.org/x/tools v0.40.0 // indirect
golang.org/x/sys v0.46.0 // indirect
google.golang.org/protobuf v1.36.8 // indirect
modernc.org/libc v1.67.6 // indirect
modernc.org/libc v1.73.4 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
)
Loading
Loading