quotacap 0.0.36 → 0.0.38

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 (39) hide show
  1. package/README.md +6 -4
  2. package/dist/adapters/index.js +4 -1
  3. package/dist/adapters/muse.d.ts +28 -0
  4. package/dist/adapters/muse.js +124 -33
  5. package/dist/adapters/opencode-go.d.ts +11 -0
  6. package/dist/adapters/opencode-go.js +122 -0
  7. package/dist/advisory/provider-names.js +9 -0
  8. package/dist/advisory/snapshot.d.ts +1 -0
  9. package/dist/advisory/types.d.ts +3 -0
  10. package/dist/cli/providers.js +121 -1
  11. package/dist/cli/status.js +6 -0
  12. package/dist/config.d.ts +11 -1
  13. package/dist/config.js +52 -3
  14. package/dist/diagnostics/failure.js +16 -3
  15. package/dist/http/server.d.ts +2 -0
  16. package/dist/http/server.js +37 -1
  17. package/dist/runtime/service.js +2 -0
  18. package/dist/version.js +1 -1
  19. package/dist/web/src/App.js +53 -4
  20. package/dist/web/src/api.d.ts +2 -0
  21. package/dist/web/src/api.js +20 -0
  22. package/dist/web/src/components/FaultBanner.d.ts +2 -1
  23. package/dist/web/src/components/FaultBanner.js +8 -2
  24. package/dist/web/src/components/OpencodeGoBanner.d.ts +12 -0
  25. package/dist/web/src/components/OpencodeGoBanner.js +36 -0
  26. package/dist/web/src/components/ProviderDrawer.js +3 -0
  27. package/dist/web/src/components/settings/ProvidersPanel.d.ts +2 -1
  28. package/dist/web/src/components/settings/ProvidersPanel.js +12 -10
  29. package/dist/web/src/components/settings/SettingsDrawer.d.ts +2 -1
  30. package/dist/web/src/components/settings/SettingsDrawer.js +2 -2
  31. package/dist/web/src/pages/Onboarding.js +2 -2
  32. package/dist/web/src/state.d.ts +3 -1
  33. package/dist/web/src/state.js +10 -2
  34. package/dist/webAssets.js +1 -1
  35. package/dist/webHtml.js +1 -1
  36. package/package.json +1 -1
  37. package/web/dist/assets/index-CD8jaN87.js +12 -0
  38. package/web/dist/index.html +1 -1
  39. package/web/dist/assets/index-gDYktkri.js +0 -12
package/dist/config.js CHANGED
@@ -37,6 +37,7 @@ const ConfigSchema = z.object({
37
37
  enabledProviders: z.array(z.string()).default(["claude", "codex", "kimi", "grok", "agy", "muse"]),
38
38
  knownProviders: z.array(z.string()).default(["claude", "codex", "kimi", "grok", "agy", "muse"]),
39
39
  providerNames: z.record(z.string(), ProviderDisplayNameSchema).default({}),
40
+ opencodeGoConsentAt: z.string().nullable().default(null),
40
41
  // Optional, omitted from defaults and `init` output. Manual ingest stays
41
42
  // in-tree but is not a public surface until the product design lands.
42
43
  experimentalIngest: z.boolean().optional(),
@@ -90,7 +91,7 @@ const ServiceConfigSchema = z.object({
90
91
  enabledProviders: z
91
92
  .array(z.string())
92
93
  .superRefine((ids, ctx) => {
93
- const bad = ids.filter((id) => !(id in adapters));
94
+ const bad = ids.filter((id) => !Object.hasOwn(adapters, id));
94
95
  if (bad.length > 0) {
95
96
  ctx.addIssue({
96
97
  code: z.ZodIssueCode.custom,
@@ -103,6 +104,7 @@ const ServiceConfigSchema = z.object({
103
104
  // start. A non-array still fails naming the field.
104
105
  knownProviders: z.array(z.string()).default(["claude", "codex", "kimi", "grok", "agy", "muse"]),
105
106
  providerNames: z.record(z.string(), ProviderDisplayNameSchema).default({}),
107
+ opencodeGoConsentAt: z.string().nullable().default(null),
106
108
  experimentalIngest: z.boolean().optional(),
107
109
  });
108
110
  export function defaultConfig() {
@@ -205,6 +207,49 @@ export async function setProviderNameOverride(id, displayName, p) {
205
207
  await fs.mkdir(path.dirname(file), { recursive: true });
206
208
  await fs.writeFile(file, JSON.stringify(rawObj, null, 2) + "\n");
207
209
  }
210
+ /**
211
+ * Safe mutation of provider enablement in config.json: raw-JSON like
212
+ * setProviderNameOverride (unknown keys preserved). Enabling opencode-go
213
+ * records the consent timestamp; disabling it revokes the consent record.
214
+ */
215
+ export async function setProviderEnabled(id, enabled, p) {
216
+ const file = getConfigPath(p);
217
+ let rawObj = {};
218
+ try {
219
+ const raw = await fs.readFile(file, "utf8");
220
+ const parsed = JSON.parse(raw);
221
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
222
+ throw new Error(`invalid config: ${file} must contain a JSON object`);
223
+ }
224
+ rawObj = parsed;
225
+ }
226
+ catch (err) {
227
+ if (err?.code === "ENOENT") {
228
+ rawObj = {};
229
+ }
230
+ else {
231
+ throw new Error(`cannot update provider enablement: failed to read ${file}: ${err?.message ?? err}`);
232
+ }
233
+ }
234
+ const list = Array.isArray(rawObj.enabledProviders)
235
+ ? [...rawObj.enabledProviders]
236
+ : [...defaultConfig().enabledProviders];
237
+ if (enabled) {
238
+ if (!list.includes(id))
239
+ list.push(id);
240
+ }
241
+ else {
242
+ const at = list.indexOf(id);
243
+ if (at >= 0)
244
+ list.splice(at, 1);
245
+ }
246
+ rawObj.enabledProviders = list;
247
+ if (id === "opencode-go") {
248
+ rawObj.opencodeGoConsentAt = enabled ? new Date().toISOString() : null;
249
+ }
250
+ await fs.mkdir(path.dirname(file), { recursive: true });
251
+ await fs.writeFile(file, JSON.stringify(rawObj, null, 2) + "\n");
252
+ }
208
253
  export async function resetAllProviderNameOverrides(p) {
209
254
  const file = getConfigPath(p);
210
255
  let rawObj = {};
@@ -233,6 +278,9 @@ export async function resetAllProviderNameOverrides(p) {
233
278
  // reads as new. Never extend this list: seeding from the live registry would
234
279
  // mark every adapter known and silently disable auto-enable.
235
280
  export const LEGACY_KNOWN_PROVIDERS = ["claude", "codex", "kimi", "grok", "agy"];
281
+ // Opt-in adapters are never auto-enabled: a binary on PATH is not consent.
282
+ // opencode-go polls a stored credential, so only an explicit enable adds it.
283
+ const OPT_IN_ADAPTERS = new Set(["opencode-go"]);
236
284
  // PATH lookup kept local: importing the sibling in src/service/macos.ts
237
285
  // would create a config<->service import cycle.
238
286
  function defaultWhich(bin) {
@@ -249,7 +297,8 @@ function defaultWhich(bin) {
249
297
  }
250
298
  /**
251
299
  * Auto-enable newly shipped adapters. For each registered adapter (minus
252
- * `manual`, which has no CLI binary) absent from knownProviders, resolve its
300
+ * `manual`, which has no CLI binary, and minus opt-in adapters, which an
301
+ * explicit enable must consent to) absent from knownProviders, resolve its
253
302
  * binary on PATH and append it to both lists when found. An adapter whose
254
303
  * binary is missing stays unknown so it is re-checked on the next start; a
255
304
  * provider already known but disabled is never re-added. Raw-JSON mutation
@@ -285,7 +334,7 @@ export async function autoEnableNewProviders(deps) {
285
334
  const enabled = [...prevEnabled];
286
335
  const known = [...prevKnown];
287
336
  for (const id of Object.keys(adapters)) {
288
- if (id === "manual" || known.includes(id))
337
+ if (id === "manual" || OPT_IN_ADAPTERS.has(id) || known.includes(id))
289
338
  continue;
290
339
  let resolved = null;
291
340
  try {
@@ -22,6 +22,8 @@ export function formatProviderName(provider) {
22
22
  return "Antigravity (3rd-party)";
23
23
  case "muse":
24
24
  return "Muse";
25
+ case "opencode-go":
26
+ return "OpenCode Go";
25
27
  case "all":
26
28
  return "QuotaCap service";
27
29
  default:
@@ -224,6 +226,15 @@ function matchPrecedence(evidence, errorName, errorCode, text) {
224
226
  if (/\bno weekly bucket\b/i.test(text)) {
225
227
  return { code: "parse_error", phrase: "no weekly bucket" };
226
228
  }
229
+ if (/\brolling usage not found\b/i.test(text)) {
230
+ return { code: "parse_error", phrase: "rolling usage not found" };
231
+ }
232
+ if (/\bbad rolling pct\b/i.test(text)) {
233
+ return { code: "parse_error", phrase: "bad rolling pct" };
234
+ }
235
+ if (/\busage status not ok\b/i.test(text)) {
236
+ return { code: "parse_error", phrase: "usage status not ok" };
237
+ }
227
238
  // Order 8b: service_unavailable — subscription or service currently unavailable
228
239
  if (/\b(?:subscription|service|usage)\s+currently\s+unavailable\b/i.test(text) ||
229
240
  /\bsubscriptions aren't currently available\b/i.test(text)) {
@@ -408,7 +419,7 @@ function mapCategory(code) {
408
419
  return "unknown";
409
420
  }
410
421
  }
411
- function getSummaryAndAction(code, providerName) {
422
+ function getSummaryAndAction(code, providerName, provider) {
412
423
  switch (code) {
413
424
  case "terminal_error":
414
425
  return {
@@ -443,7 +454,9 @@ function getSummaryAndAction(code, providerName) {
443
454
  case "service_unavailable":
444
455
  return {
445
456
  summary: `${providerName} usage currently unavailable`,
446
- action: `${providerName} reported that subscription usage is currently unavailable. Send a prompt in ${providerName} to refresh its session limits, or wait for the next scheduled poll.`,
457
+ action: provider === "muse"
458
+ ? `${providerName} reported that subscription usage is currently unavailable. QuotaCap already retried automatically with a warm-up prompt. If this persists, send a prompt in ${providerName}, or wait for the next scheduled poll.`
459
+ : `${providerName} reported that subscription usage is currently unavailable. Send a prompt in ${providerName} to refresh its session limits, or wait for the next scheduled poll.`,
447
460
  };
448
461
  case "timeout":
449
462
  return {
@@ -473,7 +486,7 @@ export function classifyFailure(provider, reason) {
473
486
  }
474
487
  const category = mapCategory(diag.diagnosticCode);
475
488
  const pName = formatProviderName(provider);
476
- const { summary, action } = getSummaryAndAction(diag.diagnosticCode, pName);
489
+ const { summary, action } = getSummaryAndAction(diag.diagnosticCode, pName, provider);
477
490
  return {
478
491
  diagnosticCode: diag.diagnosticCode,
479
492
  errorDetail: diag.errorDetail,
@@ -10,6 +10,8 @@ export interface RuntimeContext {
10
10
  coordinator: Coordinator;
11
11
  enabledProviders: string[];
12
12
  providerNames?: Record<string, string>;
13
+ /** Detected-but-not-enabled opt-in providers, computed once at start. */
14
+ detectedProviders?: string[];
13
15
  version: string;
14
16
  exec: string;
15
17
  now?: () => Date;
@@ -10,12 +10,13 @@ import { validateTask } from "../advisory/validate.js";
10
10
  import { readUpdateCache } from "../runtime/updates.js";
11
11
  import { compareVersions } from "../runtime/versions.js";
12
12
  import { parseManualUsage } from "../adapters/manual.js";
13
+ import { adapters } from "../adapters/index.js";
13
14
  import { upsertQuota } from "../store/quotas.js";
14
15
  import { createCoordinator, ServiceClosing, } from "../runtime/poll.js";
15
16
  import { classifyFailure } from "../diagnostics/failure.js";
16
17
  import { validateDisplayName } from "../advisory/validation.js";
17
18
  import { providerIdentity } from "../advisory/provider-names.js";
18
- import { setProviderNameOverride } from "../config.js";
19
+ import { setProviderNameOverride, setProviderEnabled } from "../config.js";
19
20
  import { ensureCatalogs, catalogProviders, CATALOG_FAIL_COOLDOWN_MS } from "../catalog/index.js";
20
21
  import { getAllLatest } from "../store/quotas.js";
21
22
  const LOOPBACK_HOSTS = new Set(["127.0.0.1", "localhost", "[::1]", "::1"]);
@@ -91,6 +92,7 @@ export function testCtx(db, overrides) {
91
92
  coordinator: createCoordinator({ db, enabledProviders: [] }),
92
93
  enabledProviders: [],
93
94
  providerNames: {},
95
+ detectedProviders: [],
94
96
  version: "test",
95
97
  exec: "test",
96
98
  ingestEnabled: false,
@@ -119,6 +121,7 @@ function snapshotOf(ctx) {
119
121
  upToDate: !cache || compareVersions(cache.latest, ctx.version) <= 0,
120
122
  checkedAt: cache?.checkedAt ?? null,
121
123
  },
124
+ detectedProviders: ctx.detectedProviders ?? [],
122
125
  },
123
126
  });
124
127
  }
@@ -363,6 +366,39 @@ export function buildApp(ctx) {
363
366
  override: validatedName,
364
367
  };
365
368
  });
369
+ // Enable/disable a provider. Token-gated like refresh. Consent is recorded
370
+ // protocol-level for the one provider whose key QuotaCap reads. The
371
+ // coordinator's enabled set is a start-time snapshot, so the response
372
+ // tells every caller a restart applies it — no live mutation.
373
+ app.post("/api/providers/:id/enabled", async (req, reply) => {
374
+ const headerToken = req.headers["x-quotacap-token"];
375
+ if (!isValidToken(headerToken, ctx.token)) {
376
+ return reply.status(401).send({ error: "unauthorized: missing or invalid X-QuotaCap-Token header" });
377
+ }
378
+ const { id } = req.params;
379
+ if (typeof id !== "string" || !id || !Object.hasOwn(adapters, id) || id === "manual") {
380
+ return reply.status(400).send({ error: `unknown provider id: ${id}` });
381
+ }
382
+ const body = (req.body ?? {});
383
+ if (typeof body !== "object" || body === null || typeof body.enabled !== "boolean") {
384
+ return reply.status(400).send({ error: "invalid-argument: body must contain enabled: boolean" });
385
+ }
386
+ const enabling = body.enabled === true;
387
+ if (id === "opencode-go" && enabling && body.consent !== true) {
388
+ return reply.status(400).send({ error: "consent required: enabling opencode-go requires consent: true" });
389
+ }
390
+ if (ctx.configPath) {
391
+ try {
392
+ await setProviderEnabled(id, enabling, ctx.configPath);
393
+ }
394
+ catch (err) {
395
+ return reply.status(500).send({
396
+ error: `failed to persist configuration: ${err?.message ?? String(err)}`,
397
+ });
398
+ }
399
+ }
400
+ return { ok: true, id, enabled: enabling, restartRequired: true };
401
+ });
366
402
  // Graceful-stop request for CLI-driven takeovers. The endpoint restarts
367
403
  // nothing by itself: it responds 202, then the service follows its normal
368
404
  // SIGTERM path while the CLI spawns the successor after the port releases.
@@ -16,6 +16,7 @@ import { buildApp } from "../http/server.js";
16
16
  import { detectChannel, refreshUpdateCache } from "./updates.js";
17
17
  import { VERSION } from "../version.js";
18
18
  import { claudeAdapter } from "../adapters/claude.js";
19
+ import { opencodeGoDetected } from "../adapters/opencode-go.js";
19
20
  import { classifyFailure } from "../diagnostics/failure.js";
20
21
  import { ensureCatalogs, catalogProviders } from "../catalog/index.js";
21
22
  export function resolveClaudeExecPath() {
@@ -198,6 +199,7 @@ export async function startService(opts) {
198
199
  token,
199
200
  coordinator,
200
201
  enabledProviders: config.enabledProviders,
202
+ detectedProviders: opencodeGoDetected() ? ["opencode-go"] : [],
201
203
  providerNames: config.providerNames,
202
204
  catalogTtlHours: config.catalogTtlHours,
203
205
  version: VERSION,
package/dist/version.js CHANGED
@@ -1,2 +1,2 @@
1
1
  // generated by scripts/build-embed.mjs — do not edit
2
- export const VERSION = "0.0.36";
2
+ export const VERSION = "0.0.38";
@@ -2,7 +2,7 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
2
2
  import { useCallback, useEffect, useState } from "react";
3
3
  import { createRoot } from "react-dom/client";
4
4
  import "./theme.css";
5
- import { daemonEndpoint, loadState, triggerRefresh, StateHttpError, StateNetworkError, } from "./api.js";
5
+ import { daemonEndpoint, loadState, triggerRefresh, setProviderEnabled, StateHttpError, StateNetworkError, } from "./api.js";
6
6
  import { ageLabel, firstRun, toViewModel } from "./state.js";
7
7
  import { Recommendation } from "./components/Recommendation.js";
8
8
  import { SubscriptionList } from "./components/SubscriptionList.js";
@@ -14,6 +14,7 @@ import { navigate, routeFor, useRoute } from "./router.js";
14
14
  import { Onboarding } from "./pages/Onboarding.js";
15
15
  import { Header } from "./components/Header.js";
16
16
  import { FaultBanner } from "./components/FaultBanner.js";
17
+ import { ConsentModal, OpencodeGoBanner, showOpenCodeGoBanner } from "./components/OpencodeGoBanner.js";
17
18
  import { SiteFooter } from "./components/PacingLegend.js";
18
19
  function UnavailablePanel({ endpoint, lastGood, onRetry, }) {
19
20
  return (_jsxs("div", { "data-testid": "state-unavailable", role: "alert", children: [_jsx("h2", { children: "Service unavailable" }), _jsxs("p", { children: ["The QuotaCap daemon did not answer at ", _jsx("code", { children: endpoint }), ". Start it with", " ", _jsx("code", { children: "quotacap daemon" }), ", then retry."] }), _jsx("button", { type: "button", onClick: onRetry, children: "Retry" }), lastGood && _jsxs("p", { children: ["Showing last readings (", ageLabel(lastGood.asOf), ")."] })] }));
@@ -34,6 +35,9 @@ function App() {
34
35
  const [selectedProvider, setSelectedProvider] = useState(null);
35
36
  const [adviceOpen, setAdviceOpen] = useState(false);
36
37
  const [settingsOpen, setSettingsOpen] = useState(false);
38
+ const [consentOpen, setConsentOpen] = useState(false);
39
+ const [consentBusy, setConsentBusy] = useState(false);
40
+ const [goSuppressed, setGoSuppressed] = useState(false);
37
41
  const route = useRoute();
38
42
  const target = snapshot ? routeFor(route, firstRun(snapshot)) : null;
39
43
  useEffect(() => {
@@ -98,6 +102,23 @@ function App() {
98
102
  setRefreshing(false);
99
103
  }
100
104
  }, [load]);
105
+ const handleSetProviderEnabled = (id, enabled) => {
106
+ if (id === "opencode-go" && enabled) {
107
+ setConsentOpen(true);
108
+ return;
109
+ }
110
+ void (async () => {
111
+ try {
112
+ await setProviderEnabled(id, enabled);
113
+ if (!enabled)
114
+ setGoSuppressed(false);
115
+ setNotice(`${id === "opencode-go" ? "OpenCode Go" : id} ${enabled ? "enabled" : "disabled"} — restart the daemon to apply it.`);
116
+ }
117
+ catch (e) {
118
+ setNotice(`Could not update ${id}: ${e instanceof Error ? e.message : String(e)}`);
119
+ }
120
+ })();
121
+ };
101
122
  const setupMode = target === "/setup";
102
123
  const pollErrorText = error
103
124
  ? error.kind === "http-error"
@@ -106,7 +127,21 @@ function App() {
106
127
  : null;
107
128
  const header = (_jsx(Header, { runtime: snapshot?.runtime ?? null, unreachable: error?.kind === "service-unavailable", refreshing: refreshing, onRefresh: () => void refresh(), onSettings: () => setSettingsOpen(true) }));
108
129
  if (snapshot && setupMode) {
109
- return (_jsxs(_Fragment, { children: [header, _jsx("main", { children: _jsx(Onboarding, { snapshot: snapshot, onFirstPoll: () => void refresh(), polling: refreshing, pollError: pollErrorText }) }), _jsx(SettingsDrawer, { open: settingsOpen, providers: snapshot.providers, onRefresh: () => void refresh(), refreshing: refreshing, onClose: () => setSettingsOpen(false) }), _jsx(SiteFooter, { version: snapshot.runtime.version })] }));
130
+ return (_jsxs(_Fragment, { children: [header, _jsxs("main", { children: [notice && (_jsx("div", { "data-testid": "refresh-notice", role: "status", children: notice })), showOpenCodeGoBanner(snapshot.runtime, snapshot.providers, goSuppressed) && (_jsx(OpencodeGoBanner, { onEnable: () => setConsentOpen(true) })), _jsx(Onboarding, { snapshot: snapshot, onFirstPoll: () => void refresh(), polling: refreshing, pollError: pollErrorText })] }), _jsx(SettingsDrawer, { open: settingsOpen, providers: snapshot.providers, onRefresh: () => void refresh(), refreshing: refreshing, onSetProviderEnabled: handleSetProviderEnabled, onClose: () => setSettingsOpen(false) }), consentOpen && (_jsx(ConsentModal, { busy: consentBusy, onCancel: () => setConsentOpen(false), onConfirm: () => void (async () => {
131
+ setConsentBusy(true);
132
+ try {
133
+ await setProviderEnabled("opencode-go", true, true);
134
+ setGoSuppressed(true);
135
+ setConsentOpen(false);
136
+ setNotice("OpenCode Go enabled — restart the daemon to apply it.");
137
+ }
138
+ catch (e) {
139
+ setNotice(`Could not enable OpenCode Go: ${e instanceof Error ? e.message : String(e)}`);
140
+ }
141
+ finally {
142
+ setConsentBusy(false);
143
+ }
144
+ })() })), _jsx(SiteFooter, { version: snapshot.runtime.version })] }));
110
145
  }
111
146
  return (_jsxs(_Fragment, { children: [header, _jsxs("main", { children: [notice && (_jsx("div", { "data-testid": "refresh-notice", role: "status", children: notice })), loading && !snapshot && (_jsx("div", { "data-testid": "state-loading", "aria-busy": "true", "aria-label": "Loading quota state", children: [0, 1, 2].map((i) => (_jsx("div", { "aria-hidden": "true", className: "skel", style: {
112
147
  height: 64,
@@ -114,13 +149,27 @@ function App() {
114
149
  border: i === 0 ? "1px solid var(--rec)" : "1px solid var(--line)",
115
150
  borderRadius: 8,
116
151
  marginBottom: 8,
117
- } }, i))) })), error?.kind === "service-unavailable" && !snapshot && (_jsx(UnavailablePanel, { endpoint: daemonEndpoint(), lastGood: snapshot, onRetry: () => void load() })), error?.kind === "http-error" && !snapshot && (_jsx(HttpErrorPanel, { status: error.status, message: error.message, lastGood: snapshot, onRetry: () => void load() })), error && snapshot && (_jsxs("div", { "data-testid": "state-stale-error", role: "alert", children: [error.kind === "http-error" ? `Request failed (HTTP ${error.status}): ` : "Service unavailable: ", error.message, " Showing last readings (", ageLabel(snapshot.asOf), ").", " ", _jsx("button", { type: "button", onClick: () => void load(), children: "Retry" })] })), snapshot && (_jsx(FaultBanner, { providers: snapshot.providers, onRepoll: () => void refresh(), repolling: refreshing, polling: snapshot.runtime.polling })), snapshot && (_jsx(ReadyView, { snapshot: snapshot, onSelectProvider: (id) => {
152
+ } }, i))) })), error?.kind === "service-unavailable" && !snapshot && (_jsx(UnavailablePanel, { endpoint: daemonEndpoint(), lastGood: snapshot, onRetry: () => void load() })), error?.kind === "http-error" && !snapshot && (_jsx(HttpErrorPanel, { status: error.status, message: error.message, lastGood: snapshot, onRetry: () => void load() })), error && snapshot && (_jsxs("div", { "data-testid": "state-stale-error", role: "alert", children: [error.kind === "http-error" ? `Request failed (HTTP ${error.status}): ` : "Service unavailable: ", error.message, " Showing last readings (", ageLabel(snapshot.asOf), ").", " ", _jsx("button", { type: "button", onClick: () => void load(), children: "Retry" })] })), snapshot && (_jsx(FaultBanner, { providers: snapshot.providers, onRepoll: () => void refresh(), repolling: refreshing, polling: snapshot.runtime.polling })), snapshot && showOpenCodeGoBanner(snapshot.runtime, snapshot.providers, goSuppressed) && (_jsx(OpencodeGoBanner, { onEnable: () => setConsentOpen(true) })), snapshot && (_jsx(ReadyView, { snapshot: snapshot, onSelectProvider: (id) => {
118
153
  setAdviceOpen(false);
119
154
  setSelectedProvider(id);
120
155
  }, onViewAdvice: () => {
121
156
  setSelectedProvider(null);
122
157
  setAdviceOpen(true);
123
- } }))] }), _jsx(ProviderDrawer, { provider: snapshot?.providers.find((p) => p.id === selectedProvider) ?? null, asOf: snapshot?.asOf ?? "", recommendation: snapshot?.recommendation ?? null, onClose: () => setSelectedProvider(null), onRenamed: () => void load() }), snapshot && (_jsx(AdviceDrawer, { open: adviceOpen, recommendation: snapshot.recommendation, providers: snapshot.providers, asOf: snapshot.asOf, onClose: () => setAdviceOpen(false) })), _jsx(SettingsDrawer, { open: settingsOpen, providers: snapshot?.providers ?? [], onRefresh: () => void refresh(), refreshing: refreshing, onClose: () => setSettingsOpen(false) }), _jsx(SiteFooter, { version: snapshot?.runtime.version })] }));
158
+ } }))] }), _jsx(ProviderDrawer, { provider: snapshot?.providers.find((p) => p.id === selectedProvider) ?? null, asOf: snapshot?.asOf ?? "", recommendation: snapshot?.recommendation ?? null, onClose: () => setSelectedProvider(null), onRenamed: () => void load() }), consentOpen && (_jsx(ConsentModal, { busy: consentBusy, onCancel: () => setConsentOpen(false), onConfirm: () => void (async () => {
159
+ setConsentBusy(true);
160
+ try {
161
+ await setProviderEnabled("opencode-go", true, true);
162
+ setGoSuppressed(true);
163
+ setConsentOpen(false);
164
+ setNotice("OpenCode Go enabled — restart the daemon to apply it.");
165
+ }
166
+ catch (e) {
167
+ setNotice(`Could not enable OpenCode Go: ${e instanceof Error ? e.message : String(e)}`);
168
+ }
169
+ finally {
170
+ setConsentBusy(false);
171
+ }
172
+ })() })), snapshot && (_jsx(AdviceDrawer, { open: adviceOpen, recommendation: snapshot.recommendation, providers: snapshot.providers, asOf: snapshot.asOf, onClose: () => setAdviceOpen(false) })), _jsx(SettingsDrawer, { open: settingsOpen, providers: snapshot?.providers ?? [], onRefresh: () => void refresh(), refreshing: refreshing, onSetProviderEnabled: handleSetProviderEnabled, onClose: () => setSettingsOpen(false) }), _jsx(SiteFooter, { version: snapshot?.runtime.version })] }));
124
173
  }
125
174
  const el = typeof document === "undefined" ? null : document.getElementById("app");
126
175
  if (el)
@@ -27,3 +27,5 @@ export declare function refreshMessage(body: unknown): string | null;
27
27
  export declare function triggerRefresh(): Promise<RefreshResult>;
28
28
  /** Rename provider or restore built-in default with null. */
29
29
  export declare function renameProvider(id: string, displayName: string | null): Promise<void>;
30
+ /** Enable or disable a provider. Consent is required by the server for opencode-go. */
31
+ export declare function setProviderEnabled(id: string, enabled: boolean, consent?: boolean): Promise<void>;
@@ -122,3 +122,23 @@ export async function renameProvider(id, displayName) {
122
122
  if (!r.ok)
123
123
  throw new StateHttpError(r.status, await serverMessage(r));
124
124
  }
125
+ /** Enable or disable a provider. Consent is required by the server for opencode-go. */
126
+ export async function setProviderEnabled(id, enabled, consent) {
127
+ const token = await fetchToken();
128
+ let r;
129
+ try {
130
+ r = await fetch(`/api/providers/${encodeURIComponent(id)}/enabled`, {
131
+ method: "POST",
132
+ headers: {
133
+ "Content-Type": "application/json",
134
+ "X-QuotaCap-Token": token,
135
+ },
136
+ body: JSON.stringify(consent === undefined ? { enabled } : { enabled, consent }),
137
+ });
138
+ }
139
+ catch (e) {
140
+ throw new StateNetworkError(`/api/providers/${id}/enabled`, e);
141
+ }
142
+ if (!r.ok)
143
+ throw new StateHttpError(r.status, await serverMessage(r));
144
+ }
@@ -5,7 +5,8 @@ export interface Repair {
5
5
  }
6
6
  /**
7
7
  * Repair routing per excluded provider. Auth failures point at signing into
8
- * the provider CLI (QuotaCap never handles those credentials); every other
8
+ * the provider CLI (by default QuotaCap never reads credentials; the only
9
+ * exception is OpenCode Go, which the user enables explicitly); every other
9
10
  * exclusion points at a re-poll. Service start/install is covered by the
10
11
  * service-unavailable panel when the daemon itself is down.
11
12
  */
@@ -3,15 +3,21 @@ import { ageDuration } from "../state.js";
3
3
  import { failureWords } from "./ProviderDrawer.js";
4
4
  /**
5
5
  * Repair routing per excluded provider. Auth failures point at signing into
6
- * the provider CLI (QuotaCap never handles those credentials); every other
6
+ * the provider CLI (by default QuotaCap never reads credentials; the only
7
+ * exception is OpenCode Go, which the user enables explicitly); every other
7
8
  * exclusion points at a re-poll. Service start/install is covered by the
8
9
  * service-unavailable panel when the daemon itself is down.
9
10
  */
10
11
  export function repairFor(provider) {
11
12
  const name = provider.displayName;
12
13
  if (provider.lastAttempt?.failureCategory === "auth") {
14
+ if (provider.id === "opencode-go") {
15
+ return {
16
+ text: "Run `opencode auth login -p opencode-go`, then re-poll. QuotaCap reads that key only because you enabled OpenCode Go — `quotacap providers disable opencode-go` stops it.",
17
+ };
18
+ }
13
19
  return {
14
- text: `Sign in to the ${name} CLI, then re-poll. QuotaCap never handles those credentials.`,
20
+ text: `Sign in to the ${name} CLI, then re-poll. By default QuotaCap never reads credentials; the only exception is OpenCode Go, which you enable explicitly.`,
15
21
  };
16
22
  }
17
23
  switch (provider.exclusionReason) {
@@ -0,0 +1,12 @@
1
+ import React from "react";
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.";
4
+ export declare function showOpenCodeGoBanner(runtime: Pick<RuntimeView, "detectedProviders"> | undefined, providers: Array<Pick<ProviderView, "id" | "enabled">>, suppressed: boolean): boolean;
5
+ export declare function OpencodeGoBanner({ onEnable }: {
6
+ onEnable: () => void;
7
+ }): React.JSX.Element;
8
+ export declare function ConsentModal({ onConfirm, onCancel, busy, }: {
9
+ onConfirm: () => void;
10
+ onCancel: () => void;
11
+ busy?: boolean;
12
+ }): React.JSX.Element;
@@ -0,0 +1,36 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
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.";
4
+ export function showOpenCodeGoBanner(runtime, providers, suppressed) {
5
+ const detected = runtime?.detectedProviders?.includes("opencode-go") ?? false;
6
+ const row = providers.find((p) => p.id === "opencode-go");
7
+ return detected && !row?.enabled && !suppressed;
8
+ }
9
+ export function OpencodeGoBanner({ onEnable }) {
10
+ return (_jsxs("div", { "data-testid": "opencode-go-banner", role: "status", className: "alert", children: [_jsxs("span", { children: [_jsx("strong", { children: "OpenCode Go detected." }), " Enable to track 5h/weekly usage? The API key stays with OpenCode until you enable tracking."] }), _jsx("button", { type: "button", className: "linkbtn", onClick: onEnable, style: { flex: "none", marginLeft: "auto" }, children: "Enable OpenCode Go" })] }));
11
+ }
12
+ export function ConsentModal({ onConfirm, onCancel, busy, }) {
13
+ useEffect(() => {
14
+ const onKey = (e) => {
15
+ if (e.key === "Escape")
16
+ onCancel();
17
+ };
18
+ window.addEventListener("keydown", onKey);
19
+ return () => window.removeEventListener("keydown", onKey);
20
+ }, [onCancel]);
21
+ return (_jsx("div", { role: "presentation", style: {
22
+ position: "fixed",
23
+ inset: 0,
24
+ display: "grid",
25
+ placeItems: "center",
26
+ background: "var(--scrim, rgba(0,0,0,0.5))",
27
+ zIndex: 60,
28
+ }, children: _jsxs("div", { role: "dialog", "aria-modal": "true", "aria-labelledby": "opencode-go-consent-title", style: {
29
+ background: "var(--surface)",
30
+ border: "1px solid var(--line)",
31
+ borderRadius: "var(--r-md)",
32
+ padding: "var(--s4) var(--s5)",
33
+ maxWidth: 560,
34
+ 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" })] })] }) }));
36
+ }
@@ -109,6 +109,9 @@ export function paceBasis(provider, asOfMs) {
109
109
  }
110
110
  export function credentialLabel(provider) {
111
111
  const source = provider.quota?.source;
112
+ if (provider.id === "opencode-go" && source === "api") {
113
+ return "read in-memory from OpenCode auth (opt-in), never stored";
114
+ }
112
115
  if (source === "cli" || source === "tui")
113
116
  return `handled by ${provider.displayName}`;
114
117
  return "not stored by QuotaCap";
@@ -7,8 +7,9 @@ export declare function connectionBadge(provider: ProviderView): string;
7
7
  export type SettingsStatus = "Ready" | "Stale" | "Failed" | "Invalid" | "Reset passed" | "Not reporting";
8
8
  /** Short status from server exclusion and attempt fields. */
9
9
  export declare function providerStatus(provider: ProviderView): SettingsStatus;
10
- export declare function ProvidersPanel({ providers, onRefresh, refreshing, }: {
10
+ export declare function ProvidersPanel({ providers, onRefresh, refreshing, onSetProviderEnabled, }: {
11
11
  providers: ProviderView[];
12
12
  onRefresh: () => void;
13
13
  refreshing: boolean;
14
+ onSetProviderEnabled?: (id: string, enabled: boolean) => void;
14
15
  }): React.JSX.Element;
@@ -12,6 +12,8 @@ const CONNECTION_NAMES = new Map([
12
12
  ["grok", "Grok"],
13
13
  ["agy", "Antigravity"],
14
14
  ["agy:3p", "Antigravity 3P"],
15
+ ["muse", "Muse Code"],
16
+ ["opencode-go", "OpenCode Go"],
15
17
  ]);
16
18
  export function connectionName(id) {
17
19
  return CONNECTION_NAMES.get(id) ?? id;
@@ -38,7 +40,7 @@ export function providerStatus(provider) {
38
40
  return provider.lastAttempt && !provider.lastAttempt.success ? "Failed" : "Ready";
39
41
  }
40
42
  }
41
- export function ProvidersPanel({ providers, onRefresh, refreshing, }) {
43
+ export function ProvidersPanel({ providers, onRefresh, refreshing, onSetProviderEnabled, }) {
42
44
  const listed = adapterProviders(providers);
43
45
  return (_jsxs("div", { children: [_jsxs("div", { style: { display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: "var(--s3)" }, children: [_jsx("h3", { style: { fontSize: "var(--t-3)", fontWeight: 700, margin: 0 }, children: "Active Tool Connections" }), _jsx("button", { type: "button", className: "btn btn-quiet", onClick: onRefresh, disabled: refreshing, style: { fontSize: "var(--t-1)", padding: "4px 8px" }, children: refreshing ? "Polling…" : "Re-poll adapters" })] }), _jsx("div", { style: {
44
46
  border: "1px solid var(--line)",
@@ -55,14 +57,14 @@ export function ProvidersPanel({ providers, onRefresh, refreshing, }) {
55
57
  display: "flex",
56
58
  justifyContent: "space-between",
57
59
  alignItems: "center",
58
- }, children: [_jsxs("div", { children: [_jsx("b", { style: { fontSize: "var(--t-3)" }, children: connectionName(p.id) }), _jsx("span", { style: { display: "block", fontSize: "var(--t-1)", color: "var(--ink-soft)" }, children: sourceLabel(p.quota?.source) })] }), _jsx("span", { className: isReady ? "st-ready" : "st-todo", style: {
59
- fontSize: "var(--t-1)",
60
- padding: "2px 8px",
61
- borderRadius: 999,
62
- background: isReady ? "var(--good-soft)" : "var(--warn-soft)",
63
- border: `1px solid ${isReady ? "var(--good)" : "var(--warn)"}`,
64
- color: isReady ? "var(--good)" : "var(--warn)",
65
- fontWeight: 600,
66
- }, children: connectionBadge(p) })] }, p.id));
60
+ }, children: [_jsxs("div", { children: [_jsx("b", { style: { fontSize: "var(--t-3)" }, children: connectionName(p.id) }), _jsx("span", { style: { display: "block", fontSize: "var(--t-1)", color: "var(--ink-soft)" }, children: sourceLabel(p.quota?.source) })] }), _jsxs("div", { style: { display: "flex", alignItems: "center", flex: "none" }, children: [_jsx("span", { className: isReady ? "st-ready" : "st-todo", style: {
61
+ fontSize: "var(--t-1)",
62
+ padding: "2px 8px",
63
+ borderRadius: 999,
64
+ background: isReady ? "var(--good-soft)" : "var(--warn-soft)",
65
+ border: `1px solid ${isReady ? "var(--good)" : "var(--warn)"}`,
66
+ color: isReady ? "var(--good)" : "var(--warn)",
67
+ fontWeight: 600,
68
+ }, children: connectionBadge(p) }), onSetProviderEnabled && p.id !== "agy:3p" && (_jsx("button", { type: "button", className: "btn btn-quiet", "aria-label": `${p.enabled ? "Disable" : "Enable"} ${connectionName(p.id)}`, onClick: () => onSetProviderEnabled(p.id, !p.enabled), disabled: refreshing, style: { fontSize: "var(--t-1)", padding: "4px 8px", flex: "none", marginLeft: "8px" }, children: p.enabled ? "Disable" : "Enable" }))] })] }, p.id));
67
69
  }) })] }));
68
70
  }
@@ -1,9 +1,10 @@
1
1
  import React from "react";
2
2
  import type { ProviderView } from "../../state.js";
3
- export declare function SettingsDrawer({ open, providers, onRefresh, refreshing, onClose, }: {
3
+ export declare function SettingsDrawer({ open, providers, onRefresh, refreshing, onSetProviderEnabled, onClose, }: {
4
4
  open: boolean;
5
5
  providers: ProviderView[];
6
6
  onRefresh: () => void;
7
7
  refreshing: boolean;
8
+ onSetProviderEnabled?: (id: string, enabled: boolean) => void;
8
9
  onClose: () => void;
9
10
  }): React.JSX.Element | null;
@@ -8,7 +8,7 @@ const TABS = [
8
8
  { id: "cli", label: "CLI & Shell" },
9
9
  { id: "daemon", label: "Daemon" },
10
10
  ];
11
- export function SettingsDrawer({ open, providers, onRefresh, refreshing, onClose, }) {
11
+ export function SettingsDrawer({ open, providers, onRefresh, refreshing, onSetProviderEnabled, onClose, }) {
12
12
  const [tab, setTab] = useState("providers");
13
13
  const dialogRef = useRef(null);
14
14
  const previouslyFocused = useRef(null);
@@ -51,5 +51,5 @@ export function SettingsDrawer({ open, providers, onRefresh, refreshing, onClose
51
51
  if (!open)
52
52
  return null;
53
53
  const titleId = "settings-drawer-title";
54
- return (_jsxs(_Fragment, { children: [_jsx("div", { "data-testid": "settings-scrim", className: "scrim is-open", "aria-hidden": "true", onClick: onClose }), _jsxs("aside", { ref: dialogRef, tabIndex: -1, "data-testid": "settings-drawer", role: "dialog", "aria-modal": "true", "aria-labelledby": titleId, className: "drawer is-open", style: { width: "min(580px, 100vw)" }, onClick: (e) => e.stopPropagation(), children: [_jsxs("div", { className: "drawer-head", children: [_jsxs("div", { style: { flex: 1 }, children: [_jsx("h2", { id: titleId, style: { margin: "0 0 2px" }, children: "Settings & Configuration" }), _jsx("span", { className: "sub", children: "Connected provider adapters and CLI prompt integration." })] }), _jsx("button", { className: "drawer-close", type: "button", "aria-label": "Close settings", onClick: onClose, children: _jsx("svg", { width: "16", height: "16", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2.4", strokeLinecap: "round", "aria-hidden": "true", children: _jsx("path", { d: "M6 6l12 12M18 6L6 18" }) }) })] }), _jsx("div", { className: "seg", role: "tablist", "aria-label": "Settings sections", style: { margin: "var(--s4) var(--s5) 0", display: "flex", height: 32 }, children: TABS.map((t) => (_jsx("button", { type: "button", role: "tab", className: `settings-tab${tab === t.id ? " is-active" : ""}`, "aria-selected": tab === t.id, onClick: () => setTab(t.id), style: { flex: 1, justifyContent: "center" }, children: t.label }, t.id))) }), _jsxs("div", { role: "tabpanel", style: { padding: "var(--s4) var(--s5) var(--s6)", overflowY: "auto" }, children: [tab === "providers" && (_jsx(ProvidersPanel, { providers: providers, onRefresh: onRefresh, refreshing: refreshing })), tab === "cli" && _jsx(CliIntegrationPanel, {}), tab === "daemon" && _jsx(DaemonPanel, {})] })] })] }));
54
+ return (_jsxs(_Fragment, { children: [_jsx("div", { "data-testid": "settings-scrim", className: "scrim is-open", "aria-hidden": "true", onClick: onClose }), _jsxs("aside", { ref: dialogRef, tabIndex: -1, "data-testid": "settings-drawer", role: "dialog", "aria-modal": "true", "aria-labelledby": titleId, className: "drawer is-open", style: { width: "min(580px, 100vw)" }, onClick: (e) => e.stopPropagation(), children: [_jsxs("div", { className: "drawer-head", children: [_jsxs("div", { style: { flex: 1 }, children: [_jsx("h2", { id: titleId, style: { margin: "0 0 2px" }, children: "Settings & Configuration" }), _jsx("span", { className: "sub", children: "Connected provider adapters and CLI prompt integration." })] }), _jsx("button", { className: "drawer-close", type: "button", "aria-label": "Close settings", onClick: onClose, children: _jsx("svg", { width: "16", height: "16", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2.4", strokeLinecap: "round", "aria-hidden": "true", children: _jsx("path", { d: "M6 6l12 12M18 6L6 18" }) }) })] }), _jsx("div", { className: "seg", role: "tablist", "aria-label": "Settings sections", style: { margin: "var(--s4) var(--s5) 0", display: "flex", height: 32 }, children: TABS.map((t) => (_jsx("button", { type: "button", role: "tab", className: `settings-tab${tab === t.id ? " is-active" : ""}`, "aria-selected": tab === t.id, onClick: () => setTab(t.id), style: { flex: 1, justifyContent: "center" }, children: t.label }, t.id))) }), _jsxs("div", { role: "tabpanel", style: { padding: "var(--s4) var(--s5) var(--s6)", overflowY: "auto" }, children: [tab === "providers" && (_jsx(ProvidersPanel, { providers: providers, onRefresh: onRefresh, refreshing: refreshing, onSetProviderEnabled: onSetProviderEnabled })), tab === "cli" && _jsx(CliIntegrationPanel, {}), tab === "daemon" && _jsx(DaemonPanel, {})] })] })] }));
55
55
  }