quotacap 0.0.40 → 0.0.42

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 (60) hide show
  1. package/dist/adapters/claude.js +1 -1
  2. package/dist/adapters/codex.js +27 -18
  3. package/dist/adapters/index.d.ts +2 -2
  4. package/dist/adapters/kimi.d.ts +41 -0
  5. package/dist/adapters/kimi.js +451 -19
  6. package/dist/adapters/muse.js +1 -1
  7. package/dist/adapters/opencode-go.d.ts +1 -1
  8. package/dist/adapters/opencode-go.js +8 -1
  9. package/dist/adapters/pty.d.ts +9 -0
  10. package/dist/adapters/pty.js +16 -0
  11. package/dist/cli/advise.js +1 -0
  12. package/dist/cli/clients.d.ts +4 -0
  13. package/dist/cli/models.js +4 -0
  14. package/dist/cli/progress.d.ts +32 -0
  15. package/dist/cli/progress.js +70 -0
  16. package/dist/cli/providers.js +2 -1
  17. package/dist/cli/runtime.d.ts +5 -0
  18. package/dist/cli/runtime.js +18 -8
  19. package/dist/cli/status.js +1 -0
  20. package/dist/cli/takeover.d.ts +3 -0
  21. package/dist/cli/takeover.js +7 -1
  22. package/dist/cli/update.d.ts +4 -0
  23. package/dist/cli/update.js +8 -1
  24. package/dist/config.d.ts +5 -23
  25. package/dist/diagnostics/failure.js +37 -11
  26. package/dist/format/rows.d.ts +1 -1
  27. package/dist/format/rows.js +9 -6
  28. package/dist/mcp/server.d.ts +2 -2
  29. package/dist/service/index.js +3 -1
  30. package/dist/service/macos.d.ts +2 -0
  31. package/dist/service/macos.js +4 -1
  32. package/dist/service/systemd.d.ts +2 -0
  33. package/dist/service/systemd.js +5 -3
  34. package/dist/version.js +1 -1
  35. package/dist/web/src/App.js +1 -1
  36. package/dist/web/src/components/AdviceDrawer.js +1 -1
  37. package/dist/web/src/components/FaultBanner.d.ts +10 -5
  38. package/dist/web/src/components/FaultBanner.js +19 -9
  39. package/dist/web/src/components/OpencodeGoBanner.d.ts +1 -1
  40. package/dist/web/src/components/OpencodeGoBanner.js +9 -2
  41. package/dist/web/src/components/PaceBar.d.ts +9 -4
  42. package/dist/web/src/components/PaceBar.js +26 -7
  43. package/dist/web/src/components/ProviderCard.js +2 -3
  44. package/dist/web/src/components/ProviderDrawer.js +3 -3
  45. package/dist/web/src/components/ProviderIcon.js +1 -1
  46. package/dist/web/src/components/ProviderRow.d.ts +1 -1
  47. package/dist/web/src/components/ProviderRow.js +2 -4
  48. package/dist/web/src/components/Recommendation.js +1 -1
  49. package/dist/web/src/components/ResetRail.js +1 -1
  50. package/dist/web/src/components/WindowsRow.d.ts +1 -1
  51. package/dist/web/src/components/WindowsRow.js +4 -4
  52. package/dist/web/src/components/settings/SettingsDrawer.js +1 -1
  53. package/dist/web/src/pages/Onboarding.js +2 -1
  54. package/dist/webAssets.js +1 -1
  55. package/dist/webHtml.js +1 -1
  56. package/package.json +5 -5
  57. package/web/dist/assets/{index-Dk6l5h27.css → index-C6ltEeGL.css} +1 -1
  58. package/web/dist/assets/index-DRDDTtxF.js +13 -0
  59. package/web/dist/index.html +2 -2
  60. package/web/dist/assets/index-CsOiHZyD.js +0 -12
@@ -8,7 +8,14 @@ import path from "node:path";
8
8
  import { adapterSignal } from "../runtime/spawn.js";
9
9
  const USAGE_URL = "https://opencode.ai/zen/go/v1/usage";
10
10
  const POLL_TIMEOUT_MS = 8000;
11
- export const OPENCODE_GO_CONSENT_NOTICE = "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.";
11
+ export const OPENCODE_GO_CONSENT_NOTICE = [
12
+ "• What is read: API key OpenCode already stored when you ran `opencode auth login -p opencode-go` (`~/.local/share/opencode/auth.json`, `opencode-go` → `opencode` fallback).",
13
+ "• Read frequency: in-memory, read-only once per poll (every 15 minutes by default).",
14
+ "• Request destination: sent only as `Authorization: Bearer` to `https://opencode.ai/zen/go/v1/usage` to fetch `rolling/weekly/monthly` `percent` + `resetsAt`.",
15
+ "• Never-list: never used to make chat or model requests, never written or rotated, never stored in the `quotacap` DB, never returned by the API or MCP, and never logged (errors show `[redacted]`).",
16
+ "• Revocation instructions: revoke at `https://opencode.ai/auth` or `opencode providers logout opencode-go` and run `quotacap providers disable opencode-go` to stop polling.",
17
+ "• Optional environment override: if you prefer no file access, set `OPENCODE_API_KEY` instead.",
18
+ ].join("\n");
12
19
  export function openCodeGoAuthPath() {
13
20
  return path.join(os.homedir(), ".local", "share", "opencode", "auth.json");
14
21
  }
@@ -29,6 +29,15 @@ export interface PtyRunOptions {
29
29
  */
30
30
  respondToQueries?: boolean;
31
31
  completionRegex?: RegExp;
32
+ /**
33
+ * Optional initial prompt responder before readyRegex matches.
34
+ * If matched against the cleaned transcript during the pre-ready poll loop,
35
+ * sends `reply` once to dismiss or answer the prompt (e.g. workspace trust in a probe directory).
36
+ */
37
+ preReadyResponse?: {
38
+ match: RegExp;
39
+ reply: string;
40
+ };
32
41
  /** If matched, abort immediately with a clear error (e.g. trust prompts). */
33
42
  abortOn?: RegExp;
34
43
  timeoutMs: number;
@@ -329,6 +329,7 @@ async function runPtyBun(opts) {
329
329
  if (opts.readyRegex) {
330
330
  const deadline = Date.now() + readyTimeoutMs;
331
331
  let matched = false;
332
+ let preReadySent = false;
332
333
  while (Date.now() < deadline) {
333
334
  checkCap();
334
335
  checkAborted();
@@ -341,6 +342,13 @@ async function runPtyBun(opts) {
341
342
  });
342
343
  }
343
344
  const clean = stripAnsi(transcript);
345
+ if (opts.preReadyResponse && !preReadySent && regexTest(opts.preReadyResponse.match, clean)) {
346
+ preReadySent = true;
347
+ try {
348
+ proc.terminal.write(opts.preReadyResponse.reply);
349
+ }
350
+ catch { }
351
+ }
344
352
  checkAbort(clean);
345
353
  if (regexTest(opts.readyRegex, clean)) {
346
354
  matched = true;
@@ -633,6 +641,7 @@ async function runPtyNode(opts) {
633
641
  if (opts.readyRegex) {
634
642
  const deadline = Date.now() + readyTimeoutMs;
635
643
  let matched = false;
644
+ let preReadySent = false;
636
645
  while (Date.now() < deadline) {
637
646
  checkCap();
638
647
  checkAborted();
@@ -645,6 +654,13 @@ async function runPtyNode(opts) {
645
654
  });
646
655
  }
647
656
  const clean = stripAnsi(transcript);
657
+ if (opts.preReadyResponse && !preReadySent && regexTest(opts.preReadyResponse.match, clean)) {
658
+ preReadySent = true;
659
+ try {
660
+ ptyProcess.write(opts.preReadyResponse.reply);
661
+ }
662
+ catch { }
663
+ }
648
664
  checkAbort(clean);
649
665
  if (regexTest(opts.readyRegex, clean)) {
650
666
  matched = true;
@@ -82,6 +82,7 @@ export function registerAdviseCommand(program, deps) {
82
82
  createClient,
83
83
  execService,
84
84
  ...takeoverOpts,
85
+ json: !!o.json || takeoverOpts.json,
85
86
  });
86
87
  console.error(r.message);
87
88
  // The takeover verified the daemon now reports the CLI version;
@@ -3,6 +3,7 @@ import type { ServiceClient } from "../runtime/client.js";
3
3
  import type { CatalogFetcher } from "../catalog/index.js";
4
4
  import type { SleepFn } from "./takeover.js";
5
5
  import type { UpdateCache } from "../runtime/updates.js";
6
+ import type { PhaseFn } from "./progress.js";
6
7
  export interface CreateClientOptions {
7
8
  port: number;
8
9
  token?: string;
@@ -24,8 +25,11 @@ export interface ClientCommandDeps {
24
25
  sleep?: SleepFn;
25
26
  timeoutMs?: number;
26
27
  readToken?: () => string | undefined;
28
+ phase?: PhaseFn;
29
+ json?: boolean;
27
30
  };
28
31
  checkUpdates?: () => Promise<UpdateCache | null>;
29
32
  catalogFetchers?: Record<string, CatalogFetcher>;
33
+ phase?: PhaseFn;
30
34
  }
31
35
  export declare function registerClientCommands(program: Command, deps?: ClientCommandDeps): void;
@@ -6,6 +6,7 @@ import { providerIdentity } from "../advisory/provider-names.js";
6
6
  import { readToken } from "../runtime/token.js";
7
7
  import { createServiceClient } from "../runtime/client.js";
8
8
  import { OFFLINE_LABEL } from "./snapshot-source.js";
9
+ import { phase } from "./progress.js";
9
10
  function projectModels(catalogs, quotaMap, providerNames) {
10
11
  const out = [];
11
12
  for (const [id, catalog] of catalogs) {
@@ -70,6 +71,9 @@ export function registerModelsCommand(program, deps = {}) {
70
71
  });
71
72
  let body;
72
73
  try {
74
+ if (o.refresh) {
75
+ (deps.phase ?? phase)("refreshing model catalogs…", { json: !!o.json });
76
+ }
73
77
  const url = o.refresh
74
78
  ? (o.provider ? `/api/models/refresh?provider=${encodeURIComponent(o.provider)}` : "/api/models/refresh")
75
79
  : (o.provider ? `/api/models?provider=${encodeURIComponent(o.provider)}` : "/api/models");
@@ -0,0 +1,32 @@
1
+ export interface ProgressOptions {
2
+ /** Suppress output when machine-readable JSON is requested. */
3
+ json?: boolean;
4
+ /** Explicitly silence progress (e.g. quiet mode). */
5
+ quiet?: boolean;
6
+ /** Whether the output stream is interactive. Defaults to process.stderr.isTTY. */
7
+ isTTY?: boolean;
8
+ /** Custom writer for stderr (defaults to console.error). */
9
+ stderr?: (message: string) => void;
10
+ }
11
+ export type PhaseFn = (message: string, opts?: ProgressOptions) => void;
12
+ /**
13
+ * Format a progress phase string ensuring consistent trailing ellipsis without duplication.
14
+ */
15
+ export declare function formatPhase(message: string): string;
16
+ /**
17
+ * Determine whether progress messages should be printed given the options.
18
+ */
19
+ export declare function shouldEmitProgress(opts?: ProgressOptions): boolean;
20
+ /**
21
+ * Emit a progress phase message strictly to stderr if interactive and non-json.
22
+ */
23
+ export declare function phase(message: string, opts?: ProgressOptions): void;
24
+ /**
25
+ * Await work, calling emit only if it is still pending after graceMs, so an
26
+ * already-settled fast path stays silent.
27
+ */
28
+ export declare function phaseIfSlow<T>(work: Promise<T>, emit: () => void, graceMs?: number): Promise<T>;
29
+ /**
30
+ * Create a phase reporter with pre-bound default options.
31
+ */
32
+ export declare function createPhase(baseOpts?: ProgressOptions): (message: string, overrideOpts?: ProgressOptions) => void;
@@ -0,0 +1,70 @@
1
+ // Shared progress helper for multi-second CLI operations.
2
+ // Strictly writes to stderr with a consistent lowercase phrasing ending in '…'.
3
+ // Automatically suppressed under --json, quiet mode, and non-interactive (non-TTY) runs.
4
+ /**
5
+ * Format a progress phase string ensuring consistent trailing ellipsis without duplication.
6
+ */
7
+ export function formatPhase(message) {
8
+ const trimmed = message.trim();
9
+ if (!trimmed)
10
+ return "";
11
+ if (trimmed.endsWith("...")) {
12
+ return `${trimmed.slice(0, -3)}…`;
13
+ }
14
+ if (trimmed.endsWith("…")) {
15
+ return trimmed;
16
+ }
17
+ return `${trimmed}…`;
18
+ }
19
+ /**
20
+ * Determine whether progress messages should be printed given the options.
21
+ */
22
+ export function shouldEmitProgress(opts = {}) {
23
+ if (opts.json || opts.quiet)
24
+ return false;
25
+ if (opts.isTTY !== undefined)
26
+ return Boolean(opts.isTTY);
27
+ return Boolean(process.stderr.isTTY);
28
+ }
29
+ /**
30
+ * Emit a progress phase message strictly to stderr if interactive and non-json.
31
+ */
32
+ export function phase(message, opts = {}) {
33
+ const formatted = formatPhase(message);
34
+ if (!formatted)
35
+ return;
36
+ if (!shouldEmitProgress(opts))
37
+ return;
38
+ const write = opts.stderr ?? console.error;
39
+ write(formatted);
40
+ }
41
+ /**
42
+ * Await work, calling emit only if it is still pending after graceMs, so an
43
+ * already-settled fast path stays silent.
44
+ */
45
+ export async function phaseIfSlow(work, emit, graceMs = 250) {
46
+ let timer;
47
+ const slow = new Promise((r) => {
48
+ timer = setTimeout(() => r("slow"), graceMs);
49
+ });
50
+ try {
51
+ const first = await Promise.race([
52
+ work.then(() => "done", () => "done"),
53
+ slow,
54
+ ]);
55
+ if (first === "slow")
56
+ emit();
57
+ return await work;
58
+ }
59
+ finally {
60
+ clearTimeout(timer);
61
+ }
62
+ }
63
+ /**
64
+ * Create a phase reporter with pre-bound default options.
65
+ */
66
+ export function createPhase(baseOpts = {}) {
67
+ return (message, overrideOpts = {}) => {
68
+ phase(message, { ...baseOpts, ...overrideOpts });
69
+ };
70
+ }
@@ -248,7 +248,8 @@ export function registerProvidersCommand(program, deps = {}) {
248
248
  return;
249
249
  }
250
250
  }
251
- console.log(`enabled ${id} — restart quotacap daemon to apply`);
251
+ console.log(`enabled ${id}`);
252
+ console.log("Next: run quotacap service restart to start polling");
252
253
  });
253
254
  providers
254
255
  .command("disable <id>")
@@ -2,6 +2,7 @@ import type { Command } from "commander";
2
2
  import { startService, type StartServiceOptions } from "../runtime/service.js";
3
3
  import { type ServiceClient } from "../runtime/client.js";
4
4
  import { type SleepFn } from "./takeover.js";
5
+ import { type PhaseFn } from "./progress.js";
5
6
  export type StartServiceFn = (opts?: StartServiceOptions) => ReturnType<typeof startService>;
6
7
  export type OpenBrowserFn = (url: string) => Promise<void> | void;
7
8
  export type ExecServiceFn = (args: string[], opts?: Record<string, any>) => Promise<number>;
@@ -20,10 +21,13 @@ export interface RuntimeCommandDeps {
20
21
  sleep?: SleepFn;
21
22
  timeoutMs?: number;
22
23
  readToken?: () => string | undefined;
24
+ phase?: PhaseFn;
25
+ json?: boolean;
23
26
  };
24
27
  checkUpdates?: () => Promise<unknown>;
25
28
  isInstalled?: () => Promise<boolean>;
26
29
  serviceSupported?: () => boolean;
30
+ phase?: PhaseFn;
27
31
  }
28
32
  export interface WebLaunchOptions {
29
33
  port?: string;
@@ -38,5 +42,6 @@ export declare function waitForInitialPoll(port: number, opts?: {
38
42
  }) => any;
39
43
  sleep?: SleepFn;
40
44
  timeoutMs?: number;
45
+ phase?: PhaseFn;
41
46
  }): Promise<void>;
42
47
  export declare function registerRuntimeCommands(program: Command, deps?: RuntimeCommandDeps): void;
@@ -8,6 +8,7 @@ import { createServiceClient, ServiceError, ServiceUnavailable, } from "../runti
8
8
  import { isServiceInstalled, isServiceManaged, runServiceCommand, serviceSupported, } from "../service/index.js";
9
9
  import { detectChannel, refreshUpdateCache } from "../runtime/updates.js";
10
10
  import { checkSkew, execSkewWarning, formatWedged, olderCliWarning, takeoverManaged, takeoverUnmanaged, upgradedMessage, waitForHealthy, WedgedError, } from "./takeover.js";
11
+ import { phase } from "./progress.js";
11
12
  // Shared `web` launcher handler: the `web` command and the bare `quotacap`
12
13
  // invocation both run this. Decision order: healthy daemons open (with the
13
14
  // skew matrix), registered-but-down services start, and only the last resort
@@ -28,7 +29,8 @@ export async function launchWeb(o, deps = {}) {
28
29
  const isManaged = deps.isManaged ?? isServiceManaged;
29
30
  const isInstalled = deps.isInstalled ?? isServiceInstalled;
30
31
  const supported = deps.serviceSupported ?? serviceSupported;
31
- const takeoverOpts = deps.takeoverOpts ?? {};
32
+ const phaseFn = deps.phase ?? phase;
33
+ const takeoverOpts = { phase: phaseFn, ...deps.takeoverOpts };
32
34
  const checkUpdates = deps.checkUpdates ??
33
35
  (() => refreshUpdateCache({ channel: detectChannel(), current: VERSION }));
34
36
  const sleep = takeoverOpts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
@@ -56,7 +58,7 @@ export async function launchWeb(o, deps = {}) {
56
58
  const skew = checkSkew(health, VERSION, process.execPath);
57
59
  if (skew === "match") {
58
60
  if (health.polling === "in-progress" && !health.lastCompletedPollAt) {
59
- await waitForInitialPoll(port, { createClient, sleep, timeoutMs: Math.min(readyTimeoutMs, 5000) });
61
+ await waitForInitialPoll(port, { createClient, sleep, timeoutMs: Math.min(readyTimeoutMs, 5000), phase: phaseFn });
60
62
  }
61
63
  await openDashboard(`http://127.0.0.1:${port}`, openBrowser);
62
64
  return;
@@ -64,7 +66,7 @@ export async function launchWeb(o, deps = {}) {
64
66
  if (skew === "exec-only") {
65
67
  console.error(execSkewWarning(String(health.exec)));
66
68
  if (health.polling === "in-progress" && !health.lastCompletedPollAt) {
67
- await waitForInitialPoll(port, { createClient, sleep, timeoutMs: Math.min(readyTimeoutMs, 5000) });
69
+ await waitForInitialPoll(port, { createClient, sleep, timeoutMs: Math.min(readyTimeoutMs, 5000), phase: phaseFn });
68
70
  }
69
71
  await openDashboard(`http://127.0.0.1:${port}`, openBrowser);
70
72
  return;
@@ -72,7 +74,7 @@ export async function launchWeb(o, deps = {}) {
72
74
  if (skew === "cli-older") {
73
75
  console.error(olderCliWarning(String(health.version ?? "unknown")));
74
76
  if (health.polling === "in-progress" && !health.lastCompletedPollAt) {
75
- await waitForInitialPoll(port, { createClient, sleep, timeoutMs: Math.min(readyTimeoutMs, 5000) });
77
+ await waitForInitialPoll(port, { createClient, sleep, timeoutMs: Math.min(readyTimeoutMs, 5000), phase: phaseFn });
76
78
  }
77
79
  await openDashboard(`http://127.0.0.1:${port}`, openBrowser);
78
80
  return;
@@ -95,7 +97,7 @@ export async function launchWeb(o, deps = {}) {
95
97
  exit(1);
96
98
  return;
97
99
  }
98
- await waitForInitialPoll(port, { createClient, sleep, timeoutMs: Math.min(readyTimeoutMs, 5000) });
100
+ await waitForInitialPoll(port, { createClient, sleep, timeoutMs: Math.min(readyTimeoutMs, 5000), phase: phaseFn });
99
101
  await openDashboard(`http://127.0.0.1:${port}`, openBrowser);
100
102
  return;
101
103
  }
@@ -116,7 +118,7 @@ export async function launchWeb(o, deps = {}) {
116
118
  }
117
119
  // Unmanaged successor: foreground-start the new daemon and open it.
118
120
  const started = await start(o.port ? { port } : undefined);
119
- await waitForInitialPoll(started.port, { createClient, sleep, timeoutMs: Math.min(readyTimeoutMs, 5000) });
121
+ await waitForInitialPoll(started.port, { createClient, sleep, timeoutMs: Math.min(readyTimeoutMs, 5000), phase: phaseFn });
120
122
  await openDashboard(`http://127.0.0.1:${started.port}`, openBrowser);
121
123
  return;
122
124
  }
@@ -146,16 +148,18 @@ export async function launchWeb(o, deps = {}) {
146
148
  detail = e?.message ?? String(e);
147
149
  }
148
150
  if (!detail) {
151
+ phaseFn("waiting for daemon…");
149
152
  const healthy = await waitForHealthy(port, VERSION, {
150
153
  createClient,
151
154
  sleep,
152
155
  timeoutMs: readyTimeoutMs,
156
+ phase: phaseFn,
153
157
  });
154
158
  if (!healthy)
155
159
  detail = `not ready within ${Math.round(readyTimeoutMs / 1000)}s`;
156
160
  }
157
161
  if (!detail) {
158
- await waitForInitialPoll(port, { createClient, sleep, timeoutMs: Math.min(readyTimeoutMs, 5000) });
162
+ await waitForInitialPoll(port, { createClient, sleep, timeoutMs: Math.min(readyTimeoutMs, 5000), phase: phaseFn });
159
163
  await openDashboard(`http://127.0.0.1:${port}`, openBrowser);
160
164
  return;
161
165
  }
@@ -167,7 +171,7 @@ export async function launchWeb(o, deps = {}) {
167
171
  if (!foregroundOnly && !attemptedServiceStart && supported()) {
168
172
  console.error("for a background service that survives terminal closes, run 'quotacap service install'");
169
173
  }
170
- await waitForInitialPoll(started.port, { createClient, sleep, timeoutMs: Math.min(readyTimeoutMs, 5000) });
174
+ await waitForInitialPoll(started.port, { createClient, sleep, timeoutMs: Math.min(readyTimeoutMs, 5000), phase: phaseFn });
171
175
  await openDashboard(`http://127.0.0.1:${started.port}`, openBrowser);
172
176
  }
173
177
  export async function waitForInitialPoll(port, opts = {}) {
@@ -176,6 +180,7 @@ export async function waitForInitialPoll(port, opts = {}) {
176
180
  const timeoutMs = opts.timeoutMs ?? 5000;
177
181
  const deadline = Date.now() + timeoutMs;
178
182
  const client = createClient({ port, timeoutMs: 1000 });
183
+ let emitted = false;
179
184
  while (Date.now() < deadline) {
180
185
  await sleep(250);
181
186
  try {
@@ -183,6 +188,10 @@ export async function waitForInitialPoll(port, opts = {}) {
183
188
  if (!h?.ok || h?.polling !== "in-progress" || h?.lastCompletedPollAt) {
184
189
  return;
185
190
  }
191
+ if (!emitted) {
192
+ (opts.phase ?? phase)("waiting for first readings…");
193
+ emitted = true;
194
+ }
186
195
  }
187
196
  catch {
188
197
  return;
@@ -254,6 +263,7 @@ export function registerRuntimeCommands(program, deps) {
254
263
  serviceSupported: supported,
255
264
  takeoverOpts,
256
265
  checkUpdates,
266
+ phase: deps?.phase,
257
267
  }));
258
268
  const service = program
259
269
  .command("service")
@@ -74,6 +74,7 @@ export function registerStatusCommand(program, deps) {
74
74
  createClient,
75
75
  execService,
76
76
  ...takeoverOpts,
77
+ json: !!o.json || takeoverOpts.json,
77
78
  });
78
79
  console.error(r.message);
79
80
  // The takeover verified the daemon now reports the CLI version;
@@ -1,6 +1,7 @@
1
1
  import { type ServiceClient } from "../runtime/client.js";
2
2
  import { type ClaimInfo } from "../runtime/owner.js";
3
3
  import { isServiceManaged } from "../service/index.js";
4
+ import { type PhaseFn } from "./progress.js";
4
5
  export type SkewKind = "match" | "exec-only" | "cli-newer" | "cli-older";
5
6
  export interface HealthView {
6
7
  ok?: unknown;
@@ -37,6 +38,8 @@ export interface WaitOpts {
37
38
  createClient?: CreateTakeoverClient;
38
39
  sleep?: SleepFn;
39
40
  timeoutMs?: number;
41
+ phase?: PhaseFn;
42
+ json?: boolean;
40
43
  }
41
44
  export declare function waitForHealthy(port: number, expectedVersion: string, opts?: WaitOpts): Promise<any | null>;
42
45
  export declare function waitForRelease(port: number, dataDir?: string, opts?: WaitOpts): Promise<boolean>;
@@ -9,6 +9,7 @@ import { createServiceClient, ServiceError, ServiceUnavailable, } from "../runti
9
9
  import { readToken } from "../runtime/token.js";
10
10
  import { readClaim } from "../runtime/owner.js";
11
11
  import { runServiceCommand, isServiceManaged } from "../service/index.js";
12
+ import { phase } from "./progress.js";
12
13
  export { parseVersion, compareVersions } from "../runtime/versions.js";
13
14
  import { compareVersions, parseVersion } from "../runtime/versions.js";
14
15
  export function checkSkew(health, cliVersion = VERSION, cliExec = process.execPath) {
@@ -69,6 +70,7 @@ export function formatWedged(opts) {
69
70
  `stop the process listening on port ${port} in its own terminal and retry`);
70
71
  }
71
72
  const defaultSleep = (ms) => new Promise((r) => setTimeout(r, ms));
73
+ const silentPhase = () => { };
72
74
  // Poll /health until it reports the expected version (or the deadline).
73
75
  export async function waitForHealthy(port, expectedVersion, opts = {}) {
74
76
  const createClient = opts.createClient ?? createServiceClient;
@@ -119,7 +121,10 @@ export async function takeoverManaged(opts) {
119
121
  : "unknown";
120
122
  const execService = opts.execService ?? ((args, o) => runServiceCommand(args, o ?? {}, {}));
121
123
  const verb = opts.refreshRegistration ? "install" : "restart";
122
- const code = await execService([verb], opts.refreshRegistration ? { version: cliVersion, quiet: true } : undefined);
124
+ (opts.phase ?? phase)("waiting for daemon…", { json: opts.json });
125
+ // The line above already reports this wait, so the nested restart stays
126
+ // silent instead of repeating it past the caller's --json suppression.
127
+ const code = await execService([verb], opts.refreshRegistration ? { version: cliVersion, quiet: true } : { phase: silentPhase });
123
128
  if (code !== 0) {
124
129
  throw new TakeoverError(`daemon upgrade failed: service ${verb} exited ${code}`);
125
130
  }
@@ -144,6 +149,7 @@ export async function takeoverUnmanaged(opts) {
144
149
  if (!token) {
145
150
  throw new WedgedError(formatWedged({ claim: readClaim(dataDir), port, managed: false }));
146
151
  }
152
+ (opts.phase ?? phase)("waiting for daemon to stop…", { json: opts.json });
147
153
  const createClient = opts.createClient ?? createServiceClient;
148
154
  const client = createClient({ port, token, timeoutMs: 5000 });
149
155
  try {
@@ -2,6 +2,7 @@ import type { Command } from "commander";
2
2
  import { type ServiceClient } from "../runtime/client.js";
3
3
  import { type ChannelEnv } from "../runtime/updates.js";
4
4
  import { type SleepFn } from "./takeover.js";
5
+ import { type PhaseFn } from "./progress.js";
5
6
  export interface UpdateJson {
6
7
  channel: string;
7
8
  current: string;
@@ -28,9 +29,12 @@ export interface UpdateCommandDeps {
28
29
  sleep?: SleepFn;
29
30
  timeoutMs?: number;
30
31
  readToken?: () => string | undefined;
32
+ phase?: PhaseFn;
33
+ json?: boolean;
31
34
  };
32
35
  spawnNpm?: (args: string[]) => Promise<number>;
33
36
  extractTar?: (tarball: string, destDir: string) => void;
37
+ phase?: PhaseFn;
34
38
  }
35
39
  export declare function stoppedDaemonMessage(oldVersion: string, newVersion: string): string;
36
40
  export interface PostUpdateTakeoverOpts {
@@ -7,6 +7,7 @@ import { compareVersions } from "../runtime/versions.js";
7
7
  import { detectChannel, resolveLatestVersionDetailed, updateNpm, updateStandalone, writeUpdateCache, } from "../runtime/updates.js";
8
8
  import { isServiceManaged, runServiceCommand } from "../service/index.js";
9
9
  import { checkSkew, formatWedged, olderCliWarning, takeoverManaged, takeoverUnmanaged, WedgedError, } from "./takeover.js";
10
+ import { phase } from "./progress.js";
10
11
  export function stoppedDaemonMessage(oldVersion, newVersion) {
11
12
  return (`Stopped old daemon (${oldVersion}); no background service is registered. ` +
12
13
  `Run 'quotacap web' to start ${newVersion}, or 'quotacap service install' to run it in the background.`);
@@ -190,6 +191,10 @@ export function registerUpdateCommand(program, deps) {
190
191
  const pin = typeof o.to === "string" && o.to ? stripV(o.to) : null;
191
192
  let latest = null;
192
193
  let rateLimited = null;
194
+ const phaseFn = deps?.phase ?? phase;
195
+ if (!o.check && !pin) {
196
+ phaseFn("resolving latest version…", { json: asJson });
197
+ }
193
198
  try {
194
199
  const resolved = await resolveLatestVersionDetailed({ fetchFn });
195
200
  if (resolved.status === "ok")
@@ -298,9 +303,10 @@ export function registerUpdateCommand(program, deps) {
298
303
  createClient: deps?.createClient,
299
304
  isManaged: deps?.isManaged,
300
305
  execService: deps?.execService,
301
- takeoverOpts: deps?.takeoverOpts,
306
+ takeoverOpts: { phase: phaseFn, json: asJson, ...deps?.takeoverOpts },
302
307
  };
303
308
  if (channel === "standalone") {
309
+ phaseFn(`downloading ${target}…`, { json: asJson });
304
310
  const swap = await updateStandalone({
305
311
  target,
306
312
  execPath,
@@ -356,6 +362,7 @@ export function registerUpdateCommand(program, deps) {
356
362
  }
357
363
  // npm global.
358
364
  const npmSpec = pin ?? "latest";
365
+ phaseFn(`updating npm package…`, { json: asJson });
359
366
  const npm = await updateNpm({
360
367
  target: npmSpec,
361
368
  execPath,
package/dist/config.d.ts CHANGED
@@ -1,35 +1,17 @@
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
+ }, z.core.$strip>;
33
15
  export type Config = z.infer<typeof ConfigSchema>;
34
16
  /**
35
17
  * Manual ingest (CLI `ingest`, POST /api/ingest) is off unless this returns
@@ -40,14 +40,25 @@ function stripAnsiAndControls(s) {
40
40
  .replace(/[\r\n\t]+/g, " ");
41
41
  }
42
42
  const UNRECOGNIZED_OMITTED = "Unrecognized diagnostic text omitted";
43
+ // Vendor account-confirmation wording. The sentence is safe to recognize.
44
+ // The browser link that follows it can carry a sign-in token, so the link is never stored.
45
+ // "account is not eligible" is the Antigravity sentence; a bare "not eligible" is not.
46
+ // The hyphen covers "Sign-in with". "signed in with" does not match.
47
+ const ACCOUNT_CONFIRMATION = /\b(?:account is not eligible|verify your account|eligibility check failed|device code|sign(?:ing)?[ -]in with)\b/i;
43
48
  function matchPrecedence(evidence, errorName, errorCode, text) {
44
- // Order 1: timeout — Structured AbortError or an explicit QuotaCap abort/timeout checkpoint
45
- const isAbort = errorName === "AbortError" ||
46
- errorCode === "ABORT_ERR" ||
47
- evidence?.checkpoint === "abort" ||
48
- evidence?.checkpoint === "ready timeout" ||
49
- evidence?.checkpoint === "completion timeout" ||
50
- /\bpty aborted\b/i.test(text);
49
+ // Order 1: timeout — Structured AbortError or an explicit QuotaCap abort/timeout checkpoint.
50
+ // A ready or completion timeout whose transcript is an account-confirmation
51
+ // screen falls through, so the sign-in sentence can win later. An explicit
52
+ // abort stays a timeout even if that screen is in the transcript.
53
+ const signInTimeout = ACCOUNT_CONFIRMATION.test(text) &&
54
+ (evidence?.checkpoint === "ready timeout" || evidence?.checkpoint === "completion timeout");
55
+ const isAbort = !signInTimeout &&
56
+ (errorName === "AbortError" ||
57
+ errorCode === "ABORT_ERR" ||
58
+ evidence?.checkpoint === "abort" ||
59
+ evidence?.checkpoint === "ready timeout" ||
60
+ evidence?.checkpoint === "completion timeout" ||
61
+ /\bpty aborted\b/i.test(text));
51
62
  if (isAbort) {
52
63
  return { code: "timeout", phrase: "timed out" };
53
64
  }
@@ -252,6 +263,13 @@ function matchPrecedence(evidence, errorName, errorCode, text) {
252
263
  if (/\blimits refresh requested\b/i.test(text) || /\brefresh requested\b/i.test(text)) {
253
264
  return { code: "service_unavailable", phrase: "limits refresh requested" };
254
265
  }
266
+ // Account confirmation is after network, parse, and service-unavailable.
267
+ // A sign-in menu line must not hide a harder failure. It is before the
268
+ // text timeout match so a completion timeout sitting on that screen still
269
+ // classifies as auth.
270
+ if (ACCOUNT_CONFIRMATION.test(text)) {
271
+ return { code: "auth", phrase: "account confirmation required" };
272
+ }
255
273
  // Order 9: timeout — Remaining explicit timeout or timed out errors
256
274
  if (/\btimed out\b/i.test(text) || /\btimeout\b/i.test(text)) {
257
275
  return { code: "timeout", phrase: "timed out" };
@@ -356,14 +374,16 @@ export function diagnosticError(reason, evidence) {
356
374
  if (parsed.durationMs !== undefined)
357
375
  durationMs = parsed.durationMs;
358
376
  }
359
- // Filter stdout to avoid matching non-error screen text (like login menus)
377
+ // Keep error lines and account-confirmation lines. Other PTY screen text
378
+ // stays out so a spinner or model list cannot change the classification.
360
379
  let safeStdoutText = "";
361
380
  if (evidence?.stdout) {
362
381
  if (evidence.source === "pty") {
363
382
  const lines = evidence.stdout.split(/[\r\n]+/);
364
383
  const matchedLines = lines.filter((l) => /\b(error|failed|failure|fatal|unauthorized|unauthorised|denied|forbidden|panic|exception)\b/i.test(l) ||
365
384
  /\btrust\b/i.test(l) ||
366
- /\b(stdin is not a terminal|device not configured|not a tty|inappropriate ioctl|node-pty)\b/i.test(l));
385
+ /\b(stdin is not a terminal|device not configured|not a tty|inappropriate ioctl|node-pty)\b/i.test(l) ||
386
+ ACCOUNT_CONFIRMATION.test(l));
367
387
  safeStdoutText = matchedLines.join(" ");
368
388
  }
369
389
  else {
@@ -428,7 +448,7 @@ function mapCategory(code) {
428
448
  return "unknown";
429
449
  }
430
450
  }
431
- function getSummaryAndAction(code, providerName, provider) {
451
+ function getSummaryAndAction(code, providerName, provider, errorDetail) {
432
452
  switch (code) {
433
453
  case "terminal_error":
434
454
  return {
@@ -436,6 +456,12 @@ function getSummaryAndAction(code, providerName, provider) {
436
456
  action: `Open ${providerName} directly in your terminal to check whether it starts. If it works there, inspect the QuotaCap service log and report the adapter failure. Refresh in the dashboard retries through the same service.`,
437
457
  };
438
458
  case "auth":
459
+ if (/\baccount confirmation required\b/i.test(errorDetail)) {
460
+ return {
461
+ summary: `${providerName} needs you to confirm the subscription account`,
462
+ action: `Open ${providerName}, complete the browser sign-in it shows, then select Refresh in the QuotaCap dashboard.`,
463
+ };
464
+ }
439
465
  return {
440
466
  summary: `${providerName} login required`,
441
467
  action: `Open ${providerName} and follow its sign-in instructions. Then select Refresh in the QuotaCap dashboard.`,
@@ -495,7 +521,7 @@ export function classifyFailure(provider, reason) {
495
521
  }
496
522
  const category = mapCategory(diag.diagnosticCode);
497
523
  const pName = formatProviderName(provider);
498
- const { summary, action } = getSummaryAndAction(diag.diagnosticCode, pName, provider);
524
+ const { summary, action } = getSummaryAndAction(diag.diagnosticCode, pName, provider, diag.errorDetail);
499
525
  return {
500
526
  diagnosticCode: diag.diagnosticCode,
501
527
  errorDetail: diag.errorDetail,