@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.
- package/CHANGELOG.md +14 -0
- package/dist/command-catalog.generated.d.ts +130 -0
- package/dist/command-catalog.generated.js +159 -0
- package/dist/commands/bot.js +3 -0
- package/dist/commands/channels.js +90 -0
- package/dist/commands/deploy-domains.d.ts +20 -0
- package/dist/commands/deploy-domains.js +237 -0
- package/dist/commands/dm.d.ts +7 -0
- package/dist/commands/dm.js +16 -4
- package/dist/lib/bot/fleet-local.d.ts +2 -0
- package/dist/lib/bot/fleet-local.js +75 -10
- package/dist/lib/bot/run.d.ts +2 -0
- package/dist/lib/bot/run.js +47 -13
- package/dist/lib/bot/runtime/index.js +18 -2
- package/dist/lib/bot/runtime/types.d.ts +13 -0
- package/dist/lib/bot/runtime/types.js +13 -0
- package/dist/lib/bot/runtime-failure.d.ts +17 -6
- package/dist/lib/bot/runtime-failure.js +54 -9
- package/dist/lib/bot/status.d.ts +10 -0
- package/package.json +7 -7
|
@@ -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")
|
package/dist/commands/dm.d.ts
CHANGED
|
@@ -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.
|
package/dist/commands/dm.js
CHANGED
|
@@ -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 =
|
|
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 =
|
|
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.
|
|
11
|
-
version: "0.4.
|
|
12
|
-
artifact: "hq-fleet-local-0.4.
|
|
13
|
-
artifactSha256: "
|
|
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: "
|
|
17
|
-
signatureSha256: "
|
|
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
|
|
97
|
+
throw expectedError("HQ could not provide the HQ Fleet Runtime download. Sign in with `hq login` and retry.");
|
|
96
98
|
}
|
|
97
|
-
throw expectedError("
|
|
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
|
|
200
|
-
const
|
|
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
|
}
|
package/dist/lib/bot/run.d.ts
CHANGED
|
@@ -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";
|
package/dist/lib/bot/run.js
CHANGED
|
@@ -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
|
|
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
|
|
1058
|
+
return await attemptOnce();
|
|
1045
1059
|
}
|
|
1046
1060
|
catch (err) {
|
|
1047
|
-
|
|
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
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
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
|
-
|
|
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,
|
|
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 {
|