@indigoai-us/hq-cli 5.117.1 → 5.117.3

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 (33) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/assets/bot-workers/setup/context/USER-GUIDE.md +44 -3
  3. package/assets/bot-workers/setup/context/quick-reference.md +1 -1
  4. package/dist/command-catalog.generated.d.ts +5 -2
  5. package/dist/command-catalog.generated.js +6 -2
  6. package/dist/commands/agent-kit.js +16 -1
  7. package/dist/commands/agent-probe.d.ts +1 -3
  8. package/dist/commands/agent-probe.js +11 -29
  9. package/dist/commands/billing.js +1 -1
  10. package/dist/commands/db-provision.js +4 -4
  11. package/dist/commands/meetings.js +2 -2
  12. package/dist/commands/whoami.d.ts +7 -0
  13. package/dist/commands/whoami.js +55 -1
  14. package/dist/lib/agent-kit/run/inbox.js +2 -2
  15. package/dist/lib/agent-kit/run/mesh-listener.js +11 -2
  16. package/dist/lib/agent-kit/skills.js +2 -2
  17. package/dist/lib/billing/plan-lock.d.ts +99 -0
  18. package/dist/lib/billing/plan-lock.js +230 -0
  19. package/dist/lib/doctor/__testing__/fake-hq-tree.d.ts +1 -1
  20. package/dist/lib/doctor/__testing__/fake-hq-tree.js +1 -1
  21. package/dist/lib/doctor/checks/claude-wiring.js +61 -1
  22. package/dist/lib/doctor/compat.js +1 -0
  23. package/dist/lib/doctor/fix/apply.js +21 -22
  24. package/dist/lib/doctor/fix/remediation.d.ts +7 -4
  25. package/dist/lib/doctor/fix/remediation.js +15 -7
  26. package/dist/lib/doctor/registry.js +43 -0
  27. package/dist/lib/doctor/stray-gate-entries.d.ts +35 -0
  28. package/dist/lib/doctor/stray-gate-entries.js +83 -0
  29. package/dist/lib/plan-limit-nag.js +1 -1
  30. package/dist/utils/plan-gate-error.js +9 -9
  31. package/dist/utils/team-upgrade.d.ts +1 -1
  32. package/dist/utils/team-upgrade.js +3 -3
  33. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -2,6 +2,47 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.117.3] — 2026-09-16
6
+
7
+ ### Fixed
8
+
9
+ - `hq doctor --fix` no longer breaks Claude Code sessions. Since HQ moved to a single master hook, the doctor saw every hook script as unregistered. It then added one hook entry per script with no tool filter, so guards such as the Glob path check and core protection ran on every tool call and blocked Bash, Skill and Read with "Glob needs a path" or "Edit to locked path is not allowed". An HQ update could then move those entries into `.claude/settings.local.json`. The doctor no longer auto-registers orphan scripts. It now detects the stray entries in both settings files and `hq doctor --fix` removes them, keeping your permissions and any hooks you added yourself. Scripts listed in `.claude/hooks/hook-registry.json` also no longer show as orphans.
10
+
11
+ ### Fixed
12
+
13
+ - `hq doctor` now keeps its hook-health compatibility verdict in step with
14
+ `check-hq-hooks.sh` when either required command hook is missing.
15
+
16
+ ## [5.117.2] — 2026-09-16
17
+
18
+ ### Fixed
19
+
20
+ - External agents: the work-mesh listener no longer fails every refresh with
21
+ "Agent callers may only use DM, channel comms, and reaction routes". It now
22
+ re-reads the agent's own inbox on each doorbell instead of the person-only
23
+ conversation routes.
24
+ - External agents: `hq agent probe` no longer waits for a DM to itself to show up
25
+ in the inbox, which the server never delivers. The dm check passes when the
26
+ send is accepted and the agent inbox is readable.
27
+ - External agents: kit service logs no longer write every line twice when the
28
+ services run under a supervisor.
29
+
30
+ ### Changed
31
+
32
+ - HQ now calls the paid plan by its real name everywhere you see it. If your
33
+ workspace went over its Starter limit, the notice, the error you got when you
34
+ tried to write something, and the upgrade prompt used to give three different
35
+ names for the same plan — "HQ Workforce", "HQ Team", and "Team" — which made
36
+ it genuinely unclear what you were being asked to buy. They all say HQ
37
+ Workforce now. The free plan is called Starter rather than "free plan", to
38
+ match the pricing page.
39
+
40
+ ### Fixed
41
+
42
+ - `hq doctor` now reports failed hook wiring when `.claude/settings.json` has
43
+ no usable command hook for `SessionStart` or `PreToolUse`. The file can still
44
+ be present and valid JSON, but it no longer reads as healthy when nothing runs.
45
+
5
46
  ## [5.117.1] — 2026-09-16
6
47
 
7
48
  ### Fixed
@@ -109,9 +109,9 @@ Structured storage for agent and app state — not a replacement for markdown kn
109
109
  | `hq db status --company {co}` | Create/open local SQLite at `~/.hq/db/{co}/vault.db` (WAL) |
110
110
  | `hq db sql --company {co} -- 'SELECT …'` | Query the company local DB (read-only by default) |
111
111
  | `hq db migrate --company {co} --hq-root {HQ}` | Apply reviewable SQL under `companies/{co}/db/migrations/` |
112
- | `hq db provision --company {co}` | Remote DB on **HQ Team** plan only (secrets never printed) |
112
+ | `hq db provision --company {co}` | Remote DB on **HQ Workforce** ($500/mo) or Enterprise only (secrets never printed) |
113
113
 
114
- Use local for single-machine agent skills and implementer standards. Use Team remote for multi-machine shared data and deploy SecretBinding. Never commit `*.db` files into the vault tree.
114
+ Use local for single-machine agent skills and implementer standards. Use the remote DB for multi-machine shared data and deploy SecretBinding. Never commit `*.db` files into the vault tree.
115
115
 
116
116
  ### HQ CLI: Direct messages (`hq dm`)
117
117
 
@@ -318,6 +318,47 @@ HQ/
318
318
  └── threads/ # Session threads + handoff.json
319
319
  ```
320
320
 
321
+ ## Plans and limits
322
+
323
+ HQ has three plans: **Starter**, **HQ Workforce** ($500/month per workspace), and
324
+ **Enterprise**.
325
+
326
+ Starter is free. It holds up to 5 members, and it does not include integrations
327
+ or MCP, Atlas, or telemetry. Everything else — knowledge, projects, policies,
328
+ workers, secrets, vault storage, `hq deploy`, and `hq share` — works the same as
329
+ on the paid plans.
330
+
331
+ ### When a workspace goes over
332
+
333
+ A Starter workspace locks as soon as it goes over 5 members. There is no grace
334
+ period and no countdown.
335
+
336
+ A locked workspace is read-only. Nothing is deleted or hidden, and everyone can
337
+ still read everything. Writes are refused until the workspace is back inside the
338
+ limits.
339
+
340
+ There are two ways out, and only the workspace owner can do either:
341
+
342
+ 1. Remove members until there are 5 or fewer. Removing someone from a workspace
343
+ does not delete their personal HQ or the work they saved into the workspace.
344
+ 2. Upgrade to HQ Workforce at $500/month from the workspace billing page.
345
+
346
+ Either one unlocks the workspace right away.
347
+
348
+ Existing integration connections on a locked Starter workspace are disabled, not
349
+ deleted. Upgrading turns them back on without reconnecting anything.
350
+
351
+ ### What you'll see
352
+
353
+ The owner gets one email when the workspace locks, a reminder at most once every
354
+ 7 days while it stays locked, and one confirmation when it unlocks. Billing mail
355
+ comes from billing@hqforwork.com, and billing links go to hq.computer, which is
356
+ the console.
357
+
358
+ In the console you'll see a lock banner, an upgrade wall, and a team roster that
359
+ counts members against the limit. In a Claude Code session, `/startwork` shows a
360
+ short notice on every turn while the workspace is over its limits.
361
+
321
362
  ## Meeting notes, signals & ontology
322
363
 
323
364
  HQ captures these **natively, per company** — check HQ first, not your email or a third-party notetaker.
@@ -334,7 +375,7 @@ HQ captures these **natively, per company** — check HQ first, not your email o
334
375
 
335
376
  **Your preference for "meeting notes":** defaults to HQ-native. To point a company at email instead, set `meeting_notes_source: email` in `companies/{co}/settings/knowledge/preferences.yaml` (global default lives in `personal/settings/knowledge-preferences.yaml`).
336
377
 
337
- > Signals extraction and the ontology gardener run on HQ cloud and will require HQ Pro once billing ships. Billing isn't live yet today these are provisioned per-company when you cloud-back it via `/designate-team`. Reference: `core/knowledge/public/hq-core/native-knowledge-stores.md`.
378
+ > Signals extraction and the ontology gardener run on HQ cloud and are part of HQ Workforce ($500/mo) and Enterprise. They are not included in the free Starter plan. Cloud-back a company with `/designate-team` to turn them on. Reference: `core/knowledge/public/hq-core/native-knowledge-stores.md`.
338
379
 
339
380
  ## Typical Session
340
381
 
@@ -148,7 +148,7 @@ Share-session URLs are encrypted single-use 15-minute capabilities — never per
148
148
 
149
149
  ## CLI: `hq db` (vault databases)
150
150
 
151
- Local SQLite per company (always) + optional remote Postgres-class on **HQ Team** ($500/mo). Guide: `core/knowledge/public/hq-core/vault-databases.md`. Requires `@indigoai-us/hq-cli` ≥ 5.62.0.
151
+ Local SQLite per company (always, Starter included) + optional remote Postgres-class on **HQ Workforce** ($500/mo) or Enterprise. Guide: `core/knowledge/public/hq-core/vault-databases.md`. Requires `@indigoai-us/hq-cli` ≥ 5.62.0.
152
152
 
153
153
  | Command | Use |
154
154
  |---------|-----|
@@ -800,6 +800,9 @@ export declare const COMMAND_CATALOG: readonly [{
800
800
  readonly options: readonly [{
801
801
  readonly flags: "--json";
802
802
  readonly description: "Output identity metadata as JSON (never tokens)";
803
+ }, {
804
+ readonly flags: "--company <slug>";
805
+ readonly description: "Also report this company's workspace plan state (Starter lock)";
803
806
  }];
804
807
  readonly subcommands: readonly [];
805
808
  }, {
@@ -1276,7 +1279,7 @@ export declare const COMMAND_CATALOG: readonly [{
1276
1279
  readonly subcommands: readonly [];
1277
1280
  }, {
1278
1281
  readonly name: "provision";
1279
- readonly description: "Provision (or re-bind) the company remote vault DB via HQ control plane (Team plan)";
1282
+ readonly description: "Provision (or re-bind) the company remote vault DB via HQ control plane (HQ Workforce plan)";
1280
1283
  readonly aliases: readonly [];
1281
1284
  readonly hidden: false;
1282
1285
  readonly usage: "[options]";
@@ -4603,7 +4606,7 @@ export declare const COMMAND_CATALOG: readonly [{
4603
4606
  readonly subcommands: readonly [];
4604
4607
  }, {
4605
4608
  readonly name: "upgrade";
4606
- readonly description: "Open Stripe Checkout to upgrade this company to HQ Team ($500/mo)";
4609
+ readonly description: "Open Stripe Checkout to upgrade this company to HQ Workforce ($500/mo)";
4607
4610
  readonly aliases: readonly [];
4608
4611
  readonly hidden: false;
4609
4612
  readonly usage: "[options]";
@@ -1021,6 +1021,10 @@ export const COMMAND_CATALOG = [
1021
1021
  {
1022
1022
  "flags": "--json",
1023
1023
  "description": "Output identity metadata as JSON (never tokens)"
1024
+ },
1025
+ {
1026
+ "flags": "--company <slug>",
1027
+ "description": "Also report this company's workspace plan state (Starter lock)"
1024
1028
  }
1025
1029
  ],
1026
1030
  "subcommands": []
@@ -1628,7 +1632,7 @@ export const COMMAND_CATALOG = [
1628
1632
  },
1629
1633
  {
1630
1634
  "name": "provision",
1631
- "description": "Provision (or re-bind) the company remote vault DB via HQ control plane (Team plan)",
1635
+ "description": "Provision (or re-bind) the company remote vault DB via HQ control plane (HQ Workforce plan)",
1632
1636
  "aliases": [],
1633
1637
  "hidden": false,
1634
1638
  "usage": "[options]",
@@ -5951,7 +5955,7 @@ export const COMMAND_CATALOG = [
5951
5955
  },
5952
5956
  {
5953
5957
  "name": "upgrade",
5954
- "description": "Open Stripe Checkout to upgrade this company to HQ Team ($500/mo)",
5958
+ "description": "Open Stripe Checkout to upgrade this company to HQ Workforce ($500/mo)",
5955
5959
  "aliases": [],
5956
5960
  "hidden": false,
5957
5961
  "usage": "[options]",
@@ -124,7 +124,7 @@ export function buildKitRuntime(service) {
124
124
  process.env.HQ_MACHINE_CREDS_FILE = paths.machineCredsPath;
125
125
  process.env.HQ_REQUIRE_MACHINE_IDENTITY = "1";
126
126
  process.env.HQ_VAULT_API_URL = creds.apiBaseUrl;
127
- const log = createKitLogger(paths, service, { echo: true });
127
+ const log = createKitLogger(paths, service, { echo: process.stdout.isTTY === true });
128
128
  const getToken = () => ensureCognitoToken({ tokenSource: "machine", interactive: false });
129
129
  return { paths, creds, config, getToken, log };
130
130
  }
@@ -168,6 +168,21 @@ export async function runKitService(service, rt) {
168
168
  refreshMs: rt.config.meshRefreshMs,
169
169
  getToken: rt.getToken,
170
170
  log: rt.log,
171
+ // Any doorbell (and the periodic refresh) re-polls the agent inbox, the
172
+ // only conversation source agent callers are authorized to read.
173
+ refetch: async () => {
174
+ const r = await pollInboxOnce({
175
+ paths: rt.paths,
176
+ agentUid: rt.creds.entityUid,
177
+ apiBaseUrl: rt.creds.apiBaseUrl,
178
+ pollMs: rt.config.inboxPollMs,
179
+ ack: rt.config.inboxAck,
180
+ getToken: rt.getToken,
181
+ log: rt.log,
182
+ });
183
+ if (!r.ok)
184
+ throw new Error("agent inbox poll failed; see inbox.log");
185
+ },
171
186
  // An inbox doorbell means "poll now" rather than waiting for the loop.
172
187
  onInboxDoorbell: () => pollInboxOnce({
173
188
  paths: rt.paths,
@@ -9,7 +9,7 @@
9
9
  * presence is live: a fresh last-heartbeat.json plus an ok
10
10
  * component-mesh stamp from the doorbell listener. (The company
11
11
  * roster route is owner/admin-only and 404s for agt_ callers.)
12
- * dm send a DM to self via POST /v1/notify/dm and read it back
12
+ * dm send a DM to self via POST /v1/notify/dm (accepted) and read the agent inbox (200)
13
13
  * through GET /v1/agents/{uid}/inbox (the agent read surface;
14
14
  * /v1/notify/thread is intentionally closed to agents)
15
15
  * secrets `GET /secrets/{companyUid}` answers 200
@@ -38,8 +38,6 @@ export interface ProbeResult {
38
38
  /** Seconds since the kit's last successful heartbeat, or null if never. */
39
39
  heartbeatAgeSeconds: number | null;
40
40
  }
41
- export declare const DM_ROUNDTRIP_TIMEOUT_MS = 20000;
42
- export declare const DM_ROUNDTRIP_POLL_MS = 2000;
43
41
  /** A heartbeat older than this does not count as live presence (3 missed 60 s beats). */
44
42
  export declare const PRESENCE_FRESH_SECONDS = 180;
45
43
  export interface ProbeDeps {
@@ -9,7 +9,7 @@
9
9
  * presence is live: a fresh last-heartbeat.json plus an ok
10
10
  * component-mesh stamp from the doorbell listener. (The company
11
11
  * roster route is owner/admin-only and 404s for agt_ callers.)
12
- * dm send a DM to self via POST /v1/notify/dm and read it back
12
+ * dm send a DM to self via POST /v1/notify/dm (accepted) and read the agent inbox (200)
13
13
  * through GET /v1/agents/{uid}/inbox (the agent read surface;
14
14
  * /v1/notify/thread is intentionally closed to agents)
15
15
  * secrets `GET /secrets/{companyUid}` answers 200
@@ -41,8 +41,6 @@ import { readKitConfig } from "../lib/agent-kit/kit-config.js";
41
41
  import { readLastHeartbeat } from "../lib/agent-kit/run/heartbeat.js";
42
42
  import { defaultRunPull, syncPullArgs } from "../lib/agent-kit/run/sync.js";
43
43
  import { requireExternalCreds } from "./agent-kit.js";
44
- export const DM_ROUNDTRIP_TIMEOUT_MS = 20_000;
45
- export const DM_ROUNDTRIP_POLL_MS = 2_000;
46
44
  /** A heartbeat older than this does not count as live presence (3 missed 60 s beats). */
47
45
  export const PRESENCE_FRESH_SECONDS = 180;
48
46
  function errText(err) {
@@ -59,7 +57,6 @@ export function heartbeatAgeSeconds(paths, now = () => new Date()) {
59
57
  }
60
58
  export async function runProbe(deps) {
61
59
  const now = deps.now ?? (() => new Date());
62
- const sleep = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
63
60
  const nonce = (deps.nonce ?? (() => randomBytes(6).toString("hex")))();
64
61
  const self = deps.creds.entityUid;
65
62
  const checks = [];
@@ -153,37 +150,22 @@ export async function runProbe(deps) {
153
150
  else {
154
151
  checks.push({ name: "work-mesh", ok: false, detail: "skipped: no token/company" });
155
152
  }
156
- // 4. dm round-trip: send via notify, read back via the agent inbox
153
+ // 4. dm: the send is accepted and the agent inbox is readable.
154
+ // The server never delivers a self-DM into the sender's own inbox
155
+ // (hq-pro-core notify-dm skips inbox delivery when recipient === sender),
156
+ // so a self round-trip can never succeed. Inbound delivery is proven by a
157
+ // teammate's DM, which the kit's inbox poller mirrors to inbox.jsonl.
157
158
  if (token) {
158
159
  try {
159
160
  const body = `hq agent probe ${nonce}`;
160
161
  await deps.sendDm(token, self, body);
161
- const deadline = now().getTime() + (deps.dmTimeoutMs ?? DM_ROUNDTRIP_TIMEOUT_MS);
162
- let found = false;
163
- let lastStatus = 0;
164
- for (;;) {
165
- const res = await deps.readInbox(token);
166
- lastStatus = res.status;
167
- if (res.status === 200 && inboxContainsNonce(res.body, nonce)) {
168
- found = true;
169
- break;
170
- }
171
- // A 4xx will not heal by polling (auth/route refusal): stop now.
172
- if (res.status >= 400 && res.status < 500)
173
- break;
174
- if (now().getTime() >= deadline)
175
- break;
176
- await sleep(DM_ROUNDTRIP_POLL_MS);
177
- }
178
- const secs = Math.round((deps.dmTimeoutMs ?? DM_ROUNDTRIP_TIMEOUT_MS) / 1000);
162
+ const res = await deps.readInbox(token);
179
163
  checks.push({
180
164
  name: "dm",
181
- ok: found,
182
- detail: found
183
- ? "sent to self via /v1/notify/dm and read back from /v1/agents/{uid}/inbox"
184
- : lastStatus === 200
185
- ? `sent but not visible in /v1/agents/{uid}/inbox after ${secs}s`
186
- : `sent but /v1/agents/{uid}/inbox answered ${lastStatus}`,
165
+ ok: res.status === 200,
166
+ detail: res.status === 200
167
+ ? "send accepted by /v1/notify/dm; /v1/agents/{uid}/inbox → 200 (self-DMs are not delivered to the inbox; a DM from a teammate confirms inbound)"
168
+ : `send accepted but /v1/agents/{uid}/inbox answered ${res.status}`,
187
169
  });
188
170
  }
189
171
  catch (err) {
@@ -110,7 +110,7 @@ export function registerBillingCommand(program) {
110
110
  });
111
111
  billing
112
112
  .command("upgrade")
113
- .description("Open Stripe Checkout to upgrade this company to HQ Team ($500/mo)")
113
+ .description("Open Stripe Checkout to upgrade this company to HQ Workforce ($500/mo)")
114
114
  .option("--company <slug>", "Company slug (resolves to companyUid)")
115
115
  .option("--no-browser", "Print the Stripe Checkout URL instead of opening it")
116
116
  .option("--json", "Emit the Checkout URL as JSON")
@@ -28,7 +28,7 @@ const defaultDeps = () => ({
28
28
  });
29
29
  export function registerDbProvisionCommand(db, depsFactory = defaultDeps) {
30
30
  db.command("provision")
31
- .description("Provision (or re-bind) the company remote vault DB via HQ control plane (Team plan)")
31
+ .description("Provision (or re-bind) the company remote vault DB via HQ control plane (HQ Workforce plan)")
32
32
  .requiredOption("--company <slug>", "Company slug")
33
33
  .option("--region <region>", "AWS region", "us-east-1")
34
34
  .action(async (opts) => {
@@ -65,9 +65,9 @@ export function registerDbProvisionCommand(db, depsFactory = defaultDeps) {
65
65
  throw error;
66
66
  const msg = error instanceof Error ? error.message : "Unknown error";
67
67
  const status = error.status;
68
- if (status === 402 || /PLAN_REQUIRED|Team plan|\$500/i.test(msg)) {
69
- console.error(chalk.red("Error:"), "Remote vault DB requires the HQ Team plan ($500/mo).");
70
- console.error(chalk.dim("Local databases still work: hq db status|sql|migrate — no Team plan required."));
68
+ if (status === 402 || /PLAN_REQUIRED|Team plan|Workforce|\$500/i.test(msg)) {
69
+ console.error(chalk.red("Error:"), "Remote vault DB requires the HQ Workforce plan ($500/mo).");
70
+ console.error(chalk.dim("Local databases still work: hq db status|sql|migrate — no HQ Workforce plan required."));
71
71
  }
72
72
  else if (status === 404 || /Unknown route|Not Found/i.test(msg)) {
73
73
  console.error(chalk.red("Error:"), "Remote DB control plane is not available yet (routes not deployed).");
@@ -111,8 +111,8 @@ async function handleApiError(res, json = false) {
111
111
  console.error(chalk.red(body.error ?? "Not found"));
112
112
  }
113
113
  else if (res.status === 402) {
114
- console.error(chalk.red("Meeting notetaker bots require the $500/mo HQ Team plan."));
115
- console.error(chalk.dim(body.message ?? body.error ?? "Upgrade your company to HQ Team to record meetings."));
114
+ console.error(chalk.red("Meeting notetaker bots require the $500/mo HQ Workforce plan."));
115
+ console.error(chalk.dim(body.message ?? body.error ?? "Upgrade your company to HQ Workforce to record meetings."));
116
116
  }
117
117
  else {
118
118
  console.error(chalk.red(`API error (${res.status}): ${body.error ?? res.statusText}`));
@@ -2,6 +2,7 @@
2
2
  * hq whoami — displays current user or 'not logged in'
3
3
  */
4
4
  import { Command } from 'commander';
5
+ import { type PlanLockStatus } from "../lib/billing/plan-lock.js";
5
6
  export interface WhoamiTokenIdentity {
6
7
  email?: string;
7
8
  sub?: string;
@@ -19,5 +20,11 @@ export interface WhoamiDisplayIdentity {
19
20
  * email with the token subject of the delegating machine.
20
21
  */
21
22
  export declare function resolveWhoamiIdentity(identity: WhoamiTokenIdentity): WhoamiDisplayIdentity;
23
+ /**
24
+ * Human rendering of the plan state: the full WORKSPACE LOCKED block when the
25
+ * workspace is locked, the one-line `Plan: Starter — …` orientation when it is
26
+ * Starter and healthy, and nothing at all otherwise.
27
+ */
28
+ export declare function renderWhoamiPlanBlock(status: PlanLockStatus): string | null;
22
29
  export declare function registerWhoamiCommand(program: Command): void;
23
30
  //# sourceMappingURL=whoami.d.ts.map
@@ -4,7 +4,8 @@
4
4
  import chalk from 'chalk';
5
5
  import { loadCachedTokens, isExpiring, loadMachineCreds, } from '@indigoai-us/hq-cloud';
6
6
  import { peekIdToken as decodeIdToken } from "../utils/id-token.js";
7
- import { loadMachineCachedTokens, resolveCognitoTokenSource } from "../utils/cognito-session.js";
7
+ import { ensureCognitoToken, loadMachineCachedTokens, resolveCognitoTokenSource, } from "../utils/cognito-session.js";
8
+ import { colorizePlanLockNotice, fetchPlanLockStatus, renderPlanLine, renderPlanLockNotice, } from "../lib/billing/plan-lock.js";
8
9
  /**
9
10
  * Resolves the person represented by an ID token without pairing a delegated
10
11
  * email with the token subject of the delegating machine.
@@ -40,11 +41,58 @@ function peekIdToken(idToken) {
40
41
  : undefined,
41
42
  };
42
43
  }
44
+ /**
45
+ * starter-plan-hard-limits / US-011 — read the workspace plan lock for
46
+ * `--company <slug>`.
47
+ *
48
+ * hq-pro owns the decision (`planLock` on GET /membership/me); this only reads
49
+ * it. Best-effort by construction: no session, a network failure, an older
50
+ * backend that sends no `planLock`, or a company the caller is not a member of
51
+ * all resolve to null — reported as UNKNOWN and rendered as nothing, never as
52
+ * a guessed lock or a guessed all-clear, and never as a command failure. The
53
+ * command exists to answer "who am I"; the plan line is decoration on top.
54
+ */
55
+ async function readPlanLock(company) {
56
+ const slug = company?.trim();
57
+ if (!slug)
58
+ return null;
59
+ try {
60
+ // NEVER interactive: this runs inside `--json` (before the authenticated
61
+ // check) and inside hq-core's per-turn plan-lock refresh hook. With the
62
+ // default `interactive: true`, a box with no cached session would launch a
63
+ // browser sign-in and block the caller instead of resolving to UNKNOWN.
64
+ const token = await ensureCognitoToken({ interactive: false });
65
+ return await fetchPlanLockStatus(token, slug);
66
+ }
67
+ catch {
68
+ return null;
69
+ }
70
+ }
71
+ /**
72
+ * Human rendering of the plan state: the full WORKSPACE LOCKED block when the
73
+ * workspace is locked, the one-line `Plan: Starter — …` orientation when it is
74
+ * Starter and healthy, and nothing at all otherwise.
75
+ */
76
+ export function renderWhoamiPlanBlock(status) {
77
+ if (status.lock.locked) {
78
+ return colorizePlanLockNotice(renderPlanLockNotice(status));
79
+ }
80
+ return renderPlanLine(status);
81
+ }
82
+ async function printPlanBlock(company) {
83
+ const status = await readPlanLock(company);
84
+ if (!status)
85
+ return;
86
+ const block = renderWhoamiPlanBlock(status);
87
+ if (block)
88
+ console.log(block);
89
+ }
43
90
  export function registerWhoamiCommand(program) {
44
91
  program
45
92
  .command('whoami')
46
93
  .description('Show the currently authenticated user')
47
94
  .option('--json', 'Output identity metadata as JSON (never tokens)')
95
+ .option('--company <slug>', 'Also report this company\'s workspace plan state (Starter lock)')
48
96
  .action(async (opts) => {
49
97
  try {
50
98
  // Effective source (same as auth status / ensureCognitoToken): env
@@ -67,6 +115,9 @@ export function registerWhoamiCommand(program) {
67
115
  const agentUid = machineCreds?.entityUid !== undefined
68
116
  ? (machineCreds.entityUid.startsWith('agt_') ? machineCreds.entityUid : null)
69
117
  : boundLegacyAgent;
118
+ // planLock is additive: absent-or-null means UNKNOWN, which is what
119
+ // every consumer already sees from a backend that predates it.
120
+ const planLock = await readPlanLock(opts.company);
70
121
  console.log(JSON.stringify({
71
122
  schemaVersion: 1,
72
123
  authenticated: machine ? Boolean(machineCreds) : Boolean(cached && !isExpiring(cached, 0)),
@@ -75,6 +126,7 @@ export function registerWhoamiCommand(program) {
75
126
  personUid: !machine && identity.entityType === 'person' ? identity.entityUid ?? null : null,
76
127
  agentUid,
77
128
  username: machineCreds?.username ?? null,
129
+ planLock,
78
130
  }, null, 2));
79
131
  return;
80
132
  }
@@ -88,6 +140,7 @@ export function registerWhoamiCommand(program) {
88
140
  : undefined;
89
141
  console.log(`Machine identity ${username}${entityUid ? ` (agent ${entityUid})` : ''} — sessions mint automatically`);
90
142
  console.log(`token source: machine`);
143
+ await printPlanBlock(opts.company);
91
144
  return;
92
145
  }
93
146
  if (!cached) {
@@ -101,6 +154,7 @@ export function registerWhoamiCommand(program) {
101
154
  }
102
155
  const { email, sub } = resolveWhoamiIdentity(peekIdToken(cached.idToken));
103
156
  console.log(`Logged in as ${email ?? 'unknown'}${sub ? ` (${sub})` : ''}`);
157
+ await printPlanBlock(opts.company);
104
158
  }
105
159
  catch (error) {
106
160
  console.error(chalk.red('Error:'), error instanceof Error ? error.message : 'Unknown error');
@@ -18,7 +18,6 @@
18
18
  import * as fs from "node:fs";
19
19
  import * as path from "node:path";
20
20
  import { vaultApiFetch } from "../../../utils/vault-api.js";
21
- import { warmMeshConversationCache } from "../../mesh/api.js";
22
21
  import { writeComponentStatus } from "../creds.js";
23
22
  export const SEEN_IDS_NAME = "seen-ids.json";
24
23
  export const INBOX_JSONL_NAME = "inbox.jsonl";
@@ -115,7 +114,8 @@ export function defaultAckItem(agentUid, apiBaseUrl) {
115
114
  export async function pollInboxOnce(deps) {
116
115
  const fetchInbox = deps.fetchInbox ?? defaultFetchInbox(deps.agentUid, deps.apiBaseUrl);
117
116
  const ackItem = deps.ackItem ?? defaultAckItem(deps.agentUid, deps.apiBaseUrl);
118
- const warmCache = deps.warmCache ?? ((token, uid) => warmMeshConversationCache(token, uid));
117
+ // No default cache warm: the human conversation routes 403 for agent callers.
118
+ const warmCache = deps.warmCache ?? (async () => { });
119
119
  let token;
120
120
  try {
121
121
  token = await deps.getToken();
@@ -20,7 +20,7 @@
20
20
  * is stamped ok while subscribed and refreshing, error otherwise.
21
21
  */
22
22
  import mqtt from "mqtt";
23
- import { warmMeshConversationCache } from "../../mesh/api.js";
23
+ import { defaultFetchInbox } from "./inbox.js";
24
24
  import { createPersonalRealtimeFetcher, MQTT_KEEPALIVE_SECONDS, renewalDelayMs, } from "../../mesh/live/daemon/credentials.js";
25
25
  import { presignIotWssUrl } from "../../mesh/live/daemon/presign.js";
26
26
  import { writeComponentStatus } from "../creds.js";
@@ -54,7 +54,16 @@ export async function startMeshListener(deps) {
54
54
  const setT = deps.setTimeout ?? ((fn, ms) => setTimeout(fn, ms));
55
55
  const clearT = deps.clearTimeout ?? ((h) => clearTimeout(h));
56
56
  const connect = deps.connect ?? ((url, opts) => mqtt.connect(url, opts));
57
- const refetch = deps.refetch ?? ((token, uid) => warmMeshConversationCache(token, uid));
57
+ // Agent callers are refused by the human conversation routes
58
+ // (/v1/notify/inbox|contacts|thread → 403 AGENT_ROUTE_NOT_SUPPORTED), so the
59
+ // default refetch reads the agent's own inbox, the agent-authorized source.
60
+ const refetch = deps.refetch ??
61
+ (async (token, uid) => {
62
+ const res = await defaultFetchInbox(uid, deps.apiBaseUrl)(token);
63
+ if (res.status !== 200) {
64
+ throw new Error(`GET ${res.url ?? "agent inbox"} → ${res.status}${res.bodyText ? ` body=${res.bodyText}` : ""}`);
65
+ }
66
+ });
58
67
  const fetchCredentials = deps.fetchCredentials ??
59
68
  (async () => {
60
69
  const token = await deps.getToken();
@@ -114,8 +114,8 @@ Rules:
114
114
  body: `# Work mesh status
115
115
 
116
116
  The work mesh is HQ's live board of who is working on what. It is the source
117
- of truth for project stories and active sessions; the kit's listener keeps a
118
- local cache under \`~/.hq/work-mesh/cache/\`.
117
+ of truth for project stories and active sessions. The kit's listener re-polls
118
+ this agent's inbox whenever a doorbell rings.
119
119
 
120
120
  \`\`\`bash
121
121
  hq mesh session status --company <slug> # who is working on what right now
@@ -0,0 +1,99 @@
1
+ /**
2
+ * `plan-lock` (starter-plan-hard-limits / US-011) — the CLI-side read + render
3
+ * of the workspace plan lock.
4
+ *
5
+ * Starter (free) workspaces are capped at 5 members and 0 integrations. Going
6
+ * over locks the workspace immediately: it becomes read-only until the owner
7
+ * trims back under the caps or upgrades to HQ Workforce. The lock decision is
8
+ * NOT made here — hq-pro's `src/billing/plan-lock.ts` is the single source of
9
+ * truth and ships the answer on `GET /membership/me` as a per-company
10
+ * `planLock` object. This module only reads that field and renders it.
11
+ *
12
+ * Member counts are decoration, never a second opinion: the count comes from
13
+ * `GET /v1/billing/usage-limits` on a best-effort basis and its absence only
14
+ * removes the "n of 5" detail from the notice. An absent field is UNKNOWN, so
15
+ * nothing here ever infers a lock (or an unlock) from missing data
16
+ * (hq-absent-field-never-means-constraining-value).
17
+ */
18
+ /** Mirror of hq-pro's `PlanLockReason`. */
19
+ export type PlanLockReason = "users" | "integrations";
20
+ /** Mirror of hq-pro's `PlanLock` wire shape (see src/billing/plan-lock.ts). */
21
+ export interface PlanLock {
22
+ locked: boolean;
23
+ reasons: PlanLockReason[];
24
+ upgradeUrl: string;
25
+ fixOptions: {
26
+ removeMembersTo: number;
27
+ disconnectIntegrations: boolean;
28
+ };
29
+ }
30
+ /** Optional member decoration for the notice ("7 of 5 members"). */
31
+ export interface PlanLockMembers {
32
+ used: number;
33
+ limit: number;
34
+ }
35
+ export interface PlanLockStatus {
36
+ companySlug: string;
37
+ companyUid?: string;
38
+ lock: PlanLock;
39
+ members?: PlanLockMembers;
40
+ /**
41
+ * Resolved plan id, when the usage endpoint answered. UNKNOWN (absent) when
42
+ * it did not — surfaces that gate on "is this Starter?" must then stay
43
+ * silent rather than assume either way.
44
+ */
45
+ plan?: "free" | "paid" | "enterprise";
46
+ checkedAt: string;
47
+ }
48
+ /** Starter member cap quoted when the server did not send `removeMembersTo`. */
49
+ export declare const STARTER_MEMBER_TARGET = 5;
50
+ /** Upgrade destination quoted when the server did not send one. */
51
+ export declare const DEFAULT_UPGRADE_URL = "https://hq.computer/billing";
52
+ /** The paid plan the lock wall sends owners to. Copy lives in ONE place. */
53
+ export declare const WORKFORCE_PLAN_LABEL = "HQ Workforce ($500/mo)";
54
+ /**
55
+ * Defensively parse a `planLock` payload. Anything malformed returns null —
56
+ * the caller then behaves exactly as if the server had sent nothing, which is
57
+ * "unknown", not "locked".
58
+ */
59
+ export declare function parsePlanLock(value: unknown): PlanLock | null;
60
+ /**
61
+ * Pick the membership row for `companySlug` (or `cmp_…` uid) out of a decoded
62
+ * `/membership/me` body and return its parsed lock. No matching row — or no
63
+ * `planLock` on it — is UNKNOWN, so this returns null rather than guessing.
64
+ */
65
+ export declare function selectPlanLock(body: unknown, companyRef: string): {
66
+ lock: PlanLock;
67
+ companyUid?: string;
68
+ } | null;
69
+ /** Parse the resolved plan id out of a usage-limits body. Absent → null. */
70
+ export declare function selectPlan(body: unknown): "free" | "paid" | "enterprise" | null;
71
+ /** Parse the `users` dimension out of a usage-limits body. Absent → null. */
72
+ export declare function selectMemberUsage(body: unknown): PlanLockMembers | null;
73
+ /**
74
+ * Read the live lock for one company: `planLock` from `/membership/me`, plus a
75
+ * best-effort member count for the notice. Returns null when the server did not
76
+ * answer with a lock for this company — callers must then say nothing.
77
+ */
78
+ export declare function fetchPlanLockStatus(token: string, companyRef: string, opts?: {
79
+ timeoutMs?: number;
80
+ }): Promise<PlanLockStatus | null>;
81
+ /**
82
+ * The full WORKSPACE LOCKED block: why it locked, where the workspace stands
83
+ * against the cap, and the two fixes. Plain text — colour is applied by the
84
+ * caller so scripts capturing stdout get a clean block.
85
+ */
86
+ export declare function renderPlanLockNotice(status: PlanLockStatus): string;
87
+ /**
88
+ * The one-line form injected on every turn while the workspace stays locked.
89
+ * Kept to a single line on purpose — it repeats each turn.
90
+ */
91
+ export declare function renderPlanLockLine(status: PlanLockStatus): string;
92
+ /**
93
+ * The `Plan: …` orientation line. Starter only: a paid or enterprise
94
+ * workspace — and an UNKNOWN plan — gets no line at all.
95
+ */
96
+ export declare function renderPlanLine(status: PlanLockStatus): string | null;
97
+ /** Colourised block for interactive output. */
98
+ export declare function colorizePlanLockNotice(notice: string): string;
99
+ //# sourceMappingURL=plan-lock.d.ts.map