@indigoai-us/hq-cli 5.100.0 → 5.101.1

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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,31 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.101.1] — 2026-08-14
6
+
7
+ ### Fixed
8
+
9
+ - `hq core rebuild-index threads` no longer crashes with an `ENOENT` stat error
10
+ when a `workspace/threads/T-*.json` entry that the directory scan listed can no
11
+ longer be resolved — removed mid-scan by a concurrent writer (HQ Sync, another
12
+ session, or `archive-old-threads`) or left as a dangling symlink. The renderer
13
+ now stats each file exactly once before sorting (a Schwartzian transform rather
14
+ than statting inside the sort comparator), skips entries that vanished (logging
15
+ a single summary line by basename), and still fails loudly on a genuine stat
16
+ error such as `EACCES`. Both `INDEX.md` and `recent.md` are regenerated as
17
+ before. Sentry 7669694322 (HQ-CLI-P).
18
+
19
+ ## [5.101.0] — 2026-08-13
20
+
21
+ ### Added
22
+
23
+ - `hq agents provision` now offers the authoritative Basic, Power, and Dev box
24
+ sizes during interactive creation and accepts `--size basic|power|dev` for
25
+ automation. The CLI shows HQ Pro's company-specific monthly quote and
26
+ capacity before confirmation, carries the quote version into creation so a
27
+ stale price is refused, and preserves the server default when `--size` is
28
+ omitted.
29
+
5
30
  ## [5.100.0] — 2026-08-13
6
31
 
7
32
  ### Added
@@ -31,6 +31,8 @@ export declare const VALID_TIERS: Set<string>;
31
31
  export declare const VALID_PROVIDERS: Set<string>;
32
32
  /** Auth modes hq-pro accepts on `POST /v1/agents` (`CodexAuthMode`). */
33
33
  export declare const VALID_AUTH_MODES: Set<string>;
34
+ /** Customer-facing agent size keys served by hq-pro's authoritative catalog. */
35
+ export declare const VALID_AGENT_SIZE_KEYS: Set<string>;
34
36
  /**
35
37
  * Resolve a closed-set option value, or exit(1) with a message naming the
36
38
  * offending input and the legal set.
@@ -132,7 +134,47 @@ export interface ProvisionAgentInput {
132
134
  idempotencyKey: string;
133
135
  title?: string;
134
136
  description?: string;
137
+ /** Omitted to preserve hq-pro's existing default. */
138
+ desiredInstanceType?: string;
139
+ /** Server quote assertion; hq-pro re-prices and refuses a stale amount. */
140
+ quotedNetMonthlyCents?: number;
141
+ quoteCatalogVersion?: string;
135
142
  }
143
+ export interface AgentCreateSizeOption {
144
+ key: "basic" | "power" | "dev";
145
+ productName: string;
146
+ instanceType: string;
147
+ listCents: number;
148
+ default: boolean;
149
+ selectable: boolean;
150
+ netMonthlyCents: number | null;
151
+ deltaCents: number | null;
152
+ unavailableReason: string | null;
153
+ notBilled: boolean;
154
+ lanes: number;
155
+ workers: number;
156
+ }
157
+ export interface AgentCreateOptionsView {
158
+ defaultInstanceType: string;
159
+ catalogVersion: string;
160
+ options: AgentCreateSizeOption[];
161
+ }
162
+ export type QuotedAgentCreateSizeOption = AgentCreateSizeOption & {
163
+ netMonthlyCents: number;
164
+ deltaCents: number;
165
+ };
166
+ /** Read hq-pro's company-specific creation prices and capacities. */
167
+ export declare function getAgentCreateOptions(token: string, companyUid: string, idempotencyKey?: string): Promise<AgentCreateOptionsView>;
168
+ /** Resolve a requested size from the server response, never from local prices. */
169
+ export declare function requireQuotedCreateSize(view: AgentCreateOptionsView, sizeKey: string): QuotedAgentCreateSizeOption;
170
+ /** Resolve hq-pro's default quote while leaving the POST default implicit. */
171
+ export declare function requireDefaultAgentCreateSize(view: AgentCreateOptionsView): QuotedAgentCreateSizeOption;
172
+ /** Human-facing quote summary sourced entirely from hq-pro. */
173
+ export declare function formatAgentCreateSize(option: AgentCreateSizeOption): string;
174
+ /** Ask a TTY user to choose one of hq-pro's currently selectable quotes. */
175
+ export declare function promptForAgentCreateSize(view: AgentCreateOptionsView): Promise<QuotedAgentCreateSizeOption>;
176
+ /** Confirm creation using the server quote, preserving $0 as a real answer. */
177
+ export declare function confirmAgentCreateQuoteOrExit(option: QuotedAgentCreateSizeOption, yes?: boolean): void;
136
178
  export declare function provisionAgent(token: string, input: ProvisionAgentInput): Promise<{
137
179
  uid?: string;
138
180
  slug?: string;
@@ -23,10 +23,11 @@
23
23
  */
24
24
  import chalk from "chalk";
25
25
  import { randomUUID } from "node:crypto";
26
+ import * as readline from "node:readline";
26
27
  import { resolveVaultCredential } from "../utils/resolve-vault-credential.js";
27
28
  import { gateApiKeyCapabilities } from "../utils/api-key-command-gate.js";
28
29
  import { vaultApiFetch, getCompanyUid } from "../utils/vault-api.js";
29
- import { AGENT_PRICE_CENTS, confirmChargeOrExit, parseBillingPayload, surfaceBillingBlocked, } from "../utils/billing-gate.js";
30
+ import { confirmChargeOrExit, formatUsd, parseBillingPayload, surfaceBillingBlocked, } from "../utils/billing-gate.js";
30
31
  /** Reasoning-effort values hq-pro accepts on `runtime-config`. */
31
32
  export const VALID_EFFORTS = new Set([
32
33
  "minimal",
@@ -41,6 +42,8 @@ export const VALID_TIERS = new Set(["default", "priority"]);
41
42
  export const VALID_PROVIDERS = new Set(["codex", "grok", "claude"]);
42
43
  /** Auth modes hq-pro accepts on `POST /v1/agents` (`CodexAuthMode`). */
43
44
  export const VALID_AUTH_MODES = new Set(["subscription", "apiKey"]);
45
+ /** Customer-facing agent size keys served by hq-pro's authoritative catalog. */
46
+ export const VALID_AGENT_SIZE_KEYS = new Set(["basic", "power", "dev"]);
44
47
  /**
45
48
  * Resolve a closed-set option value, or exit(1) with a message naming the
46
49
  * offending input and the legal set.
@@ -137,6 +140,115 @@ export function slugifyAgentName(name) {
137
140
  .replace(/[^a-z0-9]+/g, "-")
138
141
  .replace(/^-+|-+$/g, "");
139
142
  }
143
+ /** Read hq-pro's company-specific creation prices and capacities. */
144
+ export async function getAgentCreateOptions(token, companyUid, idempotencyKey) {
145
+ const raw = await agentsRequest({
146
+ token,
147
+ path: "/v1/agents/provision-options",
148
+ query: {
149
+ companyUid,
150
+ ...(idempotencyKey ? { idempotencyKey } : {}),
151
+ },
152
+ });
153
+ if (!raw || typeof raw !== "object") {
154
+ throw new Error("HQ Pro returned an invalid agent size catalog.");
155
+ }
156
+ const view = raw;
157
+ if (typeof view.catalogVersion !== "string" || !view.catalogVersion.trim()) {
158
+ throw new Error("HQ Pro did not return the catalog version required to protect this price quote.");
159
+ }
160
+ if (typeof view.defaultInstanceType !== "string" ||
161
+ !Array.isArray(view.options)) {
162
+ throw new Error("HQ Pro returned an invalid agent size catalog.");
163
+ }
164
+ return view;
165
+ }
166
+ /** Resolve a requested size from the server response, never from local prices. */
167
+ export function requireQuotedCreateSize(view, sizeKey) {
168
+ const option = view.options.find((candidate) => candidate.key === sizeKey);
169
+ if (!option) {
170
+ throw new Error(`HQ Pro did not return a quote for agent size ${sizeKey}.`);
171
+ }
172
+ if (!option.selectable ||
173
+ typeof option.productName !== "string" ||
174
+ !option.productName.trim() ||
175
+ typeof option.instanceType !== "string" ||
176
+ !option.instanceType.trim() ||
177
+ typeof option.notBilled !== "boolean" ||
178
+ option.netMonthlyCents === null ||
179
+ !Number.isSafeInteger(option.netMonthlyCents) ||
180
+ option.netMonthlyCents < 0 ||
181
+ option.deltaCents !== option.netMonthlyCents ||
182
+ (option.notBilled && option.netMonthlyCents !== 0) ||
183
+ !Number.isSafeInteger(option.lanes) ||
184
+ option.lanes < 0 ||
185
+ !Number.isSafeInteger(option.workers) ||
186
+ option.workers < 0) {
187
+ throw new Error(`Agent size ${option.productName} cannot be priced right now` +
188
+ (option.unavailableReason ? ` (${option.unavailableReason})` : "."));
189
+ }
190
+ return option;
191
+ }
192
+ /** Resolve hq-pro's default quote while leaving the POST default implicit. */
193
+ export function requireDefaultAgentCreateSize(view) {
194
+ const option = view.options.find((candidate) => candidate.default);
195
+ if (!option || option.instanceType !== view.defaultInstanceType) {
196
+ throw new Error("HQ Pro did not return a valid default agent size quote.");
197
+ }
198
+ return requireQuotedCreateSize(view, option.key);
199
+ }
200
+ /** Human-facing quote summary sourced entirely from hq-pro. */
201
+ export function formatAgentCreateSize(option) {
202
+ const cost = option.netMonthlyCents === null
203
+ ? "price unavailable"
204
+ : `${formatUsd(option.netMonthlyCents)}/month`;
205
+ return (`${option.productName}: ${cost}; ` +
206
+ `${option.lanes} lanes; ${option.workers} workers`);
207
+ }
208
+ /** Ask a TTY user to choose one of hq-pro's currently selectable quotes. */
209
+ export async function promptForAgentCreateSize(view) {
210
+ for (const option of view.options) {
211
+ console.log(chalk.dim(formatAgentCreateSize(option) +
212
+ (option.selectable && option.netMonthlyCents !== null
213
+ ? ""
214
+ : ` — unavailable${option.unavailableReason ? ` (${option.unavailableReason})` : ""}`)));
215
+ }
216
+ const selectable = view.options.filter((option) => option.selectable && option.netMonthlyCents !== null);
217
+ if (selectable.length === 0) {
218
+ throw new Error("HQ Pro could not price any agent size right now.");
219
+ }
220
+ const legal = selectable.map((option) => option.key).join(" | ");
221
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
222
+ try {
223
+ const answer = await new Promise((resolve) => rl.question(`Choose agent size (${legal}): `, resolve));
224
+ const key = canonicalizeOptionValue(answer, new Set(selectable.map((option) => option.key)));
225
+ if (!key) {
226
+ throw new Error(`Invalid agent size '${answer}': choose ${legal}.`);
227
+ }
228
+ return requireQuotedCreateSize(view, key);
229
+ }
230
+ finally {
231
+ rl.close();
232
+ }
233
+ }
234
+ /** Confirm creation using the server quote, preserving $0 as a real answer. */
235
+ export function confirmAgentCreateQuoteOrExit(option, yes) {
236
+ if (option.notBilled || option.netMonthlyCents === 0) {
237
+ const message = `${option.productName} is ${formatUsd(0)}/month for this company — ` +
238
+ "there is no per-agent charge.";
239
+ if (!yes) {
240
+ console.error(chalk.yellow(`${message}\nRe-run with --yes to confirm agent provisioning.`));
241
+ process.exit(1);
242
+ }
243
+ console.log(chalk.dim(`${message} Provisioning…`));
244
+ return;
245
+ }
246
+ confirmChargeOrExit({
247
+ resource: "agent",
248
+ unitCents: option.netMonthlyCents,
249
+ yes,
250
+ });
251
+ }
140
252
  export async function provisionAgent(token, input) {
141
253
  return agentsRequest({
142
254
  token,
@@ -387,7 +499,7 @@ export function registerAgentsCommand(program) {
387
499
  agents
388
500
  .command("provision <name>")
389
501
  .alias("new")
390
- .description("Provision a new cloud agent ($100/month requires --yes)")
502
+ .description("Provision a new cloud agent (company-specific monthly price shown before creation)")
391
503
  .option("--company <slug>", "Company slug (resolves to companyUid)")
392
504
  .option("--slug <slug>", "Agent slug (defaults to a slug of <name>)")
393
505
  .option("--provider <provider>", "Runtime: codex | grok | claude (default codex). claude is subscription-only")
@@ -395,7 +507,8 @@ export function registerAgentsCommand(program) {
395
507
  .option("--api-key-env <VAR>", "Env var holding the API key for --auth-mode apiKey (never pass the key as a flag)")
396
508
  .option("--title <title>", "Org-chart job title")
397
509
  .option("--description <text>", "Short description / bio")
398
- .option("--yes", "Confirm the $100/month charge (required to provision)")
510
+ .option("--size <size>", "Agent box size: basic | power | dev (omitted keeps the current default)")
511
+ .option("--yes", "Confirm the quoted monthly cost and provision the agent")
399
512
  .action(async function (name, opts) {
400
513
  try {
401
514
  // Both of these previously fell back to their default on an
@@ -430,12 +543,21 @@ export function registerAgentsCommand(program) {
430
543
  }
431
544
  const token = (await resolveVaultCredential()).token;
432
545
  const companyUid = await getCompanyUid(token, companyOf(this));
433
- // Paid gate: print the monthly cost and require --yes before any call.
434
- confirmChargeOrExit({
435
- resource: "agent",
436
- unitCents: AGENT_PRICE_CENTS,
437
- yes: opts.yes,
438
- });
546
+ const idempotencyKey = `hq-cli-${randomUUID()}`;
547
+ const sizeKey = parseEnumOption(opts.size, VALID_AGENT_SIZE_KEYS, "--size");
548
+ const shouldChooseInteractively = !sizeKey && process.stdin.isTTY === true;
549
+ const createOptions = await getAgentCreateOptions(token, companyUid, idempotencyKey);
550
+ const quotedSize = sizeKey
551
+ ? requireQuotedCreateSize(createOptions, sizeKey)
552
+ : shouldChooseInteractively
553
+ ? await promptForAgentCreateSize(createOptions)
554
+ : requireDefaultAgentCreateSize(createOptions);
555
+ if (!shouldChooseInteractively) {
556
+ console.log(chalk.dim(formatAgentCreateSize(quotedSize)));
557
+ }
558
+ // Every path confirms hq-pro's company-specific quote. Omission still
559
+ // leaves the POST size implicit, preserving the server-side default.
560
+ confirmAgentCreateQuoteOrExit(quotedSize, opts.yes);
439
561
  const slug = opts.slug ?? slugifyAgentName(name);
440
562
  try {
441
563
  const result = await provisionAgent(token, {
@@ -445,9 +567,19 @@ export function registerAgentsCommand(program) {
445
567
  codexAuthMode: authMode,
446
568
  ...(provider ? { provider } : {}),
447
569
  ...(codexApiKey ? { codexApiKey } : {}),
448
- idempotencyKey: `hq-cli-${randomUUID()}`,
570
+ idempotencyKey,
449
571
  ...(opts.title ? { title: opts.title } : {}),
450
572
  ...(opts.description ? { description: opts.description } : {}),
573
+ // Leave hq-pro's current default implicit so a Team setup agent
574
+ // still goes through its atomic entitlement claim. Every current
575
+ // CLI path nevertheless echoes the displayed amount and catalog
576
+ // snapshot, so omitted --size cannot silently accept a price that
577
+ // changed between confirmation and creation.
578
+ ...(quotedSize.default
579
+ ? {}
580
+ : { desiredInstanceType: quotedSize.instanceType }),
581
+ quotedNetMonthlyCents: quotedSize.netMonthlyCents,
582
+ quoteCatalogVersion: createOptions.catalogVersion,
451
583
  });
452
584
  const uid = typeof result.uid === "string" ? result.uid : slug;
453
585
  console.log(chalk.green(`Provisioning started for agent "${name}".`));
@@ -4,6 +4,7 @@
4
4
  import chalk from "chalk";
5
5
  import { ControlPlaneDbClient } from "../lib/db/control-plane.js";
6
6
  import { DEFAULT_VAULT_API_URL, ensureCognitoToken, } from "../utils/cognito-session.js";
7
+ import { isPlanGateError } from "../utils/plan-gate-error.js";
7
8
  import { getCompanyUid } from "../utils/vault-api.js";
8
9
  const defaultDeps = () => ({
9
10
  async resolveCompany(slug) {
@@ -59,6 +60,9 @@ export function registerDbProvisionCommand(db, depsFactory = defaultDeps) {
59
60
  console.log(`idempotent: ${result.idempotent ? "yes" : "no"}`);
60
61
  }
61
62
  catch (error) {
63
+ // Let main.ts render the shared, non-stacktrace plan-gate message.
64
+ if (isPlanGateError(error))
65
+ throw error;
62
66
  const msg = error instanceof Error ? error.message : "Unknown error";
63
67
  const status = error.status;
64
68
  if (status === 402 || /PLAN_REQUIRED|Team plan|\$500/i.test(msg)) {
@@ -1,7 +1,4 @@
1
- /**
2
- * Control-plane client for remote vault DB (US-009).
3
- * Injectable fetch for tests — never logs response bodies that might hold secrets.
4
- */
1
+ import { planGateErrorFromPayload } from "../../utils/plan-gate-error.js";
5
2
  function assertNoPostgresUrl(label, text) {
6
3
  if (/postgres:\/\//i.test(text) || /postgresql:\/\//i.test(text)) {
7
4
  throw new Error(`${label}: control plane returned a connection string (refusing to surface)`);
@@ -30,21 +27,22 @@ export class ControlPlaneDbClient {
30
27
  assertNoPostgresUrl("provision", text);
31
28
  if (!res.ok) {
32
29
  let msg = `provision failed (${res.status})`;
33
- let code;
30
+ let payload;
34
31
  try {
35
- const j = JSON.parse(text);
36
- if (j.error)
37
- msg = j.error;
38
- if (j.code)
39
- code = j.code;
32
+ payload = JSON.parse(text);
33
+ if (typeof payload.error === "string")
34
+ msg = payload.error;
40
35
  }
41
36
  catch {
42
37
  /* keep */
43
38
  }
39
+ const planGate = planGateErrorFromPayload(res.status, payload);
40
+ if (planGate)
41
+ throw planGate;
44
42
  const err = new Error(msg);
45
43
  err.status = res.status;
46
- if (code)
47
- err.code = code;
44
+ if (typeof payload?.code === "string")
45
+ err.code = payload.code;
48
46
  throw err;
49
47
  }
50
48
  return JSON.parse(text);
@@ -31,5 +31,24 @@ export declare function projectStatus(root: string, project: string, prdPath: st
31
31
  export declare function basename(file: string): string;
32
32
  export declare function isHidden(name: string): boolean;
33
33
  export declare function mtime(file: string): number;
34
+ /**
35
+ * Sort key for newest-first ordering: the file's modification time in
36
+ * nanoseconds. Stat the file exactly once, before sorting — never from inside a
37
+ * comparator, where a throw aborts `Array.sort` and an mtime that changes
38
+ * mid-sort makes the comparator inconsistent.
39
+ *
40
+ * Returns `undefined` when the entry a directory scan just listed can no longer
41
+ * be resolved: it was removed between the `readdirSync` snapshot and this stat
42
+ * (a time-of-check-to-time-of-use race in a hot, multi-writer directory), or it
43
+ * is a symlink whose target is missing (`statSync` follows symlinks). Callers
44
+ * drop such entries — mirroring how `immediateEntries` and `readJson` already
45
+ * tolerate a vanished file — so the render never crashes on a benign race.
46
+ *
47
+ * Any other stat failure (EACCES, EIO, …) is a genuine fault and is rethrown
48
+ * with the offending file's BASENAME attached, never its absolute path, which
49
+ * must not reach error reporting such as Sentry. The original errno `code` is
50
+ * preserved so upstream error classification is unaffected.
51
+ */
52
+ export declare function sortKeyMtimeNs(file: string): bigint | undefined;
34
53
  export declare function tempDirectory(prefix: string): string;
35
54
  //# sourceMappingURL=shared.d.ts.map
@@ -120,6 +120,38 @@ export function mtime(file) { try {
120
120
  catch {
121
121
  return 0;
122
122
  } }
123
+ /**
124
+ * Sort key for newest-first ordering: the file's modification time in
125
+ * nanoseconds. Stat the file exactly once, before sorting — never from inside a
126
+ * comparator, where a throw aborts `Array.sort` and an mtime that changes
127
+ * mid-sort makes the comparator inconsistent.
128
+ *
129
+ * Returns `undefined` when the entry a directory scan just listed can no longer
130
+ * be resolved: it was removed between the `readdirSync` snapshot and this stat
131
+ * (a time-of-check-to-time-of-use race in a hot, multi-writer directory), or it
132
+ * is a symlink whose target is missing (`statSync` follows symlinks). Callers
133
+ * drop such entries — mirroring how `immediateEntries` and `readJson` already
134
+ * tolerate a vanished file — so the render never crashes on a benign race.
135
+ *
136
+ * Any other stat failure (EACCES, EIO, …) is a genuine fault and is rethrown
137
+ * with the offending file's BASENAME attached, never its absolute path, which
138
+ * must not reach error reporting such as Sentry. The original errno `code` is
139
+ * preserved so upstream error classification is unaffected.
140
+ */
141
+ export function sortKeyMtimeNs(file) {
142
+ try {
143
+ return fs.statSync(file, { bigint: true }).mtimeNs;
144
+ }
145
+ catch (error) {
146
+ const code = error?.code;
147
+ if (code === "ENOENT" || code === "ENOTDIR")
148
+ return undefined;
149
+ const wrapped = new Error(`failed to stat ${basename(file)} (${code ?? "unknown error"})`);
150
+ if (code !== undefined)
151
+ wrapped.code = code;
152
+ throw wrapped;
153
+ }
154
+ }
123
155
  // Kept exported for primitive tests and consumers that need a temp base without
124
156
  // relying on an application-specific fixture location.
125
157
  export function tempDirectory(prefix) { return fs.mkdtempSync(path.join(os.tmpdir(), prefix)); }
@@ -1,19 +1,40 @@
1
1
  import * as fs from "fs";
2
- import { at, log, readJson, sanitize, timestamp, write } from "./shared.js";
2
+ import { at, basename, log, readJson, sanitize, sortKeyMtimeNs, timestamp, write } from "./shared.js";
3
3
  export function renderThreads(context, args = []) {
4
4
  const mode = args[0] ?? "--index";
5
5
  const directory = at(context.root, "workspace/threads");
6
6
  fs.mkdirSync(directory, { recursive: true });
7
7
  // Match `ls -t`: newest filesystem modification time first. `updated_at` is
8
8
  // rendered metadata only, and must not influence the order.
9
+ //
10
+ // Stat every listed file exactly once, BEFORE sorting (a Schwartzian
11
+ // transform), never from inside the comparator. workspace/threads is a hot,
12
+ // multi-writer directory (HQ Sync reconciliation, concurrent agent sessions,
13
+ // and `archive-old-threads` renaming T-*.json out of it), so an entry
14
+ // readdirSync just snapshotted can vanish before it is stat'ed. Statting
15
+ // inside the comparator turned that time-of-check-to-time-of-use window — and
16
+ // any dangling symlink — into an ENOENT that aborted the whole command before
17
+ // either file was written. Precompute the key, drop entries that no longer
18
+ // resolve (as `readJson` already drops unreadable files), then compare only
19
+ // precomputed keys so the comparator can neither throw nor be inconsistent.
20
+ const skipped = [];
9
21
  const files = fs.readdirSync(directory)
10
22
  .filter((name) => /^T-.*\.json$/.test(name) && !name.endsWith(".changeset.json"))
11
23
  .map((name) => `${directory}/${name}`)
12
- .sort((a, b) => {
13
- const aTime = fs.statSync(a, { bigint: true }).mtimeNs;
14
- const bTime = fs.statSync(b, { bigint: true }).mtimeNs;
15
- return bTime > aTime ? 1 : bTime < aTime ? -1 : 0;
16
- });
24
+ .map((file) => ({ file, key: sortKeyMtimeNs(file) }))
25
+ .filter((entry) => {
26
+ if (entry.key === undefined) {
27
+ skipped.push(basename(entry.file));
28
+ return false;
29
+ }
30
+ return true;
31
+ })
32
+ .sort((a, b) => (b.key > a.key ? 1 : b.key < a.key ? -1 : 0))
33
+ .map((entry) => entry.file);
34
+ if (skipped.length > 0) {
35
+ const shown = skipped.slice(0, 10).join(", ");
36
+ log(context, `rebuild-threads-index: skipped ${skipped.length} thread file(s) that vanished during the scan: ${shown}${skipped.length > 10 ? ", …" : ""}`);
37
+ }
17
38
  const rows = files.flatMap((file) => {
18
39
  const data = readJson(file);
19
40
  if (!data)
package/dist/main.js CHANGED
@@ -70,6 +70,7 @@ import { isEpipe } from "./utils/epipe.js";
70
70
  import { isInterceptedProcessExit } from "./utils/intercepted-process-exit.js";
71
71
  import { isAuthError } from "./utils/auth-error.js";
72
72
  import { isCompanySelectionError } from "./utils/company-selection-error.js";
73
+ import { formatPlanGateError, isPlanGateError, } from "./utils/plan-gate-error.js";
73
74
  import { refreshVersionCache, staleAgainstCachedLatest, } from "./utils/version-check.js";
74
75
  import { enforceVersionGate, shouldSkipGate, } from "./utils/version-gate.js";
75
76
  import { autoUpdateAndReexec } from "./utils/self-update.js";
@@ -359,6 +360,13 @@ export function handleTopLevelError(err, deps = defaultTopLevelErrorDependencies
359
360
  deps.stderr.write(`hq: ${err.message}\n`);
360
361
  deps.setExitCode(1);
361
362
  }
363
+ else if (isPlanGateError(err)) {
364
+ // hq-pro's plan denials are expected product limits, never a CLI crash.
365
+ // The shared vault client has already decoded and typed the small safe
366
+ // envelope; do not expose its raw body or retry a rejected creation.
367
+ deps.stderr.write(`hq: ${formatPlanGateError(err)}\n`);
368
+ deps.setExitCode(1);
369
+ }
362
370
  else if (isExpectedUserError(err)) {
363
371
  // HQ-CLI-6: a user-facing, client-caused error (a non-owner running
364
372
  // `hq integrations approve`, a stale queueId, a bad --args, an unknown
@@ -0,0 +1,30 @@
1
+ /**
2
+ * A deliberate subscription denial returned by hq-pro. Keeping its structured
3
+ * fields on a typed error lets the CLI boundary render one safe, consistent
4
+ * message instead of every command parsing and printing a server response.
5
+ */
6
+ export type PlanGateCode = "PLAN_LIMIT_EXCEEDED" | "PLAN_REQUIRED";
7
+ export interface PlanGateDetails {
8
+ resource?: string;
9
+ used?: number;
10
+ limit?: number;
11
+ upgradeUrl?: string;
12
+ }
13
+ export declare class PlanGateError extends Error {
14
+ readonly code: PlanGateCode;
15
+ readonly details: PlanGateDetails;
16
+ constructor(code: PlanGateCode, details: PlanGateDetails);
17
+ }
18
+ export declare function isPlanGateError(err: unknown): err is PlanGateError;
19
+ export declare function formatPlanGateError(err: PlanGateError): string;
20
+ /** Decode an already-read JSON envelope from either shared HTTP client. */
21
+ export declare function planGateErrorFromPayload(status: number, body: unknown): PlanGateError | null;
22
+ /**
23
+ * Decode only the two deliberate plan-gate envelopes. Malformed, unrelated,
24
+ * or non-402 responses retain their existing command-specific handling.
25
+ */
26
+ export declare function planGateErrorFromResponse(response: Response): Promise<{
27
+ error: PlanGateError | null;
28
+ response: Response;
29
+ }>;
30
+ //# sourceMappingURL=plan-gate-error.d.ts.map
@@ -0,0 +1,93 @@
1
+ export class PlanGateError extends Error {
2
+ code;
3
+ details;
4
+ constructor(code, details) {
5
+ // Commands with their own expected-error boundary commonly print
6
+ // `err.message`. Keeping the friendly copy here means those boundaries
7
+ // retain the same plan-gate voice as main.ts without per-command handling.
8
+ super(formatPlanGateDetails(code, details));
9
+ this.code = code;
10
+ this.details = details;
11
+ this.name = "PlanGateError";
12
+ }
13
+ }
14
+ export function isPlanGateError(err) {
15
+ return err instanceof PlanGateError;
16
+ }
17
+ /** Plain, stable CLI copy for plan-gated resource creation. */
18
+ function formatPlanGateDetails(code, details) {
19
+ const existingResourcesNote = "Existing resources will keep working.";
20
+ if (code === "PLAN_LIMIT_EXCEEDED" &&
21
+ typeof details.resource === "string" &&
22
+ typeof details.used === "number" &&
23
+ typeof details.limit === "number") {
24
+ const upgrade = typeof details.upgradeUrl === "string"
25
+ ? `Upgrade to HQ Team ($500/mo) to remove limits: ${details.upgradeUrl}`
26
+ : "Upgrade to HQ Team ($500/mo) to remove limits.";
27
+ return [
28
+ `Free plan limit reached: ${details.resource} ${details.used}/${details.limit} used.`,
29
+ upgrade,
30
+ existingResourcesNote,
31
+ ].join("\n");
32
+ }
33
+ const upgrade = typeof details.upgradeUrl === "string"
34
+ ? `Upgrade to HQ Team ($500/mo) to remove limits: ${details.upgradeUrl}`
35
+ : "Upgrade to HQ Team ($500/mo) to remove limits.";
36
+ return [
37
+ "HQ Team plan required for this feature.",
38
+ upgrade,
39
+ existingResourcesNote,
40
+ ].join("\n");
41
+ }
42
+ export function formatPlanGateError(err) {
43
+ return formatPlanGateDetails(err.code, err.details);
44
+ }
45
+ /** Decode an already-read JSON envelope from either shared HTTP client. */
46
+ export function planGateErrorFromPayload(status, body) {
47
+ if (status !== 402 || !body || typeof body !== "object")
48
+ return null;
49
+ const payload = body;
50
+ if (payload.code !== "PLAN_LIMIT_EXCEEDED" && payload.code !== "PLAN_REQUIRED") {
51
+ return null;
52
+ }
53
+ return new PlanGateError(payload.code, {
54
+ ...(typeof payload.resource === "string" ? { resource: payload.resource } : {}),
55
+ ...(typeof payload.used === "number" ? { used: payload.used } : {}),
56
+ ...(typeof payload.limit === "number" ? { limit: payload.limit } : {}),
57
+ ...(typeof payload.upgradeUrl === "string" ? { upgradeUrl: payload.upgradeUrl } : {}),
58
+ });
59
+ }
60
+ /**
61
+ * Decode only the two deliberate plan-gate envelopes. Malformed, unrelated,
62
+ * or non-402 responses retain their existing command-specific handling.
63
+ */
64
+ export async function planGateErrorFromResponse(response) {
65
+ // Leave every non-402 response untouched for its command-specific handler.
66
+ // A non-plan 402 is buffered and re-wrapped below so its useful error
67
+ // payload remains available to the existing command-specific handler.
68
+ if (response.status !== 402)
69
+ return { error: null, response };
70
+ let buffer;
71
+ try {
72
+ buffer = await response.arrayBuffer();
73
+ }
74
+ catch {
75
+ return { error: null, response };
76
+ }
77
+ let body = null;
78
+ try {
79
+ body = JSON.parse(new TextDecoder().decode(buffer));
80
+ }
81
+ catch {
82
+ // Preserve the original non-JSON 402 body below for its existing handler.
83
+ }
84
+ return {
85
+ error: planGateErrorFromPayload(response.status, body),
86
+ response: new Response(buffer, {
87
+ status: response.status,
88
+ statusText: response.statusText,
89
+ headers: response.headers,
90
+ }),
91
+ };
92
+ }
93
+ //# sourceMappingURL=plan-gate-error.js.map
@@ -4,6 +4,7 @@ import { AuthError } from './auth-error.js';
4
4
  import { CompanySelectionError } from './company-selection-error.js';
5
5
  import { recordPlanLimitStatus } from '../lib/plan-limit-nag.js';
6
6
  import { networkTransportErrorCode } from './network-transport-error.js';
7
+ import { planGateErrorFromResponse } from './plan-gate-error.js';
7
8
  /**
8
9
  * Identity / company resolution lookups must never hang forever. These small
9
10
  * GETs run BEFORE a command does its real work (e.g. `hq secrets env` resolves
@@ -183,7 +184,13 @@ export async function vaultApiFetch(opts) {
183
184
  level: "warning",
184
185
  data: { url: safeUrl, status: response.status },
185
186
  });
186
- return response;
187
+ // A plan gate is a normal, user-actionable denial. Decode it at the one
188
+ // shared HTTP seam so every resource-creation command reaches main.ts's
189
+ // friendly renderer without duplicating response parsing or retrying.
190
+ const planGate = await planGateErrorFromResponse(response);
191
+ if (planGate.error)
192
+ throw planGate.error;
193
+ return planGate.response;
187
194
  }
188
195
  return peekPlanLimitStatus(response);
189
196
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.100.0",
3
+ "version": "5.101.1",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -29,7 +29,7 @@
29
29
  "dependencies": {
30
30
  "@aws-sdk/client-iot-data-plane": "^3.1096.0",
31
31
  "@aws-sdk/client-s3": "^3.1049.0",
32
- "@indigoai-us/hq-cloud": "^6.14.50",
32
+ "@indigoai-us/hq-cloud": "~6.15.0",
33
33
  "@indigoai-us/hq-onboarding": "^0.1.0",
34
34
  "@sentry/node": "^10.49.0",
35
35
  "@tobilu/qmd": "2.5.3",