quotacap 0.0.38 → 0.0.40

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 (47) hide show
  1. package/README.md +2 -2
  2. package/dist/adapters/agy.js +2 -2
  3. package/dist/adapters/claude.js +5 -2
  4. package/dist/adapters/codex.js +2 -2
  5. package/dist/adapters/grok.js +1 -1
  6. package/dist/adapters/kimi.js +2 -2
  7. package/dist/adapters/manual.js +2 -2
  8. package/dist/adapters/muse.js +2 -2
  9. package/dist/adapters/opencode-go.js +30 -2
  10. package/dist/adapters/types.d.ts +17 -2
  11. package/dist/advisory/engine.d.ts +4 -2
  12. package/dist/advisory/engine.js +55 -14
  13. package/dist/advisory/snapshot.js +11 -3
  14. package/dist/advisory/types.d.ts +11 -3
  15. package/dist/catalog/claude.js +2 -1
  16. package/dist/cli/models.js +1 -1
  17. package/dist/diagnostics/failure.js +9 -0
  18. package/dist/format/rows.d.ts +6 -1
  19. package/dist/format/rows.js +27 -4
  20. package/dist/format/terminal.js +5 -4
  21. package/dist/http/server.js +3 -3
  22. package/dist/runtime/poll.js +1 -1
  23. package/dist/store/db.js +12 -1
  24. package/dist/store/quotas.js +33 -8
  25. package/dist/version.js +1 -1
  26. package/dist/web/src/components/AdviceDrawer.js +10 -5
  27. package/dist/web/src/components/PaceBar.d.ts +9 -5
  28. package/dist/web/src/components/PaceBar.js +32 -10
  29. package/dist/web/src/components/ProviderCard.js +4 -2
  30. package/dist/web/src/components/ProviderDrawer.d.ts +7 -1
  31. package/dist/web/src/components/ProviderDrawer.js +46 -9
  32. package/dist/web/src/components/ProviderRow.js +4 -4
  33. package/dist/web/src/components/Recommendation.d.ts +5 -2
  34. package/dist/web/src/components/Recommendation.js +8 -4
  35. package/dist/web/src/components/ResetRail.js +3 -2
  36. package/dist/web/src/components/SubscriptionList.d.ts +2 -1
  37. package/dist/web/src/components/SubscriptionList.js +8 -5
  38. package/dist/web/src/components/WindowsRow.d.ts +30 -0
  39. package/dist/web/src/components/WindowsRow.js +68 -0
  40. package/dist/web/src/state.d.ts +12 -0
  41. package/dist/webAssets.js +1 -1
  42. package/dist/webHtml.js +1 -1
  43. package/package.json +1 -1
  44. package/web/dist/assets/index-CsOiHZyD.js +12 -0
  45. package/web/dist/assets/{index-NtT9XTrm.css → index-Dk6l5h27.css} +1 -1
  46. package/web/dist/index.html +2 -2
  47. package/web/dist/assets/index-CD8jaN87.js +0 -12
package/README.md CHANGED
@@ -18,13 +18,13 @@ QuotaCap helps you get more from the AI coding subscriptions you already pay for
18
18
 
19
19
  | Provider | Mechanism | Source |
20
20
  |---|---|---|
21
- | Claude Code | `exec` — `claude -p /usage --output-format json` | Live |
21
+ | Claude Code | `exec` — `claude -p /usage --output-format json --strict-mcp-config` | Live |
22
22
  | Antigravity | `exec` — `agy -p /usage --output-format json` (two rows: Antigravity for Gemini, Antigravity 3P for third-party models) | Live |
23
23
  | Codex | `pty` — `codex --no-alt-screen` then `/status`, parse `Weekly/5h limit: X% left` | Live |
24
24
  | Kimi Code | `pty` — `kimi` then `/usage`, parse `Weekly/5h limit: Y% used` | Live |
25
25
  | Grok | `pty` — `grok` then `/usage`, parse `Weekly limit (plan)` + `Credits: $X` | Live |
26
26
  | Muse Code | `pty` — `muse --trust-workspace` then `/usage`, parse `Subscription · Muse Code <plan>` + `Weekly/Current N% used`; on `Currently unavailable`, up to three headless `muse exec` warm turns and re-reads inside the poll | Live |
27
- | OpenCode Go | `api` — `GET https://opencode.ai/zen/go/v1/usage` with the OpenCode auth key; opt-in via `quotacap providers enable opencode-go` (consent recorded, disable revokes it) | Live (opt-in) |
27
+ | OpenCode Go | `api` — `GET https://opencode.ai/zen/go/v1/usage` with the OpenCode auth key; reports 5h, weekly and monthly windows; opt-in via `quotacap providers enable opencode-go` (consent recorded, disable revokes it) | Live (opt-in) |
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
 
@@ -29,8 +29,8 @@ function parseGroup(g, provider, rawJsonString, now) {
29
29
  return {
30
30
  provider,
31
31
  plan: "unknown",
32
- usedPct,
33
- sessionPct,
32
+ weeklyPct: usedPct,
33
+ fiveHourPct: sessionPct,
34
34
  resetsAt,
35
35
  periodStart,
36
36
  source: "cli",
@@ -13,7 +13,7 @@ export function parseClaudeUsage(result, now = new Date()) {
13
13
  const resets = new Date(resetsAt).getTime();
14
14
  const periodStart = (parsedReset ? new Date(resets - 7 * 86400000) : new Date(now.getTime() - 7 * 86400000)).toISOString();
15
15
  return {
16
- provider: "claude", plan: "max", usedPct, sessionPct,
16
+ provider: "claude", plan: "max", weeklyPct: usedPct, fiveHourPct: sessionPct,
17
17
  resetsAt, periodStart, source: "cli", fetchedAt: now.toISOString(), raw: result,
18
18
  };
19
19
  }
@@ -23,7 +23,10 @@ export const claudeAdapter = {
23
23
  execPath: "claude",
24
24
  async poll(execPath) {
25
25
  const bin = execPath ?? claudeAdapter.execPath ?? "claude";
26
- const { stdout } = await trackedExecFile("claude", bin, ["-p", "/usage", "--output-format", "json"], { timeout: 8000 });
26
+ // --strict-mcp-config: a usage read needs no MCP servers. Without it every
27
+ // poll boots the user's servers under the daemon's PATH, and their failures
28
+ // are cached and hide those servers from the user's own sessions.
29
+ const { stdout } = await trackedExecFile("claude", bin, ["-p", "/usage", "--output-format", "json", "--strict-mcp-config"], { timeout: 8000 });
27
30
  const parsed = JSON.parse(stdout);
28
31
  const result = parsed.result ?? stdout;
29
32
  return parseClaudeUsage(result);
@@ -123,8 +123,8 @@ export function parseCodexTui(text, now = new Date()) {
123
123
  return {
124
124
  provider: "codex",
125
125
  plan,
126
- usedPct,
127
- sessionPct,
126
+ weeklyPct: usedPct,
127
+ fiveHourPct: sessionPct,
128
128
  resetsAt: weeklyIso,
129
129
  periodStart,
130
130
  source: "tui",
@@ -133,7 +133,7 @@ export function parseGrokTui(text, now = new Date()) {
133
133
  return {
134
134
  provider: "grok",
135
135
  plan,
136
- usedPct,
136
+ weeklyPct: usedPct,
137
137
  resetsAt,
138
138
  periodStart,
139
139
  source: "tui",
@@ -51,8 +51,8 @@ export function parseKimiTui(text, now = new Date()) {
51
51
  return {
52
52
  provider: "kimi",
53
53
  plan,
54
- usedPct,
55
- sessionPct,
54
+ weeklyPct: usedPct,
55
+ fiveHourPct: sessionPct,
56
56
  resetsAt: weeklyIso,
57
57
  periodStart,
58
58
  source: "tui",
@@ -15,8 +15,8 @@ export function parseManualUsage(provider, text, now = new Date()) {
15
15
  return {
16
16
  provider,
17
17
  plan: "unknown",
18
- usedPct,
19
- sessionPct,
18
+ weeklyPct: usedPct,
19
+ fiveHourPct: sessionPct,
20
20
  resetsAt,
21
21
  periodStart,
22
22
  source: "manual",
@@ -115,8 +115,8 @@ export function parseMuseTui(text, now = new Date()) {
115
115
  return {
116
116
  provider: "muse",
117
117
  plan,
118
- usedPct,
119
- sessionPct,
118
+ weeklyPct: usedPct,
119
+ fiveHourPct: sessionPct,
120
120
  resetsAt,
121
121
  periodStart,
122
122
  source: "tui",
@@ -46,6 +46,32 @@ function readBearerToken(env = process.env) {
46
46
  }
47
47
  return entry.key;
48
48
  }
49
+ // `rate-limited` is the binding state, not a failure: it is exactly the
50
+ // reading issue 109 needed. Anything unreadable fails closed.
51
+ function parseMonthly(usage) {
52
+ const monthly = usage.monthly;
53
+ if (typeof monthly !== "object" || monthly === null)
54
+ return {};
55
+ const m = monthly;
56
+ if (m.status !== "ok" && m.status !== "rate-limited") {
57
+ throw new Error("opencode-go: unknown monthly status");
58
+ }
59
+ const percent = m.percent;
60
+ if (typeof percent !== "number" || !Number.isFinite(percent) || percent < 0) {
61
+ throw new Error("opencode-go: bad monthly pct");
62
+ }
63
+ const resetsMs = Date.parse(String(m.resetsAt ?? ""));
64
+ if (!Number.isFinite(resetsMs)) {
65
+ throw new Error("opencode-go: bad monthly reset");
66
+ }
67
+ const exhausted = m.status === "rate-limited" || percent > 100;
68
+ return {
69
+ monthlyKind: "included",
70
+ monthlyStatus: exhausted ? "exhausted" : "ok",
71
+ monthlyPct: exhausted ? 100 : percent,
72
+ monthlyResetsAt: new Date(resetsMs).toISOString(),
73
+ };
74
+ }
49
75
  export function parseOpencodeGoUsage(body, now = new Date()) {
50
76
  const usage = body?.usage;
51
77
  if (typeof usage !== "object" || usage === null) {
@@ -77,15 +103,17 @@ export function parseOpencodeGoUsage(body, now = new Date()) {
77
103
  if (typeof sessionPct !== "number" || !Number.isFinite(sessionPct) || sessionPct < 0 || sessionPct > 100) {
78
104
  throw new Error("opencode-go: bad rolling pct");
79
105
  }
106
+ const monthly = parseMonthly(usage);
80
107
  return {
81
108
  provider: "opencode-go",
82
109
  plan: "unknown",
83
- usedPct,
84
- sessionPct,
110
+ weeklyPct: usedPct,
111
+ fiveHourPct: sessionPct,
85
112
  resetsAt: new Date(resetsMs).toISOString(),
86
113
  periodStart: new Date(resetsMs - 7 * 86400000).toISOString(),
87
114
  source: "api",
88
115
  fetchedAt: now.toISOString(),
116
+ ...monthly,
89
117
  };
90
118
  }
91
119
  export const opencodeGoAdapter = {
@@ -1,8 +1,16 @@
1
1
  export interface Quota {
2
2
  provider: string;
3
3
  plan: string;
4
- usedPct: number;
5
- sessionPct?: number;
4
+ /** The paced window. 0..100. Named `usedPct` before this release. */
5
+ weeklyPct: number;
6
+ /** 5-hour rolling window. 0..100. Display only — never ranks. Was `sessionPct`. */
7
+ fiveHourPct?: number;
8
+ /** Included monthly envelope. 0..100. Absent means the provider has no monthly window. */
9
+ monthlyPct?: number;
10
+ monthlyResetsAt?: string;
11
+ monthlyStatus?: "ok" | "exhausted";
12
+ /** "included" only this slice. A spend cap or PAYG ceiling will carry its own value. */
13
+ monthlyKind?: "included";
6
14
  resetsAt: string;
7
15
  periodStart: string;
8
16
  source: "cli" | "api" | "scrape" | "manual" | "tui";
@@ -10,6 +18,13 @@ export interface Quota {
10
18
  creditsUsd?: number;
11
19
  resetsAtEstimated?: boolean;
12
20
  }
21
+ /** Store and wire shape: `Quota` plus the one-release aliases. */
22
+ export interface QuotaWire extends Quota {
23
+ /** @deprecated alias for weeklyPct. Removed the release after next. */
24
+ usedPct: number;
25
+ /** @deprecated alias for fiveHourPct. Removed the release after next. */
26
+ sessionPct?: number;
27
+ }
13
28
  export interface ParsedQuota extends Quota {
14
29
  raw?: string;
15
30
  }
@@ -1,9 +1,9 @@
1
1
  import type { Quota } from "../adapters/types.js";
2
- import type { Advisory, RecommendationBasis } from "./types.js";
2
+ import type { Advisory, RecommendationBasis, BindingWindow } from "./types.js";
3
3
  /**
4
4
  * Pace from a single reading.
5
5
  *
6
- * `usedPct` is a running total since the window opened, and `periodStart` is
6
+ * `weeklyPct` is a running total since the window opened, and `periodStart` is
7
7
  * a required field every adapter sets, so a pace is always computable from
8
8
  * one poll. Returns null when `periodStart` is missing or unparseable, or
9
9
  * when less than MIN_PACE_SPAN_DAYS has elapsed (annualizing a smaller
@@ -22,6 +22,7 @@ export declare function recommend(quotas: Quota[], _task: string, paceByProvider
22
22
  wastePct: number;
23
23
  idealRate: number;
24
24
  recommendationBasis: RecommendationBasis;
25
+ bindingWindow: BindingWindow;
25
26
  alternatives: Quota[];
26
27
  advisories: Advisory[];
27
28
  } | {
@@ -30,6 +31,7 @@ export declare function recommend(quotas: Quota[], _task: string, paceByProvider
30
31
  wastePct: null;
31
32
  idealRate: number;
32
33
  recommendationBasis: RecommendationBasis;
34
+ bindingWindow: BindingWindow;
33
35
  alternatives: Quota[];
34
36
  advisories: Advisory[];
35
37
  };
@@ -3,7 +3,7 @@ const DAY_MS = 86400000;
3
3
  /**
4
4
  * Pace from a single reading.
5
5
  *
6
- * `usedPct` is a running total since the window opened, and `periodStart` is
6
+ * `weeklyPct` is a running total since the window opened, and `periodStart` is
7
7
  * a required field every adapter sets, so a pace is always computable from
8
8
  * one poll. Returns null when `periodStart` is missing or unparseable, or
9
9
  * when less than MIN_PACE_SPAN_DAYS has elapsed (annualizing a smaller
@@ -18,7 +18,7 @@ export function averagePace(q, now = new Date()) {
18
18
  const elapsedDays = (now.getTime() - start) / DAY_MS;
19
19
  if (elapsedDays < MIN_PACE_SPAN_DAYS)
20
20
  return null;
21
- const pace = q.usedPct / elapsedDays;
21
+ const pace = q.weeklyPct / elapsedDays;
22
22
  if (!Number.isFinite(pace))
23
23
  return null;
24
24
  return Math.max(0, pace);
@@ -60,13 +60,29 @@ function elapsedPctOf(q, now) {
60
60
  export function computeAdvisory(q, recent, baseline, now = new Date()) {
61
61
  const resets = new Date(q.resetsAt);
62
62
  const daysLeft = Math.max(0.1, (resets.getTime() - now.getTime()) / DAY_MS);
63
- const remaining = 100 - q.usedPct;
63
+ const remaining = 100 - q.weeklyPct;
64
64
  const idealRate = remaining / daysLeft;
65
65
  const avgPace = averagePace(q, now);
66
66
  // Nothing left to burn: force the capped verdict even when the recent
67
67
  // window is flat (burn 0 at 100% used must not read "on track").
68
68
  const exhausted = remaining <= 0;
69
69
  const burnRate = blendForecast(recent, baseline);
70
+ // Above every return: the unknown-pace path below is exactly what pool 2
71
+ // ranks, so a binding computed later would leave it on weekly numbers.
72
+ const weeklyHeadroom = remaining / daysLeft;
73
+ let bindingWindow = "weekly";
74
+ let bindingRemaining = remaining;
75
+ let bindingDaysLeft = daysLeft;
76
+ if (q.monthlyKind === "included" && typeof q.monthlyPct === "number" && q.monthlyResetsAt) {
77
+ const monthlyRemaining = q.monthlyStatus === "exhausted" ? 0 : Math.max(0, 100 - q.monthlyPct);
78
+ const monthlyDaysLeft = Math.max(0.1, (new Date(q.monthlyResetsAt).getTime() - now.getTime()) / DAY_MS);
79
+ const monthlyHeadroom = monthlyRemaining / monthlyDaysLeft;
80
+ if (monthlyHeadroom < weeklyHeadroom || monthlyRemaining === 0) {
81
+ bindingWindow = "monthly";
82
+ bindingRemaining = monthlyRemaining;
83
+ bindingDaysLeft = monthlyDaysLeft;
84
+ }
85
+ }
70
86
  // paceSource describes recent availability; the blend's composition is
71
87
  // carried by recentRate and baselineRate.
72
88
  const paceSource = recent !== null ? "recent" : burnRate !== null ? "window-average" : "unknown";
@@ -88,6 +104,9 @@ export function computeAdvisory(q, recent, baseline, now = new Date()) {
88
104
  status: exhausted ? "at risk" : "unknown",
89
105
  wastePct: exhausted ? 0 : null,
90
106
  urgency: exhausted ? "slow down" : "on track",
107
+ bindingWindow,
108
+ bindingRemaining,
109
+ bindingDaysLeft,
91
110
  };
92
111
  }
93
112
  if (exhausted) {
@@ -107,14 +126,17 @@ export function computeAdvisory(q, recent, baseline, now = new Date()) {
107
126
  status: "at risk",
108
127
  wastePct: 0,
109
128
  urgency: "slow down",
129
+ bindingWindow,
130
+ bindingRemaining,
131
+ bindingDaysLeft,
110
132
  };
111
133
  }
112
134
  // Red needs a deadband margin and a cumulatively-ahead position on a
113
135
  // verified clock; a missing or estimated clock caps at Watch.
114
136
  const elapsed = elapsedPctOf(q, now);
115
137
  const estimated = q.resetsAtEstimated === true;
116
- const gate = elapsed !== null && !estimated && q.usedPct >= elapsed * (1 - GATE_TOL);
117
- const aheadOfElapsed = elapsed !== null && !estimated && q.usedPct > elapsed * (1 + GATE_TOL);
138
+ const gate = elapsed !== null && !estimated && q.weeklyPct >= elapsed * (1 - GATE_TOL);
139
+ const aheadOfElapsed = elapsed !== null && !estimated && q.weeklyPct > elapsed * (1 + GATE_TOL);
118
140
  const wastePct = Math.max(0, remaining - burnRate * daysLeft);
119
141
  const daysToExhaust = burnRate > 0 ? remaining / burnRate : Infinity;
120
142
  const over = daysToExhaust < daysLeft * (1 - RISK_MARGIN);
@@ -137,7 +159,18 @@ export function computeAdvisory(q, recent, baseline, now = new Date()) {
137
159
  urgency = "save";
138
160
  if (estimated && urgency === "burn now")
139
161
  urgency = "use soon";
140
- return { provider: q.provider, daysLeft, remaining, idealRate, burnRate, recentRate: recent, baselineRate: baseline, aheadOfElapsed, avgPace, burnMeasured, paceSource, daysToExhaust, status, wastePct, urgency };
162
+ return { provider: q.provider, daysLeft, remaining, idealRate, burnRate, recentRate: recent, baselineRate: baseline, aheadOfElapsed, avgPace, burnMeasured, paceSource, daysToExhaust, status, wastePct, urgency, bindingWindow, bindingRemaining, bindingDaysLeft };
163
+ }
164
+ /**
165
+ * Known-waste sort key. `wastePct` is a flow (budget forecast to go unspent
166
+ * by the weekly deadline); `bindingRemaining` is a stock. The binding window's
167
+ * headroom per day, priced over the weekly days-left the pool sorts on, caps
168
+ * the claim at what the account can actually absorb. Weekly-bound providers
169
+ * are untouched: the ratio is 1 and `wastePct <= remaining` always.
170
+ */
171
+ function rankWaste(a) {
172
+ const affordable = a.bindingRemaining * (a.daysLeft / a.bindingDaysLeft);
173
+ return Math.min(a.wastePct, affordable);
141
174
  }
142
175
  export function recommend(quotas, _task, paceByProvider = new Map(), now = new Date()) {
143
176
  if (!quotas.length) {
@@ -147,6 +180,7 @@ export function recommend(quotas, _task, paceByProvider = new Map(), now = new D
147
180
  wastePct: 0,
148
181
  idealRate: 0,
149
182
  recommendationBasis: "none",
183
+ bindingWindow: "weekly",
150
184
  alternatives: [],
151
185
  advisories: [],
152
186
  };
@@ -162,32 +196,38 @@ export function recommend(quotas, _task, paceByProvider = new Map(), now = new D
162
196
  // are a fallback pool, read from the flags on the quotas already received.
163
197
  const estimatedProviders = new Set(quotas.filter(q => q.resetsAtEstimated === true).map(q => q.provider));
164
198
  // 1. Prefer measured providers with positive avoidable waste (actionable "Use more" candidates).
165
- const positiveWaste = advisories.filter((a) => a.paceSource !== "unknown" && a.wastePct !== null && a.wastePct > 0);
199
+ const positiveWaste = advisories.filter((a) => a.paceSource !== "unknown" && a.wastePct !== null && a.wastePct > 0 && a.bindingRemaining > 0);
166
200
  if (positiveWaste.length > 0) {
167
201
  const verified = positiveWaste.filter(a => !estimatedProviders.has(a.provider));
168
202
  const pool = verified.length ? verified : positiveWaste.filter(a => estimatedProviders.has(a.provider));
169
- const burnNow = pool.filter(a => a.urgency === "burn now");
170
- const use = (burnNow.length ? burnNow : pool).sort((a, b) => b.wastePct - a.wastePct)[0];
203
+ const burnNow = pool.filter(a => a.urgency === "burn now" && a.bindingWindow === "weekly");
204
+ const use = (burnNow.length ? burnNow : pool).sort((a, b) => rankWaste(b) - rankWaste(a))[0];
171
205
  return {
172
206
  use: use.provider,
173
- reason: `${Math.round(use.wastePct)}% waste in ${use.daysLeft.toFixed(1)}d`,
174
- wastePct: use.wastePct,
207
+ reason: use.bindingWindow === "monthly"
208
+ ? `${Math.round(use.bindingRemaining)}% of month left in ${use.bindingDaysLeft.toFixed(1)}d`
209
+ : `${Math.round(use.wastePct)}% waste in ${use.daysLeft.toFixed(1)}d`,
210
+ wastePct: rankWaste(use),
175
211
  idealRate: use.idealRate,
176
212
  recommendationBasis: "known-waste",
213
+ bindingWindow: use.bindingWindow,
177
214
  alternatives: quotas,
178
215
  advisories,
179
216
  };
180
217
  }
181
218
  // 2. Low-confidence fallback: rank unknown providers by remaining headroom (remaining / daysLeft).
182
- const unknownProviders = advisories.filter(a => a.paceSource === "unknown" && a.remaining > 0);
219
+ const unknownProviders = advisories.filter(a => a.paceSource === "unknown" && a.bindingRemaining > 0);
183
220
  if (unknownProviders.length > 0) {
184
- const use = unknownProviders.sort((a, b) => (b.remaining / b.daysLeft) - (a.remaining / a.daysLeft))[0];
221
+ const use = unknownProviders.sort((a, b) => (b.bindingRemaining / b.bindingDaysLeft) - (a.bindingRemaining / a.bindingDaysLeft))[0];
185
222
  return {
186
223
  use: use.provider,
187
- reason: `Measuring pace; ${Math.round(use.remaining)}% remains with ${use.daysLeft.toFixed(1)}d until reset`,
224
+ reason: use.bindingWindow === "monthly"
225
+ ? `Measuring pace; ${Math.round(use.bindingRemaining)}% of month left with ${use.bindingDaysLeft.toFixed(1)}d until reset`
226
+ : `Measuring pace; ${Math.round(use.remaining)}% remains with ${use.daysLeft.toFixed(1)}d until reset`,
188
227
  wastePct: null,
189
228
  idealRate: use.idealRate,
190
229
  recommendationBasis: "unknown-headroom",
230
+ bindingWindow: use.bindingWindow,
191
231
  alternatives: quotas,
192
232
  advisories,
193
233
  };
@@ -199,6 +239,7 @@ export function recommend(quotas, _task, paceByProvider = new Map(), now = new D
199
239
  wastePct: 0,
200
240
  idealRate: 0,
201
241
  recommendationBasis: "none",
242
+ bindingWindow: "weekly",
202
243
  alternatives: quotas,
203
244
  advisories,
204
245
  };
@@ -20,6 +20,13 @@ function normalizeAdvisory(adv) {
20
20
  avgPace: adv.avgPace !== null && Number.isFinite(adv.avgPace) ? adv.avgPace : null,
21
21
  daysToExhaust: adv.daysToExhaust !== null && Number.isFinite(adv.daysToExhaust) ? adv.daysToExhaust : null,
22
22
  wastePct: adv.wastePct !== null && Number.isFinite(adv.wastePct) ? adv.wastePct : null,
23
+ bindingWindow: adv.bindingWindow === "monthly" ? "monthly" : "weekly",
24
+ bindingRemaining: Number.isFinite(adv.bindingRemaining)
25
+ ? adv.bindingRemaining
26
+ : Number.isFinite(adv.remaining) ? adv.remaining : 0,
27
+ bindingDaysLeft: Number.isFinite(adv.bindingDaysLeft)
28
+ ? adv.bindingDaysLeft
29
+ : Number.isFinite(adv.daysLeft) ? adv.daysLeft : 0,
23
30
  };
24
31
  }
25
32
  export function buildSnapshot(db, opts) {
@@ -67,9 +74,9 @@ export function buildSnapshot(db, opts) {
67
74
  if (quota !== null) {
68
75
  const resetsMs = new Date(quota.resetsAt).getTime();
69
76
  const fetchedMs = new Date(quota.fetchedAt).getTime();
70
- if (!Number.isFinite(quota.usedPct) ||
71
- quota.usedPct < 0 ||
72
- quota.usedPct > 100 ||
77
+ if (!Number.isFinite(quota.weeklyPct) ||
78
+ quota.weeklyPct < 0 ||
79
+ quota.weeklyPct > 100 ||
73
80
  Number.isNaN(resetsMs) ||
74
81
  Number.isNaN(fetchedMs)) {
75
82
  isInvalid = true;
@@ -168,6 +175,7 @@ export function buildSnapshot(db, opts) {
168
175
  wastePct: null,
169
176
  idealRate: 0,
170
177
  recommendationBasis: "none",
178
+ bindingWindow: "weekly",
171
179
  models: [],
172
180
  catalogStatus: "unfetched",
173
181
  catalogFetchedAt: null,
@@ -1,4 +1,4 @@
1
- import type { Quota } from "../adapters/types.js";
1
+ import type { QuotaWire } from "../adapters/types.js";
2
2
  import type { AttemptRecord } from "../store/attempts.js";
3
3
  import type { CatalogStatus, CatalogView, ListedModel } from "../catalog/types.js";
4
4
  /**
@@ -15,6 +15,7 @@ export type Urgency = "burn now" | "use soon" | "slow down" | "save" | "on track
15
15
  export type BurnStatus = "at risk" | "watch" | "on track" | "unknown";
16
16
  export type PaceSource = "recent" | "window-average" | "unknown";
17
17
  export type RecommendationBasis = "known-waste" | "unknown-headroom" | "none";
18
+ export type BindingWindow = "weekly" | "monthly";
18
19
  export type ExclusionReason = "not-reporting" | "stale" | "reset-passed" | "invalid" | "provider-failed" | null;
19
20
  export type { AttemptRecord };
20
21
  export interface Advisory {
@@ -39,6 +40,12 @@ export interface Advisory {
39
40
  status: BurnStatus;
40
41
  wastePct: number | null;
41
42
  urgency: Urgency;
43
+ /** Which included window is scarcer per day left. "weekly" when there is no included monthly. */
44
+ bindingWindow: BindingWindow;
45
+ /** Remaining percent of the binding window. Equals `remaining` when weekly binds. */
46
+ bindingRemaining: number;
47
+ /** Days until the binding window resets. Equals `daysLeft` when weekly binds. */
48
+ bindingDaysLeft: number;
42
49
  }
43
50
  export interface Recommendation {
44
51
  use: string;
@@ -46,10 +53,11 @@ export interface Recommendation {
46
53
  wastePct: number | null;
47
54
  idealRate: number;
48
55
  recommendationBasis: RecommendationBasis;
56
+ bindingWindow: BindingWindow;
49
57
  models: ListedModel[];
50
58
  catalogStatus: CatalogStatus;
51
59
  catalogFetchedAt: string | null;
52
- alternatives: Array<Quota & {
60
+ alternatives: Array<QuotaWire & {
53
61
  catalog: CatalogView;
54
62
  }>;
55
63
  advisories: Advisory[];
@@ -74,7 +82,7 @@ export interface ProviderSnapshot {
74
82
  harness: string | null;
75
83
  description: string | null;
76
84
  enabled: boolean;
77
- quota: Quota | null;
85
+ quota: QuotaWire | null;
78
86
  lastAttempt: AttemptRecord | null;
79
87
  lastSuccessAt: string | null;
80
88
  reporting: boolean;
@@ -56,7 +56,8 @@ export const claudeCatalogFetcher = {
56
56
  // Reuse the pinned claude path the daemon resolves (claudeAdapter.execPath).
57
57
  const bin = claudeAdapter.execPath ?? "claude";
58
58
  // Per-fetch catalog-owned signal: never the usage poll's shared controller.
59
- const { stdout } = await trackedExecFile("claude", bin, ["-p", "/model", "--output-format", "json"], {
59
+ // No MCP servers for a model listing; see the usage adapter for why.
60
+ const { stdout } = await trackedExecFile("claude", bin, ["-p", "/model", "--output-format", "json", "--strict-mcp-config"], {
60
61
  timeout: EXEC_TIMEOUT_MS,
61
62
  signal: new AbortController().signal,
62
63
  });
@@ -11,7 +11,7 @@ function projectModels(catalogs, quotaMap, providerNames) {
11
11
  for (const [id, catalog] of catalogs) {
12
12
  const identity = providerIdentity(id, providerNames);
13
13
  const quota = quotaMap.get(id);
14
- const leftoverPct = quota?.usedPct != null ? Math.round((100 - quota.usedPct) * 10) / 10 : null;
14
+ const leftoverPct = quota?.weeklyPct != null ? Math.round((100 - quota.weeklyPct) * 10) / 10 : null;
15
15
  out.push({
16
16
  id,
17
17
  displayName: identity.displayName,
@@ -232,6 +232,15 @@ function matchPrecedence(evidence, errorName, errorCode, text) {
232
232
  if (/\bbad rolling pct\b/i.test(text)) {
233
233
  return { code: "parse_error", phrase: "bad rolling pct" };
234
234
  }
235
+ if (/\bbad monthly pct\b/i.test(text)) {
236
+ return { code: "parse_error", phrase: "bad monthly pct" };
237
+ }
238
+ if (/\bbad monthly reset\b/i.test(text)) {
239
+ return { code: "parse_error", phrase: "bad monthly reset" };
240
+ }
241
+ if (/\bunknown monthly status\b/i.test(text)) {
242
+ return { code: "parse_error", phrase: "unknown monthly status" };
243
+ }
235
244
  if (/\busage status not ok\b/i.test(text)) {
236
245
  return { code: "parse_error", phrase: "usage status not ok" };
237
246
  }
@@ -1,5 +1,5 @@
1
1
  import type { ProviderSnapshot } from "../advisory/types.js";
2
- export type StateWord = "On track" | "Behind pace" | "Ahead of pace" | "Cap risk" | "Watch" | "Measuring" | "Not reporting";
2
+ export type StateWord = "On track" | "Behind pace" | "Ahead of pace" | "Cap risk" | "Watch" | "Measuring" | "Not reporting" | "Monthly exhausted";
3
3
  export type RailCell = "used" | "tick" | "unused";
4
4
  export type SortKey = "recommended" | "reset-asc" | "reset-desc" | "used-desc" | "used-asc";
5
5
  export type CompactSuffix = "" | "!" | "~" | "?";
@@ -16,7 +16,12 @@ export interface Row {
16
16
  }
17
17
  export declare const RAIL_CELLS = 20;
18
18
  export declare const SORT_KEYS: SortKey[];
19
+ export declare function weeklyPctOf(q: {
20
+ weeklyPct?: number;
21
+ usedPct?: number;
22
+ } | null | undefined): number | undefined;
19
23
  export declare function fmtDuration(ms: number): string;
24
+ export declare function weekdayOf(iso: string | undefined): string;
20
25
  export declare function countdownText(ps: ProviderSnapshot, now: Date): string;
21
26
  export declare function elapsedPct(ps: ProviderSnapshot, now: Date): number | null;
22
27
  export declare function usedPctOf(ps: ProviderSnapshot): number | null;
@@ -9,6 +9,11 @@ function msOf(iso) {
9
9
  const ms = new Date(iso).getTime();
10
10
  return Number.isNaN(ms) ? null : ms;
11
11
  }
12
+ // The CLI talks HTTP to a daemon that may predate the rename, which sent only
13
+ // `usedPct`. Read both for one release; drop the fallback with the aliases.
14
+ export function weeklyPctOf(q) {
15
+ return q?.weeklyPct ?? q?.usedPct;
16
+ }
12
17
  // Compact duration: 7d / 12h / 45m. Countdown convention floors; sub-minute
13
18
  // spans read 1m so a live window never prints 0m.
14
19
  export function fmtDuration(ms) {
@@ -18,6 +23,12 @@ export function fmtDuration(ms) {
18
23
  return `${Math.floor(ms / HOUR_MS)}h`;
19
24
  return `${Math.max(1, Math.floor(ms / MIN_MS))}m`;
20
25
  }
26
+ // Short weekday of an ISO instant in the locked English copy, e.g. "Tue".
27
+ // Pinned to en-US: the sentence it feeds ("unused until Tue") is English, so
28
+ // a non-English machine must not print a localized weekday inside it.
29
+ export function weekdayOf(iso) {
30
+ return new Date(iso ?? "").toLocaleDateString("en-US", { weekday: "short" });
31
+ }
21
32
  export function countdownText(ps, now) {
22
33
  const resetsMs = msOf(ps.quota?.resetsAt);
23
34
  let text;
@@ -40,14 +51,14 @@ export function elapsedPct(ps, now) {
40
51
  return Math.round(Math.min(100, Math.max(0, pct)));
41
52
  }
42
53
  export function usedPctOf(ps) {
43
- const u = ps.quota?.usedPct;
54
+ const u = weeklyPctOf(ps.quota);
44
55
  return typeof u === "number" && Number.isFinite(u) ? Math.round(u) : null;
45
56
  }
46
57
  export function railCells(ps, now) {
47
58
  const cells = new Array(RAIL_CELLS).fill("unused");
48
59
  if (ps.exclusionReason !== null)
49
60
  return cells;
50
- const u = ps.quota?.usedPct;
61
+ const u = weeklyPctOf(ps.quota);
51
62
  const used = typeof u === "number" && Number.isFinite(u)
52
63
  ? Math.min(RAIL_CELLS, Math.max(0, Math.round((u / 100) * RAIL_CELLS)))
53
64
  : 0;
@@ -70,6 +81,10 @@ export function stateWord(ps) {
70
81
  const adv = ps.advisory;
71
82
  if (!adv)
72
83
  return "Not reporting";
84
+ // A blocked month outranks every weekly verdict: weekly leftover cannot be
85
+ // spent until the month resets.
86
+ if (adv.bindingWindow === "monthly" && adv.bindingRemaining === 0)
87
+ return "Monthly exhausted";
73
88
  if (adv.status === "at risk")
74
89
  return "Cap risk";
75
90
  if (adv.status === "watch")
@@ -124,7 +139,13 @@ export function forecastText(ps, now) {
124
139
  if (!adv)
125
140
  return "no readings yet";
126
141
  let text;
127
- if (adv.remaining <= 0) {
142
+ if (adv.bindingWindow === "monthly" && adv.bindingRemaining <= 0) {
143
+ text = `unused until ${weekdayOf(ps.quota?.monthlyResetsAt)}`;
144
+ }
145
+ else if (adv.bindingWindow === "monthly") {
146
+ text = `${Math.round(adv.bindingRemaining)}% of month left`;
147
+ }
148
+ else if (adv.remaining <= 0) {
128
149
  text = "Exhausted";
129
150
  }
130
151
  else if (adv.paceSource === "unknown" || adv.burnRate === null) {
@@ -179,7 +200,7 @@ function wasteOf(p) {
179
200
  return typeof w === "number" && Number.isFinite(w) ? w : null;
180
201
  }
181
202
  function finiteUsed(p) {
182
- const u = p.quota?.usedPct;
203
+ const u = weeklyPctOf(p.quota);
183
204
  return typeof u === "number" && Number.isFinite(u) ? u : null;
184
205
  }
185
206
  export function sortProviders(providers, key, use) {
@@ -192,6 +213,8 @@ export function sortProviders(providers, key, use) {
192
213
  return 0;
193
214
  if (p.exclusionReason !== null)
194
215
  return 2;
216
+ if (p.advisory?.bindingWindow === "monthly" && p.advisory.bindingRemaining === 0)
217
+ return 2;
195
218
  return 1;
196
219
  };
197
220
  indexed.sort((a, b) => {
@@ -1,4 +1,4 @@
1
- import { buildRow, compactSuffix, sortProviders, } from "./rows.js";
1
+ import { buildRow, compactSuffix, sortProviders, weeklyPctOf, } from "./rows.js";
2
2
  const GAP = " ";
3
3
  const MIN_PROV_WIDTH = 12;
4
4
  const MAX_PROV_WIDTH = 22;
@@ -32,6 +32,7 @@ const STATE_COLORS = {
32
32
  "Cap risk": "\x1b[31m",
33
33
  "Measuring": "\x1b[90m",
34
34
  "Not reporting": "\x1b[90m",
35
+ "Monthly exhausted": "\x1b[31m",
35
36
  };
36
37
  // provWidth covers the 2-char prefix plus name, measured before ANSI.
37
38
  export function provWidthFor(ids) {
@@ -92,7 +93,7 @@ export function renderWide(snapshot, opts) {
92
93
  "PACE (%/DAY)".padEnd(paceWidth),
93
94
  "USED VS TIME".padEnd(20),
94
95
  "RESETS".padEnd(9),
95
- "STATE".padEnd(15),
96
+ "STATE".padEnd(19),
96
97
  "FORECAST",
97
98
  ].join(GAP);
98
99
  const lines = rows.map(({ p, row }) => {
@@ -103,7 +104,7 @@ export function renderWide(snapshot, opts) {
103
104
  const pace = (opts.ascii ? asciiText(row.pace) : row.pace).padEnd(paceWidth);
104
105
  const rail = renderRail(row.railCells, g, row.state, color);
105
106
  const countdown = (opts.ascii ? asciiText(row.countdown) : row.countdown).padEnd(9);
106
- const state = row.state.padEnd(15);
107
+ const state = row.state.padEnd(19);
107
108
  const forecast = opts.ascii ? asciiText(row.forecast) : row.forecast;
108
109
  const main = [
109
110
  color ? colorProviderField(name, recommended, row.state) : name,
@@ -158,7 +159,7 @@ export function renderCompact(snapshot, _now) {
158
159
  const entries = sorted
159
160
  .filter((p) => p.quota !== null)
160
161
  .map((p) => {
161
- const u = p.quota?.usedPct;
162
+ const u = weeklyPctOf(p.quota);
162
163
  const used = typeof u === "number" && Number.isFinite(u) ? String(Math.round(u)) : "?";
163
164
  return `[${p.id}:${used}%${compactSuffix(p)}]`;
164
165
  });