quotacap 0.0.32 → 0.0.34

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.
@@ -52,6 +52,12 @@ function parseFiveReset(raw, now) {
52
52
  }
53
53
  export function parseCodexTui(text, now = new Date()) {
54
54
  const cleaned = stripAnsi(text);
55
+ if (/refresh token was already used|please log out and sign in again|access token could not be refreshed/i.test(cleaned)) {
56
+ throw new Error("codex: your access token could not be refreshed because your refresh token was already used. Please log out and sign in again.");
57
+ }
58
+ if (/Limits:\s*refresh requested/i.test(cleaned)) {
59
+ throw new Error("codex: limits refresh requested, run /status again shortly");
60
+ }
55
61
  let weeklyLeft = null;
56
62
  let fiveLeft = null;
57
63
  let weeklyRaw = null;
@@ -146,7 +152,7 @@ export const codexAdapter = {
146
152
  // Complete on the /status panel only. The startup statusline footer
147
153
  // ("· 5h N% left · weekly N% left") carries no reset timestamps and
148
154
  // would otherwise win the race, forcing a drifting now+7d estimate.
149
- completionRegex: /Weekly limit:/i,
155
+ completionRegex: /Weekly limit:|Limits:\s*refresh requested|refresh token was already used|log out and sign in again/i,
150
156
  abortOn: /Do you trust|Trust.*folder|trust the files in this folder/i,
151
157
  timeoutMs: 12000,
152
158
  maxBytes: 256 * 1024,
@@ -14,6 +14,9 @@ export function parseMuseTui(text, now = new Date()) {
14
14
  // After ANSI stripping the panel is ONE line (cursor-addressed, no
15
15
  // newlines), so no pattern here may anchor on ^, $ or \n.
16
16
  const cleaned = stripAnsi(text);
17
+ if (/currently unavailable|subscriptions aren't currently available|subscription_unavailable/i.test(cleaned)) {
18
+ throw new Error("muse: subscription currently unavailable in TUI output");
19
+ }
17
20
  const weekly = cleaned.match(/Weekly\s+(\d+)%\s+used/i);
18
21
  if (!weekly)
19
22
  throw new Error("muse: weekly usage not found in TUI output");
@@ -31,4 +31,12 @@ export interface WebLaunchOptions {
31
31
  open?: boolean;
32
32
  }
33
33
  export declare function launchWeb(o: WebLaunchOptions, deps?: RuntimeCommandDeps): Promise<void>;
34
+ export declare function waitForInitialPoll(port: number, opts?: {
35
+ createClient?: (opts: {
36
+ port: number;
37
+ timeoutMs: number;
38
+ }) => any;
39
+ sleep?: SleepFn;
40
+ timeoutMs?: number;
41
+ }): Promise<void>;
34
42
  export declare function registerRuntimeCommands(program: Command, deps?: RuntimeCommandDeps): void;
@@ -55,16 +55,25 @@ export async function launchWeb(o, deps = {}) {
55
55
  if (health?.ok) {
56
56
  const skew = checkSkew(health, VERSION, process.execPath);
57
57
  if (skew === "match") {
58
+ if (health.polling === "in-progress" && !health.lastCompletedPollAt) {
59
+ await waitForInitialPoll(port, { createClient, sleep, timeoutMs: Math.min(readyTimeoutMs, 5000) });
60
+ }
58
61
  await openDashboard(`http://127.0.0.1:${port}`, openBrowser);
59
62
  return;
60
63
  }
61
64
  if (skew === "exec-only") {
62
65
  console.error(execSkewWarning(String(health.exec)));
66
+ if (health.polling === "in-progress" && !health.lastCompletedPollAt) {
67
+ await waitForInitialPoll(port, { createClient, sleep, timeoutMs: Math.min(readyTimeoutMs, 5000) });
68
+ }
63
69
  await openDashboard(`http://127.0.0.1:${port}`, openBrowser);
64
70
  return;
65
71
  }
66
72
  if (skew === "cli-older") {
67
73
  console.error(olderCliWarning(String(health.version ?? "unknown")));
74
+ if (health.polling === "in-progress" && !health.lastCompletedPollAt) {
75
+ await waitForInitialPoll(port, { createClient, sleep, timeoutMs: Math.min(readyTimeoutMs, 5000) });
76
+ }
68
77
  await openDashboard(`http://127.0.0.1:${port}`, openBrowser);
69
78
  return;
70
79
  }
@@ -86,6 +95,7 @@ export async function launchWeb(o, deps = {}) {
86
95
  exit(1);
87
96
  return;
88
97
  }
98
+ await waitForInitialPoll(port, { createClient, sleep, timeoutMs: Math.min(readyTimeoutMs, 5000) });
89
99
  await openDashboard(`http://127.0.0.1:${port}`, openBrowser);
90
100
  return;
91
101
  }
@@ -106,6 +116,7 @@ export async function launchWeb(o, deps = {}) {
106
116
  }
107
117
  // Unmanaged successor: foreground-start the new daemon and open it.
108
118
  const started = await start(o.port ? { port } : undefined);
119
+ await waitForInitialPoll(started.port, { createClient, sleep, timeoutMs: Math.min(readyTimeoutMs, 5000) });
109
120
  await openDashboard(`http://127.0.0.1:${started.port}`, openBrowser);
110
121
  return;
111
122
  }
@@ -144,6 +155,7 @@ export async function launchWeb(o, deps = {}) {
144
155
  detail = `not ready within ${Math.round(readyTimeoutMs / 1000)}s`;
145
156
  }
146
157
  if (!detail) {
158
+ await waitForInitialPoll(port, { createClient, sleep, timeoutMs: Math.min(readyTimeoutMs, 5000) });
147
159
  await openDashboard(`http://127.0.0.1:${port}`, openBrowser);
148
160
  return;
149
161
  }
@@ -155,8 +167,28 @@ export async function launchWeb(o, deps = {}) {
155
167
  if (!foregroundOnly && !attemptedServiceStart && supported()) {
156
168
  console.error("for a background service that survives terminal closes, run 'quotacap service install'");
157
169
  }
170
+ await waitForInitialPoll(started.port, { createClient, sleep, timeoutMs: Math.min(readyTimeoutMs, 5000) });
158
171
  await openDashboard(`http://127.0.0.1:${started.port}`, openBrowser);
159
172
  }
173
+ export async function waitForInitialPoll(port, opts = {}) {
174
+ const createClient = opts.createClient ?? createServiceClient;
175
+ const sleep = opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
176
+ const timeoutMs = opts.timeoutMs ?? 5000;
177
+ const deadline = Date.now() + timeoutMs;
178
+ const client = createClient({ port, timeoutMs: 1000 });
179
+ while (Date.now() < deadline) {
180
+ await sleep(250);
181
+ try {
182
+ const h = await client.get("/health");
183
+ if (!h?.ok || h?.polling !== "in-progress" || h?.lastCompletedPollAt) {
184
+ return;
185
+ }
186
+ }
187
+ catch {
188
+ return;
189
+ }
190
+ }
191
+ }
160
192
  async function defaultOpenBrowser(url) {
161
193
  const cmd = process.platform === "darwin"
162
194
  ? "open"
@@ -1,5 +1,5 @@
1
1
  import type { FailureCategory } from "../store/attempts.js";
2
- export type DiagnosticCode = "terminal_error" | "auth" | "command_not_found" | "trust_prompt" | "parse_error" | "rate_limit" | "timeout" | "network" | "unknown";
2
+ export type DiagnosticCode = "terminal_error" | "auth" | "command_not_found" | "trust_prompt" | "parse_error" | "rate_limit" | "service_unavailable" | "timeout" | "network" | "unknown";
3
3
  export interface FailureEvidence {
4
4
  source: "pty" | "exec";
5
5
  checkpoint?: "spawn" | "before ready" | "during settle" | "before input" | "before completion" | "ready timeout" | "completion timeout" | "abort" | "trust prompt" | "write" | "transcript overflow";
@@ -20,6 +20,8 @@ export function formatProviderName(provider) {
20
20
  return "Antigravity";
21
21
  case "agy:3p":
22
22
  return "Antigravity (3rd-party)";
23
+ case "muse":
24
+ return "Muse";
23
25
  case "all":
24
26
  return "QuotaCap service";
25
27
  default:
@@ -134,6 +136,12 @@ function matchPrecedence(evidence, errorName, errorCode, text) {
134
136
  if (/\bexpired credentials?\b/i.test(text) || /\bexpired token\b/i.test(text) || /\bexpired api key\b/i.test(text)) {
135
137
  return { code: "auth", phrase: "expired credentials" };
136
138
  }
139
+ if (/\brefresh token was already used\b/i.test(text) ||
140
+ /\blog out and sign in again\b/i.test(text) ||
141
+ /\baccess token could not be refreshed\b/i.test(text) ||
142
+ /\bsign in again\b/i.test(text)) {
143
+ return { code: "auth", phrase: "login required" };
144
+ }
137
145
  if (/\bauthentication failed\b/i.test(text) ||
138
146
  /\bauthentication required\b/i.test(text) ||
139
147
  /\bmissing authentication\b/i.test(text)) {
@@ -216,6 +224,14 @@ function matchPrecedence(evidence, errorName, errorCode, text) {
216
224
  if (/\bno weekly bucket\b/i.test(text)) {
217
225
  return { code: "parse_error", phrase: "no weekly bucket" };
218
226
  }
227
+ // Order 8b: service_unavailable — subscription or service currently unavailable
228
+ if (/\b(?:subscription|service|usage)\s+currently\s+unavailable\b/i.test(text) ||
229
+ /\bsubscriptions aren't currently available\b/i.test(text)) {
230
+ return { code: "service_unavailable", phrase: "subscription currently unavailable" };
231
+ }
232
+ if (/\blimits refresh requested\b/i.test(text) || /\brefresh requested\b/i.test(text)) {
233
+ return { code: "service_unavailable", phrase: "limits refresh requested" };
234
+ }
219
235
  // Order 9: timeout — Remaining explicit timeout or timed out errors
220
236
  if (/\btimed out\b/i.test(text) || /\btimeout\b/i.test(text)) {
221
237
  return { code: "timeout", phrase: "timed out" };
@@ -386,6 +402,7 @@ function mapCategory(code) {
386
402
  case "command_not_found":
387
403
  case "trust_prompt":
388
404
  case "rate_limit":
405
+ case "service_unavailable":
389
406
  case "unknown":
390
407
  default:
391
408
  return "unknown";
@@ -423,6 +440,11 @@ function getSummaryAndAction(code, providerName) {
423
440
  summary: `${providerName} rate limited`,
424
441
  action: `Wait before retrying. While the service is running, QuotaCap will try again on its next scheduled poll.`,
425
442
  };
443
+ case "service_unavailable":
444
+ return {
445
+ summary: `${providerName} usage currently unavailable`,
446
+ action: `${providerName} reported that subscription usage is currently unavailable. Send a prompt in ${providerName} to refresh its session limits, or wait for the next scheduled poll.`,
447
+ };
426
448
  case "timeout":
427
449
  return {
428
450
  summary: `${providerName} took too long to respond`,
@@ -3,9 +3,10 @@
3
3
  // string order.
4
4
  export function parseVersion(v) {
5
5
  const s = v.startsWith("v") ? v.slice(1) : v;
6
- if (!/^\d+(\.\d+)*$/.test(s))
6
+ const match = s.match(/^(\d+(?:\.\d+)*)(?:-[0-9a-fA-F]{7,40}(?:-dirty)?)?$/);
7
+ if (!match || !match[1])
7
8
  return null;
8
- return s.split(".").map(Number);
9
+ return match[1].split(".").map(Number);
9
10
  }
10
11
  export function compareVersions(a, b) {
11
12
  const pa = parseVersion(a);
@@ -18,7 +19,21 @@ export function compareVersions(a, b) {
18
19
  if (x !== y)
19
20
  return x < y ? -1 : 1;
20
21
  }
21
- return 0;
22
+ if (a === b)
23
+ return 0;
24
+ const aNorm = a.startsWith("v") ? a.slice(1) : a;
25
+ const bNorm = b.startsWith("v") ? b.slice(1) : b;
26
+ if (aNorm === bNorm)
27
+ return 0;
28
+ const aHasSuffix = aNorm.includes("-");
29
+ const bHasSuffix = bNorm.includes("-");
30
+ if (!aHasSuffix && !bHasSuffix)
31
+ return 0;
32
+ if (aHasSuffix && !bHasSuffix)
33
+ return 1;
34
+ if (!aHasSuffix && bHasSuffix)
35
+ return -1;
36
+ return aNorm < bNorm ? -1 : 1;
22
37
  }
23
38
  if (a === b)
24
39
  return 0;
@@ -254,7 +254,10 @@ function bootoutQuiet(run, uid, print) {
254
254
  run(["bootout", bootoutTarget(uid)]);
255
255
  }
256
256
  catch (e) {
257
- if (/could not find service/i.test(String(e?.message ?? e))) {
257
+ // "No such process" is launchd's other not-running signal: the job record
258
+ // exists but its process is already gone. Same goal state as a missing
259
+ // record, so tolerate it the same way (systemd's stopQuiet already does).
260
+ if (/could not find service|no such process/i.test(String(e?.message ?? e))) {
258
261
  print("service job is not loaded; nothing to stop");
259
262
  return;
260
263
  }
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.32";
2
+ export const VERSION = "0.0.34";
@@ -63,13 +63,11 @@ function App() {
63
63
  useEffect(() => {
64
64
  void load();
65
65
  }, [load]);
66
- // While the daemon reports a non-idle poll state, keep re-reading until it
67
- // settles. Without this the pill ("Polling", "Cooling down") sticks forever
68
- // on the single post-refresh snapshot, which is always mid-cooldown.
66
+ // While the daemon reports an active poll, keep re-reading until it settles.
69
67
  useEffect(() => {
70
- if (!snapshot || snapshot.runtime.polling === "idle")
68
+ if (!snapshot || snapshot.runtime.polling !== "in-progress")
71
69
  return;
72
- const t = setInterval(() => void load(), 5000);
70
+ const t = setInterval(() => void load(), 1000);
73
71
  return () => clearInterval(t);
74
72
  }, [snapshot, load]);
75
73
  const refresh = useCallback(async () => {
@@ -81,7 +79,7 @@ function App() {
81
79
  setNotice(result.message);
82
80
  }
83
81
  else if (result.raw?.cooldown === true) {
84
- setNotice("Refresh cooling down — showing last readings.");
82
+ setNotice("Already up to date — showing latest readings.");
85
83
  }
86
84
  await load();
87
85
  }
@@ -116,7 +114,7 @@ function App() {
116
114
  border: i === 0 ? "1px solid var(--rec)" : "1px solid var(--line)",
117
115
  borderRadius: 8,
118
116
  marginBottom: 8,
119
- } }, 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 })), snapshot && (_jsx(ReadyView, { snapshot: snapshot, onSelectProvider: (id) => {
117
+ } }, 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 && (_jsx(ReadyView, { snapshot: snapshot, onSelectProvider: (id) => {
120
118
  setAdviceOpen(false);
121
119
  setSelectedProvider(id);
122
120
  }, onViewAdvice: () => {
@@ -10,8 +10,9 @@ export interface Repair {
10
10
  * service-unavailable panel when the daemon itself is down.
11
11
  */
12
12
  export declare function repairFor(provider: ProviderView): Repair;
13
- export declare function FaultBanner({ providers, onRepoll, repolling, }: {
13
+ export declare function FaultBanner({ providers, onRepoll, repolling, polling, }: {
14
14
  providers: ProviderView[];
15
15
  onRepoll: () => void;
16
16
  repolling: boolean;
17
+ polling?: "idle" | "in-progress" | "degraded" | string;
17
18
  }): React.JSX.Element | null;
@@ -53,8 +53,16 @@ function bannerVerb(provider) {
53
53
  return "needs attention";
54
54
  }
55
55
  }
56
- export function FaultBanner({ providers, onRepoll, repolling, }) {
57
- const faulted = providers.filter((p) => p.enabled && p.exclusionReason !== null);
56
+ export function FaultBanner({ providers, onRepoll, repolling, polling, }) {
57
+ const isPolling = repolling || polling === "in-progress";
58
+ const faulted = providers.filter((p) => {
59
+ if (!p.enabled || p.exclusionReason === null)
60
+ return false;
61
+ if (isPolling && (p.exclusionReason === "stale" || p.exclusionReason === "not-reporting")) {
62
+ return false;
63
+ }
64
+ return true;
65
+ });
58
66
  if (faulted.length === 0)
59
67
  return null;
60
68
  return (_jsx("div", { children: faulted.map((p) => {
@@ -2,9 +2,9 @@ import React from "react";
2
2
  import type { RuntimeView } from "../state.js";
3
3
  export type PillState = "live" | "polling" | "unreachable" | "not-ready";
4
4
  /**
5
- * Header pill from runtime fields. Unreachable is red, polling/cooldown is
6
- * amber, ready plus idle is green. Not-ready and unknown-version surface as
7
- * their own explicit state, never as green.
5
+ * Header pill from runtime fields. Unreachable is red, active polling is
6
+ * amber, ready is green (including during refresh cooldown). Not-ready and
7
+ * unknown-version surface as their own explicit state, never as green.
8
8
  */
9
9
  export declare function pillFor(runtime: {
10
10
  available: boolean;
@@ -1,14 +1,14 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { ThemeToggle } from "../Theme.js";
3
3
  /**
4
- * Header pill from runtime fields. Unreachable is red, polling/cooldown is
5
- * amber, ready plus idle is green. Not-ready and unknown-version surface as
6
- * their own explicit state, never as green.
4
+ * Header pill from runtime fields. Unreachable is red, active polling is
5
+ * amber, ready is green (including during refresh cooldown). Not-ready and
6
+ * unknown-version surface as their own explicit state, never as green.
7
7
  */
8
8
  export function pillFor(runtime) {
9
9
  if (!runtime.available)
10
10
  return "unreachable";
11
- if (runtime.polling === "in-progress" || runtime.polling === "cooldown")
11
+ if (runtime.polling === "in-progress")
12
12
  return "polling";
13
13
  if (!runtime.ready)
14
14
  return "not-ready";
@@ -29,7 +29,7 @@ function pillText(pill, runtime) {
29
29
  case "live":
30
30
  return "daemon live";
31
31
  case "polling":
32
- return runtime?.polling === "cooldown" ? "Cooling down" : "Polling";
32
+ return "Polling";
33
33
  case "unreachable":
34
34
  return "Daemon unreachable";
35
35
  case "not-ready":
@@ -0,0 +1,2 @@
1
+ declare const _default: import("vite").UserConfig;
2
+ export default _default;
@@ -0,0 +1,20 @@
1
+ import { defineConfig } from "vite";
2
+ import react from "@vitejs/plugin-react";
3
+ import path from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ // HMR iteration (`npm run dev:web`): the dashboard fetches same-origin /api/*,
6
+ // so forward API traffic to a locally running daemon. Override when the daemon
7
+ // binds somewhere other than the default 8787.
8
+ const devApi = process.env.QUOTACAP_DEV_API || "http://127.0.0.1:8787";
9
+ export default defineConfig({
10
+ plugins: [react()],
11
+ root: path.dirname(fileURLToPath(import.meta.url)),
12
+ build: { outDir: "dist" },
13
+ server: {
14
+ port: 5173,
15
+ proxy: {
16
+ "/api": devApi,
17
+ "/health": devApi,
18
+ },
19
+ },
20
+ });