@indigoai-us/hq-cli 5.345.86 → 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.
- package/CHANGELOG.md +22 -0
- package/dist/command-catalog.generated.d.ts +5 -1
- package/dist/command-catalog.generated.js +6 -1
- package/dist/commands/bot.d.ts +60 -12
- package/dist/commands/bot.js +349 -98
- package/dist/commands/storage-core.d.ts +2 -0
- package/dist/commands/storage-core.js +12 -1
- package/dist/commands/storage-offload.d.ts +10 -0
- package/dist/commands/storage-offload.js +4 -0
- package/dist/commands/storage.d.ts +6 -0
- package/dist/commands/storage.js +25 -6
- package/dist/lib/bot/api.d.ts +15 -0
- package/dist/lib/bot/api.js +11 -0
- package/dist/lib/bot/config.d.ts +6 -0
- package/dist/lib/bot/display-name.d.ts +64 -0
- package/dist/lib/bot/display-name.js +117 -0
- package/dist/lib/bot/index.d.ts +2 -0
- package/dist/lib/bot/index.js +2 -0
- package/dist/lib/bot/lock.d.ts +84 -0
- package/dist/lib/bot/lock.js +313 -0
- package/package.json +1 -1
package/dist/commands/bot.js
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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 --
|
|
1259
|
-
}
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
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
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 ===
|
|
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:
|
|
1525
|
-
...(local?.intro ? { intro:
|
|
1526
|
-
...(local?.kickoff ? { 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
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
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
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
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
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
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
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
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))());
|