@indigoai-us/hq-cli 5.65.0 → 5.67.0

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.
@@ -126,5 +126,29 @@ export declare function deprovisionAgent(token: string, agentUid: string): Promi
126
126
  }>;
127
127
  /** Human-readable "hot-applied to the running box" vs "saved for next launch". */
128
128
  export declare function appliedHint(applied: boolean): string;
129
+ /** One message in a DM thread, as returned by `GET /v1/notify/thread`. */
130
+ export interface DmThreadMessage {
131
+ eventId: string;
132
+ fromPersonUid: string;
133
+ fromDisplayName?: string;
134
+ fromEmail?: string;
135
+ body: string;
136
+ createdAt: string;
137
+ /** "out" = sent by the caller; "in" = received (the agent's reply). */
138
+ direction: "in" | "out";
139
+ }
140
+ /**
141
+ * Resolve an agent reference to its `agt_` uid. An `agt_…` value is used
142
+ * directly; anything else is treated as a slug or display name and resolved
143
+ * against the company's roster (requires `--company` or a single active
144
+ * membership).
145
+ */
146
+ export declare function resolveAgentUid(token: string, ref: string, companySlug: string | undefined): Promise<string>;
147
+ /** Send a DM to an agent. Returns nothing meaningful beyond success. */
148
+ export declare function sendAgentDm(token: string, agentUid: string, message: string): Promise<void>;
149
+ /** Read the two-way DM conversation with an agent (most recent `limit`). */
150
+ export declare function readAgentThread(token: string, agentUid: string, limit?: number): Promise<DmThreadMessage[]>;
151
+ /** Render one thread message as a labeled line for the terminal. */
152
+ export declare function formatThreadMessage(m: DmThreadMessage, agentLabel: string): string;
129
153
  export declare function registerAgentsCommand(program: Command): void;
130
154
  //# sourceMappingURL=agents.d.ts.map
@@ -22,7 +22,7 @@
22
22
  * the caller's single active membership (same as `members.ts`).
23
23
  */
24
24
 
25
- !function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="581b1950-a9bc-50a4-b16b-6e6649e9db30")}catch(e){}}();
25
+ !function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="26009666-ef42-5f7d-88eb-6452eca4b47a")}catch(e){}}();
26
26
  import chalk from "chalk";
27
27
  import { randomUUID } from "node:crypto";
28
28
  import { ensureCognitoToken } from "../utils/cognito-session.js";
@@ -144,6 +144,69 @@ export function appliedHint(applied) {
144
144
  : "saved — takes effect on the agent's next launch";
145
145
  }
146
146
  // ---------------------------------------------------------------------------
147
+ // Talking to agents (DM). Agents are members addressable over HQ's DM system:
148
+ // a message rides `POST /v1/notify/dm` (the wire contract accepts `agt_*`), the
149
+ // agent's box picks it up and replies over DM (a first-class agent reply
150
+ // channel), and the two-way conversation is read back via `GET /v1/notify/thread`.
151
+ // ---------------------------------------------------------------------------
152
+ /** Agent uid shape (`agt_…`). */
153
+ const AGENT_UID_PATTERN = /^agt_[A-Za-z0-9_-]+$/;
154
+ /**
155
+ * Resolve an agent reference to its `agt_` uid. An `agt_…` value is used
156
+ * directly; anything else is treated as a slug or display name and resolved
157
+ * against the company's roster (requires `--company` or a single active
158
+ * membership).
159
+ */
160
+ export async function resolveAgentUid(token, ref, companySlug) {
161
+ if (AGENT_UID_PATTERN.test(ref.trim()))
162
+ return ref.trim();
163
+ const companyUid = await getCompanyUid(token, companySlug);
164
+ const roster = await listAgents(token, companyUid);
165
+ const needle = ref.trim().toLowerCase();
166
+ const match = roster.find((a) => a.slug?.toLowerCase() === needle ||
167
+ a.name?.toLowerCase() === needle ||
168
+ a.uid === ref.trim());
169
+ if (!match) {
170
+ throw new AgentsHttpError(404, `No agent named "${ref}" in this company. Run \`hq agents list\` to see slugs/uids, ` +
171
+ `or pass the agt_ uid directly.`);
172
+ }
173
+ return match.uid;
174
+ }
175
+ /** Send a DM to an agent. Returns nothing meaningful beyond success. */
176
+ export async function sendAgentDm(token, agentUid, message) {
177
+ await agentsRequest({
178
+ token,
179
+ path: "/v1/notify/dm",
180
+ method: "POST",
181
+ body: { toPersonUid: agentUid, body: message },
182
+ });
183
+ }
184
+ /** Read the two-way DM conversation with an agent (most recent `limit`). */
185
+ export async function readAgentThread(token, agentUid, limit = 20) {
186
+ const data = await agentsRequest({
187
+ token,
188
+ path: "/v1/notify/thread",
189
+ query: { withPersonUid: agentUid, limit: String(limit) },
190
+ });
191
+ return data.messages ?? [];
192
+ }
193
+ /** Render one thread message as a labeled line for the terminal. */
194
+ export function formatThreadMessage(m, agentLabel) {
195
+ const who = m.direction === "out"
196
+ ? chalk.cyan("you")
197
+ : chalk.magenta(m.fromDisplayName || agentLabel);
198
+ const when = chalk.dim(shortTime(m.createdAt));
199
+ return `${who} ${when}\n ${m.body.replace(/\n/g, "\n ")}`;
200
+ }
201
+ function shortTime(iso) {
202
+ // Keep it dependency-free and stable: trim the ISO string to `MM-DD HH:MM`.
203
+ const m = iso.match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}:\d{2})/);
204
+ return m ? `${m[2]}-${m[3]} ${m[4]}` : iso;
205
+ }
206
+ function sleep(ms) {
207
+ return new Promise((resolve) => setTimeout(resolve, ms));
208
+ }
209
+ // ---------------------------------------------------------------------------
147
210
  // Command registration
148
211
  // ---------------------------------------------------------------------------
149
212
  function fail(err) {
@@ -165,6 +228,88 @@ export function registerAgentsCommand(program) {
165
228
  // `hq agents --company acme list`.
166
229
  const companyOf = (sub) => sub.opts().company ??
167
230
  agents.opts().company;
231
+ agents
232
+ .command("message <agent> <message...>")
233
+ .alias("msg")
234
+ .description("Send a message to an agent and wait for its reply")
235
+ .option("--company <slug>", "Company slug (to resolve an agent by name/slug)")
236
+ .option("--no-wait", "Send without waiting for the agent's reply")
237
+ .option("--timeout <seconds>", "How long to wait for a reply", "90")
238
+ .option("--json", "Emit raw JSON")
239
+ .action(async function (agent, messageParts, opts) {
240
+ try {
241
+ const message = messageParts.join(" ").trim();
242
+ if (!message) {
243
+ console.error(chalk.red("No message given. Usage: hq agents message <agent> <text>"));
244
+ process.exit(1);
245
+ }
246
+ const token = await ensureCognitoToken();
247
+ const agentUid = await resolveAgentUid(token, agent, companyOf(this));
248
+ // Baseline the conversation so we can detect the agent's NEW reply.
249
+ const before = opts.wait === false ? [] : await readAgentThread(token, agentUid, 1);
250
+ const lastBeforeAt = before.length ? before[before.length - 1].createdAt : "";
251
+ await sendAgentDm(token, agentUid, message);
252
+ if (opts.wait === false) {
253
+ console.log(chalk.green(`Sent to ${agent}.`));
254
+ console.log(chalk.dim(`Read replies later: hq agents thread ${agent}`));
255
+ return;
256
+ }
257
+ // Poll the thread until a new inbound (agent) message appears or timeout.
258
+ const timeoutMs = Math.max(5, Number(opts.timeout) || 90) * 1000;
259
+ const deadline = Date.now() + timeoutMs;
260
+ if (!opts.json)
261
+ console.log(chalk.dim(`Sent — waiting for ${agent} to reply…`));
262
+ let reply;
263
+ while (Date.now() < deadline) {
264
+ await sleep(3000);
265
+ const msgs = await readAgentThread(token, agentUid, 10);
266
+ reply = msgs.find((m) => m.direction === "in" && m.createdAt > lastBeforeAt);
267
+ if (reply)
268
+ break;
269
+ }
270
+ if (opts.json) {
271
+ process.stdout.write(JSON.stringify({ agentUid, sent: message, reply: reply ?? null }, null, 2) + "\n");
272
+ return;
273
+ }
274
+ if (reply) {
275
+ console.log(formatThreadMessage(reply, agent));
276
+ }
277
+ else {
278
+ console.log(chalk.yellow(`No reply within ${Math.round(timeoutMs / 1000)}s. The agent may still respond — ` +
279
+ `check with: hq agents thread ${agent}`));
280
+ }
281
+ }
282
+ catch (err) {
283
+ fail(err);
284
+ }
285
+ });
286
+ agents
287
+ .command("thread <agent>")
288
+ .alias("history")
289
+ .description("Show your recent conversation with an agent")
290
+ .option("--company <slug>", "Company slug (to resolve an agent by name/slug)")
291
+ .option("--limit <n>", "How many recent messages to show", "20")
292
+ .option("--json", "Emit raw JSON")
293
+ .action(async function (agent, opts) {
294
+ try {
295
+ const token = await ensureCognitoToken();
296
+ const agentUid = await resolveAgentUid(token, agent, companyOf(this));
297
+ const limit = Math.max(1, Number(opts.limit) || 20);
298
+ const msgs = await readAgentThread(token, agentUid, limit);
299
+ if (opts.json) {
300
+ process.stdout.write(JSON.stringify(msgs, null, 2) + "\n");
301
+ return;
302
+ }
303
+ if (msgs.length === 0) {
304
+ console.log(chalk.gray(`No conversation yet with ${agent}. Start one: hq agents message ${agent} "<text>"`));
305
+ return;
306
+ }
307
+ console.log(msgs.map((m) => formatThreadMessage(m, agent)).join("\n\n"));
308
+ }
309
+ catch (err) {
310
+ fail(err);
311
+ }
312
+ });
168
313
  agents
169
314
  .command("provision <name>")
170
315
  .alias("new")
@@ -476,4 +621,4 @@ export function registerAgentsCommand(program) {
476
621
  });
477
622
  }
478
623
  //# sourceMappingURL=agents.js.map
479
- //# debugId=581b1950-a9bc-50a4-b16b-6e6649e9db30
624
+ //# debugId=26009666-ef42-5f7d-88eb-6452eca4b47a
@@ -94,5 +94,34 @@ export interface OutpostExecResult {
94
94
  * `step` distinguishes not-ready / platform-unsupported / timeout / ssm.
95
95
  */
96
96
  export declare function execOutpost(token: string, command: string, outpostId?: string): Promise<OutpostExecResult>;
97
+ /** SSH connection details vended by `POST /outpost/ssh-access`. */
98
+ export interface OutpostSshAccess {
99
+ outpostId: string;
100
+ platform: "ec2" | "lightsail";
101
+ host: string;
102
+ port: number;
103
+ username: string;
104
+ /** PEM private key for the box. SENSITIVE — never printed or logged. */
105
+ privateKey: string;
106
+ }
107
+ /**
108
+ * Fetch SSH connection info + key for the caller's Outpost and open the caller's
109
+ * IP on the box's SSH port. Used to reach a Lightsail box (no SSM). Throws
110
+ * `OutpostHttpError` on a non-2xx.
111
+ */
112
+ export declare function getOutpostSshAccess(token: string, outpostId?: string): Promise<OutpostSshAccess>;
113
+ /**
114
+ * Run `command` on the box over SSH using vended access details. Writes the
115
+ * private key to a locked-down temp file, runs a non-interactive `ssh`, and
116
+ * returns stdout/stderr/exitCode. The key file + a throwaway known_hosts file
117
+ * are always cleaned up; the key is never printed. `exitCode` is `null` only
118
+ * when `ssh` itself couldn't run (e.g. binary missing) — surfaced via `error`.
119
+ */
120
+ export declare function execViaSsh(access: OutpostSshAccess, command: string): {
121
+ stdout: string;
122
+ stderr: string;
123
+ exitCode: number | null;
124
+ error?: string;
125
+ };
97
126
  export declare function registerOutpostsCommand(program: Command): void;
98
127
  //# sourceMappingURL=outposts.d.ts.map
@@ -21,8 +21,13 @@
21
21
  * and status routes that exist. Renaming an Outpost is not a backend capability.
22
22
  */
23
23
 
24
- !function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="067e20d6-0132-565a-8a22-9c27520669d2")}catch(e){}}();
24
+ !function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="951a0bc6-7dc3-5b10-b8f6-728df1074817")}catch(e){}}();
25
25
  import chalk from "chalk";
26
+ import { spawnSync } from "node:child_process";
27
+ import * as fs from "node:fs";
28
+ import * as os from "node:os";
29
+ import * as path from "node:path";
30
+ import { randomBytes } from "node:crypto";
26
31
  import { loadCachedTokens } from "@indigoai-us/hq-cloud";
27
32
  import { ensureCognitoToken } from "../utils/cognito-session.js";
28
33
  import { vaultApiFetch } from "../utils/vault-api.js";
@@ -133,6 +138,72 @@ export async function execOutpost(token, command, outpostId) {
133
138
  query: outpostId ? { outpostId } : undefined,
134
139
  });
135
140
  }
141
+ /**
142
+ * Fetch SSH connection info + key for the caller's Outpost and open the caller's
143
+ * IP on the box's SSH port. Used to reach a Lightsail box (no SSM). Throws
144
+ * `OutpostHttpError` on a non-2xx.
145
+ */
146
+ export async function getOutpostSshAccess(token, outpostId) {
147
+ return outpostRequest({
148
+ token,
149
+ path: "/outpost/ssh-access",
150
+ method: "POST",
151
+ body: {},
152
+ query: outpostId ? { outpostId } : undefined,
153
+ });
154
+ }
155
+ /**
156
+ * Run `command` on the box over SSH using vended access details. Writes the
157
+ * private key to a locked-down temp file, runs a non-interactive `ssh`, and
158
+ * returns stdout/stderr/exitCode. The key file + a throwaway known_hosts file
159
+ * are always cleaned up; the key is never printed. `exitCode` is `null` only
160
+ * when `ssh` itself couldn't run (e.g. binary missing) — surfaced via `error`.
161
+ */
162
+ export function execViaSsh(access, command) {
163
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "hq-outpost-ssh-"));
164
+ const keyPath = path.join(dir, `id_${randomBytes(6).toString("hex")}`);
165
+ const knownHosts = path.join(dir, "known_hosts");
166
+ try {
167
+ fs.writeFileSync(keyPath, ensureTrailingNewline(access.privateKey), {
168
+ mode: 0o600,
169
+ });
170
+ const result = spawnSync("ssh", [
171
+ "-i",
172
+ keyPath,
173
+ "-p",
174
+ String(access.port),
175
+ "-o",
176
+ "BatchMode=yes",
177
+ "-o",
178
+ "StrictHostKeyChecking=accept-new",
179
+ "-o",
180
+ `UserKnownHostsFile=${knownHosts}`,
181
+ "-o",
182
+ "ConnectTimeout=15",
183
+ "-o",
184
+ "LogLevel=ERROR",
185
+ `${access.username}@${access.host}`,
186
+ command,
187
+ ], { encoding: "utf8", maxBuffer: 32 * 1024 * 1024 });
188
+ if (result.error) {
189
+ const hint = result.error.code === "ENOENT"
190
+ ? "the `ssh` client is not installed or not on PATH"
191
+ : result.error.message;
192
+ return { stdout: "", stderr: "", exitCode: null, error: hint };
193
+ }
194
+ return {
195
+ stdout: result.stdout ?? "",
196
+ stderr: result.stderr ?? "",
197
+ exitCode: result.status,
198
+ };
199
+ }
200
+ finally {
201
+ fs.rmSync(dir, { recursive: true, force: true });
202
+ }
203
+ }
204
+ function ensureTrailingNewline(s) {
205
+ return s.endsWith("\n") ? s : s + "\n";
206
+ }
136
207
  // ---------------------------------------------------------------------------
137
208
  // Command registration
138
209
  // ---------------------------------------------------------------------------
@@ -294,27 +365,60 @@ export function registerOutpostsCommand(program) {
294
365
  process.exit(1);
295
366
  }
296
367
  const token = await ensureCognitoToken();
297
- const result = await execOutpost(token, command, opts.id);
298
- if (opts.json) {
299
- process.stdout.write(JSON.stringify(result, null, 2) + "\n");
300
- }
301
- else {
302
- // Stream the remote streams to ours so the command feels local, then
303
- // exit with the remote exit code.
304
- if (result.stdout)
305
- process.stdout.write(result.stdout);
306
- if (result.stderr)
307
- process.stderr.write(result.stderr);
308
- if (result.truncated) {
309
- console.error(chalk.yellow("(output truncated by SSM's inline limit — redirect to a file on the box for full output)"));
368
+ try {
369
+ const result = await execOutpost(token, command, opts.id);
370
+ if (opts.json) {
371
+ process.stdout.write(JSON.stringify(result, null, 2) + "\n");
372
+ }
373
+ else {
374
+ // Stream the remote streams to ours so the command feels local.
375
+ if (result.stdout)
376
+ process.stdout.write(result.stdout);
377
+ if (result.stderr)
378
+ process.stderr.write(result.stderr);
379
+ if (result.truncated) {
380
+ console.error(chalk.yellow("(output truncated by SSM's inline limit — redirect to a file on the box for full output)"));
381
+ }
382
+ if (result.status !== "Success" && result.exitCode === null) {
383
+ console.error(chalk.yellow(`(command ended with SSM status: ${result.status})`));
384
+ }
310
385
  }
311
- if (result.status !== "Success" && result.exitCode === null) {
312
- console.error(chalk.yellow(`(command ended with SSM status: ${result.status})`));
386
+ // Propagate the remote exit code so `hq outposts exec -- false` exits 1.
387
+ process.exitCode =
388
+ typeof result.exitCode === "number" ? result.exitCode : 0;
389
+ }
390
+ catch (err) {
391
+ // Lightsail boxes have no SSM agent — transparently fall back to SSH.
392
+ if (err instanceof OutpostHttpError &&
393
+ err.step === "platform-unsupported") {
394
+ const access = await getOutpostSshAccess(token, opts.id);
395
+ const ssh = execViaSsh(access, command);
396
+ if (ssh.error) {
397
+ console.error(chalk.red(`Could not run the command over SSH: ${ssh.error}`));
398
+ process.exit(1);
399
+ }
400
+ if (opts.json) {
401
+ process.stdout.write(JSON.stringify({
402
+ via: "ssh",
403
+ platform: access.platform,
404
+ host: access.host,
405
+ exitCode: ssh.exitCode,
406
+ stdout: ssh.stdout,
407
+ stderr: ssh.stderr,
408
+ }, null, 2) + "\n");
409
+ }
410
+ else {
411
+ if (ssh.stdout)
412
+ process.stdout.write(ssh.stdout);
413
+ if (ssh.stderr)
414
+ process.stderr.write(ssh.stderr);
415
+ }
416
+ process.exitCode =
417
+ typeof ssh.exitCode === "number" ? ssh.exitCode : 1;
418
+ return;
313
419
  }
420
+ throw err;
314
421
  }
315
- // Propagate the remote exit code so `hq outposts exec -- false` exits 1.
316
- process.exitCode =
317
- typeof result.exitCode === "number" ? result.exitCode : 0;
318
422
  }
319
423
  catch (err) {
320
424
  fail(err);
@@ -392,4 +496,4 @@ export function registerOutpostsCommand(program) {
392
496
  });
393
497
  }
394
498
  //# sourceMappingURL=outposts.js.map
395
- //# debugId=067e20d6-0132-565a-8a22-9c27520669d2
499
+ //# debugId=951a0bc6-7dc3-5b10-b8f6-728df1074817
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.65.0",
3
+ "version": "5.67.0",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -379,3 +379,108 @@ describe("hq agents provision (billing gate)", () => {
379
379
  expect(fetchSpy).not.toHaveBeenCalled();
380
380
  });
381
381
  });
382
+
383
+ describe("hq agents talk (message / thread)", () => {
384
+ function agentRow(uid: string, slug: string, name: string) {
385
+ return { uid, slug, name, companyUid: "cmp_acme" };
386
+ }
387
+
388
+ it("resolves an agent by slug and reads the thread", async () => {
389
+ // 1) roster lookup for slug→uid, 2) thread read
390
+ fetchSpy
391
+ .mockResolvedValueOnce(
392
+ jsonResponse(200, { agents: [agentRow("agt_deacon", "deacon", "Deacon")] }),
393
+ )
394
+ .mockResolvedValueOnce(
395
+ jsonResponse(200, {
396
+ messages: [
397
+ {
398
+ eventId: "e1",
399
+ fromPersonUid: "prs_me",
400
+ body: "hi",
401
+ createdAt: "2026-07-13T10:00",
402
+ direction: "out",
403
+ },
404
+ {
405
+ eventId: "e2",
406
+ fromPersonUid: "agt_deacon",
407
+ fromDisplayName: "Deacon",
408
+ body: "hello back",
409
+ createdAt: "2026-07-13T10:01",
410
+ direction: "in",
411
+ },
412
+ ],
413
+ }),
414
+ );
415
+
416
+ const logs: string[] = [];
417
+ vi.spyOn(console, "log").mockImplementation((...a) => {
418
+ logs.push(a.map(String).join(" "));
419
+ });
420
+ await run(["agents", "--company", "acme", "thread", "deacon"]);
421
+
422
+ // roster call then thread call
423
+ expect(String(fetchSpy.mock.calls[0][0])).toContain("/v1/agents");
424
+ const threadUrl = String(fetchSpy.mock.calls[1][0]);
425
+ expect(threadUrl).toContain("/v1/notify/thread");
426
+ expect(threadUrl).toContain("withPersonUid=agt_deacon");
427
+ const out = logs.join("\n");
428
+ expect(out).toContain("hello back");
429
+ expect(out).toContain("hi");
430
+ });
431
+
432
+ it("passes an agt_ uid straight through (no roster lookup)", async () => {
433
+ fetchSpy.mockResolvedValueOnce(jsonResponse(200, { messages: [] }));
434
+ await run(["agents", "thread", "agt_xyz"]);
435
+ // Only the thread call — no /v1/agents roster fetch.
436
+ expect(fetchSpy).toHaveBeenCalledTimes(1);
437
+ expect(String(fetchSpy.mock.calls[0][0])).toContain("withPersonUid=agt_xyz");
438
+ expect(mockGetCompanyUid).not.toHaveBeenCalled();
439
+ });
440
+
441
+ it("message --no-wait POSTs the DM and does not poll", async () => {
442
+ fetchSpy.mockResolvedValueOnce(jsonResponse(200, { ok: true })); // POST /v1/notify/dm
443
+ await run(["agents", "message", "agt_xyz", "--no-wait", "hello", "there"]);
444
+ expect(fetchSpy).toHaveBeenCalledTimes(1);
445
+ const [url, init] = fetchSpy.mock.calls[0];
446
+ expect(String(url)).toContain("/v1/notify/dm");
447
+ expect(init?.method).toBe("POST");
448
+ expect(JSON.parse(init?.body as string)).toEqual({
449
+ toPersonUid: "agt_xyz",
450
+ body: "hello there",
451
+ });
452
+ });
453
+
454
+ it("message waits for and prints the agent's reply", async () => {
455
+ fetchSpy
456
+ .mockResolvedValueOnce(jsonResponse(200, { messages: [] })) // baseline read
457
+ .mockResolvedValueOnce(jsonResponse(200, { ok: true })) // POST dm
458
+ .mockResolvedValueOnce(
459
+ jsonResponse(200, {
460
+ messages: [
461
+ {
462
+ eventId: "r1",
463
+ fromPersonUid: "agt_xyz",
464
+ fromDisplayName: "Xyz",
465
+ body: "on it",
466
+ createdAt: "2026-07-13T11:00",
467
+ direction: "in",
468
+ },
469
+ ],
470
+ }),
471
+ ); // first poll returns the reply
472
+
473
+ const logs: string[] = [];
474
+ vi.spyOn(console, "log").mockImplementation((...a) => {
475
+ logs.push(a.map(String).join(" "));
476
+ });
477
+ await run(["agents", "message", "agt_xyz", "--timeout", "10", "ping"]);
478
+ expect(logs.join("\n")).toContain("on it");
479
+ }, 10000);
480
+
481
+ it("requires a non-empty message", async () => {
482
+ await expect(
483
+ run(["agents", "message", "agt_xyz", " "]),
484
+ ).rejects.toThrow("process.exit(1)");
485
+ });
486
+ });
@@ -265,6 +265,107 @@ export function appliedHint(applied: boolean): string {
265
265
  : "saved — takes effect on the agent's next launch";
266
266
  }
267
267
 
268
+ // ---------------------------------------------------------------------------
269
+ // Talking to agents (DM). Agents are members addressable over HQ's DM system:
270
+ // a message rides `POST /v1/notify/dm` (the wire contract accepts `agt_*`), the
271
+ // agent's box picks it up and replies over DM (a first-class agent reply
272
+ // channel), and the two-way conversation is read back via `GET /v1/notify/thread`.
273
+ // ---------------------------------------------------------------------------
274
+
275
+ /** Agent uid shape (`agt_…`). */
276
+ const AGENT_UID_PATTERN = /^agt_[A-Za-z0-9_-]+$/;
277
+
278
+ /** One message in a DM thread, as returned by `GET /v1/notify/thread`. */
279
+ export interface DmThreadMessage {
280
+ eventId: string;
281
+ fromPersonUid: string;
282
+ fromDisplayName?: string;
283
+ fromEmail?: string;
284
+ body: string;
285
+ createdAt: string;
286
+ /** "out" = sent by the caller; "in" = received (the agent's reply). */
287
+ direction: "in" | "out";
288
+ }
289
+
290
+ /**
291
+ * Resolve an agent reference to its `agt_` uid. An `agt_…` value is used
292
+ * directly; anything else is treated as a slug or display name and resolved
293
+ * against the company's roster (requires `--company` or a single active
294
+ * membership).
295
+ */
296
+ export async function resolveAgentUid(
297
+ token: string,
298
+ ref: string,
299
+ companySlug: string | undefined,
300
+ ): Promise<string> {
301
+ if (AGENT_UID_PATTERN.test(ref.trim())) return ref.trim();
302
+ const companyUid = await getCompanyUid(token, companySlug);
303
+ const roster = await listAgents(token, companyUid);
304
+ const needle = ref.trim().toLowerCase();
305
+ const match = roster.find(
306
+ (a) =>
307
+ a.slug?.toLowerCase() === needle ||
308
+ a.name?.toLowerCase() === needle ||
309
+ a.uid === ref.trim(),
310
+ );
311
+ if (!match) {
312
+ throw new AgentsHttpError(
313
+ 404,
314
+ `No agent named "${ref}" in this company. Run \`hq agents list\` to see slugs/uids, ` +
315
+ `or pass the agt_ uid directly.`,
316
+ );
317
+ }
318
+ return match.uid;
319
+ }
320
+
321
+ /** Send a DM to an agent. Returns nothing meaningful beyond success. */
322
+ export async function sendAgentDm(
323
+ token: string,
324
+ agentUid: string,
325
+ message: string,
326
+ ): Promise<void> {
327
+ await agentsRequest({
328
+ token,
329
+ path: "/v1/notify/dm",
330
+ method: "POST",
331
+ body: { toPersonUid: agentUid, body: message },
332
+ });
333
+ }
334
+
335
+ /** Read the two-way DM conversation with an agent (most recent `limit`). */
336
+ export async function readAgentThread(
337
+ token: string,
338
+ agentUid: string,
339
+ limit = 20,
340
+ ): Promise<DmThreadMessage[]> {
341
+ const data = await agentsRequest<{ messages: DmThreadMessage[] }>({
342
+ token,
343
+ path: "/v1/notify/thread",
344
+ query: { withPersonUid: agentUid, limit: String(limit) },
345
+ });
346
+ return data.messages ?? [];
347
+ }
348
+
349
+ /** Render one thread message as a labeled line for the terminal. */
350
+ export function formatThreadMessage(m: DmThreadMessage, agentLabel: string): string {
351
+ const who =
352
+ m.direction === "out"
353
+ ? chalk.cyan("you")
354
+ : chalk.magenta(m.fromDisplayName || agentLabel);
355
+ const when = chalk.dim(shortTime(m.createdAt));
356
+ return `${who} ${when}\n ${m.body.replace(/\n/g, "\n ")}`;
357
+ }
358
+
359
+ function shortTime(iso: string): string {
360
+ // Keep it dependency-free and stable: trim the ISO string to `MM-DD HH:MM`.
361
+ const m = iso.match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}:\d{2})/);
362
+ return m ? `${m[2]}-${m[3]} ${m[4]}` : iso;
363
+ }
364
+
365
+ function sleep(ms: number): Promise<void> {
366
+ return new Promise((resolve) => setTimeout(resolve, ms));
367
+ }
368
+
268
369
  // ---------------------------------------------------------------------------
269
370
  // Command registration
270
371
  // ---------------------------------------------------------------------------
@@ -294,6 +395,107 @@ export function registerAgentsCommand(program: Command): void {
294
395
  (sub.opts().company as string | undefined) ??
295
396
  (agents.opts().company as string | undefined);
296
397
 
398
+ agents
399
+ .command("message <agent> <message...>")
400
+ .alias("msg")
401
+ .description("Send a message to an agent and wait for its reply")
402
+ .option("--company <slug>", "Company slug (to resolve an agent by name/slug)")
403
+ .option("--no-wait", "Send without waiting for the agent's reply")
404
+ .option("--timeout <seconds>", "How long to wait for a reply", "90")
405
+ .option("--json", "Emit raw JSON")
406
+ .action(async function (
407
+ this: Command,
408
+ agent: string,
409
+ messageParts: string[],
410
+ opts: { company?: string; wait?: boolean; timeout?: string; json?: boolean },
411
+ ) {
412
+ try {
413
+ const message = messageParts.join(" ").trim();
414
+ if (!message) {
415
+ console.error(chalk.red("No message given. Usage: hq agents message <agent> <text>"));
416
+ process.exit(1);
417
+ }
418
+ const token = await ensureCognitoToken();
419
+ const agentUid = await resolveAgentUid(token, agent, companyOf(this));
420
+
421
+ // Baseline the conversation so we can detect the agent's NEW reply.
422
+ const before = opts.wait === false ? [] : await readAgentThread(token, agentUid, 1);
423
+ const lastBeforeAt = before.length ? before[before.length - 1].createdAt : "";
424
+
425
+ await sendAgentDm(token, agentUid, message);
426
+
427
+ if (opts.wait === false) {
428
+ console.log(chalk.green(`Sent to ${agent}.`));
429
+ console.log(chalk.dim(`Read replies later: hq agents thread ${agent}`));
430
+ return;
431
+ }
432
+
433
+ // Poll the thread until a new inbound (agent) message appears or timeout.
434
+ const timeoutMs = Math.max(5, Number(opts.timeout) || 90) * 1000;
435
+ const deadline = Date.now() + timeoutMs;
436
+ if (!opts.json) console.log(chalk.dim(`Sent — waiting for ${agent} to reply…`));
437
+ let reply: DmThreadMessage | undefined;
438
+ while (Date.now() < deadline) {
439
+ await sleep(3000);
440
+ const msgs = await readAgentThread(token, agentUid, 10);
441
+ reply = msgs.find(
442
+ (m) => m.direction === "in" && m.createdAt > lastBeforeAt,
443
+ );
444
+ if (reply) break;
445
+ }
446
+
447
+ if (opts.json) {
448
+ process.stdout.write(
449
+ JSON.stringify({ agentUid, sent: message, reply: reply ?? null }, null, 2) + "\n",
450
+ );
451
+ return;
452
+ }
453
+ if (reply) {
454
+ console.log(formatThreadMessage(reply, agent));
455
+ } else {
456
+ console.log(
457
+ chalk.yellow(
458
+ `No reply within ${Math.round(timeoutMs / 1000)}s. The agent may still respond — ` +
459
+ `check with: hq agents thread ${agent}`,
460
+ ),
461
+ );
462
+ }
463
+ } catch (err) {
464
+ fail(err);
465
+ }
466
+ });
467
+
468
+ agents
469
+ .command("thread <agent>")
470
+ .alias("history")
471
+ .description("Show your recent conversation with an agent")
472
+ .option("--company <slug>", "Company slug (to resolve an agent by name/slug)")
473
+ .option("--limit <n>", "How many recent messages to show", "20")
474
+ .option("--json", "Emit raw JSON")
475
+ .action(async function (
476
+ this: Command,
477
+ agent: string,
478
+ opts: { company?: string; limit?: string; json?: boolean },
479
+ ) {
480
+ try {
481
+ const token = await ensureCognitoToken();
482
+ const agentUid = await resolveAgentUid(token, agent, companyOf(this));
483
+ const limit = Math.max(1, Number(opts.limit) || 20);
484
+ const msgs = await readAgentThread(token, agentUid, limit);
485
+ if (opts.json) {
486
+ process.stdout.write(JSON.stringify(msgs, null, 2) + "\n");
487
+ return;
488
+ }
489
+ if (msgs.length === 0) {
490
+ console.log(chalk.gray(`No conversation yet with ${agent}. Start one: hq agents message ${agent} "<text>"`));
491
+ return;
492
+ }
493
+ console.log(msgs.map((m) => formatThreadMessage(m, agent)).join("\n\n"));
494
+ } catch (err) {
495
+ fail(err);
496
+ }
497
+ });
498
+
297
499
  agents
298
500
  .command("provision <name>")
299
501
  .alias("new")
@@ -42,9 +42,20 @@ vi.mock("@indigoai-us/hq-cloud", async (importOriginal) => {
42
42
  };
43
43
  });
44
44
 
45
+ // The SSH fallback shells out to `ssh` via spawnSync — stub it so tests don't
46
+ // touch a real box or need the ssh binary.
47
+ vi.mock("node:child_process", async (importOriginal) => {
48
+ const original =
49
+ await importOriginal<typeof import("node:child_process")>();
50
+ return { ...original, spawnSync: vi.fn() };
51
+ });
52
+
53
+ import { spawnSync } from "node:child_process";
45
54
  import { ensureCognitoToken } from "../utils/cognito-session.js";
46
55
  import { registerOutpostsCommand } from "./outposts.js";
47
56
 
57
+ const mockSpawnSync = vi.mocked(spawnSync);
58
+
48
59
  function jsonResponse(status: number, body: unknown): Response {
49
60
  return new Response(JSON.stringify(body), {
50
61
  status,
@@ -354,3 +365,112 @@ describe("hq outposts exec", () => {
354
365
  expect(printed).toContain('"commandId": "cmd-3"');
355
366
  });
356
367
  });
368
+
369
+ describe("hq outposts exec — Lightsail SSH fallback", () => {
370
+ afterEach(() => {
371
+ process.exitCode = undefined;
372
+ });
373
+
374
+ function platformUnsupported(): Response {
375
+ return jsonResponse(409, {
376
+ error: true,
377
+ step: "platform-unsupported",
378
+ message: "remote exec requires the EC2 platform",
379
+ });
380
+ }
381
+
382
+ function sshAccess(): Response {
383
+ return jsonResponse(200, {
384
+ ok: true,
385
+ outpostId: "primary",
386
+ platform: "lightsail",
387
+ host: "52.2.2.2",
388
+ port: 22,
389
+ username: "ec2-user",
390
+ privateKey: "-----BEGIN KEY-----\nx\n-----END KEY-----\n",
391
+ });
392
+ }
393
+
394
+ it("falls back to SSH when the box is Lightsail, streams stdout, propagates exit", async () => {
395
+ const stdoutSpy = vi
396
+ .spyOn(process.stdout, "write")
397
+ .mockImplementation(() => true);
398
+ fetchSpy
399
+ .mockResolvedValueOnce(platformUnsupported()) // /outpost/exec (SSM) → 409
400
+ .mockResolvedValueOnce(sshAccess()); // /outpost/ssh-access → 200
401
+ mockSpawnSync.mockReturnValue({
402
+ status: 0,
403
+ stdout: "lightsail-out\n",
404
+ stderr: "",
405
+ signal: null,
406
+ output: [],
407
+ pid: 1,
408
+ } as unknown as ReturnType<typeof spawnSync>);
409
+
410
+ // Flags meant for the remote command go after `--` (same as the SSM path).
411
+ await run(["outposts", "exec", "--", "uname", "-a"]);
412
+
413
+ // Second HTTP call is the ssh-access mint.
414
+ expect(String(fetchSpy.mock.calls[1][0])).toContain("/outpost/ssh-access");
415
+ // ssh was invoked with the box coordinates and the command as the last arg.
416
+ const args = mockSpawnSync.mock.calls[0][1] as string[];
417
+ expect(mockSpawnSync.mock.calls[0][0]).toBe("ssh");
418
+ expect(args).toContain("ec2-user@52.2.2.2");
419
+ expect(args[args.length - 1]).toBe("uname -a");
420
+ expect(args).toContain("BatchMode=yes");
421
+ const printed = stdoutSpy.mock.calls.map((c) => String(c[0])).join("");
422
+ expect(printed).toContain("lightsail-out\n");
423
+ expect(process.exitCode).toBe(0);
424
+ });
425
+
426
+ it("propagates a non-zero SSH exit code", async () => {
427
+ vi.spyOn(process.stdout, "write").mockImplementation(() => true);
428
+ vi.spyOn(process.stderr, "write").mockImplementation(() => true);
429
+ fetchSpy
430
+ .mockResolvedValueOnce(platformUnsupported())
431
+ .mockResolvedValueOnce(sshAccess());
432
+ mockSpawnSync.mockReturnValue({
433
+ status: 7,
434
+ stdout: "",
435
+ stderr: "boom\n",
436
+ signal: null,
437
+ output: [],
438
+ pid: 1,
439
+ } as unknown as ReturnType<typeof spawnSync>);
440
+ await run(["outposts", "exec", "false"]);
441
+ expect(process.exitCode).toBe(7);
442
+ });
443
+
444
+ it("exits 1 with a clear message when the ssh client is missing", async () => {
445
+ fetchSpy
446
+ .mockResolvedValueOnce(platformUnsupported())
447
+ .mockResolvedValueOnce(sshAccess());
448
+ const enoent = Object.assign(new Error("spawnSync ssh ENOENT"), {
449
+ code: "ENOENT",
450
+ });
451
+ mockSpawnSync.mockReturnValue({
452
+ error: enoent,
453
+ status: null,
454
+ stdout: "",
455
+ stderr: "",
456
+ signal: null,
457
+ output: [],
458
+ pid: 0,
459
+ } as unknown as ReturnType<typeof spawnSync>);
460
+ await expect(run(["outposts", "exec", "echo", "hi"])).rejects.toThrow(
461
+ "process.exit(1)",
462
+ );
463
+ });
464
+
465
+ it("does NOT fall back for a non-platform error (e.g. not-ready)", async () => {
466
+ fetchSpy.mockResolvedValueOnce(
467
+ jsonResponse(409, { error: true, step: "not-ready", message: "not ready" }),
468
+ );
469
+ await expect(
470
+ run(["outposts", "exec", "echo", "hi"]),
471
+ ).rejects.toThrow("process.exit(1)");
472
+ // Only the SSM call happened; no ssh-access mint, no ssh.
473
+ expect(fetchSpy).toHaveBeenCalledTimes(1);
474
+ expect(mockSpawnSync).not.toHaveBeenCalled();
475
+ });
476
+ });
@@ -23,6 +23,11 @@
23
23
 
24
24
  import { Command } from "commander";
25
25
  import chalk from "chalk";
26
+ import { spawnSync } from "node:child_process";
27
+ import * as fs from "node:fs";
28
+ import * as os from "node:os";
29
+ import * as path from "node:path";
30
+ import { randomBytes } from "node:crypto";
26
31
  import { loadCachedTokens } from "@indigoai-us/hq-cloud";
27
32
  import { ensureCognitoToken } from "../utils/cognito-session.js";
28
33
  import { vaultApiFetch } from "../utils/vault-api.js";
@@ -222,6 +227,96 @@ export async function execOutpost(
222
227
  });
223
228
  }
224
229
 
230
+ /** SSH connection details vended by `POST /outpost/ssh-access`. */
231
+ export interface OutpostSshAccess {
232
+ outpostId: string;
233
+ platform: "ec2" | "lightsail";
234
+ host: string;
235
+ port: number;
236
+ username: string;
237
+ /** PEM private key for the box. SENSITIVE — never printed or logged. */
238
+ privateKey: string;
239
+ }
240
+
241
+ /**
242
+ * Fetch SSH connection info + key for the caller's Outpost and open the caller's
243
+ * IP on the box's SSH port. Used to reach a Lightsail box (no SSM). Throws
244
+ * `OutpostHttpError` on a non-2xx.
245
+ */
246
+ export async function getOutpostSshAccess(
247
+ token: string,
248
+ outpostId?: string,
249
+ ): Promise<OutpostSshAccess> {
250
+ return outpostRequest({
251
+ token,
252
+ path: "/outpost/ssh-access",
253
+ method: "POST",
254
+ body: {},
255
+ query: outpostId ? { outpostId } : undefined,
256
+ });
257
+ }
258
+
259
+ /**
260
+ * Run `command` on the box over SSH using vended access details. Writes the
261
+ * private key to a locked-down temp file, runs a non-interactive `ssh`, and
262
+ * returns stdout/stderr/exitCode. The key file + a throwaway known_hosts file
263
+ * are always cleaned up; the key is never printed. `exitCode` is `null` only
264
+ * when `ssh` itself couldn't run (e.g. binary missing) — surfaced via `error`.
265
+ */
266
+ export function execViaSsh(
267
+ access: OutpostSshAccess,
268
+ command: string,
269
+ ): { stdout: string; stderr: string; exitCode: number | null; error?: string } {
270
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "hq-outpost-ssh-"));
271
+ const keyPath = path.join(dir, `id_${randomBytes(6).toString("hex")}`);
272
+ const knownHosts = path.join(dir, "known_hosts");
273
+ try {
274
+ fs.writeFileSync(keyPath, ensureTrailingNewline(access.privateKey), {
275
+ mode: 0o600,
276
+ });
277
+ const result = spawnSync(
278
+ "ssh",
279
+ [
280
+ "-i",
281
+ keyPath,
282
+ "-p",
283
+ String(access.port),
284
+ "-o",
285
+ "BatchMode=yes",
286
+ "-o",
287
+ "StrictHostKeyChecking=accept-new",
288
+ "-o",
289
+ `UserKnownHostsFile=${knownHosts}`,
290
+ "-o",
291
+ "ConnectTimeout=15",
292
+ "-o",
293
+ "LogLevel=ERROR",
294
+ `${access.username}@${access.host}`,
295
+ command,
296
+ ],
297
+ { encoding: "utf8", maxBuffer: 32 * 1024 * 1024 },
298
+ );
299
+ if (result.error) {
300
+ const hint =
301
+ (result.error as NodeJS.ErrnoException).code === "ENOENT"
302
+ ? "the `ssh` client is not installed or not on PATH"
303
+ : result.error.message;
304
+ return { stdout: "", stderr: "", exitCode: null, error: hint };
305
+ }
306
+ return {
307
+ stdout: result.stdout ?? "",
308
+ stderr: result.stderr ?? "",
309
+ exitCode: result.status,
310
+ };
311
+ } finally {
312
+ fs.rmSync(dir, { recursive: true, force: true });
313
+ }
314
+ }
315
+
316
+ function ensureTrailingNewline(s: string): string {
317
+ return s.endsWith("\n") ? s : s + "\n";
318
+ }
319
+
225
320
  // ---------------------------------------------------------------------------
226
321
  // Command registration
227
322
  // ---------------------------------------------------------------------------
@@ -423,27 +518,64 @@ export function registerOutpostsCommand(program: Command): void {
423
518
  process.exit(1);
424
519
  }
425
520
  const token = await ensureCognitoToken();
426
- const result = await execOutpost(token, command, opts.id);
427
521
 
428
- if (opts.json) {
429
- process.stdout.write(JSON.stringify(result, null, 2) + "\n");
430
- } else {
431
- // Stream the remote streams to ours so the command feels local, then
432
- // exit with the remote exit code.
433
- if (result.stdout) process.stdout.write(result.stdout);
434
- if (result.stderr) process.stderr.write(result.stderr);
435
- if (result.truncated) {
436
- console.error(
437
- chalk.yellow("(output truncated by SSM's inline limit — redirect to a file on the box for full output)"),
438
- );
522
+ try {
523
+ const result = await execOutpost(token, command, opts.id);
524
+ if (opts.json) {
525
+ process.stdout.write(JSON.stringify(result, null, 2) + "\n");
526
+ } else {
527
+ // Stream the remote streams to ours so the command feels local.
528
+ if (result.stdout) process.stdout.write(result.stdout);
529
+ if (result.stderr) process.stderr.write(result.stderr);
530
+ if (result.truncated) {
531
+ console.error(
532
+ chalk.yellow("(output truncated by SSM's inline limit — redirect to a file on the box for full output)"),
533
+ );
534
+ }
535
+ if (result.status !== "Success" && result.exitCode === null) {
536
+ console.error(chalk.yellow(`(command ended with SSM status: ${result.status})`));
537
+ }
439
538
  }
440
- if (result.status !== "Success" && result.exitCode === null) {
441
- console.error(chalk.yellow(`(command ended with SSM status: ${result.status})`));
539
+ // Propagate the remote exit code so `hq outposts exec -- false` exits 1.
540
+ process.exitCode =
541
+ typeof result.exitCode === "number" ? result.exitCode : 0;
542
+ } catch (err) {
543
+ // Lightsail boxes have no SSM agent — transparently fall back to SSH.
544
+ if (
545
+ err instanceof OutpostHttpError &&
546
+ err.step === "platform-unsupported"
547
+ ) {
548
+ const access = await getOutpostSshAccess(token, opts.id);
549
+ const ssh = execViaSsh(access, command);
550
+ if (ssh.error) {
551
+ console.error(chalk.red(`Could not run the command over SSH: ${ssh.error}`));
552
+ process.exit(1);
553
+ }
554
+ if (opts.json) {
555
+ process.stdout.write(
556
+ JSON.stringify(
557
+ {
558
+ via: "ssh",
559
+ platform: access.platform,
560
+ host: access.host,
561
+ exitCode: ssh.exitCode,
562
+ stdout: ssh.stdout,
563
+ stderr: ssh.stderr,
564
+ },
565
+ null,
566
+ 2,
567
+ ) + "\n",
568
+ );
569
+ } else {
570
+ if (ssh.stdout) process.stdout.write(ssh.stdout);
571
+ if (ssh.stderr) process.stderr.write(ssh.stderr);
572
+ }
573
+ process.exitCode =
574
+ typeof ssh.exitCode === "number" ? ssh.exitCode : 1;
575
+ return;
442
576
  }
577
+ throw err;
443
578
  }
444
- // Propagate the remote exit code so `hq outposts exec -- false` exits 1.
445
- process.exitCode =
446
- typeof result.exitCode === "number" ? result.exitCode : 0;
447
579
  } catch (err) {
448
580
  fail(err);
449
581
  }