@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
|
@@ -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
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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 {
|
|
30
|
-
import {
|
|
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
|
-
|
|
37
|
-
export const
|
|
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
|
|
120
|
-
const
|
|
121
|
-
const
|
|
122
|
-
const
|
|
123
|
-
const
|
|
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:
|
|
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
|
|
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
|
|
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:
|
|
160
|
-
detail:
|
|
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
|
|
265
|
+
const bundle = await createPersonalRealtimeFetcher({ token, baseUrl: base })();
|
|
245
266
|
return { actorUid: bundle.actorUid };
|
|
246
267
|
},
|
|
247
|
-
|
|
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,
|
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).");
|
package/dist/commands/dm.d.ts
CHANGED
|
@@ -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
|
package/dist/commands/dm.js
CHANGED
|
@@ -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
|
|
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');
|
|
@@ -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
|