@indigoai-us/hq-cli 5.116.0 → 5.117.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/CHANGELOG.md +125 -0
  2. package/dist/command-catalog.generated.d.ts +59 -1
  3. package/dist/command-catalog.generated.js +77 -1
  4. package/dist/commands/agent-kit.d.ts +23 -3
  5. package/dist/commands/agent-kit.js +110 -13
  6. package/dist/commands/agent-probe.d.ts +15 -7
  7. package/dist/commands/agent-probe.js +59 -21
  8. package/dist/commands/bot.d.ts +140 -1
  9. package/dist/commands/bot.js +757 -22
  10. package/dist/commands/dm.d.ts +10 -0
  11. package/dist/commands/dm.js +80 -0
  12. package/dist/lib/agent-kit/fallback.d.ts +63 -0
  13. package/dist/lib/agent-kit/fallback.js +129 -0
  14. package/dist/lib/agent-kit/run/inbox.d.ts +18 -6
  15. package/dist/lib/agent-kit/run/inbox.js +38 -6
  16. package/dist/lib/agent-kit/run/mesh-listener.d.ts +22 -6
  17. package/dist/lib/agent-kit/run/mesh-listener.js +44 -8
  18. package/dist/lib/agent-kit/run/supervisor.d.ts +35 -0
  19. package/dist/lib/agent-kit/run/supervisor.js +85 -0
  20. package/dist/lib/bot/api.d.ts +51 -0
  21. package/dist/lib/bot/api.js +32 -0
  22. package/dist/lib/bot/daemon.d.ts +17 -0
  23. package/dist/lib/bot/daemon.js +44 -3
  24. package/dist/lib/bot/index.d.ts +4 -0
  25. package/dist/lib/bot/index.js +4 -0
  26. package/dist/lib/bot/inflight.d.ts +14 -0
  27. package/dist/lib/bot/local-config.d.ts +70 -0
  28. package/dist/lib/bot/local-config.js +147 -0
  29. package/dist/lib/bot/local-name.d.ts +54 -0
  30. package/dist/lib/bot/local-name.js +114 -0
  31. package/dist/lib/bot/run.d.ts +9 -0
  32. package/dist/lib/bot/run.js +117 -24
  33. package/dist/lib/bot/runnable.d.ts +51 -0
  34. package/dist/lib/bot/runnable.js +65 -0
  35. package/dist/lib/bot/self-heal.d.ts +52 -0
  36. package/dist/lib/bot/self-heal.js +79 -0
  37. package/dist/lib/bot/split.d.ts +32 -0
  38. package/dist/lib/bot/split.js +241 -0
  39. package/dist/lib/mesh/live/daemon/credentials.d.ts +27 -0
  40. package/dist/lib/mesh/live/daemon/credentials.js +95 -0
  41. package/package.json +1 -1
@@ -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
- throw Object.assign(new Error(`No bot named "${valid}". Create one with: hq bot create ${valid}`), {
85
- expected: true,
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
- async function startBot(name, dir, config, json, extra = {}) {
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 (json) {
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, Boolean(opts.json), { ...created_, daemon: daemonMessage });
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 { name, dir, config } = requireBot(nameArg);
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
- if (terminal)
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({ ok: terminal, name, agentUid: config.agentUid, daemon: daemon.message, identity, companiesLeft: left.done, companiesNotLeft: left.failed, localStateRemoved: terminal, workerKept: path.join(config.hqRoot, config.workerDir) }, null, 2));
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
- else {
746
- console.log(chalk.yellow(`Bot ${name} is stopped locally but its cloud identity is not fully removed yet.`));
747
- if ("failures" in identity && identity.failures) {
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, Boolean(opts.json));
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, Boolean(opts.json));
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