@indigoai-us/hq-cli 5.116.0 → 5.117.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/CHANGELOG.md +125 -0
  2. package/dist/command-catalog.generated.d.ts +59 -1
  3. package/dist/command-catalog.generated.js +77 -1
  4. package/dist/commands/agent-kit.d.ts +23 -3
  5. package/dist/commands/agent-kit.js +110 -13
  6. package/dist/commands/agent-probe.d.ts +15 -7
  7. package/dist/commands/agent-probe.js +59 -21
  8. package/dist/commands/bot.d.ts +140 -1
  9. package/dist/commands/bot.js +757 -22
  10. package/dist/commands/dm.d.ts +10 -0
  11. package/dist/commands/dm.js +80 -0
  12. package/dist/lib/agent-kit/fallback.d.ts +63 -0
  13. package/dist/lib/agent-kit/fallback.js +129 -0
  14. package/dist/lib/agent-kit/run/inbox.d.ts +18 -6
  15. package/dist/lib/agent-kit/run/inbox.js +38 -6
  16. package/dist/lib/agent-kit/run/mesh-listener.d.ts +22 -6
  17. package/dist/lib/agent-kit/run/mesh-listener.js +44 -8
  18. package/dist/lib/agent-kit/run/supervisor.d.ts +35 -0
  19. package/dist/lib/agent-kit/run/supervisor.js +85 -0
  20. package/dist/lib/bot/api.d.ts +51 -0
  21. package/dist/lib/bot/api.js +32 -0
  22. package/dist/lib/bot/daemon.d.ts +17 -0
  23. package/dist/lib/bot/daemon.js +44 -3
  24. package/dist/lib/bot/index.d.ts +4 -0
  25. package/dist/lib/bot/index.js +4 -0
  26. package/dist/lib/bot/inflight.d.ts +14 -0
  27. package/dist/lib/bot/local-config.d.ts +70 -0
  28. package/dist/lib/bot/local-config.js +147 -0
  29. package/dist/lib/bot/local-name.d.ts +54 -0
  30. package/dist/lib/bot/local-name.js +114 -0
  31. package/dist/lib/bot/run.d.ts +9 -0
  32. package/dist/lib/bot/run.js +117 -24
  33. package/dist/lib/bot/runnable.d.ts +51 -0
  34. package/dist/lib/bot/runnable.js +65 -0
  35. package/dist/lib/bot/self-heal.d.ts +52 -0
  36. package/dist/lib/bot/self-heal.js +79 -0
  37. package/dist/lib/bot/split.d.ts +32 -0
  38. package/dist/lib/bot/split.js +241 -0
  39. package/dist/lib/mesh/live/daemon/credentials.d.ts +27 -0
  40. package/dist/lib/mesh/live/daemon/credentials.js +95 -0
  41. package/package.json +1 -1
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Which folder under ~/.hq/bots/ a cloud bot belongs to.
3
+ *
4
+ * HQ gives a bot's cloud record a slug carrying the owner's suffix: a bot
5
+ * created here as `qa-x` is `qa-x-rg13gzm4` in the cloud. Deriving the local
6
+ * name from the slug alone meant re-adopting that bot built a SECOND directory
7
+ * next to the first — `~/.hq/bots/qa-x-rg13gzm4/` beside `~/.hq/bots/qa-x/` —
8
+ * with a second LaunchAgent label and a second memory folder, while the
9
+ * original kept live machine credentials that nothing would ever clean up and
10
+ * `hq bot rm qa-x` could no longer reach.
11
+ *
12
+ * One bot, one directory. So:
13
+ *
14
+ * 1. If any directory here already carries this bot's agent uid — in its
15
+ * bot.json, or (when a wipe took the config but left the credentials) in
16
+ * its machine-creds.json — that IS the bot's directory. Reuse it and
17
+ * repair in place.
18
+ * 2. Otherwise strip the owner's suffix off the slug, so a bot created here
19
+ * comes back under the name it was created with.
20
+ * 3. Unless that name is already taken by a DIFFERENT bot, in which case the
21
+ * full slug keeps the two apart.
22
+ */
23
+ import * as fs from "node:fs";
24
+ import * as path from "node:path";
25
+ import { readBotConfig } from "./config.js";
26
+ import { readBotCredsIdentity } from "./creds.js";
27
+ import { botDir, botsRoot, isValidBotName } from "./paths.js";
28
+ /** The shortest tail that can be an owner suffix; below this, stripping is guesswork. */
29
+ const MIN_OWNER_SUFFIX = 4;
30
+ /** A cloud name as a folder name here, or null when it cannot be one. */
31
+ export function botNameFromSlug(value) {
32
+ const slug = (value ?? "")
33
+ .trim()
34
+ .toLowerCase()
35
+ .replace(/[^a-z0-9]+/g, "-")
36
+ .replace(/^-+|-+$/g, "")
37
+ .slice(0, 40)
38
+ .replace(/-+$/g, "");
39
+ return slug && isValidBotName(slug) ? slug : null;
40
+ }
41
+ /** Every bot directory on this computer, with the identity it holds. */
42
+ export function localBotDirectories(root) {
43
+ const base = root ?? botsRoot();
44
+ let entries;
45
+ try {
46
+ entries = fs.readdirSync(base, { withFileTypes: true });
47
+ }
48
+ catch {
49
+ return [];
50
+ }
51
+ const out = [];
52
+ for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
53
+ if (!entry.isDirectory())
54
+ continue;
55
+ const dir = path.join(base, entry.name);
56
+ const agentUid = readBotConfig(dir)?.agentUid ?? readBotCredsIdentity(dir)?.entityUid ?? null;
57
+ out.push({ name: entry.name, dir, agentUid });
58
+ }
59
+ return out;
60
+ }
61
+ /** The directory this identity already has here, however it is named. */
62
+ export function findLocalBotByAgentUid(agentUid, root) {
63
+ const wanted = (agentUid ?? "").trim();
64
+ if (!wanted)
65
+ return null;
66
+ return localBotDirectories(root).find((d) => d.agentUid === wanted) ?? null;
67
+ }
68
+ /**
69
+ * `qa-x-rg13gzm4` → `qa-x`, when `rg13gzm4` is this owner's suffix. The suffix
70
+ * is whatever tail of the owner's uid HQ appended, so it is recognised by
71
+ * matching the tail against the uid rather than by assuming its length.
72
+ * Anything unrecognised is left exactly as it is.
73
+ */
74
+ export function stripOwnerSuffix(slug, ownerUid) {
75
+ const owner = (ownerUid ?? "").trim().toLowerCase();
76
+ if (!owner)
77
+ return slug;
78
+ const cut = slug.lastIndexOf("-");
79
+ if (cut <= 0)
80
+ return slug;
81
+ const tail = slug.slice(cut + 1);
82
+ if (tail.length < MIN_OWNER_SUFFIX || !owner.endsWith(tail))
83
+ return slug;
84
+ const base = slug.slice(0, cut);
85
+ return isValidBotName(base) ? base : slug;
86
+ }
87
+ /**
88
+ * The folder name this cloud bot has (or should have) on this computer, or
89
+ * null when its name cannot be a folder name here at all.
90
+ *
91
+ * `ownerUid` is the signed-in account, used when the listing did not say who
92
+ * owns the bot; the listing's own `ownerUid` wins when it is there.
93
+ */
94
+ export function localNameForRemoteBot(bot, opts = {}) {
95
+ const root = opts.root;
96
+ const existing = bot.agentUid ? findLocalBotByAgentUid(bot.agentUid, root) : null;
97
+ if (existing)
98
+ return existing.name;
99
+ const slug = botNameFromSlug(bot.slug) ?? botNameFromSlug(bot.name);
100
+ if (!slug)
101
+ return null;
102
+ const stripped = stripOwnerSuffix(slug, bot.ownerUid ?? opts.ownerUid ?? null);
103
+ if (stripped === slug)
104
+ return slug;
105
+ // Two different bots may want the same short name. The one that is here
106
+ // already keeps it; the newcomer keeps its full slug, so neither is lost.
107
+ const taken = localBotDirectories(root).find((d) => d.name === stripped);
108
+ if (taken && taken.agentUid !== bot.agentUid)
109
+ return slug;
110
+ if (!taken && fs.existsSync(botDir(stripped, root)))
111
+ return slug;
112
+ return stripped;
113
+ }
114
+ //# sourceMappingURL=local-name.js.map
@@ -55,6 +55,15 @@ export declare const RECENT_MESSAGES_LIMIT = 15;
55
55
  export declare const RECENT_MESSAGE_MAX_CHARS = 400;
56
56
  /** Model turns a bot runs at once (each on a different conversation). */
57
57
  export declare const MAX_PARALLEL_TURNS = 2;
58
+ /**
59
+ * Failed attempts to deliver ONE saved message before the bot stops trying.
60
+ * A body the server refuses (too long, malformed) is refused identically every
61
+ * time, so retrying it on every 2s poll only hammers the API and leaves the
62
+ * person with no answer and no explanation.
63
+ */
64
+ export declare const RECOVERY_ATTEMPT_LIMIT = 5;
65
+ /** What the person sees when a reply could not be delivered at all. */
66
+ export declare const UNDELIVERABLE_REPLY_TEXT = "I wrote a reply but couldn't deliver it. Ask me again and I'll keep it shorter.";
58
67
  export type SleepFn = (ms: number) => Promise<void>;
59
68
  export interface BotRunDeps {
60
69
  dir: string;
@@ -50,6 +50,7 @@ import { readBotCredsIdentity } from "./creds.js";
50
50
  import { isProcessed, markProcessed, readInboxState } from "./inbox-state.js";
51
51
  import { clearInflight, patchInflight, readInflight, readInflightTurns, writeInflight } from "./inflight.js";
52
52
  import { ProgressPoster } from "./progress.js";
53
+ import { splitBody } from "./split.js";
53
54
  import { createBotLogger } from "./log.js";
54
55
  import { buildSystemPrompt, introDmText, nonOwnerRefusalText, readPromptSources } from "./prompt.js";
55
56
  import { FALLBACK_CLAUDE_PERMISSION_MODE, isClaudeBypassDisabled } from "./runtime/claude.js";
@@ -70,6 +71,15 @@ export const RECENT_MESSAGES_LIMIT = 15;
70
71
  export const RECENT_MESSAGE_MAX_CHARS = 400;
71
72
  /** Model turns a bot runs at once (each on a different conversation). */
72
73
  export const MAX_PARALLEL_TURNS = 2;
74
+ /**
75
+ * Failed attempts to deliver ONE saved message before the bot stops trying.
76
+ * A body the server refuses (too long, malformed) is refused identically every
77
+ * time, so retrying it on every 2s poll only hammers the API and leaves the
78
+ * person with no answer and no explanation.
79
+ */
80
+ export const RECOVERY_ATTEMPT_LIMIT = 5;
81
+ /** What the person sees when a reply could not be delivered at all. */
82
+ export const UNDELIVERABLE_REPLY_TEXT = "I wrote a reply but couldn't deliver it. Ask me again and I'll keep it shorter.";
73
83
  export const KICKOFF_MESSAGE_ID = "kickoff";
74
84
  /**
75
85
  * The environment a model turn's `hq` commands run in.
@@ -148,6 +158,23 @@ class FailureWindow {
148
158
  function defaultSleep(ms) {
149
159
  return new Promise((resolve) => setTimeout(resolve, ms));
150
160
  }
161
+ /**
162
+ * A body posted as several parts where an early part got through and a later
163
+ * one did not: `delivered` is how many parts the person has, so a retry starts
164
+ * where it stopped instead of repeating what they already read.
165
+ */
166
+ class PartialPostError extends Error {
167
+ parts;
168
+ delivered;
169
+ failure;
170
+ constructor(parts, delivered, failure) {
171
+ super(failure instanceof Error ? failure.message : String(failure));
172
+ this.parts = parts;
173
+ this.delivered = delivered;
174
+ this.failure = failure;
175
+ this.name = "PartialPostError";
176
+ }
177
+ }
151
178
  export function preflightCreds(dir, config) {
152
179
  const identity = readBotCredsIdentity(dir);
153
180
  if (!identity)
@@ -549,16 +576,79 @@ export async function runBot(deps) {
549
576
  }
550
577
  };
551
578
  const failureReason = (outcome) => outcome.aborted ? STOPPED_BY_OWNER_REASON : `${runtime.id} stopped with an error: ${outcome.excerpt.slice(0, 160)}`;
552
- const sendToTarget = (target, body, rootEventId) => {
579
+ const postOne = (target, body, rootEventId) => {
553
580
  const root = rootEventId ? { rootEventId } : {};
554
581
  return target.kind === "dm"
555
582
  ? api.sendDm({ toPersonUid: target.peerUid, body, ...root })
556
583
  : api.sendChannelMessage({ channelId: target.channelId, body, ...root });
557
584
  };
585
+ /**
586
+ * Post `parts` in order, starting at `from`. A failure throws a
587
+ * PartialPostError naming how many parts got through, so the caller can save
588
+ * exactly the remainder.
589
+ */
590
+ const sendParts = async (target, parts, from, rootEventId, onDelivered) => {
591
+ for (let i = Math.max(0, from); i < parts.length; i += 1) {
592
+ try {
593
+ await postOne(target, parts[i], rootEventId);
594
+ }
595
+ catch (err) {
596
+ throw new PartialPostError(parts, i, err);
597
+ }
598
+ onDelivered?.(i + 1);
599
+ }
600
+ };
601
+ /** Every outbound body goes through here, so nothing over the limit is ever posted whole. */
602
+ const sendToTarget = (target, body, rootEventId) => sendParts(target, splitBody(body), 0, rootEventId);
603
+ /**
604
+ * A `send` for one conversation that remembers how much of its last failed
605
+ * body got through, so the in-flight marker can record the remainder.
606
+ */
607
+ const trackingSend = (target, rootEventId) => {
608
+ const last = { value: null };
609
+ const send = async (body) => {
610
+ try {
611
+ await sendToTarget(target, body, rootEventId);
612
+ last.value = null;
613
+ }
614
+ catch (err) {
615
+ last.value = err instanceof PartialPostError && err.delivered > 0 ? { parts: err.parts, delivered: err.delivered } : null;
616
+ throw err;
617
+ }
618
+ };
619
+ const saved = (reply) => {
620
+ const parts = splitBody(reply);
621
+ const partial = last.value;
622
+ const sameBody = partial !== null && partial.parts.length === parts.length && partial.parts.every((p, i) => p === parts[i]);
623
+ return { reply, replyParts: parts, replyPartsDelivered: sameBody ? partial.delivered : 0 };
624
+ };
625
+ return { send, saved };
626
+ };
627
+ /** Close a marker out: clear the thinking reaction, ack the message, forget it. */
628
+ const closeMarker = async (marker, isKickoff) => {
629
+ if (marker.target.kind === "dm" && marker.reactionEventId) {
630
+ await api
631
+ .setReaction({ peerUid: marker.target.peerUid, messageId: marker.reactionEventId, emoji: THINKING_EMOJI }, false)
632
+ .catch(() => false);
633
+ }
634
+ if (!isKickoff) {
635
+ markProcessed(dir, marker.messageId, now().toISOString());
636
+ await api.ackInbox(config.agentUid, marker.messageId).catch((err) => {
637
+ log("warn", `ack for recovered ${marker.messageId} failed: ${err instanceof Error ? err.message : String(err)}`);
638
+ });
639
+ }
640
+ clearInflight(dir, marker.messageId);
641
+ };
558
642
  /**
559
643
  * A turn that was cut off by a restart (or whose answer never got posted):
560
644
  * tell the person instead of running it again. Returns false when the post
561
645
  * failed, so the marker stays for the next try.
646
+ *
647
+ * The retry is bounded. Some bodies can never be delivered (the server
648
+ * refuses them however often they are sent), and re-sending one on every
649
+ * poll used to hammer the API for ever while the person saw nothing at all.
650
+ * After RECOVERY_ATTEMPT_LIMIT consecutive failures the bot posts one short
651
+ * apology it can actually deliver, closes the marker, and stops.
562
652
  */
563
653
  const recoverInflight = async (marker) => {
564
654
  const isKickoff = marker.messageId === KICKOFF_MESSAGE_ID;
@@ -566,26 +656,26 @@ export async function runBot(deps) {
566
656
  clearInflight(dir, marker.messageId);
567
657
  return true;
568
658
  }
569
- const body = marker.reply ?? didNotFinishText(RESTARTED_REASON, (marker.progressPosted ?? 0) > 0);
659
+ const parts = marker.replyParts ?? splitBody(marker.reply ?? didNotFinishText(RESTARTED_REASON, (marker.progressPosted ?? 0) > 0));
660
+ const from = marker.replyParts ? Math.min(Math.max(0, marker.replyPartsDelivered ?? 0), parts.length) : 0;
570
661
  try {
571
- await sendToTarget(marker.target, body, marker.rootEventId);
662
+ await sendParts(marker.target, parts, from, marker.rootEventId, (count) => patchInflight(dir, marker.messageId, { replyParts: parts, replyPartsDelivered: count }));
572
663
  }
573
664
  catch (err) {
574
- log("warn", `could not tell the owner about unfinished ${marker.messageId}: ${err instanceof Error ? err.message : String(err)}`);
665
+ const reason = err instanceof Error ? err.message : String(err);
666
+ const attempt = (marker.recoveryFailures ?? 0) + 1;
667
+ if (attempt >= RECOVERY_ATTEMPT_LIMIT) {
668
+ log("warn", `gave up delivering the reply to ${marker.messageId} after ${attempt} attempts (${reason}); told the owner instead`);
669
+ await postOne(marker.target, UNDELIVERABLE_REPLY_TEXT, marker.rootEventId).catch(() => undefined);
670
+ await closeMarker(marker, isKickoff);
671
+ return true;
672
+ }
673
+ const delivered = err instanceof PartialPostError ? err.delivered : from;
674
+ patchInflight(dir, marker.messageId, { replyParts: parts, replyPartsDelivered: delivered, recoveryFailures: attempt });
675
+ log("warn", `could not tell the owner about unfinished ${marker.messageId} (attempt ${attempt}/${RECOVERY_ATTEMPT_LIMIT}): ${reason}`);
575
676
  return false;
576
677
  }
577
- if (marker.target.kind === "dm" && marker.reactionEventId) {
578
- await api
579
- .setReaction({ peerUid: marker.target.peerUid, messageId: marker.reactionEventId, emoji: THINKING_EMOJI }, false)
580
- .catch(() => false);
581
- }
582
- if (!isKickoff) {
583
- markProcessed(dir, marker.messageId, now().toISOString());
584
- await api.ackInbox(config.agentUid, marker.messageId).catch((err) => {
585
- log("warn", `ack for recovered ${marker.messageId} failed: ${err instanceof Error ? err.message : String(err)}`);
586
- });
587
- }
588
- clearInflight(dir, marker.messageId);
678
+ await closeMarker(marker, isKickoff);
589
679
  log("info", marker.reply ? `posted the saved reply for ${marker.messageId}` : `told the owner ${marker.messageId} did not finish (restart); not re-run`);
590
680
  return true;
591
681
  };
@@ -611,19 +701,20 @@ export async function runBot(deps) {
611
701
  }
612
702
  };
613
703
  const target = { kind: "dm", peerUid };
704
+ const { send, saved } = trackingSend(target);
614
705
  const { outcome, poster } = await runTurnWithProgress({
615
706
  marker: { messageId: KICKOFF_MESSAGE_ID, target, ...(thinking && introEventId ? { reactionEventId: introEventId } : {}) },
616
707
  prompt,
617
708
  sessionScope: "dm",
618
709
  tReceived,
619
- send: (body) => api.sendDm({ toPersonUid: peerUid, body }),
710
+ send,
620
711
  // The intro already said the bot is starting, and the app shows it thinking.
621
712
  workingNotice: false,
622
713
  });
623
714
  await clearThinking();
624
715
  if (outcome.ok) {
625
716
  if (poster.postedCount === 0) {
626
- patchInflight(dir, KICKOFF_MESSAGE_ID, { reply: outcome.reply });
717
+ patchInflight(dir, KICKOFF_MESSAGE_ID, saved(outcome.reply));
627
718
  log("warn", "kickoff reply failed to send; it is posted on the next start");
628
719
  return;
629
720
  }
@@ -640,7 +731,7 @@ export async function runBot(deps) {
640
731
  clearInflight(dir, KICKOFF_MESSAGE_ID);
641
732
  patchBotConfig(dir, { kickoffPendingSignIn: true });
642
733
  markSignInBroken(outcome.excerpt);
643
- await tellSignInNeeded("dm", (body) => api.sendDm({ toPersonUid: peerUid, body }), true);
734
+ await tellSignInNeeded("dm", send, true);
644
735
  return;
645
736
  }
646
737
  if (readBotConfig(dir)?.kickoffPendingSignIn)
@@ -652,7 +743,7 @@ export async function runBot(deps) {
652
743
  ? `${didNotFinishText(failureReason(outcome), poster.postedCount > 0)} Send me any message and I'll pick up from there.`
653
744
  : `Sorry — I couldn't get started on my own (${runtime.id} failed: ${outcome.excerpt.slice(0, 160)}). Send me any message and I'll pick up from there, or check that ${runtime.id} is signed in on this computer.`;
654
745
  try {
655
- await api.sendDm({ toPersonUid: peerUid, body });
746
+ await send(body);
656
747
  clearInflight(dir, KICKOFF_MESSAGE_ID);
657
748
  }
658
749
  catch {
@@ -747,7 +838,7 @@ export async function runBot(deps) {
747
838
  const sessionScope = target.kind === "room" ? target.sessionScope : "dm";
748
839
  const prompt = target.kind === "room" ? await buildRoomTurn(item, target, text) : text;
749
840
  const markerTarget = target.kind === "dm" ? { kind: "dm", peerUid: target.peerUid } : { kind: "room", channelId: target.channelId };
750
- const send = (body) => sendToTarget(markerTarget, body, item.rootEventId);
841
+ const { send, saved } = trackingSend(markerTarget, item.rootEventId);
751
842
  const { outcome, poster } = await runTurnWithProgress({
752
843
  marker: {
753
844
  messageId: id,
@@ -766,9 +857,11 @@ export async function runBot(deps) {
766
857
  });
767
858
  if (outcome.ok) {
768
859
  if (poster.postedCount === 0) {
769
- // The work finished but nothing reached the chat: keep the answer so
770
- // the next poll (or a restart) posts it instead of running again.
771
- patchInflight(dir, id, { reply: outcome.reply });
860
+ // The work finished but nothing reached the chat: keep the answer
861
+ // in the parts it will be posted as, and how many of them already got
862
+ // through — so the next poll (or a restart) delivers the remainder
863
+ // instead of running the turn again or repeating what was read.
864
+ patchInflight(dir, id, saved(outcome.reply));
772
865
  await clearThinking();
773
866
  log("warn", `reply to ${id} could not be posted; it is retried on the next poll`);
774
867
  return;
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Can this Mac run the bot HQ has under this cloud record?
3
+ *
4
+ * One predicate, asked in every place that decides: `hq bot restore`, `hq bot
5
+ * adopt` and `hq bot list --remote`. They used to ask a weaker question than
6
+ * the runtime did — restore filtered on `computeMode` alone while
7
+ * `hq bot run`'s preflight compares BOTH `computeMode` and `botKind` against
8
+ * the bot.json it is about to run (`run.ts`, "is not a <kind> local bot") — so
9
+ * the two disagreed by construction.
10
+ *
11
+ * A real account showed what that cost: a company bot came back through
12
+ * `hq bot restore`, was counted as restored with `failed: 0` and `ok: true`,
13
+ * and then failed at every start, forever, with a LaunchAgent relaunching it
14
+ * at each login. Machine credentials, a state directory and a synced memory
15
+ * folder were created for a bot that could never run.
16
+ *
17
+ * The rule here is the runtime's rule, asked before anything is written: the
18
+ * bot must run on a computer (`computeMode: "local"`), and the kind this
19
+ * installation would run it as — from the settings the cloud kept for it — must
20
+ * be the kind the cloud record says it is. A company bot whose saved settings
21
+ * do not say "company" (the case above: no saved settings at all, so the adopt
22
+ * defaults to personal) is exactly the bot the runtime refuses, and it is now
23
+ * refused here instead, before it costs anything.
24
+ */
25
+ import type { MyLocalBot } from "./api.js";
26
+ /**
27
+ * Why a bot HQ lists for this account cannot run on this computer. Contract:
28
+ * the desktop app and the `--json` consumers switch on these values.
29
+ *
30
+ * cloud-compute the record runs in HQ Cloud, not on any computer
31
+ * company-bot it acts as a company member, and nothing here can run it
32
+ * as one — the runtime would refuse it at every start
33
+ * kind-mismatch anything else the runtime's identity preflight would refuse
34
+ */
35
+ export type BotNotRunnableReason = "cloud-compute" | "company-bot" | "kind-mismatch";
36
+ export interface RemoteBotRunnability {
37
+ runnable: boolean;
38
+ reason: BotNotRunnableReason | null;
39
+ /** One plain sentence for a person, naming no `agt_` uid. Null when runnable. */
40
+ message: string | null;
41
+ }
42
+ /** The record fields the decision reads — nothing else is consulted. */
43
+ export type RemoteBotRunnabilityInput = Pick<MyLocalBot, "botKind" | "computeMode" | "localConfig">;
44
+ /**
45
+ * The one answer restore, adopt and `list --remote` all use. `name` only
46
+ * shapes the sentence; the decision itself reads the record.
47
+ */
48
+ export declare function remoteBotRunnability(bot: RemoteBotRunnabilityInput, name?: string): RemoteBotRunnability;
49
+ /** True when this cloud record is a bot this computer can actually run. */
50
+ export declare function isRemoteBotRunnableHere(bot: RemoteBotRunnabilityInput): boolean;
51
+ //# sourceMappingURL=runnable.d.ts.map
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Can this Mac run the bot HQ has under this cloud record?
3
+ *
4
+ * One predicate, asked in every place that decides: `hq bot restore`, `hq bot
5
+ * adopt` and `hq bot list --remote`. They used to ask a weaker question than
6
+ * the runtime did — restore filtered on `computeMode` alone while
7
+ * `hq bot run`'s preflight compares BOTH `computeMode` and `botKind` against
8
+ * the bot.json it is about to run (`run.ts`, "is not a <kind> local bot") — so
9
+ * the two disagreed by construction.
10
+ *
11
+ * A real account showed what that cost: a company bot came back through
12
+ * `hq bot restore`, was counted as restored with `failed: 0` and `ok: true`,
13
+ * and then failed at every start, forever, with a LaunchAgent relaunching it
14
+ * at each login. Machine credentials, a state directory and a synced memory
15
+ * folder were created for a bot that could never run.
16
+ *
17
+ * The rule here is the runtime's rule, asked before anything is written: the
18
+ * bot must run on a computer (`computeMode: "local"`), and the kind this
19
+ * installation would run it as — from the settings the cloud kept for it — must
20
+ * be the kind the cloud record says it is. A company bot whose saved settings
21
+ * do not say "company" (the case above: no saved settings at all, so the adopt
22
+ * defaults to personal) is exactly the bot the runtime refuses, and it is now
23
+ * refused here instead, before it costs anything.
24
+ */
25
+ import { effectiveBotKind } from "./config.js";
26
+ import { parseBotLocalConfig } from "./local-config.js";
27
+ import { BOT_RESTORE_DEFAULTS } from "./local-config.js";
28
+ /** The kind this installation would run the bot as, from the settings the cloud kept. */
29
+ function kindHere(bot) {
30
+ const local = parseBotLocalConfig(bot.localConfig);
31
+ return effectiveBotKind({
32
+ kind: local?.kind ?? BOT_RESTORE_DEFAULTS.kind,
33
+ ...(local?.workerId ? { workerId: local.workerId } : {}),
34
+ });
35
+ }
36
+ function sentence(reason, name) {
37
+ const subject = name ? `"${name}"` : "That bot";
38
+ if (reason === "cloud-compute")
39
+ return `${subject} runs in the cloud, not on a computer, so there is nothing to bring back here.`;
40
+ if (reason === "company-bot") {
41
+ return `${subject} is a company bot: it runs in HQ Cloud for its company, not on this Mac, so there is nothing to bring back here.`;
42
+ }
43
+ return `${subject} is not set up as the kind of bot this computer can run, so there is nothing to bring back here.`;
44
+ }
45
+ /**
46
+ * The one answer restore, adopt and `list --remote` all use. `name` only
47
+ * shapes the sentence; the decision itself reads the record.
48
+ */
49
+ export function remoteBotRunnability(bot, name) {
50
+ if ((bot.computeMode ?? "local") !== "local") {
51
+ return { runnable: false, reason: "cloud-compute", message: sentence("cloud-compute", name) };
52
+ }
53
+ const record = bot.botKind === "company" ? "company" : "personal";
54
+ const here = kindHere(bot);
55
+ if (record !== here) {
56
+ const reason = record === "company" ? "company-bot" : "kind-mismatch";
57
+ return { runnable: false, reason, message: sentence(reason, name) };
58
+ }
59
+ return { runnable: true, reason: null, message: null };
60
+ }
61
+ /** True when this cloud record is a bot this computer can actually run. */
62
+ export function isRemoteBotRunnableHere(bot) {
63
+ return remoteBotRunnability(bot).runnable;
64
+ }
65
+ //# sourceMappingURL=runnable.js.map
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Self-heal for a launchd registration that outlived its bot.
3
+ *
4
+ * `hq bot run <name>` is what the LaunchAgent runs. When the bot's local
5
+ * config is gone — a wiped ~/.hq, a half-finished reinstall, a bot removed by
6
+ * hand — the run used to exit non-zero, and KeepAlive{SuccessfulExit:false}
7
+ * with ThrottleInterval 5 relaunched it every five seconds forever (one test
8
+ * Mac logged 267 identical "No bot named" lines). So the run now does two
9
+ * things instead: it exits 0, which KeepAlive does not restart, and it removes
10
+ * its own registration, so a stale plist cannot outlive the bot it points at.
11
+ *
12
+ * Order matters here because this code usually IS the launchd job: the log
13
+ * line is written and the plist unlinked first, and the `bootout` — which
14
+ * terminates the caller — goes last, in a detached session that survives it.
15
+ *
16
+ * Everything is injectable (platform, launchctl, fs, home) so tests never
17
+ * touch the real service manager or the real LaunchAgents folder.
18
+ */
19
+ import { type BotDaemonDeps } from "./daemon.js";
20
+ export interface OrphanDaemonOutcome {
21
+ name: string;
22
+ label: string;
23
+ /** True when a registration was found and taken away. */
24
+ removed: boolean;
25
+ /** The plist (or systemd unit) path, when it could be resolved. */
26
+ dest?: string;
27
+ /** Why nothing could be removed, when that is the case. */
28
+ problem?: string;
29
+ message: string;
30
+ }
31
+ /**
32
+ * Take away the startup registration of a bot that has no local config here.
33
+ * Never throws: an unresolvable `hq` binary or an unwritable LaunchAgents
34
+ * folder must not turn into a crash loop of its own.
35
+ */
36
+ export declare function removeOrphanBotDaemon(opts: {
37
+ name: string;
38
+ botDir: string;
39
+ home?: string;
40
+ /**
41
+ * Called once the plist is gone and before the `bootout` — the last moment
42
+ * a caller that is itself the launchd job is still alive to print anything.
43
+ */
44
+ announce?: (outcome: OrphanDaemonOutcome) => void;
45
+ }, deps?: BotDaemonDeps): OrphanDaemonOutcome;
46
+ /**
47
+ * What `hq bot run <name>` prints, once, when the bot is not set up on this
48
+ * computer — plain enough that a person reading ~/.hq/bots/<name>/logs/bot.log
49
+ * knows exactly what to do next.
50
+ */
51
+ export declare function orphanBotRunMessage(name: string, outcome: OrphanDaemonOutcome): string;
52
+ //# sourceMappingURL=self-heal.d.ts.map
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Self-heal for a launchd registration that outlived its bot.
3
+ *
4
+ * `hq bot run <name>` is what the LaunchAgent runs. When the bot's local
5
+ * config is gone — a wiped ~/.hq, a half-finished reinstall, a bot removed by
6
+ * hand — the run used to exit non-zero, and KeepAlive{SuccessfulExit:false}
7
+ * with ThrottleInterval 5 relaunched it every five seconds forever (one test
8
+ * Mac logged 267 identical "No bot named" lines). So the run now does two
9
+ * things instead: it exits 0, which KeepAlive does not restart, and it removes
10
+ * its own registration, so a stale plist cannot outlive the bot it points at.
11
+ *
12
+ * Order matters here because this code usually IS the launchd job: the log
13
+ * line is written and the plist unlinked first, and the `bootout` — which
14
+ * terminates the caller — goes last, in a detached session that survives it.
15
+ *
16
+ * Everything is injectable (platform, launchctl, fs, home) so tests never
17
+ * touch the real service manager or the real LaunchAgents folder.
18
+ */
19
+ import { buildBotDaemonPaths, uninstallBotDaemon } from "./daemon.js";
20
+ import { launchdLabel } from "./paths.js";
21
+ /**
22
+ * Take away the startup registration of a bot that has no local config here.
23
+ * Never throws: an unresolvable `hq` binary or an unwritable LaunchAgents
24
+ * folder must not turn into a crash loop of its own.
25
+ */
26
+ export function removeOrphanBotDaemon(opts, deps = {}) {
27
+ const label = launchdLabel(opts.name);
28
+ const toOutcome = (result) => {
29
+ const removed = /^Removed /.test(result.message);
30
+ return {
31
+ name: opts.name,
32
+ label,
33
+ removed,
34
+ ...(result.dest ? { dest: result.dest } : {}),
35
+ message: removed
36
+ ? `Removed the startup agent ${label} so it stops relaunching.`
37
+ : `No startup agent for ${opts.name} to remove.`,
38
+ };
39
+ };
40
+ let result;
41
+ try {
42
+ const paths = buildBotDaemonPaths({
43
+ name: opts.name,
44
+ botDir: opts.botDir,
45
+ ...(opts.home ? { home: opts.home } : {}),
46
+ // The plist path depends only on the name and home; a placeholder keeps
47
+ // an environment without a resolvable `hq` on PATH from throwing here.
48
+ hqBinary: "hq",
49
+ nodeBinary: process.execPath,
50
+ });
51
+ result = uninstallBotDaemon(paths, {
52
+ ...deps,
53
+ // This runs inside the very job being removed, so the bootout has to
54
+ // outlive us: detached by default, overridable for tests and for callers
55
+ // that are not the job.
56
+ bootout: deps.bootout ?? "detached",
57
+ ...(opts.announce ? { announce: (r) => opts.announce(toOutcome(r)) } : {}),
58
+ });
59
+ }
60
+ catch (err) {
61
+ const problem = err instanceof Error ? err.message : String(err);
62
+ return { name: opts.name, label, removed: false, problem, message: `Could not remove the startup agent ${label}: ${problem}` };
63
+ }
64
+ return toOutcome(result);
65
+ }
66
+ /**
67
+ * What `hq bot run <name>` prints, once, when the bot is not set up on this
68
+ * computer — plain enough that a person reading ~/.hq/bots/<name>/logs/bot.log
69
+ * knows exactly what to do next.
70
+ */
71
+ export function orphanBotRunMessage(name, outcome) {
72
+ const lines = [
73
+ `No bot named "${name}" is set up on this computer, so there is nothing to run.`,
74
+ outcome.message,
75
+ `Bring it back with: hq bot adopt ${name} (or restore every bot you own: hq bot restore)`,
76
+ ];
77
+ return lines.join("\n");
78
+ }
79
+ //# sourceMappingURL=self-heal.js.map
@@ -0,0 +1,32 @@
1
+ /**
2
+ * A long reply, cut into posts HQ Cloud will actually accept.
3
+ *
4
+ * `POST /v1/notify/dm` refuses a body over DM_BODY_MAX characters with
5
+ * `400: Message body exceeds 4000 characters`. Nothing used to split, so a
6
+ * model answer over the limit was sent whole, refused, saved on the in-flight
7
+ * marker and re-sent identically on every poll — the answer was lost, the
8
+ * person was never told, and the bot asked the API to do the impossible about
9
+ * twice a second for as long as it ran.
10
+ *
11
+ * `splitBody` is the fix, and it is pure so it can be tested on its own:
12
+ *
13
+ * - a body at or under the limit comes back untouched, as one part
14
+ * - a longer body is cut on paragraph boundaries first, then on sentence
15
+ * boundaries, and only hard-wrapped when a single sentence is still too long
16
+ * - a fenced code block is kept whole when it fits; when it cannot fit, the
17
+ * fence is closed at the end of one part and reopened (with its language) at
18
+ * the start of the next, so neither part renders as broken markdown
19
+ * - when there is more than one part, each is prefixed `(2/3)` on its own line
20
+ * so the reader can see the order; the prefix is paid for out of the limit,
21
+ * never added on top of it
22
+ *
23
+ * Every returned part is guaranteed to be at most `max` characters long.
24
+ */
25
+ /** The largest body HQ Cloud accepts on a DM or channel post. */
26
+ export declare const DM_BODY_MAX = 4000;
27
+ /**
28
+ * One outbound body as the ordered parts to post, each at most `max`
29
+ * characters. A body that already fits comes back unchanged.
30
+ */
31
+ export declare function splitBody(body: string, max?: number): string[];
32
+ //# sourceMappingURL=split.d.ts.map