@indigoai-us/hq-cli 5.117.0 → 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.
Files changed (34) hide show
  1. package/CHANGELOG.md +52 -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 +6 -3
  5. package/dist/command-catalog.generated.js +7 -3
  6. package/dist/commands/agent-kit.d.ts +23 -3
  7. package/dist/commands/agent-kit.js +126 -14
  8. package/dist/commands/agent-probe.d.ts +15 -9
  9. package/dist/commands/agent-probe.js +55 -35
  10. package/dist/commands/billing.js +1 -1
  11. package/dist/commands/db-provision.js +4 -4
  12. package/dist/commands/dm.d.ts +10 -0
  13. package/dist/commands/dm.js +80 -0
  14. package/dist/commands/meetings.js +2 -2
  15. package/dist/commands/whoami.d.ts +7 -0
  16. package/dist/commands/whoami.js +55 -1
  17. package/dist/lib/agent-kit/fallback.d.ts +63 -0
  18. package/dist/lib/agent-kit/fallback.js +129 -0
  19. package/dist/lib/agent-kit/run/inbox.d.ts +18 -6
  20. package/dist/lib/agent-kit/run/inbox.js +40 -8
  21. package/dist/lib/agent-kit/run/mesh-listener.d.ts +22 -6
  22. package/dist/lib/agent-kit/run/mesh-listener.js +55 -10
  23. package/dist/lib/agent-kit/run/supervisor.d.ts +35 -0
  24. package/dist/lib/agent-kit/run/supervisor.js +85 -0
  25. package/dist/lib/agent-kit/skills.js +2 -2
  26. package/dist/lib/billing/plan-lock.d.ts +99 -0
  27. package/dist/lib/billing/plan-lock.js +230 -0
  28. package/dist/lib/mesh/live/daemon/credentials.d.ts +27 -0
  29. package/dist/lib/mesh/live/daemon/credentials.js +95 -0
  30. package/dist/lib/plan-limit-nag.js +1 -1
  31. package/dist/utils/plan-gate-error.js +9 -9
  32. package/dist/utils/team-upgrade.d.ts +1 -1
  33. package/dist/utils/team-upgrade.js +3 -3
  34. package/package.json +1 -1
@@ -4,9 +4,14 @@
4
4
  *
5
5
  * whoami mint as the machine identity; token names this agent
6
6
  * team-sync `hq sync pull --all` succeeds and the company folder exists
7
- * work-mesh realtime credentials vend for this agent and the roster row
8
- * shows presence online/stale (heartbeat landed)
9
- * dm send a DM to self via the notify surface and read it back
7
+ * work-mesh the personal (contract-2) realtime vend names this agent, the
8
+ * agent-authorized inbox route answers 200, and this host's own
9
+ * presence is live: a fresh last-heartbeat.json plus an ok
10
+ * component-mesh stamp from the doorbell listener. (The company
11
+ * roster route is owner/admin-only and 404s for agt_ callers.)
12
+ * dm send a DM to self via POST /v1/notify/dm (accepted) and read the agent inbox (200)
13
+ * through GET /v1/agents/{uid}/inbox (the agent read surface;
14
+ * /v1/notify/thread is intentionally closed to agents)
10
15
  * secrets `GET /secrets/{companyUid}` answers 200
11
16
  *
12
17
  * Output is the exact shape the console recipes promise: one
@@ -26,15 +31,18 @@ import { CLI_VERSION } from "../cli-version.js";
26
31
  import { ensureCognitoToken } from "../utils/cognito-session.js";
27
32
  import { peekIdToken } from "../utils/id-token.js";
28
33
  import { getCompanyUid, vaultApiFetch } from "../utils/vault-api.js";
29
- import { listAgents, readAgentThread, sendAgentDm } from "./agents.js";
30
- import { createContract3Fetcher } from "../lib/mesh/live/daemon/credentials.js";
34
+ import { sendAgentDm } from "./agents.js";
35
+ import { createPersonalRealtimeFetcher } from "../lib/mesh/live/daemon/credentials.js";
36
+ import { readComponentStatus } from "../lib/agent-kit/creds.js";
37
+ import { COMPONENT_STALE_AFTER_MS } from "../lib/agent-kit/run/heartbeat.js";
38
+ import { defaultFetchInbox } from "../lib/agent-kit/run/inbox.js";
31
39
  import { agentKitPaths } from "../lib/agent-kit/paths.js";
32
40
  import { readKitConfig } from "../lib/agent-kit/kit-config.js";
33
41
  import { readLastHeartbeat } from "../lib/agent-kit/run/heartbeat.js";
34
42
  import { defaultRunPull, syncPullArgs } from "../lib/agent-kit/run/sync.js";
35
43
  import { requireExternalCreds } from "./agent-kit.js";
36
- export const DM_ROUNDTRIP_TIMEOUT_MS = 20_000;
37
- export const DM_ROUNDTRIP_POLL_MS = 2_000;
44
+ /** A heartbeat older than this does not count as live presence (3 missed 60 s beats). */
45
+ export const PRESENCE_FRESH_SECONDS = 180;
38
46
  function errText(err) {
39
47
  return err instanceof Error ? err.message : String(err);
40
48
  }
@@ -49,7 +57,6 @@ export function heartbeatAgeSeconds(paths, now = () => new Date()) {
49
57
  }
50
58
  export async function runProbe(deps) {
51
59
  const now = deps.now ?? (() => new Date());
52
- const sleep = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
53
60
  const nonce = (deps.nonce ?? (() => randomBytes(6).toString("hex")))();
54
61
  const self = deps.creds.entityUid;
55
62
  const checks = [];
@@ -108,7 +115,7 @@ export async function runProbe(deps) {
108
115
  else {
109
116
  checks.push({ name: "team-sync", ok: false, detail: "skipped: no token" });
110
117
  }
111
- // 3. mesh-presence
118
+ // 3. work-mesh: vend + agent-authorized reachability + self presence
112
119
  if (token && companyUid) {
113
120
  try {
114
121
  const vend = await deps.vendRealtime(token);
@@ -116,17 +123,23 @@ export async function runProbe(deps) {
116
123
  checks.push({ name: "work-mesh", ok: false, detail: `realtime vend is for ${vend.actorUid}` });
117
124
  }
118
125
  else {
119
- const roster = await deps.listRoster(token, companyUid);
120
- const row = roster.find((r) => r.uid === self);
121
- const presence = typeof row?.presence === "string" ? row.presence : "unknown";
122
- const last = typeof row?.lastHeartbeatAt === "string" ? row.lastHeartbeatAt : "never";
123
- const ok = presence === "online" || presence === "stale";
126
+ const inbox = await deps.readInbox(token);
127
+ const age = heartbeatAgeSeconds(deps.paths, now);
128
+ const mesh = readComponentStatus(deps.paths, "mesh", COMPONENT_STALE_AFTER_MS.mesh, now);
129
+ const reachable = inbox.status === 200;
130
+ const fresh = age !== null && age <= PRESENCE_FRESH_SECONDS;
131
+ const meshOk = mesh.status === "ok";
132
+ const ok = reachable && fresh && meshOk;
133
+ const parts = [
134
+ "realtime vend ok",
135
+ `inbox GET → ${inbox.status}`,
136
+ `heartbeat ${age === null ? "never" : `${age}s ago`}`,
137
+ `mesh listener ${mesh.status}${mesh.at ? ` @${mesh.at.toISOString()}` : ""}`,
138
+ ];
124
139
  checks.push({
125
140
  name: "work-mesh",
126
141
  ok,
127
- detail: row
128
- ? `realtime vend ok; presence=${presence} lastHeartbeatAt=${last}${ok ? "" : " — is `hq agent kit` installed and running?"}`
129
- : "realtime vend ok but this agent is not on the company roster",
142
+ detail: `${parts.join("; ")}${ok ? "" : " — is `hq agent kit` installed and running? see ~/.hq-agent/logs/mesh.log"}`,
130
143
  });
131
144
  }
132
145
  }
@@ -137,27 +150,22 @@ export async function runProbe(deps) {
137
150
  else {
138
151
  checks.push({ name: "work-mesh", ok: false, detail: "skipped: no token/company" });
139
152
  }
140
- // 4. dm-roundtrip
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.
141
158
  if (token) {
142
159
  try {
143
160
  const body = `hq agent probe ${nonce}`;
144
161
  await deps.sendDm(token, self, body);
145
- const deadline = now().getTime() + (deps.dmTimeoutMs ?? DM_ROUNDTRIP_TIMEOUT_MS);
146
- let found = false;
147
- for (;;) {
148
- const msgs = await deps.readThread(token, self);
149
- if (msgs.some((m) => typeof m.body === "string" && m.body.includes(nonce))) {
150
- found = true;
151
- break;
152
- }
153
- if (now().getTime() >= deadline)
154
- break;
155
- await sleep(DM_ROUNDTRIP_POLL_MS);
156
- }
162
+ const res = await deps.readInbox(token);
157
163
  checks.push({
158
164
  name: "dm",
159
- ok: found,
160
- detail: found ? "sent to self via /v1/notify/dm and read back" : `sent but not visible in thread after ${Math.round((deps.dmTimeoutMs ?? DM_ROUNDTRIP_TIMEOUT_MS) / 1000)}s`,
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}`,
161
169
  });
162
170
  }
163
171
  catch (err) {
@@ -205,6 +213,19 @@ export async function runProbe(deps) {
205
213
  }
206
214
  return result;
207
215
  }
216
+ /** True when any inbox item (whatever its channel shape) carries the nonce. */
217
+ export function inboxContainsNonce(body, nonce) {
218
+ const rec = body && typeof body === "object" ? body : {};
219
+ const messages = Array.isArray(rec.messages) ? rec.messages : [];
220
+ return messages.some((m) => {
221
+ try {
222
+ return JSON.stringify(m).includes(nonce);
223
+ }
224
+ catch {
225
+ return false;
226
+ }
227
+ });
228
+ }
208
229
  export function formatHeartbeatAge(age) {
209
230
  if (age === null)
210
231
  return "never (kit heartbeat has not posted yet)";
@@ -241,12 +262,11 @@ export function defaultProbeDeps(paths, creds) {
241
262
  resolveCompanyUid: (token, slug) => getCompanyUid(token, slug),
242
263
  runPull: defaultRunPull(process.execPath, process.argv[1], process.env),
243
264
  vendRealtime: async (token) => {
244
- const bundle = await createContract3Fetcher({ token, baseUrl: base })();
265
+ const bundle = await createPersonalRealtimeFetcher({ token, baseUrl: base })();
245
266
  return { actorUid: bundle.actorUid };
246
267
  },
247
- listRoster: async (token, companyUid) => (await listAgents(token, companyUid)),
268
+ readInbox: (token) => defaultFetchInbox(creds.entityUid, base)(token),
248
269
  sendDm: (token, to, body) => sendAgentDm(token, to, body),
249
- readThread: async (token, withUid) => readAgentThread(token, withUid, 20),
250
270
  listSecrets: async (token, companyUid) => {
251
271
  const res = await vaultApiFetch({
252
272
  token,
@@ -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).");
@@ -259,5 +259,15 @@ export declare function formatInboxEvent(e: DmInboxEvent, nowMs: number): string
259
259
  export declare function formatThreadMessage(m: DmThreadMessage, nowMs: number): string;
260
260
  /** Render one channel/group message line. */
261
261
  export declare function formatChannelMessage(m: ChannelMessageItem, nowMs: number): string;
262
+ /**
263
+ * The agent uid when the caller is an agent (machine) identity, else null.
264
+ * Agents may not read /v1/notify/inbox or /v1/notify/thread (the server
265
+ * allow-list is intentionally human-only); their read surface is
266
+ * GET /v1/agents/{uid}/inbox.
267
+ */
268
+ export declare function agentCallerUid(token: string, machine?: () => boolean): string | null;
269
+ export declare const AGENT_THREAD_UNAVAILABLE: string;
270
+ /** One-line summary of an agent inbox item (DM, email, … — shapes vary by channel). Pure. */
271
+ export declare function formatAgentInboxItem(item: Record<string, unknown>): string;
262
272
  export declare function registerDmCommand(program: Command): void;
263
273
  //# sourceMappingURL=dm.d.ts.map
@@ -2,6 +2,8 @@ import chalk from "chalk";
2
2
  import { readFileSync } from "node:fs";
3
3
  import { ensureCognitoToken } from "../utils/cognito-session.js";
4
4
  import { vaultApiFetch } from "../utils/vault-api.js";
5
+ import { isMachineIdentity } from "@indigoai-us/hq-cloud";
6
+ import { peekIdToken } from "../utils/id-token.js";
5
7
  const EMAIL_PATTERN = /^[^\s]+@[^\s]+$/;
6
8
  // People (prs_) and agents (agt_) are both first-class DM participants; the
7
9
  // server applies the same membership-overlap gate to either and delivers
@@ -789,9 +791,83 @@ async function ackEvents(token, eventIds) {
789
791
  throw new Error(friendlyDmError(res.status, err.code, err.error ?? err.message ?? res.statusText));
790
792
  }
791
793
  }
794
+ /**
795
+ * The agent uid when the caller is an agent (machine) identity, else null.
796
+ * Agents may not read /v1/notify/inbox or /v1/notify/thread (the server
797
+ * allow-list is intentionally human-only); their read surface is
798
+ * GET /v1/agents/{uid}/inbox.
799
+ */
800
+ export function agentCallerUid(token, machine = isMachineIdentity) {
801
+ const claims = peekIdToken(token);
802
+ const uid = claims["custom:entityUid"];
803
+ if (typeof uid !== "string" || !uid.startsWith("agt_"))
804
+ return null;
805
+ if (claims["custom:entityType"] === "agent")
806
+ return uid;
807
+ try {
808
+ return machine() ? uid : null;
809
+ }
810
+ catch {
811
+ return null;
812
+ }
813
+ }
814
+ export const AGENT_THREAD_UNAVAILABLE = "`hq dm thread` reads /v1/notify/thread, which is not available to agent identities. " +
815
+ "Use `hq dm inbox` — for agents it reads GET /v1/agents/{uid}/inbox.";
816
+ /** One-line summary of an agent inbox item (DM, email, … — shapes vary by channel). Pure. */
817
+ export function formatAgentInboxItem(item) {
818
+ const str = (k) => (typeof item[k] === "string" ? item[k] : "");
819
+ const id = str("messageId") || str("id") || "?";
820
+ const channel = str("channel") || "inbox";
821
+ const from = str("fromDisplayName") || str("fromEmail") || str("from") || str("fromPersonUid") || "unknown";
822
+ const when = str("createdAt") || str("receivedAt");
823
+ const text = str("text") || str("body") || str("subject");
824
+ return `${chalk.bold(from)} ${chalk.dim(`[${channel}] ${id}${when ? ` ${when}` : ""}`)}\n ${firstLine(text)}`;
825
+ }
826
+ async function runAgentInbox(token, agentUid, opts) {
827
+ const route = `/v1/agents/${encodeURIComponent(agentUid)}/inbox`;
828
+ const res = await vaultApiFetch({ token, path: route });
829
+ if (!res.ok) {
830
+ const text = await res.text().catch(() => "");
831
+ console.error(chalk.red(`GET ${route} → ${res.status}${text ? `: ${text.slice(0, 300)}` : ""}`));
832
+ process.exit(1);
833
+ }
834
+ const data = (await res.json().catch(() => ({})));
835
+ let items = Array.isArray(data.messages) ? data.messages : [];
836
+ if (opts.limit && Number(opts.limit) > 0)
837
+ items = items.slice(0, Number(opts.limit));
838
+ if (opts.json) {
839
+ console.log(JSON.stringify(items, null, 2));
840
+ }
841
+ else if (items.length === 0) {
842
+ console.log(chalk.dim("No pending inbox items."));
843
+ }
844
+ else {
845
+ console.log(chalk.green(`${items.length} pending inbox item${items.length === 1 ? "" : "s"}:`));
846
+ for (const item of items)
847
+ console.log(`\n${formatAgentInboxItem(item)}`);
848
+ }
849
+ if (opts.markRead) {
850
+ let acked = 0;
851
+ for (const item of items) {
852
+ const id = typeof item.messageId === "string" ? item.messageId : typeof item.id === "string" ? item.id : null;
853
+ if (!id)
854
+ continue;
855
+ const ack = await vaultApiFetch({ token, path: `${route}/${encodeURIComponent(id)}/ack`, method: "POST" });
856
+ if (ack.ok)
857
+ acked += 1;
858
+ }
859
+ if (!opts.json && acked > 0)
860
+ console.log(chalk.dim(`\nAcked ${acked}.`));
861
+ }
862
+ }
792
863
  async function runDmInbox(opts) {
793
864
  try {
794
865
  const token = await ensureCognitoToken();
866
+ const agentUid = agentCallerUid(token);
867
+ if (agentUid) {
868
+ await runAgentInbox(token, agentUid, opts);
869
+ return;
870
+ }
795
871
  const query = {};
796
872
  if (opts.limit)
797
873
  query.limit = opts.limit;
@@ -841,6 +917,10 @@ async function runDmThread(identifier, opts) {
841
917
  if (opts.limit)
842
918
  query.limit = opts.limit;
843
919
  const token = await ensureCognitoToken();
920
+ if (agentCallerUid(token)) {
921
+ console.error(chalk.yellow(AGENT_THREAD_UNAVAILABLE));
922
+ process.exit(1);
923
+ }
844
924
  const res = await vaultApiFetch({
845
925
  token,
846
926
  path: "/v1/notify/thread",
@@ -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');
@@ -0,0 +1,63 @@
1
+ /**
2
+ * No-service-manager fallback for `hq agent kit install`.
3
+ *
4
+ * When systemd --user cannot be reached (no user session bus, permission
5
+ * refused) or launchd is unavailable, the kit is started as ONE detached
6
+ * `hq agent kit run all` supervisor with output appended to
7
+ * ~/.hq-agent/logs/kit.log and its pid recorded in ~/.hq-agent/kit.pid.
8
+ * `kit status` reads the pidfile; `kit uninstall` kills it and removes it.
9
+ */
10
+ import type { ServiceHostPaths, ServiceSetResult } from "../service-manager/index.js";
11
+ import type { AgentKitPaths } from "./paths.js";
12
+ export declare const KIT_PID_NAME = "kit.pid";
13
+ export declare const KIT_FALLBACK_LOG_NAME = "kit.log";
14
+ export declare function kitPidPath(paths: Pick<AgentKitPaths, "agentDir">): string;
15
+ export declare function kitFallbackLogPath(paths: Pick<AgentKitPaths, "logsDir">): string;
16
+ /** True when an activation error means the service manager itself is unusable. */
17
+ export declare function isServiceManagerUnavailable(error: string | undefined): boolean;
18
+ /**
19
+ * Pick the fallback when activation was requested and the host has no service
20
+ * manager integration, or every failure is a service-manager-unavailable error.
21
+ */
22
+ export declare function shouldUseFallback(result: ServiceSetResult, activate: boolean): boolean;
23
+ export declare function readKitPid(paths: Pick<AgentKitPaths, "agentDir">): number | null;
24
+ export type KillFn = (pid: number, signal?: NodeJS.Signals | 0) => boolean;
25
+ export declare function isPidAlive(pid: number, kill?: KillFn): boolean;
26
+ export interface FallbackStatus {
27
+ pidFile: string;
28
+ pid: number | null;
29
+ running: boolean;
30
+ }
31
+ export declare function fallbackStatus(paths: Pick<AgentKitPaths, "agentDir">, kill?: KillFn): FallbackStatus | null;
32
+ /** Kill a recorded supervisor (if alive) and remove the pidfile. */
33
+ export declare function stopFallback(paths: Pick<AgentKitPaths, "agentDir">, kill?: KillFn): {
34
+ pid: number | null;
35
+ killed: boolean;
36
+ };
37
+ export interface DetachedSpawn {
38
+ (cmd: string, args: string[], opts: {
39
+ detached: true;
40
+ stdio: ["ignore", number, number];
41
+ env: NodeJS.ProcessEnv;
42
+ cwd: string;
43
+ }): {
44
+ pid?: number;
45
+ unref(): void;
46
+ };
47
+ }
48
+ export declare function fallbackCommand(host: ServiceHostPaths): {
49
+ cmd: string;
50
+ args: string[];
51
+ };
52
+ /** crontab line that restarts the fallback supervisor after a reboot. */
53
+ export declare function rebootCrontabLine(paths: Pick<AgentKitPaths, "agentDir" | "logsDir">, host: ServiceHostPaths): string;
54
+ export declare function startFallback(paths: AgentKitPaths, host: ServiceHostPaths, deps?: {
55
+ spawn?: DetachedSpawn;
56
+ kill?: KillFn;
57
+ env?: NodeJS.ProcessEnv;
58
+ }): {
59
+ pid: number;
60
+ logPath: string;
61
+ pidFile: string;
62
+ };
63
+ //# sourceMappingURL=fallback.d.ts.map
@@ -0,0 +1,129 @@
1
+ /**
2
+ * No-service-manager fallback for `hq agent kit install`.
3
+ *
4
+ * When systemd --user cannot be reached (no user session bus, permission
5
+ * refused) or launchd is unavailable, the kit is started as ONE detached
6
+ * `hq agent kit run all` supervisor with output appended to
7
+ * ~/.hq-agent/logs/kit.log and its pid recorded in ~/.hq-agent/kit.pid.
8
+ * `kit status` reads the pidfile; `kit uninstall` kills it and removes it.
9
+ */
10
+ import { spawn } from "node:child_process";
11
+ import * as fs from "node:fs";
12
+ import * as path from "node:path";
13
+ import { kitServiceEnv } from "./services.js";
14
+ export const KIT_PID_NAME = "kit.pid";
15
+ export const KIT_FALLBACK_LOG_NAME = "kit.log";
16
+ export function kitPidPath(paths) {
17
+ return path.join(paths.agentDir, KIT_PID_NAME);
18
+ }
19
+ export function kitFallbackLogPath(paths) {
20
+ return path.join(paths.logsDir, KIT_FALLBACK_LOG_NAME);
21
+ }
22
+ const UNAVAILABLE_PATTERNS = [
23
+ /no user session bus/i,
24
+ /failed to connect to (?:user )?bus/i,
25
+ /DBUS_SESSION_BUS_ADDRESS/i,
26
+ /XDG_RUNTIME_DIR/i,
27
+ /permission denied/i,
28
+ /operation not permitted/i,
29
+ /access denied/i,
30
+ /\bENOENT\b/i,
31
+ /command not found|spawnSync \S+ ENOENT/i,
32
+ /launchctl bootstrap failed/i,
33
+ /System has not been booted with systemd/i,
34
+ ];
35
+ /** True when an activation error means the service manager itself is unusable. */
36
+ export function isServiceManagerUnavailable(error) {
37
+ if (!error)
38
+ return false;
39
+ return UNAVAILABLE_PATTERNS.some((re) => re.test(error));
40
+ }
41
+ /**
42
+ * Pick the fallback when activation was requested and the host has no service
43
+ * manager integration, or every failure is a service-manager-unavailable error.
44
+ */
45
+ export function shouldUseFallback(result, activate) {
46
+ if (!activate)
47
+ return false;
48
+ if (result.platform === "other")
49
+ return true;
50
+ const failed = result.units.filter((u) => u.running === false);
51
+ return failed.length > 0 && failed.some((u) => isServiceManagerUnavailable(u.error));
52
+ }
53
+ export function readKitPid(paths) {
54
+ try {
55
+ const n = Number.parseInt(fs.readFileSync(kitPidPath(paths), "utf8").trim(), 10);
56
+ return Number.isInteger(n) && n > 0 ? n : null;
57
+ }
58
+ catch {
59
+ return null;
60
+ }
61
+ }
62
+ const defaultKill = (pid, signal) => process.kill(pid, signal);
63
+ export function isPidAlive(pid, kill = defaultKill) {
64
+ try {
65
+ kill(pid, 0);
66
+ return true;
67
+ }
68
+ catch (err) {
69
+ // EPERM: exists but owned by someone else — still alive.
70
+ return err?.code === "EPERM";
71
+ }
72
+ }
73
+ export function fallbackStatus(paths, kill) {
74
+ if (!fs.existsSync(kitPidPath(paths)))
75
+ return null;
76
+ const pid = readKitPid(paths);
77
+ return { pidFile: kitPidPath(paths), pid, running: pid !== null && isPidAlive(pid, kill) };
78
+ }
79
+ /** Kill a recorded supervisor (if alive) and remove the pidfile. */
80
+ export function stopFallback(paths, kill = defaultKill) {
81
+ const pid = readKitPid(paths);
82
+ let killed = false;
83
+ if (pid !== null && isPidAlive(pid, kill)) {
84
+ try {
85
+ kill(pid, "SIGTERM");
86
+ killed = true;
87
+ }
88
+ catch {
89
+ killed = false;
90
+ }
91
+ }
92
+ fs.rmSync(kitPidPath(paths), { force: true });
93
+ return { pid, killed };
94
+ }
95
+ export function fallbackCommand(host) {
96
+ return { cmd: host.nodeBinary, args: [host.hqBinary, "agent", "kit", "run", "all"] };
97
+ }
98
+ /** crontab line that restarts the fallback supervisor after a reboot. */
99
+ export function rebootCrontabLine(paths, host) {
100
+ const q = (v) => `'${v.replace(/'/g, `'\\''`)}'`;
101
+ const { cmd, args } = fallbackCommand(host);
102
+ return `@reboot HQ_AGENT_DIR=${q(paths.agentDir)} ${[cmd, ...args].map(q).join(" ")} >> ${q(kitFallbackLogPath(paths))} 2>&1`;
103
+ }
104
+ export function startFallback(paths, host, deps = {}) {
105
+ // Re-install replaces a running supervisor, like a unit restart.
106
+ stopFallback(paths, deps.kill);
107
+ fs.mkdirSync(paths.logsDir, { recursive: true, mode: 0o700 });
108
+ const logPath = kitFallbackLogPath(paths);
109
+ const fd = fs.openSync(logPath, "a", 0o600);
110
+ const doSpawn = deps.spawn ?? ((cmd, args, opts) => spawn(cmd, args, opts));
111
+ const { cmd, args } = fallbackCommand(host);
112
+ try {
113
+ const child = doSpawn(cmd, args, {
114
+ detached: true,
115
+ stdio: ["ignore", fd, fd],
116
+ env: { ...(deps.env ?? process.env), HOME: host.home, ...kitServiceEnv(paths) },
117
+ cwd: paths.agentDir,
118
+ });
119
+ if (!child.pid)
120
+ throw new Error(`could not start ${cmd} ${args.join(" ")}`);
121
+ child.unref();
122
+ fs.writeFileSync(kitPidPath(paths), `${child.pid}\n`, { mode: 0o600 });
123
+ return { pid: child.pid, logPath, pidFile: kitPidPath(paths) };
124
+ }
125
+ finally {
126
+ fs.closeSync(fd);
127
+ }
128
+ }
129
+ //# sourceMappingURL=fallback.js.map