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.
Files changed (56) hide show
  1. package/dist/adapters/claude.js +16 -5
  2. package/dist/adapters/codex.d.ts +2 -1
  3. package/dist/adapters/codex.js +19 -8
  4. package/dist/adapters/goldens.d.ts +4 -0
  5. package/dist/adapters/goldens.js +20 -0
  6. package/dist/adapters/grok.d.ts +2 -1
  7. package/dist/adapters/grok.js +26 -12
  8. package/dist/adapters/index.d.ts +20 -6
  9. package/dist/adapters/index.js +96 -37
  10. package/dist/adapters/kimi.d.ts +2 -1
  11. package/dist/adapters/kimi.js +24 -13
  12. package/dist/adapters/muse.d.ts +2 -1
  13. package/dist/adapters/muse.js +30 -15
  14. package/dist/adapters/opencode-go.d.ts +1 -1
  15. package/dist/adapters/opencode-go.js +8 -1
  16. package/dist/adapters/pty.d.ts +7 -0
  17. package/dist/adapters/pty.js +32 -2
  18. package/dist/adapters/vt.d.ts +19 -0
  19. package/dist/adapters/vt.js +506 -0
  20. package/dist/cli/providers.js +2 -1
  21. package/dist/config.d.ts +12 -23
  22. package/dist/config.js +24 -0
  23. package/dist/diagnostics/bundle.d.ts +22 -0
  24. package/dist/diagnostics/bundle.js +125 -0
  25. package/dist/diagnostics/failure.d.ts +8 -1
  26. package/dist/diagnostics/failure.js +43 -2
  27. package/dist/format/rows.d.ts +1 -1
  28. package/dist/format/rows.js +9 -6
  29. package/dist/mcp/server.d.ts +2 -2
  30. package/dist/runtime/poll.d.ts +4 -0
  31. package/dist/runtime/poll.js +30 -1
  32. package/dist/runtime/service.js +3 -1
  33. package/dist/version.js +1 -1
  34. package/dist/web/src/App.js +1 -1
  35. package/dist/web/src/components/AdviceDrawer.js +1 -1
  36. package/dist/web/src/components/OpencodeGoBanner.d.ts +1 -1
  37. package/dist/web/src/components/OpencodeGoBanner.js +9 -2
  38. package/dist/web/src/components/PaceBar.d.ts +9 -4
  39. package/dist/web/src/components/PaceBar.js +26 -7
  40. package/dist/web/src/components/ProviderCard.js +2 -3
  41. package/dist/web/src/components/ProviderDrawer.js +3 -3
  42. package/dist/web/src/components/ProviderIcon.js +1 -1
  43. package/dist/web/src/components/ProviderRow.d.ts +1 -1
  44. package/dist/web/src/components/ProviderRow.js +2 -4
  45. package/dist/web/src/components/Recommendation.js +1 -1
  46. package/dist/web/src/components/ResetRail.js +1 -1
  47. package/dist/web/src/components/WindowsRow.d.ts +1 -1
  48. package/dist/web/src/components/WindowsRow.js +4 -4
  49. package/dist/web/src/components/settings/SettingsDrawer.js +1 -1
  50. package/dist/webAssets.js +1 -1
  51. package/dist/webHtml.js +1 -1
  52. package/package.json +5 -5
  53. package/web/dist/assets/{index-Dk6l5h27.css → index-C6ltEeGL.css} +1 -1
  54. package/web/dist/assets/index-DRDDTtxF.js +13 -0
  55. package/web/dist/index.html +2 -2
  56. package/web/dist/assets/index-BUK8xUHm.js +0 -12
package/dist/config.d.ts CHANGED
@@ -1,35 +1,18 @@
1
1
  import { z } from "zod";
2
2
  export declare function getConfigPath(p?: string): string;
3
3
  export declare function getDbPath(p?: string): string;
4
- export declare const ProviderDisplayNameSchema: z.ZodEffects<z.ZodEffects<z.ZodString, string, string>, string, string>;
4
+ export declare const ProviderDisplayNameSchema: z.ZodPipe<z.ZodString, z.ZodTransform<string, string>>;
5
5
  declare const ConfigSchema: z.ZodObject<{
6
6
  port: z.ZodDefault<z.ZodNumber>;
7
7
  pollMinutes: z.ZodDefault<z.ZodNumber>;
8
8
  catalogTtlHours: z.ZodDefault<z.ZodNumber>;
9
- enabledProviders: z.ZodDefault<z.ZodArray<z.ZodString, "many">>;
10
- knownProviders: z.ZodDefault<z.ZodArray<z.ZodString, "many">>;
11
- providerNames: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodEffects<z.ZodEffects<z.ZodString, string, string>, string, string>>>;
9
+ enabledProviders: z.ZodDefault<z.ZodArray<z.ZodString>>;
10
+ knownProviders: z.ZodDefault<z.ZodArray<z.ZodString>>;
11
+ providerNames: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodPipe<z.ZodString, z.ZodTransform<string, string>>>>;
12
12
  opencodeGoConsentAt: z.ZodDefault<z.ZodNullable<z.ZodString>>;
13
13
  experimentalIngest: z.ZodOptional<z.ZodBoolean>;
14
- }, "strip", z.ZodTypeAny, {
15
- port: number;
16
- pollMinutes: number;
17
- catalogTtlHours: number;
18
- enabledProviders: string[];
19
- knownProviders: string[];
20
- providerNames: Record<string, string>;
21
- opencodeGoConsentAt: string | null;
22
- experimentalIngest?: boolean | undefined;
23
- }, {
24
- port?: number | undefined;
25
- pollMinutes?: number | undefined;
26
- catalogTtlHours?: number | undefined;
27
- enabledProviders?: string[] | undefined;
28
- knownProviders?: string[] | undefined;
29
- providerNames?: Record<string, string> | undefined;
30
- opencodeGoConsentAt?: string | null | undefined;
31
- experimentalIngest?: boolean | undefined;
32
- }>;
14
+ debugFailureBundles: z.ZodOptional<z.ZodBoolean>;
15
+ }, z.core.$strip>;
33
16
  export type Config = z.infer<typeof ConfigSchema>;
34
17
  /**
35
18
  * Manual ingest (CLI `ingest`, POST /api/ingest) is off unless this returns
@@ -37,6 +20,12 @@ export type Config = z.infer<typeof ConfigSchema>;
37
20
  * config key. Do not document this as a supported product flag.
38
21
  */
39
22
  export declare function isExperimentalIngestEnabled(config?: Pick<Config, "experimentalIngest">, env?: NodeJS.Dict<string>): boolean;
23
+ /**
24
+ * Failure evidence bundles (per-provider redacted transcripts under
25
+ * <dataDir>/failures/) are off unless this returns true. Env
26
+ * `QUOTACAP_DEBUG_FAILURES` wins; otherwise the optional config key.
27
+ */
28
+ export declare function isFailureBundlesEnabled(config?: Pick<Config, "debugFailureBundles">, env?: NodeJS.Dict<string>): boolean;
40
29
  export declare function readConfig(p?: string): Promise<Config>;
41
30
  export declare function defaultConfig(): Config;
42
31
  export interface EnsureConfigResult {
package/dist/config.js CHANGED
@@ -41,6 +41,10 @@ const ConfigSchema = z.object({
41
41
  // Optional, omitted from defaults and `init` output. Manual ingest stays
42
42
  // in-tree but is not a public surface until the product design lands.
43
43
  experimentalIngest: z.boolean().optional(),
44
+ // Opt-in failure forensics: on poll failure, write a redacted evidence
45
+ // bundle (transcript, error, versions, timings) under <dataDir>/failures/.
46
+ // Local-only, never uploaded. Env QUOTACAP_DEBUG_FAILURES wins (see below).
47
+ debugFailureBundles: z.boolean().optional(),
44
48
  });
45
49
  function envFlag(raw) {
46
50
  if (raw === undefined)
@@ -71,6 +75,25 @@ export function isExperimentalIngestEnabled(config, env = process.env) {
71
75
  return false;
72
76
  }
73
77
  }
78
+ /**
79
+ * Failure evidence bundles (per-provider redacted transcripts under
80
+ * <dataDir>/failures/) are off unless this returns true. Env
81
+ * `QUOTACAP_DEBUG_FAILURES` wins; otherwise the optional config key.
82
+ */
83
+ export function isFailureBundlesEnabled(config, env = process.env) {
84
+ const fromEnv = envFlag(env.QUOTACAP_DEBUG_FAILURES);
85
+ if (fromEnv !== undefined)
86
+ return fromEnv;
87
+ if (config)
88
+ return config.debugFailureBundles === true;
89
+ try {
90
+ const parsed = JSON.parse(fsSync.readFileSync(getConfigPath(), "utf8"));
91
+ return parsed?.debugFailureBundles === true;
92
+ }
93
+ catch {
94
+ return false;
95
+ }
96
+ }
74
97
  export async function readConfig(p) {
75
98
  try {
76
99
  const raw = await fs.readFile(getConfigPath(p), "utf8");
@@ -106,6 +129,7 @@ const ServiceConfigSchema = z.object({
106
129
  providerNames: z.record(z.string(), ProviderDisplayNameSchema).default({}),
107
130
  opencodeGoConsentAt: z.string().nullable().default(null),
108
131
  experimentalIngest: z.boolean().optional(),
132
+ debugFailureBundles: z.boolean().optional(),
109
133
  });
110
134
  export function defaultConfig() {
111
135
  return ConfigSchema.parse({});
@@ -0,0 +1,22 @@
1
+ import { type DiagnosticCode } from "./failure.js";
2
+ export declare const BUNDLE_KEEP_PER_PROVIDER = 5;
3
+ export declare function redactText(s: string): string;
4
+ export declare function providerBin(provider: string, providerPaths?: Record<string, string | null>): string | null;
5
+ /** Best-effort `<bin> --version` first line, or null. Never throws. */
6
+ export declare function getCliVersion(bin: string, timeoutMs?: number): Promise<string | null>;
7
+ export interface FailureBundleInput {
8
+ dataDir: string;
9
+ provider: string;
10
+ attemptedAt: string;
11
+ reason: unknown;
12
+ diagnosticCode: DiagnosticCode;
13
+ summary: string;
14
+ errorDetail: string;
15
+ attemptDurationMs?: number;
16
+ timeoutMs?: number;
17
+ quotacapVersion?: string;
18
+ cliBin?: string | null;
19
+ cliVersion?: string | null;
20
+ }
21
+ /** Write one bundle. Never throws: returns the dir path, or null. */
22
+ export declare function writeFailureBundle(input: FailureBundleInput): Promise<string | null>;
@@ -0,0 +1,125 @@
1
+ // Opt-in failure forensics: on a poll failure, persist a redacted evidence
2
+ // bundle under <dataDir>/failures/<provider>-<timestamp>-<rand>/. Bundles
3
+ // are local-only (never uploaded) and exist so the next "bad resets
4
+ // timestamp" arrives with the transcript that explains it. Off by default;
5
+ // enabled via config `debugFailureBundles` or env QUOTACAP_DEBUG_FAILURES.
6
+ import { execFile } from "node:child_process";
7
+ import fs from "node:fs/promises";
8
+ import path from "node:path";
9
+ import { getErrorEvidence } from "./failure.js";
10
+ import { renderScreen } from "../adapters/vt.js";
11
+ import { isGoldenCovered } from "../adapters/goldens.js";
12
+ export const BUNDLE_KEEP_PER_PROVIDER = 5;
13
+ const RAW_CAP = 64 * 1024;
14
+ const RENDERED_CAP = 32 * 1024;
15
+ const STDERR_CAP = 8 * 1024;
16
+ const MESSAGE_CAP = 2048;
17
+ // Emails, URLs, bearer tokens, and long token-like runs. Percents, dates,
18
+ // and short identifiers pass through: parse debugging needs them.
19
+ export function redactText(s) {
20
+ return s
21
+ .replace(/[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g, "[email]")
22
+ .replace(/https?:\/\/\S+/g, "[url]")
23
+ .replace(/(bearer\s+)[A-Za-z0-9._~+/-]+/gi, "$1[token]")
24
+ .replace(/\b[A-Za-z0-9_~+\/=.-]{32,}\b/g, "[token]");
25
+ }
26
+ function cap(s, n) {
27
+ return s.length > n ? s.slice(0, n) + `\n…[truncated, ${s.length} chars total]` : s;
28
+ }
29
+ // Providers without a version-reporting CLI (credential stores, virtual).
30
+ const NO_CLI_PROVIDERS = new Set(["opencode-go", "manual", "all"]);
31
+ export function providerBin(provider, providerPaths) {
32
+ if (NO_CLI_PROVIDERS.has(provider))
33
+ return null;
34
+ const recorded = providerPaths?.[provider];
35
+ if (typeof recorded === "string" && recorded.length > 0)
36
+ return recorded;
37
+ return provider;
38
+ }
39
+ /** Best-effort `<bin> --version` first line, or null. Never throws. */
40
+ export function getCliVersion(bin, timeoutMs = 3000) {
41
+ return new Promise((resolve) => {
42
+ execFile(bin, ["--version"], { timeout: timeoutMs }, (err, stdout, stderr) => {
43
+ if (err) {
44
+ resolve(null);
45
+ return;
46
+ }
47
+ const first = `${stdout ?? ""}\n${stderr ?? ""}`
48
+ .split("\n")
49
+ .map((l) => l.trim())
50
+ .find((l) => l.length > 0);
51
+ resolve(first ? first.slice(0, 120) : null);
52
+ });
53
+ });
54
+ }
55
+ async function pruneOldBundles(failuresDir, provider) {
56
+ try {
57
+ const entries = await fs.readdir(failuresDir);
58
+ const ours = entries.filter((e) => e === provider || e.startsWith(`${provider}-`)).sort();
59
+ const stale = ours.slice(0, Math.max(0, ours.length - BUNDLE_KEEP_PER_PROVIDER));
60
+ await Promise.all(stale.map((e) => fs.rm(path.join(failuresDir, e), { recursive: true, force: true }).catch(() => { })));
61
+ }
62
+ catch { }
63
+ }
64
+ /** Write one bundle. Never throws: returns the dir path, or null. */
65
+ export async function writeFailureBundle(input) {
66
+ try {
67
+ const stamp = input.attemptedAt.replace(/[:.]/g, "-");
68
+ const rand = Math.floor(Math.random() * 0x10000).toString(16).padStart(4, "0");
69
+ const dir = path.join(input.dataDir, "failures", `${input.provider}-${stamp}-${rand}`);
70
+ await fs.mkdir(dir, { recursive: true });
71
+ const evidence = getErrorEvidence(input.reason);
72
+ const raw = typeof evidence?.stdout === "string" ? evidence.stdout : "";
73
+ const message = input.reason instanceof Error ? input.reason.message : String(input.reason?.message ?? input.reason);
74
+ const files = [];
75
+ if (raw) {
76
+ files.push(["transcript-raw.txt", redactText(cap(raw, RAW_CAP))]);
77
+ if (evidence?.source === "pty") {
78
+ files.push(["transcript.txt", redactText(cap(renderScreen(raw), RENDERED_CAP))]);
79
+ }
80
+ }
81
+ const errLines = [
82
+ `summary: ${input.summary}`,
83
+ `code: ${input.diagnosticCode}`,
84
+ `detail: ${input.errorDetail}`,
85
+ `message: ${redactText(cap(message, MESSAGE_CAP))}`,
86
+ ];
87
+ if (evidence?.checkpoint)
88
+ errLines.push(`checkpoint: ${evidence.checkpoint}`);
89
+ if (evidence?.exitCode !== undefined)
90
+ errLines.push(`exitCode: ${evidence.exitCode}`);
91
+ if (evidence?.durationMs !== undefined)
92
+ errLines.push(`durationMs: ${evidence.durationMs}`);
93
+ if (evidence?.timings)
94
+ errLines.push(`timings: ${JSON.stringify(evidence.timings)}`);
95
+ const stderr = typeof evidence?.stderr === "string" ? evidence.stderr.trim() : "";
96
+ if (stderr)
97
+ errLines.push(`stderr:\n${redactText(cap(stderr, STDERR_CAP))}`);
98
+ files.push(["error.txt", errLines.join("\n") + "\n"]);
99
+ const meta = {
100
+ provider: input.provider,
101
+ attemptedAt: input.attemptedAt,
102
+ quotacapVersion: input.quotacapVersion ?? null,
103
+ cliBin: input.cliBin ?? null,
104
+ cliVersion: input.cliVersion ?? null,
105
+ goldenCovered: input.cliVersion ? isGoldenCovered(input.provider, input.cliVersion) : null,
106
+ diagnosticCode: input.diagnosticCode,
107
+ summary: input.summary,
108
+ errorDetail: input.errorDetail,
109
+ attemptDurationMs: input.attemptDurationMs ?? null,
110
+ timeoutMs: input.timeoutMs ?? null,
111
+ checkpoint: evidence?.checkpoint ?? null,
112
+ exitCode: evidence?.exitCode ?? null,
113
+ durationMs: evidence?.durationMs ?? null,
114
+ timings: evidence?.timings ?? null,
115
+ hasTranscript: raw.length > 0,
116
+ };
117
+ files.push(["meta.json", JSON.stringify(meta, null, 2) + "\n"]);
118
+ await Promise.all(files.map(([name, content]) => fs.writeFile(path.join(dir, name), content, "utf8")));
119
+ await pruneOldBundles(path.join(input.dataDir, "failures"), input.provider);
120
+ return dir;
121
+ }
122
+ catch {
123
+ return null;
124
+ }
125
+ }
@@ -7,6 +7,8 @@ export interface FailureEvidence {
7
7
  durationMs?: number;
8
8
  stdout?: string;
9
9
  stderr?: string;
10
+ /** Phase marks (ms since attempt start): spawn, input, completion, end. */
11
+ timings?: Record<string, number>;
10
12
  }
11
13
  export interface SafeDiagnostic {
12
14
  diagnosticCode: DiagnosticCode;
@@ -19,8 +21,13 @@ export interface ClassifiedFailure extends SafeDiagnostic {
19
21
  }
20
22
  export declare class DiagnosticError extends Error {
21
23
  readonly diagnostic: SafeDiagnostic;
22
- constructor(diagnostic: SafeDiagnostic);
24
+ /** Raw failure evidence (transcript, timings). Memory-only: classification
25
+ * and logs use `diagnostic`; bundles read this. */
26
+ readonly evidence?: FailureEvidence;
27
+ constructor(diagnostic: SafeDiagnostic, evidence?: FailureEvidence);
23
28
  }
29
+ export declare function attachEvidence(error: unknown, evidence: FailureEvidence): void;
30
+ export declare function getErrorEvidence(reason: unknown): FailureEvidence | undefined;
24
31
  export declare function formatProviderName(provider: string): string;
25
32
  export declare function diagnosticError(reason: unknown, evidence?: FailureEvidence): DiagnosticError;
26
33
  export declare function classifyFailure(provider: string, reason: unknown): ClassifiedFailure;
@@ -1,11 +1,52 @@
1
1
  export class DiagnosticError extends Error {
2
2
  diagnostic;
3
- constructor(diagnostic) {
3
+ /** Raw failure evidence (transcript, timings). Memory-only: classification
4
+ * and logs use `diagnostic`; bundles read this. */
5
+ evidence;
6
+ constructor(diagnostic, evidence) {
4
7
  super(diagnostic.errorDetail);
5
8
  this.name = "DiagnosticError";
6
9
  this.diagnostic = Object.freeze({ ...diagnostic });
10
+ // Non-enumerable: evidence must never leak through JSON.stringify or
11
+ // spreads — serialized errors carry the diagnosis only. Readable via
12
+ // getErrorEvidence.
13
+ if (evidence) {
14
+ Object.defineProperty(this, "evidence", {
15
+ value: Object.freeze({ ...evidence }),
16
+ enumerable: false,
17
+ writable: false,
18
+ });
19
+ }
7
20
  }
8
21
  }
22
+ // Side-channel evidence for plain Errors: adapter polls attach the transcript
23
+ // a parse throw would otherwise discard, without touching the message that
24
+ // classification and existing logs depend on.
25
+ const EVIDENCE_KEY = "__quotacapEvidence";
26
+ export function attachEvidence(error, evidence) {
27
+ if (typeof error === "object" && error !== null) {
28
+ try {
29
+ // Non-enumerable, like DiagnosticError.evidence: serialization-safe.
30
+ Object.defineProperty(error, EVIDENCE_KEY, {
31
+ value: evidence,
32
+ enumerable: false,
33
+ writable: true,
34
+ configurable: true,
35
+ });
36
+ }
37
+ catch { }
38
+ }
39
+ }
40
+ export function getErrorEvidence(reason) {
41
+ if (reason instanceof DiagnosticError && reason.evidence)
42
+ return reason.evidence;
43
+ if (typeof reason === "object" && reason !== null) {
44
+ const ev = reason[EVIDENCE_KEY];
45
+ if (ev && typeof ev === "object" && (ev.source === "pty" || ev.source === "exec"))
46
+ return ev;
47
+ }
48
+ return undefined;
49
+ }
9
50
  export function formatProviderName(provider) {
10
51
  switch (provider) {
11
52
  case "claude":
@@ -426,7 +467,7 @@ export function diagnosticError(reason, evidence) {
426
467
  return new DiagnosticError({
427
468
  diagnosticCode: match.code,
428
469
  errorDetail: detail,
429
- });
470
+ }, evidence);
430
471
  }
431
472
  function mapCategory(code) {
432
473
  switch (code) {
@@ -21,7 +21,7 @@ export declare function weeklyPctOf(q: {
21
21
  usedPct?: number;
22
22
  } | null | undefined): number | undefined;
23
23
  export declare function fmtDuration(ms: number): string;
24
- export declare function weekdayOf(iso: string | undefined): string;
24
+ export declare function dayOf(iso: string | undefined): string;
25
25
  export declare function countdownText(ps: ProviderSnapshot, now: Date): string;
26
26
  export declare function elapsedPct(ps: ProviderSnapshot, now: Date): number | null;
27
27
  export declare function usedPctOf(ps: ProviderSnapshot): number | null;
@@ -23,11 +23,14 @@ export function fmtDuration(ms) {
23
23
  return `${Math.floor(ms / HOUR_MS)}h`;
24
24
  return `${Math.max(1, Math.floor(ms / MIN_MS))}m`;
25
25
  }
26
- // Short weekday of an ISO instant in the locked English copy, e.g. "Tue".
27
- // Pinned to en-US: the sentence it feeds ("unused until Tue") is English, so
28
- // a non-English machine must not print a localized weekday inside it.
29
- export function weekdayOf(iso) {
30
- return new Date(iso ?? "").toLocaleDateString("en-US", { weekday: "short" });
26
+ // Short weekday plus calendar date of an ISO instant in the locked English
27
+ // copy, e.g. "Tue Sep 15". Pinned to en-US: the sentence it feeds
28
+ // ("unused until Tue Sep 15") is English, so a non-English machine must not
29
+ // print localized parts inside it. A bare weekday scans as this coming week,
30
+ // which misreads a monthly reset sitting weeks out.
31
+ export function dayOf(iso) {
32
+ const when = new Date(iso ?? "");
33
+ return `${when.toLocaleDateString("en-US", { weekday: "short" })} ${when.toLocaleDateString("en-US", { day: "numeric", month: "short" })}`;
31
34
  }
32
35
  export function countdownText(ps, now) {
33
36
  const resetsMs = msOf(ps.quota?.resetsAt);
@@ -140,7 +143,7 @@ export function forecastText(ps, now) {
140
143
  return "no readings yet";
141
144
  let text;
142
145
  if (adv.bindingWindow === "monthly" && adv.bindingRemaining <= 0) {
143
- text = `unused until ${weekdayOf(ps.quota?.monthlyResetsAt)}`;
146
+ text = `unused until ${dayOf(ps.quota?.monthlyResetsAt)}`;
144
147
  }
145
148
  else if (adv.bindingWindow === "monthly") {
146
149
  text = `${Math.round(adv.bindingRemaining)}% of month left`;
@@ -29,10 +29,10 @@ export declare const tools: ({
29
29
  inputSchema: {
30
30
  type: string;
31
31
  properties: {
32
+ task?: undefined;
32
33
  provider: {
33
34
  type: string;
34
35
  };
35
- task?: undefined;
36
36
  };
37
37
  required?: undefined;
38
38
  };
@@ -42,10 +42,10 @@ export declare const tools: ({
42
42
  inputSchema: {
43
43
  type: string;
44
44
  properties: {
45
+ task?: undefined;
45
46
  provider: {
46
47
  type: string;
47
48
  };
48
- task?: undefined;
49
49
  };
50
50
  required: string[];
51
51
  };
@@ -44,6 +44,10 @@ export interface CoordinatorOptions {
44
44
  onOwnershipLost?: () => void;
45
45
  /** Fired after each scheduled tick settles (success or failure). The daemon refreshes its daily update cache here. */
46
46
  onSettled?: () => void;
47
+ /** Data dir for failure bundles. Required with debugFailureBundles. */
48
+ dataDir?: string;
49
+ /** Opt-in forensics: write a redacted evidence bundle per poll failure. */
50
+ debugFailureBundles?: boolean;
47
51
  }
48
52
  export interface Coordinator {
49
53
  refresh(): Promise<RefreshResult>;
@@ -1,7 +1,10 @@
1
1
  // Poll coordinator: scheduled and manual refresh share one in-flight poll
2
2
  // with a completion-measured cooldown (decisions D3/D4).
3
- import { pollAll } from "../adapters/index.js";
3
+ import { pollAll, ADAPTER_TIMEOUTS } from "../adapters/index.js";
4
4
  import { getAllLatest, upsertQuota } from "../store/quotas.js";
5
+ import { readServiceMetadata } from "../config.js";
6
+ import { writeFailureBundle, providerBin, getCliVersion } from "../diagnostics/bundle.js";
7
+ import { VERSION } from "../version.js";
5
8
  import { recordAttempt, getAttempt, } from "../store/attempts.js";
6
9
  import { classifyFailure, } from "../diagnostics/failure.js";
7
10
  export class ServiceClosing extends Error {
@@ -51,6 +54,8 @@ export function createCoordinator(opts) {
51
54
  const ownershipVerify = opts.ownershipVerify;
52
55
  const onOwnershipLost = opts.onOwnershipLost ?? (() => process.exit(1));
53
56
  const onSettled = opts.onSettled;
57
+ const dataDir = opts.dataDir;
58
+ const debugFailureBundles = opts.debugFailureBundles === true;
54
59
  let inFlight = null;
55
60
  let lastResult = null;
56
61
  let lastCompletedPollAt = null;
@@ -171,6 +176,30 @@ export function createCoordinator(opts) {
171
176
  errorDetail: diagnosis.errorDetail,
172
177
  });
173
178
  console.log(`[${completedAt}] [quotacap] [${row.provider}] poll failed (${diagnosis.diagnosticCode}): ${diagnosis.summary}: ${diagnosis.errorDetail}`);
179
+ if (debugFailureBundles && dataDir) {
180
+ try {
181
+ const bin = providerBin(row.provider, readServiceMetadata(dataDir)?.providerPaths);
182
+ const cliVersion = bin ? await getCliVersion(bin) : null;
183
+ const bundleDir = await writeFailureBundle({
184
+ dataDir,
185
+ provider: row.provider,
186
+ attemptedAt,
187
+ reason: row.reason,
188
+ diagnosticCode: diagnosis.diagnosticCode,
189
+ summary: diagnosis.summary,
190
+ errorDetail: diagnosis.errorDetail,
191
+ attemptDurationMs: new Date(completedAt).getTime() - new Date(attemptedAt).getTime(),
192
+ timeoutMs: ADAPTER_TIMEOUTS[row.provider],
193
+ quotacapVersion: VERSION,
194
+ cliBin: bin,
195
+ cliVersion,
196
+ });
197
+ if (bundleDir) {
198
+ console.log(`[${completedAt}] [quotacap] [${row.provider}] failure bundle: ${bundleDir}`);
199
+ }
200
+ }
201
+ catch { }
202
+ }
174
203
  }
175
204
  }
176
205
  lastResult = result;
@@ -11,7 +11,7 @@ import { createCoordinator } from "./poll.js";
11
11
  import { killAll } from "./spawn.js";
12
12
  import { ensureToken } from "./token.js";
13
13
  import { openDb, migrate } from "../store/db.js";
14
- import { autoEnableNewProviders, ensureConfig, getConfigPath, getDbPath, isExperimentalIngestEnabled, readServiceConfig, readServiceMetadata } from "../config.js";
14
+ import { autoEnableNewProviders, ensureConfig, getConfigPath, getDbPath, isExperimentalIngestEnabled, isFailureBundlesEnabled, readServiceConfig, readServiceMetadata } from "../config.js";
15
15
  import { buildApp } from "../http/server.js";
16
16
  import { detectChannel, refreshUpdateCache } from "./updates.js";
17
17
  import { VERSION } from "../version.js";
@@ -183,6 +183,8 @@ export async function startService(opts) {
183
183
  coordinator = createCoordinator({
184
184
  db,
185
185
  enabledProviders: config.enabledProviders,
186
+ dataDir,
187
+ debugFailureBundles: isFailureBundlesEnabled(config),
186
188
  ownershipVerify: () => claim.verify(),
187
189
  // Passive daily update signal: refresh the shared cache on schedule.
188
190
  // Background and best-effort — never blocks or fails a poll.
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.41";
2
+ export const VERSION = "0.0.43";
@@ -1,4 +1,4 @@
1
- import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useCallback, useEffect, useState } from "react";
3
3
  import { createRoot } from "react-dom/client";
4
4
  import "./theme.css";
@@ -1,4 +1,4 @@
1
- import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useEffect, useRef } from "react";
3
3
  import { paceBadge, timeLeft } from "./PaceBar.js";
4
4
  import { ProviderIcon, providerTint } from "./ProviderIcon.js";
@@ -1,6 +1,6 @@
1
1
  import React from "react";
2
2
  import type { ProviderView, RuntimeView } from "../state.js";
3
- export declare const OPENCODE_GO_CONSENT_COPY = "QuotaCap will read the API key OpenCode already stored when you ran `opencode auth login -p opencode-go` (`~/.local/share/opencode/auth.json`, `opencode-go` \u2192 `opencode` fallback). It is read in-memory, read-only once per poll (every 15 minutes by default). It is sent only as `Authorization: Bearer` to `https://opencode.ai/zen/go/v1/usage` to fetch `rolling/weekly/monthly` `percent` + `resetsAt`. It is 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]`). Revoke at `https://opencode.ai/auth` or `opencode providers logout opencode-go` and run `quotacap providers disable opencode-go` to stop polling. If you prefer no file access, set `OPENCODE_API_KEY` instead.";
3
+ export declare const OPENCODE_GO_CONSENT_COPY: string;
4
4
  export declare function showOpenCodeGoBanner(runtime: Pick<RuntimeView, "detectedProviders"> | undefined, providers: Array<Pick<ProviderView, "id" | "enabled">>, suppressed: boolean): boolean;
5
5
  export declare function OpencodeGoBanner({ onEnable }: {
6
6
  onEnable: () => void;
@@ -1,6 +1,13 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useEffect } from "react";
3
- export const OPENCODE_GO_CONSENT_COPY = "QuotaCap will read the API key OpenCode already stored when you ran `opencode auth login -p opencode-go` (`~/.local/share/opencode/auth.json`, `opencode-go` → `opencode` fallback). It is read in-memory, read-only once per poll (every 15 minutes by default). It is sent only as `Authorization: Bearer` to `https://opencode.ai/zen/go/v1/usage` to fetch `rolling/weekly/monthly` `percent` + `resetsAt`. It is 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]`). Revoke at `https://opencode.ai/auth` or `opencode providers logout opencode-go` and run `quotacap providers disable opencode-go` to stop polling. If you prefer no file access, set `OPENCODE_API_KEY` instead.";
3
+ export const OPENCODE_GO_CONSENT_COPY = [
4
+ "• 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).",
5
+ "• Read frequency: in-memory, read-only once per poll (every 15 minutes by default).",
6
+ "• Request destination: sent only as `Authorization: Bearer` to `https://opencode.ai/zen/go/v1/usage` to fetch `rolling/weekly/monthly` `percent` + `resetsAt`.",
7
+ "• 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]`).",
8
+ "• Revocation instructions: revoke at `https://opencode.ai/auth` or `opencode providers logout opencode-go` and run `quotacap providers disable opencode-go` to stop polling.",
9
+ "• Optional environment override: if you prefer no file access, set `OPENCODE_API_KEY` instead.",
10
+ ].join("\n");
4
11
  export function showOpenCodeGoBanner(runtime, providers, suppressed) {
5
12
  const detected = runtime?.detectedProviders?.includes("opencode-go") ?? false;
6
13
  const row = providers.find((p) => p.id === "opencode-go");
@@ -32,5 +39,5 @@ export function ConsentModal({ onConfirm, onCancel, busy, }) {
32
39
  padding: "var(--s4) var(--s5)",
33
40
  maxWidth: 560,
34
41
  margin: "var(--s4)",
35
- }, children: [_jsx("h2", { id: "opencode-go-consent-title", style: { fontSize: "var(--t-4)", margin: "0 0 var(--s3)" }, children: "Enable OpenCode Go?" }), _jsx("p", { style: { fontSize: "var(--t-2)", color: "var(--ink-soft)", margin: "0 0 var(--s4)" }, children: OPENCODE_GO_CONSENT_COPY }), _jsxs("div", { style: { display: "flex", justifyContent: "flex-end", gap: "var(--s2)" }, children: [_jsx("button", { type: "button", className: "btn btn-quiet", onClick: onCancel, disabled: busy, children: "Cancel" }), _jsx("button", { type: "button", className: "btn btn-primary", onClick: onConfirm, disabled: busy, children: busy ? "Enabling…" : "Enable" })] })] }) }));
42
+ }, children: [_jsx("h2", { id: "opencode-go-consent-title", style: { fontSize: "var(--t-4)", margin: "0 0 var(--s3)" }, children: "Enable OpenCode Go?" }), _jsx("p", { style: { fontSize: "var(--t-2)", color: "var(--ink-soft)", margin: "0 0 var(--s4)", whiteSpace: "pre-line" }, children: OPENCODE_GO_CONSENT_COPY }), _jsxs("div", { style: { display: "flex", justifyContent: "flex-end", gap: "var(--s2)" }, children: [_jsx("button", { type: "button", className: "btn btn-quiet", onClick: onCancel, disabled: busy, children: "Cancel" }), _jsx("button", { type: "button", className: "btn btn-primary", onClick: onConfirm, disabled: busy, children: busy ? "Enabling…" : "Enable" })] })] }) }));
36
43
  }
@@ -63,10 +63,15 @@ export declare function exclusionDetail(reason: ExclusionReason): string | null;
63
63
  export declare function isEstimated(provider: ProviderView): boolean;
64
64
  /** Weekday plus 24h clock, e.g. "Thu 21:00". Null when unparseable. */
65
65
  export declare function resetClock(resetsAt: string, locale?: string): string | null;
66
- /** Short weekday in the locked English copy, e.g. "Tue". Mirrors weekdayOf in
67
- * src/format/rows.ts, including its pinned en-US locale, so the CLI and the
68
- * dashboard word the same sentence identically on any machine. */
69
- export declare function resetWeekday(iso: string | undefined): string;
66
+ /** Weekday, calendar date and 24h clock, e.g. "Thu Oct 22 23:05". Monthly
67
+ * resets sit weeks out, where resetClock's weekday-only reading ("Thu 23:05")
68
+ * scans as this coming Thursday. Null when unparseable. */
69
+ export declare function resetDateClock(resetsAt: string, locale?: string): string | null;
70
+ /** Short weekday plus calendar date in the locked English copy, e.g.
71
+ * "Tue Sep 15". Mirrors dayOf in src/format/rows.ts, including its pinned
72
+ * en-US locale, so the CLI and the dashboard word the same sentence
73
+ * identically on any machine. */
74
+ export declare function resetDay(iso: string | undefined): string;
70
75
  /** "6d 12h" style time left from server timestamps. Null when unparseable. */
71
76
  export declare function timeLeft(resetsAt: string, asOfMs: number): string | null;
72
77
  /**
@@ -1,4 +1,4 @@
1
- import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { ageDuration } from "../state.js";
3
3
  /**
4
4
  * Pace-state badge from the mapping table. Exclusion always wins; a blocked
@@ -112,11 +112,30 @@ export function resetClock(resetsAt, locale) {
112
112
  });
113
113
  return `${day} ${time}`;
114
114
  }
115
- /** Short weekday in the locked English copy, e.g. "Tue". Mirrors weekdayOf in
116
- * src/format/rows.ts, including its pinned en-US locale, so the CLI and the
117
- * dashboard word the same sentence identically on any machine. */
118
- export function resetWeekday(iso) {
119
- return new Date(iso ?? "").toLocaleDateString("en-US", { weekday: "short" });
115
+ /** Weekday, calendar date and 24h clock, e.g. "Thu Oct 22 23:05". Monthly
116
+ * resets sit weeks out, where resetClock's weekday-only reading ("Thu 23:05")
117
+ * scans as this coming Thursday. Null when unparseable. */
118
+ export function resetDateClock(resetsAt, locale) {
119
+ const ms = Date.parse(resetsAt);
120
+ if (!Number.isFinite(ms))
121
+ return null;
122
+ const when = new Date(ms);
123
+ const day = when.toLocaleDateString(locale, { weekday: "short" });
124
+ const date = when.toLocaleDateString(locale, { day: "numeric", month: "short" });
125
+ const time = when.toLocaleTimeString(locale, {
126
+ hour: "2-digit",
127
+ minute: "2-digit",
128
+ hour12: false,
129
+ });
130
+ return `${day} ${date} ${time}`;
131
+ }
132
+ /** Short weekday plus calendar date in the locked English copy, e.g.
133
+ * "Tue Sep 15". Mirrors dayOf in src/format/rows.ts, including its pinned
134
+ * en-US locale, so the CLI and the dashboard word the same sentence
135
+ * identically on any machine. */
136
+ export function resetDay(iso) {
137
+ const when = new Date(iso ?? "");
138
+ return `${when.toLocaleDateString("en-US", { weekday: "short" })} ${when.toLocaleDateString("en-US", { day: "numeric", month: "short" })}`;
120
139
  }
121
140
  /** "6d 12h" style time left from server timestamps. Null when unparseable. */
122
141
  export function timeLeft(resetsAt, asOfMs) {
@@ -360,7 +379,7 @@ export function forecastLine(provider) {
360
379
  if (!advisory)
361
380
  return null;
362
381
  if (advisory.bindingWindow === "monthly" && advisory.bindingRemaining <= 0) {
363
- return `unused until ${resetWeekday(provider.quota.monthlyResetsAt)}`;
382
+ return `unused until ${resetDay(provider.quota.monthlyResetsAt)}`;
364
383
  }
365
384
  if (advisory.bindingWindow === "monthly") {
366
385
  return `${Math.round(advisory.bindingRemaining)}% of month left`;