quotacap 0.0.36 → 0.0.37

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.
package/README.md CHANGED
@@ -23,9 +23,9 @@ QuotaCap helps you get more from the AI coding subscriptions you already pay for
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
- | Muse Code | `pty` — `muse --trust-workspace` then `/usage`, parse `Subscription · Muse Code <plan>` + `Weekly/Current N% used` | Live |
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
27
 
28
- 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). It dominates `POST /api/refresh` and the first poll, not the steady-state 15 m timer.
28
+ 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.
29
29
 
30
30
  Live adapters invoke the CLIs you already logged into. No API keys. No token files are read.
31
31
 
@@ -21,7 +21,7 @@ const ADAPTER_TIMEOUTS = {
21
21
  kimi: 8000,
22
22
  grok: 14000,
23
23
  agy: 20000,
24
- muse: 14000,
24
+ muse: 90000,
25
25
  };
26
26
  export async function pollAll(enabled, opts) {
27
27
  const rawJobs = enabled.map(id => {
@@ -1,4 +1,32 @@
1
1
  import type { ParsedQuota } from "./types.js";
2
+ /** Canonical unavailability error: parseMuseTui throws it, poll() recognises it
3
+ * to start recovery, and failure.ts classifies its text as service_unavailable. */
4
+ export declare const MUSE_UNAVAILABLE_MESSAGE = "muse: subscription currently unavailable in TUI output";
5
+ export declare const WARM_ATTEMPTS = 3;
6
+ export declare const WARM_SETTLE_MS = 3000;
7
+ export declare const WARM_EXEC_TIMEOUT_MS = 20000;
8
+ export declare const FALLBACK_BUDGET_MS = 60000;
9
+ export interface RecoveryDeps {
10
+ unavailable: Error;
11
+ usagePass: (timeoutMs: number) => Promise<ParsedQuota>;
12
+ warmTurn: (timeoutMs: number) => Promise<void>;
13
+ sleep?: (ms: number) => Promise<void>;
14
+ now?: () => number;
15
+ aborted?: () => boolean;
16
+ log?: (message: string) => void;
17
+ attempts?: number;
18
+ settleMs?: number;
19
+ budgetMs?: number;
20
+ }
21
+ /**
22
+ * Recovery loop for an unavailable subscription, entered only on
23
+ * MUSE_UNAVAILABLE_MESSAGE. Attempt = settle, headless warm turn, settle,
24
+ * /usage re-read. The budget is checked before every step, and the warm turn,
25
+ * sleep and readiness/completion waits are clamped to what remains; the
26
+ * adapter's 90 s timeout is the backstop for the fixed per-pass overheads.
27
+ * Exhaustion rethrows the last unavailability error.
28
+ */
29
+ export declare function recoverSubscription(deps: RecoveryDeps): Promise<ParsedQuota>;
2
30
  export declare function museProbeDir(): string;
3
31
  export declare function parseMuseTui(text: string, now?: Date): ParsedQuota;
4
32
  export declare const museAdapter: {
@@ -3,7 +3,71 @@ import os from "node:os";
3
3
  import path from "node:path";
4
4
  import { parseResetText } from "./parse.js";
5
5
  import { runPty, stripAnsi } from "./pty.js";
6
- import { adapterSignal } from "../runtime/spawn.js";
6
+ import { adapterSignal, trackedExecFile } from "../runtime/spawn.js";
7
+ /** Canonical unavailability error: parseMuseTui throws it, poll() recognises it
8
+ * to start recovery, and failure.ts classifies its text as service_unavailable. */
9
+ export const MUSE_UNAVAILABLE_MESSAGE = "muse: subscription currently unavailable in TUI output";
10
+ export const WARM_ATTEMPTS = 3;
11
+ export const WARM_SETTLE_MS = 3000;
12
+ export const WARM_EXEC_TIMEOUT_MS = 20000;
13
+ export const FALLBACK_BUDGET_MS = 60000;
14
+ const USAGE_TIMEOUT_MS = 14000;
15
+ const MIN_STEP_MS = 1000;
16
+ function delay(ms) {
17
+ return new Promise((resolve) => setTimeout(resolve, ms));
18
+ }
19
+ /**
20
+ * Recovery loop for an unavailable subscription, entered only on
21
+ * MUSE_UNAVAILABLE_MESSAGE. Attempt = settle, headless warm turn, settle,
22
+ * /usage re-read. The budget is checked before every step, and the warm turn,
23
+ * sleep and readiness/completion waits are clamped to what remains; the
24
+ * adapter's 90 s timeout is the backstop for the fixed per-pass overheads.
25
+ * Exhaustion rethrows the last unavailability error.
26
+ */
27
+ export async function recoverSubscription(deps) {
28
+ const sleep = deps.sleep ?? delay;
29
+ const now = deps.now ?? Date.now;
30
+ const aborted = deps.aborted ?? (() => adapterSignal("muse")?.aborted === true);
31
+ const log = deps.log ?? ((message) => console.warn(`[quotacap] muse: ${message}`));
32
+ const attempts = deps.attempts ?? WARM_ATTEMPTS;
33
+ const settleMs = deps.settleMs ?? WARM_SETTLE_MS;
34
+ const deadline = now() + (deps.budgetMs ?? FALLBACK_BUDGET_MS);
35
+ const remaining = () => deadline - now();
36
+ let unavailable = deps.unavailable;
37
+ for (let attempt = 1; attempt <= attempts; attempt++) {
38
+ if (remaining() < MIN_STEP_MS)
39
+ break;
40
+ await sleep(Math.min(settleMs, remaining()));
41
+ if (remaining() < MIN_STEP_MS)
42
+ break;
43
+ try {
44
+ await deps.warmTurn(Math.min(WARM_EXEC_TIMEOUT_MS, remaining()));
45
+ }
46
+ catch (warmError) {
47
+ if (aborted())
48
+ throw warmError;
49
+ log(`warm attempt ${attempt} failed: ${warmError?.message}`);
50
+ continue;
51
+ }
52
+ if (remaining() < MIN_STEP_MS)
53
+ break;
54
+ await sleep(Math.min(settleMs, remaining()));
55
+ if (remaining() < MIN_STEP_MS)
56
+ break;
57
+ try {
58
+ const quota = await deps.usagePass(Math.min(USAGE_TIMEOUT_MS, remaining()));
59
+ log(`subscription unavailable; recovered after ${attempt} warm prompt(s)`);
60
+ return quota;
61
+ }
62
+ catch (readError) {
63
+ if (readError?.message !== MUSE_UNAVAILABLE_MESSAGE)
64
+ throw readError;
65
+ unavailable = readError;
66
+ }
67
+ }
68
+ log("subscription still unavailable; warm recovery exhausted");
69
+ throw unavailable;
70
+ }
7
71
  // QuotaCap-owned empty probe dir, beside the config and database. Muse loads
8
72
  // project-local skills, rules, hooks and plugin config from cwd, so the probe
9
73
  // never runs in $HOME or a user project — an empty dir grants nothing.
@@ -15,7 +79,7 @@ export function parseMuseTui(text, now = new Date()) {
15
79
  // newlines), so no pattern here may anchor on ^, $ or \n.
16
80
  const cleaned = stripAnsi(text);
17
81
  if (/currently unavailable|subscriptions aren't currently available|subscription_unavailable/i.test(cleaned)) {
18
- throw new Error("muse: subscription currently unavailable in TUI output");
82
+ throw new Error(MUSE_UNAVAILABLE_MESSAGE);
19
83
  }
20
84
  const weekly = cleaned.match(/Weekly\s+(\d+)%\s+used/i);
21
85
  if (!weekly)
@@ -60,40 +124,67 @@ export function parseMuseTui(text, now = new Date()) {
60
124
  raw: cleaned.slice(0, 4096),
61
125
  };
62
126
  }
127
+ /** One `/usage` read: the TUI scrape, shared by the first attempt and every
128
+ * recovery re-read. */
129
+ async function usagePass(timeoutMs = USAGE_TIMEOUT_MS) {
130
+ const cwd = museProbeDir();
131
+ fs.mkdirSync(cwd, { recursive: true });
132
+ const transcript = await runPty({
133
+ file: "muse",
134
+ args: ["--trust-workspace"],
135
+ cwd,
136
+ // Without this, a missing binary triggers a foreground 248 MB download
137
+ // on every poll; with it the launcher dies fast with a clear message.
138
+ env: { MUSE_NO_AUTO_UPDATE: "1" },
139
+ cols: 140,
140
+ rows: 50,
141
+ readyRegex: /muse-spark|Muse Code \d/,
142
+ readyTimeoutMs: 8000,
143
+ settleDelayMs: 1000,
144
+ // Two-phase submit: "/usage" first, CR 1500ms later. A single combined
145
+ // write is swallowed by the slash-command autocomplete and never runs.
146
+ input: "/usage",
147
+ submitInput: "\r",
148
+ submitAfterMs: 1500,
149
+ completionRegex: /Subscription/i,
150
+ // Trust prompt and the accidental-turn guard stay fail-closed. The
151
+ // unavailable-subscription message is deliberately not here: parseMuseTui
152
+ // owns detection so the poll can recover from it.
153
+ abortOn: /Do you trust this workspace|Working \(\d+s/,
154
+ timeoutMs,
155
+ respondToQueries: true,
156
+ maxBytes: 256 * 1024,
157
+ signal: adapterSignal("muse"),
158
+ label: "muse",
159
+ });
160
+ return parseMuseTui(transcript);
161
+ }
63
162
  export const museAdapter = {
64
163
  id: "muse",
65
164
  requiresAuth: "muse login (CLI owns credentials)",
66
165
  async poll() {
67
- const cwd = museProbeDir();
68
- fs.mkdirSync(cwd, { recursive: true });
69
- const transcript = await runPty({
70
- file: "muse",
71
- args: ["--trust-workspace"],
72
- cwd,
73
- // Without this, a missing binary triggers a foreground 248 MB download
74
- // on every poll; with it the launcher dies fast with a clear message.
75
- env: { MUSE_NO_AUTO_UPDATE: "1" },
76
- cols: 140,
77
- rows: 50,
78
- readyRegex: /muse-spark|Muse Code \d/,
79
- readyTimeoutMs: 8000,
80
- settleDelayMs: 1000,
81
- // Two-phase submit: "/usage" first, CR 1500ms later. A single combined
82
- // write is swallowed by the slash-command autocomplete and never runs.
83
- input: "/usage",
84
- submitInput: "\r",
85
- submitAfterMs: 1500,
86
- completionRegex: /Subscription/i,
87
- // Trust prompt (in case a future Muse ignores the flag), an accidental
88
- // model turn from a mistimed Enter, and the unavailable-subscription
89
- // fast-fail — every one aborts to a degraded row, never a 14s timeout.
90
- abortOn: /Do you trust this workspace|Working \(\d+s|Subscriptions aren't currently available|subscription_unavailable/,
91
- timeoutMs: 14000,
92
- respondToQueries: true,
93
- maxBytes: 256 * 1024,
94
- signal: adapterSignal("muse"),
95
- label: "muse",
96
- });
97
- return parseMuseTui(transcript);
166
+ // Captured before the first pass: pollAll clears the adapter signal map
167
+ // entry in a microtask when its gate rejects, but clearing the map does
168
+ // not reset the signal object, so the loop can still observe the abort.
169
+ const signal = adapterSignal("muse");
170
+ try {
171
+ return await usagePass();
172
+ }
173
+ catch (e) {
174
+ if (e?.message !== MUSE_UNAVAILABLE_MESSAGE)
175
+ throw e;
176
+ return recoverSubscription({
177
+ unavailable: e,
178
+ usagePass: (timeoutMs) => usagePass(timeoutMs),
179
+ warmTurn: async (timeoutMs) => {
180
+ await trackedExecFile("muse", "muse", ["exec", "hi"], {
181
+ cwd: museProbeDir(),
182
+ env: { ...process.env, MUSE_NO_AUTO_UPDATE: "1" },
183
+ timeout: timeoutMs,
184
+ });
185
+ },
186
+ aborted: () => signal?.aborted === true,
187
+ });
188
+ }
98
189
  },
99
190
  };
@@ -408,7 +408,7 @@ function mapCategory(code) {
408
408
  return "unknown";
409
409
  }
410
410
  }
411
- function getSummaryAndAction(code, providerName) {
411
+ function getSummaryAndAction(code, providerName, provider) {
412
412
  switch (code) {
413
413
  case "terminal_error":
414
414
  return {
@@ -443,7 +443,9 @@ function getSummaryAndAction(code, providerName) {
443
443
  case "service_unavailable":
444
444
  return {
445
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.`,
446
+ action: provider === "muse"
447
+ ? `${providerName} reported that subscription usage is currently unavailable. QuotaCap already retried automatically with a warm-up prompt. If this persists, send a prompt in ${providerName}, or wait for the next scheduled poll.`
448
+ : `${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
449
  };
448
450
  case "timeout":
449
451
  return {
@@ -473,7 +475,7 @@ export function classifyFailure(provider, reason) {
473
475
  }
474
476
  const category = mapCategory(diag.diagnosticCode);
475
477
  const pName = formatProviderName(provider);
476
- const { summary, action } = getSummaryAndAction(diag.diagnosticCode, pName);
478
+ const { summary, action } = getSummaryAndAction(diag.diagnosticCode, pName, provider);
477
479
  return {
478
480
  diagnosticCode: diag.diagnosticCode,
479
481
  errorDetail: diag.errorDetail,
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.36";
2
+ export const VERSION = "0.0.37";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "quotacap",
3
- "version": "0.0.36",
3
+ "version": "0.0.37",
4
4
  "description": "Local quota tracker for AI coding plans with a dashboard, CLI, MCP server, and next-plan recommendations.",
5
5
  "license": "MIT",
6
6
  "keywords": [