quotacap 0.0.42 → 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.
@@ -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);
@@ -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
- const { stdout } = await trackedExecFile("claude", bin, ["-p", "/usage", "--output-format", "json", "--strict-mcp-config"], { timeout: 8000 });
30
- const parsed = JSON.parse(stdout);
31
- const result = parsed.result ?? stdout;
32
- return parseClaudeUsage(result);
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
  };
@@ -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;
@@ -1,6 +1,8 @@
1
1
  import os from "node:os";
2
- import { runPty, stripAnsi } from "./pty.js";
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
- const cleaned = stripAnsi(text);
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
- return parseCodexTui(transcript);
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
+ }
@@ -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;
@@ -1,6 +1,8 @@
1
1
  import os from "node:os";
2
- import { runPty, stripAnsi } from "./pty.js";
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
- const m = raw.trim().match(/^([A-Za-z]+)\s+(\d{1,2}),\s*(\d{1,2}):(\d{2})$/);
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
- const cleaned = stripAnsi(text);
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+\d+,\s*\d+:\d+)/gi;
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
- else {
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
- return parseGrokTui(transcript);
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
  };
@@ -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
- } | {
12
- value?: undefined;
13
- provider: string;
14
- status: "skipped";
15
- reason: any;
16
30
  } | {
17
31
  value?: undefined;
18
32
  provider: string;
19
33
  status: "rejected";
20
- reason: any;
34
+ reason: unknown;
21
35
  })[]>;
@@ -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
- const ADAPTER_TIMEOUTS = {
21
- claude: 8000,
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
- export async function pollAll(enabled, opts) {
30
- const rawJobs = enabled.map(id => {
31
- const a = adapters[id];
32
- if (!a)
33
- return Promise.reject(new Error(`unknown adapter ${id}`));
34
- // manual adapter has no poll capability — skip without degraded
35
- if (id === "manual")
36
- return Promise.reject(new Error("manual skipped"));
37
- const timeout = opts?.timeouts?.[id] ?? ADAPTER_TIMEOUTS[id] ?? 8000;
38
- // One controller per adapter: a timeout aborts only that adapter's
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
- const gated = new Promise((resolve, reject) => {
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(new Error(`timeout after ${timeout}ms`));
79
+ reject(gateError);
57
80
  }, timeout);
58
- p.then((v) => { clearTimeout(timer); resolve(v); }, (e) => { clearTimeout(timer); reject(e); });
81
+ p.then((v) => {
82
+ clearTimeout(timer);
83
+ resolve(v);
84
+ }, (e) => {
85
+ clearTimeout(timer);
86
+ reject(e);
87
+ });
59
88
  });
60
- return gated.finally(() => clearAdapterSignal(id));
61
- });
62
- const settled = await Promise.allSettled(rawJobs);
63
- return settled.map((s, i) => {
64
- const id = enabled[i];
65
- if (s.status === "fulfilled")
66
- return { provider: id, status: "fulfilled", value: s.value };
67
- const msg = String(s.reason?.message ?? s.reason);
68
- if (msg.includes("manual skipped"))
69
- return { provider: id, status: "skipped", reason: s.reason };
70
- return { provider: id, status: "rejected", reason: s.reason };
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
  }
@@ -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;
@@ -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, stripAnsi } from "./pty.js";
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
- const cleaned = stripAnsi(text);
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
- const weeklyIso = parseResetText(`resets ${weeklyRaw}`, now);
42
- if (!weeklyIso)
43
- throw new Error(`kimi: bad weekly reset "${weeklyRaw}"`);
44
- if (fiveRaw) {
45
- const fiveIso = parseResetText(`resets ${fiveRaw}`, now);
46
- if (!fiveIso)
47
- throw new Error(`kimi: bad 5h reset "${fiveRaw}"`);
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
- return parseKimiTui(transcript);
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",
@@ -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;
@@ -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, stripAnsi } from "./pty.js";
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
- // After ANSI stripping the panel is ONE line (cursor-addressed, no
79
- // newlines), so no pattern here may anchor on ^, $ or \n.
80
- const cleaned = stripAnsi(text);
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 throws rather than
106
- // estimating: an invented reset would feed the advisory a false deadline.
107
- const resetMatch = cleaned.match(/Weekly[^\n]*?Resets\s+([A-Za-z]{3}\s+\d{1,2}\s+at\s+\d{1,2}:\d{2}\s*[AP]M)/i);
108
- if (!resetMatch)
109
- throw new Error("muse: bad weekly reset");
110
- const resetRaw = resetMatch[1].trim();
111
- const resetsAt = parseResetText(`resets ${resetRaw}`, now);
112
- if (!resetsAt)
113
- throw new Error(`muse: bad weekly reset "${resetRaw}"`);
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
- return parseMuseTui(transcript);
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",
@@ -22,6 +22,13 @@ export interface PtyRunOptions {
22
22
  */
23
23
  submitInput?: string;
24
24
  submitAfterMs?: number;
25
+ /**
26
+ * Grace delay after the completion pattern first matches, before the
27
+ * child is killed. Usage dialogs paint asynchronously (header first,
28
+ * timestamp later); without this the kill can land mid-paint. Bounded
29
+ * and fixed (not quiescence-detected: spinners never settle). Default 400.
30
+ */
31
+ completionSettleMs?: number;
25
32
  /**
26
33
  * Opt-in terminal-query responder: answers DSR, DA1 and OSC 4/10/11
27
34
  * capability queries the child emits. Off by default; TUIs that never