@indigoai-us/hq-cli 5.115.6 → 5.117.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.
- package/CHANGELOG.md +169 -11
- package/dist/command-catalog.generated.d.ts +220 -2
- package/dist/command-catalog.generated.js +281 -2
- package/dist/command-registration-plan.d.ts +6 -0
- package/dist/command-registration-plan.js +1 -0
- package/dist/commands/agent-enroll.d.ts +105 -0
- package/dist/commands/agent-enroll.js +273 -0
- package/dist/commands/agent-kit.d.ts +53 -0
- package/dist/commands/agent-kit.js +260 -0
- package/dist/commands/agent-mcp.d.ts +22 -0
- package/dist/commands/agent-mcp.js +104 -0
- package/dist/commands/agent-probe.d.ts +71 -0
- package/dist/commands/agent-probe.js +294 -0
- package/dist/commands/agent.d.ts +12 -0
- package/dist/commands/agent.js +23 -0
- package/dist/commands/agents.d.ts +27 -0
- package/dist/commands/agents.js +280 -6
- package/dist/commands/bot.d.ts +140 -1
- package/dist/commands/bot.js +757 -22
- package/dist/commands/secrets.js +17 -5
- package/dist/lib/agent-kit/creds.d.ts +60 -0
- package/dist/lib/agent-kit/creds.js +123 -0
- package/dist/lib/agent-kit/kit-config.d.ts +29 -0
- package/dist/lib/agent-kit/kit-config.js +54 -0
- package/dist/lib/agent-kit/log.d.ts +17 -0
- package/dist/lib/agent-kit/log.js +46 -0
- package/dist/lib/agent-kit/mcp/jsonrpc.d.ts +84 -0
- package/dist/lib/agent-kit/mcp/jsonrpc.js +164 -0
- package/dist/lib/agent-kit/mcp/tools.d.ts +45 -0
- package/dist/lib/agent-kit/mcp/tools.js +280 -0
- package/dist/lib/agent-kit/paths.d.ts +42 -0
- package/dist/lib/agent-kit/paths.js +56 -0
- package/dist/lib/agent-kit/run/heartbeat.d.ts +52 -0
- package/dist/lib/agent-kit/run/heartbeat.js +97 -0
- package/dist/lib/agent-kit/run/inbox.d.ts +59 -0
- package/dist/lib/agent-kit/run/inbox.js +152 -0
- package/dist/lib/agent-kit/run/mesh-listener.d.ts +58 -0
- package/dist/lib/agent-kit/run/mesh-listener.js +193 -0
- package/dist/lib/agent-kit/run/sync.d.ts +33 -0
- package/dist/lib/agent-kit/run/sync.js +58 -0
- package/dist/lib/agent-kit/services.d.ts +21 -0
- package/dist/lib/agent-kit/services.js +46 -0
- package/dist/lib/agent-kit/skills.d.ts +18 -0
- package/dist/lib/agent-kit/skills.js +149 -0
- package/dist/lib/bot/api.d.ts +51 -0
- package/dist/lib/bot/api.js +32 -0
- package/dist/lib/bot/daemon.d.ts +17 -0
- package/dist/lib/bot/daemon.js +44 -3
- package/dist/lib/bot/index.d.ts +4 -0
- package/dist/lib/bot/index.js +4 -0
- package/dist/lib/bot/inflight.d.ts +14 -0
- package/dist/lib/bot/local-config.d.ts +70 -0
- package/dist/lib/bot/local-config.js +147 -0
- package/dist/lib/bot/local-name.d.ts +54 -0
- package/dist/lib/bot/local-name.js +114 -0
- package/dist/lib/bot/run.d.ts +9 -0
- package/dist/lib/bot/run.js +117 -24
- package/dist/lib/bot/runnable.d.ts +51 -0
- package/dist/lib/bot/runnable.js +65 -0
- package/dist/lib/bot/self-heal.d.ts +52 -0
- package/dist/lib/bot/self-heal.js +79 -0
- package/dist/lib/bot/split.d.ts +32 -0
- package/dist/lib/bot/split.js +241 -0
- package/dist/lib/service-manager/index.d.ts +43 -0
- package/dist/lib/service-manager/index.js +114 -0
- package/dist/lib/service-manager/launchd.d.ts +23 -0
- package/dist/lib/service-manager/launchd.js +81 -0
- package/dist/lib/service-manager/systemd.d.ts +19 -0
- package/dist/lib/service-manager/systemd.js +72 -0
- package/dist/lib/service-manager/types.d.ts +32 -0
- package/dist/lib/service-manager/types.js +26 -0
- package/dist/utils/self-update.js +2 -30
- package/dist/utils/update-command-supervisor.cjs +194 -0
- package/dist/utils/version-gate.d.ts +18 -0
- package/dist/utils/version-gate.js +126 -7
- package/package.json +2 -2
package/dist/commands/bot.js
CHANGED
|
@@ -7,7 +7,9 @@ import { hasPromotionHold, promotionHoldSummary } from "../lib/bot/promotion-hol
|
|
|
7
7
|
* hq bot create <name> [--kind personal|company] [--company slug]... [--runtime claude|codex|grok] [--model m] [--effort level] [--worker id] [--intro text] [--kickoff prompt] [--memory synced|local] [--no-daemon]
|
|
8
8
|
* hq bot companies <name> [--add slug]... [--remove slug]... (a company bot's memberships)
|
|
9
9
|
* hq bot workers [--json] (company + core workers a bot can be created from)
|
|
10
|
-
* hq bot list [--json]
|
|
10
|
+
* hq bot list [--remote] [--json]
|
|
11
|
+
* hq bot adopt <name> [--runtime r] [--model m] (a bot you own, back on this computer)
|
|
12
|
+
* hq bot restore [--all] [--dry-run] (every bot you own that is missing here)
|
|
11
13
|
* hq bot start|stop|restart <name>
|
|
12
14
|
* hq bot status <name> [--json]
|
|
13
15
|
* hq bot logs <name> [--follow] [-n N]
|
|
@@ -26,6 +28,7 @@ import { registerBotContinuityCommand } from "./bot-continuity.js";
|
|
|
26
28
|
import chalk from "chalk";
|
|
27
29
|
import { execFileSync, spawn } from "node:child_process";
|
|
28
30
|
import * as fs from "node:fs";
|
|
31
|
+
import * as os from "node:os";
|
|
29
32
|
import * as path from "node:path";
|
|
30
33
|
import * as readline from "node:readline";
|
|
31
34
|
import { DEFAULT_COGNITO, ensureCognitoToken, resolveDefaultHqRoot, } from "../utils/cognito-session.js";
|
|
@@ -33,7 +36,7 @@ import { peekHqApiKey } from "../utils/resolve-vault-credential.js";
|
|
|
33
36
|
import { getCompanyUid, resolveCallerPersonUid } from "../utils/vault-api.js";
|
|
34
37
|
import { resolveHqBinary } from "../lib/mesh/live/daemon/install.js";
|
|
35
38
|
import { runtimeDisplayName } from "../lib/bot/runtime-sign-in.js";
|
|
36
|
-
import { BotApi, BotApiError, botDaemonStatus, botDir, botCredsPath, botLogPath, botsRoot, botTokenStateDir, botWorkerRelDir, buildBotDaemonPaths, createBotLogger, deleteBotCreds, installBotDaemon, introDmText, isBotMemoryMode, botLocalMemoryDir, botMemoryMode, resolveBotMemoryDir, validateBotIntro, validateBotKickoff, validateBotEffort, validateBotModel, effectiveBotEffort, BOT_EFFORT_LEVELS, DEFAULT_BOT_EFFORT, BOT_MEMORY_MODES, BOT_KINDS, isBotKind, effectiveBotKind, effectiveBotCompanies, normalizeBotCompanies, SETUP_BOT_WORKER_ID, isBotRuntimeId, isPidAlive, listBotWorkerOptions, patchBotConfig, readBotConfig, readBotCredsIdentity, readBotStatus, resolveBotWorker, runBot, runtimeFor, scaffoldBotMemory, scaffoldBotWorker, startBotDaemon, stopBotDaemon, uninstallBotDaemon, validateBotName, writeBotConfig, writeBotCreds, BOT_RUNTIMES, BOT_INTRO_MAX_CHARS, BOT_KICKOFF_MAX_CHARS, } from "../lib/bot/index.js";
|
|
39
|
+
import { BotApi, BotApiError, BOT_RESTORE_DEFAULTS, buildBotLocalConfig, describeBotLocalConfig, orphanBotRunMessage, parseBotLocalConfig, removeOrphanBotDaemon, botDaemonStatus, botDir, botCredsPath, botLogPath, botsRoot, botTokenStateDir, botWorkerRelDir, buildBotDaemonPaths, createBotLogger, deleteBotCreds, installBotDaemon, introDmText, isBotMemoryMode, botLocalMemoryDir, botMemoryMode, resolveBotMemoryDir, validateBotIntro, validateBotKickoff, validateBotEffort, validateBotModel, effectiveBotEffort, BOT_EFFORT_LEVELS, DEFAULT_BOT_EFFORT, BOT_MEMORY_MODES, BOT_KINDS, isBotKind, effectiveBotKind, effectiveBotCompanies, normalizeBotCompanies, SETUP_BOT_WORKER_ID, isBotRuntimeId, localNameForRemoteBot, remoteBotRunnability, isPidAlive, listBotWorkerOptions, patchBotConfig, readBotConfig, readBotCredsIdentity, readBotStatus, resolveBotWorker, runBot, runtimeFor, scaffoldBotMemory, scaffoldBotWorker, startBotDaemon, stopBotDaemon, uninstallBotDaemon, validateBotName, writeBotConfig, writeBotCreds, BOT_RUNTIMES, BOT_INTRO_MAX_CHARS, BOT_KICKOFF_MAX_CHARS, } from "../lib/bot/index.js";
|
|
37
40
|
function collectRepeated(value, previous) {
|
|
38
41
|
return [...previous, value];
|
|
39
42
|
}
|
|
@@ -81,9 +84,10 @@ function requireBot(name) {
|
|
|
81
84
|
const dir = botDir(valid);
|
|
82
85
|
const config = readBotConfig(dir);
|
|
83
86
|
if (!config) {
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
+
// Never point at `create` alone: after a wiped ~/.hq the cloud still has
|
|
88
|
+
// the bot, so creating it refuses with a 409 and sends the person straight
|
|
89
|
+
// back here. Adopting is the move that works.
|
|
90
|
+
throw Object.assign(new Error(`No bot named "${valid}" is set up on this computer. Bring it back with: hq bot adopt ${valid} (or make a new one: hq bot create ${valid})`), { expected: true });
|
|
87
91
|
}
|
|
88
92
|
return { name: valid, dir, config };
|
|
89
93
|
}
|
|
@@ -223,7 +227,12 @@ function stopProcess(dir) {
|
|
|
223
227
|
return false;
|
|
224
228
|
}
|
|
225
229
|
}
|
|
226
|
-
|
|
230
|
+
/**
|
|
231
|
+
* `output`: "json" | "text" print the result; "silent" prints nothing — a
|
|
232
|
+
* restore brings several bots back and its own JSON must be the only document
|
|
233
|
+
* on stdout (the desktop app parses it whole).
|
|
234
|
+
*/
|
|
235
|
+
async function startBot(name, dir, config, output, extra = {}) {
|
|
227
236
|
if (hasPromotionHold(dir))
|
|
228
237
|
throw new Error("This bot is held for cloud promotion. Continue its promotion before changing local settings or lifecycle.");
|
|
229
238
|
if (config.enabled === false)
|
|
@@ -249,9 +258,11 @@ async function startBot(name, dir, config, json, extra = {}) {
|
|
|
249
258
|
return !!s && s.state === "running" && isPidAlive(s.pid);
|
|
250
259
|
}, 15_000);
|
|
251
260
|
const status = readBotStatus(dir);
|
|
252
|
-
if (
|
|
261
|
+
if (output === "silent")
|
|
262
|
+
return { running, how };
|
|
263
|
+
if (output === "json") {
|
|
253
264
|
console.log(JSON.stringify({ ok: running, name, ...extra, how, state: status?.state ?? "unknown", pid: status?.pid ?? null, lastError: status?.lastError ?? null }, null, 2));
|
|
254
|
-
return;
|
|
265
|
+
return { running, how };
|
|
255
266
|
}
|
|
256
267
|
if (running)
|
|
257
268
|
console.log(chalk.green(`${name} is running`) + chalk.dim(` (${how})`));
|
|
@@ -261,6 +272,7 @@ async function startBot(name, dir, config, json, extra = {}) {
|
|
|
261
272
|
console.log(chalk.dim(` last error: ${status.lastError}`));
|
|
262
273
|
console.log(chalk.dim(` logs: hq bot logs ${name}`));
|
|
263
274
|
}
|
|
275
|
+
return { running, how };
|
|
264
276
|
}
|
|
265
277
|
/**
|
|
266
278
|
* `output`: "json" | "text" print a result; "silent" prints nothing — used
|
|
@@ -380,12 +392,28 @@ export async function runBotCreate(nameArg, opts) {
|
|
|
380
392
|
throw Object.assign(new Error(`Cannot create ${name}: ${err instanceof Error ? err.message : String(err)} (company "${slug}").`), { expected: true });
|
|
381
393
|
}
|
|
382
394
|
}
|
|
395
|
+
// The bot's non-secret settings travel with its cloud identity, so a
|
|
396
|
+
// reinstall (or a second Mac) can rebuild the same bot with `hq bot adopt`
|
|
397
|
+
// instead of creating a new one. The machine secret never travels.
|
|
398
|
+
const localConfig = buildBotLocalConfig({
|
|
399
|
+
runtime,
|
|
400
|
+
kind,
|
|
401
|
+
...(kind === "company" ? { companies } : {}),
|
|
402
|
+
...(model ? { model } : {}),
|
|
403
|
+
...(effort ? { effort } : {}),
|
|
404
|
+
...(opts.autoApprove === false ? { autoApprove: false } : {}),
|
|
405
|
+
...(worker
|
|
406
|
+
? { workerSource: "worker", workerId: worker.id, ...(worker.company ? { companySlug: worker.company } : {}) }
|
|
407
|
+
: { workerSource: "scaffold" }),
|
|
408
|
+
...(memoryMode === "local" ? { memoryDir: botLocalMemoryDir(dir) } : {}),
|
|
409
|
+
});
|
|
383
410
|
let created;
|
|
384
411
|
try {
|
|
385
412
|
created = await api.createLocalBot({
|
|
386
413
|
name,
|
|
387
414
|
kind,
|
|
388
415
|
...(kind === "company" ? { companyUid: companyUids[0], companyMemberships: companyUids.slice(1) } : {}),
|
|
416
|
+
localConfig,
|
|
389
417
|
});
|
|
390
418
|
}
|
|
391
419
|
catch (err) {
|
|
@@ -400,6 +428,13 @@ export async function runBotCreate(nameArg, opts) {
|
|
|
400
428
|
expected: true,
|
|
401
429
|
});
|
|
402
430
|
}
|
|
431
|
+
if (err instanceof BotApiError && err.status === 409) {
|
|
432
|
+
// The cloud already has this bot; it is simply not set up on this
|
|
433
|
+
// computer (a reinstall, or a second Mac). Re-creating it is the wrong
|
|
434
|
+
// move — adopting brings the existing one back, memory and all.
|
|
435
|
+
throw Object.assign(new Error(`You already have a bot named "${name}" in HQ — it is just not set up on this computer. ` +
|
|
436
|
+
`Bring it back with: hq bot adopt ${name} (or restore every bot you own: hq bot restore)`), { expected: true });
|
|
437
|
+
}
|
|
403
438
|
throw err;
|
|
404
439
|
}
|
|
405
440
|
const agentUid = created.agent.uid;
|
|
@@ -519,7 +554,7 @@ export async function runBotCreate(nameArg, opts) {
|
|
|
519
554
|
console.log(chalk.yellow(`${name} has not reported running yet; check hq bot logs ${name}`));
|
|
520
555
|
return;
|
|
521
556
|
}
|
|
522
|
-
await startBot(name, dir, config,
|
|
557
|
+
await startBot(name, dir, config, opts.json ? "json" : "text", { ...created_, daemon: daemonMessage });
|
|
523
558
|
return;
|
|
524
559
|
}
|
|
525
560
|
if (opts.json) {
|
|
@@ -605,7 +640,33 @@ export async function runBotWorkers(opts) {
|
|
|
605
640
|
}
|
|
606
641
|
// ── run (resident) ───────────────────────────────────────────────────────────
|
|
607
642
|
async function runBotResident(nameArg) {
|
|
608
|
-
const
|
|
643
|
+
const name = validateBotName(nameArg);
|
|
644
|
+
const dir = botDir(name);
|
|
645
|
+
const config = readBotConfig(dir);
|
|
646
|
+
if (!config) {
|
|
647
|
+
// A LaunchAgent that outlived its bot: exiting non-zero here made launchd
|
|
648
|
+
// relaunch this every five seconds forever. Say so once, take the stale
|
|
649
|
+
// registration away, and exit 0 (KeepAlive{SuccessfulExit:false} leaves a
|
|
650
|
+
// clean exit alone).
|
|
651
|
+
//
|
|
652
|
+
// Under launchd this process IS the job, so the order is load-bearing: the
|
|
653
|
+
// line lands in bot.log and the plist is unlinked while we are still
|
|
654
|
+
// alive, and only then is the `bootout` — which kills us — asked for, in a
|
|
655
|
+
// detached session that outlives us. Printing after the removal, as this
|
|
656
|
+
// did, produced an empty log and a surviving plist on a real Mac.
|
|
657
|
+
let said = false;
|
|
658
|
+
const outcome = removeOrphanBotDaemon({
|
|
659
|
+
name,
|
|
660
|
+
botDir: dir,
|
|
661
|
+
announce: (o) => {
|
|
662
|
+
said = true;
|
|
663
|
+
console.log(orphanBotRunMessage(name, o));
|
|
664
|
+
},
|
|
665
|
+
}, daemonDeps());
|
|
666
|
+
if (!said)
|
|
667
|
+
console.log(orphanBotRunMessage(name, outcome));
|
|
668
|
+
return;
|
|
669
|
+
}
|
|
609
670
|
const api = new BotApi({ token: botTokenSupplier(dir) });
|
|
610
671
|
const owner = new OwnerContextCache(() => lookupOwnerContext(config.ownerUid), Date.now, undefined, config.ownerUid);
|
|
611
672
|
const handle = await runBot({ dir, config, api, runtime: runtimeFor(config.runtime), ownerContext: () => owner.get() });
|
|
@@ -614,6 +675,10 @@ async function runBotResident(nameArg) {
|
|
|
614
675
|
}
|
|
615
676
|
// ── list / status / logs ─────────────────────────────────────────────────────
|
|
616
677
|
async function runBotList(opts) {
|
|
678
|
+
if (opts.remote) {
|
|
679
|
+
await runBotListRemote(opts);
|
|
680
|
+
return;
|
|
681
|
+
}
|
|
617
682
|
const rows = listBotNames(botsRoot()).map((n) => rowFor(n));
|
|
618
683
|
await decorateOnline(rows);
|
|
619
684
|
if (opts.json) {
|
|
@@ -698,7 +763,134 @@ async function confirm(question) {
|
|
|
698
763
|
});
|
|
699
764
|
});
|
|
700
765
|
}
|
|
766
|
+
/**
|
|
767
|
+
* `hq bot rm <name>` for a bot whose bot.json is gone — a wiped ~/.hq, a
|
|
768
|
+
* half-finished reinstall, a folder deleted by hand. Without this the
|
|
769
|
+
* leftovers were unreachable: `rm` refused because there was no config, and
|
|
770
|
+
* the message it gave pointed at `create`, which refuses in turn because the
|
|
771
|
+
* cloud still has the bot. Remove what is actually on this computer, and with
|
|
772
|
+
* --remote the bot HQ still holds under that name.
|
|
773
|
+
*/
|
|
774
|
+
async function runBotRmOrphan(name, opts) {
|
|
775
|
+
const dir = botDir(name);
|
|
776
|
+
const hadDir = fs.existsSync(dir);
|
|
777
|
+
let plistPath = null;
|
|
778
|
+
try {
|
|
779
|
+
plistPath = buildBotDaemonPaths({ name, botDir: dir, hqBinary: "hq", nodeBinary: process.execPath }).plistPath;
|
|
780
|
+
}
|
|
781
|
+
catch {
|
|
782
|
+
/* no resolvable home: there is nothing to say about a startup agent */
|
|
783
|
+
}
|
|
784
|
+
const hadDaemon = plistPath !== null && fs.existsSync(plistPath);
|
|
785
|
+
// Ask HQ before touching anything local, so a server that cannot answer does
|
|
786
|
+
// not leave the removal half done.
|
|
787
|
+
let api = null;
|
|
788
|
+
let remote = null;
|
|
789
|
+
if (opts.remote) {
|
|
790
|
+
const token = await ownerToken();
|
|
791
|
+
api = new BotApi({ token: async () => token });
|
|
792
|
+
const mine = await listMyBotsOrExplain(api, {
|
|
793
|
+
json: opts.json,
|
|
794
|
+
emptyResult: { name, orphan: true, localStateRemoved: false, remoteRemoved: false },
|
|
795
|
+
});
|
|
796
|
+
const hits = matchRemoteBots(mine, name);
|
|
797
|
+
if (hits.length > 1) {
|
|
798
|
+
throw Object.assign(new Error(`You own more than one bot called "${name}": ${hits.map((h) => h.agentUid).join(", ")}. Name the one you mean by its id.`), {
|
|
799
|
+
expected: true,
|
|
800
|
+
});
|
|
801
|
+
}
|
|
802
|
+
remote = hits[0] ?? null;
|
|
803
|
+
}
|
|
804
|
+
if (!hadDir && !hadDaemon && !remote) {
|
|
805
|
+
throw Object.assign(new Error(opts.remote
|
|
806
|
+
? `Nothing named "${name}" is left on this computer, and HQ has no bot by that name either.`
|
|
807
|
+
: `Nothing named "${name}" is left on this computer. If HQ still has a bot by that name, remove that too with: hq bot rm ${name} --remote`), { expected: true });
|
|
808
|
+
}
|
|
809
|
+
if (!opts.yes) {
|
|
810
|
+
const ok = await confirm(`"${name}" has no settings left on this computer. Remove what is left of it${remote ? `, and the bot HQ still has by that name (${remote.agentUid})` : ""}? [y/N] `);
|
|
811
|
+
if (!ok) {
|
|
812
|
+
console.log(chalk.dim("Cancelled."));
|
|
813
|
+
return;
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
// Inline bootout: unlike the self-heal, this process is not the launchd job.
|
|
817
|
+
const daemon = removeOrphanBotDaemon({ name, botDir: dir }, { ...daemonDeps(), bootout: "inline" });
|
|
818
|
+
if (hadDir)
|
|
819
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
820
|
+
let remoteRemoved = false;
|
|
821
|
+
let remoteDetail = null;
|
|
822
|
+
let remoteSteps = [];
|
|
823
|
+
let failure = null;
|
|
824
|
+
if (remote && api) {
|
|
825
|
+
try {
|
|
826
|
+
const identity = await api.deleteLocalBot(remote.agentUid);
|
|
827
|
+
remoteRemoved = identity.terminal;
|
|
828
|
+
if (identity.terminal) {
|
|
829
|
+
remoteDetail = "removed from HQ";
|
|
830
|
+
}
|
|
831
|
+
else {
|
|
832
|
+
const incomplete = botRemovalIncomplete(identity.failures);
|
|
833
|
+
remoteDetail = incomplete.detail;
|
|
834
|
+
remoteSteps = incomplete.steps;
|
|
835
|
+
failure = {
|
|
836
|
+
reason: incomplete.reason,
|
|
837
|
+
message: `"${name}" is gone from this computer, but ${incomplete.detail}.` +
|
|
838
|
+
(incomplete.retryable ? ` Run the same command again to finish clearing it from HQ.` : ` The leftover is HQ's to clear.`),
|
|
839
|
+
};
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
catch (err) {
|
|
843
|
+
if (err instanceof BotApiError && err.status === 404) {
|
|
844
|
+
remoteRemoved = true;
|
|
845
|
+
remoteDetail = "it was already gone from HQ";
|
|
846
|
+
}
|
|
847
|
+
else {
|
|
848
|
+
// Never the transport string: one plain sentence, and the reason the
|
|
849
|
+
// desktop switches on.
|
|
850
|
+
const refused = botRemoteDeleteRefused(name, err);
|
|
851
|
+
remoteDetail = refused.detail;
|
|
852
|
+
failure = { reason: refused.reason, message: refused.message };
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
if (opts.json) {
|
|
857
|
+
console.log(JSON.stringify({
|
|
858
|
+
ok: remote === null || remoteRemoved,
|
|
859
|
+
...(failure ? { reason: failure.reason, message: failure.message } : {}),
|
|
860
|
+
name,
|
|
861
|
+
orphan: true,
|
|
862
|
+
agentUid: remote?.agentUid ?? null,
|
|
863
|
+
daemon: daemon.message,
|
|
864
|
+
localStateRemoved: hadDir,
|
|
865
|
+
remoteRemoved,
|
|
866
|
+
remoteDetail,
|
|
867
|
+
remoteStepsUnfinished: remoteSteps,
|
|
868
|
+
}, null, 2));
|
|
869
|
+
if (failure)
|
|
870
|
+
throw Object.assign(new Error(failure.message), { expected: true });
|
|
871
|
+
return;
|
|
872
|
+
}
|
|
873
|
+
console.log(chalk.green(`Removed what was left of "${name}" on this computer`) + chalk.dim(" (its settings were already gone)."));
|
|
874
|
+
console.log(chalk.dim(` startup agent: ${daemon.message}`));
|
|
875
|
+
console.log(chalk.dim(` local folder: ${hadDir ? `removed ${dir}` : "there was none left"}`));
|
|
876
|
+
if (failure) {
|
|
877
|
+
if (remoteSteps.length > 0)
|
|
878
|
+
console.log(chalk.dim(` in HQ: did not finish ${remoteSteps.join(", ")}`));
|
|
879
|
+
throw Object.assign(new Error(failure.message), { expected: true });
|
|
880
|
+
}
|
|
881
|
+
if (remote)
|
|
882
|
+
console.log(chalk.green(` in HQ: ${remoteDetail}`));
|
|
883
|
+
else if (opts.remote)
|
|
884
|
+
console.log(chalk.dim(` in HQ: HQ has no bot named "${name}", so there was nothing to remove there.`));
|
|
885
|
+
else
|
|
886
|
+
console.log(chalk.dim(` HQ may still have a bot named "${name}". Remove that too with: hq bot rm ${name} --remote`));
|
|
887
|
+
}
|
|
701
888
|
async function runBotRm(nameArg, opts) {
|
|
889
|
+
const validated = validateBotName(nameArg);
|
|
890
|
+
if (readBotConfig(botDir(validated)) === null) {
|
|
891
|
+
await runBotRmOrphan(validated, opts);
|
|
892
|
+
return;
|
|
893
|
+
}
|
|
702
894
|
const { name, dir, config } = requireBot(nameArg);
|
|
703
895
|
if (hasPromotionHold(dir))
|
|
704
896
|
throw new Error("This bot is held for cloud promotion. Continue its promotion before changing local settings or lifecycle.");
|
|
@@ -725,15 +917,48 @@ async function runBotRm(nameArg, opts) {
|
|
|
725
917
|
throw err;
|
|
726
918
|
}
|
|
727
919
|
const terminal = "alreadyGone" in identity ? true : identity.terminal;
|
|
920
|
+
// A teardown HQ could only half-finish. Classified here so neither branch
|
|
921
|
+
// below ever repeats what the server said: one real answer carried the AWS
|
|
922
|
+
// account id, an assumed-role ARN and a DynamoDB table ARN.
|
|
923
|
+
const incomplete = terminal ? null : botRemovalIncomplete("failures" in identity ? identity.failures : undefined);
|
|
728
924
|
// A company bot is a member of its companies: leave them too. Best effort —
|
|
729
925
|
// a stale membership row must not keep the bot's local state around.
|
|
730
926
|
const memberships = effectiveBotCompanies(config);
|
|
731
927
|
const left = memberships.length > 0 ? await removeBotFromCompanies({ agentUid: config.agentUid, companies: memberships, token }) : { done: [], failed: [] };
|
|
732
928
|
deleteBotCreds(dir);
|
|
733
|
-
|
|
929
|
+
// The local-state decision. A refusal, or a step whose target was already
|
|
930
|
+
// gone, answers the same way forever — and by then the steps that did
|
|
931
|
+
// succeed have left a bot that can no longer sign in, so keeping its folder
|
|
932
|
+
// only strands the person with something that cannot run and cannot be
|
|
933
|
+
// re-created under the same name. Everything else keeps the folder, so the
|
|
934
|
+
// same command can pick the job back up.
|
|
935
|
+
const localStateRemoved = terminal || incomplete.retryable === false;
|
|
936
|
+
if (localStateRemoved)
|
|
734
937
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
938
|
+
const message = incomplete
|
|
939
|
+
? `HQ removed what it could of "${name}", but ${incomplete.detail}.` +
|
|
940
|
+
(incomplete.retryable
|
|
941
|
+
? ` Its settings are kept on this computer, so run the same command again to finish it.`
|
|
942
|
+
: ` It is gone from this computer; the leftover is HQ's to clear.`)
|
|
943
|
+
: null;
|
|
735
944
|
if (opts.json) {
|
|
736
|
-
console.log(JSON.stringify({
|
|
945
|
+
console.log(JSON.stringify({
|
|
946
|
+
ok: terminal,
|
|
947
|
+
...(incomplete && message ? { reason: incomplete.reason, message } : {}),
|
|
948
|
+
name,
|
|
949
|
+
orphan: false,
|
|
950
|
+
agentUid: config.agentUid,
|
|
951
|
+
daemon: daemon.message,
|
|
952
|
+
companiesLeft: left.done,
|
|
953
|
+
companiesNotLeft: left.failed,
|
|
954
|
+
localStateRemoved,
|
|
955
|
+
remoteRemoved: terminal,
|
|
956
|
+
remoteDetail: incomplete ? incomplete.detail : "alreadyGone" in identity ? "it was already gone from HQ" : "removed from HQ",
|
|
957
|
+
remoteStepsUnfinished: incomplete?.steps ?? [],
|
|
958
|
+
workerKept: path.join(config.hqRoot, config.workerDir),
|
|
959
|
+
}, null, 2));
|
|
960
|
+
if (message)
|
|
961
|
+
throw Object.assign(new Error(message), { expected: true });
|
|
737
962
|
return;
|
|
738
963
|
}
|
|
739
964
|
for (const f of left.failed) {
|
|
@@ -741,15 +966,11 @@ async function runBotRm(nameArg, opts) {
|
|
|
741
966
|
}
|
|
742
967
|
if (terminal) {
|
|
743
968
|
console.log(chalk.green(`Removed bot ${name}`) + chalk.dim(` (${config.agentUid}${left.done.length ? `; left ${left.done.join(", ")}` : ""}); worker folder kept at ${path.join(config.hqRoot, config.workerDir)}`));
|
|
969
|
+
return;
|
|
744
970
|
}
|
|
745
|
-
|
|
746
|
-
console.log(chalk.
|
|
747
|
-
|
|
748
|
-
for (const [step, msg] of Object.entries(identity.failures))
|
|
749
|
-
console.log(chalk.dim(` ${step}: ${msg}`));
|
|
750
|
-
}
|
|
751
|
-
console.log(chalk.dim(` Re-run: hq bot rm ${name} --yes`));
|
|
752
|
-
}
|
|
971
|
+
if (incomplete.steps.length > 0)
|
|
972
|
+
console.log(chalk.dim(` HQ did not finish: ${incomplete.steps.join(", ")}`));
|
|
973
|
+
throw Object.assign(new Error(message), { expected: true });
|
|
753
974
|
}
|
|
754
975
|
/**
|
|
755
976
|
* `hq bot set <name> --model <id|default> --effort <level|default>`: change
|
|
@@ -804,6 +1025,500 @@ async function runBotIntro(nameArg) {
|
|
|
804
1025
|
patchBotConfig(dir, { introSentAt: new Date().toISOString() });
|
|
805
1026
|
console.log(chalk.green(`${name} introduced itself to you.`));
|
|
806
1027
|
}
|
|
1028
|
+
// ── adopt / restore (bring a bot you own back to this computer) ──────────────
|
|
1029
|
+
/**
|
|
1030
|
+
* Which computer a re-issue is for. Never a secret and never an identifier the
|
|
1031
|
+
* server needs to trust — it only shows up in the bot's audit trail so its
|
|
1032
|
+
* owner can see where it was last brought back.
|
|
1033
|
+
*/
|
|
1034
|
+
function localMachineId() {
|
|
1035
|
+
const host = os.hostname().trim();
|
|
1036
|
+
return host || "unknown-machine";
|
|
1037
|
+
}
|
|
1038
|
+
/**
|
|
1039
|
+
* The one bot the caller means. Matches the local folder name, the cloud slug,
|
|
1040
|
+
* the display name, or the agt_ uid; an ambiguous name is refused rather than
|
|
1041
|
+
* guessed at.
|
|
1042
|
+
*/
|
|
1043
|
+
export function matchRemoteBots(bots, wanted) {
|
|
1044
|
+
const needle = wanted.trim().toLowerCase();
|
|
1045
|
+
return bots.filter((b) => b.agentUid === wanted.trim() ||
|
|
1046
|
+
(b.slug ?? "").trim().toLowerCase() === needle ||
|
|
1047
|
+
(b.name ?? "").trim().toLowerCase() === needle ||
|
|
1048
|
+
localNameForRemoteBot(b) === needle);
|
|
1049
|
+
}
|
|
1050
|
+
export function resolveRemoteBot(bots, wanted) {
|
|
1051
|
+
const hits = matchRemoteBots(bots, wanted);
|
|
1052
|
+
if (hits.length === 1)
|
|
1053
|
+
return hits[0];
|
|
1054
|
+
if (hits.length > 1) {
|
|
1055
|
+
throw Object.assign(new Error(`You own more than one bot called "${wanted}": ${hits.map((h) => h.agentUid).join(", ")}. Name the one you mean by its id.`), { expected: true });
|
|
1056
|
+
}
|
|
1057
|
+
const known = bots.map((b) => localNameForRemoteBot(b) ?? b.agentUid);
|
|
1058
|
+
throw Object.assign(new Error(known.length > 0
|
|
1059
|
+
? `You have no bot called "${wanted}" in HQ. Bots you own: ${known.join(", ")}.`
|
|
1060
|
+
: `You have no local bots in HQ yet. Create one with: hq bot create ${wanted}`), { expected: true });
|
|
1061
|
+
}
|
|
1062
|
+
/** Turn a BotApiError from the credential route into a sentence a person can act on. */
|
|
1063
|
+
function credentialError(err, name) {
|
|
1064
|
+
if (err instanceof BotApiError && err.status === 403) {
|
|
1065
|
+
return Object.assign(new Error(`HQ will not hand out credentials for "${name}": it is either not yours, or it is a cloud agent rather than a local bot.`), { expected: true });
|
|
1066
|
+
}
|
|
1067
|
+
if (err instanceof BotApiError && err.status === 404) {
|
|
1068
|
+
return Object.assign(new Error(`HQ no longer has a bot called "${name}". Create a new one with: hq bot create ${name}`), {
|
|
1069
|
+
expected: true,
|
|
1070
|
+
});
|
|
1071
|
+
}
|
|
1072
|
+
return err;
|
|
1073
|
+
}
|
|
1074
|
+
/**
|
|
1075
|
+
* Turn any failure of `GET /v1/agents/mine` into one plain sentence plus a
|
|
1076
|
+
* stable reason. Production HQ Cloud does not serve that route yet, and an
|
|
1077
|
+
* uncaught 404 reached the person verbatim as "HQ API /v1/agents/mine → 404:
|
|
1078
|
+
* Not found" — and, under --json, as no document at all.
|
|
1079
|
+
*/
|
|
1080
|
+
export function remoteBotsUnavailable(err) {
|
|
1081
|
+
if (err instanceof BotApiError) {
|
|
1082
|
+
if (err.status === 404 || err.status === 501) {
|
|
1083
|
+
return {
|
|
1084
|
+
reason: "server-unsupported",
|
|
1085
|
+
message: "Your HQ Cloud cannot list the bots you own yet, so there is nothing to bring back from here. Update HQ Cloud, or try again later.",
|
|
1086
|
+
};
|
|
1087
|
+
}
|
|
1088
|
+
if (err.status === 401 || err.status === 403) {
|
|
1089
|
+
return { reason: "auth", message: "HQ did not accept your sign-in, so it would not say which bots you own. Sign in again with: hq login" };
|
|
1090
|
+
}
|
|
1091
|
+
if (err.status >= 500) {
|
|
1092
|
+
return { reason: "server-error", message: "HQ Cloud had trouble listing the bots you own. Try again in a minute." };
|
|
1093
|
+
}
|
|
1094
|
+
return { reason: "error", message: "HQ could not say which bots you own, so there is nothing to bring back from here. Try again in a minute." };
|
|
1095
|
+
}
|
|
1096
|
+
const text = err instanceof Error ? err.message : String(err);
|
|
1097
|
+
if (/fetch failed|ENOTFOUND|ECONNREFUSED|ECONNRESET|EAI_AGAIN|EHOSTUNREACH|ETIMEDOUT|network|socket hang up|timed? ?out/i.test(text)) {
|
|
1098
|
+
return { reason: "network", message: "HQ could not be reached, so it could not say which bots you own. Check your connection and try again." };
|
|
1099
|
+
}
|
|
1100
|
+
return { reason: "error", message: "HQ could not say which bots you own, so there is nothing to bring back from here. Try again in a minute." };
|
|
1101
|
+
}
|
|
1102
|
+
const REFUSED_TEARDOWN_STEP = /access\s?denied|not authori[sz]ed|unauthori[sz]ed|forbidden|identity-based policy|permission/i;
|
|
1103
|
+
const ALREADY_GONE_TEARDOWN_STEP = /not\s?found|no such|does not exist|already (?:gone|deleted|removed)/i;
|
|
1104
|
+
/**
|
|
1105
|
+
* Step names are fixed server vocabulary (`delete-creator-owner`). Anything
|
|
1106
|
+
* that is not plainly one is dropped rather than echoed.
|
|
1107
|
+
*/
|
|
1108
|
+
function safeTeardownSteps(failures) {
|
|
1109
|
+
return Object.keys(failures ?? {}).filter((step) => /^[a-z0-9][a-z0-9._-]{0,39}$/i.test(step));
|
|
1110
|
+
}
|
|
1111
|
+
export function botRemovalIncomplete(failures) {
|
|
1112
|
+
const steps = safeTeardownSteps(failures);
|
|
1113
|
+
const texts = Object.values(failures ?? {});
|
|
1114
|
+
const refused = texts.some((text) => REFUSED_TEARDOWN_STEP.test(text));
|
|
1115
|
+
const permanent = texts.length > 0 && texts.every((text) => REFUSED_TEARDOWN_STEP.test(text) || ALREADY_GONE_TEARDOWN_STEP.test(text));
|
|
1116
|
+
if (permanent) {
|
|
1117
|
+
return refused
|
|
1118
|
+
? { reason: "auth", detail: "HQ was not allowed to finish clearing its cloud identity, and would be refused the same way next time", retryable: false, steps }
|
|
1119
|
+
: { reason: "error", detail: "HQ found part of its cloud identity already gone, and would find the same next time", retryable: false, steps };
|
|
1120
|
+
}
|
|
1121
|
+
return { reason: "server-error", detail: "HQ has not finished removing it from the cloud", retryable: true, steps };
|
|
1122
|
+
}
|
|
1123
|
+
/**
|
|
1124
|
+
* A cloud delete that threw. Reuses {@link remoteBotsUnavailable}'s
|
|
1125
|
+
* classification of the transport, with a sentence about removal rather than
|
|
1126
|
+
* listing — and, as there, the transport string never reaches the person.
|
|
1127
|
+
* A 404 is not routed here: for a delete it means the bot was already gone.
|
|
1128
|
+
*/
|
|
1129
|
+
function botRemoteDeleteRefused(name, err) {
|
|
1130
|
+
const { reason } = remoteBotsUnavailable(err);
|
|
1131
|
+
const next = reason === "auth" ? "Sign in again with: hq login, then run this again." : reason === "network" ? "Check your connection and run this again." : "Try again in a minute.";
|
|
1132
|
+
return {
|
|
1133
|
+
reason,
|
|
1134
|
+
detail: reason === "auth" ? "HQ did not accept your sign-in" : reason === "network" ? "HQ could not be reached" : "HQ could not remove it just now",
|
|
1135
|
+
message: `"${name}" is gone from this computer, but HQ could not remove the bot it still has under that name. ${next}`,
|
|
1136
|
+
};
|
|
1137
|
+
}
|
|
1138
|
+
/**
|
|
1139
|
+
* The account's own local bots, or a refusal shaped for both audiences: one
|
|
1140
|
+
* plain sentence for a person (via the thrown expected error, exit 1), and —
|
|
1141
|
+
* under --json — a document on stdout that always parses, carrying the reason
|
|
1142
|
+
* the desktop keys off plus whatever empty fields its success shape promises.
|
|
1143
|
+
*/
|
|
1144
|
+
async function listMyBotsOrExplain(api, opts) {
|
|
1145
|
+
try {
|
|
1146
|
+
return await api.listMine({ local: true });
|
|
1147
|
+
}
|
|
1148
|
+
catch (err) {
|
|
1149
|
+
const failure = remoteBotsUnavailable(err);
|
|
1150
|
+
if (opts.json)
|
|
1151
|
+
console.log(JSON.stringify({ ok: false, ...failure, ...(opts.emptyResult ?? {}) }, null, 2));
|
|
1152
|
+
throw Object.assign(new Error(failure.message), { expected: true });
|
|
1153
|
+
}
|
|
1154
|
+
}
|
|
1155
|
+
/**
|
|
1156
|
+
* Rebuild a bot.json from the settings the cloud kept for it. A bot created
|
|
1157
|
+
* before HQ saved settings comes back on defaults, and anything this HQ can no
|
|
1158
|
+
* longer provide (a worker that is not here, a thinking level the chosen
|
|
1159
|
+
* runtime does not accept) is reported rather than silently kept.
|
|
1160
|
+
*/
|
|
1161
|
+
export function configFromLocalConfig(input) {
|
|
1162
|
+
const { name, local } = input;
|
|
1163
|
+
const notes = [];
|
|
1164
|
+
if (!local) {
|
|
1165
|
+
notes.push(`${name} was created before HQ kept a bot's settings in the cloud, so it comes back on defaults (${describeBotLocalConfig(null)}). Change them with: hq bot set ${name} --model <id> --effort <level>`);
|
|
1166
|
+
}
|
|
1167
|
+
const runtime = input.overrides?.runtime ?? local?.runtime ?? BOT_RESTORE_DEFAULTS.runtime;
|
|
1168
|
+
const kind = local?.kind ?? BOT_RESTORE_DEFAULTS.kind;
|
|
1169
|
+
const memory = local?.memory ?? BOT_RESTORE_DEFAULTS.memory;
|
|
1170
|
+
const model = input.overrides?.model ?? local?.model;
|
|
1171
|
+
let effort = local?.effort;
|
|
1172
|
+
if (effort && !BOT_EFFORT_LEVELS[runtime].includes(effort)) {
|
|
1173
|
+
notes.push(`${runtime} has no "${effort}" thinking level, so ${name} comes back on the default (${DEFAULT_BOT_EFFORT}).`);
|
|
1174
|
+
effort = undefined;
|
|
1175
|
+
}
|
|
1176
|
+
let workerSource = "scaffold";
|
|
1177
|
+
let workerDir = botWorkerRelDir(name);
|
|
1178
|
+
let workerId;
|
|
1179
|
+
let companySlug = local?.companySlug;
|
|
1180
|
+
if (local?.workerSource === "worker" && local.workerId) {
|
|
1181
|
+
try {
|
|
1182
|
+
const worker = resolveBotWorker(input.hqRoot, local.workerId);
|
|
1183
|
+
workerSource = "worker";
|
|
1184
|
+
workerDir = worker.path;
|
|
1185
|
+
workerId = worker.id;
|
|
1186
|
+
companySlug = worker.company ?? undefined;
|
|
1187
|
+
}
|
|
1188
|
+
catch {
|
|
1189
|
+
notes.push(`the worker "${local.workerId}" is not in this HQ, so ${name} comes back with its own scaffolded worker folder instead.`);
|
|
1190
|
+
companySlug = undefined;
|
|
1191
|
+
}
|
|
1192
|
+
}
|
|
1193
|
+
const config = {
|
|
1194
|
+
v: 1,
|
|
1195
|
+
name,
|
|
1196
|
+
agentUid: input.agentUid,
|
|
1197
|
+
ownerUid: input.ownerUid,
|
|
1198
|
+
runtime,
|
|
1199
|
+
kind,
|
|
1200
|
+
...(kind === "company" ? { companies: local?.companies ?? [] } : {}),
|
|
1201
|
+
...(model ? { model } : {}),
|
|
1202
|
+
...(effort ? { effort } : {}),
|
|
1203
|
+
...(local?.autoApprove === false ? { autoApprove: false } : {}),
|
|
1204
|
+
hqRoot: input.hqRoot,
|
|
1205
|
+
workerDir,
|
|
1206
|
+
...(workerSource === "worker" && workerId ? { workerSource, workerId } : {}),
|
|
1207
|
+
...(companySlug ? { companySlug } : {}),
|
|
1208
|
+
...(memory === "local"
|
|
1209
|
+
? { memoryDir: botLocalMemoryDir(input.dir) }
|
|
1210
|
+
: workerSource === "worker"
|
|
1211
|
+
? { memoryDir: path.join(botWorkerRelDir(name), "memory") }
|
|
1212
|
+
: {}),
|
|
1213
|
+
...(local?.intro ? { intro: local.intro } : {}),
|
|
1214
|
+
...(local?.kickoff ? { kickoff: local.kickoff } : {}),
|
|
1215
|
+
createdAt: (input.now ?? (() => new Date()))().toISOString(),
|
|
1216
|
+
enabled: true,
|
|
1217
|
+
// A restore is not a first start: the bot's one-off kickoff task already
|
|
1218
|
+
// ran on the machine it was created on, and `runs once` has to keep
|
|
1219
|
+
// meaning once. `hq bot intro <name>` has it say hello again.
|
|
1220
|
+
introSentAt: (input.now ?? (() => new Date()))().toISOString(),
|
|
1221
|
+
};
|
|
1222
|
+
return { config, notes };
|
|
1223
|
+
}
|
|
1224
|
+
/**
|
|
1225
|
+
* The tail of the create path, run against a bot that already exists in the
|
|
1226
|
+
* cloud: fresh machine credentials, a rebuilt bot.json, its worker/memory
|
|
1227
|
+
* folders, and its launchd agent.
|
|
1228
|
+
*/
|
|
1229
|
+
async function adoptRemoteBot(input) {
|
|
1230
|
+
const { bot, name } = input;
|
|
1231
|
+
const dir = botDir(name);
|
|
1232
|
+
if (readBotConfig(dir)) {
|
|
1233
|
+
throw Object.assign(new Error(`Bot "${name}" is already set up on this computer (${dir}). Start it with: hq bot start ${name}`), {
|
|
1234
|
+
expected: true,
|
|
1235
|
+
});
|
|
1236
|
+
}
|
|
1237
|
+
// The last gate before anything is written or any secret is rotated: the
|
|
1238
|
+
// runtime's own rule, so nothing is ever adopted that could not then start.
|
|
1239
|
+
const runnable = remoteBotRunnability(bot, name);
|
|
1240
|
+
if (!runnable.runnable) {
|
|
1241
|
+
throw Object.assign(new Error(runnable.message), { expected: true });
|
|
1242
|
+
}
|
|
1243
|
+
let reissued;
|
|
1244
|
+
try {
|
|
1245
|
+
reissued = await input.api.reissueCredentials(bot.agentUid, { machineId: localMachineId(), reason: "reinstall" });
|
|
1246
|
+
}
|
|
1247
|
+
catch (err) {
|
|
1248
|
+
throw credentialError(err, name);
|
|
1249
|
+
}
|
|
1250
|
+
writeBotCreds(dir, {
|
|
1251
|
+
username: reissued.identity.cognitoUsername,
|
|
1252
|
+
secret: reissued.identity.secret,
|
|
1253
|
+
entityType: "agent",
|
|
1254
|
+
entityUid: bot.agentUid,
|
|
1255
|
+
clientId: DEFAULT_COGNITO.clientId,
|
|
1256
|
+
region: DEFAULT_COGNITO.region,
|
|
1257
|
+
});
|
|
1258
|
+
const local = parseBotLocalConfig(reissued.localConfig ?? bot.localConfig);
|
|
1259
|
+
const { config, notes } = configFromLocalConfig({
|
|
1260
|
+
name,
|
|
1261
|
+
agentUid: bot.agentUid,
|
|
1262
|
+
ownerUid: reissued.agent.ownerUid ?? bot.ownerUid ?? "",
|
|
1263
|
+
hqRoot: input.hqRoot,
|
|
1264
|
+
dir,
|
|
1265
|
+
local,
|
|
1266
|
+
...(input.overrides ? { overrides: input.overrides } : {}),
|
|
1267
|
+
});
|
|
1268
|
+
writeBotConfig(dir, config);
|
|
1269
|
+
const scaffoldOpts = { memoryDir: config.memoryDir };
|
|
1270
|
+
const scaffold = config.workerSource === "worker"
|
|
1271
|
+
? scaffoldBotMemory(input.hqRoot, name, undefined, scaffoldOpts)
|
|
1272
|
+
: scaffoldBotWorker(input.hqRoot, name, bot.agentUid, config.runtime, undefined, scaffoldOpts);
|
|
1273
|
+
createBotLogger(dir)("info", `adopted ${name} (${bot.agentUid}) on ${localMachineId()} kind=${config.kind}${local ? "" : " (no saved settings; defaults)"} runtime=${config.runtime}`);
|
|
1274
|
+
let daemonMessage = null;
|
|
1275
|
+
if (input.daemon && process.platform === "darwin") {
|
|
1276
|
+
daemonMessage = installBotDaemon(buildBotDaemonPaths({ name, botDir: dir, hqRoot: input.hqRoot }), daemonDeps()).message;
|
|
1277
|
+
}
|
|
1278
|
+
return {
|
|
1279
|
+
config,
|
|
1280
|
+
dir,
|
|
1281
|
+
result: {
|
|
1282
|
+
name,
|
|
1283
|
+
agentUid: bot.agentUid,
|
|
1284
|
+
runtime: config.runtime,
|
|
1285
|
+
kind: effectiveBotKind(config),
|
|
1286
|
+
companies: effectiveBotCompanies(config),
|
|
1287
|
+
dir,
|
|
1288
|
+
worker: scaffold.dir,
|
|
1289
|
+
memory: botMemoryMode(config),
|
|
1290
|
+
memoryDir: scaffold.memoryDir,
|
|
1291
|
+
defaulted: local === null,
|
|
1292
|
+
notes,
|
|
1293
|
+
daemon: daemonMessage,
|
|
1294
|
+
},
|
|
1295
|
+
};
|
|
1296
|
+
}
|
|
1297
|
+
export async function runBotAdopt(nameArg, opts) {
|
|
1298
|
+
const asked = validateBotName(nameArg);
|
|
1299
|
+
if (readBotConfig(botDir(asked))) {
|
|
1300
|
+
throw Object.assign(new Error(`Bot "${asked}" is already set up on this computer (${botDir(asked)}). Start it with: hq bot start ${asked}`), {
|
|
1301
|
+
expected: true,
|
|
1302
|
+
});
|
|
1303
|
+
}
|
|
1304
|
+
if (opts.runtime !== undefined && !isBotRuntimeId(opts.runtime)) {
|
|
1305
|
+
throw Object.assign(new Error(`--runtime must be one of ${BOT_RUNTIMES.join(", ")}`), { expected: true });
|
|
1306
|
+
}
|
|
1307
|
+
const model = opts.model !== undefined ? validateBotModel(opts.model) : undefined;
|
|
1308
|
+
const hqRoot = resolveDefaultHqRoot({ onMissing: "throw" });
|
|
1309
|
+
const token = await ownerToken();
|
|
1310
|
+
const api = new BotApi({ token: async () => token });
|
|
1311
|
+
const bot = resolveRemoteBot(await listMyBotsOrExplain(api, { json: opts.json }), asked);
|
|
1312
|
+
// One bot, one directory: a bot created here as `qa-x` and slugged
|
|
1313
|
+
// `qa-x-<owner>` in the cloud comes back to ~/.hq/bots/qa-x/, not beside it.
|
|
1314
|
+
const name = localNameForRemoteBot(bot) ?? asked;
|
|
1315
|
+
const dir = botDir(name);
|
|
1316
|
+
const runnable = remoteBotRunnability(bot, name);
|
|
1317
|
+
if (!runnable.runnable) {
|
|
1318
|
+
if (opts.json) {
|
|
1319
|
+
console.log(JSON.stringify({ ok: false, reason: "not-runnable-here", notRunnable: runnable.reason, name, agentUid: bot.agentUid, message: runnable.message }, null, 2));
|
|
1320
|
+
}
|
|
1321
|
+
throw Object.assign(new Error(runnable.message), { expected: true });
|
|
1322
|
+
}
|
|
1323
|
+
if (readBotConfig(dir)) {
|
|
1324
|
+
throw Object.assign(new Error(`Bot "${name}" is already set up on this computer (${dir}). Start it with: hq bot start ${name}`), {
|
|
1325
|
+
expected: true,
|
|
1326
|
+
});
|
|
1327
|
+
}
|
|
1328
|
+
const { result, config } = await adoptRemoteBot({
|
|
1329
|
+
bot,
|
|
1330
|
+
name,
|
|
1331
|
+
api,
|
|
1332
|
+
hqRoot,
|
|
1333
|
+
overrides: {
|
|
1334
|
+
...(isBotRuntimeId(opts.runtime) ? { runtime: opts.runtime } : {}),
|
|
1335
|
+
...(model ? { model } : {}),
|
|
1336
|
+
},
|
|
1337
|
+
daemon: opts.daemon !== false,
|
|
1338
|
+
});
|
|
1339
|
+
if (!opts.json) {
|
|
1340
|
+
console.log(chalk.green(`Brought back bot ${chalk.bold(name)}`) + chalk.dim(` (${result.agentUid}, ${result.runtime})`));
|
|
1341
|
+
console.log(chalk.dim(` kind: ${describeKind(result.kind, result.companies)}`));
|
|
1342
|
+
console.log(chalk.dim(` worker: ${result.worker}`));
|
|
1343
|
+
console.log(chalk.dim(` memory: ${result.memoryDir} (${result.memory})`));
|
|
1344
|
+
console.log(chalk.dim(` state: ${result.dir}`));
|
|
1345
|
+
if (result.daemon)
|
|
1346
|
+
console.log(chalk.dim(` ${result.daemon}`));
|
|
1347
|
+
for (const note of result.notes)
|
|
1348
|
+
console.log(chalk.yellow(` ${note}`));
|
|
1349
|
+
console.log(chalk.dim(` Have it say hello again with: hq bot intro ${name}`));
|
|
1350
|
+
}
|
|
1351
|
+
if (opts.start !== false) {
|
|
1352
|
+
await startBot(name, result.dir, config, opts.json ? "json" : "text", { ...result });
|
|
1353
|
+
return;
|
|
1354
|
+
}
|
|
1355
|
+
if (opts.json)
|
|
1356
|
+
console.log(JSON.stringify({ ok: true, ...result, running: false }, null, 2));
|
|
1357
|
+
}
|
|
1358
|
+
/** New credentials + a reinstalled startup agent for a bot that IS already here. */
|
|
1359
|
+
async function repairLocalBot(name, api, bot, daemon) {
|
|
1360
|
+
const dir = botDir(name);
|
|
1361
|
+
const config = readBotConfig(dir);
|
|
1362
|
+
let reissued;
|
|
1363
|
+
try {
|
|
1364
|
+
reissued = await api.reissueCredentials(bot.agentUid, { machineId: localMachineId(), reason: "reinstall" });
|
|
1365
|
+
}
|
|
1366
|
+
catch (err) {
|
|
1367
|
+
throw credentialError(err, name);
|
|
1368
|
+
}
|
|
1369
|
+
writeBotCreds(dir, {
|
|
1370
|
+
username: reissued.identity.cognitoUsername,
|
|
1371
|
+
secret: reissued.identity.secret,
|
|
1372
|
+
entityType: "agent",
|
|
1373
|
+
entityUid: bot.agentUid,
|
|
1374
|
+
clientId: DEFAULT_COGNITO.clientId,
|
|
1375
|
+
region: DEFAULT_COGNITO.region,
|
|
1376
|
+
});
|
|
1377
|
+
createBotLogger(dir)("info", `re-issued machine credentials for ${name} (${bot.agentUid}) on ${localMachineId()}`);
|
|
1378
|
+
if (daemon && process.platform === "darwin") {
|
|
1379
|
+
installBotDaemon(buildBotDaemonPaths({ name, botDir: dir, hqRoot: config.hqRoot }), daemonDeps());
|
|
1380
|
+
}
|
|
1381
|
+
return "new credentials, startup agent reinstalled";
|
|
1382
|
+
}
|
|
1383
|
+
/**
|
|
1384
|
+
* `hq bot restore` — every local bot this account owns that is not set up on
|
|
1385
|
+
* this computer comes back. This is what the desktop app and a post-install
|
|
1386
|
+
* hook call after a reinstall or on a new Mac.
|
|
1387
|
+
*/
|
|
1388
|
+
export async function runBotRestore(opts) {
|
|
1389
|
+
const hqRoot = resolveDefaultHqRoot({ onMissing: "throw" });
|
|
1390
|
+
const token = await ownerToken();
|
|
1391
|
+
const api = new BotApi({ token: async () => token });
|
|
1392
|
+
const bots = (await listMyBotsOrExplain(api, {
|
|
1393
|
+
json: opts.json,
|
|
1394
|
+
emptyResult: { dryRun: opts.dryRun === true, restored: 0, repaired: 0, skipped: 0, failed: 0, bots: [] },
|
|
1395
|
+
}));
|
|
1396
|
+
const rows = [];
|
|
1397
|
+
for (const bot of bots) {
|
|
1398
|
+
const name = localNameForRemoteBot(bot);
|
|
1399
|
+
if (!name) {
|
|
1400
|
+
rows.push({ name: bot.name ?? bot.agentUid, agentUid: bot.agentUid, action: "failed", detail: "its name cannot be a folder name here; rename it in HQ" });
|
|
1401
|
+
continue;
|
|
1402
|
+
}
|
|
1403
|
+
// A bot this computer cannot run is never brought back: it would get
|
|
1404
|
+
// credentials, a folder and a startup agent, and then fail at every start.
|
|
1405
|
+
// It is counted as skipped and says why, rather than being silently absent.
|
|
1406
|
+
const runnable = remoteBotRunnability(bot, name);
|
|
1407
|
+
if (!runnable.runnable) {
|
|
1408
|
+
rows.push({
|
|
1409
|
+
name,
|
|
1410
|
+
agentUid: bot.agentUid,
|
|
1411
|
+
action: opts.dryRun ? "would-skip" : "skipped",
|
|
1412
|
+
reason: runnable.reason,
|
|
1413
|
+
detail: runnable.message,
|
|
1414
|
+
});
|
|
1415
|
+
continue;
|
|
1416
|
+
}
|
|
1417
|
+
const here = readBotConfig(botDir(name)) !== null;
|
|
1418
|
+
const settings = describeBotLocalConfig(parseBotLocalConfig(bot.localConfig));
|
|
1419
|
+
if (here && hasPromotionHold(botDir(name))) {
|
|
1420
|
+
rows.push({ name, agentUid: bot.agentUid, action: opts.dryRun ? "would-skip" : "skipped", detail: "held for cloud promotion" });
|
|
1421
|
+
continue;
|
|
1422
|
+
}
|
|
1423
|
+
if (here && !opts.all) {
|
|
1424
|
+
rows.push({ name, agentUid: bot.agentUid, action: opts.dryRun ? "would-skip" : "skipped", detail: "already set up here" });
|
|
1425
|
+
continue;
|
|
1426
|
+
}
|
|
1427
|
+
if (opts.dryRun) {
|
|
1428
|
+
rows.push({ name, agentUid: bot.agentUid, action: here ? "would-repair" : "would-restore", detail: here ? "new credentials" : settings });
|
|
1429
|
+
continue;
|
|
1430
|
+
}
|
|
1431
|
+
try {
|
|
1432
|
+
if (here) {
|
|
1433
|
+
rows.push({ name, agentUid: bot.agentUid, action: "repaired", detail: await repairLocalBot(name, api, bot, opts.daemon !== false) });
|
|
1434
|
+
}
|
|
1435
|
+
else {
|
|
1436
|
+
const { result, config } = await adoptRemoteBot({ bot, name, api, hqRoot, daemon: opts.daemon !== false });
|
|
1437
|
+
// Silent: this command's own JSON has to be the only document on stdout.
|
|
1438
|
+
const started = opts.start === false ? null : await startBot(name, result.dir, config, "silent").catch(() => null);
|
|
1439
|
+
rows.push({
|
|
1440
|
+
name,
|
|
1441
|
+
agentUid: bot.agentUid,
|
|
1442
|
+
action: "restored",
|
|
1443
|
+
detail: [settings, ...result.notes, ...(started ? [started.running ? "started" : "not running yet — hq bot logs " + name] : [])].join("; "),
|
|
1444
|
+
});
|
|
1445
|
+
}
|
|
1446
|
+
}
|
|
1447
|
+
catch (err) {
|
|
1448
|
+
rows.push({ name, agentUid: bot.agentUid, action: "failed", detail: err instanceof Error ? err.message : String(err) });
|
|
1449
|
+
}
|
|
1450
|
+
}
|
|
1451
|
+
const counted = (action) => rows.filter((r) => r.action === action).length;
|
|
1452
|
+
if (opts.json) {
|
|
1453
|
+
console.log(JSON.stringify({
|
|
1454
|
+
ok: counted("failed") === 0,
|
|
1455
|
+
dryRun: opts.dryRun === true,
|
|
1456
|
+
restored: counted("restored") + counted("would-restore"),
|
|
1457
|
+
repaired: counted("repaired") + counted("would-repair"),
|
|
1458
|
+
skipped: counted("skipped") + counted("would-skip"),
|
|
1459
|
+
failed: counted("failed"),
|
|
1460
|
+
bots: rows,
|
|
1461
|
+
}, null, 2));
|
|
1462
|
+
return;
|
|
1463
|
+
}
|
|
1464
|
+
if (rows.length === 0) {
|
|
1465
|
+
console.log(chalk.gray("You have no local bots in HQ. Create one with: hq bot create assistant"));
|
|
1466
|
+
return;
|
|
1467
|
+
}
|
|
1468
|
+
for (const row of rows) {
|
|
1469
|
+
const colour = row.action === "failed" ? chalk.red : row.action.startsWith("would") || row.action === "skipped" ? chalk.gray : chalk.green;
|
|
1470
|
+
console.log(`${chalk.bold(row.name.padEnd(16))} ${colour(row.action.padEnd(14))} ${chalk.dim(row.detail)}`);
|
|
1471
|
+
}
|
|
1472
|
+
const summary = `${counted("restored") + counted("would-restore")} to bring back, ${counted("repaired") + counted("would-repair")} to repair, ${counted("skipped") + counted("would-skip")} already here, ${counted("failed")} failed`;
|
|
1473
|
+
console.log(chalk.dim(opts.dryRun ? `\nDry run — nothing changed. ${summary}.` : `\n${summary}.`));
|
|
1474
|
+
if (counted("failed") > 0) {
|
|
1475
|
+
throw Object.assign(new Error(`Could not bring back ${counted("failed")} of your bots (see the list above).`), { expected: true });
|
|
1476
|
+
}
|
|
1477
|
+
}
|
|
1478
|
+
/** The one clause the table shows instead of an adopt command it must not offer. */
|
|
1479
|
+
function notRunnableHint(reason) {
|
|
1480
|
+
if (reason === "cloud-compute")
|
|
1481
|
+
return "runs in the cloud, not on a computer";
|
|
1482
|
+
if (reason === "company-bot")
|
|
1483
|
+
return "a company bot: runs in HQ Cloud, not on this Mac";
|
|
1484
|
+
return "not a bot this computer can run";
|
|
1485
|
+
}
|
|
1486
|
+
/** `hq bot list --remote` — the local bots this account owns, wherever they live. */
|
|
1487
|
+
async function runBotListRemote(opts) {
|
|
1488
|
+
const token = await ownerToken();
|
|
1489
|
+
const api = new BotApi({ token: async () => token });
|
|
1490
|
+
const rows = (await listMyBotsOrExplain(api, { json: opts.json, emptyResult: { bots: [] } })).map((bot) => {
|
|
1491
|
+
const name = localNameForRemoteBot(bot);
|
|
1492
|
+
const runnable = remoteBotRunnability(bot);
|
|
1493
|
+
return {
|
|
1494
|
+
name: name ?? bot.name ?? bot.agentUid,
|
|
1495
|
+
agentUid: bot.agentUid,
|
|
1496
|
+
kind: bot.botKind ?? "personal",
|
|
1497
|
+
online: bot.online === true,
|
|
1498
|
+
lastHeartbeatAt: bot.lastHeartbeatAt ?? null,
|
|
1499
|
+
here: name !== null && readBotConfig(botDir(name)) !== null,
|
|
1500
|
+
settings: describeBotLocalConfig(parseBotLocalConfig(bot.localConfig)),
|
|
1501
|
+
runnable: runnable.runnable,
|
|
1502
|
+
reason: runnable.reason,
|
|
1503
|
+
};
|
|
1504
|
+
});
|
|
1505
|
+
if (opts.json) {
|
|
1506
|
+
// `ok` on success too: the failure document carries `ok: false`, and a
|
|
1507
|
+
// consumer that learned to branch on it read a good listing as a failure.
|
|
1508
|
+
console.log(JSON.stringify({ ok: true, bots: rows }, null, 2));
|
|
1509
|
+
return;
|
|
1510
|
+
}
|
|
1511
|
+
if (rows.length === 0) {
|
|
1512
|
+
console.log(chalk.gray("You have no local bots in HQ. Create one with: hq bot create assistant"));
|
|
1513
|
+
return;
|
|
1514
|
+
}
|
|
1515
|
+
console.log(chalk.dim(`${"NAME".padEnd(16)} ${"AGENT".padEnd(26)} ${"KIND".padEnd(9)} ${"ONLINE".padEnd(8)} HERE`));
|
|
1516
|
+
for (const row of rows) {
|
|
1517
|
+
console.log(`${chalk.bold(row.name.padEnd(16))} ${row.agentUid.padEnd(26)} ${row.kind.padEnd(9)} ${row.online ? chalk.green("online".padEnd(8)) : chalk.gray("offline".padEnd(8))} ${row.here ? chalk.green("yes") : chalk.yellow("no")}${row.here ? "" : row.runnable ? chalk.dim(` — hq bot adopt ${row.name}`) : chalk.dim(` — ${notRunnableHint(row.reason)}`)}`);
|
|
1518
|
+
}
|
|
1519
|
+
if (rows.some((r) => !r.here && r.runnable))
|
|
1520
|
+
console.log(chalk.dim(`\nBring them all back with: hq bot restore`));
|
|
1521
|
+
}
|
|
807
1522
|
// ── registration ─────────────────────────────────────────────────────────────
|
|
808
1523
|
export function registerBotCommand(program) {
|
|
809
1524
|
const bot = program
|
|
@@ -861,15 +1576,34 @@ export function registerBotCommand(program) {
|
|
|
861
1576
|
bot
|
|
862
1577
|
.command("list")
|
|
863
1578
|
.description("List local bots with runtime, online state, and pid")
|
|
1579
|
+
.option("--remote", "List the local bots your HQ account owns instead, with a here column saying which are set up on this computer")
|
|
864
1580
|
.option("--json", "Print machine-readable JSON")
|
|
865
1581
|
.action((opts) => wrap(() => runBotList(opts))());
|
|
1582
|
+
bot
|
|
1583
|
+
.command("adopt <name>")
|
|
1584
|
+
.description("Bring a bot you already own back to this computer: new machine credentials, its saved settings, worker folder, and startup agent")
|
|
1585
|
+
.option("--runtime <runtime>", `Model CLI to run it with, overriding its saved one: ${BOT_RUNTIMES.join("|")}`)
|
|
1586
|
+
.option("--model <model>", "Model override passed to the runtime CLI, replacing its saved one")
|
|
1587
|
+
.option("--no-daemon", "Do not install the launchd agent (macOS)")
|
|
1588
|
+
.option("--no-start", "Bring it back without starting it")
|
|
1589
|
+
.option("--json", "Print machine-readable JSON")
|
|
1590
|
+
.action((name, opts) => wrap(() => runBotAdopt(name, opts))());
|
|
1591
|
+
bot
|
|
1592
|
+
.command("restore")
|
|
1593
|
+
.description("Bring back every local bot your HQ account owns that is not set up on this computer (after a reinstall, or on a new Mac)")
|
|
1594
|
+
.option("--all", "Also re-issue credentials and reinstall the startup agent for the bots already set up here")
|
|
1595
|
+
.option("--dry-run", "Show what would be brought back, change nothing")
|
|
1596
|
+
.option("--no-daemon", "Do not install the launchd agents (macOS)")
|
|
1597
|
+
.option("--no-start", "Bring them back without starting them")
|
|
1598
|
+
.option("--json", "Print machine-readable JSON")
|
|
1599
|
+
.action((opts) => wrap(() => runBotRestore(opts))());
|
|
866
1600
|
bot
|
|
867
1601
|
.command("start <name>")
|
|
868
1602
|
.description("Start the bot (via launchd when installed, else a detached process)")
|
|
869
1603
|
.option("--json", "Print machine-readable JSON")
|
|
870
1604
|
.action((name, opts) => wrap(async () => {
|
|
871
1605
|
const b = requireBot(name);
|
|
872
|
-
await startBot(b.name, b.dir, b.config,
|
|
1606
|
+
await startBot(b.name, b.dir, b.config, opts.json ? "json" : "text");
|
|
873
1607
|
})());
|
|
874
1608
|
bot
|
|
875
1609
|
.command("stop <name>")
|
|
@@ -886,7 +1620,7 @@ export function registerBotCommand(program) {
|
|
|
886
1620
|
.action((name, opts) => wrap(async () => {
|
|
887
1621
|
const b = requireBot(name);
|
|
888
1622
|
await stopBot(b.name, b.dir, b.config, "silent");
|
|
889
|
-
await startBot(b.name, b.dir, b.config,
|
|
1623
|
+
await startBot(b.name, b.dir, b.config, opts.json ? "json" : "text");
|
|
890
1624
|
})());
|
|
891
1625
|
bot
|
|
892
1626
|
.command("status <name>")
|
|
@@ -907,6 +1641,7 @@ export function registerBotCommand(program) {
|
|
|
907
1641
|
.command("rm <name>")
|
|
908
1642
|
.description("Stop the bot, remove its launchd agent, delete its cloud identity and local state")
|
|
909
1643
|
.option("-y, --yes", "Skip the confirmation prompt")
|
|
1644
|
+
.option("--remote", "When the bot's local settings are already gone, also delete the bot HQ still has under that name")
|
|
910
1645
|
.option("--json", "Print machine-readable JSON")
|
|
911
1646
|
.action((name, opts) => wrap(() => runBotRm(name, opts))());
|
|
912
1647
|
bot
|