quotacap 0.0.39 → 0.0.41

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 (68) hide show
  1. package/README.md +1 -1
  2. package/dist/adapters/agy.js +2 -2
  3. package/dist/adapters/claude.js +1 -1
  4. package/dist/adapters/codex.js +29 -20
  5. package/dist/adapters/grok.js +1 -1
  6. package/dist/adapters/kimi.d.ts +41 -0
  7. package/dist/adapters/kimi.js +453 -21
  8. package/dist/adapters/manual.js +2 -2
  9. package/dist/adapters/muse.js +3 -3
  10. package/dist/adapters/opencode-go.js +30 -2
  11. package/dist/adapters/pty.d.ts +9 -0
  12. package/dist/adapters/pty.js +16 -0
  13. package/dist/adapters/types.d.ts +17 -2
  14. package/dist/advisory/engine.d.ts +4 -2
  15. package/dist/advisory/engine.js +55 -14
  16. package/dist/advisory/snapshot.js +11 -3
  17. package/dist/advisory/types.d.ts +11 -3
  18. package/dist/cli/advise.js +1 -0
  19. package/dist/cli/clients.d.ts +4 -0
  20. package/dist/cli/models.js +5 -1
  21. package/dist/cli/progress.d.ts +32 -0
  22. package/dist/cli/progress.js +70 -0
  23. package/dist/cli/runtime.d.ts +5 -0
  24. package/dist/cli/runtime.js +18 -8
  25. package/dist/cli/status.js +1 -0
  26. package/dist/cli/takeover.d.ts +3 -0
  27. package/dist/cli/takeover.js +7 -1
  28. package/dist/cli/update.d.ts +4 -0
  29. package/dist/cli/update.js +8 -1
  30. package/dist/diagnostics/failure.js +46 -11
  31. package/dist/format/rows.d.ts +6 -1
  32. package/dist/format/rows.js +27 -4
  33. package/dist/format/terminal.js +5 -4
  34. package/dist/http/server.js +3 -3
  35. package/dist/runtime/poll.js +1 -1
  36. package/dist/service/index.js +3 -1
  37. package/dist/service/macos.d.ts +2 -0
  38. package/dist/service/macos.js +4 -1
  39. package/dist/service/systemd.d.ts +2 -0
  40. package/dist/service/systemd.js +5 -3
  41. package/dist/store/db.js +12 -1
  42. package/dist/store/quotas.js +33 -8
  43. package/dist/version.js +1 -1
  44. package/dist/web/src/components/AdviceDrawer.js +10 -5
  45. package/dist/web/src/components/FaultBanner.d.ts +10 -5
  46. package/dist/web/src/components/FaultBanner.js +19 -9
  47. package/dist/web/src/components/PaceBar.d.ts +9 -5
  48. package/dist/web/src/components/PaceBar.js +32 -10
  49. package/dist/web/src/components/ProviderCard.js +4 -2
  50. package/dist/web/src/components/ProviderDrawer.d.ts +7 -1
  51. package/dist/web/src/components/ProviderDrawer.js +46 -9
  52. package/dist/web/src/components/ProviderRow.js +4 -4
  53. package/dist/web/src/components/Recommendation.d.ts +5 -2
  54. package/dist/web/src/components/Recommendation.js +8 -4
  55. package/dist/web/src/components/ResetRail.js +3 -2
  56. package/dist/web/src/components/SubscriptionList.d.ts +2 -1
  57. package/dist/web/src/components/SubscriptionList.js +8 -5
  58. package/dist/web/src/components/WindowsRow.d.ts +30 -0
  59. package/dist/web/src/components/WindowsRow.js +68 -0
  60. package/dist/web/src/pages/Onboarding.js +2 -1
  61. package/dist/web/src/state.d.ts +12 -0
  62. package/dist/webAssets.js +1 -1
  63. package/dist/webHtml.js +1 -1
  64. package/package.json +1 -1
  65. package/web/dist/assets/index-BUK8xUHm.js +12 -0
  66. package/web/dist/assets/{index-NtT9XTrm.css → index-Dk6l5h27.css} +1 -1
  67. package/web/dist/index.html +2 -2
  68. package/web/dist/assets/index-CD8jaN87.js +0 -12
@@ -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,
@@ -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
  }
@@ -232,6 +243,15 @@ function matchPrecedence(evidence, errorName, errorCode, text) {
232
243
  if (/\bbad rolling pct\b/i.test(text)) {
233
244
  return { code: "parse_error", phrase: "bad rolling pct" };
234
245
  }
246
+ if (/\bbad monthly pct\b/i.test(text)) {
247
+ return { code: "parse_error", phrase: "bad monthly pct" };
248
+ }
249
+ if (/\bbad monthly reset\b/i.test(text)) {
250
+ return { code: "parse_error", phrase: "bad monthly reset" };
251
+ }
252
+ if (/\bunknown monthly status\b/i.test(text)) {
253
+ return { code: "parse_error", phrase: "unknown monthly status" };
254
+ }
235
255
  if (/\busage status not ok\b/i.test(text)) {
236
256
  return { code: "parse_error", phrase: "usage status not ok" };
237
257
  }
@@ -243,6 +263,13 @@ function matchPrecedence(evidence, errorName, errorCode, text) {
243
263
  if (/\blimits refresh requested\b/i.test(text) || /\brefresh requested\b/i.test(text)) {
244
264
  return { code: "service_unavailable", phrase: "limits refresh requested" };
245
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
+ }
246
273
  // Order 9: timeout — Remaining explicit timeout or timed out errors
247
274
  if (/\btimed out\b/i.test(text) || /\btimeout\b/i.test(text)) {
248
275
  return { code: "timeout", phrase: "timed out" };
@@ -347,14 +374,16 @@ export function diagnosticError(reason, evidence) {
347
374
  if (parsed.durationMs !== undefined)
348
375
  durationMs = parsed.durationMs;
349
376
  }
350
- // 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.
351
379
  let safeStdoutText = "";
352
380
  if (evidence?.stdout) {
353
381
  if (evidence.source === "pty") {
354
382
  const lines = evidence.stdout.split(/[\r\n]+/);
355
383
  const matchedLines = lines.filter((l) => /\b(error|failed|failure|fatal|unauthorized|unauthorised|denied|forbidden|panic|exception)\b/i.test(l) ||
356
384
  /\btrust\b/i.test(l) ||
357
- /\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));
358
387
  safeStdoutText = matchedLines.join(" ");
359
388
  }
360
389
  else {
@@ -419,7 +448,7 @@ function mapCategory(code) {
419
448
  return "unknown";
420
449
  }
421
450
  }
422
- function getSummaryAndAction(code, providerName, provider) {
451
+ function getSummaryAndAction(code, providerName, provider, errorDetail) {
423
452
  switch (code) {
424
453
  case "terminal_error":
425
454
  return {
@@ -427,6 +456,12 @@ function getSummaryAndAction(code, providerName, provider) {
427
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.`,
428
457
  };
429
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
+ }
430
465
  return {
431
466
  summary: `${providerName} login required`,
432
467
  action: `Open ${providerName} and follow its sign-in instructions. Then select Refresh in the QuotaCap dashboard.`,
@@ -486,7 +521,7 @@ export function classifyFailure(provider, reason) {
486
521
  }
487
522
  const category = mapCategory(diag.diagnosticCode);
488
523
  const pName = formatProviderName(provider);
489
- const { summary, action } = getSummaryAndAction(diag.diagnosticCode, pName, provider);
524
+ const { summary, action } = getSummaryAndAction(diag.diagnosticCode, pName, provider, diag.errorDetail);
490
525
  return {
491
526
  diagnosticCode: diag.diagnosticCode,
492
527
  errorDetail: diag.errorDetail,
@@ -1,5 +1,5 @@
1
1
  import type { ProviderSnapshot } from "../advisory/types.js";
2
- export type StateWord = "On track" | "Behind pace" | "Ahead of pace" | "Cap risk" | "Watch" | "Measuring" | "Not reporting";
2
+ export type StateWord = "On track" | "Behind pace" | "Ahead of pace" | "Cap risk" | "Watch" | "Measuring" | "Not reporting" | "Monthly exhausted";
3
3
  export type RailCell = "used" | "tick" | "unused";
4
4
  export type SortKey = "recommended" | "reset-asc" | "reset-desc" | "used-desc" | "used-asc";
5
5
  export type CompactSuffix = "" | "!" | "~" | "?";
@@ -16,7 +16,12 @@ export interface Row {
16
16
  }
17
17
  export declare const RAIL_CELLS = 20;
18
18
  export declare const SORT_KEYS: SortKey[];
19
+ export declare function weeklyPctOf(q: {
20
+ weeklyPct?: number;
21
+ usedPct?: number;
22
+ } | null | undefined): number | undefined;
19
23
  export declare function fmtDuration(ms: number): string;
24
+ export declare function weekdayOf(iso: string | undefined): string;
20
25
  export declare function countdownText(ps: ProviderSnapshot, now: Date): string;
21
26
  export declare function elapsedPct(ps: ProviderSnapshot, now: Date): number | null;
22
27
  export declare function usedPctOf(ps: ProviderSnapshot): number | null;
@@ -9,6 +9,11 @@ function msOf(iso) {
9
9
  const ms = new Date(iso).getTime();
10
10
  return Number.isNaN(ms) ? null : ms;
11
11
  }
12
+ // The CLI talks HTTP to a daemon that may predate the rename, which sent only
13
+ // `usedPct`. Read both for one release; drop the fallback with the aliases.
14
+ export function weeklyPctOf(q) {
15
+ return q?.weeklyPct ?? q?.usedPct;
16
+ }
12
17
  // Compact duration: 7d / 12h / 45m. Countdown convention floors; sub-minute
13
18
  // spans read 1m so a live window never prints 0m.
14
19
  export function fmtDuration(ms) {
@@ -18,6 +23,12 @@ export function fmtDuration(ms) {
18
23
  return `${Math.floor(ms / HOUR_MS)}h`;
19
24
  return `${Math.max(1, Math.floor(ms / MIN_MS))}m`;
20
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" });
31
+ }
21
32
  export function countdownText(ps, now) {
22
33
  const resetsMs = msOf(ps.quota?.resetsAt);
23
34
  let text;
@@ -40,14 +51,14 @@ export function elapsedPct(ps, now) {
40
51
  return Math.round(Math.min(100, Math.max(0, pct)));
41
52
  }
42
53
  export function usedPctOf(ps) {
43
- const u = ps.quota?.usedPct;
54
+ const u = weeklyPctOf(ps.quota);
44
55
  return typeof u === "number" && Number.isFinite(u) ? Math.round(u) : null;
45
56
  }
46
57
  export function railCells(ps, now) {
47
58
  const cells = new Array(RAIL_CELLS).fill("unused");
48
59
  if (ps.exclusionReason !== null)
49
60
  return cells;
50
- const u = ps.quota?.usedPct;
61
+ const u = weeklyPctOf(ps.quota);
51
62
  const used = typeof u === "number" && Number.isFinite(u)
52
63
  ? Math.min(RAIL_CELLS, Math.max(0, Math.round((u / 100) * RAIL_CELLS)))
53
64
  : 0;
@@ -70,6 +81,10 @@ export function stateWord(ps) {
70
81
  const adv = ps.advisory;
71
82
  if (!adv)
72
83
  return "Not reporting";
84
+ // A blocked month outranks every weekly verdict: weekly leftover cannot be
85
+ // spent until the month resets.
86
+ if (adv.bindingWindow === "monthly" && adv.bindingRemaining === 0)
87
+ return "Monthly exhausted";
73
88
  if (adv.status === "at risk")
74
89
  return "Cap risk";
75
90
  if (adv.status === "watch")
@@ -124,7 +139,13 @@ export function forecastText(ps, now) {
124
139
  if (!adv)
125
140
  return "no readings yet";
126
141
  let text;
127
- if (adv.remaining <= 0) {
142
+ if (adv.bindingWindow === "monthly" && adv.bindingRemaining <= 0) {
143
+ text = `unused until ${weekdayOf(ps.quota?.monthlyResetsAt)}`;
144
+ }
145
+ else if (adv.bindingWindow === "monthly") {
146
+ text = `${Math.round(adv.bindingRemaining)}% of month left`;
147
+ }
148
+ else if (adv.remaining <= 0) {
128
149
  text = "Exhausted";
129
150
  }
130
151
  else if (adv.paceSource === "unknown" || adv.burnRate === null) {
@@ -179,7 +200,7 @@ function wasteOf(p) {
179
200
  return typeof w === "number" && Number.isFinite(w) ? w : null;
180
201
  }
181
202
  function finiteUsed(p) {
182
- const u = p.quota?.usedPct;
203
+ const u = weeklyPctOf(p.quota);
183
204
  return typeof u === "number" && Number.isFinite(u) ? u : null;
184
205
  }
185
206
  export function sortProviders(providers, key, use) {
@@ -192,6 +213,8 @@ export function sortProviders(providers, key, use) {
192
213
  return 0;
193
214
  if (p.exclusionReason !== null)
194
215
  return 2;
216
+ if (p.advisory?.bindingWindow === "monthly" && p.advisory.bindingRemaining === 0)
217
+ return 2;
195
218
  return 1;
196
219
  };
197
220
  indexed.sort((a, b) => {
@@ -1,4 +1,4 @@
1
- import { buildRow, compactSuffix, sortProviders, } from "./rows.js";
1
+ import { buildRow, compactSuffix, sortProviders, weeklyPctOf, } from "./rows.js";
2
2
  const GAP = " ";
3
3
  const MIN_PROV_WIDTH = 12;
4
4
  const MAX_PROV_WIDTH = 22;
@@ -32,6 +32,7 @@ const STATE_COLORS = {
32
32
  "Cap risk": "\x1b[31m",
33
33
  "Measuring": "\x1b[90m",
34
34
  "Not reporting": "\x1b[90m",
35
+ "Monthly exhausted": "\x1b[31m",
35
36
  };
36
37
  // provWidth covers the 2-char prefix plus name, measured before ANSI.
37
38
  export function provWidthFor(ids) {
@@ -92,7 +93,7 @@ export function renderWide(snapshot, opts) {
92
93
  "PACE (%/DAY)".padEnd(paceWidth),
93
94
  "USED VS TIME".padEnd(20),
94
95
  "RESETS".padEnd(9),
95
- "STATE".padEnd(15),
96
+ "STATE".padEnd(19),
96
97
  "FORECAST",
97
98
  ].join(GAP);
98
99
  const lines = rows.map(({ p, row }) => {
@@ -103,7 +104,7 @@ export function renderWide(snapshot, opts) {
103
104
  const pace = (opts.ascii ? asciiText(row.pace) : row.pace).padEnd(paceWidth);
104
105
  const rail = renderRail(row.railCells, g, row.state, color);
105
106
  const countdown = (opts.ascii ? asciiText(row.countdown) : row.countdown).padEnd(9);
106
- const state = row.state.padEnd(15);
107
+ const state = row.state.padEnd(19);
107
108
  const forecast = opts.ascii ? asciiText(row.forecast) : row.forecast;
108
109
  const main = [
109
110
  color ? colorProviderField(name, recommended, row.state) : name,
@@ -158,7 +159,7 @@ export function renderCompact(snapshot, _now) {
158
159
  const entries = sorted
159
160
  .filter((p) => p.quota !== null)
160
161
  .map((p) => {
161
- const u = p.quota?.usedPct;
162
+ const u = weeklyPctOf(p.quota);
162
163
  const used = typeof u === "number" && Number.isFinite(u) ? String(Math.round(u)) : "?";
163
164
  return `[${p.id}:${used}%${compactSuffix(p)}]`;
164
165
  });
@@ -220,7 +220,7 @@ export function buildApp(ctx) {
220
220
  for (const [id, catalog] of catalogs) {
221
221
  const identity = providerIdentity(id, ctx.providerNames);
222
222
  const quota = quotaMap.get(id);
223
- const leftoverPct = quota?.usedPct != null ? Math.round((100 - quota.usedPct) * 10) / 10 : null;
223
+ const leftoverPct = quota?.weeklyPct != null ? Math.round((100 - quota.weeklyPct) * 10) / 10 : null;
224
224
  out.push({
225
225
  id,
226
226
  displayName: identity.displayName,
@@ -452,8 +452,8 @@ export function buildApp(ctx) {
452
452
  upsertQuota(ctx.db, {
453
453
  provider: parsed.provider,
454
454
  plan: parsed.plan,
455
- usedPct: parsed.usedPct,
456
- sessionPct: parsed.sessionPct,
455
+ weeklyPct: parsed.weeklyPct,
456
+ fiveHourPct: parsed.fiveHourPct,
457
457
  resetsAt: parsed.resetsAt,
458
458
  periodStart: parsed.periodStart,
459
459
  source: "manual",
@@ -138,7 +138,7 @@ export function createCoordinator(opts) {
138
138
  success: true,
139
139
  failureCategory: null,
140
140
  });
141
- const usedPct = row.value?.usedPct;
141
+ const usedPct = row.value?.weeklyPct;
142
142
  const pctSuffix = typeof usedPct === "number" && Number.isFinite(usedPct)
143
143
  ? ` (${usedPct}% used)`
144
144
  : "";
@@ -94,7 +94,9 @@ export async function runServiceCommand(args, opts = {}, deps = {}) {
94
94
  await backend.stop(deps);
95
95
  break;
96
96
  case "restart":
97
- await backend.restart(deps);
97
+ // Internal: a takeover passes its own phase reporter so the nested
98
+ // restart honours the calling command's progress suppression.
99
+ await backend.restart({ ...deps, phase: opts.phase ?? deps.phase });
98
100
  break;
99
101
  case "status":
100
102
  await backend.status(deps, { json: !!opts.json });
@@ -1,3 +1,4 @@
1
+ import { type PhaseFn } from "../cli/progress.js";
1
2
  export declare const SERVICE_LABEL = "quotacap";
2
3
  export declare const PROVIDER_BINS: string[];
3
4
  export declare function serviceSupported(platform?: string): boolean;
@@ -22,6 +23,7 @@ export interface ServiceDeps {
22
23
  bootstrapTimeoutMs?: number;
23
24
  version?: string;
24
25
  quiet?: boolean;
26
+ phase?: PhaseFn;
25
27
  }
26
28
  export declare function foregroundGuidance(verb: string, platform: string): string;
27
29
  export declare function defaultRunLaunchctl(args: string[]): string;
@@ -9,6 +9,7 @@ import { VERSION } from "../version.js";
9
9
  import { getDbPath, readServiceMetadata, writeServiceMetadata, } from "../config.js";
10
10
  import { ensureConfig, readConfig } from "../config.js";
11
11
  import { createServiceClient } from "../runtime/client.js";
12
+ import { phase, phaseIfSlow } from "../cli/progress.js";
12
13
  export const SERVICE_LABEL = "quotacap";
13
14
  export const PROVIDER_BINS = ["claude", "codex", "kimi", "grok", "agy", "muse"];
14
15
  export function serviceSupported(platform = process.platform) {
@@ -428,7 +429,8 @@ export async function start(deps = {}) {
428
429
  run(["enable", `gui/${uid}/${SERVICE_LABEL}`]);
429
430
  const waitReady = deps.waitReady ?? defaultWaitReady;
430
431
  const cfg = await readConfig();
431
- const ready = await waitReady(cfg.port);
432
+ // An already-loaded, ready job answers at once; report only a real wait.
433
+ const ready = await phaseIfSlow(waitReady(cfg.port), () => (deps.phase ?? phase)("waiting for daemon…", { quiet: deps.quiet }));
432
434
  if (!ready) {
433
435
  throw new Error(`service did not become ready on port ${cfg.port}; check ${path.join(dataDir, "logs", "service.log")}`);
434
436
  }
@@ -445,6 +447,7 @@ export async function restart(deps = {}) {
445
447
  // process for the port and start() fails its readiness check.
446
448
  // deps.port keeps tests off the real daemon's port.
447
449
  const port = deps.port ?? (await readConfig()).port;
450
+ (deps.phase ?? phase)("waiting for daemon to stop…", { quiet: deps.quiet });
448
451
  const waitReleased = deps.waitReleased ?? defaultWaitReleased;
449
452
  if (!(await waitReleased(port))) {
450
453
  (deps.print ?? console.log)(`port ${port} is still in use after stopping; starting anyway`);
@@ -1,3 +1,4 @@
1
+ import { type PhaseFn } from "../cli/progress.js";
1
2
  import { defaultWhich, type ServiceStatus } from "./macos.js";
2
3
  export declare const SERVICE_UNIT = "quotacap.service";
3
4
  export declare function serviceSupported(platform?: string): boolean;
@@ -19,6 +20,7 @@ export interface SystemdDeps {
19
20
  now?: () => Date;
20
21
  version?: string;
21
22
  quiet?: boolean;
23
+ phase?: PhaseFn;
22
24
  }
23
25
  export declare function formatSystemctlError(args: string[], detail: string): Error;
24
26
  export declare function defaultRunSystemctl(args: string[]): string;
@@ -8,9 +8,9 @@ import fs from "node:fs";
8
8
  import os from "node:os";
9
9
  import path from "node:path";
10
10
  import { VERSION } from "../version.js";
11
- import { getDbPath, readServiceMetadata, writeServiceMetadata, } from "../config.js";
12
- import { ensureConfig, readConfig } from "../config.js";
11
+ import { getDbPath, readServiceMetadata, writeServiceMetadata, ensureConfig, readConfig, } from "../config.js";
13
12
  import { createServiceClient } from "../runtime/client.js";
13
+ import { phase, phaseIfSlow } from "../cli/progress.js";
14
14
  import { SERVICE_LABEL, PROVIDER_BINS, buildChildPath, defaultWaitReady, defaultWaitReleased, defaultWhich, formatMissingProviders, formatStatus, foregroundGuidance, resolveProviderPaths, resolveServiceExec, } from "./macos.js";
15
15
  export const SERVICE_UNIT = `${SERVICE_LABEL}.service`;
16
16
  export function serviceSupported(platform = process.platform) {
@@ -241,7 +241,8 @@ export async function start(deps = {}) {
241
241
  }
242
242
  const { dataDir, print, run } = resolved(deps);
243
243
  run(["enable", "--now", SERVICE_UNIT]);
244
- const port = await waitForReady(deps, dataDir);
244
+ // An already-active, ready unit answers at once; report only a real wait.
245
+ const port = await phaseIfSlow(waitForReady(deps, dataDir), () => (deps.phase ?? phase)("waiting for daemon…", { quiet: deps.quiet }));
245
246
  print(`service started and ready on port ${port}`);
246
247
  }
247
248
  export async function restart(deps = {}) {
@@ -254,6 +255,7 @@ export async function restart(deps = {}) {
254
255
  // systemctl stop can return before the port is released; same race as launchd.
255
256
  // deps.port mirrors start(): tests must never probe the real daemon's port.
256
257
  const port = deps.port ?? (await readConfig()).port;
258
+ (deps.phase ?? phase)("waiting for daemon to stop…", { quiet: deps.quiet });
257
259
  const waitReleased = deps.waitReleased ?? defaultWaitReleased;
258
260
  if (!(await waitReleased(port))) {
259
261
  (deps.print ?? console.log)(`port ${port} is still in use after stopping; starting anyway`);