@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.
- package/CHANGELOG.md +52 -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 +6 -3
- package/dist/command-catalog.generated.js +7 -3
- package/dist/commands/agent-kit.d.ts +23 -3
- package/dist/commands/agent-kit.js +126 -14
- package/dist/commands/agent-probe.d.ts +15 -9
- package/dist/commands/agent-probe.js +55 -35
- package/dist/commands/billing.js +1 -1
- package/dist/commands/db-provision.js +4 -4
- package/dist/commands/dm.d.ts +10 -0
- package/dist/commands/dm.js +80 -0
- 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/fallback.d.ts +63 -0
- package/dist/lib/agent-kit/fallback.js +129 -0
- package/dist/lib/agent-kit/run/inbox.d.ts +18 -6
- package/dist/lib/agent-kit/run/inbox.js +40 -8
- package/dist/lib/agent-kit/run/mesh-listener.d.ts +22 -6
- package/dist/lib/agent-kit/run/mesh-listener.js +55 -10
- package/dist/lib/agent-kit/run/supervisor.d.ts +35 -0
- package/dist/lib/agent-kit/run/supervisor.js +85 -0
- 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/mesh/live/daemon/credentials.d.ts +27 -0
- package/dist/lib/mesh/live/daemon/credentials.js +95 -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
|
@@ -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
|
|
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:
|
|
68
|
-
path:
|
|
89
|
+
baseUrl: url.origin,
|
|
90
|
+
path: url.pathname,
|
|
69
91
|
});
|
|
70
|
-
const
|
|
71
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
12
|
-
* (POST /v1/realtime/credentials
|
|
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
|
|
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?:
|
|
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
|
|
12
|
-
* (POST /v1/realtime/credentials
|
|
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 {
|
|
18
|
-
import {
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 ${
|
|
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
|
|
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
|