@indigoai-us/hq-cli 5.345.85 → 5.345.87

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 (37) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/dist/command-catalog.generated.d.ts +42 -1
  3. package/dist/command-catalog.generated.js +51 -1
  4. package/dist/command-registration-plan.d.ts +6 -0
  5. package/dist/command-registration-plan.js +1 -0
  6. package/dist/commands/bot.d.ts +60 -12
  7. package/dist/commands/bot.js +349 -98
  8. package/dist/commands/scan.d.ts +23 -0
  9. package/dist/commands/scan.js +59 -0
  10. package/dist/commands/storage-core.d.ts +2 -0
  11. package/dist/commands/storage-core.js +12 -1
  12. package/dist/commands/storage-offload.d.ts +10 -0
  13. package/dist/commands/storage-offload.js +4 -0
  14. package/dist/commands/storage.d.ts +6 -0
  15. package/dist/commands/storage.js +25 -6
  16. package/dist/lib/bot/api.d.ts +15 -0
  17. package/dist/lib/bot/api.js +11 -0
  18. package/dist/lib/bot/config.d.ts +6 -0
  19. package/dist/lib/bot/display-name.d.ts +64 -0
  20. package/dist/lib/bot/display-name.js +117 -0
  21. package/dist/lib/bot/index.d.ts +2 -0
  22. package/dist/lib/bot/index.js +2 -0
  23. package/dist/lib/bot/lock.d.ts +84 -0
  24. package/dist/lib/bot/lock.js +313 -0
  25. package/dist/lib/secret-scan/detect.d.ts +27 -0
  26. package/dist/lib/secret-scan/detect.js +129 -0
  27. package/dist/lib/secret-scan/gitleaks-rules.generated.d.ts +6 -0
  28. package/dist/lib/secret-scan/gitleaks-rules.generated.js +3602 -0
  29. package/dist/lib/secret-scan/go-regex.d.ts +22 -0
  30. package/dist/lib/secret-scan/go-regex.js +250 -0
  31. package/dist/lib/secret-scan/rules.d.ts +31 -0
  32. package/dist/lib/secret-scan/rules.js +84 -0
  33. package/dist/lib/secret-scan/staged.d.ts +17 -0
  34. package/dist/lib/secret-scan/staged.js +112 -0
  35. package/dist/lib/secret-scan/types.d.ts +34 -0
  36. package/dist/lib/secret-scan/types.js +3 -0
  37. package/package.json +1 -1
@@ -51,7 +51,7 @@ import { isAncestorProcess } from "../lib/bot/self-process.js";
51
51
  import { runtimeDisplayName } from "../lib/bot/runtime-sign-in.js";
52
52
  import { classifyRuntimeFailure, runtimeFailureReason } from "../lib/bot/runtime-failure.js";
53
53
  import { PROBE_DEFAULT_TIMEOUT_MS, probeBotRuntime } from "../lib/bot/probe.js";
54
- import { BotApi, BotApiError, BOT_RESTORE_DEFAULTS, buildBotLocalConfig, describeBotLocalConfig, orphanBotRunMessage, parseBotLocalConfig, removeOrphanBotDaemon, botDaemonStatus, botDir, botCredsPath, botLogPath, botsRoot, botTokenStateDir, botWorkerRelDir, buildBotDaemonPaths, createBotLogger, deleteBotCreds, installBotDaemon, repairBotDaemonIfStale, healBotDaemonNodeFromJob, introDmText, isBotMemoryMode, botLocalMemoryDir, botMemoryMode, resolveBotMemoryDir, validateBotIntro, validateBotDisplayName, 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_DISPLAY_NAME_MAX_CHARS, BOT_KICKOFF_MAX_CHARS, detectBotPlatform, } from "../lib/bot/index.js";
54
+ import { BotApi, BotApiError, BOT_RESTORE_DEFAULTS, buildBotLocalConfig, describeBotLocalConfig, orphanBotRunMessage, parseBotLocalConfig, removeOrphanBotDaemon, botDaemonStatus, botDir, botCredsPath, botLogPath, botsRoot, botTokenStateDir, botWorkerRelDir, buildBotDaemonPaths, createBotLogger, deleteBotCreds, installBotDaemon, repairBotDaemonIfStale, healBotDaemonNodeFromJob, introDmText, isBotMemoryMode, botLocalMemoryDir, botMemoryMode, resolveBotMemoryDir, validateBotIntro, validateBotDisplayName, 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_DISPLAY_NAME_MAX_CHARS, BOT_KICKOFF_MAX_CHARS, detectBotPlatform, withBotLock, localBotRole, remoteBotRole, remoteBotDisplayName, personFacingName, writeCloudDisplayName, setupSlugFor, ASSISTANT_ROLE, } from "../lib/bot/index.js";
55
55
  function collectRepeated(value, previous) {
56
56
  return [...previous, value];
57
57
  }
@@ -191,6 +191,7 @@ function rowFor(name, opts = {}) {
191
191
  memory: botMemoryMode(config),
192
192
  memoryDir: resolveBotMemoryDir(config),
193
193
  ...(config.displayName ? { displayName: config.displayName } : {}),
194
+ role: localBotRole(config),
194
195
  ...(config.intro ? { intro: config.intro } : {}),
195
196
  ...(config.kickoff ? { kickoff: config.kickoff } : {}),
196
197
  };
@@ -211,6 +212,13 @@ async function decorateOnline(rows) {
211
212
  const rec = await api.getAgent(row.agentUid);
212
213
  row.online = rec.online === true;
213
214
  row.lastHeartbeatAt = rec.runtime?.lastHeartbeat?.at ?? row.lastHeartbeatAt;
215
+ // The cloud holds the name people see; bot.json is this computer's copy.
216
+ const cloudName = personFacingName(rec.name, [row.name, `${row.name}-${row.ownerUid.slice(-8).toLowerCase()}`, row.agentUid]);
217
+ // A rename HQ could not take yet is newer than HQ's copy.
218
+ if (cloudName && !(row.displayName && readBotConfig(row.dir)?.displayNameLocalOnly))
219
+ row.displayName = cloudName;
220
+ if (rec.agentRole === ASSISTANT_ROLE)
221
+ row.role = ASSISTANT_ROLE;
214
222
  }
215
223
  catch {
216
224
  row.online = null;
@@ -317,7 +325,7 @@ async function startBot(name, dir, config, output, extra = {}) {
317
325
  if (output === "silent")
318
326
  return { running, how };
319
327
  if (output === "json") {
320
- console.log(JSON.stringify({ ok: running, name, ...extra, how, state: status?.state ?? "unknown", pid: status?.pid ?? null, lastError: status?.lastError ?? null }, null, 2));
328
+ console.log(JSON.stringify({ ok: running, name, ...extra, running, how, state: status?.state ?? "unknown", pid: status?.pid ?? null, lastError: status?.lastError ?? null }, null, 2));
321
329
  return { running, how };
322
330
  }
323
331
  if (running)
@@ -475,8 +483,124 @@ export function resolveCreateKind(opts, worker) {
475
483
  }
476
484
  return { kind, companies };
477
485
  }
486
+ /**
487
+ * `hq bot create`. Runs under the bot's lock, so a create racing an adopt,
488
+ * restore, rm or set of the same bot waits for it instead of tripping over
489
+ * its half-written folder.
490
+ */
478
491
  export async function runBotCreate(nameArg, opts) {
479
492
  const name = validateBotName(nameArg);
493
+ await withBotLock(name, () => runBotCreateLocked(name, opts), { command: "hq bot create" });
494
+ }
495
+ /**
496
+ * Write a bot's new name to the cloud. Never fails the caller's command when
497
+ * `tolerant`: a server without the name route, or any other failure, leaves
498
+ * the name on this computer only and says so in the result and the bot's log.
499
+ */
500
+ async function saveCloudName(api, dir, agentUid, displayName, tolerant) {
501
+ const log = createBotLogger(dir);
502
+ let saved;
503
+ try {
504
+ const r = await writeCloudDisplayName(api, agentUid, displayName);
505
+ if (r.saved === "local-only")
506
+ log("info", `name "${displayName}" kept on this computer only: ${r.detail}`);
507
+ else
508
+ log("info", `name "${displayName}" saved in HQ`);
509
+ saved = r.saved;
510
+ }
511
+ catch (err) {
512
+ if (!tolerant)
513
+ throw err;
514
+ log("warn", `could not save the name "${displayName}" in HQ, kept it on this computer: ${err instanceof Error ? err.message : String(err)}`);
515
+ saved = "local-only";
516
+ }
517
+ // Remember a name HQ does not have yet, so list keeps showing it over HQ's older one.
518
+ if (readBotConfig(dir))
519
+ patchBotConfig(dir, { displayNameLocalOnly: saved === "local-only" ? true : undefined });
520
+ return saved;
521
+ }
522
+ /**
523
+ * Bring a running bot up on its current bot.json, so a changed name reaches
524
+ * its system prompt (built once at start). From inside one of the bot's own
525
+ * turns a stop would end that turn, so the restart is left for later and the
526
+ * result says so.
527
+ */
528
+ async function restartForNewSettings(name, dir, config, output, extra) {
529
+ const status = readBotStatus(dir);
530
+ const running = !!status && isPidAlive(status.pid) && status.state === "running";
531
+ if (running && selfStopProbe(status.pid)) {
532
+ const r = await startBot(name, dir, config, output, { ...extra, restarted: false, restartPending: true });
533
+ return { ...r, restarted: false, restartPending: true };
534
+ }
535
+ if (running) {
536
+ await stopBot(name, dir, config, "silent");
537
+ // A bot that did not go down is still on its old system prompt: starting
538
+ // now would find it "already running" and call that a restart.
539
+ const down = await waitFor(() => {
540
+ const s = readBotStatus(dir);
541
+ return !s || !isPidAlive(s.pid);
542
+ }, 3_000);
543
+ if (!down) {
544
+ const r = await startBot(name, dir, config, output, { ...extra, restarted: false, restartPending: true });
545
+ return { ...r, restarted: false, restartPending: true };
546
+ }
547
+ }
548
+ const r = await startBot(name, dir, config, output, { ...extra, restarted: running, restartPending: false });
549
+ return { ...r, restarted: running, restartPending: false };
550
+ }
551
+ /** `hq bot create setup` when the assistant is already set up on this computer: apply the name, make sure it runs. */
552
+ async function finishExistingSetupBot(name, dir, config, displayName, opts) {
553
+ let nameSaved = null;
554
+ let current = config;
555
+ let nameChanged = false;
556
+ if (displayName) {
557
+ // The assistant is already here, so nothing about the cloud may turn this
558
+ // into a failure: without a login the name stays on this computer.
559
+ const api = new BotApi({ token: () => ownerToken() });
560
+ nameSaved = await saveCloudName(api, dir, config.agentUid, displayName, true);
561
+ if (config.displayName !== displayName) {
562
+ patchBotConfig(dir, { displayName });
563
+ current = readBotConfig(dir) ?? { ...config, displayName };
564
+ nameChanged = true;
565
+ }
566
+ }
567
+ const extra = {
568
+ outcome: "existing",
569
+ agentUid: current.agentUid,
570
+ displayName: current.displayName ?? null,
571
+ role: localBotRole(current),
572
+ nameSaved,
573
+ };
574
+ if (!opts.json)
575
+ console.log(chalk.green(`${name} is already set up on this computer`) + chalk.dim(` (${current.agentUid})`));
576
+ let started;
577
+ if (opts.start === false) {
578
+ const status = readBotStatus(dir);
579
+ const running = !!status && isPidAlive(status.pid) && status.state === "running";
580
+ started = { running, how: null, restarted: false, restartPending: nameChanged && running };
581
+ }
582
+ else if (nameChanged) {
583
+ started = await restartForNewSettings(name, dir, current, "silent", {});
584
+ }
585
+ else {
586
+ started = { ...(await startBot(name, dir, current, "silent")), restarted: false, restartPending: false };
587
+ }
588
+ // The assistant is set up, so this succeeded whether or not it reported
589
+ // running within the wait; `running` says which. The desktop reads state
590
+ // from the bot list, never from a create's ok.
591
+ const status = readBotStatus(dir);
592
+ if (opts.json) {
593
+ console.log(JSON.stringify({ ok: true, name, ...extra, ...started, state: status?.state ?? "unknown", pid: status?.pid ?? null, lastError: status?.lastError ?? null }, null, 2));
594
+ return;
595
+ }
596
+ if (started.restartPending)
597
+ console.log(chalk.dim(` it uses the new name after its next restart (hq bot restart ${name})`));
598
+ else if (started.running)
599
+ console.log(chalk.green(`${name} is running`) + (started.how ? chalk.dim(` (${started.how})`) : ""));
600
+ else if (opts.start !== false)
601
+ console.log(chalk.yellow(`${name} did not report running within 15s`) + chalk.dim(` logs: hq bot logs ${name}`));
602
+ }
603
+ async function runBotCreateLocked(name, opts) {
480
604
  const runtime = opts.runtime ?? "claude";
481
605
  if (!isBotRuntimeId(runtime)) {
482
606
  throw Object.assign(new Error(`--runtime must be one of ${BOT_RUNTIMES.join(", ")}`), { expected: true });
@@ -491,11 +615,22 @@ export async function runBotCreate(nameArg, opts) {
491
615
  const model = opts.model !== undefined ? validateBotModel(opts.model) : undefined;
492
616
  const effort = opts.effort !== undefined ? validateBotEffort(runtime, opts.effort) : undefined;
493
617
  const dir = botDir(name);
494
- if (readBotConfig(dir)) {
618
+ const existingBeforeRoot = readBotConfig(dir);
619
+ // Only HQ's setup assistant is idempotent; any other bot keeps failing fast.
620
+ const maybeSetup = name === RESERVED_SETUP_BOT_NAME &&
621
+ opts.worker?.trim() === SETUP_BOT_WORKER_ID &&
622
+ (opts.kind === undefined || opts.kind.trim().toLowerCase() === "personal");
623
+ if (existingBeforeRoot && !maybeSetup) {
495
624
  throw Object.assign(new Error(`Bot "${name}" already exists (${dir}). Use hq bot start ${name} or hq bot rm ${name}.`), {
496
625
  expected: true,
497
626
  });
498
627
  }
628
+ // The assistant is already here: nothing else (the HQ folder, the setup
629
+ // worker's files) has to resolve for this to succeed.
630
+ if (existingBeforeRoot && localBotRole(existingBeforeRoot) === ASSISTANT_ROLE) {
631
+ await finishExistingSetupBot(name, dir, existingBeforeRoot, displayName, opts);
632
+ return;
633
+ }
499
634
  const hqRoot = resolveDefaultHqRoot({ onMissing: "throw" });
500
635
  // Resolve the company worker before provisioning anything (a typo must not
501
636
  // cost an identity).
@@ -505,6 +640,16 @@ export async function runBotCreate(nameArg, opts) {
505
640
  if (name === RESERVED_SETUP_BOT_NAME && kind === "personal" && !isSetupBot) {
506
641
  throw Object.assign(new Error(`The name ${RESERVED_SETUP_BOT_NAME} is reserved for HQ's setup bot; create it with --worker ${RESERVED_SETUP_BOT_NAME}.`), { expected: true });
507
642
  }
643
+ const existing = readBotConfig(dir);
644
+ if (existing) {
645
+ if (isSetupBot && localBotRole(existing) === ASSISTANT_ROLE) {
646
+ await finishExistingSetupBot(name, dir, existing, displayName, opts);
647
+ return;
648
+ }
649
+ throw Object.assign(new Error(`Bot "${name}" already exists (${dir}). Use hq bot start ${name} or hq bot rm ${name}.`), {
650
+ expected: true,
651
+ });
652
+ }
508
653
  const token = await ownerToken();
509
654
  const owner = await resolveCallerPersonUid(token, undefined, { command: "hq bot create" });
510
655
  if (!owner)
@@ -527,7 +672,7 @@ export async function runBotCreate(nameArg, opts) {
527
672
  if (matches.length > 1) {
528
673
  throw Object.assign(new Error(`You own more than one bot called "${name}": ${matches.map((bot) => bot.agentUid).join(", ")}. Name the one you mean by its id.`), { expected: true });
529
674
  }
530
- const setupSlug = `${RESERVED_SETUP_BOT_NAME}-${owner.slice(-8).toLowerCase()}`;
675
+ const setupSlug = setupSlugFor(owner);
531
676
  const slugMatches = mine.filter((bot) => (bot.slug ?? "").trim().toLowerCase() === setupSlug);
532
677
  if (slugMatches.length > 1) {
533
678
  throw Object.assign(new Error(`HQ returned more than one agent with the setup slug "${setupSlug}". The CLI cannot safely choose one; ask your HQ admin to inspect the duplicate records.`), { expected: true });
@@ -543,11 +688,21 @@ export async function runBotCreate(nameArg, opts) {
543
688
  name,
544
689
  api,
545
690
  hqRoot,
546
- overrides: { ...(model ? { model } : {}) },
691
+ // The person's choices for this start win over yesterday's saved ones.
692
+ overrides: {
693
+ ...(model ? { model } : {}),
694
+ ...(displayName ? { displayName } : {}),
695
+ ...(intro ? { intro } : {}),
696
+ ...(kickoff ? { kickoff } : {}),
697
+ },
547
698
  daemon: opts.daemon !== false,
548
699
  preserveFirstStart: true,
549
700
  });
701
+ const nameSaved = displayName ? await saveCloudName(api, result.dir, result.agentUid, displayName, true) : null;
550
702
  const recovered = {
703
+ outcome: "adopted",
704
+ role: localBotRole(config),
705
+ nameSaved,
551
706
  agentUid: result.agentUid,
552
707
  kind: effectiveBotKind(config),
553
708
  companies: effectiveBotCompanies(config),
@@ -574,7 +729,8 @@ export async function runBotCreate(nameArg, opts) {
574
729
  console.log(chalk.dim(` ${result.daemon}`));
575
730
  }
576
731
  if (opts.start !== false) {
577
- await startBot(name, result.dir, config, opts.json ? "json" : "text", { ...recovered, daemon: result.daemon });
732
+ // The assistant is set up either way: ok, with `running` saying whether it came up in time.
733
+ await startBot(name, result.dir, config, opts.json ? "json" : "text", { ok: true, ...recovered, daemon: result.daemon });
578
734
  return true;
579
735
  }
580
736
  if (opts.json)
@@ -727,6 +883,10 @@ export async function runBotCreate(nameArg, opts) {
727
883
  : scaffoldBotWorker(hqRoot, name, agentUid, runtime, undefined, scaffoldOpts);
728
884
  createBotLogger(dir)("info", `created ${name} (${agentUid}) kind=${kind}${companies.length ? ` companies=${companies.join(",")}` : ""} runtime=${runtime}${worker ? ` worker=${worker.id} company=${worker.company ?? "-"}` : ""} memory=${memoryMode}`);
729
885
  const created_ = {
886
+ outcome: "created",
887
+ role: localBotRole(config),
888
+ // The create itself carried the name to HQ (entity name and saved settings).
889
+ nameSaved: (displayName ? "cloud" : null),
730
890
  agentUid,
731
891
  kind,
732
892
  companies,
@@ -773,7 +933,7 @@ export async function runBotCreate(nameArg, opts) {
773
933
  console.log(chalk.yellow(`${name} has not reported running yet; check hq bot logs ${name}`));
774
934
  return;
775
935
  }
776
- await startBot(name, dir, config, opts.json ? "json" : "text", { ...created_, daemon: daemonMessage });
936
+ await startBot(name, dir, config, opts.json ? "json" : "text", { ...(isSetupBot ? { ok: true } : {}), ...created_, daemon: daemonMessage });
777
937
  return;
778
938
  }
779
939
  if (opts.json) {
@@ -1160,6 +1320,12 @@ async function runBotRmOrphan(name, opts) {
1160
1320
  }
1161
1321
  async function runBotRm(nameArg, opts) {
1162
1322
  const validated = validateBotName(nameArg);
1323
+ // Under the bot's lock: an rm racing a create, adopt or restore of the same
1324
+ // bot runs before or after it, never through the middle of it.
1325
+ await withBotLock(validated, () => runBotRmLocked(validated, opts), { command: "hq bot rm" });
1326
+ }
1327
+ async function runBotRmLocked(validated, opts) {
1328
+ const nameArg = validated;
1163
1329
  if (readBotConfig(botDir(validated)) === null) {
1164
1330
  await runBotRmOrphan(validated, opts);
1165
1331
  return;
@@ -1245,49 +1411,116 @@ async function runBotRm(nameArg, opts) {
1245
1411
  console.log(chalk.dim(` HQ did not finish: ${incomplete.steps.join(", ")}`));
1246
1412
  throw Object.assign(new Error(message), { expected: true });
1247
1413
  }
1248
- /**
1249
- * `hq bot set <name> --model <id|default> --effort <level|default>`: change
1250
- * what the bot thinks with. bot.json is re-read on every turn, so the next
1251
- * message uses it; no restart.
1252
- */
1253
1414
  export async function runBotSet(nameArg, opts) {
1415
+ const name = validateBotName(nameArg);
1416
+ await withBotLock(name, () => runBotSetLocked(name, opts), { command: "hq bot set" });
1417
+ }
1418
+ /** A sentence for a refused name write: the status only, never the server's text. */
1419
+ function nameWriteRefused(name, err) {
1420
+ const status = err instanceof BotApiError ? err.status : null;
1421
+ const code = err instanceof BotApiError ? err.code : undefined;
1422
+ // Only a busy record, a server error or no answer can pass on a retry.
1423
+ const retryable = status === null || status === 409 || status >= 500;
1424
+ const why = code === "NOT_A_LOCAL_BOT"
1425
+ ? "it runs in HQ, not on this computer, so rename it in HQ"
1426
+ : status === 401
1427
+ ? "your HQ sign-in has expired; sign in again"
1428
+ : status === 404
1429
+ ? "HQ has no record of this bot"
1430
+ : status === 403
1431
+ ? "HQ says this bot is not yours to rename"
1432
+ : status === 400
1433
+ ? "HQ did not accept that name"
1434
+ : status === 409
1435
+ ? "its record in HQ kept changing"
1436
+ : status === null
1437
+ ? "HQ could not be reached"
1438
+ : `HQ answered ${status}`;
1439
+ return Object.assign(new Error(`Could not rename ${name}: ${why}. Nothing was changed${retryable ? "; try again in a moment" : ""}.`), {
1440
+ expected: true,
1441
+ });
1442
+ }
1443
+ async function runBotSetLocked(nameArg, opts) {
1254
1444
  const { name, dir, config } = requireBot(nameArg);
1255
1445
  if (hasPromotionHold(dir))
1256
1446
  throw new Error("This bot is held for cloud promotion. Continue its promotion before changing local settings or lifecycle.");
1257
- if (opts.model === undefined && opts.effort === undefined) {
1258
- throw Object.assign(new Error("Nothing to change: pass --model <id|default> and/or --effort <level|default>."), { expected: true });
1259
- }
1260
- const next = { ...config };
1261
- if (opts.model !== undefined) {
1262
- const model = validateBotModel(opts.model);
1263
- if (model)
1264
- next.model = model;
1265
- else
1266
- delete next.model;
1447
+ if (opts.model === undefined && opts.effort === undefined && opts.displayName === undefined) {
1448
+ throw Object.assign(new Error("Nothing to change: pass --model <id|default>, --effort <level|default> and/or --display-name <name>."), { expected: true });
1449
+ }
1450
+ // Only the keys this command changes are written, onto a fresh read of
1451
+ // bot.json: the running bot writes it too (introSentAt, kickoff state), and
1452
+ // the cloud write below can take a while.
1453
+ const patch = {};
1454
+ if (opts.model !== undefined)
1455
+ patch.model = validateBotModel(opts.model) || undefined;
1456
+ if (opts.effort !== undefined)
1457
+ patch.effort = validateBotEffort(config.runtime, opts.effort) || undefined;
1458
+ const displayName = opts.displayName !== undefined ? validateBotDisplayName(opts.displayName) : undefined;
1459
+ let nameSaved = null;
1460
+ if (displayName !== undefined) {
1461
+ try {
1462
+ // Also records displayNameLocalOnly in bot.json.
1463
+ nameSaved = await saveCloudName(new BotApi({ token: () => ownerToken() }), dir, config.agentUid, displayName, false);
1464
+ }
1465
+ catch (err) {
1466
+ // Status and code only: the server's own text stays out of the log too.
1467
+ const what = err instanceof BotApiError ? `HTTP ${err.status}${err.code ? ` ${err.code}` : ""}` : "no answer from HQ";
1468
+ createBotLogger(dir)("warn", `rename to "${displayName}" refused: ${what}`);
1469
+ throw nameWriteRefused(name, err);
1470
+ }
1471
+ patch.displayName = displayName;
1267
1472
  }
1268
- if (opts.effort !== undefined) {
1269
- const effort = validateBotEffort(config.runtime, opts.effort);
1270
- if (effort)
1271
- next.effort = effort;
1272
- else
1273
- delete next.effort;
1473
+ const next = patchBotConfig(dir, patch);
1474
+ createBotLogger(dir)("info", `settings: model=${next.model ?? "default"} effort=${effectiveBotEffort(next)}${next.effort ? "" : " (default)"}${displayName !== undefined ? ` name="${displayName}"` : ""}`);
1475
+ const status = readBotStatus(dir);
1476
+ let running = !!status && isPidAlive(status.pid) && status.state === "running";
1477
+ let restarted = false;
1478
+ let restartPending = false;
1479
+ // Only a name change needs a restart, and only a running bot gets one.
1480
+ if (displayName !== undefined && displayName !== config.displayName && running) {
1481
+ const r = await restartForNewSettings(name, dir, next, "silent", {});
1482
+ running = r.running;
1483
+ restarted = r.restarted;
1484
+ restartPending = r.restartPending;
1274
1485
  }
1275
- writeBotConfig(dir, next);
1276
- createBotLogger(dir)("info", `settings: model=${next.model ?? "default"} effort=${effectiveBotEffort(next)}${next.effort ? "" : " (default)"}`);
1277
1486
  const result = {
1278
- ok: true,
1487
+ // A restart that did not bring the bot back is the one failure left.
1488
+ ok: !(restarted && !running),
1279
1489
  name,
1490
+ agentUid: config.agentUid,
1280
1491
  runtime: config.runtime,
1281
1492
  model: next.model ?? null,
1282
1493
  effort: effectiveBotEffort(next),
1283
1494
  effortIsDefault: !next.effort,
1284
1495
  effortLevels: BOT_EFFORT_LEVELS[config.runtime],
1496
+ displayName: next.displayName ?? null,
1497
+ role: localBotRole(next),
1498
+ nameSaved,
1499
+ running,
1500
+ restarted,
1501
+ restartPending,
1285
1502
  };
1503
+ // A bot the restart left down is a failure the exit code has to carry too.
1504
+ if (!result.ok)
1505
+ process.exitCode = 1;
1286
1506
  if (opts.json) {
1287
1507
  console.log(JSON.stringify(result, null, 2));
1288
1508
  return;
1289
1509
  }
1290
- console.log(chalk.green(`${name} now thinks with ${result.model ?? "the default model"} at ${result.effort} effort (from its next message).`));
1510
+ if (opts.model !== undefined || opts.effort !== undefined) {
1511
+ console.log(chalk.green(`${name} now thinks with ${result.model ?? "the default model"} at ${result.effort} effort (from its next message).`));
1512
+ }
1513
+ if (displayName !== undefined) {
1514
+ const where = nameSaved === "local-only" ? " (kept on this computer; HQ does not take names yet)" : "";
1515
+ const how = restarted
1516
+ ? running
1517
+ ? "; restarted so it uses the new name"
1518
+ : `; it did not come back after the restart, check: hq bot logs ${name}`
1519
+ : restartPending
1520
+ ? `; it uses the new name after its next restart (hq bot restart ${name})`
1521
+ : "";
1522
+ console.log(chalk.green(`${name} is now called ${displayName}`) + chalk.dim(`${where}${how}`));
1523
+ }
1291
1524
  }
1292
1525
  /** `hq bot probe`: exit code 0 when the runtime answered, 1 when it did not. */
1293
1526
  export async function runBotProbe(opts, deps = {}) {
@@ -1352,7 +1585,7 @@ function isReservedSetupBotRecord(bot, ownerUid) {
1352
1585
  const local = parseBotLocalConfig(bot.localConfig);
1353
1586
  return (!!owner &&
1354
1587
  bot.ownerUid === owner &&
1355
- bot.slug === `${RESERVED_SETUP_BOT_NAME}-${owner.slice(-8).toLowerCase()}` &&
1588
+ bot.slug === setupSlugFor(owner) &&
1356
1589
  bot.botKind === "personal" &&
1357
1590
  bot.computeMode === "local" &&
1358
1591
  local?.workerSource === "worker" &&
@@ -1521,9 +1754,9 @@ export function configFromLocalConfig(input) {
1521
1754
  : workerSource === "worker"
1522
1755
  ? { memoryDir: path.join(botWorkerRelDir(name), "memory") }
1523
1756
  : {}),
1524
- ...(local?.displayName ? { displayName: local.displayName } : {}),
1525
- ...(local?.intro ? { intro: local.intro } : {}),
1526
- ...(local?.kickoff ? { kickoff: local.kickoff } : {}),
1757
+ ...(input.overrides?.displayName ?? local?.displayName ? { displayName: input.overrides?.displayName ?? local?.displayName } : {}),
1758
+ ...(input.overrides?.intro ?? local?.intro ? { intro: input.overrides?.intro ?? local?.intro } : {}),
1759
+ ...(input.overrides?.kickoff ?? local?.kickoff ? { kickoff: input.overrides?.kickoff ?? local?.kickoff } : {}),
1527
1760
  createdAt: (input.now ?? (() => new Date()))().toISOString(),
1528
1761
  enabled: true,
1529
1762
  // A restore is not a first start: the bot's one-off kickoff task already
@@ -1541,6 +1774,12 @@ export function configFromLocalConfig(input) {
1541
1774
  * folders, and its launchd agent.
1542
1775
  */
1543
1776
  async function adoptRemoteBot(input) {
1777
+ // Every adopter (create, adopt, restore) holds the bot's lock, so the
1778
+ // check below and the secret rotation after it happen once, in order. A
1779
+ // caller that already holds it re-enters without waiting.
1780
+ return withBotLock(input.name, () => adoptRemoteBotLocked(input), { command: "hq bot adopt" });
1781
+ }
1782
+ async function adoptRemoteBotLocked(input) {
1544
1783
  const { bot, name } = input;
1545
1784
  const dir = botDir(name);
1546
1785
  if (readBotConfig(dir)) {
@@ -1604,6 +1843,8 @@ async function adoptRemoteBot(input) {
1604
1843
  memory: botMemoryMode(config),
1605
1844
  memoryDir: scaffold.memoryDir,
1606
1845
  defaulted: local === null,
1846
+ displayName: config.displayName ?? null,
1847
+ role: localBotRole(config),
1607
1848
  notes,
1608
1849
  daemon: daemonMessage,
1609
1850
  },
@@ -1635,40 +1876,42 @@ export async function runBotAdopt(nameArg, opts) {
1635
1876
  }
1636
1877
  throw Object.assign(new Error(runnable.message), { expected: true });
1637
1878
  }
1638
- if (readBotConfig(dir)) {
1639
- throw Object.assign(new Error(`Bot "${name}" is already set up on this computer (${dir}). Start it with: hq bot start ${name}`), {
1640
- expected: true,
1879
+ await withBotLock(name, async () => {
1880
+ if (readBotConfig(dir)) {
1881
+ throw Object.assign(new Error(`Bot "${name}" is already set up on this computer (${dir}). Start it with: hq bot start ${name}`), {
1882
+ expected: true,
1883
+ });
1884
+ }
1885
+ const { result, config } = await adoptRemoteBot({
1886
+ bot,
1887
+ name,
1888
+ api,
1889
+ hqRoot,
1890
+ overrides: {
1891
+ ...(isBotRuntimeId(opts.runtime) ? { runtime: opts.runtime } : {}),
1892
+ ...(model ? { model } : {}),
1893
+ },
1894
+ daemon: opts.daemon !== false,
1641
1895
  });
1642
- }
1643
- const { result, config } = await adoptRemoteBot({
1644
- bot,
1645
- name,
1646
- api,
1647
- hqRoot,
1648
- overrides: {
1649
- ...(isBotRuntimeId(opts.runtime) ? { runtime: opts.runtime } : {}),
1650
- ...(model ? { model } : {}),
1651
- },
1652
- daemon: opts.daemon !== false,
1653
- });
1654
- if (!opts.json) {
1655
- console.log(chalk.green(`Brought back bot ${chalk.bold(name)}`) + chalk.dim(` (${result.agentUid}, ${result.runtime})`));
1656
- console.log(chalk.dim(` kind: ${describeKind(result.kind, result.companies)}`));
1657
- console.log(chalk.dim(` worker: ${result.worker}`));
1658
- console.log(chalk.dim(` memory: ${result.memoryDir} (${result.memory})`));
1659
- console.log(chalk.dim(` state: ${result.dir}`));
1660
- if (result.daemon)
1661
- console.log(chalk.dim(` ${result.daemon}`));
1662
- for (const note of result.notes)
1663
- console.log(chalk.yellow(` ${note}`));
1664
- console.log(chalk.dim(` Have it say hello again with: hq bot intro ${name}`));
1665
- }
1666
- if (opts.start !== false) {
1667
- await startBot(name, result.dir, config, opts.json ? "json" : "text", { ...result });
1668
- return;
1669
- }
1670
- if (opts.json)
1671
- console.log(JSON.stringify({ ok: true, ...result, running: false }, null, 2));
1896
+ if (!opts.json) {
1897
+ console.log(chalk.green(`Brought back bot ${chalk.bold(name)}`) + chalk.dim(` (${result.agentUid}, ${result.runtime})`));
1898
+ console.log(chalk.dim(` kind: ${describeKind(result.kind, result.companies)}`));
1899
+ console.log(chalk.dim(` worker: ${result.worker}`));
1900
+ console.log(chalk.dim(` memory: ${result.memoryDir} (${result.memory})`));
1901
+ console.log(chalk.dim(` state: ${result.dir}`));
1902
+ if (result.daemon)
1903
+ console.log(chalk.dim(` ${result.daemon}`));
1904
+ for (const note of result.notes)
1905
+ console.log(chalk.yellow(` ${note}`));
1906
+ console.log(chalk.dim(` Have it say hello again with: hq bot intro ${name}`));
1907
+ }
1908
+ if (opts.start !== false) {
1909
+ await startBot(name, result.dir, config, opts.json ? "json" : "text", { ...result });
1910
+ return;
1911
+ }
1912
+ if (opts.json)
1913
+ console.log(JSON.stringify({ ok: true, ...result, running: false }, null, 2));
1914
+ }, { command: "hq bot adopt" });
1672
1915
  }
1673
1916
  /** New credentials + a reinstalled startup agent for a bot that IS already here. */
1674
1917
  async function repairLocalBot(name, api, bot, daemon) {
@@ -1712,8 +1955,9 @@ export async function runBotRestore(opts) {
1712
1955
  const rows = [];
1713
1956
  for (const bot of bots) {
1714
1957
  const name = localNameForRemoteBot(bot);
1958
+ const shown = { displayName: remoteBotDisplayName(bot, name), role: remoteBotRole(bot) };
1715
1959
  if (!name) {
1716
- 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" });
1960
+ 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", ...shown });
1717
1961
  continue;
1718
1962
  }
1719
1963
  // A bot this computer cannot run is never brought back: it would get
@@ -1727,41 +1971,45 @@ export async function runBotRestore(opts) {
1727
1971
  action: opts.dryRun ? "would-skip" : "skipped",
1728
1972
  reason: runnable.reason,
1729
1973
  detail: runnable.message,
1974
+ ...shown,
1730
1975
  });
1731
1976
  continue;
1732
1977
  }
1733
- const here = readBotConfig(botDir(name)) !== null;
1734
1978
  const settings = describeBotLocalConfig(parseBotLocalConfig(bot.localConfig));
1735
- if (here && hasPromotionHold(botDir(name))) {
1736
- rows.push({ name, agentUid: bot.agentUid, action: opts.dryRun ? "would-skip" : "skipped", detail: "held for cloud promotion" });
1737
- continue;
1738
- }
1739
- if (here && !opts.all) {
1740
- rows.push({ name, agentUid: bot.agentUid, action: opts.dryRun ? "would-skip" : "skipped", detail: "already set up here" });
1741
- continue;
1742
- }
1743
- if (opts.dryRun) {
1744
- rows.push({ name, agentUid: bot.agentUid, action: here ? "would-repair" : "would-restore", detail: here ? "new credentials" : settings });
1745
- continue;
1746
- }
1747
- try {
1748
- if (here) {
1749
- rows.push({ name, agentUid: bot.agentUid, action: "repaired", detail: await repairLocalBot(name, api, bot, opts.daemon !== false) });
1979
+ // Decided and done under the bot's lock: a create or adopt of the same bot
1980
+ // running at the same moment either finished first (and this skips it as
1981
+ // already here) or waits for this one.
1982
+ const restoreOne = async () => {
1983
+ const here = readBotConfig(botDir(name)) !== null;
1984
+ if (here && hasPromotionHold(botDir(name))) {
1985
+ return { name, agentUid: bot.agentUid, action: opts.dryRun ? "would-skip" : "skipped", detail: "held for cloud promotion", ...shown };
1750
1986
  }
1751
- else {
1752
- const { result, config } = await adoptRemoteBot({ bot, name, api, hqRoot, daemon: opts.daemon !== false });
1753
- // Silent: this command's own JSON has to be the only document on stdout.
1754
- const started = opts.start === false ? null : await startBot(name, result.dir, config, "silent").catch(() => null);
1755
- rows.push({
1756
- name,
1757
- agentUid: bot.agentUid,
1758
- action: "restored",
1759
- detail: [settings, ...result.notes, ...(started ? [started.running ? "started" : "not running yet — hq bot logs " + name] : [])].join("; "),
1760
- });
1987
+ if (here && !opts.all) {
1988
+ return { name, agentUid: bot.agentUid, action: opts.dryRun ? "would-skip" : "skipped", detail: "already set up here", ...shown };
1989
+ }
1990
+ if (opts.dryRun) {
1991
+ return { name, agentUid: bot.agentUid, action: here ? "would-repair" : "would-restore", detail: here ? "new credentials" : settings, ...shown };
1992
+ }
1993
+ if (here) {
1994
+ return { name, agentUid: bot.agentUid, action: "repaired", detail: await repairLocalBot(name, api, bot, opts.daemon !== false), ...shown };
1761
1995
  }
1996
+ const { result, config } = await adoptRemoteBot({ bot, name, api, hqRoot, daemon: opts.daemon !== false });
1997
+ // Silent: this command's own JSON has to be the only document on stdout.
1998
+ const started = opts.start === false ? null : await startBot(name, result.dir, config, "silent").catch(() => null);
1999
+ return {
2000
+ name,
2001
+ agentUid: bot.agentUid,
2002
+ action: "restored",
2003
+ detail: [settings, ...result.notes, ...(started ? [started.running ? "started" : "not running yet (see hq bot logs " + name + ")"] : [])].join("; "),
2004
+ displayName: shown.displayName ?? result.displayName,
2005
+ role: shown.role ?? result.role,
2006
+ };
2007
+ };
2008
+ try {
2009
+ rows.push(opts.dryRun ? await restoreOne() : await withBotLock(name, restoreOne, { command: "hq bot restore" }));
1762
2010
  }
1763
2011
  catch (err) {
1764
- rows.push({ name, agentUid: bot.agentUid, action: "failed", detail: err instanceof Error ? err.message : String(err) });
2012
+ rows.push({ name, agentUid: bot.agentUid, action: "failed", detail: err instanceof Error ? err.message : String(err), ...shown });
1765
2013
  }
1766
2014
  }
1767
2015
  const counted = (action) => rows.filter((r) => r.action === action).length;
@@ -1816,6 +2064,8 @@ async function runBotListRemote(opts) {
1816
2064
  settings: describeBotLocalConfig(parseBotLocalConfig(bot.localConfig)),
1817
2065
  runnable: runnable.runnable,
1818
2066
  reason: runnable.reason,
2067
+ displayName: remoteBotDisplayName(bot, name),
2068
+ role: remoteBotRole(bot),
1819
2069
  };
1820
2070
  });
1821
2071
  if (opts.json) {
@@ -1970,8 +2220,9 @@ export function registerBotCommand(program) {
1970
2220
  .action((name, opts) => wrap(() => runBotRm(name, opts))());
1971
2221
  bot
1972
2222
  .command("set <name>")
1973
- .description("Change the model and thinking level a bot uses (applies from its next message)")
2223
+ .description("Change the model and thinking level a bot uses (applies from its next message), or the name people see for it (saved in HQ; a running bot restarts to use it)")
1974
2224
  .option("--model <model>", "Model id for the runtime CLI, or default for the CLI's own default")
2225
+ .option("--display-name <name>", `The name people see for the bot in HQ (up to ${BOT_DISPLAY_NAME_MAX_CHARS} chars). The handle stays <name>`)
1975
2226
  .option("--effort <level>", `Thinking level (claude ${BOT_EFFORT_LEVELS.claude.join("|")}; codex ${BOT_EFFORT_LEVELS.codex.join("|")}; grok ${BOT_EFFORT_LEVELS.grok.join("|")}), or default (${DEFAULT_BOT_EFFORT})`)
1976
2227
  .option("--json", "Print machine-readable JSON")
1977
2228
  .action((name, opts) => wrap(() => runBotSet(name, opts))());