quotacap 0.0.39 → 0.0.41

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 (68) hide show
  1. package/README.md +1 -1
  2. package/dist/adapters/agy.js +2 -2
  3. package/dist/adapters/claude.js +1 -1
  4. package/dist/adapters/codex.js +29 -20
  5. package/dist/adapters/grok.js +1 -1
  6. package/dist/adapters/kimi.d.ts +41 -0
  7. package/dist/adapters/kimi.js +453 -21
  8. package/dist/adapters/manual.js +2 -2
  9. package/dist/adapters/muse.js +3 -3
  10. package/dist/adapters/opencode-go.js +30 -2
  11. package/dist/adapters/pty.d.ts +9 -0
  12. package/dist/adapters/pty.js +16 -0
  13. package/dist/adapters/types.d.ts +17 -2
  14. package/dist/advisory/engine.d.ts +4 -2
  15. package/dist/advisory/engine.js +55 -14
  16. package/dist/advisory/snapshot.js +11 -3
  17. package/dist/advisory/types.d.ts +11 -3
  18. package/dist/cli/advise.js +1 -0
  19. package/dist/cli/clients.d.ts +4 -0
  20. package/dist/cli/models.js +5 -1
  21. package/dist/cli/progress.d.ts +32 -0
  22. package/dist/cli/progress.js +70 -0
  23. package/dist/cli/runtime.d.ts +5 -0
  24. package/dist/cli/runtime.js +18 -8
  25. package/dist/cli/status.js +1 -0
  26. package/dist/cli/takeover.d.ts +3 -0
  27. package/dist/cli/takeover.js +7 -1
  28. package/dist/cli/update.d.ts +4 -0
  29. package/dist/cli/update.js +8 -1
  30. package/dist/diagnostics/failure.js +46 -11
  31. package/dist/format/rows.d.ts +6 -1
  32. package/dist/format/rows.js +27 -4
  33. package/dist/format/terminal.js +5 -4
  34. package/dist/http/server.js +3 -3
  35. package/dist/runtime/poll.js +1 -1
  36. package/dist/service/index.js +3 -1
  37. package/dist/service/macos.d.ts +2 -0
  38. package/dist/service/macos.js +4 -1
  39. package/dist/service/systemd.d.ts +2 -0
  40. package/dist/service/systemd.js +5 -3
  41. package/dist/store/db.js +12 -1
  42. package/dist/store/quotas.js +33 -8
  43. package/dist/version.js +1 -1
  44. package/dist/web/src/components/AdviceDrawer.js +10 -5
  45. package/dist/web/src/components/FaultBanner.d.ts +10 -5
  46. package/dist/web/src/components/FaultBanner.js +19 -9
  47. package/dist/web/src/components/PaceBar.d.ts +9 -5
  48. package/dist/web/src/components/PaceBar.js +32 -10
  49. package/dist/web/src/components/ProviderCard.js +4 -2
  50. package/dist/web/src/components/ProviderDrawer.d.ts +7 -1
  51. package/dist/web/src/components/ProviderDrawer.js +46 -9
  52. package/dist/web/src/components/ProviderRow.js +4 -4
  53. package/dist/web/src/components/Recommendation.d.ts +5 -2
  54. package/dist/web/src/components/Recommendation.js +8 -4
  55. package/dist/web/src/components/ResetRail.js +3 -2
  56. package/dist/web/src/components/SubscriptionList.d.ts +2 -1
  57. package/dist/web/src/components/SubscriptionList.js +8 -5
  58. package/dist/web/src/components/WindowsRow.d.ts +30 -0
  59. package/dist/web/src/components/WindowsRow.js +68 -0
  60. package/dist/web/src/pages/Onboarding.js +2 -1
  61. package/dist/web/src/state.d.ts +12 -0
  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-BUK8xUHm.js +12 -0
  66. package/web/dist/assets/{index-NtT9XTrm.css → index-Dk6l5h27.css} +1 -1
  67. package/web/dist/index.html +2 -2
  68. package/web/dist/assets/index-CD8jaN87.js +0 -12
@@ -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",
@@ -177,7 +177,7 @@ export const museAdapter = {
177
177
  unavailable: e,
178
178
  usagePass: (timeoutMs) => usagePass(timeoutMs),
179
179
  warmTurn: async (timeoutMs) => {
180
- await trackedExecFile("muse", "muse", ["exec", "hi"], {
180
+ await trackedExecFile("muse", "muse", ["exec", "1+1"], {
181
181
  cwd: museProbeDir(),
182
182
  env: { ...process.env, MUSE_NO_AUTO_UPDATE: "1" },
183
183
  timeout: timeoutMs,
@@ -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 = {
@@ -29,6 +29,15 @@ export interface PtyRunOptions {
29
29
  */
30
30
  respondToQueries?: boolean;
31
31
  completionRegex?: RegExp;
32
+ /**
33
+ * Optional initial prompt responder before readyRegex matches.
34
+ * If matched against the cleaned transcript during the pre-ready poll loop,
35
+ * sends `reply` once to dismiss or answer the prompt (e.g. workspace trust in a probe directory).
36
+ */
37
+ preReadyResponse?: {
38
+ match: RegExp;
39
+ reply: string;
40
+ };
32
41
  /** If matched, abort immediately with a clear error (e.g. trust prompts). */
33
42
  abortOn?: RegExp;
34
43
  timeoutMs: number;
@@ -329,6 +329,7 @@ async function runPtyBun(opts) {
329
329
  if (opts.readyRegex) {
330
330
  const deadline = Date.now() + readyTimeoutMs;
331
331
  let matched = false;
332
+ let preReadySent = false;
332
333
  while (Date.now() < deadline) {
333
334
  checkCap();
334
335
  checkAborted();
@@ -341,6 +342,13 @@ async function runPtyBun(opts) {
341
342
  });
342
343
  }
343
344
  const clean = stripAnsi(transcript);
345
+ if (opts.preReadyResponse && !preReadySent && regexTest(opts.preReadyResponse.match, clean)) {
346
+ preReadySent = true;
347
+ try {
348
+ proc.terminal.write(opts.preReadyResponse.reply);
349
+ }
350
+ catch { }
351
+ }
344
352
  checkAbort(clean);
345
353
  if (regexTest(opts.readyRegex, clean)) {
346
354
  matched = true;
@@ -633,6 +641,7 @@ async function runPtyNode(opts) {
633
641
  if (opts.readyRegex) {
634
642
  const deadline = Date.now() + readyTimeoutMs;
635
643
  let matched = false;
644
+ let preReadySent = false;
636
645
  while (Date.now() < deadline) {
637
646
  checkCap();
638
647
  checkAborted();
@@ -645,6 +654,13 @@ async function runPtyNode(opts) {
645
654
  });
646
655
  }
647
656
  const clean = stripAnsi(transcript);
657
+ if (opts.preReadyResponse && !preReadySent && regexTest(opts.preReadyResponse.match, clean)) {
658
+ preReadySent = true;
659
+ try {
660
+ ptyProcess.write(opts.preReadyResponse.reply);
661
+ }
662
+ catch { }
663
+ }
648
664
  checkAbort(clean);
649
665
  if (regexTest(opts.readyRegex, clean)) {
650
666
  matched = true;
@@ -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;
@@ -82,6 +82,7 @@ export function registerAdviseCommand(program, deps) {
82
82
  createClient,
83
83
  execService,
84
84
  ...takeoverOpts,
85
+ json: !!o.json || takeoverOpts.json,
85
86
  });
86
87
  console.error(r.message);
87
88
  // The takeover verified the daemon now reports the CLI version;
@@ -3,6 +3,7 @@ import type { ServiceClient } from "../runtime/client.js";
3
3
  import type { CatalogFetcher } from "../catalog/index.js";
4
4
  import type { SleepFn } from "./takeover.js";
5
5
  import type { UpdateCache } from "../runtime/updates.js";
6
+ import type { PhaseFn } from "./progress.js";
6
7
  export interface CreateClientOptions {
7
8
  port: number;
8
9
  token?: string;
@@ -24,8 +25,11 @@ export interface ClientCommandDeps {
24
25
  sleep?: SleepFn;
25
26
  timeoutMs?: number;
26
27
  readToken?: () => string | undefined;
28
+ phase?: PhaseFn;
29
+ json?: boolean;
27
30
  };
28
31
  checkUpdates?: () => Promise<UpdateCache | null>;
29
32
  catalogFetchers?: Record<string, CatalogFetcher>;
33
+ phase?: PhaseFn;
30
34
  }
31
35
  export declare function registerClientCommands(program: Command, deps?: ClientCommandDeps): void;
@@ -6,12 +6,13 @@ import { providerIdentity } from "../advisory/provider-names.js";
6
6
  import { readToken } from "../runtime/token.js";
7
7
  import { createServiceClient } from "../runtime/client.js";
8
8
  import { OFFLINE_LABEL } from "./snapshot-source.js";
9
+ import { phase } from "./progress.js";
9
10
  function projectModels(catalogs, quotaMap, providerNames) {
10
11
  const out = [];
11
12
  for (const [id, catalog] of catalogs) {
12
13
  const identity = providerIdentity(id, providerNames);
13
14
  const quota = quotaMap.get(id);
14
- const leftoverPct = quota?.usedPct != null ? Math.round((100 - quota.usedPct) * 10) / 10 : null;
15
+ const leftoverPct = quota?.weeklyPct != null ? Math.round((100 - quota.weeklyPct) * 10) / 10 : null;
15
16
  out.push({
16
17
  id,
17
18
  displayName: identity.displayName,
@@ -70,6 +71,9 @@ export function registerModelsCommand(program, deps = {}) {
70
71
  });
71
72
  let body;
72
73
  try {
74
+ if (o.refresh) {
75
+ (deps.phase ?? phase)("refreshing model catalogs…", { json: !!o.json });
76
+ }
73
77
  const url = o.refresh
74
78
  ? (o.provider ? `/api/models/refresh?provider=${encodeURIComponent(o.provider)}` : "/api/models/refresh")
75
79
  : (o.provider ? `/api/models?provider=${encodeURIComponent(o.provider)}` : "/api/models");
@@ -0,0 +1,32 @@
1
+ export interface ProgressOptions {
2
+ /** Suppress output when machine-readable JSON is requested. */
3
+ json?: boolean;
4
+ /** Explicitly silence progress (e.g. quiet mode). */
5
+ quiet?: boolean;
6
+ /** Whether the output stream is interactive. Defaults to process.stderr.isTTY. */
7
+ isTTY?: boolean;
8
+ /** Custom writer for stderr (defaults to console.error). */
9
+ stderr?: (message: string) => void;
10
+ }
11
+ export type PhaseFn = (message: string, opts?: ProgressOptions) => void;
12
+ /**
13
+ * Format a progress phase string ensuring consistent trailing ellipsis without duplication.
14
+ */
15
+ export declare function formatPhase(message: string): string;
16
+ /**
17
+ * Determine whether progress messages should be printed given the options.
18
+ */
19
+ export declare function shouldEmitProgress(opts?: ProgressOptions): boolean;
20
+ /**
21
+ * Emit a progress phase message strictly to stderr if interactive and non-json.
22
+ */
23
+ export declare function phase(message: string, opts?: ProgressOptions): void;
24
+ /**
25
+ * Await work, calling emit only if it is still pending after graceMs, so an
26
+ * already-settled fast path stays silent.
27
+ */
28
+ export declare function phaseIfSlow<T>(work: Promise<T>, emit: () => void, graceMs?: number): Promise<T>;
29
+ /**
30
+ * Create a phase reporter with pre-bound default options.
31
+ */
32
+ export declare function createPhase(baseOpts?: ProgressOptions): (message: string, overrideOpts?: ProgressOptions) => void;
@@ -0,0 +1,70 @@
1
+ // Shared progress helper for multi-second CLI operations.
2
+ // Strictly writes to stderr with a consistent lowercase phrasing ending in '…'.
3
+ // Automatically suppressed under --json, quiet mode, and non-interactive (non-TTY) runs.
4
+ /**
5
+ * Format a progress phase string ensuring consistent trailing ellipsis without duplication.
6
+ */
7
+ export function formatPhase(message) {
8
+ const trimmed = message.trim();
9
+ if (!trimmed)
10
+ return "";
11
+ if (trimmed.endsWith("...")) {
12
+ return `${trimmed.slice(0, -3)}…`;
13
+ }
14
+ if (trimmed.endsWith("…")) {
15
+ return trimmed;
16
+ }
17
+ return `${trimmed}…`;
18
+ }
19
+ /**
20
+ * Determine whether progress messages should be printed given the options.
21
+ */
22
+ export function shouldEmitProgress(opts = {}) {
23
+ if (opts.json || opts.quiet)
24
+ return false;
25
+ if (opts.isTTY !== undefined)
26
+ return Boolean(opts.isTTY);
27
+ return Boolean(process.stderr.isTTY);
28
+ }
29
+ /**
30
+ * Emit a progress phase message strictly to stderr if interactive and non-json.
31
+ */
32
+ export function phase(message, opts = {}) {
33
+ const formatted = formatPhase(message);
34
+ if (!formatted)
35
+ return;
36
+ if (!shouldEmitProgress(opts))
37
+ return;
38
+ const write = opts.stderr ?? console.error;
39
+ write(formatted);
40
+ }
41
+ /**
42
+ * Await work, calling emit only if it is still pending after graceMs, so an
43
+ * already-settled fast path stays silent.
44
+ */
45
+ export async function phaseIfSlow(work, emit, graceMs = 250) {
46
+ let timer;
47
+ const slow = new Promise((r) => {
48
+ timer = setTimeout(() => r("slow"), graceMs);
49
+ });
50
+ try {
51
+ const first = await Promise.race([
52
+ work.then(() => "done", () => "done"),
53
+ slow,
54
+ ]);
55
+ if (first === "slow")
56
+ emit();
57
+ return await work;
58
+ }
59
+ finally {
60
+ clearTimeout(timer);
61
+ }
62
+ }
63
+ /**
64
+ * Create a phase reporter with pre-bound default options.
65
+ */
66
+ export function createPhase(baseOpts = {}) {
67
+ return (message, overrideOpts = {}) => {
68
+ phase(message, { ...baseOpts, ...overrideOpts });
69
+ };
70
+ }
@@ -2,6 +2,7 @@ import type { Command } from "commander";
2
2
  import { startService, type StartServiceOptions } from "../runtime/service.js";
3
3
  import { type ServiceClient } from "../runtime/client.js";
4
4
  import { type SleepFn } from "./takeover.js";
5
+ import { type PhaseFn } from "./progress.js";
5
6
  export type StartServiceFn = (opts?: StartServiceOptions) => ReturnType<typeof startService>;
6
7
  export type OpenBrowserFn = (url: string) => Promise<void> | void;
7
8
  export type ExecServiceFn = (args: string[], opts?: Record<string, any>) => Promise<number>;
@@ -20,10 +21,13 @@ export interface RuntimeCommandDeps {
20
21
  sleep?: SleepFn;
21
22
  timeoutMs?: number;
22
23
  readToken?: () => string | undefined;
24
+ phase?: PhaseFn;
25
+ json?: boolean;
23
26
  };
24
27
  checkUpdates?: () => Promise<unknown>;
25
28
  isInstalled?: () => Promise<boolean>;
26
29
  serviceSupported?: () => boolean;
30
+ phase?: PhaseFn;
27
31
  }
28
32
  export interface WebLaunchOptions {
29
33
  port?: string;
@@ -38,5 +42,6 @@ export declare function waitForInitialPoll(port: number, opts?: {
38
42
  }) => any;
39
43
  sleep?: SleepFn;
40
44
  timeoutMs?: number;
45
+ phase?: PhaseFn;
41
46
  }): Promise<void>;
42
47
  export declare function registerRuntimeCommands(program: Command, deps?: RuntimeCommandDeps): void;