@indigoai-us/hq-cli 5.122.1 → 5.123.0

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 (36) hide show
  1. package/CHANGELOG.md +53 -0
  2. package/README.md +46 -0
  3. package/dist/command-catalog.generated.d.ts +6 -3
  4. package/dist/command-catalog.generated.js +7 -3
  5. package/dist/commands/bot.d.ts +3 -0
  6. package/dist/commands/bot.js +14 -2
  7. package/dist/commands/dm.d.ts +54 -1
  8. package/dist/commands/dm.js +204 -8
  9. package/dist/commands/whoami.d.ts +2 -2
  10. package/dist/commands/whoami.js +18 -19
  11. package/dist/lib/billing/plan-lock.d.ts +64 -69
  12. package/dist/lib/billing/plan-lock.js +230 -161
  13. package/dist/lib/bot/api.d.ts +4 -1
  14. package/dist/lib/bot/api.js +20 -2
  15. package/dist/lib/bot/daemon.d.ts +11 -1
  16. package/dist/lib/bot/daemon.js +44 -2
  17. package/dist/lib/bot/run.d.ts +19 -1
  18. package/dist/lib/bot/run.js +51 -6
  19. package/dist/lib/bot/status.d.ts +5 -0
  20. package/dist/lib/doctor/checks/bots.d.ts +10 -0
  21. package/dist/lib/doctor/checks/bots.js +149 -0
  22. package/dist/lib/doctor/registry.js +4 -0
  23. package/dist/lib/mesh/api.js +96 -28
  24. package/dist/lib/mesh/live/daemon/board-refresh.d.ts +4 -1
  25. package/dist/lib/mesh/live/daemon/board-refresh.js +71 -30
  26. package/dist/lib/mesh/live/daemon/credentials.d.ts +6 -1
  27. package/dist/lib/mesh/live/daemon/credentials.js +20 -4
  28. package/dist/lib/mesh/live/daemon/doctor.d.ts +2 -0
  29. package/dist/lib/mesh/live/daemon/doctor.js +12 -0
  30. package/dist/lib/mesh/live/daemon/install.d.ts +7 -0
  31. package/dist/lib/mesh/live/daemon/install.js +17 -1
  32. package/dist/lib/mesh/live/daemon/run.js +6 -1
  33. package/dist/lib/mesh/live/daemon/state.d.ts +3 -0
  34. package/dist/utils/cli-telemetry.d.ts +21 -0
  35. package/dist/utils/cli-telemetry.js +77 -1
  36. package/package.json +3 -2
@@ -1,59 +1,54 @@
1
1
  /**
2
- * `plan-lock` (starter-plan-hard-limits / US-011, rewritten for US-036) — the
3
- * CLI-side read + render of a workspace's Starter plan-limit state.
2
+ * CLI-side read + render of a workspace's Starter plan-limit nag
3
+ * (starter-plan-hard-limits / US-011, rewritten for US-036, then US-040).
4
4
  *
5
5
  * Starter (free) workspaces are capped on members, integrations, secrets and
6
6
  * agents (deployments and storage nag but never arm a stop). Going over does
7
7
  * NOT stop the workspace working (US-033 owner decision 12): everything that
8
8
  * exists keeps working, and the only two things that pause are adding new
9
9
  * files to the vault and adding new secrets (owner decision 13). Every string
10
- * this module renders is a nag, never a claim that HQ has stopped. The state
11
- * is NOT decided here — hq-pro is the single source of truth and ships the
12
- * answer on `GET /membership/me` as a per-company `planLock` object. This
13
- * module only reads that field and renders it.
10
+ * this module renders is a nag, never a claim that HQ has stopped.
11
+ *
12
+ * US-040: hq-pro no longer attaches `planLock` on `/membership/me` or
13
+ * `/v1/billing/usage-limits`. This module reads the nag payload from
14
+ * usage-limits (`planLimits` resource rows, top-level `freeMonth`,
15
+ * `upgradeUrl`) and never a lock object.
14
16
  *
15
17
  * Copy rule (US-036): no string a customer reads may contain "locked" or
16
18
  * "read-only". `plan-lock.test.ts` scans every rendered string for both.
17
19
  *
18
- * Member counts are decoration, never a second opinion: the count comes from
19
- * `GET /v1/billing/usage-limits` on a best-effort basis and its absence only
20
- * removes the "n of 5" detail from the notice. An absent field is UNKNOWN, so
21
- * nothing here ever infers a lock (or an unlock) from missing data
22
- * (hq-absent-field-never-means-constraining-value).
20
+ * An absent field is UNKNOWN, so nothing here ever infers over-limit from
21
+ * missing data (hq-absent-field-never-means-constraining-value).
23
22
  */
24
23
  import chalk from "chalk";
25
24
  import { vaultApiFetch } from "../../utils/vault-api.js";
26
25
  /**
27
- * Mirror of hq-pro's `PlanLockReason`. Owner decision 7 (2026-09-17) fixes the
28
- * nagging set at these four: `deployments` and `storageBytes` are nag-only and
29
- * never appear here. An unrecognised reason is dropped by `parsePlanLock`, so a
30
- * server that adds a fifth dimension renders as the generic line rather than as
31
- * a false claim about members.
26
+ * Dimensions the bind notice names with a remedy. Owner decision 7 (2026-09-17)
27
+ * fixes the nagging set at these four: `deployments` and `storageBytes` are
28
+ * nag-only and never appear here.
32
29
  */
33
- export const PLAN_LOCK_REASONS = [
30
+ export const PLAN_LIMIT_REASONS = [
34
31
  "users",
35
32
  "integrations",
36
33
  "secrets",
37
34
  "agents",
38
35
  ];
39
- function isPlanLockReason(value) {
40
- return PLAN_LOCK_REASONS.includes(value);
41
- }
42
- /** Starter member cap quoted when the server did not send `removeMembersTo`. */
36
+ /** @deprecated US-040 — alias of {@link PLAN_LIMIT_REASONS}. */
37
+ export const PLAN_LOCK_REASONS = PLAN_LIMIT_REASONS;
38
+ /** Starter member cap quoted when the server did not send a users.limit. */
43
39
  export const STARTER_MEMBER_TARGET = 5;
44
40
  /**
45
- * Starter integration cap quoted when the server did not send
46
- * `disconnectIntegrationsTo`. One: Starter includes a single integration
47
- * (US-020 owner decision 11, 2026-09-17 — it was zero before that).
41
+ * Starter integration cap quoted when the server did not send a limit.
42
+ * One: Starter includes a single integration (US-020 owner decision 11).
48
43
  */
49
44
  export const STARTER_INTEGRATION_TARGET = 1;
50
- /** Starter secret cap quoted when the server did not send `removeSecretsTo`. */
45
+ /** Starter secret cap quoted when the server did not send a secrets.limit. */
51
46
  export const STARTER_SECRET_TARGET = 10;
52
- /** Starter agent cap quoted when the server did not send `deprovisionAgentsTo`. */
47
+ /** Starter agent cap quoted when the server did not send an agents.limit. */
53
48
  export const STARTER_AGENT_TARGET = 0;
54
49
  /** Upgrade destination quoted when the server did not send one. */
55
50
  export const DEFAULT_UPGRADE_URL = "https://hq.computer/billing";
56
- /** The paid plan the lock wall sends owners to. Copy lives in ONE place. */
51
+ /** The paid plan the nag sends owners to. Copy lives in ONE place. */
57
52
  export const WORKFORCE_PLAN_LABEL = "HQ Workforce ($500/mo)";
58
53
  function finiteNumber(value) {
59
54
  return typeof value === "number" && Number.isFinite(value) ? value : null;
@@ -64,58 +59,65 @@ function asRecord(value) {
64
59
  }
65
60
  return value;
66
61
  }
62
+ /** Published Starter cap for a dimension, quoted when the server sends no limit. */
63
+ function starterCap(reason) {
64
+ switch (reason) {
65
+ case "users":
66
+ return STARTER_MEMBER_TARGET;
67
+ case "integrations":
68
+ return STARTER_INTEGRATION_TARGET;
69
+ case "secrets":
70
+ return STARTER_SECRET_TARGET;
71
+ case "agents":
72
+ return STARTER_AGENT_TARGET;
73
+ }
74
+ }
67
75
  /**
68
- * Defensively parse a `planLock` payload. Anything malformed returns null —
69
- * the caller then behaves exactly as if the server had sent nothing, which is
70
- * "unknown", not "locked".
76
+ * Parse one usage row. An absent or null `limit` is UNKNOWN, never zero: the
77
+ * renderer quotes the published Starter cap instead of "7 of 0 members"
78
+ * (hq-absent-field-never-means-constraining-value).
71
79
  */
72
- export function parsePlanLock(value) {
80
+ function parseUsageRow(reason, value) {
73
81
  const rec = asRecord(value);
74
82
  if (!rec)
75
83
  return null;
76
- if (typeof rec.locked !== "boolean")
84
+ const used = finiteNumber(rec.used);
85
+ if (used === null)
77
86
  return null;
78
- const reasons = [];
79
- if (Array.isArray(rec.reasons)) {
80
- for (const reason of rec.reasons) {
81
- if (isPlanLockReason(reason))
82
- reasons.push(reason);
83
- }
84
- }
85
- const fix = asRecord(rec.fixOptions);
86
- const removeMembersTo = finiteNumber(fix?.removeMembersTo) ?? STARTER_MEMBER_TARGET;
87
- const disconnectIntegrationsTo = finiteNumber(fix?.disconnectIntegrationsTo);
88
- const removeSecretsTo = finiteNumber(fix?.removeSecretsTo);
89
- const deprovisionAgentsTo = finiteNumber(fix?.deprovisionAgentsTo);
90
- const fixOptions = {
91
- removeMembersTo,
92
- disconnectIntegrations: fix?.disconnectIntegrations === true,
93
- };
94
- // Absent stays absent: an omitted remedy target is UNKNOWN, and the renderer
95
- // quotes the published Starter cap rather than inventing a server answer.
96
- if (disconnectIntegrationsTo !== null) {
97
- fixOptions.disconnectIntegrationsTo = disconnectIntegrationsTo;
98
- }
99
- if (removeSecretsTo !== null)
100
- fixOptions.removeSecretsTo = removeSecretsTo;
101
- if (deprovisionAgentsTo !== null) {
102
- fixOptions.deprovisionAgentsTo = deprovisionAgentsTo;
103
- }
87
+ const limit = finiteNumber(rec.limit);
88
+ return { used, limit: limit ?? starterCap(reason) };
89
+ }
90
+ function rowIsOver(value) {
91
+ const rec = asRecord(value);
92
+ if (!rec)
93
+ return false;
94
+ if (rec.over === true)
95
+ return true;
96
+ const used = finiteNumber(rec.used);
97
+ const limit = finiteNumber(rec.limit);
98
+ return used !== null && limit !== null && used > limit;
99
+ }
100
+ export function parseFreeMonth(value) {
101
+ const rec = asRecord(value);
102
+ if (!rec)
103
+ return undefined;
104
+ if (rec.eligible !== true)
105
+ return undefined;
106
+ const url = typeof rec.url === "string" && rec.url.trim().length > 0
107
+ ? rec.url.trim()
108
+ : undefined;
109
+ const trialDays = finiteNumber(rec.trialDays) ?? undefined;
104
110
  return {
105
- locked: rec.locked,
106
- reasons,
107
- upgradeUrl: typeof rec.upgradeUrl === "string" && rec.upgradeUrl.trim().length > 0
108
- ? rec.upgradeUrl.trim()
109
- : DEFAULT_UPGRADE_URL,
110
- fixOptions,
111
+ eligible: true,
112
+ ...(trialDays !== undefined ? { trialDays } : {}),
113
+ ...(url ? { url } : {}),
111
114
  };
112
115
  }
113
116
  /**
114
117
  * Pick the membership row for `companySlug` (or `cmp_…` uid) out of a decoded
115
- * `/membership/me` body and return its parsed lock. No matching row — or no
116
- * `planLock` on it — is UNKNOWN, so this returns null rather than guessing.
118
+ * `/membership/me` body. No matching row is UNKNOWN. Does not read `planLock`.
117
119
  */
118
- export function selectPlanLock(body, companyRef) {
120
+ export function selectCompanyMembership(body, companyRef) {
119
121
  const rec = asRecord(body);
120
122
  const rows = Array.isArray(rec?.memberships) ? rec.memberships : [];
121
123
  const ref = companyRef.trim().toLowerCase();
@@ -129,11 +131,7 @@ export function selectPlanLock(body, companyRef) {
129
131
  const uid = typeof row.companyUid === "string" ? row.companyUid.toLowerCase() : "";
130
132
  if (slug !== ref && uid !== ref)
131
133
  continue;
132
- const lock = parsePlanLock(row.planLock);
133
- if (!lock)
134
- return null;
135
134
  return {
136
- lock,
137
135
  companyUid: typeof row.companyUid === "string" ? row.companyUid : undefined,
138
136
  };
139
137
  }
@@ -159,12 +157,68 @@ export function selectMemberUsage(body) {
159
157
  : STARTER_MEMBER_TARGET;
160
158
  return { used: users.used, limit };
161
159
  }
160
+ function selectUpgradeUrl(body) {
161
+ const rec = asRecord(body);
162
+ const raw = rec?.upgradeUrl;
163
+ if (typeof raw === "string" && raw.trim().length > 0)
164
+ return raw.trim();
165
+ return DEFAULT_UPGRADE_URL;
166
+ }
162
167
  /**
163
- * Read the live lock for one company: `planLock` from `/membership/me`, plus a
164
- * best-effort member count for the notice. Returns null when the server did not
165
- * answer with a lock for this company — callers must then say nothing.
168
+ * Read over-limit dimensions from a usage-limits body. `planLimits` nested
169
+ * map is preferred when present; otherwise the top-level resource rows.
166
170
  */
167
- export async function fetchPlanLockStatus(token, companyRef, opts = {}) {
171
+ export function selectOverReasons(body) {
172
+ const rec = asRecord(body);
173
+ if (!rec)
174
+ return [];
175
+ const nested = asRecord(rec.planLimits);
176
+ const source = nested ?? rec;
177
+ const reasons = [];
178
+ for (const key of PLAN_LIMIT_REASONS) {
179
+ if (rowIsOver(source[key]))
180
+ reasons.push(key);
181
+ }
182
+ return reasons;
183
+ }
184
+ export function selectUsages(body) {
185
+ const rec = asRecord(body);
186
+ if (!rec)
187
+ return {};
188
+ const nested = asRecord(rec.planLimits);
189
+ const source = nested ?? rec;
190
+ const out = {};
191
+ for (const key of PLAN_LIMIT_REASONS) {
192
+ const row = parseUsageRow(key, source[key]);
193
+ if (row)
194
+ out[key] = row;
195
+ }
196
+ return out;
197
+ }
198
+ function statusFromUsage(companySlug, companyUid, body) {
199
+ const usages = selectUsages(body);
200
+ const reasons = selectOverReasons(body);
201
+ const rec = asRecord(body);
202
+ const anyOverFlag = rec?.anyOver === true;
203
+ return {
204
+ companySlug,
205
+ companyUid,
206
+ over: anyOverFlag || reasons.length > 0,
207
+ reasons,
208
+ members: selectMemberUsage(body) ?? usages.users,
209
+ usages,
210
+ upgradeUrl: selectUpgradeUrl(body),
211
+ freeMonth: parseFreeMonth(rec?.freeMonth),
212
+ plan: selectPlan(body) ?? undefined,
213
+ checkedAt: new Date().toISOString(),
214
+ };
215
+ }
216
+ /**
217
+ * Read the live nag for one company from usage-limits. `/membership/me` is
218
+ * used only to resolve slug → companyUid. Returns null when the server did
219
+ * not answer for this company — callers must then say nothing.
220
+ */
221
+ export async function fetchPlanLimitStatus(token, companyRef, opts = {}) {
168
222
  const timeoutMs = opts.timeoutMs ?? 8000;
169
223
  const res = await vaultApiFetch({
170
224
  token,
@@ -173,42 +227,29 @@ export async function fetchPlanLockStatus(token, companyRef, opts = {}) {
173
227
  });
174
228
  if (!res.ok)
175
229
  return null;
176
- const selected = selectPlanLock(await res.json().catch(() => null), companyRef);
177
- if (!selected)
230
+ const selected = selectCompanyMembership(await res.json().catch(() => null), companyRef);
231
+ if (!selected?.companyUid)
232
+ return null;
233
+ try {
234
+ const usage = await vaultApiFetch({
235
+ token,
236
+ path: "/v1/billing/usage-limits",
237
+ query: { companyUid: selected.companyUid },
238
+ signal: AbortSignal.timeout(timeoutMs),
239
+ });
240
+ if (!usage.ok)
241
+ return null;
242
+ const body = await usage.json().catch(() => null);
243
+ if (!body)
244
+ return null;
245
+ return statusFromUsage(companyRef, selected.companyUid, body);
246
+ }
247
+ catch {
178
248
  return null;
179
- let members;
180
- let plan;
181
- // Decoration only. The lock answer above is authoritative; the usage read
182
- // adds the member count and the plan id that the notices and the `Plan:`
183
- // orientation line quote. A failure here must never suppress — or invent —
184
- // a lock, so every field it feeds stays optional.
185
- if (selected.companyUid) {
186
- try {
187
- const usage = await vaultApiFetch({
188
- token,
189
- path: "/v1/billing/usage-limits",
190
- query: { companyUid: selected.companyUid },
191
- signal: AbortSignal.timeout(timeoutMs),
192
- });
193
- if (usage.ok) {
194
- const body = await usage.json().catch(() => null);
195
- members = selectMemberUsage(body) ?? undefined;
196
- plan = selectPlan(body) ?? undefined;
197
- }
198
- }
199
- catch {
200
- // Network/timeout: leave both unknown.
201
- }
202
249
  }
203
- return {
204
- companySlug: companyRef,
205
- companyUid: selected.companyUid,
206
- lock: selected.lock,
207
- members,
208
- plan,
209
- checkedAt: new Date().toISOString(),
210
- };
211
250
  }
251
+ /** @deprecated US-040 — alias of {@link fetchPlanLimitStatus}. */
252
+ export const fetchPlanLockStatus = fetchPlanLimitStatus;
212
253
  /** Why the workspace is over, one clause per reason. */
213
254
  export function reasonLabel(reason) {
214
255
  switch (reason) {
@@ -222,48 +263,80 @@ export function reasonLabel(reason) {
222
263
  return "agents are not included on Starter";
223
264
  }
224
265
  }
225
- function integrationTarget(lock) {
226
- return lock.fixOptions.disconnectIntegrationsTo ?? STARTER_INTEGRATION_TARGET;
227
- }
228
- function secretTarget(lock) {
229
- return lock.fixOptions.removeSecretsTo ?? STARTER_SECRET_TARGET;
230
- }
231
- function agentTarget(lock) {
232
- return lock.fixOptions.deprovisionAgentsTo ?? STARTER_AGENT_TARGET;
266
+ function usageTarget(reason, status) {
267
+ const used = status.usages[reason]?.limit;
268
+ if (typeof used === "number")
269
+ return used;
270
+ switch (reason) {
271
+ case "users":
272
+ return status.members?.limit ?? STARTER_MEMBER_TARGET;
273
+ case "integrations":
274
+ return STARTER_INTEGRATION_TARGET;
275
+ case "secrets":
276
+ return STARTER_SECRET_TARGET;
277
+ case "agents":
278
+ return STARTER_AGENT_TARGET;
279
+ }
233
280
  }
234
281
  /** The remedy sentence for one reason — what the owner does to clear it. */
235
- function reasonRemedy(reason, lock) {
282
+ function reasonRemedy(reason, status) {
283
+ const target = usageTarget(reason, status);
236
284
  switch (reason) {
237
285
  case "users":
238
- return `remove members until you are at ${lock.fixOptions.removeMembersTo} or fewer`;
286
+ return `remove members until you are at ${target} or fewer`;
239
287
  case "integrations":
240
- return `disconnect integrations until you are at ${integrationTarget(lock)} or fewer`;
288
+ return `disconnect integrations until you are at ${target} or fewer`;
241
289
  case "secrets":
242
- return `delete secrets until you are at ${secretTarget(lock)} or fewer`;
243
- case "agents": {
244
- const target = agentTarget(lock);
290
+ return `delete secrets until you are at ${target} or fewer`;
291
+ case "agents":
245
292
  return target > 0
246
293
  ? `deprovision agents until you are at ${target} or fewer`
247
294
  : "deprovision the workspace's agents";
248
- }
249
295
  }
250
296
  }
251
297
  /** The short per-reason clause used inside the one-line turn notice. */
252
298
  function reasonDetail(reason, status) {
253
- const { lock, members } = status;
299
+ const row = status.usages[reason];
300
+ const target = usageTarget(reason, status);
301
+ switch (reason) {
302
+ case "users":
303
+ return row || status.members
304
+ ? `${(row ?? status.members).used} of ${target} members`
305
+ : `over its ${target}-member limit`;
306
+ case "integrations":
307
+ return row
308
+ ? `${row.used} of ${target} integrations`
309
+ : `over its ${target}-integration limit`;
310
+ case "secrets":
311
+ return row
312
+ ? `${row.used} of ${target} secrets`
313
+ : `over its ${target}-secret limit`;
314
+ case "agents":
315
+ return "agents are not included on Starter";
316
+ }
317
+ }
318
+ function usageLine(reason, status) {
319
+ const row = status.usages[reason];
320
+ const target = usageTarget(reason, status);
254
321
  switch (reason) {
255
322
  case "users": {
256
- const target = lock.fixOptions.removeMembersTo;
323
+ const members = row ?? status.members;
257
324
  return members
258
- ? `${members.used} of ${target} members`
259
- : `over its ${target}-member limit`;
325
+ ? ` Members: ${members.used} of ${target}.`
326
+ : ` Members allowed on Starter: ${target}.`;
260
327
  }
261
328
  case "integrations":
262
- return `over its ${integrationTarget(status.lock)}-integration limit`;
329
+ return row
330
+ ? ` Integrations: ${row.used} of ${target}.`
331
+ : ` Integrations allowed on Starter: ${target}.`;
263
332
  case "secrets":
264
- return `over its ${secretTarget(status.lock)}-secret limit`;
333
+ return row
334
+ ? ` Secrets: ${row.used} of ${target}.`
335
+ : ` Secrets allowed on Starter: ${target}.`;
265
336
  case "agents":
266
- return "agents are not included on Starter";
337
+ return row
338
+ ? ` Agents: ${row.used} of ${target}.`
339
+ : ` Agents allowed on Starter: ${target}.`;
267
340
  }
268
341
  }
269
342
  /**
@@ -285,43 +358,41 @@ function capitalize(text) {
285
358
  * against the cap, and the ways out. Plain text — colour is applied by the
286
359
  * caller so scripts capturing stdout get a clean block.
287
360
  *
288
- * Every line is derived from `lock.reasons`. A workspace over on secrets is
361
+ * Every line is derived from `status.reasons`. A workspace over on secrets is
289
362
  * never told it has too many members, and an empty reason list (a server
290
363
  * dimension this CLI does not know) renders a generic line rather than a claim
291
364
  * about a dimension nobody measured.
292
365
  */
293
366
  export function renderPlanLimitNotice(status) {
294
- const { lock, members, companySlug } = status;
367
+ const { companySlug, freeMonth } = status;
295
368
  const lines = [];
296
369
  lines.push(`HQ Starter — ${companySlug} is over its plan limits.`);
297
- const reasons = lock.reasons.length
298
- ? lock.reasons.map(reasonLabel).join("; ")
370
+ const reasons = status.reasons.length
371
+ ? status.reasons.map(reasonLabel).join("; ")
299
372
  : "over the Starter plan limits";
300
373
  lines.push(` Why: ${reasons}.`);
301
- if (lock.reasons.includes("users")) {
302
- const target = lock.fixOptions.removeMembersTo;
303
- lines.push(members
304
- ? ` Members: ${members.used} of ${target}.`
305
- : ` Members allowed on Starter: ${target}.`);
306
- }
307
- if (lock.reasons.includes("integrations")) {
308
- lines.push(` Integrations allowed on Starter: ${integrationTarget(lock)}.`);
309
- }
310
- if (lock.reasons.includes("secrets")) {
311
- lines.push(` Secrets allowed on Starter: ${secretTarget(lock)}.`);
312
- }
313
- if (lock.reasons.includes("agents")) {
314
- lines.push(` Agents allowed on Starter: ${agentTarget(lock)}.`);
374
+ for (const reason of status.reasons) {
375
+ lines.push(usageLine(reason, status));
315
376
  }
316
377
  lines.push(HARD_STOP_SENTENCE_LINES[0]);
317
378
  lines.push(HARD_STOP_SENTENCE_LINES[1]);
318
- lines.push(" Two ways to sort it:");
319
- const remedy = lock.reasons.length
320
- ? lock.reasons.map((reason) => reasonRemedy(reason, lock)).join(", and ")
379
+ const remedy = status.reasons.length
380
+ ? status.reasons.map((reason) => reasonRemedy(reason, status)).join(", and ")
321
381
  : "come back under the Starter plan limits";
322
- lines.push(` 1. ${capitalize(remedy)}.`);
323
- lines.push(` 2. Upgrade to ${WORKFORCE_PLAN_LABEL}.`);
324
- lines.push(` Upgrade: ${lock.upgradeUrl}`);
382
+ const freeMonthUrl = freeMonth?.url;
383
+ const hasFreeMonth = Boolean(freeMonth?.eligible && freeMonthUrl);
384
+ if (hasFreeMonth) {
385
+ lines.push(" Three ways to sort it:");
386
+ lines.push(` 1. Start your free month — add a card, first month free: ${freeMonthUrl}`);
387
+ lines.push(` 2. ${capitalize(remedy)}.`);
388
+ lines.push(` 3. Upgrade to ${WORKFORCE_PLAN_LABEL}.`);
389
+ }
390
+ else {
391
+ lines.push(" Two ways to sort it:");
392
+ lines.push(` 1. ${capitalize(remedy)}.`);
393
+ lines.push(` 2. Upgrade to ${WORKFORCE_PLAN_LABEL}.`);
394
+ }
395
+ lines.push(` Upgrade: ${status.upgradeUrl}`);
325
396
  return lines.join("\n");
326
397
  }
327
398
  /**
@@ -329,24 +400,22 @@ export function renderPlanLimitNotice(status) {
329
400
  * Kept to a single line on purpose — it repeats each turn.
330
401
  */
331
402
  export function renderPlanLimitLine(status) {
332
- const detail = status.lock.reasons.length
333
- ? status.lock.reasons
334
- .map((reason) => reasonDetail(reason, status))
335
- .join("; ")
403
+ const detail = status.reasons.length
404
+ ? status.reasons.map((reason) => reasonDetail(reason, status)).join("; ")
336
405
  : "over the Starter plan limits";
337
406
  return (`Company ${status.companySlug} is over its Starter limits (${detail}). ` +
338
- `New files and new secrets are paused until this is sorted: ${status.lock.upgradeUrl}`);
407
+ `New files and new secrets are paused until this is sorted: ${status.upgradeUrl}`);
339
408
  }
340
409
  /**
341
410
  * The `Plan: …` orientation line. Starter only: a paid or enterprise
342
411
  * workspace — and an UNKNOWN plan — gets no line at all.
343
412
  */
344
413
  export function renderPlanLine(status) {
345
- if (status.lock.locked)
414
+ if (status.over)
346
415
  return "Plan: Starter — over its limits";
347
416
  if (status.plan !== "free")
348
417
  return null;
349
- const target = status.lock.fixOptions.removeMembersTo;
418
+ const target = status.members?.limit ?? STARTER_MEMBER_TARGET;
350
419
  if (status.members) {
351
420
  return `Plan: Starter — ${status.members.used} of ${target} members`;
352
421
  }
@@ -85,8 +85,11 @@ export declare class BotApiError extends Error {
85
85
  readonly status: number;
86
86
  readonly path: string;
87
87
  readonly code?: string | undefined;
88
- constructor(status: number, path: string, code?: string | undefined, detail?: string);
88
+ readonly retryAfterMs?: number | undefined;
89
+ constructor(status: number, path: string, code?: string | undefined, detail?: string, retryAfterMs?: number | undefined);
89
90
  }
91
+ /** Parse a Retry-After header (delta-seconds or HTTP-date) to milliseconds. */
92
+ export declare function retryAfterHeaderMs(value: string | null | undefined, nowMs?: number): number | undefined;
90
93
  export interface BotApiOptions {
91
94
  token: TokenSupplier;
92
95
  baseUrl?: string;
@@ -11,14 +11,32 @@ export class BotApiError extends Error {
11
11
  status;
12
12
  path;
13
13
  code;
14
- constructor(status, path, code, detail) {
14
+ retryAfterMs;
15
+ constructor(status, path, code, detail, retryAfterMs) {
15
16
  super(`HQ API ${path} → ${status}${code ? ` ${code}` : ""}${detail ? `: ${detail}` : ""}`);
16
17
  this.status = status;
17
18
  this.path = path;
18
19
  this.code = code;
20
+ this.retryAfterMs = retryAfterMs;
19
21
  this.name = "BotApiError";
20
22
  }
21
23
  }
24
+ /** Parse a Retry-After header (delta-seconds or HTTP-date) to milliseconds. */
25
+ export function retryAfterHeaderMs(value, nowMs = Date.now()) {
26
+ if (!value)
27
+ return undefined;
28
+ const trimmed = value.trim();
29
+ if (!trimmed)
30
+ return undefined;
31
+ const seconds = Number(trimmed);
32
+ if (Number.isFinite(seconds) && seconds >= 0) {
33
+ return Math.round(seconds * 1000);
34
+ }
35
+ const dateMs = Date.parse(trimmed);
36
+ if (!Number.isNaN(dateMs))
37
+ return Math.max(0, dateMs - nowMs);
38
+ return undefined;
39
+ }
22
40
  async function readError(res) {
23
41
  try {
24
42
  const body = (await res.json());
@@ -54,7 +72,7 @@ export class BotApi {
54
72
  });
55
73
  if (!accept.includes(res.status)) {
56
74
  const err = await readError(res);
57
- throw new BotApiError(res.status, path, err.code, err.detail);
75
+ throw new BotApiError(res.status, path, err.code, err.detail, retryAfterHeaderMs(res.headers.get("Retry-After")));
58
76
  }
59
77
  const text = await res.text();
60
78
  return { status: res.status, body: (text ? JSON.parse(text) : {}) };
@@ -81,7 +81,17 @@ export interface BotDaemonDeps {
81
81
  */
82
82
  announce?: (result: BotDaemonResult) => void;
83
83
  }
84
- /** Write the unit and load it (RunAtLoad starts the bot immediately). */
84
+ export declare function launchdPlistProgramArguments(plist: string): string[];
85
+ /** The hq script path baked into an installed unit, or null if none. */
86
+ export declare function installedDaemonHqBinary(p: BotDaemonPaths, io?: {
87
+ existsSync(path: string): boolean;
88
+ readFileSync(path: string, encoding: "utf8"): string;
89
+ }): string | null;
90
+ /**
91
+ * Rewrite the unit when its hq path is missing or is the pre-move
92
+ * toolchain/node/bin/hq while toolchain/npm-global/bin/hq exists.
93
+ */
94
+ export declare function repairBotDaemonIfStale(p: BotDaemonPaths, deps?: BotDaemonDeps): BotDaemonResult | null;
85
95
  export declare function installBotDaemon(p: BotDaemonPaths, deps?: BotDaemonDeps): BotDaemonResult;
86
96
  export declare function uninstallBotDaemon(p: BotDaemonPaths, deps?: BotDaemonDeps): BotDaemonResult;
87
97
  export declare function botDaemonStatus(p: BotDaemonPaths, deps?: BotDaemonDeps): BotDaemonResult;