quotacap 0.0.51 → 0.0.53

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 (71) 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.d.ts +2 -0
  8. package/dist/adapters/muse.js +60 -10
  9. package/dist/adapters/opencode-go.d.ts +12 -2
  10. package/dist/adapters/opencode-go.js +156 -31
  11. package/dist/adapters/types.d.ts +2 -0
  12. package/dist/advisory/snapshot.d.ts +1 -0
  13. package/dist/advisory/snapshot.js +7 -1
  14. package/dist/advisory/types.d.ts +3 -0
  15. package/dist/cli/models.js +5 -4
  16. package/dist/diagnostics/failure.js +4 -4
  17. package/dist/format/rows.d.ts +30 -1
  18. package/dist/format/rows.js +111 -25
  19. package/dist/format/terminal.js +5 -3
  20. package/dist/http/server.d.ts +3 -0
  21. package/dist/http/server.js +7 -3
  22. package/dist/runtime/service.js +14 -3
  23. package/dist/service/macos.d.ts +5 -0
  24. package/dist/service/macos.js +6 -2
  25. package/dist/store/db.js +2 -1
  26. package/dist/store/quotas.d.ts +1 -1
  27. package/dist/store/quotas.js +65 -2
  28. package/dist/version.js +1 -1
  29. package/dist/web/src/App.js +10 -4
  30. package/dist/web/src/components/AdviceDrawer.d.ts +2 -1
  31. package/dist/web/src/components/AdviceDrawer.js +27 -16
  32. package/dist/web/src/components/BurnDown.d.ts +37 -0
  33. package/dist/web/src/components/BurnDown.js +182 -0
  34. package/dist/web/src/components/ClosedWeeks.d.ts +6 -5
  35. package/dist/web/src/components/ClosedWeeks.js +25 -37
  36. package/dist/web/src/components/CopyButton.d.ts +4 -0
  37. package/dist/web/src/components/CopyButton.js +27 -0
  38. package/dist/web/src/components/ErrorBoundary.d.ts +37 -0
  39. package/dist/web/src/components/ErrorBoundary.js +32 -0
  40. package/dist/web/src/components/FaultBanner.d.ts +3 -2
  41. package/dist/web/src/components/FaultBanner.js +7 -4
  42. package/dist/web/src/components/OpencodeGoBanner.d.ts +3 -1
  43. package/dist/web/src/components/OpencodeGoBanner.js +16 -10
  44. package/dist/web/src/components/PaceBar.d.ts +81 -3
  45. package/dist/web/src/components/PaceBar.js +255 -36
  46. package/dist/web/src/components/PacingLegend.js +1 -1
  47. package/dist/web/src/components/ProviderCard.js +6 -9
  48. package/dist/web/src/components/ProviderDrawer.d.ts +56 -10
  49. package/dist/web/src/components/ProviderDrawer.js +278 -144
  50. package/dist/web/src/components/ProviderRow.js +3 -3
  51. package/dist/web/src/components/Recommendation.js +11 -7
  52. package/dist/web/src/components/ResetRail.js +13 -9
  53. package/dist/web/src/components/SubscriptionList.js +11 -4
  54. package/dist/web/src/components/WindowsRow.d.ts +13 -3
  55. package/dist/web/src/components/WindowsRow.js +30 -7
  56. package/dist/web/src/components/settings/CliIntegrationPanel.js +7 -45
  57. package/dist/web/src/components/settings/DaemonPanel.js +1 -28
  58. package/dist/web/src/components/settings/ProvidersPanel.d.ts +2 -2
  59. package/dist/web/src/components/settings/ProvidersPanel.js +10 -24
  60. package/dist/web/src/components/settings/SettingsDrawer.d.ts +1 -2
  61. package/dist/web/src/components/settings/SettingsDrawer.js +2 -2
  62. package/dist/web/src/state.d.ts +6 -1
  63. package/dist/web/src/state.js +19 -10
  64. package/dist/webAssets.js +1 -1
  65. package/dist/webHtml.js +1 -1
  66. package/package.json +1 -1
  67. package/web/dist/assets/index-Co33DTl5.css +1 -0
  68. package/web/dist/assets/index-DHHYR0xZ.js +13 -0
  69. package/web/dist/index.html +2 -2
  70. package/web/dist/assets/index-BSQW0C_t.css +0 -1
  71. package/web/dist/assets/index-CkfnMcPS.js +0 -13
@@ -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;
@@ -9,6 +9,85 @@ function msOf(iso) {
9
9
  const ms = new Date(iso).getTime();
10
10
  return Number.isNaN(ms) ? null : ms;
11
11
  }
12
+ /**
13
+ * Quota-window resolution, mirroring the dashboard helpers: the advisory
14
+ * binding window rations the account, so standalone resets, usage,
15
+ * elapsed, and pace read it. Weekly-bound rows fall back to the weekly
16
+ * fields unchanged; rate-limit windows never drive these figures.
17
+ */
18
+ export function isMonthlyBound(ps) {
19
+ return (ps.advisory?.bindingWindow === "monthly" &&
20
+ ps.quota?.monthlyKind === "included" &&
21
+ typeof ps.quota?.monthlyPct === "number" &&
22
+ !!ps.quota?.monthlyResetsAt);
23
+ }
24
+ /** Reset instant of the quota window, or null without a quota reading. */
25
+ export function bindingResetsAt(ps) {
26
+ if (!ps.quota)
27
+ return null;
28
+ if (isMonthlyBound(ps))
29
+ return ps.quota.monthlyResetsAt ?? null;
30
+ return ps.quota.resetsAt ?? null;
31
+ }
32
+ /** Used percent of the quota window. Null without a quota reading. */
33
+ export function bindingUsedPct(ps) {
34
+ if (!ps.quota)
35
+ return null;
36
+ if (isMonthlyBound(ps)) {
37
+ const m = ps.quota.monthlyPct;
38
+ return typeof m === "number" && Number.isFinite(m) ? Math.round(m) : null;
39
+ }
40
+ const u = weeklyPctOf(ps.quota);
41
+ return typeof u === "number" && Number.isFinite(u) ? Math.round(u) : null;
42
+ }
43
+ /** Start of the monthly window: one calendar month before its reported reset. */
44
+ function monthlyStartMs(resetMs) {
45
+ const start = new Date(resetMs);
46
+ start.setMonth(start.getMonth() - 1);
47
+ return start.getTime();
48
+ }
49
+ /**
50
+ * Binding leftover for a raw quota without an advisory (model catalogs):
51
+ * the same scarcer-headroom rule the engine applies, so the catalog
52
+ * leftover and reset name the quota window, not always the week.
53
+ */
54
+ export function bindingLeftover(quota, now) {
55
+ if (!quota || typeof quota.weeklyPct !== "number") {
56
+ return { leftoverPct: null, resetsAt: quota?.resetsAt ?? null };
57
+ }
58
+ const weeklyRemaining = 100 - quota.weeklyPct;
59
+ const weeklyDays = Math.max(0.1, (new Date(quota.resetsAt ?? "").getTime() - now.getTime()) / DAY_MS);
60
+ if (quota.monthlyKind === "included" &&
61
+ typeof quota.monthlyPct === "number" &&
62
+ quota.monthlyResetsAt) {
63
+ const monthlyRemaining = quota.monthlyStatus === "exhausted" ? 0 : Math.max(0, 100 - quota.monthlyPct);
64
+ const monthlyDays = Math.max(0.1, (new Date(quota.monthlyResetsAt).getTime() - now.getTime()) / DAY_MS);
65
+ if (monthlyRemaining / monthlyDays < weeklyRemaining / weeklyDays || monthlyRemaining === 0) {
66
+ return {
67
+ leftoverPct: Math.round(monthlyRemaining * 10) / 10,
68
+ resetsAt: quota.monthlyResetsAt,
69
+ };
70
+ }
71
+ }
72
+ return {
73
+ leftoverPct: Math.round(weeklyRemaining * 10) / 10,
74
+ resetsAt: quota.resetsAt ?? null,
75
+ };
76
+ }
77
+ /** Window-average pace of the quota window; monthly divides monthly used by elapsed monthly days. */
78
+ export function bindingAvgPace(ps, now) {
79
+ if (!isMonthlyBound(ps))
80
+ return ps.advisory?.avgPace ?? null;
81
+ const used = ps.quota?.monthlyPct;
82
+ const resetMs = msOf(ps.quota?.monthlyResetsAt);
83
+ if (typeof used !== "number" || !Number.isFinite(used) || resetMs === null)
84
+ return null;
85
+ const elapsedDays = (now.getTime() - monthlyStartMs(resetMs)) / DAY_MS;
86
+ if (elapsedDays < 0.25)
87
+ return null;
88
+ const pace = used / elapsedDays;
89
+ return Number.isFinite(pace) ? Math.max(0, pace) : null;
90
+ }
12
91
  // The CLI talks HTTP to a daemon that may predate the rename, which sent only
13
92
  // `usedPct`. Read both for one release; drop the fallback with the aliases.
14
93
  export function weeklyPctOf(q) {
@@ -33,7 +112,7 @@ export function dayOf(iso) {
33
112
  return `${when.toLocaleDateString("en-US", { weekday: "short" })} ${when.toLocaleDateString("en-US", { day: "numeric", month: "short" })}`;
34
113
  }
35
114
  export function countdownText(ps, now) {
36
- const resetsMs = msOf(ps.quota?.resetsAt);
115
+ const resetsMs = msOf(bindingResetsAt(ps));
37
116
  let text;
38
117
  if (resetsMs === null)
39
118
  text = "—";
@@ -41,27 +120,31 @@ export function countdownText(ps, now) {
41
120
  text = "passed";
42
121
  else
43
122
  text = fmtDuration(resetsMs - now.getTime());
44
- if (ps.quota?.resetsAtEstimated)
123
+ if (!isMonthlyBound(ps) && ps.quota?.resetsAtEstimated)
45
124
  text += " (est.)";
46
125
  return text;
47
126
  }
48
127
  export function elapsedPct(ps, now) {
49
- const startMs = msOf(ps.quota?.periodStart);
50
- const resetsMs = msOf(ps.quota?.resetsAt);
128
+ const resetsMs = msOf(bindingResetsAt(ps));
129
+ const startMs = isMonthlyBound(ps)
130
+ ? resetsMs === null
131
+ ? null
132
+ : monthlyStartMs(resetsMs)
133
+ : msOf(ps.quota?.periodStart);
51
134
  if (startMs === null || resetsMs === null || resetsMs <= startMs)
52
135
  return null;
53
136
  const pct = ((now.getTime() - startMs) / (resetsMs - startMs)) * 100;
54
137
  return Math.round(Math.min(100, Math.max(0, pct)));
55
138
  }
56
139
  export function usedPctOf(ps) {
57
- const u = weeklyPctOf(ps.quota);
58
- return typeof u === "number" && Number.isFinite(u) ? Math.round(u) : null;
140
+ return bindingUsedPct(ps);
59
141
  }
60
142
  export function railCells(ps, now) {
61
143
  const cells = new Array(RAIL_CELLS).fill("unused");
62
- if (ps.exclusionReason !== null)
144
+ const u = bindingUsedPct(ps);
145
+ const is100Exhausted = typeof u === "number" && u >= 100 && !ps.resetPassed;
146
+ if (ps.exclusionReason !== null && !is100Exhausted)
63
147
  return cells;
64
- const u = weeklyPctOf(ps.quota);
65
148
  const used = typeof u === "number" && Number.isFinite(u)
66
149
  ? Math.min(RAIL_CELLS, Math.max(0, Math.round((u / 100) * RAIL_CELLS)))
67
150
  : 0;
@@ -167,7 +250,8 @@ export function forecastText(ps, now) {
167
250
  text = "Exhausts before reset";
168
251
  }
169
252
  // Engine estimate vocabulary, mirroring the snapshot reason transform.
170
- if (ps.quota?.resetsAtEstimated) {
253
+ // A monthly quota forecast never carries the weekly estimate flag.
254
+ if (!isMonthlyBound(ps) && ps.quota?.resetsAtEstimated) {
171
255
  if (text.includes("waste in"))
172
256
  text = text.replace("waste in", "estimated waste in");
173
257
  else if (text.includes("until reset"))
@@ -189,7 +273,7 @@ export function compactSuffix(ps) {
189
273
  return "!";
190
274
  if (adv.status === "watch")
191
275
  return "~";
192
- if (adv.urgency !== "on track" || ps.quota?.resetsAtEstimated)
276
+ if (adv.urgency !== "on track" || (!isMonthlyBound(ps) && ps.quota?.resetsAtEstimated))
193
277
  return "~";
194
278
  return "";
195
279
  }
@@ -203,8 +287,7 @@ function wasteOf(p) {
203
287
  return typeof w === "number" && Number.isFinite(w) ? w : null;
204
288
  }
205
289
  function finiteUsed(p) {
206
- const u = weeklyPctOf(p.quota);
207
- return typeof u === "number" && Number.isFinite(u) ? u : null;
290
+ return bindingUsedPct(p);
208
291
  }
209
292
  export function sortProviders(providers, key, use) {
210
293
  assertValidSortKey(key);
@@ -241,8 +324,8 @@ export function sortProviders(providers, key, use) {
241
324
  case "reset-desc": {
242
325
  const dir = key === "reset-asc" ? 1 : -1;
243
326
  indexed.sort((a, b) => {
244
- const ra = msOf(a.p.quota?.resetsAt);
245
- const rb = msOf(b.p.quota?.resetsAt);
327
+ const ra = msOf(bindingResetsAt(a.p));
328
+ const rb = msOf(bindingResetsAt(b.p));
246
329
  if (ra === null && rb === null)
247
330
  return a.i - b.i;
248
331
  if (ra === null)
@@ -275,21 +358,24 @@ export function sortProviders(providers, key, use) {
275
358
  }
276
359
  }
277
360
  }
278
- // Compact pace pair: window average first, 24h rate alongside. A pair that
279
- // renders identically at display precision collapses to one figure.
280
- export function paceText(ps) {
361
+ // Compact pace pair: quota-window average first, 24h rate alongside. A pair
362
+ // that renders identically at display precision collapses to one figure.
363
+ // The 24h rate is weekly-only: a monthly-bound row shows its monthly
364
+ // average alone.
365
+ export function paceText(ps, now = new Date()) {
281
366
  const adv = ps.advisory;
282
367
  if (!adv)
283
368
  return "—";
284
- // avgPace is absent on advisories from a daemon older than the split.
285
- const avg = adv.avgPace ?? null;
369
+ const avg = bindingAvgPace(ps, now);
286
370
  // The 24h figure is the measured recentRate, never the blended forecast
287
371
  // input; an older daemon omits the field and burnRate was the raw rate then.
288
- const recent = adv.recentRate !== undefined
289
- ? adv.recentRate
290
- : adv.paceSource === "recent"
291
- ? adv.burnRate
292
- : null;
372
+ const recent = isMonthlyBound(ps)
373
+ ? null
374
+ : adv.recentRate !== undefined
375
+ ? adv.recentRate
376
+ : adv.paceSource === "recent"
377
+ ? adv.burnRate
378
+ : null;
293
379
  if (avg !== null && recent !== null && avg.toFixed(1) !== recent.toFixed(1)) {
294
380
  return `${avg.toFixed(1)} avg · ${recent.toFixed(1)} 24h`;
295
381
  }
@@ -308,6 +394,6 @@ export function buildRow(ps, now) {
308
394
  elapsedPct: elapsedPct(ps, now),
309
395
  usedPct: usedPctOf(ps),
310
396
  railCells: railCells(ps, now),
311
- pace: paceText(ps),
397
+ pace: paceText(ps, now),
312
398
  };
313
399
  }
@@ -1,4 +1,4 @@
1
- import { buildRow, compactSuffix, sortProviders, weeklyPctOf, } from "./rows.js";
1
+ import { buildRow, compactSuffix, isMonthlyBound, sortProviders, weeklyPctOf, } from "./rows.js";
2
2
  const GAP = " ";
3
3
  const MIN_PROV_WIDTH = 12;
4
4
  const MAX_PROV_WIDTH = 22;
@@ -83,7 +83,8 @@ export function renderWide(snapshot, opts) {
83
83
  const close = p.lastCloses?.[0];
84
84
  if (!close)
85
85
  return null;
86
- const tail = `last week ${close.leftoverPct}% leftover${close.resetsAtEstimated ? " (est.)" : ""}`;
86
+ const unit = isMonthlyBound(p) ? "month" : "week";
87
+ const tail = `last ${unit} ${close.leftoverPct}% leftover${close.resetsAtEstimated ? " (est.)" : ""}`;
87
88
  return `${resetsIndent}${paint(tail, DIM)}`;
88
89
  };
89
90
  const header = [
@@ -144,7 +145,8 @@ export function renderNarrow(snapshot, opts) {
144
145
  const close = p.lastCloses?.[0];
145
146
  if (close) {
146
147
  const indent = " ".repeat(row.railCells.length + " resets ".length);
147
- const tail = `last week ${close.leftoverPct}% leftover${close.resetsAtEstimated ? " (est.)" : ""}`;
148
+ const unit = isMonthlyBound(p) ? "month" : "week";
149
+ const tail = `last ${unit} ${close.leftoverPct}% leftover${close.resetsAtEstimated ? " (est.)" : ""}`;
148
150
  lines.push(`${indent}${color ? `${DIM}${tail}${RESET}` : tail}`);
149
151
  }
150
152
  }
@@ -12,6 +12,9 @@ export interface RuntimeContext {
12
12
  providerNames?: Record<string, string>;
13
13
  /** Detected-but-not-enabled opt-in providers, computed once at start. */
14
14
  detectedProviders?: string[];
15
+ /** Login command for the detected OpenCode CLI version, filled in
16
+ * asynchronously after start; null until known. */
17
+ opencodeLoginCommand?: string | null;
15
18
  version: string;
16
19
  exec: string;
17
20
  now?: () => Date;
@@ -19,6 +19,7 @@ import { providerIdentity } from "../advisory/provider-names.js";
19
19
  import { setProviderNameOverride, setProviderEnabled } from "../config.js";
20
20
  import { ensureCatalogs, catalogProviders, CATALOG_FAIL_COOLDOWN_MS } from "../catalog/index.js";
21
21
  import { getAllLatest } from "../store/quotas.js";
22
+ import { bindingLeftover } from "../format/rows.js";
22
23
  const LOOPBACK_HOSTS = new Set(["127.0.0.1", "localhost", "[::1]", "::1"]);
23
24
  export function hostnameFromHostHeader(hostHeader) {
24
25
  if (typeof hostHeader !== "string" || !hostHeader.trim())
@@ -122,6 +123,7 @@ function snapshotOf(ctx) {
122
123
  checkedAt: cache?.checkedAt ?? null,
123
124
  },
124
125
  detectedProviders: ctx.detectedProviders ?? [],
126
+ opencodeLoginCommand: ctx.opencodeLoginCommand ?? null,
125
127
  },
126
128
  });
127
129
  }
@@ -217,17 +219,18 @@ export function buildApp(ctx) {
217
219
  const catalogTtl = ctx.catalogTtlHours ?? 6;
218
220
  function projectModels(catalogs, quotaMap) {
219
221
  const out = [];
222
+ const now = ctx.now?.() ?? new Date();
220
223
  for (const [id, catalog] of catalogs) {
221
224
  const identity = providerIdentity(id, ctx.providerNames);
222
225
  const quota = quotaMap.get(id);
223
- const leftoverPct = quota?.weeklyPct != null ? Math.round((100 - quota.weeklyPct) * 10) / 10 : null;
226
+ const binding = bindingLeftover(quota, now);
224
227
  out.push({
225
228
  id,
226
229
  displayName: identity.displayName,
227
230
  harness: identity.harness,
228
231
  vendor: identity.vendor,
229
- leftoverPct,
230
- resetsAt: quota?.resetsAt ?? null,
232
+ leftoverPct: binding.leftoverPct,
233
+ resetsAt: binding.resetsAt,
231
234
  catalog,
232
235
  });
233
236
  }
@@ -454,6 +457,7 @@ export function buildApp(ctx) {
454
457
  plan: parsed.plan,
455
458
  weeklyPct: parsed.weeklyPct,
456
459
  fiveHourPct: parsed.fiveHourPct,
460
+ fiveHourResetsAt: parsed.fiveHourResetsAt,
457
461
  resetsAt: parsed.resetsAt,
458
462
  periodStart: parsed.periodStart,
459
463
  source: "manual",
@@ -16,7 +16,7 @@ import { buildApp } from "../http/server.js";
16
16
  import { detectChannel, refreshUpdateCache } from "./updates.js";
17
17
  import { VERSION } from "../version.js";
18
18
  import { claudeAdapter } from "../adapters/claude.js";
19
- import { opencodeGoDetected } from "../adapters/opencode-go.js";
19
+ import { detectOpenCodeLoginCommand, opencodeGoDetected } from "../adapters/opencode-go.js";
20
20
  import { classifyFailure } from "../diagnostics/failure.js";
21
21
  import { ensureCatalogs, catalogProviders } from "../catalog/index.js";
22
22
  export function resolveClaudeExecPath() {
@@ -196,12 +196,13 @@ export async function startService(opts) {
196
196
  }).catch(() => { });
197
197
  },
198
198
  });
199
- app = buildApp({
199
+ const appCtx = {
200
200
  db,
201
201
  token,
202
202
  coordinator,
203
203
  enabledProviders: config.enabledProviders,
204
204
  detectedProviders: opencodeGoDetected() ? ["opencode-go"] : [],
205
+ opencodeLoginCommand: null,
205
206
  providerNames: config.providerNames,
206
207
  catalogTtlHours: config.catalogTtlHours,
207
208
  version: VERSION,
@@ -213,7 +214,17 @@ export async function startService(opts) {
213
214
  // hoisted declaration below; it can only fire after listen, so the
214
215
  // shutdown bindings it closes over are initialized by then).
215
216
  onRestart: () => onSignal(),
216
- });
217
+ };
218
+ app = buildApp(appCtx);
219
+ // Version-specific OpenCode login command for the fault banner:
220
+ // detected once, in the background, never blocking readiness.
221
+ if (config.enabledProviders.includes("opencode-go") || appCtx.detectedProviders.length > 0) {
222
+ void detectOpenCodeLoginCommand()
223
+ .then((cmd) => {
224
+ appCtx.opencodeLoginCommand = cmd;
225
+ })
226
+ .catch(() => { });
227
+ }
217
228
  // 6. Bind; unwind on failure without polling.
218
229
  await app.listen({ port, host: "127.0.0.1" });
219
230
  const bound = app.server.address();
@@ -16,6 +16,11 @@ export interface ServiceDeps {
16
16
  lintPlist?: (plistFile: string) => void;
17
17
  waitReady?: (port: number) => Promise<boolean>;
18
18
  waitReleased?: (port: number) => Promise<boolean>;
19
+ /** Internal: the caller just stopped this job and is replacing it, so a
20
+ * still-loaded record at bootstrap time is the dying one — keep
21
+ * retrying until it clears instead of treating it as already loaded.
22
+ * Only restart sets this; a plain start keeps the idempotent shortcut. */
23
+ expectReplace?: boolean;
19
24
  print?: (msg: string) => void;
20
25
  error?: (msg: string) => void;
21
26
  now?: () => Date;
@@ -422,7 +422,7 @@ export async function start(deps = {}) {
422
422
  const { home, uid, dataDir, print, run, sleep, bootstrapTimeoutMs } = resolved(deps);
423
423
  const plistFile = plistFileFor(home);
424
424
  await bootstrapWithRetry(run, uid, plistFile, {
425
- expectReplace: false,
425
+ expectReplace: deps.expectReplace ?? false,
426
426
  sleep,
427
427
  timeoutMs: bootstrapTimeoutMs,
428
428
  });
@@ -452,7 +452,11 @@ export async function restart(deps = {}) {
452
452
  if (!(await waitReleased(port))) {
453
453
  (deps.print ?? console.log)(`port ${port} is still in use after stopping; starting anyway`);
454
454
  }
455
- await start(deps);
455
+ // The record we just booted out can still be clearing when start
456
+ // bootstraps: error 5 plus a loaded record would read as an idempotent
457
+ // double-start and skip the bootstrap entirely, leaving nothing running
458
+ // once the old record finishes clearing. Replace, don't shortcut.
459
+ await start({ ...deps, expectReplace: true });
456
460
  }
457
461
  function parsePrintOutput(out) {
458
462
  const pidMatch = out.match(/^\s*pid\s*=\s*(\d+)/m);
package/dist/store/db.js CHANGED
@@ -36,7 +36,7 @@ export function openDb(p) {
36
36
  return db;
37
37
  }
38
38
  export function migrate(db) {
39
- db.exec(`CREATE TABLE IF NOT EXISTS quotas(id INTEGER PRIMARY KEY, provider TEXT, plan TEXT, used_pct REAL, resets_at TEXT, period_start TEXT, source TEXT, fetched_at TEXT, credits_usd REAL, resets_at_estimated INTEGER, session_pct REAL, monthly_pct REAL, monthly_resets_at TEXT, monthly_status TEXT, monthly_kind TEXT);
39
+ db.exec(`CREATE TABLE IF NOT EXISTS quotas(id INTEGER PRIMARY KEY, provider TEXT, plan TEXT, used_pct REAL, resets_at TEXT, period_start TEXT, source TEXT, fetched_at TEXT, credits_usd REAL, resets_at_estimated INTEGER, session_pct REAL, monthly_pct REAL, monthly_resets_at TEXT, monthly_status TEXT, monthly_kind TEXT, five_hour_resets_at TEXT);
40
40
  CREATE TABLE IF NOT EXISTS snapshots(day TEXT, provider TEXT, used_pct REAL, burn_rate REAL, ideal_rate REAL, PRIMARY KEY(day, provider));
41
41
  CREATE TABLE IF NOT EXISTS adapter_attempts(provider TEXT PRIMARY KEY, attempted_at TEXT NOT NULL, completed_at TEXT, succeeded_at TEXT, success INTEGER NOT NULL, failure_category TEXT, diagnostic_code TEXT, summary TEXT, action TEXT, error_detail TEXT);
42
42
  CREATE INDEX IF NOT EXISTS idx_quotas_provider ON quotas(provider);
@@ -104,6 +104,7 @@ export function migrate(db) {
104
104
  ["monthly_resets_at", "TEXT"],
105
105
  ["monthly_status", "TEXT"],
106
106
  ["monthly_kind", "TEXT"],
107
+ ["five_hour_resets_at", "TEXT"],
107
108
  ]) {
108
109
  if (!quotaCols.has(column))
109
110
  db.exec(`ALTER TABLE quotas ADD COLUMN ${column} ${type}`);
@@ -8,7 +8,7 @@ export declare function isWeeklyCycleBreak(prev: {
8
8
  resetsAt: string | null;
9
9
  }): boolean;
10
10
  export declare function upsertQuota(db: any, q: any): void;
11
- export declare function getWindowCloses(db: any, provider: string, limit?: number): Array<{
11
+ export declare function getWindowCloses(db: any, provider: string, limit?: number, window?: "weekly" | "monthly"): Array<{
12
12
  provider: string;
13
13
  plan: string | null;
14
14
  usedPct: number;
@@ -30,6 +30,10 @@ function mapRow(row) {
30
30
  delete out.fiveHourPct;
31
31
  delete out.sessionPct;
32
32
  }
33
+ out.fiveHourResetsAt = row.five_hour_resets_at ?? row.fiveHourResetsAt ?? undefined;
34
+ delete out.five_hour_resets_at;
35
+ if (out.fiveHourResetsAt == null)
36
+ delete out.fiveHourResetsAt;
33
37
  // A junk monthly percent means no usable monthly reading. It never
34
38
  // invalidates the provider; only a bad weekly percent does (snapshot.ts).
35
39
  const m = row.monthly_pct;
@@ -85,7 +89,7 @@ export function upsertQuota(db, q) {
85
89
  .run(prev.provider, prev.plan ?? null, prev.weeklyPct, Math.max(0, 100 - prev.weeklyPct), prev.fetchedAt, prev.id, prev.periodStart ?? null, prev.resetsAt ?? null, prev.resetsAtEstimated ? 1 : null, q.fetchedAt, reason);
86
90
  }
87
91
  }
88
- db.prepare(`INSERT INTO quotas(provider, plan, used_pct, resets_at, period_start, source, fetched_at, credits_usd, resets_at_estimated, session_pct, monthly_pct, monthly_resets_at, monthly_status, monthly_kind) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)`).run(q.provider, q.plan, weeklyPct, q.resetsAt, q.periodStart, q.source, q.fetchedAt, credits, estimated, fiveHourPct, q.monthlyPct ?? null, q.monthlyResetsAt ?? null, q.monthlyStatus ?? null, q.monthlyKind ?? null);
92
+ db.prepare(`INSERT INTO quotas(provider, plan, used_pct, resets_at, period_start, source, fetched_at, credits_usd, resets_at_estimated, session_pct, monthly_pct, monthly_resets_at, monthly_status, monthly_kind, five_hour_resets_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`).run(q.provider, q.plan, weeklyPct, q.resetsAt, q.periodStart, q.source, q.fetchedAt, credits, estimated, fiveHourPct, q.monthlyPct ?? null, q.monthlyResetsAt ?? null, q.monthlyStatus ?? null, q.monthlyKind ?? null, q.fiveHourResetsAt ?? null);
89
93
  const day = new Date().toISOString().slice(0, 10);
90
94
  db.prepare(`INSERT INTO snapshots(day, provider, used_pct) VALUES(?,?,?) ON CONFLICT(day, provider) DO UPDATE SET used_pct=excluded.used_pct`).run(day, q.provider, weeklyPct);
91
95
  db.exec("COMMIT");
@@ -98,7 +102,66 @@ export function upsertQuota(db, q) {
98
102
  throw e;
99
103
  }
100
104
  }
101
- export function getWindowCloses(db, provider, limit = 4) {
105
+ function monthlyPeriodStart(resetIso) {
106
+ if (!resetIso)
107
+ return null;
108
+ const resetMs = Date.parse(resetIso);
109
+ if (!Number.isFinite(resetMs))
110
+ return null;
111
+ const start = new Date(resetMs);
112
+ start.setMonth(start.getMonth() - 1);
113
+ return start.toISOString();
114
+ }
115
+ export function getWindowCloses(db, provider, limit = 4, window = "weekly") {
116
+ if (window === "monthly") {
117
+ let qRows = [];
118
+ try {
119
+ qRows = db
120
+ .prepare(`SELECT id, provider, plan, monthly_pct, monthly_resets_at, monthly_kind, fetched_at FROM quotas WHERE provider=? ORDER BY fetched_at ASC, id ASC`)
121
+ .all(provider);
122
+ }
123
+ catch (e) {
124
+ if (!/no such table|no such column/i.test(String(e?.message ?? "")))
125
+ throw e;
126
+ }
127
+ const valid = qRows.filter((r) => r.monthly_kind === "included" &&
128
+ typeof r.monthly_pct === "number" &&
129
+ Number.isFinite(r.monthly_pct) &&
130
+ r.monthly_pct >= 0 &&
131
+ r.monthly_pct <= 100 &&
132
+ typeof r.monthly_resets_at === "string" &&
133
+ Number.isFinite(Date.parse(r.monthly_resets_at)) &&
134
+ Number.isFinite(Date.parse(r.fetched_at)));
135
+ const closes = [];
136
+ for (let i = 1; i < valid.length; i++) {
137
+ const prev = valid[i - 1];
138
+ const cur = valid[i];
139
+ const reason = cycleBreakReason({
140
+ usedPct: prev.monthly_pct,
141
+ t: Date.parse(prev.fetched_at),
142
+ resetsAt: prev.monthly_resets_at ?? null,
143
+ }, {
144
+ usedPct: cur.monthly_pct,
145
+ t: Date.parse(cur.fetched_at),
146
+ resetsAt: cur.monthly_resets_at ?? null,
147
+ });
148
+ if (reason) {
149
+ closes.push({
150
+ provider: prev.provider,
151
+ plan: prev.plan ?? null,
152
+ usedPct: prev.monthly_pct,
153
+ leftoverPct: Math.max(0, 100 - prev.monthly_pct),
154
+ sampledAt: prev.fetched_at,
155
+ periodStart: monthlyPeriodStart(prev.monthly_resets_at),
156
+ resetsAt: prev.monthly_resets_at ?? null,
157
+ resetsAtEstimated: false,
158
+ detectedAt: cur.fetched_at,
159
+ reason,
160
+ });
161
+ }
162
+ }
163
+ return closes.reverse().slice(0, limit);
164
+ }
102
165
  let rows = [];
103
166
  try {
104
167
  rows = db.prepare(`SELECT provider, plan, used_pct, leftover_pct, sampled_at, period_start, resets_at, resets_at_estimated, detected_at, reason FROM window_closes WHERE provider=? ORDER BY detected_at DESC, id DESC LIMIT ?`).all(provider, limit);
package/dist/version.js CHANGED
@@ -1,2 +1,2 @@
1
1
  // generated by scripts/build-embed.mjs — do not edit
2
- export const VERSION = "0.0.51";
2
+ export const VERSION = "0.0.53";
@@ -8,6 +8,7 @@ import { Recommendation } from "./components/Recommendation.js";
8
8
  import { SubscriptionList } from "./components/SubscriptionList.js";
9
9
  import { ResetRail } from "./components/ResetRail.js";
10
10
  import { ProviderDrawer } from "./components/ProviderDrawer.js";
11
+ import { DrawerErrorFallback, ErrorBoundary } from "./components/ErrorBoundary.js";
11
12
  import { AdviceDrawer } from "./components/AdviceDrawer.js";
12
13
  import { SettingsDrawer } from "./components/settings/SettingsDrawer.js";
13
14
  import { navigate, routeFor, useRoute } from "./router.js";
@@ -127,7 +128,7 @@ function App() {
127
128
  : null;
128
129
  const header = (_jsx(Header, { runtime: snapshot?.runtime ?? null, unreachable: error?.kind === "service-unavailable", refreshing: refreshing, onRefresh: () => void refresh(), onSettings: () => setSettingsOpen(true) }));
129
130
  if (snapshot && setupMode) {
130
- return (_jsxs(_Fragment, { children: [header, _jsxs("main", { children: [notice && (_jsx("div", { "data-testid": "refresh-notice", role: "status", children: notice })), showOpenCodeGoBanner(snapshot.runtime, snapshot.providers, goSuppressed) && (_jsx(OpencodeGoBanner, { onEnable: () => setConsentOpen(true) })), _jsx(Onboarding, { snapshot: snapshot, onFirstPoll: () => void refresh(), polling: refreshing, pollError: pollErrorText })] }), _jsx(SettingsDrawer, { open: settingsOpen, providers: snapshot.providers, onRefresh: () => void refresh(), refreshing: refreshing, onSetProviderEnabled: handleSetProviderEnabled, onClose: () => setSettingsOpen(false) }), consentOpen && (_jsx(ConsentModal, { busy: consentBusy, onCancel: () => setConsentOpen(false), onConfirm: () => void (async () => {
131
+ return (_jsxs(_Fragment, { children: [header, _jsxs("main", { children: [notice && (_jsx("div", { "data-testid": "refresh-notice", role: "status", children: notice })), showOpenCodeGoBanner(snapshot.runtime, snapshot.providers, goSuppressed) && (_jsx(OpencodeGoBanner, { onEnable: () => setConsentOpen(true) })), _jsx(Onboarding, { snapshot: snapshot, onFirstPoll: () => void refresh(), polling: refreshing, pollError: pollErrorText })] }), _jsx(SettingsDrawer, { open: settingsOpen, providers: snapshot.providers, refreshing: refreshing, onSetProviderEnabled: handleSetProviderEnabled, onClose: () => setSettingsOpen(false) }), consentOpen && (_jsx(ConsentModal, { loginCommand: snapshot?.runtime.opencodeLoginCommand, busy: consentBusy, onCancel: () => setConsentOpen(false), onConfirm: () => void (async () => {
131
132
  setConsentBusy(true);
132
133
  try {
133
134
  await setProviderEnabled("opencode-go", true, true);
@@ -149,13 +150,15 @@ function App() {
149
150
  border: i === 0 ? "1px solid var(--rec)" : "1px solid var(--line)",
150
151
  borderRadius: 8,
151
152
  marginBottom: 8,
152
- } }, i))) })), error?.kind === "service-unavailable" && !snapshot && (_jsx(UnavailablePanel, { endpoint: daemonEndpoint(), lastGood: snapshot, onRetry: () => void load() })), error?.kind === "http-error" && !snapshot && (_jsx(HttpErrorPanel, { status: error.status, message: error.message, lastGood: snapshot, onRetry: () => void load() })), error && snapshot && (_jsxs("div", { "data-testid": "state-stale-error", role: "alert", children: [error.kind === "http-error" ? `Request failed (HTTP ${error.status}): ` : "Service unavailable: ", error.message, " Showing last readings (", ageLabel(snapshot.asOf), ").", " ", _jsx("button", { type: "button", onClick: () => void load(), children: "Retry" })] })), snapshot && (_jsx(FaultBanner, { providers: snapshot.providers, onRepoll: () => void refresh(), repolling: refreshing, polling: snapshot.runtime.polling })), snapshot && showOpenCodeGoBanner(snapshot.runtime, snapshot.providers, goSuppressed) && (_jsx(OpencodeGoBanner, { onEnable: () => setConsentOpen(true) })), snapshot && (_jsx(ReadyView, { snapshot: snapshot, onSelectProvider: (id) => {
153
+ } }, i))) })), error?.kind === "service-unavailable" && !snapshot && (_jsx(UnavailablePanel, { endpoint: daemonEndpoint(), lastGood: snapshot, onRetry: () => void load() })), error?.kind === "http-error" && !snapshot && (_jsx(HttpErrorPanel, { status: error.status, message: error.message, lastGood: snapshot, onRetry: () => void load() })), error && snapshot && (_jsxs("div", { "data-testid": "state-stale-error", role: "alert", children: [error.kind === "http-error" ? `Request failed (HTTP ${error.status}): ` : "Service unavailable: ", error.message, " Showing last readings (", ageLabel(snapshot.asOf), ").", " ", _jsx("button", { type: "button", onClick: () => void load(), children: "Retry" })] })), snapshot && (_jsx(FaultBanner, { providers: snapshot.providers, onRepoll: () => void refresh(), repolling: refreshing, polling: snapshot.runtime.polling, opencodeLoginCommand: snapshot.runtime.opencodeLoginCommand })), snapshot && showOpenCodeGoBanner(snapshot.runtime, snapshot.providers, goSuppressed) && (_jsx(OpencodeGoBanner, { onEnable: () => setConsentOpen(true) })), snapshot && (_jsx(ReadyView, { snapshot: snapshot, onSelectProvider: (id) => {
153
154
  setAdviceOpen(false);
154
155
  setSelectedProvider(id);
155
156
  }, onViewAdvice: () => {
156
157
  setSelectedProvider(null);
157
158
  setAdviceOpen(true);
158
- } }))] }), _jsx(ProviderDrawer, { provider: snapshot?.providers.find((p) => p.id === selectedProvider) ?? null, asOf: snapshot?.asOf ?? "", recommendation: snapshot?.recommendation ?? null, onClose: () => setSelectedProvider(null), onRenamed: () => void load() }), consentOpen && (_jsx(ConsentModal, { busy: consentBusy, onCancel: () => setConsentOpen(false), onConfirm: () => void (async () => {
159
+ } }))] }), _jsx(ErrorBoundary, { resetKey: selectedProvider, fallback: _jsx(DrawerErrorFallback, { name: snapshot?.providers.find((p) => p.id === selectedProvider)?.displayName ??
160
+ selectedProvider ??
161
+ "provider", onClose: () => setSelectedProvider(null) }), children: _jsx(ProviderDrawer, { provider: snapshot?.providers.find((p) => p.id === selectedProvider) ?? null, asOf: snapshot?.asOf ?? "", recommendation: snapshot?.recommendation ?? null, onClose: () => setSelectedProvider(null), onRenamed: () => void load() }) }), consentOpen && (_jsx(ConsentModal, { loginCommand: snapshot?.runtime.opencodeLoginCommand, busy: consentBusy, onCancel: () => setConsentOpen(false), onConfirm: () => void (async () => {
159
162
  setConsentBusy(true);
160
163
  try {
161
164
  await setProviderEnabled("opencode-go", true, true);
@@ -169,7 +172,10 @@ function App() {
169
172
  finally {
170
173
  setConsentBusy(false);
171
174
  }
172
- })() })), snapshot && (_jsx(AdviceDrawer, { open: adviceOpen, recommendation: snapshot.recommendation, providers: snapshot.providers, asOf: snapshot.asOf, onClose: () => setAdviceOpen(false) })), _jsx(SettingsDrawer, { open: settingsOpen, providers: snapshot?.providers ?? [], onRefresh: () => void refresh(), refreshing: refreshing, onSetProviderEnabled: handleSetProviderEnabled, onClose: () => setSettingsOpen(false) }), _jsx(SiteFooter, { version: snapshot?.runtime.version })] }));
175
+ })() })), snapshot && (_jsx(AdviceDrawer, { open: adviceOpen, recommendation: snapshot.recommendation, providers: snapshot.providers, asOf: snapshot.asOf, onClose: () => setAdviceOpen(false), onSelectProvider: (id) => {
176
+ setAdviceOpen(false);
177
+ setSelectedProvider(id);
178
+ } })), _jsx(SettingsDrawer, { open: settingsOpen, providers: snapshot?.providers ?? [], refreshing: refreshing, onSetProviderEnabled: handleSetProviderEnabled, onClose: () => setSettingsOpen(false) }), _jsx(SiteFooter, { version: snapshot?.runtime.version })] }));
173
179
  }
174
180
  const el = typeof document === "undefined" ? null : document.getElementById("app");
175
181
  if (el)
@@ -1,9 +1,10 @@
1
1
  import React from "react";
2
2
  import type { ProviderView, RecommendationView } from "../state.js";
3
- export declare function AdviceDrawer({ open, recommendation, providers, asOf, onClose, }: {
3
+ export declare function AdviceDrawer({ open, recommendation, providers, asOf, onClose, onSelectProvider, }: {
4
4
  open: boolean;
5
5
  recommendation: RecommendationView;
6
6
  providers: ProviderView[];
7
7
  asOf: string;
8
8
  onClose: () => void;
9
+ onSelectProvider?: (id: string) => void;
9
10
  }): React.JSX.Element | null;