@indigoai-us/hq-cli 5.345.159 → 5.345.160

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 (54) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/dist/command-catalog.generated.d.ts +4 -4
  3. package/dist/command-catalog.generated.js +4 -4
  4. package/dist/commands/access.d.ts +6 -0
  5. package/dist/commands/access.js +18 -1
  6. package/dist/commands/acl-lock.d.ts +41 -0
  7. package/dist/commands/acl-lock.js +28 -0
  8. package/dist/commands/agent-enroll.d.ts +2 -0
  9. package/dist/commands/agent-enroll.js +9 -4
  10. package/dist/commands/agents.js +5 -11
  11. package/dist/commands/billing-sponsor.d.ts +4 -4
  12. package/dist/commands/billing-sponsor.js +11 -11
  13. package/dist/commands/billing-usage.d.ts +22 -8
  14. package/dist/commands/billing-usage.js +57 -22
  15. package/dist/commands/billing.d.ts +7 -7
  16. package/dist/commands/billing.js +13 -13
  17. package/dist/commands/cloud-provision.js +2 -2
  18. package/dist/commands/cloud.js +7 -7
  19. package/dist/commands/db-remote-deps.js +12 -1
  20. package/dist/commands/files.js +19 -7
  21. package/dist/commands/meetings.d.ts +4 -4
  22. package/dist/commands/meetings.js +17 -8
  23. package/dist/commands/skill.js +6 -6
  24. package/dist/commands/whoami.js +1 -1
  25. package/dist/hook-engine-path.js +13 -2
  26. package/dist/hq-cloud-sync-runner/bin/sync-runner.js +982 -426
  27. package/dist/hq-cloud-sync-runner/package.json +1 -1
  28. package/dist/lib/billing/plan-limit-refusals.d.ts +13 -3
  29. package/dist/lib/billing/plan-limit-refusals.js +26 -3
  30. package/dist/lib/billing/plan-limit-upgrade-url.d.ts +8 -0
  31. package/dist/lib/billing/plan-limit-upgrade-url.js +18 -0
  32. package/dist/lib/billing/plan-lock.d.ts +22 -6
  33. package/dist/lib/billing/plan-lock.js +58 -30
  34. package/dist/lib/billing/usage-cost.d.ts +24 -2
  35. package/dist/lib/billing/usage-cost.js +103 -3
  36. package/dist/lib/billing/usage-only.d.ts +37 -1
  37. package/dist/lib/billing/usage-only.js +89 -4
  38. package/dist/lib/billing/usage-pause.d.ts +27 -0
  39. package/dist/lib/billing/usage-pause.js +50 -0
  40. package/dist/lib/db/control-plane.d.ts +5 -0
  41. package/dist/lib/db/control-plane.js +10 -0
  42. package/dist/lib/flag-registry-worker-source.generated.d.ts +1 -1
  43. package/dist/lib/flag-registry-worker-source.generated.js +1 -1
  44. package/dist/lib/hook-engine/resolve.d.ts +14 -0
  45. package/dist/lib/hook-engine/resolve.js +55 -1
  46. package/dist/lib/plan-limit-nag.d.ts +0 -16
  47. package/dist/lib/plan-limit-nag.js +17 -18
  48. package/dist/main.d.ts +0 -2
  49. package/dist/main.js +19 -57
  50. package/dist/utils/plan-gate-error.d.ts +30 -14
  51. package/dist/utils/plan-gate-error.js +60 -46
  52. package/dist/utils/team-upgrade.d.ts +19 -6
  53. package/dist/utils/team-upgrade.js +37 -39
  54. package/package.json +8 -8
@@ -1,3 +1,4 @@
1
+ import { type SpawnSyncReturns } from 'node:child_process';
1
2
  export declare const HOOK_ENGINE_REPAIR = "npm install -g @indigoai-us/hq-cli --include=optional";
2
3
  export declare const HOOK_ENGINE_REPAIR_PIN = "hq core hook-engine-path --repair";
3
4
  export interface EngineOptions {
@@ -23,6 +24,19 @@ export declare function pinnedHookEngineMatches(engine: EnginePackage): boolean;
23
24
  export declare function findHookEngine(options?: EngineOptions): EnginePackage | null;
24
25
  /** Never truncate or replace a running Windows executable. */
25
26
  export declare function installHookEngine(options?: EngineOptions): string | null;
27
+ /** master-hook.sh reads this one-line file with shell builtins to find the
28
+ * pinned engine without starting Node (US-021 bridge). It always names the
29
+ * engine of the CLI that wrote it, so a CLI update retargets it. */
30
+ export declare function hookEnginePointerPath(options?: EngineOptions): string;
31
+ /** Best effort and atomic: a missing pointer only keeps the bridge off. */
32
+ export declare function writeHookEnginePointer(pinned: string, options?: EngineOptions): void;
33
+ export declare function removeHookEnginePointer(options?: EngineOptions): void;
34
+ export interface VerifyOptions {
35
+ spawn?: (command: string, args: readonly string[]) => Pick<SpawnSyncReturns<string>, 'status' | 'stdout' | 'error'>;
36
+ }
37
+ /** Run the engine's own `--self-check` and read its report. Returns the
38
+ * problem as text, or null when the binary answers as an hq-hook engine. */
39
+ export declare function verifyHookEngine(binary: string, options?: VerifyOptions): string | null;
26
40
  /** Call only after settings no longer reference these old versions. Windows
27
41
  * refuses unlink of a mapped executable; a busy version stays for a later pass.
28
42
  * POSIX permits unlink while running, so retain those versions conservatively.
@@ -1,5 +1,6 @@
1
1
  import { createRequire } from 'node:module';
2
- import { cpSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, readlinkSync, renameSync, rmSync, rmdirSync, unlinkSync } from 'node:fs';
2
+ import { spawnSync } from 'node:child_process';
3
+ import { cpSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, readlinkSync, renameSync, rmSync, rmdirSync, unlinkSync, writeFileSync } from 'node:fs';
3
4
  import { homedir } from 'node:os';
4
5
  import { dirname, join } from 'node:path';
5
6
  const require = createRequire(import.meta.url);
@@ -104,8 +105,61 @@ export function installHookEngine(options = {}) {
104
105
  if (!pinnedHookEngineMatches(engine)) {
105
106
  throw new Error(`Hook engine ${engine.version} differs from its pinned copy; close HQ sessions and repair with: ${HOOK_ENGINE_REPAIR_PIN}`);
106
107
  }
108
+ writeHookEnginePointer(engine.pinned, options);
107
109
  return engine.pinned;
108
110
  }
111
+ /** master-hook.sh reads this one-line file with shell builtins to find the
112
+ * pinned engine without starting Node (US-021 bridge). It always names the
113
+ * engine of the CLI that wrote it, so a CLI update retargets it. */
114
+ export function hookEnginePointerPath(options = {}) {
115
+ return join(options.home ?? homedir(), '.hq', 'bin', 'hq-hook', 'current');
116
+ }
117
+ /** Best effort and atomic: a missing pointer only keeps the bridge off. */
118
+ export function writeHookEnginePointer(pinned, options = {}) {
119
+ const pointer = hookEnginePointerPath(options);
120
+ try {
121
+ mkdirSync(dirname(pointer), { recursive: true });
122
+ const stage = `${pointer}.${process.pid}.tmp`;
123
+ writeFileSync(stage, `${pinned}\n`);
124
+ renameSync(stage, pointer);
125
+ }
126
+ catch {
127
+ // The hook path falls back to bash when the pointer is absent.
128
+ }
129
+ }
130
+ export function removeHookEnginePointer(options = {}) {
131
+ try {
132
+ unlinkSync(hookEnginePointerPath(options));
133
+ }
134
+ catch { /* already absent */ }
135
+ }
136
+ /** Run the engine's own `--self-check` and read its report. Returns the
137
+ * problem as text, or null when the binary answers as an hq-hook engine. */
138
+ export function verifyHookEngine(binary, options = {}) {
139
+ const spawn = options.spawn ?? ((command, args) => spawnSync(command, args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 10_000 }));
140
+ let result;
141
+ try {
142
+ result = spawn(binary, ['--self-check']);
143
+ }
144
+ catch (error) {
145
+ return `hook engine self-check could not start: ${error instanceof Error ? error.message : String(error)}`;
146
+ }
147
+ if (result.error)
148
+ return `hook engine self-check could not start: ${result.error.message}`;
149
+ let report;
150
+ try {
151
+ report = JSON.parse(String(result.stdout ?? '').trim().split('\n').pop() ?? '');
152
+ }
153
+ catch {
154
+ return `hook engine self-check printed no report (exit ${result.status ?? 'signal'})`;
155
+ }
156
+ if (report.engine !== 'hq-hook' || typeof report.version !== 'string' || typeof report.bridge_schema !== 'number') {
157
+ return 'hook engine self-check report is not from hq-hook';
158
+ }
159
+ if (result.status !== 0 || report.ok !== true)
160
+ return `hook engine self-check failed: ${typeof report.error === 'string' ? report.error : `exit ${result.status ?? 'signal'}`}`;
161
+ return null;
162
+ }
109
163
  /** Call only after settings no longer reference these old versions. Windows
110
164
  * refuses unlink of a mapped executable; a busy version stays for a later pass.
111
165
  * POSIX permits unlink while running, so retain those versions conservatively.
@@ -79,22 +79,6 @@ export declare function recordPlanLimitStatus(body: unknown, opts?: {
79
79
  * never claims the workspace has stopped working.
80
80
  */
81
81
  export declare function buildOverLine(overEntries: Array<[string, PlanLimitEntry]>, upgradeUrl: string): string;
82
- /**
83
- * My Telemetry is an Individual-plan feature on a personal scope (owner
84
- * decision 9 / us-044-individual-plan-design.md §11). When hq-pro refuses it,
85
- * the CLI prints this notice INSTEAD of telemetry output.
86
- *
87
- * It lives beside the nag copy so plan wording stays in one file, but it is
88
- * NOT part of the nag model: no persistence, no throttle, no episode state. It
89
- * is rendered once, on the spot, by whoever received the refusal. The refusal
90
- * is a plan fact, so it neither counts toward the nag bands nor is silenced by
91
- * the nag cadence.
92
- *
93
- * Copy names the plan and its price only — no Outposts, no bot pricing (owner
94
- * decisions 10 and 11).
95
- */
96
- export declare const INDIVIDUAL_PLAN_NAME = "Individual";
97
- export declare const INDIVIDUAL_PRICE_LABEL = "$50/mo";
98
82
  /**
99
83
  * The notice a personal feature refusal prints. `upgradeUrl` is the server's
100
84
  * own checkout target; an older hq-pro sends none, and the line then names the
@@ -367,16 +367,18 @@ export function buildOverLine(overEntries, upgradeUrl) {
367
367
  const facts = overEntries
368
368
  .map(([key, entry]) => `${PLAN_LIMIT_DIMENSION_LABELS[key] ?? key} ${entry.used} of ${entry.limit}`)
369
369
  .join(", ");
370
- return (`⚠ HQ Starter: ${facts}. New files and new secrets are paused. ` +
371
- `Upgrade: ${upgradeUrl}`);
370
+ // US-024: names no plan and offers no upgrade; the link is the Billing page.
371
+ return (`⚠ Over this account's limits: ${facts}. New files and new secrets are paused. ` +
372
+ `Billing: ${upgradeUrl}`);
372
373
  }
373
374
  // ---------------------------------------------------------------------------
374
- // Individual-plan feature notice (US-054)
375
+ // Personal feature notice (US-054, copy per US-024)
375
376
  // ---------------------------------------------------------------------------
376
377
  /**
377
- * My Telemetry is an Individual-plan feature on a personal scope (owner
378
- * decision 9 / us-044-individual-plan-design.md §11). When hq-pro refuses it,
379
- * the CLI prints this notice INSTEAD of telemetry output.
378
+ * My Telemetry is a paid feature on a personal scope (owner decision 9 /
379
+ * us-044-individual-plan-design.md §11). When hq-pro refuses it, the CLI
380
+ * prints this notice INSTEAD of telemetry output. The flat personal plan is
381
+ * closed (US-024), so the notice names no plan or price.
380
382
  *
381
383
  * It lives beside the nag copy so plan wording stays in one file, but it is
382
384
  * NOT part of the nag model: no persistence, no throttle, no episode state. It
@@ -384,11 +386,8 @@ export function buildOverLine(overEntries, upgradeUrl) {
384
386
  * is a plan fact, so it neither counts toward the nag bands nor is silenced by
385
387
  * the nag cadence.
386
388
  *
387
- * Copy names the plan and its price only — no Outposts, no bot pricing (owner
388
- * decisions 10 and 11).
389
+ * No Outposts and no bot pricing in the copy (owner decisions 10 and 11).
389
390
  */
390
- export const INDIVIDUAL_PLAN_NAME = "Individual";
391
- export const INDIVIDUAL_PRICE_LABEL = "$50/mo";
392
391
  /** Human names for the features a personal scope can be refused. */
393
392
  const PERSONAL_FEATURE_LABELS = {
394
393
  telemetry: "My Telemetry",
@@ -403,11 +402,11 @@ export function renderPersonalPlanFeatureNotice(feature, upgradeUrl, opts = {})
403
402
  // A person on usage pricing has no Individual plan to buy (US-012).
404
403
  if (isUsageOnly(opts.pricingPlan))
405
404
  return usageOnlyPersonalFeatureNotice(label);
406
- const headline = `${label} is included with the HQ ${INDIVIDUAL_PLAN_NAME} plan (${INDIVIDUAL_PRICE_LABEL}).`;
405
+ const headline = `${label} is not included on your personal account's current billing.`;
407
406
  const action = typeof upgradeUrl === "string" && upgradeUrl.trim().length > 0
408
- ? `Upgrade: ${upgradeUrl.trim()}`
409
- : "Upgrade in your HQ billing settings.";
410
- return `${headline} Your personal account is on the free tier. ${action}`;
407
+ ? `Billing: ${upgradeUrl.trim()}`
408
+ : "See your HQ billing settings.";
409
+ return `${headline} ${action}`;
411
410
  }
412
411
  /** `"{resource}:{band}"` — the persisted key, matching the server's own. */
413
412
  function crossingKey(crossing) {
@@ -429,9 +428,9 @@ export function renderThresholdNotice(crossing, upgradeUrl) {
429
428
  const name = dimensionName(crossing.resource);
430
429
  const counts = `${crossing.used} of ${crossing.limit}`;
431
430
  const headline = crossing.band === "over"
432
- ? `HQ Starter: you are past your ${name} limit (${counts}).`
433
- : `HQ Starter: you have reached 80% of your ${name} (${counts}).`;
434
- return `${headline} Upgrade: ${upgradeUrl}`;
431
+ ? `You are past your ${name} limit (${counts}).`
432
+ : `You have reached 80% of your ${name} limit (${counts}).`;
433
+ return `${headline} Billing: ${upgradeUrl}`;
435
434
  }
436
435
  /**
437
436
  * Print every crossing this machine has not already printed for this episode.
@@ -521,7 +520,7 @@ export function emitPlanLimitNag(opts = {}) {
521
520
  if (worst === null)
522
521
  return;
523
522
  warningShownThisSession = true;
524
- const line = `⚠ HQ Starter: ${formatEntryLine(worst.key, worst.entry)}. Add headroom before you hit the cap: ${resolvedUpgradeUrl}`;
523
+ const line = `⚠ Nearing this account's limits: ${formatEntryLine(worst.key, worst.entry)}. Make room before you hit the cap. Billing: ${resolvedUpgradeUrl}`;
525
524
  write(chalk.yellow(line) + "\n");
526
525
  if (isCliLimitResourceKind(worst.key)) {
527
526
  queueCliLimitNoticeExposure(worst.key, "advisory");
package/dist/main.d.ts CHANGED
@@ -9,7 +9,6 @@ export { handleStreamError } from "./stream-error-boundary.js";
9
9
  export type { StreamErrorDependencies } from "./stream-error-boundary.js";
10
10
  import { type PlanGateError } from "./utils/plan-gate-error.js";
11
11
  import { type PricingPlan } from "./lib/billing/usage-only.js";
12
- import { upgradeToTeam } from "./utils/team-upgrade.js";
13
12
  /**
14
13
  * Total time one CLI invocation may await foreground network work. This spans
15
14
  * the hard version decision, pre-action observability, and release-health
@@ -44,7 +43,6 @@ export type TopLevelErrorDependencies = {
44
43
  argv?: readonly string[];
45
44
  ask?: (question: string) => Promise<string>;
46
45
  openUrl?: (url: string) => Promise<void>;
47
- upgradeToTeam?: typeof upgradeToTeam;
48
46
  /**
49
47
  * Usage pricing (US-012): resolve the account's pricing plan for a plan gate
50
48
  * whose 402 body did not carry one. Absent = use the body only.
package/dist/main.js CHANGED
@@ -40,9 +40,8 @@ import { browserLoginAbandonedMessage } from "./utils/browser-login-abandoned.js
40
40
  import { callbackPortBusyMessage, DEFAULT_CALLBACK_PORT } from "./utils/callback-port-busy.js";
41
41
  import { DEFAULT_COGNITO, isMachineIdentity, loadMachineCachedTokens, } from "./utils/cognito-session.js";
42
42
  import { isCompanySelectionError } from "./utils/company-selection-error.js";
43
- import { canOfferTeamUpgrade, formatPlanGateError, offerTeamUpgrade, offerUpgradeLink, planGateErrorFromVaultError, withPricingPlan, } from "./utils/plan-gate-error.js";
43
+ import { canOfferTeamUpgrade, formatPlanGateError, offerUpgradeLink, planGateActionUrl, planGateErrorFromVaultError, withPricingPlan, } from "./utils/plan-gate-error.js";
44
44
  import { isUsageOnly, resolveCompanyPricingPlan, resolvePersonPricingPlan, } from "./lib/billing/usage-only.js";
45
- import { upgradeToTeam } from "./utils/team-upgrade.js";
46
45
  import { printCachedVersionNudge, printVersionAndNudge, refreshVersionCache, staleAgainstCachedLatest, } from "./utils/version-check.js";
47
46
  import { enforceVersionGate, getVersionGatePhase, isLocalDependencyInstall, resolveRunningInstall, shouldSkipGate, } from "./utils/version-gate.js";
48
47
  import { autoUpdateAndReexec } from "./utils/self-update.js";
@@ -654,8 +653,8 @@ export async function handleTopLevelError(err, deps = defaultTopLevelErrorDepend
654
653
  }
655
654
  else if (planGate) {
656
655
  // Plan denials are expected product limits, never a CLI crash. The
657
- // server URL is always shown. Company refusals that the Workforce
658
- // checkout satisfies retain the direct Stripe checkout action.
656
+ // server URL is always shown. The flat-plan checkout is closed, so the
657
+ // only offer is to open the server's billing link.
659
658
  //
660
659
  // A plan-limit refusal (a Starter hard stop) always prints its reason,
661
660
  // usage and fix options, even non-interactively: the user can fix it
@@ -671,60 +670,23 @@ export async function handleTopLevelError(err, deps = defaultTopLevelErrorDepend
671
670
  ? queueCliLimitNoticeExposure(planGate.details.resource, "refusal", planGate.details.companyUid)
672
671
  : null;
673
672
  deps.setExitCode(1);
674
- // A usage-only account has no plan to buy: no checkout or upgrade offer.
675
- if (interactive && planGate.details.upgradeUrl && !usageOnlyGate) {
676
- const companyUid = planGate.details.companyUid;
677
- const isCompanyWorkforceCheckout = typeof companyUid === "string" &&
678
- /^cmp_[A-Za-z0-9_-]+$/.test(companyUid) &&
679
- (planGate.code === "PLAN_REQUIRED" ||
680
- planGate.code === "MEETING_PLAN_REQUIRED" ||
681
- planGate.code === "BILLING_REQUIRED");
682
- if (isCompanyWorkforceCheckout && companyUid) {
683
- try {
684
- const upgraded = await offerTeamUpgrade(async () => {
685
- await (deps.upgradeToTeam ?? upgradeToTeam)({ companyUid });
686
- }, {
687
- interactive: true,
688
- argv: deps.argv,
689
- ask: deps.ask,
690
- });
691
- if (upgraded)
692
- queueCliLimitNoticeEngagement(promptExposure);
693
- }
694
- catch (upgradeErr) {
695
- const message = upgradeErr instanceof Error ? upgradeErr.message : String(upgradeErr);
696
- deps.stderr.write(`hq: ${message}\n`);
697
- try {
698
- const opened = await offerUpgradeLink(planGate.details.upgradeUrl, {
699
- interactive: true,
700
- argv: deps.argv,
701
- ask: deps.ask,
702
- openUrl: deps.openUrl,
703
- });
704
- if (opened)
705
- queueCliLimitNoticeEngagement(promptExposure);
706
- }
707
- catch (linkErr) {
708
- const linkMessage = linkErr instanceof Error ? linkErr.message : String(linkErr);
709
- deps.stderr.write(`hq: ${linkMessage}\n`);
710
- }
711
- }
673
+ // A usage-only account has no plan to buy: no upgrade offer. A usage
674
+ // pause offers its own Billing link.
675
+ const actionUrl = planGateActionUrl(planGate);
676
+ if (interactive && actionUrl && (!usageOnlyGate || planGate.details.usagePause)) {
677
+ try {
678
+ const opened = await offerUpgradeLink(actionUrl, {
679
+ interactive: true,
680
+ argv: deps.argv,
681
+ ask: deps.ask,
682
+ openUrl: deps.openUrl,
683
+ });
684
+ if (opened)
685
+ queueCliLimitNoticeEngagement(promptExposure);
712
686
  }
713
- else {
714
- try {
715
- const opened = await offerUpgradeLink(planGate.details.upgradeUrl, {
716
- interactive: true,
717
- argv: deps.argv,
718
- ask: deps.ask,
719
- openUrl: deps.openUrl,
720
- });
721
- if (opened)
722
- queueCliLimitNoticeEngagement(promptExposure);
723
- }
724
- catch (upgradeErr) {
725
- const message = upgradeErr instanceof Error ? upgradeErr.message : String(upgradeErr);
726
- deps.stderr.write(`hq: ${message}\n`);
727
- }
687
+ catch (upgradeErr) {
688
+ const message = upgradeErr instanceof Error ? upgradeErr.message : String(upgradeErr);
689
+ deps.stderr.write(`hq: ${message}\n`);
728
690
  }
729
691
  }
730
692
  }
@@ -1,7 +1,7 @@
1
1
  import { type PlanLimitFreeMonth } from "../lib/billing/free-month.js";
2
- import { type PricingPlan } from "../lib/billing/usage-only.js";
2
+ import { type PricingPlan, type UsagePause } from "../lib/billing/usage-only.js";
3
3
  export type PlanGateCode = "PLAN_LIMIT_EXCEEDED" | "PLAN_REQUIRED" | "MEETING_PLAN_REQUIRED"
4
- /** A PERSONAL scope asked for a feature the paid Individual plan includes. */
4
+ /** A PERSONAL scope asked for a feature its current plan does not include. */
5
5
  | "PERSONAL_PLAN_REQUIRED"
6
6
  /** External agent enrollment was denied by the company plan. */
7
7
  | "BILLING_REQUIRED";
@@ -29,19 +29,41 @@ export interface PlanGateDetails {
29
29
  /** Hard-stop 402: the free-month offer, present only when it stands. */
30
30
  freeMonth?: PlanLimitFreeMonth;
31
31
  /**
32
- * Usage pricing (US-012): from the 402 body, or attached by the caller from
33
- * /membership/me, usage-limits or the billing summary. `"usage-only"`
34
- * renders no free month, Starter, Workforce or Individual copy and no plan
35
- * upgrade link. Absent keeps the legacy copy.
32
+ * Usage pricing (US-012): from the 402 body (`billingState` first, then the
33
+ * legacy `pricingPlan`), or attached by the caller from the membership row,
34
+ * usage-limits or the billing summary. `"usage-only"` renders no retired
35
+ * plan copy and no plan upgrade link. Absent (unknown) renders neutral copy
36
+ * that names no plan.
36
37
  */
37
38
  pricingPlan?: PricingPlan;
39
+ /**
40
+ * The server's usage pause (`usagePause` on the 402 body, hq-pro
41
+ * src/billing/usage-pause.ts). When present its message is the whole
42
+ * refusal and its `actionUrl` the only link.
43
+ */
44
+ usagePause?: UsagePause;
45
+ /**
46
+ * Hard-stop 402: true when HQ Workforce is closed to the company (hq-pro
47
+ * `billing.legacy-plans-closed`, owner decision 2026-10-19). `upgradeUrl`
48
+ * is then the usage-pricing switch page; the refusal offers the switch and
49
+ * never the free Workforce month.
50
+ */
51
+ legacyPlansClosed?: boolean;
38
52
  }
39
53
  /** Copy a plan gate with the caller's pricing plan attached. */
40
54
  export declare function withPricingPlan(err: PlanGateError, pricingPlan: PricingPlan | null | undefined): PlanGateError;
41
- /** One renderer for server-selected upgrade links across CLI plan refusals. */
55
+ /**
56
+ * One renderer for server-selected billing links across CLI refusals. The
57
+ * link is labelled `Billing:`, or `Switch:` when the server said it is the
58
+ * usage-pricing switch page. It never says "Upgrade": the flat plans that
59
+ * word led to are closed.
60
+ */
42
61
  export declare function formatUpgradeRefusal(reason: string, upgradeUrl: string | undefined, options?: {
43
62
  urlOnly?: boolean;
63
+ legacyPlansClosed?: boolean;
44
64
  }): string;
65
+ /** The link a refusal offers: the usage pause's action, else the server's upgrade URL. */
66
+ export declare function planGateActionUrl(err: PlanGateError): string | undefined;
45
67
  /** Recover a company UID only from the server's UID-keyed billing callback. */
46
68
  export declare function companyUidFromPlanUpgradeUrl(value: unknown): string | undefined;
47
69
  export declare class PlanGateError extends Error {
@@ -61,14 +83,8 @@ export interface UpgradeLinkOfferDependencies {
61
83
  argv?: readonly string[];
62
84
  interactive?: boolean;
63
85
  }
64
- /** Ask before opening the exact URL returned by the server. */
86
+ /** Ask before opening the exact billing URL returned by the server. */
65
87
  export declare function offerUpgradeLink(url: string, deps?: UpgradeLinkOfferDependencies): Promise<boolean>;
66
- /** Legacy checkout callback retained for callers outside the shared refusal paths. */
67
- export declare function offerTeamUpgrade(onConfirm: () => Promise<void>, deps?: {
68
- ask?: (question: string) => Promise<string>;
69
- argv?: readonly string[];
70
- interactive?: boolean;
71
- }): Promise<boolean>;
72
88
  /** Decode an already-read JSON envelope from either shared HTTP client. */
73
89
  export declare function planGateErrorFromPayload(status: number, body: unknown, companyUid?: string): PlanGateError | null;
74
90
  /**
@@ -6,9 +6,9 @@
6
6
  import { PLAN_LIMIT_DIMENSION_LABELS, renderPersonalPlanFeatureNotice, } from "../lib/plan-limit-nag.js";
7
7
  import { withCliLimitEntrySurface } from "../lib/billing/plan-limit-upgrade-url.js";
8
8
  import { parseFreeMonth, } from "../lib/billing/free-month.js";
9
- import { isUsageOnly, pricingPlanFromBody, usageOnlyCardRequiredLines, USAGE_PRICING_POINTER, } from "../lib/billing/usage-only.js";
10
- /** Legacy plan names and prices a usage-only account must never be shown. */
11
- const LEGACY_PLAN_COPY = /Starter|Workforce|Individual|\$500|\$50\b|free month|upgrade/i;
9
+ import { isUsageOnly, pricingPlanFromBody, usageOnlyCardRequiredLines, usagePauseFromBody, USAGE_PRICING_POINTER, } from "../lib/billing/usage-only.js";
10
+ /** Retired plan names, prices and offers a usage-only account must never be shown. */
11
+ const LEGACY_PLAN_COPY = /Starter|Workforce|Individual|\$500|\$50\b|free month|upgrade|plan limit/i;
12
12
  /** Copy a plan gate with the caller's pricing plan attached. */
13
13
  export function withPricingPlan(err, pricingPlan) {
14
14
  if (!pricingPlan || err.details.pricingPlan === pricingPlan)
@@ -100,23 +100,28 @@ function planLimitRefusalText(head, details) {
100
100
  `${formatResourceAmount(row.resource, row.used)} of ` +
101
101
  `${formatResourceAmount(row.resource, row.limit)} used.`);
102
102
  }
103
+ // The free month led to the flat-plan checkout, which is closed; it is
104
+ // never offered. The way out besides trimming is the switch to usage
105
+ // pricing, offered only when the server said the company is on an earlier
106
+ // plan (or that the flat plans are closed to it).
103
107
  const fixes = [];
104
- const freeMonthUrl = safeUpgradeUrl(details.freeMonth?.url);
105
- if (details.freeMonth?.eligible && freeMonthUrl) {
106
- fixes.push(`Start your free month — add a card, first month free: ${freeMonthUrl}`);
107
- }
108
108
  for (const [resource, target] of Object.entries(details.fixOptions ?? {})) {
109
- fixes.push(`${resourceFixAction(resource)} to make room (your plan allows ` +
109
+ fixes.push(`${resourceFixAction(resource)} to make room (the limit is ` +
110
110
  `${formatResourceAmount(resource, target)}).`);
111
111
  }
112
112
  if (fixes.length > 0) {
113
- fixes.push("Upgrade your plan.");
113
+ fixes.push(details.legacyPlansClosed || details.pricingPlan === "current"
114
+ ? "Switch to usage pricing: no plan fee, and these limits stop applying."
115
+ : "Open the company's Billing page.");
114
116
  lines.push(" Ways to sort it:");
115
117
  fixes.forEach((fix, index) => lines.push(` ${index + 1}. ${fix}`));
116
118
  }
117
119
  return lines.join("\n");
118
120
  }
119
121
  function planGateReason(code, details) {
122
+ // The server's usage pause is the whole answer: its message, nothing added.
123
+ if (details.usagePause)
124
+ return details.usagePause.message;
120
125
  if (isUsageOnly(details.pricingPlan))
121
126
  return usageOnlyPlanGateReason(code, details);
122
127
  if (code === "PLAN_LIMIT_EXCEEDED") {
@@ -125,9 +130,9 @@ function planGateReason(code, details) {
125
130
  : typeof details.resource === "string" &&
126
131
  typeof details.used === "number" &&
127
132
  typeof details.limit === "number"
128
- ? `Starter plan limit reached: ${details.resource} ${details.used}/${details.limit} used.`
133
+ ? `Limit reached: ${details.resource} ${details.used}/${details.limit} used.`
129
134
  : details.resources?.length || details.fixOptions || details.freeMonth
130
- ? "Your plan limit has been reached."
135
+ ? "This account has reached a limit."
131
136
  : undefined;
132
137
  if (head)
133
138
  return planLimitRefusalText(head, details);
@@ -138,14 +143,19 @@ function planGateReason(code, details) {
138
143
  return renderPersonalPlanFeatureNotice(details.feature ?? "this feature", null);
139
144
  }
140
145
  if (code === "MEETING_PLAN_REQUIRED") {
141
- return "HQ Workforce plan required to invite a meeting bot.";
146
+ return "Meeting notetaker bots are not available on this company's current billing.";
142
147
  }
143
148
  if (code === "BILLING_REQUIRED") {
144
- return "Upgrade the company plan before enrolling an external agent.";
149
+ return "Set up billing for this company before enrolling an external agent.";
145
150
  }
146
- return "HQ Workforce plan required for this feature.";
151
+ return "This feature is not available on this company's current billing.";
147
152
  }
148
- /** One renderer for server-selected upgrade links across CLI plan refusals. */
153
+ /**
154
+ * One renderer for server-selected billing links across CLI refusals. The
155
+ * link is labelled `Billing:`, or `Switch:` when the server said it is the
156
+ * usage-pricing switch page. It never says "Upgrade": the flat plans that
157
+ * word led to are closed.
158
+ */
149
159
  export function formatUpgradeRefusal(reason, upgradeUrl, options = {}) {
150
160
  const safeUrl = safeUpgradeUrl(upgradeUrl);
151
161
  const cliLimitUrl = safeUrl ? withCliLimitEntrySurface(safeUrl) : undefined;
@@ -153,7 +163,11 @@ export function formatUpgradeRefusal(reason, upgradeUrl, options = {}) {
153
163
  return cliLimitUrl;
154
164
  if (!cliLimitUrl)
155
165
  return reason;
156
- return `${reason}\nUpgrade: ${cliLimitUrl}`;
166
+ return `${reason}\n${options.legacyPlansClosed ? "Switch" : "Billing"}: ${cliLimitUrl}`;
167
+ }
168
+ /** The link a refusal offers: the usage pause's action, else the server's upgrade URL. */
169
+ export function planGateActionUrl(err) {
170
+ return err.details.usagePause?.actionUrl ?? err.details.upgradeUrl;
157
171
  }
158
172
  function safeUpgradeUrl(value) {
159
173
  if (typeof value !== "string" ||
@@ -187,6 +201,9 @@ export function companyUidFromPlanUpgradeUrl(value) {
187
201
  return match?.[1];
188
202
  }
189
203
  function formatPlanGateErrorText(code, details) {
204
+ if (details.usagePause) {
205
+ return formatUpgradeRefusal(details.usagePause.message, details.usagePause.actionUrl);
206
+ }
190
207
  if (isUsageOnly(details.pricingPlan))
191
208
  return planGateReason(code, details);
192
209
  return formatUpgradeRefusal(planGateReason(code, details), details.upgradeUrl);
@@ -206,17 +223,21 @@ export function isPlanGateError(err) {
206
223
  }
207
224
  /** Plain, stable CLI copy for plan-gated resource creation. */
208
225
  export function formatPlanGateError(err, options = {}) {
226
+ // A usage pause: the server's message and its Billing link, nothing else.
227
+ if (err.details.usagePause) {
228
+ return formatUpgradeRefusal(err.details.usagePause.message, err.details.usagePause.actionUrl, { urlOnly: options.urlOnly });
229
+ }
209
230
  // A usage-only account has no plan to upgrade to: no upgrade link.
210
231
  if (isUsageOnly(err.details.pricingPlan))
211
232
  return planGateReason(err.code, err.details);
212
- return formatUpgradeRefusal(planGateReason(err.code, err.details), err.details.upgradeUrl, options);
233
+ return formatUpgradeRefusal(planGateReason(err.code, err.details), err.details.upgradeUrl, { ...options, legacyPlansClosed: err.details.legacyPlansClosed === true });
213
234
  }
214
235
  export function canOfferTeamUpgrade(argv = process.argv) {
215
236
  return Boolean(process.stdin.isTTY && process.stderr.isTTY) &&
216
237
  !process.env.CI &&
217
238
  !argv.includes("--json");
218
239
  }
219
- /** Ask before opening the exact URL returned by the server. */
240
+ /** Ask before opening the exact billing URL returned by the server. */
220
241
  export async function offerUpgradeLink(url, deps = {}) {
221
242
  const safeUrl = safeUpgradeUrl(url);
222
243
  if (!safeUrl)
@@ -226,13 +247,13 @@ export async function offerUpgradeLink(url, deps = {}) {
226
247
  return false;
227
248
  let answer;
228
249
  if (deps.ask) {
229
- answer = await deps.ask("Open upgrade link now? [y/N] ");
250
+ answer = await deps.ask("Open the billing link now? [y/N] ");
230
251
  }
231
252
  else {
232
253
  const { createInterface } = await import("node:readline/promises");
233
254
  const readline = createInterface({ input: process.stdin, output: process.stderr });
234
255
  try {
235
- answer = await readline.question("Open upgrade link now? [y/N] ");
256
+ answer = await readline.question("Open the billing link now? [y/N] ");
236
257
  }
237
258
  finally {
238
259
  readline.close();
@@ -249,38 +270,29 @@ export async function offerUpgradeLink(url, deps = {}) {
249
270
  }
250
271
  return true;
251
272
  }
252
- /** Legacy checkout callback retained for callers outside the shared refusal paths. */
253
- export async function offerTeamUpgrade(onConfirm, deps = {}) {
254
- if (!(deps.interactive ?? canOfferTeamUpgrade(deps.argv)))
255
- return false;
256
- if (deps.ask) {
257
- const answer = await deps.ask("Upgrade now? (opens checkout) [y/N] ");
258
- if (!/^y(?:es)?$/i.test(answer.trim()))
259
- return false;
260
- await onConfirm();
261
- return true;
262
- }
263
- const { createInterface } = await import("node:readline/promises");
264
- const readline = createInterface({ input: process.stdin, output: process.stderr });
265
- try {
266
- const answer = await readline.question("Upgrade now? (opens checkout) [y/N] ");
267
- if (!/^y(?:es)?$/i.test(answer.trim()))
268
- return false;
269
- await onConfirm();
270
- return true;
271
- }
272
- finally {
273
- readline.close();
274
- }
275
- }
276
273
  /** Decode an already-read JSON envelope from either shared HTTP client. */
277
274
  export function planGateErrorFromPayload(status, body, companyUid) {
278
275
  if (status !== 402 || !body || typeof body !== "object")
279
276
  return null;
280
277
  const payload = body;
281
- const decoded = decodePlanGatePayload(payload, companyUid);
278
+ const usagePause = usagePauseFromBody(payload);
279
+ // A usage pause in a body this decoder does not otherwise know is still a
280
+ // refusal: the server's message is the whole answer.
281
+ const decoded = decodePlanGatePayload(payload, companyUid) ??
282
+ (usagePause
283
+ ? new PlanGateError("PLAN_LIMIT_EXCEEDED", {
284
+ reason: usagePause.message,
285
+ ...(usagePause.actionUrl ? { upgradeUrl: usagePause.actionUrl } : {}),
286
+ ...(companyUid ? { companyUid } : {}),
287
+ })
288
+ : null);
289
+ if (!decoded)
290
+ return null;
282
291
  const pricingPlan = pricingPlanFromBody(payload);
283
- return decoded && pricingPlan ? withPricingPlan(decoded, pricingPlan) : decoded;
292
+ const withPlan = pricingPlan ? withPricingPlan(decoded, pricingPlan) : decoded;
293
+ return usagePause
294
+ ? new PlanGateError(withPlan.code, { ...withPlan.details, usagePause })
295
+ : withPlan;
284
296
  }
285
297
  function decodePlanGatePayload(payload, companyUid) {
286
298
  // US-054: the personal feature denial. hq-pro identifies it with
@@ -392,6 +404,7 @@ function planLimitRefusalFromPayload(payload, upgradeUrl, companyUid) {
392
404
  : undefined;
393
405
  const resources = parseResources(payload.resources);
394
406
  const fixOptions = parseFixOptions(payload.fixOptions);
407
+ const legacyPlansClosed = payload.legacyPlansClosed === true;
395
408
  const parsedFreeMonth = parseFreeMonth(payload.freeMonth);
396
409
  const freeMonthUrl = safeUpgradeUrl(parsedFreeMonth?.url);
397
410
  const freeMonth = parsedFreeMonth
@@ -416,6 +429,7 @@ function planLimitRefusalFromPayload(payload, upgradeUrl, companyUid) {
416
429
  ...(upgradeUrl ? { upgradeUrl } : {}),
417
430
  ...(reason ? { reason } : {}),
418
431
  ...(companyUid ? { companyUid } : {}),
432
+ ...(legacyPlansClosed ? { legacyPlansClosed } : {}),
419
433
  });
420
434
  }
421
435
  /** Wire codes that mark a vault/sync error as a plan-limit refusal. */
@@ -1,6 +1,15 @@
1
- export interface TeamUpgradeOptions {
2
- company?: string;
3
- companyUid?: string;
1
+ /**
2
+ * `hq billing upgrade` for a company not on usage pricing (US-024).
3
+ *
4
+ * The flat plans are closed to new subscribers (hq-pro
5
+ * `billing.legacy-plans-closed`, owner decision 2026-10-19), so there is no
6
+ * checkout to open. The command points at the console's switch to usage
7
+ * pricing for the company. It POSTs nothing: the switch itself, and its owner
8
+ * gate, live on that page.
9
+ */
10
+ export interface UsageSwitchOptions {
11
+ /** The company slug, for the company's switch page. */
12
+ companySlug?: string;
4
13
  noBrowser?: boolean;
5
14
  /** Testable override for environments where browser availability is known. */
6
15
  headless?: boolean;
@@ -9,9 +18,13 @@ export interface TeamUpgradeOptions {
9
18
  write?: (text: string) => void;
10
19
  error?: (text: string) => void;
11
20
  }
21
+ /** The lines printed before the switch page link. Names no plan. */
22
+ export declare function usageSwitchLines(url: string): string[];
23
+ /** The fallback sentence when no console origin can be derived. */
24
+ export declare const USAGE_SWITCH_NO_URL_MESSAGE = "Switch this company to usage pricing from its Billing page in the HQ Console.";
12
25
  /**
13
- * Start the owner-gated HQ Workforce Checkout flow. Stripe collects payment details
14
- * in its hosted browser page; the CLI never receives card data.
26
+ * Print (and open, unless told not to) the company's usage-pricing switch
27
+ * page. Returns the URL, or null when none could be built.
15
28
  */
16
- export declare function upgradeToTeam(opts?: TeamUpgradeOptions): Promise<string>;
29
+ export declare function openUsageSwitch(opts?: UsageSwitchOptions): Promise<string | null>;
17
30
  //# sourceMappingURL=team-upgrade.d.ts.map