quotacap 0.0.41 → 0.0.43
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/dist/adapters/claude.js +16 -5
- package/dist/adapters/codex.d.ts +2 -1
- package/dist/adapters/codex.js +19 -8
- package/dist/adapters/goldens.d.ts +4 -0
- package/dist/adapters/goldens.js +20 -0
- package/dist/adapters/grok.d.ts +2 -1
- package/dist/adapters/grok.js +26 -12
- package/dist/adapters/index.d.ts +20 -6
- package/dist/adapters/index.js +96 -37
- package/dist/adapters/kimi.d.ts +2 -1
- package/dist/adapters/kimi.js +24 -13
- package/dist/adapters/muse.d.ts +2 -1
- package/dist/adapters/muse.js +30 -15
- package/dist/adapters/opencode-go.d.ts +1 -1
- package/dist/adapters/opencode-go.js +8 -1
- package/dist/adapters/pty.d.ts +7 -0
- package/dist/adapters/pty.js +32 -2
- package/dist/adapters/vt.d.ts +19 -0
- package/dist/adapters/vt.js +506 -0
- package/dist/cli/providers.js +2 -1
- package/dist/config.d.ts +12 -23
- package/dist/config.js +24 -0
- package/dist/diagnostics/bundle.d.ts +22 -0
- package/dist/diagnostics/bundle.js +125 -0
- package/dist/diagnostics/failure.d.ts +8 -1
- package/dist/diagnostics/failure.js +43 -2
- package/dist/format/rows.d.ts +1 -1
- package/dist/format/rows.js +9 -6
- package/dist/mcp/server.d.ts +2 -2
- package/dist/runtime/poll.d.ts +4 -0
- package/dist/runtime/poll.js +30 -1
- package/dist/runtime/service.js +3 -1
- package/dist/version.js +1 -1
- package/dist/web/src/App.js +1 -1
- package/dist/web/src/components/AdviceDrawer.js +1 -1
- package/dist/web/src/components/OpencodeGoBanner.d.ts +1 -1
- package/dist/web/src/components/OpencodeGoBanner.js +9 -2
- package/dist/web/src/components/PaceBar.d.ts +9 -4
- package/dist/web/src/components/PaceBar.js +26 -7
- package/dist/web/src/components/ProviderCard.js +2 -3
- package/dist/web/src/components/ProviderDrawer.js +3 -3
- package/dist/web/src/components/ProviderIcon.js +1 -1
- package/dist/web/src/components/ProviderRow.d.ts +1 -1
- package/dist/web/src/components/ProviderRow.js +2 -4
- package/dist/web/src/components/Recommendation.js +1 -1
- package/dist/web/src/components/ResetRail.js +1 -1
- package/dist/web/src/components/WindowsRow.d.ts +1 -1
- package/dist/web/src/components/WindowsRow.js +4 -4
- package/dist/web/src/components/settings/SettingsDrawer.js +1 -1
- package/dist/webAssets.js +1 -1
- package/dist/webHtml.js +1 -1
- package/package.json +5 -5
- package/web/dist/assets/{index-Dk6l5h27.css → index-C6ltEeGL.css} +1 -1
- package/web/dist/assets/index-DRDDTtxF.js +13 -0
- package/web/dist/index.html +2 -2
- package/web/dist/assets/index-BUK8xUHm.js +0 -12
package/dist/adapters/claude.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { trackedExecFile } from "../runtime/spawn.js";
|
|
2
|
+
import { attachEvidence } from "../diagnostics/failure.js";
|
|
2
3
|
import { parseResetText } from "./parse.js";
|
|
3
4
|
export function parseClaudeUsage(result, now = new Date()) {
|
|
4
5
|
const sessionMatch = result.match(/Current session:\s+(\d+)%\s+used/i);
|
|
@@ -13,7 +14,7 @@ export function parseClaudeUsage(result, now = new Date()) {
|
|
|
13
14
|
const resets = new Date(resetsAt).getTime();
|
|
14
15
|
const periodStart = (parsedReset ? new Date(resets - 7 * 86400000) : new Date(now.getTime() - 7 * 86400000)).toISOString();
|
|
15
16
|
return {
|
|
16
|
-
provider: "claude", plan: "
|
|
17
|
+
provider: "claude", plan: "unknown", weeklyPct: usedPct, fiveHourPct: sessionPct,
|
|
17
18
|
resetsAt, periodStart, source: "cli", fetchedAt: now.toISOString(), raw: result,
|
|
18
19
|
};
|
|
19
20
|
}
|
|
@@ -26,9 +27,19 @@ export const claudeAdapter = {
|
|
|
26
27
|
// --strict-mcp-config: a usage read needs no MCP servers. Without it every
|
|
27
28
|
// poll boots the user's servers under the daemon's PATH, and their failures
|
|
28
29
|
// are cached and hide those servers from the user's own sessions.
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
const
|
|
32
|
-
|
|
30
|
+
// 15s matches the registry gate (ADAPTER_TIMEOUTS): cold starts measured
|
|
31
|
+
// 2-4.4s wall, and seven concurrent polls contend for the same CPU.
|
|
32
|
+
const { stdout } = await trackedExecFile("claude", bin, ["-p", "/usage", "--output-format", "json", "--strict-mcp-config"], { timeout: 15000 });
|
|
33
|
+
try {
|
|
34
|
+
const parsed = JSON.parse(stdout);
|
|
35
|
+
const result = parsed.result ?? stdout;
|
|
36
|
+
return parseClaudeUsage(result);
|
|
37
|
+
}
|
|
38
|
+
catch (e) {
|
|
39
|
+
// Side-channel only: the output a parse throw would discard, for
|
|
40
|
+
// failure bundles. The message is untouched.
|
|
41
|
+
attachEvidence(e, { source: "exec", stdout });
|
|
42
|
+
throw e;
|
|
43
|
+
}
|
|
33
44
|
}
|
|
34
45
|
};
|
package/dist/adapters/codex.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
+
import { type GridDims } from "./vt.js";
|
|
1
2
|
import type { ParsedQuota } from "./types.js";
|
|
2
|
-
export declare function parseCodexTui(text: string, now?: Date): ParsedQuota;
|
|
3
|
+
export declare function parseCodexTui(text: string, now?: Date, dims?: Partial<GridDims>): ParsedQuota;
|
|
3
4
|
export declare const codexAdapter: {
|
|
4
5
|
id: string;
|
|
5
6
|
requiresAuth: string;
|
package/dist/adapters/codex.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import os from "node:os";
|
|
2
|
-
import { runPty
|
|
2
|
+
import { runPty } from "./pty.js";
|
|
3
|
+
import { parseScreens } from "./vt.js";
|
|
3
4
|
import { adapterSignal } from "../runtime/spawn.js";
|
|
5
|
+
import { attachEvidence } from "../diagnostics/failure.js";
|
|
4
6
|
const MONTHS = {
|
|
5
7
|
jan: 0, feb: 1, mar: 2, apr: 3, may: 4, jun: 5,
|
|
6
8
|
jul: 6, aug: 7, sep: 8, oct: 9, nov: 10, dec: 11,
|
|
@@ -59,8 +61,10 @@ function parseFiveReset(raw, now) {
|
|
|
59
61
|
}
|
|
60
62
|
return dt.toISOString();
|
|
61
63
|
}
|
|
62
|
-
export function parseCodexTui(text, now = new Date()) {
|
|
63
|
-
|
|
64
|
+
export function parseCodexTui(text, now = new Date(), dims) {
|
|
65
|
+
return parseScreens(text, dims, (cleaned) => parseCodexCleaned(cleaned, now));
|
|
66
|
+
}
|
|
67
|
+
function parseCodexCleaned(cleaned, now) {
|
|
64
68
|
if (/refresh token was already used|please log out and sign in again|access token could not be refreshed/i.test(cleaned)) {
|
|
65
69
|
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.");
|
|
66
70
|
}
|
|
@@ -106,14 +110,13 @@ export function parseCodexTui(text, now = new Date()) {
|
|
|
106
110
|
let estimated = false;
|
|
107
111
|
if (weeklyRaw) {
|
|
108
112
|
weeklyIso = parseWeeklyReset(weeklyRaw, now);
|
|
109
|
-
if (!weeklyIso)
|
|
110
|
-
throw new Error(`codex: bad weekly reset "${weeklyRaw}"`);
|
|
111
113
|
}
|
|
112
114
|
if (fiveRaw) {
|
|
113
115
|
fiveIso = parseFiveReset(fiveRaw, now);
|
|
114
|
-
if (!fiveIso)
|
|
115
|
-
throw new Error(`codex: bad 5h reset "${fiveRaw}"`);
|
|
116
116
|
}
|
|
117
|
+
// Degrade, don't fail (grok precedent): the percents are the critical
|
|
118
|
+
// fields; a garbled or reworded timestamp degrades to an estimate instead
|
|
119
|
+
// of failing the provider.
|
|
117
120
|
if (!weeklyIso) {
|
|
118
121
|
weeklyIso = new Date(now.getTime() + 7 * 86400000).toISOString();
|
|
119
122
|
estimated = true;
|
|
@@ -168,6 +171,14 @@ export const codexAdapter = {
|
|
|
168
171
|
signal: adapterSignal("codex"),
|
|
169
172
|
label: "codex",
|
|
170
173
|
});
|
|
171
|
-
|
|
174
|
+
try {
|
|
175
|
+
return parseCodexTui(transcript);
|
|
176
|
+
}
|
|
177
|
+
catch (e) {
|
|
178
|
+
// Side-channel only: the transcript a parse throw would discard, for
|
|
179
|
+
// failure bundles. The message is untouched.
|
|
180
|
+
attachEvidence(e, { source: "pty", stdout: transcript });
|
|
181
|
+
throw e;
|
|
182
|
+
}
|
|
172
183
|
},
|
|
173
184
|
};
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export declare const GOLDEN_CLI_VERSIONS: Record<string, string[]>;
|
|
2
|
+
export declare function goldenFixtureDir(provider: string, version: string): string;
|
|
3
|
+
/** Substring match: "grok 1.0.41 (4220f3b…)" covers "1.0.41". */
|
|
4
|
+
export declare function isGoldenCovered(provider: string, cliVersion: string | null | undefined): boolean;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// Golden CLI-version coverage: every entry has a redacted real transcript at
|
|
2
|
+
// tests/fixtures/golden/<provider>-<version>/transcript.txt plus expected.json,
|
|
3
|
+
// exercised by tests/adapters/goldens.test.ts. When a vendor CLI updates,
|
|
4
|
+
// capture its usage output, redact identifiers, add the fixture, and extend
|
|
5
|
+
// the list. Failure bundles record whether the failing CLI was covered, so
|
|
6
|
+
// an uncovered version is the first suspect when parsing breaks.
|
|
7
|
+
export const GOLDEN_CLI_VERSIONS = {
|
|
8
|
+
claude: ["2.1.283"],
|
|
9
|
+
codex: ["0.157.1"],
|
|
10
|
+
grok: ["1.0.41"],
|
|
11
|
+
};
|
|
12
|
+
export function goldenFixtureDir(provider, version) {
|
|
13
|
+
return `${provider}-${version}`;
|
|
14
|
+
}
|
|
15
|
+
/** Substring match: "grok 1.0.41 (4220f3b…)" covers "1.0.41". */
|
|
16
|
+
export function isGoldenCovered(provider, cliVersion) {
|
|
17
|
+
if (!cliVersion)
|
|
18
|
+
return false;
|
|
19
|
+
return (GOLDEN_CLI_VERSIONS[provider] ?? []).some((v) => cliVersion.includes(v));
|
|
20
|
+
}
|
package/dist/adapters/grok.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
+
import { type GridDims } from "./vt.js";
|
|
1
2
|
import type { ParsedQuota } from "./types.js";
|
|
2
|
-
export declare function parseGrokTui(text: string, now?: Date): ParsedQuota;
|
|
3
|
+
export declare function parseGrokTui(text: string, now?: Date, dims?: Partial<GridDims>): ParsedQuota;
|
|
3
4
|
export declare const grokAdapter: {
|
|
4
5
|
id: string;
|
|
5
6
|
requiresAuth: string;
|
package/dist/adapters/grok.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import os from "node:os";
|
|
2
|
-
import { runPty
|
|
2
|
+
import { runPty } from "./pty.js";
|
|
3
|
+
import { parseScreens } from "./vt.js";
|
|
3
4
|
import { adapterSignal } from "../runtime/spawn.js";
|
|
5
|
+
import { attachEvidence } from "../diagnostics/failure.js";
|
|
4
6
|
const FULL_MONTHS = {
|
|
5
7
|
january: 0, february: 1, march: 2, april: 3, may: 4, june: 5,
|
|
6
8
|
july: 6, august: 7, september: 8, october: 9, november: 10, december: 11,
|
|
@@ -10,7 +12,10 @@ const ABBR_MONTHS = {
|
|
|
10
12
|
jul: 6, aug: 7, sep: 8, oct: 9, nov: 10, dec: 11,
|
|
11
13
|
};
|
|
12
14
|
function parseGrokReset(raw, now) {
|
|
13
|
-
|
|
15
|
+
// The month/day gap is optional: cursor repositioning can drop it
|
|
16
|
+
// ("Resets: October5, 10:22", observed 2026-09-28). Letters-meets-digits
|
|
17
|
+
// keeps the boundary unambiguous.
|
|
18
|
+
const m = raw.trim().match(/^([A-Za-z]+)\s*(\d{1,2}),\s*(\d{1,2}):(\d{2})$/);
|
|
14
19
|
if (!m)
|
|
15
20
|
return null;
|
|
16
21
|
const monStr = m[1].toLowerCase();
|
|
@@ -38,8 +43,10 @@ function parseGrokReset(raw, now) {
|
|
|
38
43
|
function validPct(v) {
|
|
39
44
|
return Number.isFinite(v) && v >= 0 && v <= 100;
|
|
40
45
|
}
|
|
41
|
-
export function parseGrokTui(text, now = new Date()) {
|
|
42
|
-
|
|
46
|
+
export function parseGrokTui(text, now = new Date(), dims) {
|
|
47
|
+
return parseScreens(text, dims, (cleaned) => parseGrokCleaned(cleaned, now));
|
|
48
|
+
}
|
|
49
|
+
function parseGrokCleaned(cleaned, now) {
|
|
43
50
|
// The TUI writes "Weekly lim", repositions the cursor, then writes
|
|
44
51
|
// "t (Tier)", so after ANSI stripping the header reads "Weekly limt".
|
|
45
52
|
// The [i!l] class stays optional to keep tolerating past glitch variants.
|
|
@@ -111,7 +118,7 @@ export function parseGrokTui(text, now = new Date()) {
|
|
|
111
118
|
if (Number.isFinite(v) && v >= 0)
|
|
112
119
|
creditsUsd = v;
|
|
113
120
|
}
|
|
114
|
-
const resetRe = /Resets:\s*([A-Za-z]+\s
|
|
121
|
+
const resetRe = /Resets:\s*([A-Za-z]+\s*\d+,\s*\d+:\d+)/gi;
|
|
115
122
|
let resetsRaw = null;
|
|
116
123
|
for (const m of cleaned.matchAll(resetRe))
|
|
117
124
|
resetsRaw = m[1].trim();
|
|
@@ -119,13 +126,12 @@ export function parseGrokTui(text, now = new Date()) {
|
|
|
119
126
|
let estimated = false;
|
|
120
127
|
if (resetsRaw) {
|
|
121
128
|
resetsAt = parseGrokReset(resetsRaw, now);
|
|
122
|
-
if (!resetsAt)
|
|
123
|
-
throw new Error(`grok: bad resets timestamp "${resetsRaw}"`);
|
|
124
|
-
}
|
|
125
|
-
else if (cleaned.match(/Resets:/i)) {
|
|
126
|
-
throw new Error("grok: bad resets timestamp");
|
|
127
129
|
}
|
|
128
|
-
|
|
130
|
+
if (!resetsAt) {
|
|
131
|
+
// Degrade, don't fail: the weekly percent is the critical field, and a
|
|
132
|
+
// garbled or reworded timestamp must not fail the provider. The estimate
|
|
133
|
+
// flag keeps advisory and scheduler honest (estimated resets are never
|
|
134
|
+
// used for pre-reset scheduling).
|
|
129
135
|
resetsAt = new Date(now.getTime() + 7 * 86400000).toISOString();
|
|
130
136
|
estimated = true;
|
|
131
137
|
}
|
|
@@ -164,6 +170,14 @@ export const grokAdapter = {
|
|
|
164
170
|
signal: adapterSignal("grok"),
|
|
165
171
|
label: "grok",
|
|
166
172
|
});
|
|
167
|
-
|
|
173
|
+
try {
|
|
174
|
+
return parseGrokTui(transcript);
|
|
175
|
+
}
|
|
176
|
+
catch (e) {
|
|
177
|
+
// Side-channel only: the transcript a parse throw would discard, for
|
|
178
|
+
// failure bundles. The message is untouched.
|
|
179
|
+
attachEvidence(e, { source: "pty", stdout: transcript });
|
|
180
|
+
throw e;
|
|
181
|
+
}
|
|
168
182
|
},
|
|
169
183
|
};
|
package/dist/adapters/index.d.ts
CHANGED
|
@@ -1,21 +1,35 @@
|
|
|
1
1
|
import type { Adapter } from "./types.js";
|
|
2
2
|
export declare const adapters: Record<string, Adapter>;
|
|
3
|
+
export declare const ADAPTER_TIMEOUTS: Record<string, number>;
|
|
3
4
|
export interface PollAllOptions {
|
|
4
5
|
timeouts?: Record<string, number>;
|
|
6
|
+
/** Max concurrent adapter polls. Default 3: seven heavy CLI/TUI spawns at
|
|
7
|
+
* once starve each other into timeouts. */
|
|
8
|
+
maxConcurrency?: number;
|
|
9
|
+
/** Attempts per adapter including the first. Default 2 (one retry).
|
|
10
|
+
* 1 disables retry. */
|
|
11
|
+
maxAttempts?: number;
|
|
12
|
+
/** Delay between attempts. Default 1000. */
|
|
13
|
+
retryDelayMs?: number;
|
|
14
|
+
/** When aborted, in-flight attempts run out but no retry is started. */
|
|
15
|
+
signal?: AbortSignal;
|
|
5
16
|
}
|
|
17
|
+
/** Order-preserving concurrency-limited map. */
|
|
18
|
+
export declare function mapWithConcurrency<T, R>(items: T[], limit: number, fn: (item: T, index: number) => Promise<R>): Promise<R[]>;
|
|
19
|
+
export declare function isRetryableChildFailure(reason: unknown): boolean;
|
|
6
20
|
export declare function pollAll(enabled: string[], opts?: PollAllOptions): Promise<({
|
|
21
|
+
provider: string;
|
|
22
|
+
status: "skipped";
|
|
23
|
+
reason: Error;
|
|
24
|
+
value?: undefined;
|
|
25
|
+
} | {
|
|
7
26
|
provider: string;
|
|
8
27
|
status: "fulfilled";
|
|
9
28
|
value: unknown;
|
|
10
29
|
reason?: undefined;
|
|
11
30
|
} | {
|
|
12
|
-
provider: string;
|
|
13
|
-
status: "skipped";
|
|
14
|
-
reason: any;
|
|
15
31
|
value?: undefined;
|
|
16
|
-
} | {
|
|
17
32
|
provider: string;
|
|
18
33
|
status: "rejected";
|
|
19
|
-
reason:
|
|
20
|
-
value?: undefined;
|
|
34
|
+
reason: unknown;
|
|
21
35
|
})[]>;
|
package/dist/adapters/index.js
CHANGED
|
@@ -7,6 +7,7 @@ import { agyAdapter } from "./agy.js";
|
|
|
7
7
|
import { museAdapter } from "./muse.js";
|
|
8
8
|
import { opencodeGoAdapter } from "./opencode-go.js";
|
|
9
9
|
import { installAdapterSignal, clearAdapterSignal } from "../runtime/spawn.js";
|
|
10
|
+
import { classifyFailure } from "../diagnostics/failure.js";
|
|
10
11
|
export const adapters = {
|
|
11
12
|
claude: claudeAdapter,
|
|
12
13
|
manual: manualAdapter,
|
|
@@ -17,8 +18,11 @@ export const adapters = {
|
|
|
17
18
|
muse: museAdapter,
|
|
18
19
|
"opencode-go": opencodeGoAdapter,
|
|
19
20
|
};
|
|
20
|
-
|
|
21
|
-
|
|
21
|
+
// Kept in sync with the per-adapter spawn/PTY timeouts: claude.ts exec 15s,
|
|
22
|
+
// codex/grok/kimi/muse runPty timeouts match their entries here. The gate is
|
|
23
|
+
// the outer bound; the inner spawn timeout should fire first.
|
|
24
|
+
export const ADAPTER_TIMEOUTS = {
|
|
25
|
+
claude: 15000,
|
|
22
26
|
codex: 12000,
|
|
23
27
|
kimi: 8000,
|
|
24
28
|
grok: 14000,
|
|
@@ -26,47 +30,102 @@ const ADAPTER_TIMEOUTS = {
|
|
|
26
30
|
muse: 90000,
|
|
27
31
|
"opencode-go": 8000,
|
|
28
32
|
};
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
// children; the signal clears when its job settles.
|
|
40
|
-
const controller = new AbortController();
|
|
41
|
-
installAdapterSignal(id, controller.signal);
|
|
42
|
-
let p;
|
|
43
|
-
try {
|
|
44
|
-
p = Promise.resolve(a.poll());
|
|
45
|
-
}
|
|
46
|
-
catch (e) {
|
|
47
|
-
clearAdapterSignal(id);
|
|
48
|
-
throw e;
|
|
33
|
+
/** Order-preserving concurrency-limited map. */
|
|
34
|
+
export async function mapWithConcurrency(items, limit, fn) {
|
|
35
|
+
const results = new Array(items.length);
|
|
36
|
+
let next = 0;
|
|
37
|
+
const workers = new Array(Math.min(Math.max(1, limit), items.length))
|
|
38
|
+
.fill(null)
|
|
39
|
+
.map(async () => {
|
|
40
|
+
while (next < items.length) {
|
|
41
|
+
const i = next++;
|
|
42
|
+
results[i] = await fn(items[i], i);
|
|
49
43
|
}
|
|
50
|
-
|
|
44
|
+
});
|
|
45
|
+
await Promise.all(workers);
|
|
46
|
+
return results;
|
|
47
|
+
}
|
|
48
|
+
// A child that ran and produced a diagnosable output problem is worth one
|
|
49
|
+
// more attempt; a login wall, missing binary, trust prompt, or rate limit
|
|
50
|
+
// will not clear in a second, and a mid-flight child death (killAll,
|
|
51
|
+
// crash) must never respawn — especially during shutdown.
|
|
52
|
+
const RETRYABLE_CODES = new Set(["parse_error", "network", "service_unavailable"]);
|
|
53
|
+
export function isRetryableChildFailure(reason) {
|
|
54
|
+
return RETRYABLE_CODES.has(classifyFailure("Provider", reason).diagnosticCode);
|
|
55
|
+
}
|
|
56
|
+
async function pollOnceWithGate(id, adapter, timeout) {
|
|
57
|
+
// One controller per attempt: a timeout aborts only that attempt's
|
|
58
|
+
// children; the signal clears when the attempt settles.
|
|
59
|
+
const controller = new AbortController();
|
|
60
|
+
installAdapterSignal(id, controller.signal);
|
|
61
|
+
let p;
|
|
62
|
+
try {
|
|
63
|
+
p = Promise.resolve(adapter.poll());
|
|
64
|
+
}
|
|
65
|
+
catch (e) {
|
|
66
|
+
clearAdapterSignal(id);
|
|
67
|
+
return { ok: false, reason: e, timedOut: false };
|
|
68
|
+
}
|
|
69
|
+
// Identity-tagged: only this exact instance means our gate fired, never a
|
|
70
|
+
// child error that happens to mention timeouts.
|
|
71
|
+
const gateError = new Error(`timeout after ${timeout}ms`);
|
|
72
|
+
try {
|
|
73
|
+
const value = await new Promise((resolve, reject) => {
|
|
51
74
|
const timer = setTimeout(() => {
|
|
52
75
|
try {
|
|
53
76
|
controller.abort();
|
|
54
77
|
}
|
|
55
78
|
catch { }
|
|
56
|
-
reject(
|
|
79
|
+
reject(gateError);
|
|
57
80
|
}, timeout);
|
|
58
|
-
p.then((v) => {
|
|
81
|
+
p.then((v) => {
|
|
82
|
+
clearTimeout(timer);
|
|
83
|
+
resolve(v);
|
|
84
|
+
}, (e) => {
|
|
85
|
+
clearTimeout(timer);
|
|
86
|
+
reject(e);
|
|
87
|
+
});
|
|
59
88
|
});
|
|
60
|
-
return
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
89
|
+
return { ok: true, value, timedOut: false };
|
|
90
|
+
}
|
|
91
|
+
catch (e) {
|
|
92
|
+
return { ok: false, reason: e, timedOut: e === gateError };
|
|
93
|
+
}
|
|
94
|
+
finally {
|
|
95
|
+
clearAdapterSignal(id);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
const delay = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
99
|
+
export async function pollAll(enabled, opts) {
|
|
100
|
+
const maxConcurrency = opts?.maxConcurrency ?? 3;
|
|
101
|
+
const maxAttempts = Math.max(1, opts?.maxAttempts ?? 2);
|
|
102
|
+
const retryDelayMs = Math.max(0, opts?.retryDelayMs ?? 1000);
|
|
103
|
+
const runOne = async (id) => {
|
|
104
|
+
const a = adapters[id];
|
|
105
|
+
if (!a)
|
|
106
|
+
return { provider: id, status: "rejected", reason: new Error(`unknown adapter ${id}`) };
|
|
107
|
+
// manual adapter has no poll capability — skip without degraded
|
|
108
|
+
if (id === "manual")
|
|
109
|
+
return { provider: id, status: "skipped", reason: new Error("manual skipped") };
|
|
110
|
+
const timeout = opts?.timeouts?.[id] ?? ADAPTER_TIMEOUTS[id] ?? 8000;
|
|
111
|
+
let lastReason = new Error(`${id}: poll produced no result`);
|
|
112
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
113
|
+
if (attempt > 1 && opts?.signal?.aborted)
|
|
114
|
+
break;
|
|
115
|
+
const outcome = await pollOnceWithGate(id, a, timeout);
|
|
116
|
+
if (outcome.ok)
|
|
117
|
+
return { provider: id, status: "fulfilled", value: outcome.value };
|
|
118
|
+
lastReason = outcome.reason;
|
|
119
|
+
if (attempt >= maxAttempts || opts?.signal?.aborted)
|
|
120
|
+
break;
|
|
121
|
+
// Our own gate firing means transient slowness: always retry. A child
|
|
122
|
+
// failure retries only for output problems, never for deaths.
|
|
123
|
+
const retry = outcome.timedOut || isRetryableChildFailure(outcome.reason);
|
|
124
|
+
if (!retry)
|
|
125
|
+
break;
|
|
126
|
+
await delay(retryDelayMs);
|
|
127
|
+
}
|
|
128
|
+
return { provider: id, status: "rejected", reason: lastReason };
|
|
129
|
+
};
|
|
130
|
+
return mapWithConcurrency(enabled, maxConcurrency, runOne);
|
|
72
131
|
}
|
package/dist/adapters/kimi.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
+
import { type GridDims } from "./vt.js";
|
|
1
2
|
import type { ParsedQuota } from "./types.js";
|
|
2
3
|
export declare function kimiProbeDir(): string;
|
|
3
|
-
export declare function parseKimiTui(text: string, now?: Date): ParsedQuota;
|
|
4
|
+
export declare function parseKimiTui(text: string, now?: Date, dims?: Partial<GridDims>): ParsedQuota;
|
|
4
5
|
export declare function parseKimiApiUsage(usagesBody: unknown, meBody?: unknown, now?: Date): ParsedQuota;
|
|
5
6
|
export interface KimiStoredCreds {
|
|
6
7
|
access_token?: string;
|
package/dist/adapters/kimi.js
CHANGED
|
@@ -2,8 +2,10 @@ import fs from "node:fs";
|
|
|
2
2
|
import os from "node:os";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { parseResetText } from "./parse.js";
|
|
5
|
-
import { runPty
|
|
5
|
+
import { runPty } from "./pty.js";
|
|
6
|
+
import { parseScreens } from "./vt.js";
|
|
6
7
|
import { adapterSignal } from "../runtime/spawn.js";
|
|
8
|
+
import { attachEvidence } from "../diagnostics/failure.js";
|
|
7
9
|
const CLIENT_ID = "17e5f671-d194-4dfb-9706-5516cb48c098";
|
|
8
10
|
const DEFAULT_OAUTH_HOST = "https://auth.kimi.ai";
|
|
9
11
|
const DEFAULT_BASE_URL = "https://api.kimi.ai/coding/v1";
|
|
@@ -11,8 +13,10 @@ const POLL_TIMEOUT_MS = 8000;
|
|
|
11
13
|
export function kimiProbeDir() {
|
|
12
14
|
return path.join(process.env.QUOTACAP_HOME ?? os.homedir(), ".quotacap", "kimi-probe");
|
|
13
15
|
}
|
|
14
|
-
export function parseKimiTui(text, now = new Date()) {
|
|
15
|
-
|
|
16
|
+
export function parseKimiTui(text, now = new Date(), dims) {
|
|
17
|
+
return parseScreens(text, dims, (cleaned) => parseKimiCleaned(cleaned, now));
|
|
18
|
+
}
|
|
19
|
+
function parseKimiCleaned(cleaned, now) {
|
|
16
20
|
const weeklyRe = /Weekly limit\s+[^0-9]*(\d+)%\s+used\s+resets\s+(in\s+[^\n│\r]+)/i;
|
|
17
21
|
const fiveRe = /5h limit\s+[^0-9]*(\d+)%\s+used\s+resets\s+(in\s+[^\n│\r]+)/i;
|
|
18
22
|
const w = cleaned.match(weeklyRe);
|
|
@@ -20,10 +24,8 @@ export function parseKimiTui(text, now = new Date()) {
|
|
|
20
24
|
throw new Error("kimi: weekly limit not found in TUI output");
|
|
21
25
|
const f = cleaned.match(fiveRe);
|
|
22
26
|
let sessionPct = null;
|
|
23
|
-
let fiveRaw = null;
|
|
24
27
|
if (f) {
|
|
25
28
|
sessionPct = parseInt(f[1], 10);
|
|
26
|
-
fiveRaw = f[2].trim();
|
|
27
29
|
}
|
|
28
30
|
else {
|
|
29
31
|
const fallback = cleaned.match(/5h limit[^\d%]*(\d+)%\s+used/i);
|
|
@@ -38,13 +40,13 @@ export function parseKimiTui(text, now = new Date()) {
|
|
|
38
40
|
if (!Number.isFinite(sessionPct) || sessionPct < 0 || sessionPct > 100)
|
|
39
41
|
throw new Error("kimi: bad 5h pct");
|
|
40
42
|
const weeklyRaw = w[2].trim();
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
43
|
+
// Degrade, don't fail (grok precedent): the percents are the critical
|
|
44
|
+
// fields; an unparseable timestamp degrades to an estimate.
|
|
45
|
+
let weeklyIso = parseResetText(`resets ${weeklyRaw}`, now);
|
|
46
|
+
let estimated = false;
|
|
47
|
+
if (!weeklyIso) {
|
|
48
|
+
weeklyIso = new Date(now.getTime() + 7 * 86400000).toISOString();
|
|
49
|
+
estimated = true;
|
|
48
50
|
}
|
|
49
51
|
let plan = "unknown";
|
|
50
52
|
const paren = cleaned.match(/Weekly limit\s*\(([^)]+)\)/i);
|
|
@@ -88,6 +90,7 @@ export function parseKimiTui(text, now = new Date()) {
|
|
|
88
90
|
periodStart,
|
|
89
91
|
source: "tui",
|
|
90
92
|
fetchedAt: now.toISOString(),
|
|
93
|
+
resetsAtEstimated: estimated || undefined,
|
|
91
94
|
raw: cleaned.slice(0, 4096),
|
|
92
95
|
...monthlyFields,
|
|
93
96
|
};
|
|
@@ -497,7 +500,15 @@ export async function pollKimiPty() {
|
|
|
497
500
|
signal: adapterSignal("kimi"),
|
|
498
501
|
label: "kimi",
|
|
499
502
|
});
|
|
500
|
-
|
|
503
|
+
try {
|
|
504
|
+
return parseKimiTui(transcript, new Date(), { cols: 140, rows: 35 });
|
|
505
|
+
}
|
|
506
|
+
catch (e) {
|
|
507
|
+
// Side-channel only: the transcript a parse throw would discard, for
|
|
508
|
+
// failure bundles. The message is untouched.
|
|
509
|
+
attachEvidence(e, { source: "pty", stdout: transcript });
|
|
510
|
+
throw e;
|
|
511
|
+
}
|
|
501
512
|
}
|
|
502
513
|
export const kimiAdapter = {
|
|
503
514
|
id: "kimi",
|
package/dist/adapters/muse.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type GridDims } from "./vt.js";
|
|
1
2
|
import type { ParsedQuota } from "./types.js";
|
|
2
3
|
/** Canonical unavailability error: parseMuseTui throws it, poll() recognises it
|
|
3
4
|
* to start recovery, and failure.ts classifies its text as service_unavailable. */
|
|
@@ -28,7 +29,7 @@ export interface RecoveryDeps {
|
|
|
28
29
|
*/
|
|
29
30
|
export declare function recoverSubscription(deps: RecoveryDeps): Promise<ParsedQuota>;
|
|
30
31
|
export declare function museProbeDir(): string;
|
|
31
|
-
export declare function parseMuseTui(text: string, now?: Date): ParsedQuota;
|
|
32
|
+
export declare function parseMuseTui(text: string, now?: Date, dims?: Partial<GridDims>): ParsedQuota;
|
|
32
33
|
export declare const museAdapter: {
|
|
33
34
|
id: string;
|
|
34
35
|
requiresAuth: string;
|
package/dist/adapters/muse.js
CHANGED
|
@@ -2,8 +2,10 @@ import fs from "node:fs";
|
|
|
2
2
|
import os from "node:os";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { parseResetText } from "./parse.js";
|
|
5
|
-
import { runPty
|
|
5
|
+
import { runPty } from "./pty.js";
|
|
6
|
+
import { parseScreens } from "./vt.js";
|
|
6
7
|
import { adapterSignal, trackedExecFile } from "../runtime/spawn.js";
|
|
8
|
+
import { attachEvidence } from "../diagnostics/failure.js";
|
|
7
9
|
/** Canonical unavailability error: parseMuseTui throws it, poll() recognises it
|
|
8
10
|
* to start recovery, and failure.ts classifies its text as service_unavailable. */
|
|
9
11
|
export const MUSE_UNAVAILABLE_MESSAGE = "muse: subscription currently unavailable in TUI output";
|
|
@@ -74,10 +76,13 @@ export async function recoverSubscription(deps) {
|
|
|
74
76
|
export function museProbeDir() {
|
|
75
77
|
return path.join(process.env.QUOTACAP_HOME ?? os.homedir(), ".quotacap", "muse-probe");
|
|
76
78
|
}
|
|
77
|
-
export function parseMuseTui(text, now = new Date()) {
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
79
|
+
export function parseMuseTui(text, now = new Date(), dims) {
|
|
80
|
+
return parseScreens(text, dims, (cleaned) => parseMuseCleaned(cleaned, now));
|
|
81
|
+
}
|
|
82
|
+
// Input is either the rendered screen (visual rows) or the byte soup (one
|
|
83
|
+
// glued line): no pattern here may anchor on ^ or $, and row-spanning
|
|
84
|
+
// matches must allow newlines.
|
|
85
|
+
function parseMuseCleaned(cleaned, now) {
|
|
81
86
|
if (/currently unavailable|subscriptions aren't currently available|subscription_unavailable/i.test(cleaned)) {
|
|
82
87
|
throw new Error(MUSE_UNAVAILABLE_MESSAGE);
|
|
83
88
|
}
|
|
@@ -102,15 +107,16 @@ export function parseMuseTui(text, now = new Date()) {
|
|
|
102
107
|
plan = planMatch[1].trim();
|
|
103
108
|
// The Current-window reset ("Resets at 3:51 PM") is a bare clock time with
|
|
104
109
|
// no date, and Quota has no session-reset field — discarded, as claude and
|
|
105
|
-
// kimi already do. A missing or unparseable Weekly reset
|
|
106
|
-
//
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
110
|
+
// kimi already do. A missing or unparseable Weekly reset degrades to an
|
|
111
|
+
// estimate (grok precedent): the percents are the critical fields, and the
|
|
112
|
+
// estimate flag keeps advisory and scheduler honest.
|
|
113
|
+
const resetMatch = cleaned.match(/Weekly[\s\S]{0,400}?Resets\s+([A-Za-z]{3}\s+\d{1,2}\s+at\s+\d{1,2}:\d{2}\s*[AP]M)/i);
|
|
114
|
+
let resetsAt = resetMatch ? parseResetText(`resets ${resetMatch[1].trim()}`, now) : null;
|
|
115
|
+
let estimated = false;
|
|
116
|
+
if (!resetsAt) {
|
|
117
|
+
resetsAt = new Date(now.getTime() + 7 * 86400000).toISOString();
|
|
118
|
+
estimated = true;
|
|
119
|
+
}
|
|
114
120
|
const periodStart = new Date(new Date(resetsAt).getTime() - 7 * 86400000).toISOString();
|
|
115
121
|
return {
|
|
116
122
|
provider: "muse",
|
|
@@ -121,6 +127,7 @@ export function parseMuseTui(text, now = new Date()) {
|
|
|
121
127
|
periodStart,
|
|
122
128
|
source: "tui",
|
|
123
129
|
fetchedAt: now.toISOString(),
|
|
130
|
+
resetsAtEstimated: estimated || undefined,
|
|
124
131
|
raw: cleaned.slice(0, 4096),
|
|
125
132
|
};
|
|
126
133
|
}
|
|
@@ -157,7 +164,15 @@ async function usagePass(timeoutMs = USAGE_TIMEOUT_MS) {
|
|
|
157
164
|
signal: adapterSignal("muse"),
|
|
158
165
|
label: "muse",
|
|
159
166
|
});
|
|
160
|
-
|
|
167
|
+
try {
|
|
168
|
+
return parseMuseTui(transcript);
|
|
169
|
+
}
|
|
170
|
+
catch (e) {
|
|
171
|
+
// Side-channel only: the transcript a parse throw would discard, for
|
|
172
|
+
// failure bundles. The message is untouched.
|
|
173
|
+
attachEvidence(e, { source: "pty", stdout: transcript });
|
|
174
|
+
throw e;
|
|
175
|
+
}
|
|
161
176
|
}
|
|
162
177
|
export const museAdapter = {
|
|
163
178
|
id: "muse",
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ParsedQuota } from "./types.js";
|
|
2
|
-
export declare const OPENCODE_GO_CONSENT_NOTICE
|
|
2
|
+
export declare const OPENCODE_GO_CONSENT_NOTICE: string;
|
|
3
3
|
export declare function openCodeGoAuthPath(): string;
|
|
4
4
|
/** Detection is existence only — no content read before enablement. */
|
|
5
5
|
export declare function opencodeGoDetected(): boolean;
|
|
@@ -8,7 +8,14 @@ import path from "node:path";
|
|
|
8
8
|
import { adapterSignal } from "../runtime/spawn.js";
|
|
9
9
|
const USAGE_URL = "https://opencode.ai/zen/go/v1/usage";
|
|
10
10
|
const POLL_TIMEOUT_MS = 8000;
|
|
11
|
-
export const OPENCODE_GO_CONSENT_NOTICE =
|
|
11
|
+
export const OPENCODE_GO_CONSENT_NOTICE = [
|
|
12
|
+
"• What is read: API key OpenCode already stored when you ran `opencode auth login -p opencode-go` (`~/.local/share/opencode/auth.json`, `opencode-go` → `opencode` fallback).",
|
|
13
|
+
"• Read frequency: in-memory, read-only once per poll (every 15 minutes by default).",
|
|
14
|
+
"• Request destination: sent only as `Authorization: Bearer` to `https://opencode.ai/zen/go/v1/usage` to fetch `rolling/weekly/monthly` `percent` + `resetsAt`.",
|
|
15
|
+
"• Never-list: never used to make chat or model requests, never written or rotated, never stored in the `quotacap` DB, never returned by the API or MCP, and never logged (errors show `[redacted]`).",
|
|
16
|
+
"• Revocation instructions: revoke at `https://opencode.ai/auth` or `opencode providers logout opencode-go` and run `quotacap providers disable opencode-go` to stop polling.",
|
|
17
|
+
"• Optional environment override: if you prefer no file access, set `OPENCODE_API_KEY` instead.",
|
|
18
|
+
].join("\n");
|
|
12
19
|
export function openCodeGoAuthPath() {
|
|
13
20
|
return path.join(os.homedir(), ".local", "share", "opencode", "auth.json");
|
|
14
21
|
}
|