@indigoai-us/hq-cli 5.117.1 → 5.117.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +24 -0
- package/assets/bot-workers/setup/context/USER-GUIDE.md +44 -3
- package/assets/bot-workers/setup/context/quick-reference.md +1 -1
- package/dist/command-catalog.generated.d.ts +5 -2
- package/dist/command-catalog.generated.js +6 -2
- package/dist/commands/agent-kit.js +16 -1
- package/dist/commands/agent-probe.d.ts +1 -3
- package/dist/commands/agent-probe.js +11 -29
- package/dist/commands/billing.js +1 -1
- package/dist/commands/db-provision.js +4 -4
- package/dist/commands/meetings.js +2 -2
- package/dist/commands/whoami.d.ts +7 -0
- package/dist/commands/whoami.js +55 -1
- package/dist/lib/agent-kit/run/inbox.js +2 -2
- package/dist/lib/agent-kit/run/mesh-listener.js +11 -2
- package/dist/lib/agent-kit/skills.js +2 -2
- package/dist/lib/billing/plan-lock.d.ts +99 -0
- package/dist/lib/billing/plan-lock.js +230 -0
- package/dist/lib/plan-limit-nag.js +1 -1
- package/dist/utils/plan-gate-error.js +9 -9
- package/dist/utils/team-upgrade.d.ts +1 -1
- package/dist/utils/team-upgrade.js +3 -3
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,30 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [5.117.2] — 2026-09-16
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- External agents: the work-mesh listener no longer fails every refresh with
|
|
10
|
+
"Agent callers may only use DM, channel comms, and reaction routes". It now
|
|
11
|
+
re-reads the agent's own inbox on each doorbell instead of the person-only
|
|
12
|
+
conversation routes.
|
|
13
|
+
- External agents: `hq agent probe` no longer waits for a DM to itself to show up
|
|
14
|
+
in the inbox, which the server never delivers. The dm check passes when the
|
|
15
|
+
send is accepted and the agent inbox is readable.
|
|
16
|
+
- External agents: kit service logs no longer write every line twice when the
|
|
17
|
+
services run under a supervisor.
|
|
18
|
+
|
|
19
|
+
### Changed
|
|
20
|
+
|
|
21
|
+
- HQ now calls the paid plan by its real name everywhere you see it. If your
|
|
22
|
+
workspace went over its Starter limit, the notice, the error you got when you
|
|
23
|
+
tried to write something, and the upgrade prompt used to give three different
|
|
24
|
+
names for the same plan — "HQ Workforce", "HQ Team", and "Team" — which made
|
|
25
|
+
it genuinely unclear what you were being asked to buy. They all say HQ
|
|
26
|
+
Workforce now. The free plan is called Starter rather than "free plan", to
|
|
27
|
+
match the pricing page.
|
|
28
|
+
|
|
5
29
|
## [5.117.1] — 2026-09-16
|
|
6
30
|
|
|
7
31
|
### 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
|
|
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
|
|
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
|
|
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
|
|
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 (
|
|
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
|
|
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 (
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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:
|
|
182
|
-
detail:
|
|
183
|
-
? "
|
|
184
|
-
:
|
|
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) {
|
package/dist/commands/billing.js
CHANGED
|
@@ -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
|
|
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 (
|
|
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
|
|
70
|
-
console.error(chalk.dim("Local databases still work: hq db status|sql|migrate — no
|
|
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
|
|
115
|
-
console.error(chalk.dim(body.message ?? body.error ?? "Upgrade your company to HQ
|
|
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
|
package/dist/commands/whoami.js
CHANGED
|
@@ -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
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
|
118
|
-
|
|
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
|
|
@@ -0,0 +1,230 @@
|
|
|
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
|
+
import chalk from "chalk";
|
|
19
|
+
import { vaultApiFetch } from "../../utils/vault-api.js";
|
|
20
|
+
/** Starter member cap quoted when the server did not send `removeMembersTo`. */
|
|
21
|
+
export const STARTER_MEMBER_TARGET = 5;
|
|
22
|
+
/** Upgrade destination quoted when the server did not send one. */
|
|
23
|
+
export const DEFAULT_UPGRADE_URL = "https://hq.computer/billing";
|
|
24
|
+
/** The paid plan the lock wall sends owners to. Copy lives in ONE place. */
|
|
25
|
+
export const WORKFORCE_PLAN_LABEL = "HQ Workforce ($500/mo)";
|
|
26
|
+
function asRecord(value) {
|
|
27
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
return value;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Defensively parse a `planLock` payload. Anything malformed returns null —
|
|
34
|
+
* the caller then behaves exactly as if the server had sent nothing, which is
|
|
35
|
+
* "unknown", not "locked".
|
|
36
|
+
*/
|
|
37
|
+
export function parsePlanLock(value) {
|
|
38
|
+
const rec = asRecord(value);
|
|
39
|
+
if (!rec)
|
|
40
|
+
return null;
|
|
41
|
+
if (typeof rec.locked !== "boolean")
|
|
42
|
+
return null;
|
|
43
|
+
const reasons = [];
|
|
44
|
+
if (Array.isArray(rec.reasons)) {
|
|
45
|
+
for (const reason of rec.reasons) {
|
|
46
|
+
if (reason === "users" || reason === "integrations")
|
|
47
|
+
reasons.push(reason);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
const fix = asRecord(rec.fixOptions);
|
|
51
|
+
const removeMembersTo = typeof fix?.removeMembersTo === "number" &&
|
|
52
|
+
Number.isFinite(fix.removeMembersTo)
|
|
53
|
+
? fix.removeMembersTo
|
|
54
|
+
: STARTER_MEMBER_TARGET;
|
|
55
|
+
return {
|
|
56
|
+
locked: rec.locked,
|
|
57
|
+
reasons,
|
|
58
|
+
upgradeUrl: typeof rec.upgradeUrl === "string" && rec.upgradeUrl.trim().length > 0
|
|
59
|
+
? rec.upgradeUrl.trim()
|
|
60
|
+
: DEFAULT_UPGRADE_URL,
|
|
61
|
+
fixOptions: {
|
|
62
|
+
removeMembersTo,
|
|
63
|
+
disconnectIntegrations: fix?.disconnectIntegrations === true,
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Pick the membership row for `companySlug` (or `cmp_…` uid) out of a decoded
|
|
69
|
+
* `/membership/me` body and return its parsed lock. No matching row — or no
|
|
70
|
+
* `planLock` on it — is UNKNOWN, so this returns null rather than guessing.
|
|
71
|
+
*/
|
|
72
|
+
export function selectPlanLock(body, companyRef) {
|
|
73
|
+
const rec = asRecord(body);
|
|
74
|
+
const rows = Array.isArray(rec?.memberships) ? rec.memberships : [];
|
|
75
|
+
const ref = companyRef.trim().toLowerCase();
|
|
76
|
+
for (const raw of rows) {
|
|
77
|
+
const row = asRecord(raw);
|
|
78
|
+
if (!row)
|
|
79
|
+
continue;
|
|
80
|
+
if (typeof row.status === "string" && row.status !== "active")
|
|
81
|
+
continue;
|
|
82
|
+
const slug = typeof row.companySlug === "string" ? row.companySlug.toLowerCase() : "";
|
|
83
|
+
const uid = typeof row.companyUid === "string" ? row.companyUid.toLowerCase() : "";
|
|
84
|
+
if (slug !== ref && uid !== ref)
|
|
85
|
+
continue;
|
|
86
|
+
const lock = parsePlanLock(row.planLock);
|
|
87
|
+
if (!lock)
|
|
88
|
+
return null;
|
|
89
|
+
return {
|
|
90
|
+
lock,
|
|
91
|
+
companyUid: typeof row.companyUid === "string" ? row.companyUid : undefined,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
/** Parse the resolved plan id out of a usage-limits body. Absent → null. */
|
|
97
|
+
export function selectPlan(body) {
|
|
98
|
+
const plan = asRecord(body)?.plan;
|
|
99
|
+
return plan === "free" || plan === "paid" || plan === "enterprise"
|
|
100
|
+
? plan
|
|
101
|
+
: null;
|
|
102
|
+
}
|
|
103
|
+
/** Parse the `users` dimension out of a usage-limits body. Absent → null. */
|
|
104
|
+
export function selectMemberUsage(body) {
|
|
105
|
+
const users = asRecord(asRecord(body)?.users);
|
|
106
|
+
if (!users)
|
|
107
|
+
return null;
|
|
108
|
+
if (typeof users.used !== "number" || !Number.isFinite(users.used)) {
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
const limit = typeof users.limit === "number" && Number.isFinite(users.limit)
|
|
112
|
+
? users.limit
|
|
113
|
+
: STARTER_MEMBER_TARGET;
|
|
114
|
+
return { used: users.used, limit };
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Read the live lock for one company: `planLock` from `/membership/me`, plus a
|
|
118
|
+
* best-effort member count for the notice. Returns null when the server did not
|
|
119
|
+
* answer with a lock for this company — callers must then say nothing.
|
|
120
|
+
*/
|
|
121
|
+
export async function fetchPlanLockStatus(token, companyRef, opts = {}) {
|
|
122
|
+
const timeoutMs = opts.timeoutMs ?? 8000;
|
|
123
|
+
const res = await vaultApiFetch({
|
|
124
|
+
token,
|
|
125
|
+
path: "/membership/me",
|
|
126
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
127
|
+
});
|
|
128
|
+
if (!res.ok)
|
|
129
|
+
return null;
|
|
130
|
+
const selected = selectPlanLock(await res.json().catch(() => null), companyRef);
|
|
131
|
+
if (!selected)
|
|
132
|
+
return null;
|
|
133
|
+
let members;
|
|
134
|
+
let plan;
|
|
135
|
+
// Decoration only. The lock answer above is authoritative; the usage read
|
|
136
|
+
// adds the member count and the plan id that the notices and the `Plan:`
|
|
137
|
+
// orientation line quote. A failure here must never suppress — or invent —
|
|
138
|
+
// a lock, so every field it feeds stays optional.
|
|
139
|
+
if (selected.companyUid) {
|
|
140
|
+
try {
|
|
141
|
+
const usage = await vaultApiFetch({
|
|
142
|
+
token,
|
|
143
|
+
path: "/v1/billing/usage-limits",
|
|
144
|
+
query: { companyUid: selected.companyUid },
|
|
145
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
146
|
+
});
|
|
147
|
+
if (usage.ok) {
|
|
148
|
+
const body = await usage.json().catch(() => null);
|
|
149
|
+
members = selectMemberUsage(body) ?? undefined;
|
|
150
|
+
plan = selectPlan(body) ?? undefined;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
// Network/timeout: leave both unknown.
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return {
|
|
158
|
+
companySlug: companyRef,
|
|
159
|
+
companyUid: selected.companyUid,
|
|
160
|
+
lock: selected.lock,
|
|
161
|
+
members,
|
|
162
|
+
plan,
|
|
163
|
+
checkedAt: new Date().toISOString(),
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
function reasonLabel(reason) {
|
|
167
|
+
return reason === "users"
|
|
168
|
+
? "too many members"
|
|
169
|
+
: "integrations are not included on Starter";
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* The full WORKSPACE LOCKED block: why it locked, where the workspace stands
|
|
173
|
+
* against the cap, and the two fixes. Plain text — colour is applied by the
|
|
174
|
+
* caller so scripts capturing stdout get a clean block.
|
|
175
|
+
*/
|
|
176
|
+
export function renderPlanLockNotice(status) {
|
|
177
|
+
const { lock, members, companySlug } = status;
|
|
178
|
+
const target = lock.fixOptions.removeMembersTo;
|
|
179
|
+
const lines = [];
|
|
180
|
+
lines.push(`WORKSPACE LOCKED — ${companySlug} is over its Starter plan.`);
|
|
181
|
+
const reasons = lock.reasons.length
|
|
182
|
+
? lock.reasons.map(reasonLabel).join("; ")
|
|
183
|
+
: "over the Starter plan limits";
|
|
184
|
+
lines.push(` Why: ${reasons}.`);
|
|
185
|
+
if (members) {
|
|
186
|
+
lines.push(` Members: ${members.used} of ${target}.`);
|
|
187
|
+
}
|
|
188
|
+
else {
|
|
189
|
+
lines.push(` Members allowed on Starter: ${target}.`);
|
|
190
|
+
}
|
|
191
|
+
lines.push(" This workspace is read-only until it is fixed. Two ways out:");
|
|
192
|
+
lines.push(` 1. Remove members until you are at ${target} or fewer${lock.fixOptions.disconnectIntegrations
|
|
193
|
+
? ", and disconnect the workspace's integrations"
|
|
194
|
+
: ""}.`);
|
|
195
|
+
lines.push(` 2. Upgrade to ${WORKFORCE_PLAN_LABEL}.`);
|
|
196
|
+
lines.push(` Upgrade: ${lock.upgradeUrl}`);
|
|
197
|
+
return lines.join("\n");
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* The one-line form injected on every turn while the workspace stays locked.
|
|
201
|
+
* Kept to a single line on purpose — it repeats each turn.
|
|
202
|
+
*/
|
|
203
|
+
export function renderPlanLockLine(status) {
|
|
204
|
+
const target = status.lock.fixOptions.removeMembersTo;
|
|
205
|
+
const count = status.members
|
|
206
|
+
? `${status.members.used} of ${target} members`
|
|
207
|
+
: `over its ${target}-member limit`;
|
|
208
|
+
return (`Company ${status.companySlug} is locked on Starter (${count}). ` +
|
|
209
|
+
`Writes to HQ cloud will fail until fixed: ${status.lock.upgradeUrl}`);
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* The `Plan: …` orientation line. Starter only: a paid or enterprise
|
|
213
|
+
* workspace — and an UNKNOWN plan — gets no line at all.
|
|
214
|
+
*/
|
|
215
|
+
export function renderPlanLine(status) {
|
|
216
|
+
if (status.lock.locked)
|
|
217
|
+
return "Plan: Starter — LOCKED";
|
|
218
|
+
if (status.plan !== "free")
|
|
219
|
+
return null;
|
|
220
|
+
const target = status.lock.fixOptions.removeMembersTo;
|
|
221
|
+
if (status.members) {
|
|
222
|
+
return `Plan: Starter — ${status.members.used} of ${target} members`;
|
|
223
|
+
}
|
|
224
|
+
return `Plan: Starter — ${target} members included`;
|
|
225
|
+
}
|
|
226
|
+
/** Colourised block for interactive output. */
|
|
227
|
+
export function colorizePlanLockNotice(notice) {
|
|
228
|
+
return chalk.red(notice);
|
|
229
|
+
}
|
|
230
|
+
//# sourceMappingURL=plan-lock.js.map
|
|
@@ -224,7 +224,7 @@ export function emitPlanLimitNag(opts = {}) {
|
|
|
224
224
|
if (worst === null)
|
|
225
225
|
return;
|
|
226
226
|
warningShownThisSession = true;
|
|
227
|
-
const line = `⚠ HQ
|
|
227
|
+
const line = `⚠ HQ Starter plan: ${formatEntryLine(worst.key, worst.entry)}. Upgrade: ${resolvedUpgradeUrl}`;
|
|
228
228
|
write(chalk.yellow(line) + "\n");
|
|
229
229
|
}
|
|
230
230
|
catch {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
const TEAM_UPGRADE_HINT = "Run `hq billing upgrade` to move to
|
|
1
|
+
const TEAM_UPGRADE_HINT = "Run `hq billing upgrade` to move to HQ Workforce.";
|
|
2
2
|
export class PlanGateError extends Error {
|
|
3
3
|
code;
|
|
4
4
|
details;
|
|
@@ -23,19 +23,19 @@ function formatPlanGateDetails(code, details) {
|
|
|
23
23
|
typeof details.used === "number" &&
|
|
24
24
|
typeof details.limit === "number") {
|
|
25
25
|
const upgrade = typeof details.upgradeUrl === "string"
|
|
26
|
-
? `Upgrade to HQ
|
|
27
|
-
: "Upgrade to HQ
|
|
26
|
+
? `Upgrade to HQ Workforce ($500/mo) to remove limits: ${details.upgradeUrl}`
|
|
27
|
+
: "Upgrade to HQ Workforce ($500/mo) to remove limits.";
|
|
28
28
|
return [
|
|
29
|
-
`
|
|
29
|
+
`Starter plan limit reached: ${details.resource} ${details.used}/${details.limit} used.`,
|
|
30
30
|
upgrade,
|
|
31
31
|
existingResourcesNote,
|
|
32
32
|
].join("\n");
|
|
33
33
|
}
|
|
34
34
|
const upgrade = typeof details.upgradeUrl === "string"
|
|
35
|
-
? `Upgrade to HQ
|
|
36
|
-
: "Upgrade to HQ
|
|
35
|
+
? `Upgrade to HQ Workforce ($500/mo) to remove limits: ${details.upgradeUrl}`
|
|
36
|
+
: "Upgrade to HQ Workforce ($500/mo) to remove limits.";
|
|
37
37
|
return [
|
|
38
|
-
"HQ
|
|
38
|
+
"HQ Workforce plan required for this feature.",
|
|
39
39
|
upgrade,
|
|
40
40
|
existingResourcesNote,
|
|
41
41
|
].join("\n");
|
|
@@ -56,7 +56,7 @@ export async function offerTeamUpgrade(onConfirm, deps = {}) {
|
|
|
56
56
|
if (!(deps.interactive ?? canOfferTeamUpgrade(deps.argv)))
|
|
57
57
|
return false;
|
|
58
58
|
if (deps.ask) {
|
|
59
|
-
const answer = await deps.ask("Upgrade to HQ
|
|
59
|
+
const answer = await deps.ask("Upgrade to HQ Workforce ($500/mo) now? [y/N] ");
|
|
60
60
|
if (!/^y(?:es)?$/i.test(answer.trim()))
|
|
61
61
|
return false;
|
|
62
62
|
await onConfirm();
|
|
@@ -65,7 +65,7 @@ export async function offerTeamUpgrade(onConfirm, deps = {}) {
|
|
|
65
65
|
const { createInterface } = await import("node:readline/promises");
|
|
66
66
|
const readline = createInterface({ input: process.stdin, output: process.stderr });
|
|
67
67
|
try {
|
|
68
|
-
const answer = await readline.question("Upgrade to HQ
|
|
68
|
+
const answer = await readline.question("Upgrade to HQ Workforce ($500/mo) now? [y/N] ");
|
|
69
69
|
if (!/^y(?:es)?$/i.test(answer.trim()))
|
|
70
70
|
return false;
|
|
71
71
|
await onConfirm();
|
|
@@ -10,7 +10,7 @@ export interface TeamUpgradeOptions {
|
|
|
10
10
|
error?: (text: string) => void;
|
|
11
11
|
}
|
|
12
12
|
/**
|
|
13
|
-
* Start the owner-gated HQ
|
|
13
|
+
* Start the owner-gated HQ Workforce Checkout flow. Stripe collects payment details
|
|
14
14
|
* in its hosted browser page; the CLI never receives card data.
|
|
15
15
|
*/
|
|
16
16
|
export declare function upgradeToTeam(opts?: TeamUpgradeOptions): Promise<string>;
|
|
@@ -14,7 +14,7 @@ function ownerUpgradeError() {
|
|
|
14
14
|
return Object.assign(new Error("Only a company owner can upgrade. Ask a company owner to upgrade."), { expected: true });
|
|
15
15
|
}
|
|
16
16
|
/**
|
|
17
|
-
* Start the owner-gated HQ
|
|
17
|
+
* Start the owner-gated HQ Workforce Checkout flow. Stripe collects payment details
|
|
18
18
|
* in its hosted browser page; the CLI never receives card data.
|
|
19
19
|
*/
|
|
20
20
|
export async function upgradeToTeam(opts = {}) {
|
|
@@ -35,7 +35,7 @@ export async function upgradeToTeam(opts = {}) {
|
|
|
35
35
|
if (response.status === 403 || /owner/i.test(`${body.error ?? ""} ${body.message ?? ""}`)) {
|
|
36
36
|
throw ownerUpgradeError();
|
|
37
37
|
}
|
|
38
|
-
throw Object.assign(new Error(`Could not start HQ
|
|
38
|
+
throw Object.assign(new Error(`Could not start HQ Workforce checkout: ${body.error ?? body.message ?? response.statusText}`), { expected: true });
|
|
39
39
|
}
|
|
40
40
|
const { url } = (await response.json());
|
|
41
41
|
if (typeof url !== "string" || !url) {
|
|
@@ -46,7 +46,7 @@ export async function upgradeToTeam(opts = {}) {
|
|
|
46
46
|
write(`${JSON.stringify({ url })}\n`);
|
|
47
47
|
return url;
|
|
48
48
|
}
|
|
49
|
-
write("Opening HQ
|
|
49
|
+
write("Opening HQ Workforce checkout ($500/mo) in your browser — finish there and you're upgraded.\n");
|
|
50
50
|
const skipBrowser = opts.noBrowser || (opts.headless ?? isHeadless());
|
|
51
51
|
if (skipBrowser) {
|
|
52
52
|
write(`Open this URL to continue: ${url}\n`);
|