@indigoai-us/hq-cli 5.345.173 → 5.345.175

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.
@@ -615,6 +615,223 @@ async function removeAction(domainArg, options) {
615
615
  console.log(displayText(warning));
616
616
  }
617
617
  }
618
+ // ---------------------------------------------------------------------------
619
+ // Company deploy domain (`base`): one domain for every app of the company
620
+ // (`<app>.<domain>`), served by hq-deploy's /api/orgs/:orgSlug/base-domain
621
+ // routes, the same ones the desktop Deployments panel uses. The flag,
622
+ // Enterprise and price checks run on the server (hq-pro), so this command
623
+ // prints the server's answer: billingBasis and monthlyPriceUsd.
624
+ /** Hard deadline for `base status --wait`, so a script never waits forever. */
625
+ export const BASE_WAIT_DEFAULT_MINUTES = 15;
626
+ export const BASE_WAIT_MAX_MINUTES = 60;
627
+ const BASE_POLL_INTERVAL_MS = 15_000;
628
+ /** Clock hooks for `base status --wait`. Tests replace these. */
629
+ export const deployDomainsClock = {
630
+ now: () => Date.now(),
631
+ sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
632
+ };
633
+ const BASE_MESSAGES = {
634
+ DOMAIN_NOT_ENTITLED: "A company deploy domain is not enabled for this company yet.",
635
+ BASE_DOMAIN_NOT_FOUND: "This company has no deploy domain. Add one with 'hq deploy domains base add <domain>'.",
636
+ BASE_DOMAIN_ALREADY_SET: "This company already has a deploy domain. Remove it first with 'hq deploy domains base remove'.",
637
+ BASE_DOMAIN_CLAIM_EXPIRED: "The claim expired before it went live. Add the domain again to get new DNS records.",
638
+ BASE_DOMAIN_CLAIMED: "That domain is already the deploy domain of another company.",
639
+ ORG_ACCESS_DENIED: "You are not a member of that company.",
640
+ };
641
+ async function baseError(response, orgSlug) {
642
+ const clone = response.clone();
643
+ const body = await readBody(clone);
644
+ const code = apiCode(body, response.status);
645
+ if (BASE_MESSAGES[code] && !SERVER_WORDED_CODES.has(code)) {
646
+ return new DeployDomainsError(BASE_MESSAGES[code], code, response.status);
647
+ }
648
+ if (response.status === 409 && !DEPLOY_MESSAGES[code]) {
649
+ const serverMessage = apiField(body, "message");
650
+ if (typeof serverMessage === "string" && serverMessage.trim()) {
651
+ return new DeployDomainsError(displayText(serverMessage), code, response.status);
652
+ }
653
+ }
654
+ return deployError(response, orgSlug);
655
+ }
656
+ /**
657
+ * The price line for a company deploy domain, from the server's answer:
658
+ * Enterprise includes it; a company with a price override (or comped) pays
659
+ * what the server says, $0 included; otherwise the server's price, falling
660
+ * back to the published premium price, then $500.
661
+ */
662
+ export function baseDomainPriceLine(body, pricing) {
663
+ const basis = body.billingBasis;
664
+ const price = typeof body.monthlyPriceUsd === "number" && body.monthlyPriceUsd >= 0 ? body.monthlyPriceUsd : undefined;
665
+ if (basis === "enterprise")
666
+ return "Included with the Enterprise plan: no monthly charge.";
667
+ if (basis === "comped")
668
+ return `This company deploy domain is included for this company: ${usd(price ?? 0)}/month.`;
669
+ if (basis === "paid") {
670
+ const published = domainsBlock(pricing)?.premiumUsd;
671
+ const amount = price ?? (typeof published === "number" && published > 0 ? published : 500);
672
+ return amount === 0
673
+ ? "This company deploy domain is $0/month for this company (price override)."
674
+ : `This company deploy domain adds ${usd(amount)}/month to the company bill.`;
675
+ }
676
+ return undefined;
677
+ }
678
+ /** The approval question for `base add`, before the server says the basis. */
679
+ export function baseAddConfirmQuestion(domain, orgSlug, pricing) {
680
+ const premium = domainsBlock(pricing)?.premiumUsd;
681
+ const price = typeof premium === "number" && premium > 0 ? `${usd(premium)}/month` : "a monthly add-on";
682
+ return `Make ${displayText(domain)} the deploy domain of '${orgSlug}', so every app serves at <app>.${displayText(domain)}? ` +
683
+ `It costs ${price}, billed to '${orgSlug}', unless the company is Enterprise or has a price override.`;
684
+ }
685
+ function baseSlug(options) {
686
+ if (options.company !== undefined)
687
+ return resolveSlug(options.company, undefined);
688
+ const binding = readLinkedApp(path.resolve(options.path ?? process.cwd()));
689
+ return resolveSlug(undefined, binding?.orgSlug);
690
+ }
691
+ function baseRoute(orgSlug, suffix = "") {
692
+ return `/api/orgs/${encodeURIComponent(orgSlug)}/base-domain${suffix}`;
693
+ }
694
+ function baseState(body) {
695
+ const certificate = body.certificate && typeof body.certificate === "object" ? body.certificate : {};
696
+ const serving = body.serving && typeof body.serving === "object" ? body.serving : {};
697
+ const status = typeof body.status === "string" ? body.status : "unknown";
698
+ const servingStatus = typeof serving.status === "string" ? serving.status : null;
699
+ return {
700
+ ...(typeof body.baseDomain === "string" ? { baseDomain: body.baseDomain } : {}),
701
+ status,
702
+ certStatus: typeof certificate.status === "string" ? certificate.status : null,
703
+ servingStatus,
704
+ servingError: typeof serving.error === "string" && serving.error ? serving.error : null,
705
+ records: dnsRecords(body.records),
706
+ live: status === "live" || servingStatus === "live",
707
+ raw: body,
708
+ };
709
+ }
710
+ function describeBaseState(state) {
711
+ if (state.live)
712
+ return "live";
713
+ if (state.status === "failed")
714
+ return "failed";
715
+ if (state.certStatus !== "ISSUED")
716
+ return `waiting for DNS validation (certificate ${state.certStatus ?? "not requested"})`;
717
+ return `certificate issued, serving ${state.servingStatus ?? "not attached yet"}`;
718
+ }
719
+ async function baseAddAction(domainArg, options) {
720
+ const domain = normalizeDomain(domainArg);
721
+ const token = await ensureCognitoToken();
722
+ const orgSlug = baseSlug(options);
723
+ const pricing = await fetchPricingBody();
724
+ await confirm(baseAddConfirmQuestion(domain, orgSlug, pricing), options.yes, "add the company deploy domain");
725
+ const response = await request(token, orgSlug, baseRoute(orgSlug), "POST", { baseDomain: domain });
726
+ if (!response.ok)
727
+ throw await baseError(response, orgSlug);
728
+ const body = await readBody(response);
729
+ const state = baseState(body);
730
+ const priceLine = baseDomainPriceLine(body, pricing);
731
+ if (options.json) {
732
+ console.log(safeJson({
733
+ ok: true,
734
+ orgSlug,
735
+ baseDomain: state.baseDomain ?? domain,
736
+ status: state.status,
737
+ certificate: state.certStatus,
738
+ records: state.records,
739
+ ...(typeof body.billingBasis === "string" ? { billingBasis: body.billingBasis } : {}),
740
+ ...(typeof body.monthlyPriceUsd === "number" ? { monthlyPriceUsd: body.monthlyPriceUsd } : {}),
741
+ ...(priceLine ? { billingMessage: priceLine } : {}),
742
+ }));
743
+ return;
744
+ }
745
+ console.log(`${displayText(domain)} is now the deploy domain of '${orgSlug}'. It is not live yet.`);
746
+ if (priceLine)
747
+ console.log(priceLine);
748
+ printDnsRecords(state.records);
749
+ if (state.records.length < 2) {
750
+ console.log("The certificate's validation record can take a few seconds to appear; 'hq deploy domains base status' prints it.");
751
+ }
752
+ console.log("When the records are in place, run: hq deploy domains base status --wait");
753
+ }
754
+ async function baseStatusAction(options) {
755
+ const token = await ensureCognitoToken();
756
+ const orgSlug = baseSlug(options);
757
+ const minutes = options.timeout === undefined ? BASE_WAIT_DEFAULT_MINUTES : Number(options.timeout);
758
+ if (!Number.isFinite(minutes) || minutes <= 0 || minutes > BASE_WAIT_MAX_MINUTES) {
759
+ throw new DeployDomainsError(`--timeout must be 1 to ${BASE_WAIT_MAX_MINUTES} minutes.`, "INVALID_TIMEOUT");
760
+ }
761
+ const deadline = deployDomainsClock.now() + minutes * 60_000;
762
+ let state;
763
+ let attempts = 0;
764
+ for (;;) {
765
+ attempts += 1;
766
+ // Refresh advances the claim itself: it re-checks the certificate and,
767
+ // once issued, attaches serving and polls it to live.
768
+ const response = await request(token, orgSlug, baseRoute(orgSlug, "/refresh"), "POST");
769
+ if (!response.ok)
770
+ throw await baseError(response, orgSlug);
771
+ state = baseState(await readBody(response));
772
+ if (state.live || state.status === "failed" || !options.wait)
773
+ break;
774
+ if (!options.json)
775
+ console.error(`${new Date(deployDomainsClock.now()).toISOString()} ${describeBaseState(state)}`);
776
+ if (deployDomainsClock.now() + BASE_POLL_INTERVAL_MS > deadline)
777
+ break;
778
+ await deployDomainsClock.sleep(BASE_POLL_INTERVAL_MS);
779
+ }
780
+ const timedOut = Boolean(options.wait) && !state.live && state.status !== "failed";
781
+ if (!state.live)
782
+ process.exitCode = state.status === "failed" ? 1 : REFRESH_NOT_SERVING_EXIT_CODE;
783
+ if (options.json) {
784
+ console.log(safeJson({
785
+ ok: true,
786
+ orgSlug,
787
+ baseDomain: state.baseDomain ?? null,
788
+ status: state.status,
789
+ live: state.live,
790
+ certificate: state.certStatus,
791
+ serving: state.servingStatus,
792
+ ...(state.servingError ? { servingError: state.servingError } : {}),
793
+ records: state.live ? [] : state.records,
794
+ attempts,
795
+ ...(timedOut ? { timedOut: true } : {}),
796
+ }));
797
+ return;
798
+ }
799
+ const name = displayText(state.baseDomain ?? "The company deploy domain");
800
+ if (state.live) {
801
+ console.log(`${name} is live. Every app of '${orgSlug}' serves at <app>.${name}.`);
802
+ return;
803
+ }
804
+ if (state.status === "failed") {
805
+ console.log(`${name} failed to go live${state.servingError ? `: ${displayText(state.servingError)}` : "."} Remove it and add it again.`);
806
+ return;
807
+ }
808
+ console.log(`${name} is not live yet: ${describeBaseState(state)}.`);
809
+ printDnsRecords(state.records);
810
+ console.log(timedOut
811
+ ? `Stopped waiting after ${minutes} minutes. Run 'hq deploy domains base status --wait' again to keep waiting.`
812
+ : "Run 'hq deploy domains base status --wait' to wait until it is live.");
813
+ }
814
+ async function baseRemoveAction(options) {
815
+ const token = await ensureCognitoToken();
816
+ const orgSlug = baseSlug(options);
817
+ await confirm(`Remove the deploy domain of '${orgSlug}'? Every app stops serving on it, and if it is billed, its monthly charge stops.`, options.yes, "remove the company deploy domain");
818
+ const response = await request(token, orgSlug, baseRoute(orgSlug), "DELETE");
819
+ if (!response.ok)
820
+ throw await baseError(response, orgSlug);
821
+ const body = await readBody(response);
822
+ const removed = body.removed === true;
823
+ const billingRemovalPending = body.billingRemovalPending === true;
824
+ const warning = typeof body.warning === "string" ? body.warning : undefined;
825
+ if (options.json) {
826
+ console.log(safeJson({ ok: true, orgSlug, removed, billingRemovalPending, ...(warning ? { warning } : {}) }));
827
+ return;
828
+ }
829
+ console.log(removed ? `Removed the deploy domain of '${orgSlug}'.` : `'${orgSlug}' had no deploy domain to remove.`);
830
+ if (warning)
831
+ console.log(displayText(warning));
832
+ else if (billingRemovalPending)
833
+ console.log("Its charge could not be stopped right away. HQ keeps retrying until the charge is removed.");
834
+ }
618
835
  async function purchaseRequest(token, companyUid, suffix, body) {
619
836
  try {
620
837
  return await vaultApiFetch({
@@ -782,6 +999,26 @@ export function registerDeployDomainsCommand(deploy) {
782
999
  .description("Unlink a custom domain from an app")
783
1000
  .option("--env <name>", "pick the environment when the domain points at more than one")
784
1001
  .option("--yes", "remove without a prompt")).action((domain, options) => run(options.json, () => removeAction(domain, options)));
1002
+ const base = domains
1003
+ .command("base")
1004
+ .description("Manage the company deploy domain: one domain for every app (<app>.<domain>)");
1005
+ const baseCommon = (command) => command
1006
+ .option("--company <slug>", "use this company instead of the linked or active company")
1007
+ .option("--path <dir>", "folder linked to an app of the company (default: current folder)")
1008
+ .option("--json", "print machine-readable JSON");
1009
+ baseCommon(base
1010
+ .command("add <domain>")
1011
+ .description("Claim a company deploy domain; prints the price and the DNS records")
1012
+ .option("--yes", "add without a prompt (the domain can add a monthly charge)")).action((domain, options) => run(options.json, () => baseAddAction(domain, options)));
1013
+ baseCommon(base
1014
+ .command("status")
1015
+ .description(`Check the company deploy domain and advance it toward live (exits ${REFRESH_NOT_SERVING_EXIT_CODE} while not live)`)
1016
+ .option("--wait", "keep checking until it is live or the timeout passes")
1017
+ .option("--timeout <minutes>", `stop waiting after this many minutes (default ${BASE_WAIT_DEFAULT_MINUTES}, at most ${BASE_WAIT_MAX_MINUTES})`)).action((options) => run(options.json, () => baseStatusAction(options)));
1018
+ baseCommon(base
1019
+ .command("remove")
1020
+ .description("Release the company deploy domain")
1021
+ .option("--yes", "remove without a prompt")).action((options) => run(options.json, () => baseRemoveAction(options)));
785
1022
  domains
786
1023
  .command("search <query>")
787
1024
  .description("Check whether a domain can be bought, and its price")
@@ -392,6 +392,13 @@ export interface CallerIdentity {
392
392
  * Pure → unit-testable.
393
393
  */
394
394
  export declare function callerFromToken(token: string): CallerIdentity;
395
+ /**
396
+ * Vault scope uid for `--attach`. Human Cognito sessions do not carry
397
+ * `custom:entityUid` (hq-pro sets that only on agent and outpost tokens), so
398
+ * JWT peek is not enough after a normal `hq login`. Fall back to the
399
+ * canonical person lookup used by `--personal` files and secrets.
400
+ */
401
+ export declare function callerUidForAttach(token: string): Promise<string>;
395
402
  /**
396
403
  * Newest-first channel history → the caller's most recent top-level eventId.
397
404
  * Replies (those with rootEventId) are skipped. Pure → unit-testable.
@@ -1,7 +1,7 @@
1
1
  import chalk from "chalk";
2
2
  import { readFileSync } from "node:fs";
3
3
  import { ensureCognitoToken } from "../utils/cognito-session.js";
4
- import { resolveDestinationCompanyUid, vaultApiFetch } from "../utils/vault-api.js";
4
+ import { resolveCallerPersonUid, resolveDestinationCompanyUid, vaultApiFetch } from "../utils/vault-api.js";
5
5
  import { isMachineIdentity } from "@indigoai-us/hq-cloud";
6
6
  import { peekIdToken } from "../utils/id-token.js";
7
7
  import { redactAgentInboxItem as redactInboxCredentials } from "../lib/agent-kit/inbox-redact.js";
@@ -792,9 +792,9 @@ async function runChannelSend(channelName, message, opts) {
792
792
  // personal channels in the caller's personal vault.
793
793
  let uploaded = [];
794
794
  if (localFiles.length > 0) {
795
- const callerUid = callerFromToken(token).personUid;
795
+ const callerUid = await callerUidForAttach(token);
796
796
  const scopeUid = channel.companyUid?.trim()
797
- ? attachmentScopeForChannel(channel, callerUid ?? "")
797
+ ? attachmentScopeForChannel(channel, callerUid)
798
798
  : personalScopeForCaller(callerUid);
799
799
  uploaded = await uploadDmAttachments({
800
800
  token,
@@ -989,7 +989,7 @@ async function runDmSend(recipient, message, rawOpts) {
989
989
  if (localFiles.length > 0) {
990
990
  // A 1:1 attachment is stored in the SENDER's personal vault under the
991
991
  // sorted participant pair; the server lets either member read it.
992
- const callerUid = callerFromToken(token).personUid;
992
+ const callerUid = await callerUidForAttach(token);
993
993
  const scopeUid = personalScopeForCaller(callerUid);
994
994
  const peerUid = await resolveDmPeerUid(token, recipient);
995
995
  uploaded = await uploadDmAttachments({
@@ -1363,6 +1363,18 @@ export function callerFromToken(token) {
1363
1363
  ...(emailNorm ? { email: emailNorm } : {}),
1364
1364
  };
1365
1365
  }
1366
+ /**
1367
+ * Vault scope uid for `--attach`. Human Cognito sessions do not carry
1368
+ * `custom:entityUid` (hq-pro sets that only on agent and outpost tokens), so
1369
+ * JWT peek is not enough after a normal `hq login`. Fall back to the
1370
+ * canonical person lookup used by `--personal` files and secrets.
1371
+ */
1372
+ export async function callerUidForAttach(token) {
1373
+ const fromJwt = callerFromToken(token).personUid;
1374
+ if (fromJwt)
1375
+ return fromJwt;
1376
+ return resolveCallerPersonUid(token, undefined, { command: "hq dm --attach" });
1377
+ }
1366
1378
  function isCallerMessage(m, me) {
1367
1379
  if (me.personUid && m.fromPersonUid === me.personUid)
1368
1380
  return true;
@@ -56,6 +56,7 @@ export interface FleetLocalInstallDeps {
56
56
  fleetHome?: string;
57
57
  fetch?: typeof fetch;
58
58
  token?: () => string;
59
+ hqReleaseLinks?: (release: FleetLocalReleasePin) => Promise<Map<string, string> | null>;
59
60
  now?: () => Date;
60
61
  release?: FleetLocalReleasePin;
61
62
  publicKey?: string;
@@ -114,6 +115,7 @@ export declare const HQ_FLEET_LOCAL_PUBLIC_KEY = "-----BEGIN PUBLIC KEY-----\nMC
114
115
  export declare function fleetHome(home?: string, env?: NodeJS.ProcessEnv): string;
115
116
  export declare function verifyFleetLocalRelease(files: FleetLocalReleaseFiles, release?: FleetLocalReleasePin, publicKey?: string): void;
116
117
  export declare function resolveFleetReleaseToken(env?: NodeJS.ProcessEnv): string;
118
+ export declare function fetchHqFleetReleaseLinks(release: FleetLocalReleasePin): Promise<Map<string, string> | null>;
117
119
  export declare const FLEET_LOCAL_METADATA_DOWNLOAD_TIMEOUT_MS = 30000;
118
120
  export declare const FLEET_LOCAL_ARCHIVE_DOWNLOAD_TIMEOUT_MS: number;
119
121
  export declare const FLEET_LOCAL_RUNTIME_DOWNLOAD_TIMEOUT_MS: number;
@@ -5,16 +5,18 @@ import * as os from "node:os";
5
5
  import * as path from "node:path";
6
6
  import { HQ_FLEET_RUNTIME_ENGINE_ID, } from "./config.js";
7
7
  import { ensureFleetPython, fleetUvEnv } from "./fleet-python.js";
8
+ import { ensureCognitoToken } from "../../utils/cognito-session.js";
9
+ import { vaultApiFetch } from "../../utils/vault-api.js";
8
10
  export const HQ_FLEET_LOCAL_RELEASE = {
9
11
  repository: "indigoai-us/hq-fleet-local",
10
- tag: "v0.4.5",
11
- version: "0.4.5",
12
- artifact: "hq-fleet-local-0.4.5.zip",
13
- artifactSha256: "5dc05b1a9c8a3fce0d748c1f642a908c4166958cf4e1c3cec8ee3799148eef80",
12
+ tag: "v0.4.6",
13
+ version: "0.4.6",
14
+ artifact: "hq-fleet-local-0.4.6.zip",
15
+ artifactSha256: "24e48887dd4450ad63eda11e61f78363b0cc53b3fc87e3fd21bfe97e4a430865",
14
16
  runtimeArtifact: "hq-fleet-runtime-abccac13bd9e.tar",
15
17
  runtimeArtifactSha256: "29bcfe718ad7fbd3636afd00623772f2a25bc84c4f6f10d8d57f38e549d528d5",
16
- manifestSha256: "ffb81d7cbb61cdd05e91208ac3eccf045789d9e068309da3164fe002991f7780",
17
- signatureSha256: "3792d43c191c65c731e9f27a003d86334b4695a43feb8ac23a7b679f79531616",
18
+ manifestSha256: "5cc06b30a724419c22f4ab116db1ae745158d33c4aa46c7be04d29639172af1d",
19
+ signatureSha256: "b7a6c771dc674423b56db36f58b5a2a2a49eb2e27b33a41206fcc228a11d1def",
18
20
  runtimeCommit: "abccac13bd9ea593b24c8cd9c0ba28d5c6050cc9",
19
21
  };
20
22
  export const HQ_FLEET_LOCAL_PUBLIC_KEY = `-----BEGIN PUBLIC KEY-----
@@ -92,9 +94,69 @@ export function resolveFleetReleaseToken(env = process.env) {
92
94
  return value;
93
95
  }
94
96
  catch {
95
- throw expectedError("HQ Fleet Local is a private Indigo preview. Sign in with GitHub CLI or set HQ_FLEET_RELEASE_GITHUB_TOKEN before creating this bot.");
97
+ throw expectedError("HQ could not provide the HQ Fleet Runtime download. Sign in with `hq login` and retry.");
96
98
  }
97
- throw expectedError("GitHub CLI returned an empty token for the private HQ Fleet Local release. Run gh auth login and retry.");
99
+ throw expectedError("HQ could not provide the HQ Fleet Runtime download. Sign in with `hq login` and retry.");
100
+ }
101
+ export async function fetchHqFleetReleaseLinks(release) {
102
+ let token;
103
+ try {
104
+ token = await ensureCognitoToken({ tokenSource: "person", interactive: false });
105
+ }
106
+ catch {
107
+ return null;
108
+ }
109
+ const response = await vaultApiFetch({
110
+ token,
111
+ path: `/v1/fleet-local/releases/${encodeURIComponent(release.version)}`,
112
+ signal: AbortSignal.timeout(FLEET_LOCAL_METADATA_DOWNLOAD_TIMEOUT_MS),
113
+ });
114
+ if (response.status === 401 || response.status === 403) {
115
+ throw expectedError("HQ refused the HQ Fleet Runtime download. Sign in with `hq login` and retry.");
116
+ }
117
+ if (response.status === 404)
118
+ return null;
119
+ if (!response.ok) {
120
+ throw expectedError(`Could not get the HQ Fleet Local ${release.tag} download: HQ returned HTTP ${response.status}.`);
121
+ }
122
+ const body = (await response.json());
123
+ const links = new Map();
124
+ for (const asset of body.assets ?? []) {
125
+ if (typeof asset.name === "string" && typeof asset.url === "string")
126
+ links.set(asset.name, asset.url);
127
+ }
128
+ return links.size > 0 ? links : null;
129
+ }
130
+ function trustedReleaseLink(url) {
131
+ try {
132
+ const parsed = new URL(url);
133
+ return parsed.protocol === "https:" && parsed.hostname.endsWith(".amazonaws.com");
134
+ }
135
+ catch {
136
+ return false;
137
+ }
138
+ }
139
+ async function downloadFleetLocalReleaseFromHq(release, fetcher, links) {
140
+ const get = async (name, timeoutMs) => {
141
+ const url = links.get(name);
142
+ if (!url)
143
+ throw expectedError(`HQ Fleet Local ${release.tag} is missing release asset ${name}.`);
144
+ if (!trustedReleaseLink(url)) {
145
+ throw expectedError(`HQ Fleet Local ${release.tag} returned an untrusted download link for ${name}.`);
146
+ }
147
+ const response = await fetcher(url, { signal: AbortSignal.timeout(timeoutMs) });
148
+ if (!response.ok) {
149
+ throw expectedError(`Could not download ${name}: HQ storage returned HTTP ${response.status}.`);
150
+ }
151
+ return Buffer.from(await response.arrayBuffer());
152
+ };
153
+ const [archive, runtimeArchive, manifest, signature] = await Promise.all([
154
+ get(release.artifact, FLEET_LOCAL_ARCHIVE_DOWNLOAD_TIMEOUT_MS),
155
+ get(release.runtimeArtifact, FLEET_LOCAL_RUNTIME_DOWNLOAD_TIMEOUT_MS),
156
+ get("release-manifest.json", FLEET_LOCAL_METADATA_DOWNLOAD_TIMEOUT_MS),
157
+ get("release-manifest.sig", FLEET_LOCAL_METADATA_DOWNLOAD_TIMEOUT_MS),
158
+ ]);
159
+ return { archive, runtimeArchive, manifest, signature };
98
160
  }
99
161
  async function responseBytes(response, label) {
100
162
  if (!response.ok) {
@@ -196,8 +258,11 @@ export async function installPinnedFleetLocalDistribution(deps = {}) {
196
258
  verifyFleetLocalRelease(current, release, publicKey);
197
259
  }
198
260
  else {
199
- const token = (deps.token ?? resolveFleetReleaseToken)();
200
- const files = await downloadFleetLocalRelease(release, deps.fetch ?? fetch, token);
261
+ const fetcher = deps.fetch ?? fetch;
262
+ const links = await (deps.hqReleaseLinks ?? fetchHqFleetReleaseLinks)(release);
263
+ const files = links
264
+ ? await downloadFleetLocalReleaseFromHq(release, fetcher, links)
265
+ : await downloadFleetLocalRelease(release, fetcher, (deps.token ?? resolveFleetReleaseToken)());
201
266
  verifyFleetLocalRelease(files, release, publicKey);
202
267
  installFiles(dir, files, release, publicKey);
203
268
  }
@@ -85,6 +85,8 @@ export declare const FAILURE_WINDOW_MS: number;
85
85
  export declare const FAILURE_LIMIT = 5;
86
86
  /** Resumed-session failures with the model CLI's unknown error, in a row, before the session is cleared. */
87
87
  export declare const RESUMED_UNKNOWN_ERROR_LIMIT = 2;
88
+ /** Wait before the one automatic re-run of a turn whose runtime crashed before it started. */
89
+ export declare const RUNTIME_CRASH_RETRY_DELAY_MS = 3000;
88
90
  export declare const MAX_ATTEMPTS_PER_MESSAGE = 3;
89
91
  export declare const THINKING_EMOJI = "\uD83D\uDC40";
90
92
  export declare const ROOM_THINKING_STATUS = "is thinking\u2026";
@@ -129,6 +129,8 @@ export const FAILURE_WINDOW_MS = 10 * 60 * 1000;
129
129
  export const FAILURE_LIMIT = 5;
130
130
  /** Resumed-session failures with the model CLI's unknown error, in a row, before the session is cleared. */
131
131
  export const RESUMED_UNKNOWN_ERROR_LIMIT = 2;
132
+ /** Wait before the one automatic re-run of a turn whose runtime crashed before it started. */
133
+ export const RUNTIME_CRASH_RETRY_DELAY_MS = 3_000;
132
134
  /** The model CLI's own message, exactly; "Unexpected token" in a stack trace is not it. */
133
135
  const RESUMED_UNKNOWN_ERROR = /an unknown error occurred \(unexpected\)/i;
134
136
  export const MAX_ATTEMPTS_PER_MESSAGE = 3;
@@ -693,7 +695,7 @@ export async function runBot(deps) {
693
695
  // nothing while their next messages wait out the hold.
694
696
  if (tell) {
695
697
  try {
696
- await tell(holdNoticeText(modelHoldMs, messagePending, cls ? runtimeFailureText(cls, runtime.id) : undefined));
698
+ await tell(holdNoticeText(modelHoldMs, messagePending, cls ? runtimeFailureText(cls, runtime.id, "message", config.name) : undefined));
697
699
  }
698
700
  catch (err) {
699
701
  log("warn", `could not say that turns are on hold: ${err instanceof Error ? err.message : String(err)}`);
@@ -1039,16 +1041,35 @@ export async function runBot(deps) {
1039
1041
  }
1040
1042
  return turn;
1041
1043
  };
1042
- const session = readBotSession(dir, config.runtime, sessionScope);
1044
+ const attemptOnce = async () => {
1045
+ const session = readBotSession(dir, config.runtime, sessionScope);
1046
+ try {
1047
+ return await runAttempt(session, await composePrompt(session));
1048
+ }
1049
+ catch (err) {
1050
+ if (!missingResumedSession(err))
1051
+ throw err;
1052
+ clearBotSession(dir, sessionScope);
1053
+ log("warn", "saved model session is gone; starting a new one with recent DM history");
1054
+ return await runAttempt(null, await composePrompt(null));
1055
+ }
1056
+ };
1043
1057
  try {
1044
- return await runAttempt(session, await composePrompt(session));
1058
+ return await attemptOnce();
1045
1059
  }
1046
1060
  catch (err) {
1047
- if (!missingResumedSession(err))
1061
+ // The coding tool's process died before the turn started (Bun's
1062
+ // "error: An unknown error occurred (Unexpected)" after a long idle,
1063
+ // October 2026). A fresh process usually works: wait a moment and run
1064
+ // the same turn once more. If that fails too, the caller's normal
1065
+ // failure path tells the person (with the Heal bot card).
1066
+ if (stream.signal?.aborted || classifyRuntimeError(err) !== "runtime-crashed")
1048
1067
  throw err;
1049
- clearBotSession(dir, sessionScope);
1050
- log("warn", "saved model session is gone; starting a new one with recent DM history");
1051
- return await runAttempt(null, await composePrompt(null));
1068
+ log("info", "runtime crashed before the turn started; retrying once");
1069
+ await sleep(RUNTIME_CRASH_RETRY_DELAY_MS, stream.signal);
1070
+ if (stream.signal?.aborted)
1071
+ throw err;
1072
+ return await attemptOnce();
1052
1073
  }
1053
1074
  };
1054
1075
  /** Failure bookkeeping shared by every model turn: window, health, status, log, stale session. */
@@ -1059,7 +1080,20 @@ export async function runBot(deps) {
1059
1080
  const count = failures.record(now().getTime());
1060
1081
  modelHealth = count >= FAILURE_LIMIT ? "failed" : "degraded";
1061
1082
  setStatus({ restarts: (readStatus()?.restarts ?? 0) + 1, recentFailures: count, lastError: excerpt.slice(0, 400) });
1062
- log("error", `model turn failed (${count}/${FAILURE_LIMIT} in window, ${classifyRuntimeError(err)}): ${excerpt}`);
1083
+ const cls = classifyRuntimeError(err);
1084
+ log("error", `model turn failed (${count}/${FAILURE_LIMIT} in window, ${cls}): ${excerpt}`);
1085
+ if (cls === "runtime-crashed" && err instanceof RuntimeError) {
1086
+ const stderr = (err.stderrFull ?? err.stderrExcerpt).slice(0, 2000);
1087
+ log("error", `runtime crash evidence: exit ${err.exitCode ?? "?"}, lived ${err.durationMs ?? "?"} ms, binary ${err.binaryPath ?? runtime.binary}; stderr: ${JSON.stringify(stderr)}; stdout head: ${JSON.stringify((err.stdoutHead ?? "").slice(0, 300))}`);
1088
+ setStatus({
1089
+ lastCrash: {
1090
+ at: now().toISOString(),
1091
+ code: err.exitCode,
1092
+ ...(err.durationMs !== undefined ? { durationMs: err.durationMs } : {}),
1093
+ stderr,
1094
+ },
1095
+ });
1096
+ }
1063
1097
  // A resumed session that no longer exists must not poison every turn: the
1064
1098
  // next turn starts fresh with the recent DM history prepended. The model
1065
1099
  // CLI's "An unknown error occurred (Unexpected)" (every turn of a
@@ -1683,7 +1717,7 @@ export async function runBot(deps) {
1683
1717
  runtime: runtime.id,
1684
1718
  botName: config.name,
1685
1719
  context: "kickoff",
1686
- ...(outcome.started && poster.postedCount > 0 ? { text: runtimeDidNotFinishText(cls, runtime.id, true) } : {}),
1720
+ ...(outcome.started && poster.postedCount > 0 ? { text: runtimeDidNotFinishText(cls, runtime.id, true, config.name) } : {}),
1687
1721
  });
1688
1722
  try {
1689
1723
  await postRepair(target, notice);
@@ -1852,7 +1886,7 @@ export async function runBot(deps) {
1852
1886
  }
1853
1887
  // A missing tool does not come back between retries: say so now.
1854
1888
  if (attempt >= MAX_ATTEMPTS_PER_MESSAGE || cls === "not-installed") {
1855
- const plain = runtimeFailureText(cls, runtime.id);
1889
+ const plain = runtimeFailureText(cls, runtime.id, "message", config.name);
1856
1890
  await sendToPerson(`I couldn't run this one. ${plain}`).catch(() => undefined);
1857
1891
  await completeJobRun(jobId, id, { status: "failed", output: jobRunOutput(plain), disposition: `${runtime.id} could not be started` });
1858
1892
  markReported("failed");
@@ -1872,12 +1906,12 @@ export async function runBot(deps) {
1872
1906
  // reports and says this real reason, not "the bot was restarted".
1873
1907
  // What the cloud stores as the run's output: plain words, never the raw
1874
1908
  // runtime text (that is in the bot log and status.json).
1875
- const jobOutput = outcome.aborted ? `${STOPPED_BY_OWNER_REASON}.` : runtimeFailureText(failureClass(outcome), runtime.id);
1909
+ const jobOutput = outcome.aborted ? `${STOPPED_BY_OWNER_REASON}.` : runtimeFailureText(failureClass(outcome), runtime.id, "message", config.name);
1876
1910
  patchInflight(dir, id, { jobFailure: { reason, output: jobOutput } });
1877
1911
  try {
1878
1912
  await sendToPerson(outcome.aborted
1879
1913
  ? didNotFinishText(reason, poster.postedCount > 0)
1880
- : runtimeDidNotFinishText(failureClass(outcome), runtime.id, poster.postedCount > 0));
1914
+ : runtimeDidNotFinishText(failureClass(outcome), runtime.id, poster.postedCount > 0, config.name));
1881
1915
  }
1882
1916
  catch (err) {
1883
1917
  // Leave it un-acked with the marker: the next poll reports it.
@@ -2381,7 +2415,7 @@ export async function runBot(deps) {
2381
2415
  runtime: runtime.id,
2382
2416
  botName: config.name,
2383
2417
  context: "message",
2384
- text: runtimeDidNotFinishText(cls, runtime.id, poster.postedCount > 0),
2418
+ text: runtimeDidNotFinishText(cls, runtime.id, poster.postedCount > 0, config.name),
2385
2419
  retryOf: item.eventId ?? id,
2386
2420
  }), item.rootEventId);
2387
2421
  }
@@ -394,12 +394,15 @@ export async function runRuntimeTurn(runtime, input, deps = {}) {
394
394
  let modelFallback = remembered ? { from: remembered.from, to: remembered.to } : undefined;
395
395
  /** The retries on supported models failed too: report the first failure (its cause is the old CLI). */
396
396
  let fallbackFailure;
397
+ let spawnedAt = nowMs();
397
398
  try {
398
399
  result = await spawnImpl(binary, args, spawnOptions);
399
400
  if (runtime.id === "grok" && result.code !== 0 && !input.signal?.aborted) {
400
401
  const retryArgs = grokRetryArgsAfterInvalidOutputFormat(args, result.stderr);
401
- if (retryArgs)
402
+ if (retryArgs) {
403
+ spawnedAt = nowMs();
402
404
  result = await spawnImpl(binary, retryArgs, spawnOptions);
405
+ }
403
406
  }
404
407
  if (remembered) {
405
408
  // A turn on the remembered model: it either still works or the memo is wrong.
@@ -439,6 +442,7 @@ export async function runRuntimeTurn(runtime, input, deps = {}) {
439
442
  continue;
440
443
  tried.push(to);
441
444
  const retryArgs = runtime.buildArgs({ ...turnInput, model: to }, { outputFile, newSessionId });
445
+ spawnedAt = nowMs();
442
446
  const retry = await spawnImpl(binary, retryArgs, spawnOptions);
443
447
  if (input.signal?.aborted) {
444
448
  // The owner stopped it: report the stop, remember nothing.
@@ -493,6 +497,11 @@ export async function runRuntimeTurn(runtime, input, deps = {}) {
493
497
  }
494
498
  }
495
499
  if (result.code !== 0 || result.signal) {
500
+ const durationMs = Math.max(0, nowMs() - spawnedAt);
501
+ const sawStreamEvent = result.stdout.split("\n").some((line) => {
502
+ const ev = parseJsonLine(line);
503
+ return ev !== null && (ev.type === "system" || ev.type === "assistant" || ev.type === "result");
504
+ });
496
505
  const reason = runtime.failureReason?.(result.stdout, result.stderr);
497
506
  // Streaming runtimes write JSON events to stdout: never show those to a
498
507
  // person as the reason. stderr, then a plain description, instead.
@@ -505,7 +514,14 @@ export async function runRuntimeTurn(runtime, input, deps = {}) {
505
514
  : result.signal
506
515
  ? `the ${runtime.binary} process was killed (${result.signal})`
507
516
  : `${runtime.binary} exited with code ${result.code}`;
508
- throw new RuntimeError(`${runtime.binary} exited with ${result.code ?? `signal ${result.signal}`}${reason ? `: ${reason.slice(0, 200)}` : ""}${fallbackFailure ? ` (${fallbackFailure})` : ""}`, reason ? reason.slice(0, 400) : fallback, result.code, !reason && stdoutTail ? { excerptIsOutputTail: true } : {});
517
+ throw new RuntimeError(`${runtime.binary} exited with ${result.code ?? `signal ${result.signal}`}${reason ? `: ${reason.slice(0, 200)}` : ""}${fallbackFailure ? ` (${fallbackFailure})` : ""}`, reason ? reason.slice(0, 400) : fallback, result.code, {
518
+ ...(!reason && stdoutTail ? { excerptIsOutputTail: true } : {}),
519
+ durationMs,
520
+ sawStreamEvent,
521
+ binaryPath: binary,
522
+ stderrFull: result.stderr.slice(0, 2000),
523
+ stdoutHead: result.stdout.slice(0, 300),
524
+ });
509
525
  }
510
526
  const parsed = runtime.parseOutput(result.stdout, { outputFile, outputFileText });
511
527
  // A turn that ran on the requested model and succeeded: nothing to remember.
@@ -128,10 +128,23 @@ export declare class RuntimeError extends Error {
128
128
  * must never decide what went wrong; only the message may.
129
129
  */
130
130
  readonly excerptIsOutputTail: boolean;
131
+ /** How long the runtime process lived, in ms, when known. */
132
+ readonly durationMs: number | undefined;
133
+ /** True when stdout carried at least one stream event (`system`, `assistant`, `result`): the turn started. */
134
+ readonly sawStreamEvent: boolean;
135
+ /** Crash evidence for the bot log: the binary spawned, full stderr and the head of stdout (bounded). */
136
+ readonly binaryPath: string | undefined;
137
+ readonly stderrFull: string | undefined;
138
+ readonly stdoutHead: string | undefined;
131
139
  constructor(message: string, stderrExcerpt: string, exitCode: number | null, opts?: {
132
140
  started?: boolean;
133
141
  aborted?: boolean;
134
142
  excerptIsOutputTail?: boolean;
143
+ durationMs?: number;
144
+ sawStreamEvent?: boolean;
145
+ binaryPath?: string;
146
+ stderrFull?: string;
147
+ stdoutHead?: string;
135
148
  });
136
149
  }
137
150
  export interface BotRuntime {