@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
@@ -37,19 +37,31 @@ export interface InboxDeps {
37
37
  ack: boolean;
38
38
  getToken: () => Promise<string>;
39
39
  log: KitLogger;
40
- fetchInbox?: (token: string) => Promise<{
41
- status: number;
42
- body: unknown;
43
- }>;
40
+ fetchInbox?: (token: string) => Promise<InboxFetchResult>;
44
41
  ackItem?: (token: string, id: string) => Promise<number>;
45
42
  warmCache?: (token: string, agentUid: string) => Promise<unknown>;
46
43
  sleep?: (ms: number) => Promise<void>;
47
44
  maxPolls?: number;
48
45
  }
49
- export declare function defaultFetchInbox(agentUid: string, apiBaseUrl: string): (token: string) => Promise<{
46
+ export declare const INBOX_ERROR_BODY_MAX = 300;
47
+ export interface InboxFetchResult {
50
48
  status: number;
51
49
  body: unknown;
52
- }>;
50
+ /** Full request URL (never carries credentials). */
51
+ url?: string;
52
+ /** Raw response text, truncated to INBOX_ERROR_BODY_MAX chars. */
53
+ bodyText?: string;
54
+ }
55
+ /**
56
+ * Join apiBaseUrl (from machine-creds.json) with the agent inbox route.
57
+ * `new URL("/v1/…", base)` would silently drop a base path such as an API
58
+ * Gateway stage (`…/prod`), and a base that already ends in `/v1` must not
59
+ * become `/v1/v1`. Trailing slashes are tolerated.
60
+ */
61
+ export declare function agentApiUrl(apiBaseUrl: string, route: string): string;
62
+ export declare function agentInboxUrl(apiBaseUrl: string, agentUid: string): string;
63
+ export declare function truncateBody(text: string, max?: number): string;
64
+ export declare function defaultFetchInbox(agentUid: string, apiBaseUrl: string): (token: string) => Promise<InboxFetchResult>;
53
65
  export declare function defaultAckItem(agentUid: string, apiBaseUrl: string): (token: string, id: string) => Promise<number>;
54
66
  export declare function pollInboxOnce(deps: InboxDeps): Promise<{
55
67
  ok: boolean;
@@ -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";
@@ -60,15 +59,45 @@ export function mirrorInboxItem(paths, item) {
60
59
  fs.appendFileSync(path.join(paths.inboxDir, INBOX_JSONL_NAME), `${JSON.stringify({ id: item.id, mirroredAt: new Date().toISOString(), ...item.raw })}\n`, { mode: 0o600 });
61
60
  return dest;
62
61
  }
62
+ export const INBOX_ERROR_BODY_MAX = 300;
63
+ /**
64
+ * Join apiBaseUrl (from machine-creds.json) with the agent inbox route.
65
+ * `new URL("/v1/…", base)` would silently drop a base path such as an API
66
+ * Gateway stage (`…/prod`), and a base that already ends in `/v1` must not
67
+ * become `/v1/v1`. Trailing slashes are tolerated.
68
+ */
69
+ export function agentApiUrl(apiBaseUrl, route) {
70
+ const base = new URL(apiBaseUrl);
71
+ let prefix = base.pathname.replace(/\/+$/, "");
72
+ const suffix = route.startsWith("/") ? route : `/${route}`;
73
+ if (prefix.endsWith("/v1") && suffix.startsWith("/v1/"))
74
+ prefix = prefix.slice(0, -3);
75
+ return `${base.origin}${prefix}${suffix}`;
76
+ }
77
+ export function agentInboxUrl(apiBaseUrl, agentUid) {
78
+ return agentApiUrl(apiBaseUrl, `/v1/agents/${encodeURIComponent(agentUid)}/inbox`);
79
+ }
80
+ export function truncateBody(text, max = INBOX_ERROR_BODY_MAX) {
81
+ const flat = text.replace(/\s+/g, " ").trim();
82
+ return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat;
83
+ }
63
84
  export function defaultFetchInbox(agentUid, apiBaseUrl) {
64
85
  return async (token) => {
86
+ const url = new URL(agentInboxUrl(apiBaseUrl, agentUid));
65
87
  const res = await vaultApiFetch({
66
88
  token,
67
- baseUrl: apiBaseUrl,
68
- path: `/v1/agents/${encodeURIComponent(agentUid)}/inbox`,
89
+ baseUrl: url.origin,
90
+ path: url.pathname,
69
91
  });
70
- const body = await res.json().catch(() => ({}));
71
- return { status: res.status, body };
92
+ const text = await res.text().catch(() => "");
93
+ let body;
94
+ try {
95
+ body = text ? JSON.parse(text) : {};
96
+ }
97
+ catch {
98
+ body = {};
99
+ }
100
+ return { status: res.status, body, url: url.toString(), bodyText: truncateBody(text) };
72
101
  };
73
102
  }
74
103
  export function defaultAckItem(agentUid, apiBaseUrl) {
@@ -85,7 +114,8 @@ export function defaultAckItem(agentUid, apiBaseUrl) {
85
114
  export async function pollInboxOnce(deps) {
86
115
  const fetchInbox = deps.fetchInbox ?? defaultFetchInbox(deps.agentUid, deps.apiBaseUrl);
87
116
  const ackItem = deps.ackItem ?? defaultAckItem(deps.agentUid, deps.apiBaseUrl);
88
- 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 () => { });
89
119
  let token;
90
120
  try {
91
121
  token = await deps.getToken();
@@ -101,12 +131,14 @@ export async function pollInboxOnce(deps) {
101
131
  }
102
132
  catch (err) {
103
133
  writeComponentStatus(deps.paths, "inbox", "error");
104
- deps.log("error", `inbox fetch failed: ${err instanceof Error ? err.message : String(err)}`);
134
+ deps.log("error", `inbox fetch failed: GET ${agentInboxUrl(deps.apiBaseUrl, deps.agentUid)}: ${err instanceof Error ? err.message : String(err)}`);
105
135
  return { ok: false, mirrored: 0 };
106
136
  }
107
137
  if (res.status !== 200) {
108
138
  writeComponentStatus(deps.paths, "inbox", "error");
109
- deps.log("warn", `inbox poll ${res.status}`);
139
+ const url = res.url ?? agentInboxUrl(deps.apiBaseUrl, deps.agentUid);
140
+ const bodyText = truncateBody(res.bodyText ?? (res.body === undefined ? "" : JSON.stringify(res.body)));
141
+ deps.log("warn", `inbox poll GET ${url} → ${res.status}${bodyText ? ` body=${bodyText}` : ""}`);
110
142
  return { ok: false, mirrored: 0 };
111
143
  }
112
144
  writeComponentStatus(deps.paths, "inbox", "ok");
@@ -3,25 +3,39 @@
3
3
  *
4
4
  * Policy hq-work-mesh-source-of-truth: the work mesh (REST) is the source of
5
5
  * truth; MQTT carries IDS-ONLY doorbells on the agent's personal topics
6
- * `hq/{agt_*}/{dm,work,sessions,notifications}`. This listener is the cache
6
+ * `hq/{agt_*}/{dm,work,sessions,notifications,inbox}`. This listener is the cache
7
7
  * writer: on any doorbell it refetches through the REST API into
8
8
  * ~/.hq/work-mesh/cache (warmMeshConversationCache) — message bodies are
9
9
  * never taken from MQTT. A periodic refresh covers missed doorbells.
10
10
  *
11
- * Credentials come from the same contract-3 vend the mesh daemon uses
12
- * (POST /v1/realtime/credentials); the WSS URL is SigV4-presigned. The
11
+ * Credentials come from the contract-2 (personal) vend
12
+ * (POST /v1/realtime/credentials {contractVersion: 2}), whose STS policy grants
13
+ * the caller's own `hq/{uid}/*` topics. The contract-3 vend the presence daemon
14
+ * uses only grants company presence + thread-directory topics, so subscribing
15
+ * to personal doorbells with it made AWS IoT drop the connection before SUBACK.
16
+ * Only doorbell topics the vend advertises are subscribed (one ungranted topic
17
+ * in a SUBSCRIBE disconnects the whole client). An `inbox` doorbell triggers
18
+ * an immediate inbox poll via `onInboxDoorbell`. The WSS URL is SigV4-presigned. The
13
19
  * connection is rebuilt before the vended credentials expire. `component-mesh`
14
20
  * is stamped ok while subscribed and refreshing, error otherwise.
15
21
  */
16
22
  import { type IClientOptions } from "mqtt";
17
- import { type CredentialsFetcher } from "../../mesh/live/daemon/credentials.js";
23
+ import { type PersonalCredentialsFetcher, type PersonalRealtimeBundle } from "../../mesh/live/daemon/credentials.js";
18
24
  import type { KitLogger } from "../log.js";
19
25
  import type { AgentKitPaths } from "../paths.js";
20
- export declare const DOORBELL_KINDS: readonly ["dm", "work", "sessions", "notifications"];
26
+ export declare const DOORBELL_KINDS: readonly ["dm", "work", "sessions", "notifications", "inbox"];
21
27
  export declare const DOORBELL_DEBOUNCE_MS = 2000;
22
28
  export declare const RECONNECT_BASE_MS = 1000;
23
29
  export declare const RECONNECT_MAX_MS = 60000;
24
30
  export declare function doorbellTopics(actorUid: string): string[];
31
+ /**
32
+ * Doorbell topics this session may subscribe to: the intersection of
33
+ * DOORBELL_KINDS with the personal topics the vend advertises. AWS IoT
34
+ * disconnects the client on any unauthorized topic in a SUBSCRIBE, so a kind
35
+ * the server does not (yet) grant is skipped rather than risked.
36
+ */
37
+ export declare function grantedDoorbellTopics(bundle: Pick<PersonalRealtimeBundle, "actorUid" | "topics">): string[];
38
+ export declare function isInboxDoorbell(topic: string): boolean;
25
39
  /** Minimal mqtt client surface the listener needs (test seam). */
26
40
  export interface DoorbellMqttClient {
27
41
  on(event: string, handler: (...args: any[]) => void): unknown;
@@ -38,7 +52,9 @@ export interface MeshListenerDeps {
38
52
  refreshMs: number;
39
53
  getToken: () => Promise<string>;
40
54
  log: KitLogger;
41
- fetchCredentials?: CredentialsFetcher;
55
+ fetchCredentials?: PersonalCredentialsFetcher;
56
+ /** Called immediately (not debounced) when an `hq/{uid}/inbox` doorbell rings. */
57
+ onInboxDoorbell?: () => Promise<unknown> | void;
42
58
  connect?: DoorbellConnectFn;
43
59
  refetch?: (token: string, actorUid: string) => Promise<unknown>;
44
60
  now?: () => Date;
@@ -3,28 +3,47 @@
3
3
  *
4
4
  * Policy hq-work-mesh-source-of-truth: the work mesh (REST) is the source of
5
5
  * truth; MQTT carries IDS-ONLY doorbells on the agent's personal topics
6
- * `hq/{agt_*}/{dm,work,sessions,notifications}`. This listener is the cache
6
+ * `hq/{agt_*}/{dm,work,sessions,notifications,inbox}`. This listener is the cache
7
7
  * writer: on any doorbell it refetches through the REST API into
8
8
  * ~/.hq/work-mesh/cache (warmMeshConversationCache) — message bodies are
9
9
  * never taken from MQTT. A periodic refresh covers missed doorbells.
10
10
  *
11
- * Credentials come from the same contract-3 vend the mesh daemon uses
12
- * (POST /v1/realtime/credentials); the WSS URL is SigV4-presigned. The
11
+ * Credentials come from the contract-2 (personal) vend
12
+ * (POST /v1/realtime/credentials {contractVersion: 2}), whose STS policy grants
13
+ * the caller's own `hq/{uid}/*` topics. The contract-3 vend the presence daemon
14
+ * uses only grants company presence + thread-directory topics, so subscribing
15
+ * to personal doorbells with it made AWS IoT drop the connection before SUBACK.
16
+ * Only doorbell topics the vend advertises are subscribed (one ungranted topic
17
+ * in a SUBSCRIBE disconnects the whole client). An `inbox` doorbell triggers
18
+ * an immediate inbox poll via `onInboxDoorbell`. The WSS URL is SigV4-presigned. The
13
19
  * connection is rebuilt before the vended credentials expire. `component-mesh`
14
20
  * is stamped ok while subscribed and refreshing, error otherwise.
15
21
  */
16
22
  import mqtt from "mqtt";
17
- import { warmMeshConversationCache } from "../../mesh/api.js";
18
- import { createContract3Fetcher, MQTT_KEEPALIVE_SECONDS, renewalDelayMs, } from "../../mesh/live/daemon/credentials.js";
23
+ import { defaultFetchInbox } from "./inbox.js";
24
+ import { createPersonalRealtimeFetcher, MQTT_KEEPALIVE_SECONDS, renewalDelayMs, } from "../../mesh/live/daemon/credentials.js";
19
25
  import { presignIotWssUrl } from "../../mesh/live/daemon/presign.js";
20
26
  import { writeComponentStatus } from "../creds.js";
21
- export const DOORBELL_KINDS = ["dm", "work", "sessions", "notifications"];
27
+ export const DOORBELL_KINDS = ["dm", "work", "sessions", "notifications", "inbox"];
22
28
  export const DOORBELL_DEBOUNCE_MS = 2_000;
23
29
  export const RECONNECT_BASE_MS = 1_000;
24
30
  export const RECONNECT_MAX_MS = 60_000;
25
31
  export function doorbellTopics(actorUid) {
26
32
  return DOORBELL_KINDS.map((k) => `hq/${actorUid}/${k}`);
27
33
  }
34
+ /**
35
+ * Doorbell topics this session may subscribe to: the intersection of
36
+ * DOORBELL_KINDS with the personal topics the vend advertises. AWS IoT
37
+ * disconnects the client on any unauthorized topic in a SUBSCRIBE, so a kind
38
+ * the server does not (yet) grant is skipped rather than risked.
39
+ */
40
+ export function grantedDoorbellTopics(bundle) {
41
+ const advertised = new Set(Object.values(bundle.topics));
42
+ return doorbellTopics(bundle.actorUid).filter((t) => advertised.has(t));
43
+ }
44
+ export function isInboxDoorbell(topic) {
45
+ return /^hq\/[^/]+\/inbox$/.test(topic);
46
+ }
28
47
  function backoff(attempt, random) {
29
48
  const cap = Math.min(RECONNECT_MAX_MS, RECONNECT_BASE_MS * 2 ** attempt);
30
49
  return Math.max(RECONNECT_BASE_MS, Math.floor(random() * cap));
@@ -35,11 +54,20 @@ export async function startMeshListener(deps) {
35
54
  const setT = deps.setTimeout ?? ((fn, ms) => setTimeout(fn, ms));
36
55
  const clearT = deps.clearTimeout ?? ((h) => clearTimeout(h));
37
56
  const connect = deps.connect ?? ((url, opts) => mqtt.connect(url, opts));
38
- 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
+ });
39
67
  const fetchCredentials = deps.fetchCredentials ??
40
68
  (async () => {
41
69
  const token = await deps.getToken();
42
- return createContract3Fetcher({ token, baseUrl: deps.apiBaseUrl })();
70
+ return createPersonalRealtimeFetcher({ token, baseUrl: deps.apiBaseUrl })();
43
71
  });
44
72
  let state = "idle";
45
73
  let client = null;
@@ -78,6 +106,14 @@ export async function startMeshListener(deps) {
78
106
  return refetching;
79
107
  };
80
108
  const ring = (topic) => {
109
+ if (isInboxDoorbell(topic) && deps.onInboxDoorbell) {
110
+ try {
111
+ void Promise.resolve(deps.onInboxDoorbell()).catch((err) => deps.log("warn", `inbox poll on doorbell failed: ${err instanceof Error ? err.message : String(err)}`));
112
+ }
113
+ catch (err) {
114
+ deps.log("warn", `inbox poll on doorbell failed: ${err instanceof Error ? err.message : String(err)}`);
115
+ }
116
+ }
81
117
  if (debounce)
82
118
  clearT(debounce);
83
119
  debounce = setT(() => {
@@ -128,8 +164,17 @@ export async function startMeshListener(deps) {
128
164
  });
129
165
  client = c;
130
166
  let settled = false;
167
+ const topics = grantedDoorbellTopics(bundle);
168
+ const skipped = doorbellTopics(bundle.actorUid).filter((t) => !topics.includes(t));
169
+ if (skipped.length > 0)
170
+ deps.log("info", `vend does not grant ${skipped.join(", ")}; not subscribing`);
131
171
  c.on("connect", () => {
132
- c.subscribe(doorbellTopics(bundle.actorUid), { qos: 1 }, (err) => {
172
+ if (topics.length === 0) {
173
+ deps.log("error", "realtime vend advertises no doorbell topics");
174
+ c.end(true);
175
+ return;
176
+ }
177
+ c.subscribe(topics, { qos: 1 }, (err) => {
133
178
  if (err) {
134
179
  deps.log("error", `subscribe failed: ${err.message}`);
135
180
  c.end(true);
@@ -139,7 +184,7 @@ export async function startMeshListener(deps) {
139
184
  attempt = 0;
140
185
  state = "subscribed";
141
186
  writeComponentStatus(deps.paths, "mesh", "ok");
142
- deps.log("info", `subscribed to ${doorbellTopics(bundle.actorUid).length} doorbell topics`);
187
+ deps.log("info", `subscribed to ${topics.length} doorbell topics`);
143
188
  void doRefetch("connect");
144
189
  if (renewal)
145
190
  clearT(renewal);
@@ -0,0 +1,35 @@
1
+ /**
2
+ * `hq agent kit run all` — supervise the four kit services in ONE process.
3
+ *
4
+ * The fallback for hosts with no usable service manager (a Linux box with no
5
+ * systemd user session bus, a container, launchd unavailable): each service
6
+ * runs as a child `node hq agent kit run <service>` whose output is appended
7
+ * to its own ~/.hq-agent/logs/<service>.log, and a child that exits is
8
+ * restarted with per-service exponential backoff (1 s doubling to 60 s,
9
+ * reset once a child has stayed up for a minute).
10
+ */
11
+ import type { KitService } from "../services.js";
12
+ export declare const SUPERVISOR_BACKOFF_BASE_MS = 1000;
13
+ export declare const SUPERVISOR_BACKOFF_MAX_MS = 60000;
14
+ /** A child that ran at least this long resets its backoff. */
15
+ export declare const SUPERVISOR_HEALTHY_RUN_MS = 60000;
16
+ export interface SupervisedChild {
17
+ on(event: "exit", handler: (code: number | null, signal: string | null) => void): unknown;
18
+ kill(signal?: NodeJS.Signals | number): boolean;
19
+ }
20
+ export interface SupervisorDeps {
21
+ services: readonly KitService[];
22
+ spawnService: (service: KitService) => SupervisedChild;
23
+ log: (level: "info" | "warn" | "error", msg: string) => void;
24
+ now?: () => number;
25
+ setTimeout?: (fn: () => void, ms: number) => unknown;
26
+ clearTimeout?: (handle: unknown) => void;
27
+ }
28
+ export interface SupervisorHandle {
29
+ stop: (signal?: NodeJS.Signals) => void;
30
+ restarts: () => Record<string, number>;
31
+ running: () => KitService[];
32
+ }
33
+ export declare function supervisorBackoffMs(attempt: number): number;
34
+ export declare function superviseKitServices(deps: SupervisorDeps): SupervisorHandle;
35
+ //# sourceMappingURL=supervisor.d.ts.map
@@ -0,0 +1,85 @@
1
+ /**
2
+ * `hq agent kit run all` — supervise the four kit services in ONE process.
3
+ *
4
+ * The fallback for hosts with no usable service manager (a Linux box with no
5
+ * systemd user session bus, a container, launchd unavailable): each service
6
+ * runs as a child `node hq agent kit run <service>` whose output is appended
7
+ * to its own ~/.hq-agent/logs/<service>.log, and a child that exits is
8
+ * restarted with per-service exponential backoff (1 s doubling to 60 s,
9
+ * reset once a child has stayed up for a minute).
10
+ */
11
+ export const SUPERVISOR_BACKOFF_BASE_MS = 1_000;
12
+ export const SUPERVISOR_BACKOFF_MAX_MS = 60_000;
13
+ /** A child that ran at least this long resets its backoff. */
14
+ export const SUPERVISOR_HEALTHY_RUN_MS = 60_000;
15
+ export function supervisorBackoffMs(attempt) {
16
+ return Math.min(SUPERVISOR_BACKOFF_MAX_MS, SUPERVISOR_BACKOFF_BASE_MS * 2 ** Math.max(0, attempt));
17
+ }
18
+ export function superviseKitServices(deps) {
19
+ const now = deps.now ?? Date.now;
20
+ const setT = deps.setTimeout ?? ((fn, ms) => setTimeout(fn, ms));
21
+ const clearT = deps.clearTimeout ?? ((h) => clearTimeout(h));
22
+ const children = new Map();
23
+ const attempts = new Map();
24
+ const restarts = {};
25
+ const timers = new Map();
26
+ let stopped = false;
27
+ const start = (service) => {
28
+ if (stopped)
29
+ return;
30
+ timers.delete(service);
31
+ let child;
32
+ const startedAt = now();
33
+ try {
34
+ child = deps.spawnService(service);
35
+ }
36
+ catch (err) {
37
+ deps.log("error", `${service} failed to start: ${err instanceof Error ? err.message : String(err)}`);
38
+ scheduleRestart(service);
39
+ return;
40
+ }
41
+ children.set(service, child);
42
+ child.on("exit", (code, signal) => {
43
+ if (children.get(service) === child)
44
+ children.delete(service);
45
+ if (stopped)
46
+ return;
47
+ if (now() - startedAt >= SUPERVISOR_HEALTHY_RUN_MS)
48
+ attempts.set(service, 0);
49
+ deps.log("warn", `${service} exited (code=${code ?? "null"} signal=${signal ?? "none"})`);
50
+ scheduleRestart(service);
51
+ });
52
+ };
53
+ const scheduleRestart = (service) => {
54
+ if (stopped)
55
+ return;
56
+ const attempt = attempts.get(service) ?? 0;
57
+ attempts.set(service, attempt + 1);
58
+ const delay = supervisorBackoffMs(attempt);
59
+ restarts[service] = (restarts[service] ?? 0) + 1;
60
+ deps.log("info", `restarting ${service} in ${delay}ms (restart ${restarts[service]})`);
61
+ timers.set(service, setT(() => start(service), delay));
62
+ };
63
+ for (const s of deps.services)
64
+ start(s);
65
+ return {
66
+ stop: (signal = "SIGTERM") => {
67
+ stopped = true;
68
+ for (const h of timers.values())
69
+ clearT(h);
70
+ timers.clear();
71
+ for (const child of children.values()) {
72
+ try {
73
+ child.kill(signal);
74
+ }
75
+ catch {
76
+ /* already gone */
77
+ }
78
+ }
79
+ children.clear();
80
+ },
81
+ restarts: () => ({ ...restarts }),
82
+ running: () => [...children.keys()],
83
+ };
84
+ }
85
+ //# sourceMappingURL=supervisor.js.map
@@ -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