@indigoai-us/hq-cli 5.345.200 → 5.345.201

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 CHANGED
@@ -2,6 +2,20 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.345.201] — 2026-11-04
6
+ - `hq deploy domains buy` accepts a registrar price in `usd` or `USD`. Search shows that price as US dollars, and renew accepts the same code. A price in another currency is still refused, and the message shows the currency code the server sent.
7
+ - The Executive sends source reports to its owner when a meeting ends and one final threaded report after all source lanes finish. Delivery receipts suppress normal duplicates and retain reports during the ten-minute cooldown. Recovery retries an intent without a sent receipt; a crash after sending but before recording the receipt can resend that report.
8
+ - Re-pin native policy enforcement to the corrected staging gate, including keyed host approvals and consumed fact proofs. Fix CRLF transport in Windows answer rows, receipt names and required fact names. Recognize drive-qualified and MSYS absolute read paths while preserving exact receipt names. The port remains opt-in and requires the matching core script.
9
+ - `hq-hook`: `block-agent-secrets-reveal` has a full native port (US-011). `hq-hook bridge block-agent-secrets-reveal` answers every call of the hq-core-staging guard (sha256 `e1a1b05d`) itself, including denies, and matches the bash guard byte for byte on macOS and Windows. It runs the flag reader with node only when the command is a secret reveal, as the guard does. The flag `hook-engine-port-block-agent-secrets-reveal` stays off, so nothing changes until it is turned on.
10
+ - `hq-hook`: port `lanes-repo-merge-hold` payload validation, merge prefilter, dependency checks and hold-response formatting to native Rust. Preserve the bounded CLI call and its failure notices, including timeouts without a hold record. Convert Git Bash TMPDIR drive paths through the shared MSYS converter. Capture CLI output in private files so inherited child handles cannot extend the timeout wait. The port flag stays off.
11
+ - Older local bots on the Codex or Grok runner now move to the HQ Fleet Runtime at their next start when their provider sign-in is readable on the computer: the Codex CLI login (or the bot's own Codex login) is imported, and a Grok login is copied from another Fleet bot on the same computer. A bot whose sign-in is not readable stays on its legacy runner, records why, files one failure report with HQ per reason (the same route a Fleet bot reports a failed turn through), and DMs its owner once per reason with the `hq bot auth <name> --provider <chatgpt|grok>` fix. A Claude bot with its own Claude account stays and tells the owner that `hq bot auth <name> --provider claude` moves it onto the owner's Claude Code login. `hq bot list --json`, `hq bot status --json` and `hq bot start --json` now carry `brainSignIn` (the provider a bot of any engine must sign in to before it can run, with the command) and, for a legacy bot, `legacy` (whether it moves at its next start, the hold reason, the last failed move, the owner DM and the report id); `hq bot status` and `hq doctor --only bots` say the same in words. `HQ_BOT_KEEP_LEGACY_RUNTIME=1` still keeps any bot legacy.
12
+ - Local bots now install HQ Fleet Local 0.4.26 on HQ Fleet Runtime main d257fdd1
13
+ (same gzip runtime archive as 0.4.25). A bot whose turn failed no longer counts its
14
+ own "I'm away for a bit" reply as a worked turn, so `hq bot status` and `hq doctor`
15
+ keep showing the failure and its fix until the bot really answers. Installed bots
16
+ move to 0.4.26 at the daemon's next update pass.
17
+ - `hq sync pull` now recovers when the HQ server rejects a sign-in that still looks valid on your machine (`hq auth status` and `hq auth refresh` only check the saved expiry): it refreshes the saved session once and retries, and if the server still rejects it the message tells you to run `hq login` instead of printing a bare "Unauthorized". Waiting on the sign-in service during a sync now stops after 45 seconds with a clear message.
18
+
5
19
  ## [5.345.200] — 2026-11-04
6
20
  - `hq creators apply`, `hq library` (search, add, describe, link, rm),
7
21
  `hq sync held`, and `hq lanes` no longer open a browser sign-in when no
@@ -1,3 +1,4 @@
1
+ import { type LegacyMoveState, type LegacyProviderLoginProbe } from "../lib/bot/legacy-convert.js";
1
2
  import { Command } from "commander";
2
3
  import { type RuntimeSignInCheck, type RuntimeSignInIssue } from "../lib/bot/runtime-sign-in.js";
3
4
  import { type ClaudeLoginCheck } from "../lib/bot/claude-login.js";
@@ -62,6 +63,16 @@ export interface BotRow {
62
63
  runtimeSignedIn: boolean | null;
63
64
  /** The terminal command that signs the bot's coding tool in on this computer. */
64
65
  signInCommand: string;
66
+ /**
67
+ * Set when the bot cannot run until its model provider is signed in, on any
68
+ * engine: a Fleet bot never signed in or whose last turn failed on auth, a
69
+ * legacy bot held for a sign-in it cannot import, or a legacy runner that
70
+ * found its tool signed out. `command` signs the bot in (and moves a legacy
71
+ * bot to the HQ Fleet Runtime); null when no sign-in is needed.
72
+ */
73
+ brainSignIn: BrainSignIn | null;
74
+ /** A bot still on its legacy runner: whether and why it moves at its next start. Absent for a Fleet bot. */
75
+ legacy?: Record<string, unknown>;
65
76
  /**
66
77
  * The coding tool crashed before its turn started since the bot last
67
78
  * started, and no turn has worked since; null otherwise. A restart clears it.
@@ -113,8 +124,40 @@ export declare function ownerSignInAllowed(): boolean;
113
124
  * sign-out, and the next start checks afresh.
114
125
  */
115
126
  export declare function runtimeSignedIn(status: Pick<BotStatusFile, "runtimeSignIn" | "runtimeSignInCheck"> | null | undefined, processAlive: boolean): boolean | null;
127
+ export interface BrainSignIn {
128
+ provider: BotRuntimeId;
129
+ needed: true;
130
+ reason: string;
131
+ command: string;
132
+ }
133
+ /**
134
+ * Whether the bot's model provider must be signed in before it can run, from
135
+ * what this computer knows: the bot's settings, its last reported failure or
136
+ * sign-in check, and (legacy) why it has not moved to the HQ Fleet Runtime.
137
+ */
138
+ export declare function brainSignInFor(name: string, config: BotConfig, status: BotStatusFile | null, legacy: LegacyMoveState | null): BrainSignIn | null;
116
139
  /** Spawn `hq bot run <name>` detached (no launchd). */
117
140
  export declare function spawnDetachedRun(name: string, dir: string): number;
141
+ /**
142
+ * Move a legacy bot onto the HQ Fleet Runtime under the same identity.
143
+ * Returns the bot's new settings, or null when it stays on the legacy runner
144
+ * (it needs its own sign-in, the move is turned off, or the move failed, in
145
+ * which case the next start tries again).
146
+ *
147
+ * bot.json switches to Fleet only after the Fleet service is installed, so a
148
+ * failure or a kill at any earlier step leaves a legacy bot that starts as
149
+ * before. Legacy jobs move and the legacy startup agent goes after that.
150
+ *
151
+ * `from: "start"` stops the legacy runner first; a failed move leaves it
152
+ * stopped and the caller starts it again. `from: "run"` is the legacy runner
153
+ * itself starting (launchd, hq daemon or a detached run), before it polls:
154
+ * its own startup agent is booted out last, detached.
155
+ */
156
+ /** Seams for the legacy move, replaced in tests. */
157
+ export declare const legacyConvertSeams: {
158
+ /** Whether the provider sign-in a Codex or Grok legacy bot needs is readable here, as HQ Fleet Local imports it. */
159
+ providerLogin: (dir: string, config: BotConfig) => LegacyProviderLoginProbe;
160
+ };
118
161
  /**
119
162
  * `output`: "json" | "text" print a result; "silent" prints nothing — used
120
163
  * when stop is a step inside rm/restart, whose own JSON must be the only
@@ -188,6 +231,10 @@ export declare function runBotCompanies(nameArg: string, opts: {
188
231
  export declare function runBotWorkers(opts: {
189
232
  json?: boolean;
190
233
  }): Promise<void>;
234
+ /** The `legacy` object in `hq bot status --json` for a bot still on its legacy runner. */
235
+ export declare function legacyStatusJson(state: LegacyMoveState): Record<string, unknown>;
236
+ /** The `fleet move:` lines of `hq bot status` for a bot still on its legacy runner. */
237
+ export declare function legacyStatusLines(name: string, state: LegacyMoveState): string[];
191
238
  /**
192
239
  * `hq bot set <name> [--model <id|default>] [--effort <level|default>]
193
240
  * [--display-name <name>]`: change what the bot thinks with, or the name
@@ -1,5 +1,8 @@
1
1
  import { withLocalJobs } from "../lib/bot/local-jobs.js";
2
- import { fleetMoveFailurePath, fleetMoveRecentlyFailed, legacyConversionDecision, writeFleetMoveFailure } from "../lib/bot/legacy-convert.js";
2
+ import { clearFleetMoveRecords, fleetMoveHoldPath, fleetMoveRecentlyFailed, holdReportDue, legacyConversionDecision, legacyHoldFailureReport, legacyHoldNotice, legacyHoldNotifiesOwner, legacyMoveState, ownerHoldNoticeDue, readFleetMoveFailure, readFleetMoveHold, writeFleetMoveFailure, writeFleetMoveHold, } from "../lib/bot/legacy-convert.js";
3
+ import { legacyProviderLoginProbe } from "../lib/bot/legacy-provider-login.js";
4
+ import { randomUUID } from "node:crypto";
5
+ import { CLI_VERSION } from "../cli-version.js";
3
6
  import { runSignals } from "../lib/signals/engine.js";
4
7
  import { BOT_SIGNALS_ENGINE_JOB_FLAG, signalsEngineForJob } from "../lib/bot/signals-engine-flag.js";
5
8
  import { resolveProcessFlagGate } from "../lib/flag-registry.js";
@@ -70,7 +73,7 @@ import { unresolvedLastFailure } from "../lib/doctor/checks/bots.js";
70
73
  import { classifyRuntimeFailure, runtimeFailureReason } from "../lib/bot/runtime-failure.js";
71
74
  import { PROBE_DEFAULT_TIMEOUT_MS, probeBotRuntime } from "../lib/bot/probe.js";
72
75
  import { BOT_LOGIN_RUNTIMES, botAuthEnv, botRuntimeAuth, capacityAccountHome, describeBotAuth, formatBotAuth, isBotLoginRuntime, restoreBotSessions, setAsideBotSessions, signInBotRuntime, signOutBotRuntime, withRuntimeAuth, } from "../lib/bot/runtime-auth.js";
73
- 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, runtimeCrashSinceStart, 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, HQ_FLEET_RUNTIME_ENGINE_ID, HQ_FLEET_LOCAL_RELEASE, authenticateFleetLocalBot, bootstrapFleetLocalBot, buildSystemPrompt, controlFleetLocalBot, ensureFleetPython, fleetLocalGatewayPid, fleetLocalProfileNeedsAdoption, fleetLocalServiceInstalled, fleetProviderForRuntime, importFleetLocalMachineCredential, fleetProviderNeedsSignIn, fleetOwnerCompanies, refreshFleetLocalInstructions, fleetLocalPromotionRuntime, installPinnedFleetLocalDistribution, installTargetFleetLocalDistribution, resolveFleetLocalPointerTarget, resolveFleetLocalTargetRelease, fleetLocalProfileDir, fleetProfileOwnerCompanies, fleetLocalChannels, readFleetLocalGatewayState, readPromptSources, removeFleetLocalBot, } from "../lib/bot/index.js";
76
+ 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, runtimeCrashSinceStart, 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, HQ_FLEET_RUNTIME_ENGINE_ID, HQ_FLEET_LOCAL_RELEASE, authenticateFleetLocalBot, bootstrapFleetLocalBot, buildSystemPrompt, controlFleetLocalBot, ensureFleetPython, fleetLocalGatewayPid, fleetLocalProfileNeedsAdoption, fleetLocalServiceInstalled, fleetProviderForRuntime, importFleetLocalMachineCredential, importFleetLocalProviderLogin, fleetProviderNeedsSignIn, fleetOwnerCompanies, refreshFleetLocalInstructions, fleetLocalPromotionRuntime, installPinnedFleetLocalDistribution, installTargetFleetLocalDistribution, resolveFleetLocalPointerTarget, resolveFleetLocalTargetRelease, fleetLocalProfileDir, fleetProfileOwnerCompanies, fleetLocalChannels, readFleetLocalGatewayState, readPromptSources, removeFleetLocalBot, } from "../lib/bot/index.js";
74
77
  import { gatedInteractiveLogin } from "../utils/interactive-caller.js";
75
78
  import { fleetInstallProgressReporter, runBotRuntimeInstall, runBotRuntimeStatus } from "./bot-runtime.js";
76
79
  function collectRepeated(value, previous) {
@@ -368,11 +371,39 @@ export function runtimeSignedIn(status, processAlive) {
368
371
  return false;
369
372
  return status.runtimeSignInCheck?.signedIn ?? null;
370
373
  }
374
+ /**
375
+ * Whether the bot's model provider must be signed in before it can run, from
376
+ * what this computer knows: the bot's settings, its last reported failure or
377
+ * sign-in check, and (legacy) why it has not moved to the HQ Fleet Runtime.
378
+ */
379
+ export function brainSignInFor(name, config, status, legacy) {
380
+ const provider = config.runtime;
381
+ const command = fleetAuthCommand(name, config);
382
+ if (legacy && !legacy.decision.convert && legacyHoldNotifiesOwner(legacy.decision.reason)) {
383
+ return { provider, needed: true, reason: legacy.notice?.cause ?? legacy.decision.detail, command };
384
+ }
385
+ if (config.fleetAuthPending === true)
386
+ return { provider, needed: true, reason: `${name} has not signed in to ${runtimeDisplayName(provider)} yet.`, command };
387
+ // A legacy bot that moves at its next start needs no sign-in, whatever its old runner last reported.
388
+ if (legacy?.decision.convert)
389
+ return null;
390
+ const failure = unresolvedLastFailure(status);
391
+ if (failure?.class === "auth")
392
+ return { provider, needed: true, reason: failure.cause, command };
393
+ if (status?.runtimeSignIn)
394
+ return { provider, needed: true, reason: signedOutReason(name, status.runtimeSignIn), command };
395
+ return null;
396
+ }
397
+ function signedOutReason(name, issue) {
398
+ const tool = runtimeDisplayName(issue.runtime);
399
+ return issue.state === "signed-out" ? `${tool} is not signed in for ${name} on this computer.` : `${tool} needs ${name}'s owner to sign in again on this computer.`;
400
+ }
371
401
  function rowFor(name, opts = {}) {
372
402
  const dir = botDir(name);
373
403
  const config = readBotConfig(dir);
374
404
  const fleet = isFleetRuntimeBot(config);
375
405
  const status = readBotStatus(dir);
406
+ const legacy = fleet ? null : opts.legacy ?? legacyMoveState(dir, config, legacyConvertSeams.providerLogin(dir, config));
376
407
  const pid = fleet ? fleetLocalGatewayPid(config.agentUid) : (status?.pid ?? null);
377
408
  const processAlive = pid !== null && isPidAlive(pid) && (fleet || status?.state === "running");
378
409
  // Turns this bot has taken and not finished: what "thinking" means.
@@ -419,6 +450,8 @@ function rowFor(name, opts = {}) {
419
450
  ...(fleet && config.fleetAuthPending === true ? { needsSignIn: true, authCommand: fleetAuthCommand(name, config) } : {}),
420
451
  ...(fleet && config.fleetServiceInstallPending === true ? { needsSignIn: false, serviceInstallPending: true } : {}),
421
452
  signInCommand: fleet ? fleetAuthCommand(name, config) : signInCommand(config.runtime),
453
+ brainSignIn: brainSignInFor(name, config, status, legacy),
454
+ ...(legacy ? { legacy: legacyStatusJson(legacy) } : {}),
422
455
  runtimeCrash: processAlive ? runtimeCrashSinceStart(status) : null,
423
456
  daemonInstalled,
424
457
  daemonLoaded,
@@ -525,9 +558,12 @@ async function startBot(name, dir, config, output, extra = {}, opts = {}) {
525
558
  throw new Error("This bot is held for cloud promotion. Continue its promotion before changing local settings or lifecycle.");
526
559
  if (isFleetRuntimeBot(config))
527
560
  return startFleetRuntimeBot(name, dir, config, output, extra);
528
- const converted = opts.keepLegacy ? null : await convertLegacyBotToFleet(name, dir, config, "start");
529
- if (converted)
530
- return reportFleetBotStart(name, dir, converted, output, { ...extra, movedToFleet: true }, true, "started by the HQ Fleet Runtime");
561
+ const move = opts.keepLegacy ? null : await convertLegacyBotToFleet(name, dir, config, "start");
562
+ if (move?.fleet)
563
+ return reportFleetBotStart(name, dir, move.fleet, output, { ...extra, movedToFleet: true }, true, "started by the HQ Fleet Runtime");
564
+ // The app reads these to say the bot is still legacy and, when a sign-in is all it waits for, to offer it.
565
+ if (move?.state)
566
+ extra = { ...extra, movedToFleet: false, legacy: legacyStatusJson(move.state), brainSignIn: brainSignInFor(name, config, readBotStatus(dir), move.state) };
531
567
  if (config.enabled === false)
532
568
  patchBotConfig(dir, { enabled: true });
533
569
  const paths = buildBotDaemonPaths({ name, botDir: dir, hqRoot: config.hqRoot });
@@ -604,21 +640,37 @@ async function startBot(name, dir, config, output, extra = {}, opts = {}) {
604
640
  * itself starting (launchd, hq daemon or a detached run), before it polls:
605
641
  * its own startup agent is booted out last, detached.
606
642
  */
643
+ /** Seams for the legacy move, replaced in tests. */
644
+ export const legacyConvertSeams = {
645
+ /** Whether the provider sign-in a Codex or Grok legacy bot needs is readable here, as HQ Fleet Local imports it. */
646
+ providerLogin: (dir, config) => legacyProviderLoginProbe(dir, config),
647
+ };
607
648
  async function convertLegacyBotToFleet(name, dir, config, from) {
608
- if (!legacyConversionDecision(config).convert || hasPromotionHold(dir))
609
- return null;
610
- // A launchd or daemon restart loop would otherwise retry a failing install before every start.
649
+ if (hasPromotionHold(dir))
650
+ return { fleet: null, state: null };
651
+ // A launchd or daemon restart loop would otherwise retry a failing install (and probe the sign-in) before every start.
611
652
  if (from === "run" && fleetMoveRecentlyFailed(dir))
612
- return null;
653
+ return { fleet: null, state: null };
654
+ const probe = legacyConvertSeams.providerLogin(dir, config);
655
+ const decision = legacyConversionDecision(config, process.env, probe);
656
+ if (!decision.convert) {
657
+ if (legacyHoldNotifiesOwner(decision.reason))
658
+ await holdLegacyBot(name, dir, config, decision);
659
+ return { fleet: null, state: { decision, notice: legacyHoldNotice(name, config.runtime, decision), lastFailure: readFleetMoveFailure(dir), hold: readFleetMoveHold(dir) } };
660
+ }
661
+ // The hold is over: whatever stops the move from here is a failure, not a hold.
662
+ fs.rmSync(fleetMoveHoldPath(dir), { force: true });
663
+ const ready = () => ({ fleet: null, state: { decision, notice: null, lastFailure: readFleetMoveFailure(dir), hold: null } });
613
664
  if (from === "start") {
614
665
  const running = readBotStatus(dir);
615
666
  if (running && isPidAlive(running.pid) && selfStopProbe(running.pid))
616
- return null;
667
+ return ready();
617
668
  }
618
669
  const log = createBotLogger(dir);
619
670
  const keepLegacy = (step, err) => {
620
- writeFleetMoveFailure(dir);
621
- log("warn", `could not ${step} ${name} on the HQ Fleet Runtime (${err instanceof Error ? err.message : String(err)}); it runs on ${config.runtime} this time and tries again at its next start`);
671
+ const error = err instanceof Error ? err.message : String(err);
672
+ writeFleetMoveFailure(dir, new Date(), { step, error });
673
+ log("warn", `could not ${step} ${name} on the HQ Fleet Runtime (${error}); it runs on ${config.runtime} this time and tries again at its next start`);
622
674
  };
623
675
  let engine;
624
676
  try {
@@ -628,9 +680,27 @@ async function convertLegacyBotToFleet(name, dir, config, from) {
628
680
  }
629
681
  catch (err) {
630
682
  keepLegacy("install", err);
631
- return null;
683
+ return ready();
684
+ }
685
+ if (decision.signIn) {
686
+ try {
687
+ importFleetLocalProviderLogin(config.agentUid, decision.signIn.runtime, {}, decision.signIn.env ? { env: decision.signIn.env } : {});
688
+ log("info", `imported ${name}'s ${config.runtime} sign-in from this computer for the HQ Fleet Runtime`);
689
+ }
690
+ catch (err) {
691
+ keepLegacy("sign in", err);
692
+ return ready();
693
+ }
632
694
  }
633
- const fleet = { ...(readBotConfig(dir) ?? config), engine, enabled: true, fleetServiceInstallPending: false };
695
+ const current = readBotConfig(dir) ?? config;
696
+ const fleet = {
697
+ ...current,
698
+ engine,
699
+ enabled: true,
700
+ fleetServiceInstallPending: false,
701
+ // The imported login is the bot's Fleet sign-in now; the legacy own-login setting no longer applies.
702
+ ...(decision.signIn?.env ? { runtimeAuth: withRuntimeAuth(current.runtimeAuth, decision.signIn.runtime, null) } : {}),
703
+ };
634
704
  if (from === "start")
635
705
  await stopBot(name, dir, config, "silent");
636
706
  let failure = null;
@@ -651,16 +721,77 @@ async function convertLegacyBotToFleet(name, dir, config, from) {
651
721
  /* nothing was running */
652
722
  }
653
723
  keepLegacy("start", failure);
654
- return null;
724
+ return ready();
655
725
  }
656
726
  writeBotConfig(dir, fleet);
657
- fs.rmSync(fleetMoveFailurePath(dir), { force: true });
727
+ clearFleetMoveRecords(dir);
658
728
  // The legacy runner's copy of the machine credentials goes only now: a failed move above still needs it.
659
729
  fs.rmSync(botCredsPath(dir), { force: true });
660
730
  log("info", `moved ${name} to the HQ Fleet Runtime under the same identity; its ${config.runtime} runner is retired`);
661
731
  syncFleetRuntimeJobs(name, dir, fleet);
662
732
  retireLegacyBotAgent(name, dir, config, from === "run" ? "detached" : "inline");
663
- return fleet;
733
+ return { fleet, state: null };
734
+ }
735
+ /**
736
+ * A legacy bot that cannot move yet: record why, and tell the owner once per
737
+ * reason through the bot's own DM, the way a Fleet bot reports a failed turn.
738
+ * The record is what `hq bot status` and `hq doctor` show.
739
+ */
740
+ async function holdLegacyBot(name, dir, config, decision) {
741
+ const log = createBotLogger(dir);
742
+ const notice = legacyHoldNotice(name, config.runtime, decision);
743
+ const previous = readFleetMoveHold(dir);
744
+ const now = new Date().toISOString();
745
+ const hold = {
746
+ reason: decision.reason,
747
+ detail: decision.detail,
748
+ at: now,
749
+ ...(previous?.notifiedAt ? { notifiedAt: previous.notifiedAt } : {}),
750
+ ...(previous?.notifiedReason ? { notifiedReason: previous.notifiedReason } : {}),
751
+ ...(previous?.reportId ? { reportId: previous.reportId } : {}),
752
+ ...(previous?.reportedReason ? { reportedReason: previous.reportedReason } : {}),
753
+ };
754
+ writeFleetMoveHold(dir, hold);
755
+ log("warn", `${name} stays on its ${config.runtime} runner: ${notice.cause} ${notice.fix}`);
756
+ const reportDue = holdReportDue(previous, decision.reason);
757
+ const noticeDue = ownerHoldNoticeDue(previous, decision.reason);
758
+ if (!reportDue && !noticeDue)
759
+ return;
760
+ const api = new BotApi({ token: botTokenSupplier(dir) });
761
+ // One failure report per reason, the same route a Fleet bot files a failed turn through, so stuck legacy bots are visible in HQ.
762
+ if (reportDue) {
763
+ try {
764
+ const report = legacyHoldFailureReport({
765
+ reportId: randomUUID(),
766
+ createdAt: now,
767
+ name,
768
+ config,
769
+ decision,
770
+ notice,
771
+ machine: os.hostname().trim(),
772
+ hqCliVersion: CLI_VERSION,
773
+ os: `${process.platform} ${os.release()}`,
774
+ logPath: botLogPath(dir),
775
+ });
776
+ const { reportId } = await api.reportFailure(config.agentUid, report);
777
+ Object.assign(hold, { reportedReason: decision.reason, ...(reportId ? { reportId } : {}) });
778
+ writeFleetMoveHold(dir, hold);
779
+ log("info", `filed a failure report with HQ for ${name}'s held move (${decision.reason}${reportId ? `, report ${reportId}` : ""})`);
780
+ }
781
+ catch (err) {
782
+ log("warn", `could not file ${name}'s held move with HQ (${err instanceof Error ? err.message : String(err)}); the next start tries again`);
783
+ }
784
+ }
785
+ if (!noticeDue)
786
+ return;
787
+ try {
788
+ await api.sendDm({ toPersonUid: config.ownerUid, body: `${notice.cause} ${notice.fix}` });
789
+ writeFleetMoveHold(dir, { ...hold, notifiedAt: now, notifiedReason: decision.reason });
790
+ log("info", `told the owner once that ${name} has not moved to the HQ Fleet Runtime (${decision.reason})`);
791
+ }
792
+ catch (err) {
793
+ log("warn", `could not tell the owner why ${name} has not moved to the HQ Fleet Runtime (${err instanceof Error ? err.message : String(err)}); the next start tries again`);
794
+ }
664
795
  }
665
796
  /** Remove a legacy bot's own startup agent once the bot runs on the HQ Fleet Runtime. True when there was one. */
666
797
  function retireLegacyBotAgent(name, dir, config, bootout) {
@@ -1576,7 +1707,7 @@ async function runBotResident(nameArg) {
1576
1707
  console.log(orphanBotRunMessage(name, outcome));
1577
1708
  return;
1578
1709
  }
1579
- if (await convertLegacyBotToFleet(name, dir, config, "run"))
1710
+ if ((await convertLegacyBotToFleet(name, dir, config, "run")).fleet)
1580
1711
  return;
1581
1712
  if (healNodePathFromJob(name, dir, config))
1582
1713
  return;
@@ -1694,18 +1825,28 @@ async function runBotList(opts) {
1694
1825
  async function runBotStatus(nameArg, opts) {
1695
1826
  const { name, dir, config } = requireBot(nameArg);
1696
1827
  const fleet = isFleetRuntimeBot(config);
1697
- const row = rowFor(name);
1828
+ const legacy = fleet ? null : legacyMoveState(dir, config, legacyConvertSeams.providerLogin(dir, config));
1829
+ const row = rowFor(name, { legacy });
1698
1830
  await decorateOnline([row]);
1699
1831
  const status = readBotStatus(dir);
1700
1832
  const creds = readBotCredsIdentity(dir);
1701
1833
  const channels = fleet ? fleetLocalChannels(readFleetLocalGatewayState(config.agentUid), row.processAlive) : null;
1702
1834
  if (opts.json) {
1703
- console.log(JSON.stringify({ ...row, executive: executiveStatus(config, dir), ...(fleet ? { engine: config.engine, channels } : {}), credsPresent: fleet || !!creds, login: describeBotAuth(dir, config), status }, null, 2));
1835
+ console.log(JSON.stringify({
1836
+ ...row,
1837
+ executive: executiveStatus(config, dir),
1838
+ ...(fleet ? { engine: config.engine, channels } : {}),
1839
+ credsPresent: fleet || !!creds,
1840
+ login: describeBotAuth(dir, config),
1841
+ status,
1842
+ }, null, 2));
1704
1843
  return;
1705
1844
  }
1706
1845
  console.log(`${chalk.bold(name)} ${row.agentUid}`);
1707
1846
  if (fleet)
1708
1847
  console.log(` engine: HQ Fleet Runtime ${config.engine?.version ?? ""} ${chalk.dim(`logs ~/.hq-fleet/profiles/${config.agentUid}/logs`)}`);
1848
+ else
1849
+ console.log(` engine: ${chalk.yellow(`legacy ${runtimeDisplayName(config.runtime)} runner (not on the HQ Fleet Runtime)`)}`);
1709
1850
  console.log(` kind: ${describeKind(row.kind, row.companies)}`);
1710
1851
  const login = describeBotAuth(dir, config);
1711
1852
  console.log(` runtime: ${row.runtime} model ${row.model ?? "default"} thinking ${row.effort}${row.effortIsDefault ? " (default)" : ""}`);
@@ -1732,6 +1873,10 @@ async function runBotStatus(nameArg, opts) {
1732
1873
  else {
1733
1874
  console.log(` launchd: ${row.daemonInstalled ? (row.daemonLoaded ? "installed, loaded" : "installed, not loaded") : "not installed"}`);
1734
1875
  }
1876
+ if (legacy) {
1877
+ for (const line of legacyStatusLines(name, legacy))
1878
+ console.log(` ${line}`);
1879
+ }
1735
1880
  if (row.daemonNodeMissing) {
1736
1881
  console.log(` ${chalk.red(`node missing: ${row.daemonNodeMissing}`)} — launchd cannot start ${name}; fix with: hq bot start ${name}`);
1737
1882
  }
@@ -1781,6 +1926,37 @@ async function runBotStatus(nameArg, opts) {
1781
1926
  }
1782
1927
  console.log(chalk.dim(` state dir: ${dir}`));
1783
1928
  }
1929
+ /** The `legacy` object in `hq bot status --json` for a bot still on its legacy runner. */
1930
+ export function legacyStatusJson(state) {
1931
+ const { decision } = state;
1932
+ return {
1933
+ moves: decision.convert,
1934
+ ...(decision.convert ? {} : { reason: decision.reason, detail: decision.detail, cause: state.notice?.cause ?? null, fix: state.notice?.fix ?? null }),
1935
+ lastMoveFailure: state.lastFailure,
1936
+ ownerNotifiedAt: state.hold?.notifiedAt ?? null,
1937
+ reportId: state.hold?.reportId ?? null,
1938
+ };
1939
+ }
1940
+ /** The `fleet move:` lines of `hq bot status` for a bot still on its legacy runner. */
1941
+ export function legacyStatusLines(name, state) {
1942
+ const lines = [];
1943
+ const { decision } = state;
1944
+ if (decision.convert) {
1945
+ lines.push(`fleet move: ready; it moves to the HQ Fleet Runtime at its next start (hq bot start ${name})`);
1946
+ }
1947
+ else {
1948
+ lines.push(`fleet move: ${chalk.yellow(`held (${decision.reason})`)}: ${state.notice?.cause ?? decision.detail}`);
1949
+ if (state.notice?.fix)
1950
+ lines.push(` fix: ${state.notice.fix}`);
1951
+ if (legacyHoldNotifiesOwner(decision.reason))
1952
+ lines.push(chalk.dim(` owner DM: ${state.hold?.notifiedAt ? `sent ${state.hold.notifiedAt}` : "not sent yet"}`));
1953
+ }
1954
+ if (state.lastFailure) {
1955
+ const f = state.lastFailure;
1956
+ lines.push(`last move: ${chalk.yellow(`failed ${f.at}${f.step ? ` (could not ${f.step})` : ""}${f.error ? `: ${f.error}` : ""}`)}; it tries again at its next start`);
1957
+ }
1958
+ return lines;
1959
+ }
1784
1960
  async function runBotLogs(nameArg, opts) {
1785
1961
  const { dir } = requireBot(nameArg);
1786
1962
  const file = botLogPath(dir);
@@ -2503,6 +2679,7 @@ async function runBotAuth(nameArg, opts) {
2503
2679
  if (legacy && engine) {
2504
2680
  const moved = { ...(readBotConfig(b.dir) ?? legacy), engine };
2505
2681
  writeBotConfig(b.dir, moved);
2682
+ clearFleetMoveRecords(b.dir);
2506
2683
  fs.rmSync(botCredsPath(b.dir), { force: true });
2507
2684
  createBotLogger(b.dir)("info", `moved ${b.name} to the HQ Fleet Runtime with its ${opts.provider} sign-in; its ${legacy.runtime} runner is retired`);
2508
2685
  syncFleetRuntimeJobs(b.name, b.dir, moved);
@@ -2510,8 +2687,11 @@ async function runBotAuth(nameArg, opts) {
2510
2687
  }
2511
2688
  const signedInRuntime = result.runtimeProvider === "anthropic" ? "claude" : result.runtimeProvider === "xai-oauth" ? "grok" : result.runtimeProvider === "openai-codex" ? "codex" : null;
2512
2689
  const pending = b.config.fleetAuthPending === true || legacy !== null;
2690
+ // A moved bot answers with the login it just signed in with; its legacy own-login setting no longer applies.
2691
+ const runtimeAuth = legacy && signedInRuntime && legacy.runtimeAuth?.[signedInRuntime] !== undefined ? withRuntimeAuth(legacy.runtimeAuth, signedInRuntime, null) : undefined;
2513
2692
  patchBotConfig(b.dir, {
2514
2693
  ...(signedInRuntime ? { runtime: signedInRuntime } : {}),
2694
+ ...(legacy && signedInRuntime && legacy.runtimeAuth?.[signedInRuntime] !== undefined ? { runtimeAuth } : {}),
2515
2695
  model: result.model,
2516
2696
  ...(pending ? { fleetAuthPending: false, fleetServiceInstallPending: !result.restarted, enabled: result.restarted } : {}),
2517
2697
  });
@@ -59,10 +59,49 @@ export declare class SyncSessionExpiredError extends Error {
59
59
  readonly reason?: string | undefined;
60
60
  constructor(kind: "reauth" | "unavailable", reason?: string | undefined);
61
61
  }
62
+ /** Shown when the vault rejected the session even after a forced refresh. */
63
+ export declare const SYNC_SESSION_REJECTED_MESSAGE = "The HQ server rejected your session even after it was refreshed, so the saved sign-in is no longer accepted. `hq auth status` only checks the saved expiry, not the server. Run `hq login`, then rerun the sync.";
64
+ /** Reason text when token acquisition does not settle within the bound. */
65
+ export declare const SYNC_TOKEN_TIMEOUT_REASON = "timed out waiting for the sign-in service";
66
+ /** Longest a single token acquisition or refresh may take before the sync fails. */
67
+ export declare const SYNC_TOKEN_TIMEOUT_MS = 45000;
68
+ /**
69
+ * The vault rejected the session after a forced refresh had already replaced
70
+ * the saved token. Retrying again cannot help; the user has to sign in again.
71
+ */
72
+ export declare class SyncSessionRejectedError extends Error {
73
+ constructor();
74
+ }
62
75
  export interface SyncVaultConfigDeps {
63
76
  /** Non-interactive token resolver: returns the cached token or refreshes it. */
64
77
  ensureToken?: () => Promise<string>;
78
+ /** Bound on one token acquisition (default {@link SYNC_TOKEN_TIMEOUT_MS}). */
79
+ tokenTimeoutMs?: number;
65
80
  }
81
+ export interface RejectedSessionRecoveryDeps {
82
+ /** Refresh the saved session even though it looks valid. */
83
+ forceRefresh?: () => Promise<{
84
+ refreshed: boolean;
85
+ reason?: string;
86
+ }>;
87
+ /**
88
+ * The token-getter failure recorded for the latest attempt, if any. hq-cloud
89
+ * turns a getter error (network, refresh failure, identity swap) into a plain
90
+ * 401, so a 401 with a recorded failure is not a server rejection of a token.
91
+ */
92
+ recordedFailure?: () => Error | null;
93
+ /** Runs once, after a recoverable 401 and before the refresh and rerun. */
94
+ onRetry?: () => void | Promise<void>;
95
+ }
96
+ /**
97
+ * feedback_3d752910: the vault can reject a token that still looks valid in the
98
+ * local cache (revoked or replaced server-side). The cached expiry check never
99
+ * notices, and `hq auth refresh` is a no-op for a valid-looking token, so every
100
+ * sync kept presenting the same rejected token. On a vault 401, force one real
101
+ * refresh and rerun the sync once. A second rejection is reported as a rejected
102
+ * session (sign in again), never retried again.
103
+ */
104
+ export declare function runWithRejectedSessionRecovery<T>(run: () => Promise<T>, deps?: RejectedSessionRecoveryDeps): Promise<T>;
66
105
  /** The last token failure recorded by a {@link buildSyncVaultConfig} config. */
67
106
  export declare function syncAuthFailureFor(config: VaultServiceConfig | undefined): Error | null;
68
107
  /**