quotacap 0.0.40 → 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.
@@ -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
+ }
@@ -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,
@@ -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,
@@ -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`);
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.40";
2
+ export const VERSION = "0.0.41";
@@ -3,12 +3,17 @@ import type { ProviderView } from "../state.js";
3
3
  export interface Repair {
4
4
  text: string;
5
5
  }
6
+ /** Dashboard wording for a server auth action. The button on this page is Retry. */
7
+ export declare function authInstruction(name: string, attempt: {
8
+ summary?: string | null;
9
+ action?: string | null;
10
+ } | null | undefined): string;
6
11
  /**
7
- * Repair routing per excluded provider. Auth failures point at signing into
8
- * the provider CLI (by default QuotaCap never reads credentials; the only
9
- * exception is OpenCode Go, which the user enables explicitly); every other
10
- * exclusion points at a re-poll. Service start/install is covered by the
11
- * service-unavailable panel when the daemon itself is down.
12
+ * Repair routing per excluded provider. Auth failures use the server's
13
+ * summary and action, with Retry in place of the dashboard Refresh control.
14
+ * OpenCode Go names its own login command. Every other exclusion points at
15
+ * a re-poll. Service start/install is covered by the service-unavailable
16
+ * panel when the daemon itself is down.
12
17
  */
13
18
  export declare function repairFor(provider: ProviderView): Repair;
14
19
  export declare function FaultBanner({ providers, onRepoll, repolling, polling, }: {