@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.
- package/CHANGELOG.md +125 -0
- package/dist/command-catalog.generated.d.ts +59 -1
- package/dist/command-catalog.generated.js +77 -1
- package/dist/commands/agent-kit.d.ts +23 -3
- package/dist/commands/agent-kit.js +110 -13
- package/dist/commands/agent-probe.d.ts +15 -7
- package/dist/commands/agent-probe.js +59 -21
- package/dist/commands/bot.d.ts +140 -1
- package/dist/commands/bot.js +757 -22
- package/dist/commands/dm.d.ts +10 -0
- package/dist/commands/dm.js +80 -0
- package/dist/lib/agent-kit/fallback.d.ts +63 -0
- package/dist/lib/agent-kit/fallback.js +129 -0
- package/dist/lib/agent-kit/run/inbox.d.ts +18 -6
- package/dist/lib/agent-kit/run/inbox.js +38 -6
- package/dist/lib/agent-kit/run/mesh-listener.d.ts +22 -6
- package/dist/lib/agent-kit/run/mesh-listener.js +44 -8
- package/dist/lib/agent-kit/run/supervisor.d.ts +35 -0
- package/dist/lib/agent-kit/run/supervisor.js +85 -0
- package/dist/lib/bot/api.d.ts +51 -0
- package/dist/lib/bot/api.js +32 -0
- package/dist/lib/bot/daemon.d.ts +17 -0
- package/dist/lib/bot/daemon.js +44 -3
- package/dist/lib/bot/index.d.ts +4 -0
- package/dist/lib/bot/index.js +4 -0
- package/dist/lib/bot/inflight.d.ts +14 -0
- package/dist/lib/bot/local-config.d.ts +70 -0
- package/dist/lib/bot/local-config.js +147 -0
- package/dist/lib/bot/local-name.d.ts +54 -0
- package/dist/lib/bot/local-name.js +114 -0
- package/dist/lib/bot/run.d.ts +9 -0
- package/dist/lib/bot/run.js +117 -24
- package/dist/lib/bot/runnable.d.ts +51 -0
- package/dist/lib/bot/runnable.js +65 -0
- package/dist/lib/bot/self-heal.d.ts +52 -0
- package/dist/lib/bot/self-heal.js +79 -0
- package/dist/lib/bot/split.d.ts +32 -0
- package/dist/lib/bot/split.js +241 -0
- package/dist/lib/mesh/live/daemon/credentials.d.ts +27 -0
- package/dist/lib/mesh/live/daemon/credentials.js +95 -0
- 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
|
package/dist/lib/bot/run.d.ts
CHANGED
|
@@ -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;
|
package/dist/lib/bot/run.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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,
|
|
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",
|
|
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
|
|
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
|
|
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
|
|
770
|
-
// the
|
|
771
|
-
|
|
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
|