quotacap 0.0.51 → 0.0.52

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/README.md +2 -2
  2. package/dist/adapters/agy.js +3 -0
  3. package/dist/adapters/claude.js +3 -0
  4. package/dist/adapters/codex.js +4 -2
  5. package/dist/adapters/kimi.js +14 -0
  6. package/dist/adapters/manual.js +4 -0
  7. package/dist/adapters/muse.js +19 -5
  8. package/dist/adapters/opencode-go.d.ts +12 -2
  9. package/dist/adapters/opencode-go.js +156 -31
  10. package/dist/adapters/types.d.ts +2 -0
  11. package/dist/advisory/snapshot.d.ts +1 -0
  12. package/dist/advisory/snapshot.js +7 -1
  13. package/dist/advisory/types.d.ts +3 -0
  14. package/dist/cli/models.js +5 -4
  15. package/dist/format/rows.d.ts +30 -1
  16. package/dist/format/rows.js +109 -24
  17. package/dist/format/terminal.js +5 -3
  18. package/dist/http/server.d.ts +3 -0
  19. package/dist/http/server.js +7 -3
  20. package/dist/runtime/service.js +14 -3
  21. package/dist/service/macos.d.ts +5 -0
  22. package/dist/service/macos.js +6 -2
  23. package/dist/store/db.js +2 -1
  24. package/dist/store/quotas.d.ts +1 -1
  25. package/dist/store/quotas.js +65 -2
  26. package/dist/version.js +1 -1
  27. package/dist/web/src/App.js +10 -4
  28. package/dist/web/src/components/AdviceDrawer.d.ts +2 -1
  29. package/dist/web/src/components/AdviceDrawer.js +27 -16
  30. package/dist/web/src/components/BurnDown.d.ts +37 -0
  31. package/dist/web/src/components/BurnDown.js +182 -0
  32. package/dist/web/src/components/ClosedWeeks.d.ts +6 -5
  33. package/dist/web/src/components/ClosedWeeks.js +25 -37
  34. package/dist/web/src/components/CopyButton.d.ts +4 -0
  35. package/dist/web/src/components/CopyButton.js +27 -0
  36. package/dist/web/src/components/ErrorBoundary.d.ts +37 -0
  37. package/dist/web/src/components/ErrorBoundary.js +32 -0
  38. package/dist/web/src/components/FaultBanner.d.ts +3 -2
  39. package/dist/web/src/components/FaultBanner.js +7 -4
  40. package/dist/web/src/components/OpencodeGoBanner.d.ts +3 -1
  41. package/dist/web/src/components/OpencodeGoBanner.js +16 -10
  42. package/dist/web/src/components/PaceBar.d.ts +80 -3
  43. package/dist/web/src/components/PaceBar.js +225 -34
  44. package/dist/web/src/components/PacingLegend.js +1 -1
  45. package/dist/web/src/components/ProviderCard.js +6 -9
  46. package/dist/web/src/components/ProviderDrawer.d.ts +56 -10
  47. package/dist/web/src/components/ProviderDrawer.js +263 -143
  48. package/dist/web/src/components/ProviderRow.js +3 -3
  49. package/dist/web/src/components/Recommendation.js +11 -7
  50. package/dist/web/src/components/ResetRail.js +13 -9
  51. package/dist/web/src/components/SubscriptionList.js +11 -4
  52. package/dist/web/src/components/WindowsRow.d.ts +13 -3
  53. package/dist/web/src/components/WindowsRow.js +27 -5
  54. package/dist/web/src/components/settings/CliIntegrationPanel.js +7 -45
  55. package/dist/web/src/components/settings/DaemonPanel.js +1 -28
  56. package/dist/web/src/components/settings/ProvidersPanel.d.ts +2 -2
  57. package/dist/web/src/components/settings/ProvidersPanel.js +10 -24
  58. package/dist/web/src/components/settings/SettingsDrawer.d.ts +1 -2
  59. package/dist/web/src/components/settings/SettingsDrawer.js +2 -2
  60. package/dist/web/src/state.d.ts +6 -1
  61. package/dist/web/src/state.js +19 -10
  62. package/dist/webAssets.js +1 -1
  63. package/dist/webHtml.js +1 -1
  64. package/package.json +1 -1
  65. package/web/dist/assets/index-Co33DTl5.css +1 -0
  66. package/web/dist/assets/index-DqK6QVaw.js +13 -0
  67. package/web/dist/index.html +2 -2
  68. package/web/dist/assets/index-BSQW0C_t.css +0 -1
  69. package/web/dist/assets/index-CkfnMcPS.js +0 -13
package/README.md CHANGED
@@ -28,7 +28,7 @@ QuotaCap helps you get more from the AI coding subscriptions you already pay for
28
28
 
29
29
  Exec adapters run via `execFile` with an argv list. PTY adapters run via `node-pty` (`src/adapters/pty.ts`). They are TUI-fragile: a vendor text change breaks the parser and the row degrades fail-closed until the regex is fixed. Poll latency is 2–10 s per PTY provider (settle plus completion); `muse` can reach about 75 s when it has to warm an unavailable subscription. It dominates `POST /api/refresh` and the first poll, not the steady-state 15 m timer.
30
30
 
31
- Live adapters invoke the CLIs you already logged into — no API keys, no token files read. The one opt-in exception is OpenCode Go: after you run `quotacap providers enable opencode-go`, each poll reads the OpenCode auth key in-memory (`~/.local/share/opencode/auth.json`, `opencode-go` → `opencode` fallback, or `OPENCODE_API_KEY`) and sends it only to `opencode.ai/zen/go/v1/usage`. It is never stored, logged, or returned by the API or MCP. Disable any time with `quotacap providers disable opencode-go`.
31
+ Live adapters invoke the CLIs you already logged into — no API keys, no token files read. The one opt-in exception is OpenCode Go: after you run `quotacap providers enable opencode-go`, each poll reads the bearer OpenCode already stored (v2 `~/.local/share/opencode/opencode.db` active credential, else legacy `auth.json`; `opencode-go` then `opencode`, or `OPENCODE_API_KEY`) and sends it only to `opencode.ai/zen/go/v1/usage`. It is never stored, logged, or returned by the API or MCP. Disable any time with `quotacap providers disable opencode-go`.
32
32
 
33
33
  ## Install
34
34
 
@@ -121,7 +121,7 @@ curl -X PATCH http://localhost:8787/api/providers/claude \
121
121
 
122
122
  QuotaCap is a local daemon. It binds to `127.0.0.1` only (`src/runtime/service.ts` `app.listen`). It does not listen on `0.0.0.0`. There is no LAN surface.
123
123
 
124
- It owns no tokens. It never reads `~/.codex/auth.json`, `~/.kimi-code/credentials/kimi-code.json`, `~/.kimi/credentials/kimi-code.json`, `~/.grok/auth.json`, or `~/.gemini/oauth_creds.json`. It never uses `refresh_token` or `grant_type=refresh_token`. It has no hardcoded client ids. Those OAuth paths and the `.qc-bak` and `.qc-lock` helpers were removed in #14. This is asserted by `tests/adapters/credential-free.test.ts`. Each CLI owns its own session. It never reads `~/.config/muse/auth.json`, `~/.local/share/muse/sessions/`, or `~/.config/muse/tui-history.jsonl`. Each CLI owns its own session. The one opt-in exception is OpenCode Go: when you enable it, QuotaCap reads `~/.local/share/opencode/auth.json` in-memory, read-only, once per poll, and sends the key only as `Authorization: Bearer` to `https://opencode.ai/zen/go/v1/usage`. The read is consent-gated (`opencodeGoConsentAt` in config), asserted read-only by `tests/adapters/credential-free.test.ts`, and revoked by `quotacap providers disable opencode-go`. QuotaCap only spawns the CLI and reads its stdout via `exec` (`claude`, `agy`) or PTY (`codex`, `kimi`, `grok`, `muse`).
124
+ It owns no tokens. It never reads `~/.codex/auth.json`, `~/.kimi-code/credentials/kimi-code.json`, `~/.kimi/credentials/kimi-code.json`, `~/.grok/auth.json`, or `~/.gemini/oauth_creds.json`. It never uses `refresh_token` or `grant_type=refresh_token`. It has no hardcoded client ids. Those OAuth paths and the `.qc-bak` and `.qc-lock` helpers were removed in #14. This is asserted by `tests/adapters/credential-free.test.ts`. Each CLI owns its own session. It never reads `~/.config/muse/auth.json`, `~/.local/share/muse/sessions/`, or `~/.config/muse/tui-history.jsonl`. Each CLI owns its own session. The one opt-in exception is OpenCode Go: when you enable it, QuotaCap reads the bearer OpenCode stored — the active credential in `~/.local/share/opencode/opencode.db`, or the legacy `auth.json` key — in-memory, read-only, once per poll, and sends it only as `Authorization: Bearer` to `https://opencode.ai/zen/go/v1/usage`. The read is consent-gated (`opencodeGoConsentAt` in config), asserted read-only by `tests/adapters/credential-free.test.ts`, and revoked by `quotacap providers disable opencode-go`. QuotaCap only spawns the CLI and reads its stdout via `exec` (`claude`, `agy`) or PTY (`codex`, `kimi`, `grok`, `muse`).
125
125
 
126
126
  It stores no `raw` provider payload. The `raw` column was dropped and migrated in `src/store/db.ts` `migrate`. `GET /api/quotas` and MCP `get_quotas` never return `raw` (`tests/http/api.test.ts`). History and the token live under `~/.quotacap/` with `0700` on the directory and `0600` on files.
127
127
 
@@ -26,11 +26,14 @@ function parseGroup(g, provider, rawJsonString, now) {
26
26
  const sessionPct = fiveHour
27
27
  ? usedPctFromRemaining(fiveHour.remaining_fraction) ?? undefined
28
28
  : undefined;
29
+ const fiveResetDate = fiveHour?.reset_time ? new Date(fiveHour.reset_time) : null;
30
+ const fiveHourResetsAt = fiveResetDate && !Number.isNaN(fiveResetDate.getTime()) ? fiveResetDate.toISOString() : undefined;
29
31
  return {
30
32
  provider,
31
33
  plan: "unknown",
32
34
  weeklyPct: usedPct,
33
35
  fiveHourPct: sessionPct,
36
+ ...(fiveHourResetsAt ? { fiveHourResetsAt } : {}),
34
37
  resetsAt,
35
38
  periodStart,
36
39
  source: "cli",
@@ -12,6 +12,8 @@ export function parseClaudeUsage(result, now = new Date()) {
12
12
  // and whole-text farthest-wins would pick it instead (muse precedent:
13
13
  // scope to the weekly section, then degrade to +7d when it won't parse).
14
14
  const weeklyLine = result.match(/^.*current week.*$/gim)?.[0];
15
+ const sessionLine = result.match(/^.*current session.*$/gim)?.[0];
16
+ const fiveHourResetsAt = sessionLine ? parseResetText(sessionLine, now) ?? undefined : undefined;
15
17
  let resetsAt = parseResetText(weeklyLine ?? result, now);
16
18
  const parsedReset = !!resetsAt;
17
19
  if (!resetsAt)
@@ -20,6 +22,7 @@ export function parseClaudeUsage(result, now = new Date()) {
20
22
  const periodStart = (parsedReset ? new Date(resets - 7 * 86400000) : new Date(now.getTime() - 7 * 86400000)).toISOString();
21
23
  return {
22
24
  provider: "claude", plan: "unknown", weeklyPct: usedPct, fiveHourPct: sessionPct,
25
+ ...(fiveHourResetsAt ? { fiveHourResetsAt } : {}),
23
26
  resetsAt, periodStart, source: "cli", fetchedAt: now.toISOString(), raw: result,
24
27
  };
25
28
  }
@@ -121,8 +121,9 @@ function parseCodexCleaned(cleaned, now) {
121
121
  weeklyIso = new Date(now.getTime() + 7 * 86400000).toISOString();
122
122
  estimated = true;
123
123
  }
124
- if (!fiveIso)
125
- fiveIso = new Date(now.getTime() + 5 * 3600000).toISOString();
124
+ // The 5h reset is display-only: expose it only when actually parsed,
125
+ // never the synthesized fallback a garbled timestamp would produce.
126
+ const fiveHourResetsAt = fiveIso ?? undefined;
126
127
  const usedPct = 100 - weeklyLeft;
127
128
  const sessionPct = 100 - fiveLeft;
128
129
  // The /status panel carries "Account: <email> (<plan>)" above the limits.
@@ -137,6 +138,7 @@ function parseCodexCleaned(cleaned, now) {
137
138
  plan,
138
139
  weeklyPct: usedPct,
139
140
  fiveHourPct: sessionPct,
141
+ ...(fiveHourResetsAt ? { fiveHourResetsAt } : {}),
140
142
  resetsAt: weeklyIso,
141
143
  periodStart,
142
144
  source: "tui",
@@ -43,6 +43,7 @@ function parseKimiCleaned(cleaned, now) {
43
43
  // Degrade, don't fail (grok precedent): the percents are the critical
44
44
  // fields; an unparseable timestamp degrades to an estimate.
45
45
  let weeklyIso = parseResetText(`resets ${weeklyRaw}`, now);
46
+ const fiveHourResetsAt = f ? parseResetText(`resets ${f[2].trim()}`, now) ?? undefined : undefined;
46
47
  let estimated = false;
47
48
  if (!weeklyIso) {
48
49
  weeklyIso = new Date(now.getTime() + 7 * 86400000).toISOString();
@@ -86,6 +87,7 @@ function parseKimiCleaned(cleaned, now) {
86
87
  plan,
87
88
  weeklyPct: usedPct,
88
89
  fiveHourPct: sessionPct,
90
+ ...(fiveHourResetsAt ? { fiveHourResetsAt } : {}),
89
91
  resetsAt: weeklyIso,
90
92
  periodStart,
91
93
  source: "tui",
@@ -184,6 +186,7 @@ export function parseKimiApiUsage(usagesBody, meBody, now = new Date()) {
184
186
  }
185
187
  // 2. 5-hour window (from limits array)
186
188
  let sessionPct = null;
189
+ let fiveHourResetsAt;
187
190
  if (Array.isArray(u.limits)) {
188
191
  for (const item of u.limits) {
189
192
  if (item && typeof item === "object") {
@@ -193,6 +196,11 @@ export function parseKimiApiUsage(usagesBody, meBody, now = new Date()) {
193
196
  const limit = Number(detail.limit);
194
197
  if (Number.isFinite(used) && Number.isFinite(limit) && limit > 0) {
195
198
  sessionPct = Math.round((used / limit) * 100);
199
+ if (typeof detail.resetTime === "string") {
200
+ const parsed = Date.parse(detail.resetTime);
201
+ if (Number.isFinite(parsed))
202
+ fiveHourResetsAt = new Date(parsed).toISOString();
203
+ }
196
204
  break;
197
205
  }
198
206
  }
@@ -218,6 +226,11 @@ export function parseKimiApiUsage(usagesBody, meBody, now = new Date()) {
218
226
  if (l5h && typeof l5h.used_ratio === "number") {
219
227
  sessionPct = Math.round(l5h.used_ratio * 100);
220
228
  }
229
+ if (l5h && typeof l5h.reset_time === "string" && !fiveHourResetsAt) {
230
+ const parsed = Date.parse(l5h.reset_time);
231
+ if (Number.isFinite(parsed))
232
+ fiveHourResetsAt = new Date(parsed).toISOString();
233
+ }
221
234
  }
222
235
  }
223
236
  // 3. Plan identity (from meBody), before the field throws: an empty
@@ -252,6 +265,7 @@ export function parseKimiApiUsage(usagesBody, meBody, now = new Date()) {
252
265
  plan,
253
266
  weeklyPct,
254
267
  fiveHourPct: sessionPct,
268
+ ...(fiveHourResetsAt ? { fiveHourResetsAt } : {}),
255
269
  resetsAt,
256
270
  periodStart,
257
271
  source: "api",
@@ -20,11 +20,15 @@ export function parseManualUsage(provider, text, now = new Date()) {
20
20
  if (Number.isFinite(v) && v >= 0 && v <= 100)
21
21
  sessionPct = v;
22
22
  }
23
+ // The short window's own reset lives on its line, never the weekly one.
24
+ const fiveLine = text.match(/^.*(?:5h|5-hour).*$/gim)?.[0];
25
+ const fiveHourResetsAt = fiveLine ? parseResetText(fiveLine, now) ?? undefined : undefined;
23
26
  return {
24
27
  provider,
25
28
  plan: "unknown",
26
29
  weeklyPct: usedPct,
27
30
  fiveHourPct: sessionPct,
31
+ ...(fiveHourResetsAt ? { fiveHourResetsAt } : {}),
28
32
  resetsAt,
29
33
  periodStart,
30
34
  source: "manual",
@@ -105,11 +105,24 @@ function parseMuseCleaned(cleaned, now) {
105
105
  const planMatch = cleaned.match(/Subscription\s*·\s*Muse Code\s+(.+?)\s{2,}/i);
106
106
  if (planMatch)
107
107
  plan = planMatch[1].trim();
108
- // The Current-window reset ("Resets at 3:51 PM") is a bare clock time with
109
- // no date, and Quota has no session-reset field — discarded, as claude and
110
- // kimi already do. A missing or unparseable Weekly reset degrades to an
111
- // estimate (grok precedent): the percents are the critical fields, and the
112
- // estimate flag keeps advisory and scheduler honest.
108
+ // The Current-window reset ("Resets at 3:51 PM") is a bare clock time
109
+ // with no date: resolve it against the panel's Brisbane date, rolling to
110
+ // tomorrow once today's time has passed. Display-only; a missing or
111
+ // unparseable Weekly reset degrades to an estimate (grok precedent): the
112
+ // percents are the critical fields, and the estimate flag keeps advisory
113
+ // and scheduler honest.
114
+ const currentReset = cleaned.match(/Current\s+\d+%\s+used[\s\S]{0,120}?Resets at\s+(\d{1,2}):(\d{2})\s*([AP]M)/i);
115
+ let fiveHourResetsAt;
116
+ if (currentReset) {
117
+ let hour = parseInt(currentReset[1], 10) % 12;
118
+ if (/pm/i.test(currentReset[3]))
119
+ hour += 12;
120
+ const brisbaneNow = new Date(now.getTime() + 10 * 3600000);
121
+ let candidate = Date.UTC(brisbaneNow.getUTCFullYear(), brisbaneNow.getUTCMonth(), brisbaneNow.getUTCDate(), hour - 10, parseInt(currentReset[2], 10));
122
+ if (candidate <= now.getTime())
123
+ candidate += 86400000;
124
+ fiveHourResetsAt = new Date(candidate).toISOString();
125
+ }
113
126
  const resetMatch = cleaned.match(/Weekly[\s\S]{0,400}?Resets\s+([A-Za-z]{3}\s+\d{1,2}\s+at\s+\d{1,2}:\d{2}\s*[AP]M)/i);
114
127
  let resetsAt = resetMatch ? parseResetText(`resets ${resetMatch[1].trim()}`, now) : null;
115
128
  let estimated = false;
@@ -123,6 +136,7 @@ function parseMuseCleaned(cleaned, now) {
123
136
  plan,
124
137
  weeklyPct: usedPct,
125
138
  fiveHourPct: sessionPct,
139
+ ...(fiveHourResetsAt ? { fiveHourResetsAt } : {}),
126
140
  resetsAt,
127
141
  periodStart,
128
142
  source: "tui",
@@ -1,8 +1,18 @@
1
1
  import type { ParsedQuota } from "./types.js";
2
+ export declare const OPENCODE_LOGIN_COMMAND_V2 = "opencode auth login opencode";
3
+ export declare const OPENCODE_LOGIN_COMMAND_V1 = "opencode console login";
4
+ /** Login command for the installed OpenCode CLI major version, or null
5
+ * when the version text carries no readable major. */
6
+ export declare function openCodeLoginCommandForVersion(versionText: string | null | undefined): string | null;
7
+ /** Best-effort local detection: `<opencode> --version`, never throws. */
8
+ export declare function detectOpenCodeLoginCommand(versionReader?: () => Promise<string | null>): Promise<string | null>;
9
+ export declare function openCodeGoConsentNotice(loginCommand?: string | null): string;
2
10
  export declare const OPENCODE_GO_CONSENT_NOTICE: string;
3
- export declare function openCodeGoAuthPath(): string;
11
+ export declare function openCodeGoAuthPath(env?: NodeJS.Dict<string>, home?: string): string;
12
+ /** v2 database. `OPENCODE_DB` wins, otherwise the OpenCode data directory. */
13
+ export declare function openCodeGoDbPath(env?: NodeJS.Dict<string>, home?: string): string;
4
14
  /** Detection is existence only — no content read before enablement. */
5
- export declare function opencodeGoDetected(): boolean;
15
+ export declare function opencodeGoDetected(env?: NodeJS.Dict<string>, home?: string): boolean;
6
16
  export declare function parseOpencodeGoUsage(body: unknown, now?: Date): ParsedQuota;
7
17
  export declare const opencodeGoAdapter: {
8
18
  id: string;
@@ -1,57 +1,177 @@
1
- // Consented exception to credential-free polling: reads the OpenCode auth
2
- // key in-memory, only after `providers enable opencode-go` records consent.
1
+ // Consented exception to credential-free polling: reads the OpenCode bearer
2
+ // in-memory, only after `providers enable opencode-go` records consent.
3
+ // v2 stores it in opencode.db. v1 stored an API key in auth.json.
3
4
  // The static gate pins the auth-file literal to this adapter plus the
4
5
  // generated dashboard bundle — see tests/adapters/credential-free.test.ts.
6
+ import { createRequire } from "node:module";
5
7
  import fsSync from "node:fs";
6
8
  import os from "node:os";
7
9
  import path from "node:path";
8
10
  import { adapterSignal } from "../runtime/spawn.js";
11
+ import { getCliVersion } from "../diagnostics/bundle.js";
12
+ const require = createRequire(import.meta.url);
9
13
  const USAGE_URL = "https://opencode.ai/zen/go/v1/usage";
10
14
  const POLL_TIMEOUT_MS = 8000;
11
- export const OPENCODE_GO_CONSENT_NOTICE = [
12
- "• What is read: API key OpenCode already stored when you ran `opencode auth login -p opencode-go` (`~/.local/share/opencode/auth.json`, `opencode-go` → `opencode` fallback).",
13
- "• Read frequency: in-memory, read-only once per poll (every 15 minutes by default).",
14
- "• Request destination: sent only as `Authorization: Bearer` to `https://opencode.ai/zen/go/v1/usage` to fetch `rolling/weekly/monthly` `percent` + `resetsAt`.",
15
- "• Never-list: never used to make chat or model requests, never written or rotated, never stored in the `quotacap` DB, never returned by the API or MCP, and never logged (errors show `[redacted]`).",
16
- "• Revocation instructions: revoke at `https://opencode.ai/auth` or `opencode providers logout opencode-go` and run `quotacap providers disable opencode-go` to stop polling.",
17
- "• Optional environment override: if you prefer no file access, set `OPENCODE_API_KEY` instead.",
18
- ].join("\n");
19
- export function openCodeGoAuthPath() {
20
- return path.join(os.homedir(), ".local", "share", "opencode", "auth.json");
15
+ export const OPENCODE_LOGIN_COMMAND_V2 = "opencode auth login opencode";
16
+ export const OPENCODE_LOGIN_COMMAND_V1 = "opencode console login";
17
+ /** Login command for the installed OpenCode CLI major version, or null
18
+ * when the version text carries no readable major. */
19
+ export function openCodeLoginCommandForVersion(versionText) {
20
+ if (!versionText)
21
+ return null;
22
+ const m = versionText.match(/(?:^|\s)v?(\d+)\.\d+/);
23
+ if (!m)
24
+ return null;
25
+ const major = parseInt(m[1], 10);
26
+ if (major >= 2)
27
+ return OPENCODE_LOGIN_COMMAND_V2;
28
+ if (major === 1)
29
+ return OPENCODE_LOGIN_COMMAND_V1;
30
+ return null;
31
+ }
32
+ /** Best-effort local detection: `<opencode> --version`, never throws. */
33
+ export async function detectOpenCodeLoginCommand(versionReader = () => getCliVersion("opencode")) {
34
+ try {
35
+ return openCodeLoginCommandForVersion(await versionReader());
36
+ }
37
+ catch {
38
+ return null;
39
+ }
40
+ }
41
+ export function openCodeGoConsentNotice(loginCommand) {
42
+ const loginClause = loginCommand
43
+ ? `when you ran \`${loginCommand}\``
44
+ : `with the command for your CLI version: v2 \`${OPENCODE_LOGIN_COMMAND_V2}\` or v1 \`${OPENCODE_LOGIN_COMMAND_V1}\``;
45
+ return [
46
+ `• What is read: Bearer [REDACTED] already stored ${loginClause} (v2 \`~/.local/share/opencode/opencode.db\` active credential, else legacy \`~/.local/share/opencode/auth.json\`; \`opencode-go\` then \`opencode\`).`,
47
+ "• Read frequency: in-memory, read-only once per poll (every 15 minutes by default).",
48
+ "• Request destination: sent only as `Authorization: Bearer` to `https://opencode.ai/zen/go/v1/usage` to fetch `rolling/weekly/monthly` `percent` + `resetsAt`.",
49
+ "• Never-list: never used to make chat or model requests, never written or rotated, never stored in the `quotacap` DB, never returned by the API or MCP, and never logged (errors show `[redacted]`).",
50
+ "• Revocation instructions: revoke at `https://opencode.ai/auth` or `opencode providers logout opencode-go` and run `quotacap providers disable opencode-go` to stop polling.",
51
+ "• Optional environment override: if you prefer no file access, set `OPENCODE_API_KEY` instead.",
52
+ ].join("\n");
53
+ }
54
+ export const OPENCODE_GO_CONSENT_NOTICE = openCodeGoConsentNotice();
55
+ export function openCodeGoAuthPath(env = process.env, home = os.homedir()) {
56
+ return path.join(openCodeDataDir(env, home), "auth.json");
57
+ }
58
+ /** v2 database. `OPENCODE_DB` wins, otherwise the OpenCode data directory. */
59
+ export function openCodeGoDbPath(env = process.env, home = os.homedir()) {
60
+ const override = env.OPENCODE_DB;
61
+ if (typeof override === "string" && override.length > 0)
62
+ return override;
63
+ return path.join(openCodeDataDir(env, home), "opencode.db");
64
+ }
65
+ function openCodeDataDir(env, home) {
66
+ const xdg = env.XDG_DATA_HOME;
67
+ if (typeof xdg === "string" && xdg.length > 0)
68
+ return path.join(xdg, "opencode");
69
+ return path.join(home, ".local", "share", "opencode");
21
70
  }
22
71
  /** Detection is existence only — no content read before enablement. */
23
- export function opencodeGoDetected() {
72
+ export function opencodeGoDetected(env = process.env, home = os.homedir()) {
24
73
  try {
25
- return fsSync.existsSync(openCodeGoAuthPath());
74
+ return fsSync.existsSync(openCodeGoDbPath(env, home)) || fsSync.existsSync(openCodeGoAuthPath(env, home));
26
75
  }
27
76
  catch {
28
77
  return false;
29
78
  }
30
79
  }
80
+ function openReadonlySqlite(dbPath) {
81
+ try {
82
+ const { DatabaseSync } = require("node:sqlite");
83
+ return new DatabaseSync(dbPath, { readOnly: true });
84
+ }
85
+ catch (err) {
86
+ const code = err.code;
87
+ if (code !== "ERR_UNKNOWN_BUILTIN_MODULE" && code !== "MODULE_NOT_FOUND")
88
+ throw err;
89
+ const { Database } = require("bun:sqlite");
90
+ return new Database(dbPath, { readonly: true });
91
+ }
92
+ }
93
+ /** API key, or the OAuth access token. Refresh material is never read. */
94
+ function bearerFromStoredValue(raw) {
95
+ if (typeof raw !== "string" || raw.length === 0)
96
+ return null;
97
+ let parsed;
98
+ try {
99
+ parsed = JSON.parse(raw);
100
+ }
101
+ catch {
102
+ return null;
103
+ }
104
+ if (typeof parsed !== "object" || parsed === null)
105
+ return null;
106
+ const entry = parsed;
107
+ if (entry.type === "api" && typeof entry.key === "string" && entry.key.length > 0)
108
+ return entry.key;
109
+ if (entry.type === "oauth" && typeof entry.access === "string" && entry.access.length > 0)
110
+ return entry.access;
111
+ return null;
112
+ }
113
+ /**
114
+ * Active v2 credential. `opencode-go` wins over `opencode`. Newest row wins
115
+ * within one integration. A missing or unreadable database is no credential.
116
+ */
117
+ function readV2Bearer(env, home) {
118
+ const dbPath = openCodeGoDbPath(env, home);
119
+ if (!fsSync.existsSync(dbPath))
120
+ return null;
121
+ let db;
122
+ try {
123
+ db = openReadonlySqlite(dbPath);
124
+ const rows = db.prepare("SELECT integration_id, value FROM credential WHERE active = 1 AND integration_id IN ('opencode-go', 'opencode') ORDER BY rowid DESC").all();
125
+ const chosen = new Map();
126
+ for (const row of rows) {
127
+ const id = row.integration_id;
128
+ if (typeof id !== "string" || chosen.has(id))
129
+ continue;
130
+ const bearer = bearerFromStoredValue(row.value);
131
+ if (bearer)
132
+ chosen.set(id, bearer);
133
+ }
134
+ return chosen.get("opencode-go") ?? chosen.get("opencode") ?? null;
135
+ }
136
+ catch {
137
+ return null;
138
+ }
139
+ finally {
140
+ try {
141
+ db?.close();
142
+ }
143
+ catch { /* a failed open has nothing to close */ }
144
+ }
145
+ }
146
+ function readLegacyBearer(env, home) {
147
+ try {
148
+ const parsed = JSON.parse(fsSync.readFileSync(openCodeGoAuthPath(env, home), "utf8"));
149
+ const entry = (parsed["opencode-go"] ?? parsed["opencode"]);
150
+ if (!entry || entry.type !== "api" || typeof entry.key !== "string" || entry.key.length === 0)
151
+ return null;
152
+ return entry.key;
153
+ }
154
+ catch {
155
+ return null;
156
+ }
157
+ }
31
158
  /**
32
159
  * Consent-gated: poll() runs only when the provider is in enabledProviders,
33
160
  * which the enable flow records as explicit consent. One read, in-memory
34
161
  * only, never written or logged. Env wins so a host can avoid file access.
162
+ * v2's database wins over the legacy file, which v2 no longer updates.
35
163
  */
36
- function readBearerToken(env = process.env) {
164
+ function readBearerToken(env = process.env, home = os.homedir()) {
37
165
  const fromEnv = env.OPENCODE_API_KEY;
38
166
  if (typeof fromEnv === "string" && fromEnv.length > 0)
39
167
  return fromEnv;
40
- let parsed;
41
- try {
42
- parsed = JSON.parse(fsSync.readFileSync(openCodeGoAuthPath(), "utf8"));
43
- }
44
- catch {
45
- throw new Error("opencode-go: missing API key in auth file");
46
- }
47
- const entry = (parsed["opencode-go"] ?? parsed["opencode"]);
48
- if (!entry ||
49
- entry.type !== "api" ||
50
- typeof entry.key !== "string" ||
51
- !entry.key.startsWith("sk-")) {
52
- throw new Error("opencode-go: missing API key in auth file");
53
- }
54
- return entry.key;
168
+ const fromV2 = readV2Bearer(env, home);
169
+ if (fromV2)
170
+ return fromV2;
171
+ const fromLegacy = readLegacyBearer(env, home);
172
+ if (fromLegacy)
173
+ return fromLegacy;
174
+ throw new Error("opencode-go: missing API key in auth file");
55
175
  }
56
176
  // `rate-limited` is the binding state, not a failure: it is exactly the
57
177
  // reading issue 109 needed. Anything unreadable fails closed.
@@ -110,12 +230,17 @@ export function parseOpencodeGoUsage(body, now = new Date()) {
110
230
  if (typeof sessionPct !== "number" || !Number.isFinite(sessionPct) || sessionPct < 0 || sessionPct > 100) {
111
231
  throw new Error("opencode-go: bad rolling pct");
112
232
  }
233
+ const rollingResetsMs = Date.parse(String(rolling.resetsAt ?? ""));
234
+ const fiveHourResetsAt = Number.isFinite(rollingResetsMs)
235
+ ? new Date(rollingResetsMs).toISOString()
236
+ : undefined;
113
237
  const monthly = parseMonthly(usage);
114
238
  return {
115
239
  provider: "opencode-go",
116
240
  plan: "unknown",
117
241
  weeklyPct: usedPct,
118
242
  fiveHourPct: sessionPct,
243
+ ...(fiveHourResetsAt ? { fiveHourResetsAt } : {}),
119
244
  resetsAt: new Date(resetsMs).toISOString(),
120
245
  periodStart: new Date(resetsMs - 7 * 86400000).toISOString(),
121
246
  source: "api",
@@ -125,7 +250,7 @@ export function parseOpencodeGoUsage(body, now = new Date()) {
125
250
  }
126
251
  export const opencodeGoAdapter = {
127
252
  id: "opencode-go",
128
- requiresAuth: "opencode auth login -p opencode-go (read only after you enable opencode-go)",
253
+ requiresAuth: "OpenCode CLI login for your version (read only after you enable opencode-go)",
129
254
  async poll() {
130
255
  const bearer = readBearerToken();
131
256
  const adapter = adapterSignal("opencode-go");
@@ -5,6 +5,8 @@ export interface Quota {
5
5
  weeklyPct: number;
6
6
  /** 5-hour rolling window. 0..100. Display only — never ranks. Was `sessionPct`. */
7
7
  fiveHourPct?: number;
8
+ /** When the 5-hour window resets, when the provider reports it. Display only. */
9
+ fiveHourResetsAt?: string;
8
10
  /** Included monthly envelope. 0..100. Absent means the provider has no monthly window. */
9
11
  monthlyPct?: number;
10
12
  monthlyResetsAt?: string;
@@ -17,6 +17,7 @@ export interface SnapshotOptions {
17
17
  checkedAt: string | null;
18
18
  };
19
19
  detectedProviders?: string[];
20
+ opencodeLoginCommand?: string | null;
20
21
  };
21
22
  }
22
23
  export declare function buildSnapshot(db: any, opts: SnapshotOptions): StateSnapshot;
@@ -151,6 +151,12 @@ export function buildSnapshot(db, opts) {
151
151
  paceByProvider.set(id, pace);
152
152
  }
153
153
  const catalog = catalogMap.get(id) ?? emptyCatalog();
154
+ const closeWindow = advisory?.bindingWindow === "monthly" &&
155
+ quota?.monthlyKind === "included" &&
156
+ typeof quota?.monthlyPct === "number" &&
157
+ !!quota?.monthlyResetsAt
158
+ ? "monthly"
159
+ : "weekly";
154
160
  providerSnapshots.push({
155
161
  id,
156
162
  ...providerIdentity(id, opts.providerNames),
@@ -165,7 +171,7 @@ export function buildSnapshot(db, opts) {
165
171
  evidence,
166
172
  exclusionReason,
167
173
  advisory,
168
- lastCloses: getWindowCloses(db, id, 4),
174
+ lastCloses: getWindowCloses(db, id, 4, closeWindow),
169
175
  catalog,
170
176
  });
171
177
  }
@@ -113,6 +113,9 @@ export interface StateSnapshot {
113
113
  /** Auth detected for these provider ids (detection-only; consumers intersect with the enabled set).
114
114
  * Empty on daemons predating the field. */
115
115
  detectedProviders?: string[];
116
+ /** Login command for the locally detected OpenCode CLI version, or
117
+ * null when the version could not be read. */
118
+ opencodeLoginCommand?: string | null;
116
119
  };
117
120
  providers: ProviderSnapshot[];
118
121
  recommendation: Recommendation;
@@ -5,21 +5,22 @@ import { getAllLatest } from "../store/quotas.js";
5
5
  import { providerIdentity } from "../advisory/provider-names.js";
6
6
  import { readToken } from "../runtime/token.js";
7
7
  import { createServiceClient } from "../runtime/client.js";
8
+ import { bindingLeftover } from "../format/rows.js";
8
9
  import { OFFLINE_LABEL } from "./snapshot-source.js";
9
10
  import { phase } from "./progress.js";
10
- function projectModels(catalogs, quotaMap, providerNames) {
11
+ function projectModels(catalogs, quotaMap, providerNames, now = new Date()) {
11
12
  const out = [];
12
13
  for (const [id, catalog] of catalogs) {
13
14
  const identity = providerIdentity(id, providerNames);
14
15
  const quota = quotaMap.get(id);
15
- const leftoverPct = quota?.weeklyPct != null ? Math.round((100 - quota.weeklyPct) * 10) / 10 : null;
16
+ const binding = bindingLeftover(quota, now);
16
17
  out.push({
17
18
  id,
18
19
  displayName: identity.displayName,
19
20
  harness: identity.harness,
20
21
  vendor: identity.vendor,
21
- leftoverPct,
22
- resetsAt: quota?.resetsAt ?? null,
22
+ leftoverPct: binding.leftoverPct,
23
+ resetsAt: binding.resetsAt,
23
24
  catalog,
24
25
  });
25
26
  }
@@ -16,6 +16,35 @@ export interface Row {
16
16
  }
17
17
  export declare const RAIL_CELLS = 20;
18
18
  export declare const SORT_KEYS: SortKey[];
19
+ /**
20
+ * Quota-window resolution, mirroring the dashboard helpers: the advisory
21
+ * binding window rations the account, so standalone resets, usage,
22
+ * elapsed, and pace read it. Weekly-bound rows fall back to the weekly
23
+ * fields unchanged; rate-limit windows never drive these figures.
24
+ */
25
+ export declare function isMonthlyBound(ps: ProviderSnapshot): boolean;
26
+ /** Reset instant of the quota window, or null without a quota reading. */
27
+ export declare function bindingResetsAt(ps: ProviderSnapshot): string | null;
28
+ /** Used percent of the quota window. Null without a quota reading. */
29
+ export declare function bindingUsedPct(ps: ProviderSnapshot): number | null;
30
+ /**
31
+ * Binding leftover for a raw quota without an advisory (model catalogs):
32
+ * the same scarcer-headroom rule the engine applies, so the catalog
33
+ * leftover and reset name the quota window, not always the week.
34
+ */
35
+ export declare function bindingLeftover(quota: {
36
+ weeklyPct?: number;
37
+ resetsAt?: string;
38
+ monthlyKind?: string;
39
+ monthlyPct?: number;
40
+ monthlyResetsAt?: string;
41
+ monthlyStatus?: string;
42
+ } | null | undefined, now: Date): {
43
+ leftoverPct: number | null;
44
+ resetsAt: string | null;
45
+ };
46
+ /** Window-average pace of the quota window; monthly divides monthly used by elapsed monthly days. */
47
+ export declare function bindingAvgPace(ps: ProviderSnapshot, now: Date): number | null;
19
48
  export declare function weeklyPctOf(q: {
20
49
  weeklyPct?: number;
21
50
  usedPct?: number;
@@ -31,5 +60,5 @@ export declare function forecastText(ps: ProviderSnapshot, now: Date): string;
31
60
  export declare function compactSuffix(ps: ProviderSnapshot): CompactSuffix;
32
61
  export declare function assertValidSortKey(key: string): asserts key is SortKey;
33
62
  export declare function sortProviders(providers: ProviderSnapshot[], key: string, use: string): ProviderSnapshot[];
34
- export declare function paceText(ps: ProviderSnapshot): string;
63
+ export declare function paceText(ps: ProviderSnapshot, now?: Date): string;
35
64
  export declare function buildRow(ps: ProviderSnapshot, now: Date): Row;