@indigoai-us/hq-cli 5.116.0 → 5.117.0
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 +97 -0
- package/dist/command-catalog.generated.d.ts +58 -0
- package/dist/command-catalog.generated.js +76 -0
- package/dist/commands/bot.d.ts +140 -1
- package/dist/commands/bot.js +757 -22
- 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/package.json +1 -1
package/dist/lib/bot/api.d.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* existing Cognito machinery. `fetch` is injectable for tests.
|
|
7
7
|
*/
|
|
8
8
|
import { vaultApiFetch } from "../../utils/vault-api.js";
|
|
9
|
+
import { type BotLocalConfig } from "./local-config.js";
|
|
9
10
|
export type TokenSupplier = () => Promise<string>;
|
|
10
11
|
/**
|
|
11
12
|
* One agent-inbox item. DMs carry the base fields; room (channel / group chat)
|
|
@@ -55,6 +56,31 @@ export interface AgentRecord {
|
|
|
55
56
|
} | null;
|
|
56
57
|
};
|
|
57
58
|
}
|
|
59
|
+
/**
|
|
60
|
+
* One of the caller's own local bots, as `GET /v1/agents/mine?local=1` lists
|
|
61
|
+
* them — enough to tell a bot that is missing from this machine from one that
|
|
62
|
+
* is already here, and to rebuild it.
|
|
63
|
+
*/
|
|
64
|
+
export interface MyLocalBot {
|
|
65
|
+
agentUid: string;
|
|
66
|
+
slug?: string;
|
|
67
|
+
name?: string;
|
|
68
|
+
botKind?: "personal" | "company";
|
|
69
|
+
computeMode?: string;
|
|
70
|
+
ownerUid?: string;
|
|
71
|
+
online?: boolean;
|
|
72
|
+
lastHeartbeatAt?: string | null;
|
|
73
|
+
localConfig?: unknown;
|
|
74
|
+
}
|
|
75
|
+
/** What the credential re-issue route hands back (a rotated machine secret). */
|
|
76
|
+
export interface ReissuedBotCredentials {
|
|
77
|
+
agent: AgentRecord;
|
|
78
|
+
identity: {
|
|
79
|
+
cognitoUsername: string;
|
|
80
|
+
secret: string;
|
|
81
|
+
};
|
|
82
|
+
localConfig: unknown;
|
|
83
|
+
}
|
|
58
84
|
export declare class BotApiError extends Error {
|
|
59
85
|
readonly status: number;
|
|
60
86
|
readonly path: string;
|
|
@@ -194,6 +220,12 @@ export declare class BotApi {
|
|
|
194
220
|
/** Company bots: the company it acts for, plus any further companies. The server makes it a member of each. */
|
|
195
221
|
companyUid?: string;
|
|
196
222
|
companyMemberships?: string[];
|
|
223
|
+
/**
|
|
224
|
+
* The bot's non-secret settings, kept by the cloud so a reinstall can
|
|
225
|
+
* rebuild it. Checked here too: the server 400s on any key that looks
|
|
226
|
+
* like a credential.
|
|
227
|
+
*/
|
|
228
|
+
localConfig?: BotLocalConfig;
|
|
197
229
|
}): Promise<{
|
|
198
230
|
agent: AgentRecord;
|
|
199
231
|
identity: {
|
|
@@ -201,6 +233,25 @@ export declare class BotApi {
|
|
|
201
233
|
secret: string;
|
|
202
234
|
};
|
|
203
235
|
}>;
|
|
236
|
+
/**
|
|
237
|
+
* POST /v1/agents/{uid}/credentials — owner-only, local bots only: mint a
|
|
238
|
+
* fresh machine secret for a bot this account already owns, so it can come
|
|
239
|
+
* back after a reinstall or move to another Mac.
|
|
240
|
+
*
|
|
241
|
+
* This ROTATES the secret: whatever machine held the bot before stops being
|
|
242
|
+
* able to authenticate as it (one machine runs a local bot at a time).
|
|
243
|
+
*/
|
|
244
|
+
reissueCredentials(agentUid: string, input?: {
|
|
245
|
+
machineId?: string;
|
|
246
|
+
reason?: "reinstall" | "move";
|
|
247
|
+
}): Promise<ReissuedBotCredentials>;
|
|
248
|
+
/**
|
|
249
|
+
* GET /v1/agents/mine — the caller's own agents; `local: true` narrows it to
|
|
250
|
+
* the local bots they own in any company, which is what a restore works from.
|
|
251
|
+
*/
|
|
252
|
+
listMine(opts?: {
|
|
253
|
+
local?: boolean;
|
|
254
|
+
}): Promise<MyLocalBot[]>;
|
|
204
255
|
/** DELETE /v1/agents/{uid} — owner tears the identity down. */
|
|
205
256
|
deleteLocalBot(agentUid: string): Promise<{
|
|
206
257
|
uid: string;
|
package/dist/lib/bot/api.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* existing Cognito machinery. `fetch` is injectable for tests.
|
|
7
7
|
*/
|
|
8
8
|
import { vaultApiFetch } from "../../utils/vault-api.js";
|
|
9
|
+
import { assertNoSecretKeys } from "./local-config.js";
|
|
9
10
|
export class BotApiError extends Error {
|
|
10
11
|
status;
|
|
11
12
|
path;
|
|
@@ -186,6 +187,8 @@ export class BotApi {
|
|
|
186
187
|
* "company" is evaluated as itself through its memberships). Default personal.
|
|
187
188
|
*/
|
|
188
189
|
async createLocalBot(input) {
|
|
190
|
+
if (input.localConfig)
|
|
191
|
+
assertNoSecretKeys(input.localConfig);
|
|
189
192
|
const { body } = await this.call("/v1/agents", {
|
|
190
193
|
method: "POST",
|
|
191
194
|
body: {
|
|
@@ -196,10 +199,39 @@ export class BotApi {
|
|
|
196
199
|
...(input.companyUid ? { companyUid: input.companyUid } : {}),
|
|
197
200
|
...(input.companyMemberships?.length ? { companyMemberships: input.companyMemberships } : {}),
|
|
198
201
|
role: "local-bot",
|
|
202
|
+
...(input.localConfig ? { localConfig: input.localConfig } : {}),
|
|
199
203
|
},
|
|
200
204
|
});
|
|
201
205
|
return body;
|
|
202
206
|
}
|
|
207
|
+
/**
|
|
208
|
+
* POST /v1/agents/{uid}/credentials — owner-only, local bots only: mint a
|
|
209
|
+
* fresh machine secret for a bot this account already owns, so it can come
|
|
210
|
+
* back after a reinstall or move to another Mac.
|
|
211
|
+
*
|
|
212
|
+
* This ROTATES the secret: whatever machine held the bot before stops being
|
|
213
|
+
* able to authenticate as it (one machine runs a local bot at a time).
|
|
214
|
+
*/
|
|
215
|
+
async reissueCredentials(agentUid, input = {}) {
|
|
216
|
+
const { body } = await this.call(`/v1/agents/${encodeURIComponent(agentUid)}/credentials`, {
|
|
217
|
+
method: "POST",
|
|
218
|
+
body: {
|
|
219
|
+
...(input.machineId ? { machineId: input.machineId } : {}),
|
|
220
|
+
...(input.reason ? { reason: input.reason } : {}),
|
|
221
|
+
},
|
|
222
|
+
}, [200]);
|
|
223
|
+
return { agent: body.agent, identity: body.identity, localConfig: body.localConfig ?? null };
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* GET /v1/agents/mine — the caller's own agents; `local: true` narrows it to
|
|
227
|
+
* the local bots they own in any company, which is what a restore works from.
|
|
228
|
+
*/
|
|
229
|
+
async listMine(opts = {}) {
|
|
230
|
+
const { body } = await this.call("/v1/agents/mine", {
|
|
231
|
+
...(opts.local ? { query: { local: "1" } } : {}),
|
|
232
|
+
});
|
|
233
|
+
return Array.isArray(body.agents) ? body.agents : [];
|
|
234
|
+
}
|
|
203
235
|
/** DELETE /v1/agents/{uid} — owner tears the identity down. */
|
|
204
236
|
async deleteLocalBot(agentUid) {
|
|
205
237
|
const { body } = await this.call(`/v1/agents/${encodeURIComponent(agentUid)}`, { method: "DELETE" });
|
package/dist/lib/bot/daemon.d.ts
CHANGED
|
@@ -63,6 +63,23 @@ export interface BotDaemonDeps {
|
|
|
63
63
|
fsImpl?: Pick<typeof fs, "existsSync" | "writeFileSync" | "mkdirSync" | "unlinkSync">;
|
|
64
64
|
/** Blocking sleep, injectable for tests. */
|
|
65
65
|
sleepMs?: (ms: number) => void;
|
|
66
|
+
/**
|
|
67
|
+
* How `uninstallBotDaemon` issues its final `launchctl bootout`.
|
|
68
|
+
*
|
|
69
|
+
* "inline" (default) runs it in this process, which is right when something
|
|
70
|
+
* other than the job itself is doing the removing. "detached" hands it to a
|
|
71
|
+
* short-lived grandchild in its own session instead, which is the only way
|
|
72
|
+
* that works when the caller IS the launchd job being booted out: `bootout`
|
|
73
|
+
* kills the job, so an inline call never returns.
|
|
74
|
+
*/
|
|
75
|
+
bootout?: "inline" | "detached";
|
|
76
|
+
/** Injectable detached spawn, so tests never reach the real service manager. */
|
|
77
|
+
spawnDetached?: (command: string, args: string[]) => void;
|
|
78
|
+
/**
|
|
79
|
+
* Called once the removal is durable (plist gone) but before the bootout —
|
|
80
|
+
* the last moment a self-booting-out caller is still alive to say anything.
|
|
81
|
+
*/
|
|
82
|
+
announce?: (result: BotDaemonResult) => void;
|
|
66
83
|
}
|
|
67
84
|
/** Write the unit and load it (RunAtLoad starts the bot immediately). */
|
|
68
85
|
export declare function installBotDaemon(p: BotDaemonPaths, deps?: BotDaemonDeps): BotDaemonResult;
|
package/dist/lib/bot/daemon.js
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
import * as fs from "node:fs";
|
|
13
13
|
import * as os from "node:os";
|
|
14
14
|
import * as path from "node:path";
|
|
15
|
+
import { spawn } from "node:child_process";
|
|
15
16
|
import { resolveHqBinary, resolveNodeBinary } from "../mesh/live/daemon/install.js";
|
|
16
17
|
import { botLogPath, launchdLabel } from "./paths.js";
|
|
17
18
|
export function detectBotPlatform(platform = process.platform) {
|
|
@@ -142,6 +143,31 @@ export function launchdDomain(uid = os.userInfo().uid) {
|
|
|
142
143
|
function blockingSleep(ms) {
|
|
143
144
|
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
144
145
|
}
|
|
146
|
+
function shQuote(value) {
|
|
147
|
+
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
148
|
+
}
|
|
149
|
+
/** Detached `launchctl bootout` that outlives the process asking for it. */
|
|
150
|
+
function defaultSpawnDetached(command, args) {
|
|
151
|
+
const child = spawn(command, args, { detached: true, stdio: "ignore" });
|
|
152
|
+
child.unref();
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Take the job out of the launchd session.
|
|
156
|
+
*
|
|
157
|
+
* When `deps.bootout` is "detached" the call is handed to a new session
|
|
158
|
+
* (`sh -c 'sleep 1; exec launchctl bootout …'`) so the caller can be the job
|
|
159
|
+
* being removed: the caller exits 0 first — which KeepAlive{SuccessfulExit:
|
|
160
|
+
* false} leaves alone — and the grandchild, in a session launchd is not
|
|
161
|
+
* tearing down, finishes the job a second later.
|
|
162
|
+
*/
|
|
163
|
+
function bootoutLaunchd(p, deps) {
|
|
164
|
+
const target = `${launchdDomain(deps.uid)}/${p.label}`;
|
|
165
|
+
if (deps.bootout === "detached") {
|
|
166
|
+
(deps.spawnDetached ?? defaultSpawnDetached)("/bin/sh", ["-c", `sleep 1; exec launchctl bootout ${shQuote(target)}`]);
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
deps.launchctl?.(["bootout", target]);
|
|
170
|
+
}
|
|
145
171
|
/**
|
|
146
172
|
* `launchctl bootout` returns before the job is actually gone; a `bootstrap`
|
|
147
173
|
* issued right away fails ("service already loaded" / EIO) and the old job
|
|
@@ -174,6 +200,11 @@ export function installBotDaemon(p, deps = {}) {
|
|
|
174
200
|
io.mkdirSync(path.dirname(p.logPath), { recursive: true, mode: 0o700 });
|
|
175
201
|
if (platform === "darwin") {
|
|
176
202
|
io.mkdirSync(path.dirname(p.plistPath), { recursive: true });
|
|
203
|
+
// Replace rather than overwrite, so re-adopting a bot over the stale plist
|
|
204
|
+
// a wiped ~/.hq left behind lands a fresh file at 0600 (writeFileSync
|
|
205
|
+
// keeps the mode an existing file already has).
|
|
206
|
+
if (io.existsSync(p.plistPath))
|
|
207
|
+
io.unlinkSync(p.plistPath);
|
|
177
208
|
io.writeFileSync(p.plistPath, renderBotLaunchdPlist(p), { mode: 0o600 });
|
|
178
209
|
let loaded = false;
|
|
179
210
|
if (deps.launchctl) {
|
|
@@ -224,11 +255,16 @@ export function uninstallBotDaemon(p, deps = {}) {
|
|
|
224
255
|
const platform = deps.platform ?? detectBotPlatform();
|
|
225
256
|
const io = deps.fsImpl ?? fs;
|
|
226
257
|
if (platform === "darwin") {
|
|
227
|
-
|
|
258
|
+
// Order is load-bearing. `bootout` used to run first, which is fine from a
|
|
259
|
+
// shell and fatal from inside the job: when the orphan self-heal is itself
|
|
260
|
+
// the launchd job, bootout kills it, so the unlink below and the log line
|
|
261
|
+
// the person reads never happened — on a real Mac the stale plist survived
|
|
262
|
+
// and RunAtLoad resurrected the orphan at every login. Remove the file
|
|
263
|
+
// first, say so, and boot out last (detached when the caller is the job).
|
|
228
264
|
const existed = io.existsSync(p.plistPath);
|
|
229
265
|
if (existed)
|
|
230
266
|
io.unlinkSync(p.plistPath);
|
|
231
|
-
|
|
267
|
+
const result = {
|
|
232
268
|
platform,
|
|
233
269
|
action: "uninstall",
|
|
234
270
|
installed: false,
|
|
@@ -236,12 +272,15 @@ export function uninstallBotDaemon(p, deps = {}) {
|
|
|
236
272
|
dest: p.plistPath,
|
|
237
273
|
message: existed ? `Removed LaunchAgent ${p.label}` : `LaunchAgent ${p.label} was not installed`,
|
|
238
274
|
};
|
|
275
|
+
deps.announce?.(result);
|
|
276
|
+
bootoutLaunchd(p, deps);
|
|
277
|
+
return result;
|
|
239
278
|
}
|
|
240
279
|
if (platform === "linux") {
|
|
241
280
|
const existed = io.existsSync(p.systemdUnitPath);
|
|
242
281
|
if (existed)
|
|
243
282
|
io.unlinkSync(p.systemdUnitPath);
|
|
244
|
-
|
|
283
|
+
const result = {
|
|
245
284
|
platform,
|
|
246
285
|
action: "uninstall",
|
|
247
286
|
installed: false,
|
|
@@ -249,6 +288,8 @@ export function uninstallBotDaemon(p, deps = {}) {
|
|
|
249
288
|
dest: p.systemdUnitPath,
|
|
250
289
|
message: existed ? `Removed systemd unit for ${p.name}` : `No systemd unit for ${p.name}`,
|
|
251
290
|
};
|
|
291
|
+
deps.announce?.(result);
|
|
292
|
+
return result;
|
|
252
293
|
}
|
|
253
294
|
return { platform, action: "uninstall", installed: false, loaded: false, message: "Nothing to uninstall on this platform" };
|
|
254
295
|
}
|
package/dist/lib/bot/index.d.ts
CHANGED
|
@@ -13,4 +13,8 @@ export * from "./worker-source.js";
|
|
|
13
13
|
export * from "./company-bind.js";
|
|
14
14
|
export * from "./run.js";
|
|
15
15
|
export * from "./runtime/index.js";
|
|
16
|
+
export * from "./local-config.js";
|
|
17
|
+
export * from "./self-heal.js";
|
|
18
|
+
export * from "./runnable.js";
|
|
19
|
+
export * from "./local-name.js";
|
|
16
20
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/lib/bot/index.js
CHANGED
|
@@ -13,4 +13,8 @@ export * from "./worker-source.js";
|
|
|
13
13
|
export * from "./company-bind.js";
|
|
14
14
|
export * from "./run.js";
|
|
15
15
|
export * from "./runtime/index.js";
|
|
16
|
+
export * from "./local-config.js";
|
|
17
|
+
export * from "./self-heal.js";
|
|
18
|
+
export * from "./runnable.js";
|
|
19
|
+
export * from "./local-name.js";
|
|
16
20
|
//# sourceMappingURL=index.js.map
|
|
@@ -37,6 +37,20 @@ export interface InflightTurn {
|
|
|
37
37
|
* restart posts this instead of "did not finish".
|
|
38
38
|
*/
|
|
39
39
|
reply?: string;
|
|
40
|
+
/**
|
|
41
|
+
* The message to deliver, already cut to the body limit (split.ts). Recovery
|
|
42
|
+
* resends from `replyPartsDelivered` on, so a part the person has already
|
|
43
|
+
* read is never posted twice.
|
|
44
|
+
*/
|
|
45
|
+
replyParts?: string[];
|
|
46
|
+
/** How many of `replyParts` actually reached the chat. */
|
|
47
|
+
replyPartsDelivered?: number;
|
|
48
|
+
/**
|
|
49
|
+
* Consecutive failed attempts to deliver this marker. A body the server will
|
|
50
|
+
* never accept must not be retried for ever, so the bot gives up after
|
|
51
|
+
* RECOVERY_ATTEMPT_LIMIT and says so once (run.ts).
|
|
52
|
+
*/
|
|
53
|
+
recoveryFailures?: number;
|
|
40
54
|
}
|
|
41
55
|
export declare function botInflightPath(dir: string): string;
|
|
42
56
|
/** Every turn that was in progress, oldest first. */
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `localConfig` — the non-secret shape of a bot's bot.json that the cloud
|
|
3
|
+
* stores alongside the agent record, so a reinstall (or a second Mac) can
|
|
4
|
+
* rebuild the same bot instead of re-creating it.
|
|
5
|
+
*
|
|
6
|
+
* The server treats it as opaque, caps it at 8 KB, and refuses any value that
|
|
7
|
+
* carries a key named secret/token/password/creds (case-insensitive). The CLI
|
|
8
|
+
* therefore builds it from an explicit allow-list of bot.json fields and
|
|
9
|
+
* checks the result before it leaves this machine: a bot's machine credentials
|
|
10
|
+
* live only in ~/.hq/bots/<name>/machine-creds.json and never travel here.
|
|
11
|
+
*/
|
|
12
|
+
import { type BotConfig, type BotKind, type BotMemoryMode, type BotRuntimeId } from "./config.js";
|
|
13
|
+
/** Same limit the server enforces on the stored value. */
|
|
14
|
+
export declare const BOT_LOCAL_CONFIG_MAX_BYTES: number;
|
|
15
|
+
/** Key names the server rejects outright (case-insensitive, substring). */
|
|
16
|
+
export declare const BOT_LOCAL_CONFIG_FORBIDDEN_KEY_RE: RegExp;
|
|
17
|
+
/**
|
|
18
|
+
* Everything needed to rebuild a bot here, minus anything secret. Written on
|
|
19
|
+
* create, returned by `GET /v1/agents/mine?local=1` and by the credential
|
|
20
|
+
* re-issue route.
|
|
21
|
+
*/
|
|
22
|
+
export interface BotLocalConfig {
|
|
23
|
+
v: 1;
|
|
24
|
+
runtime?: BotRuntimeId;
|
|
25
|
+
/** "scaffold" (its own personal/workers/<name>) or "worker" (an HQ worker). */
|
|
26
|
+
workerSource?: "scaffold" | "worker";
|
|
27
|
+
workerId?: string;
|
|
28
|
+
companySlug?: string;
|
|
29
|
+
model?: string;
|
|
30
|
+
effort?: string;
|
|
31
|
+
kind?: BotKind;
|
|
32
|
+
/** Company bots: the company slugs it is a member of. */
|
|
33
|
+
companies?: string[];
|
|
34
|
+
autoApprove?: boolean;
|
|
35
|
+
/** Only the memory *kind* travels — never an absolute path from another Mac. */
|
|
36
|
+
memory?: BotMemoryMode;
|
|
37
|
+
intro?: string;
|
|
38
|
+
kickoff?: string;
|
|
39
|
+
}
|
|
40
|
+
/** Walks a value and throws when any key looks like it could carry a secret. */
|
|
41
|
+
export declare function assertNoSecretKeys(value: unknown, path?: string): void;
|
|
42
|
+
/** The bot.json fields a localConfig is built from — nothing else is read. */
|
|
43
|
+
export type BotLocalConfigSource = Pick<BotConfig, "runtime" | "workerSource" | "workerId" | "companySlug" | "model" | "effort" | "kind" | "companies" | "autoApprove" | "memoryDir" | "intro" | "kickoff">;
|
|
44
|
+
/**
|
|
45
|
+
* The non-secret settings of a bot, for the cloud to keep. Absolute paths
|
|
46
|
+
* (hqRoot, memoryDir, workerDir) are deliberately left out: they belong to the
|
|
47
|
+
* machine the bot was created on, and a restore rebuilds them locally.
|
|
48
|
+
*/
|
|
49
|
+
export declare function buildBotLocalConfig(config: BotLocalConfigSource): BotLocalConfig;
|
|
50
|
+
/**
|
|
51
|
+
* Tolerant read of whatever the server handed back: an older bot has no
|
|
52
|
+
* localConfig at all, and a field HQ no longer understands is dropped rather
|
|
53
|
+
* than losing the bot. Returns null when nothing usable was stored.
|
|
54
|
+
*/
|
|
55
|
+
export declare function parseBotLocalConfig(value: unknown): BotLocalConfig | null;
|
|
56
|
+
/** What a restore falls back to for a bot created before localConfig existed. */
|
|
57
|
+
export declare const BOT_RESTORE_DEFAULTS: {
|
|
58
|
+
runtime: BotRuntimeId;
|
|
59
|
+
memory: BotMemoryMode;
|
|
60
|
+
kind: BotKind;
|
|
61
|
+
};
|
|
62
|
+
/** Human summary of the settings a restore is about to apply. */
|
|
63
|
+
export declare function describeBotLocalConfig(local: BotLocalConfig | null): string;
|
|
64
|
+
/** Exported so callers can present the accepted values without importing config.ts. */
|
|
65
|
+
export declare const BOT_LOCAL_CONFIG_VALUES: {
|
|
66
|
+
readonly runtimes: readonly ["claude", "codex", "grok"];
|
|
67
|
+
readonly kinds: readonly ["personal", "company"];
|
|
68
|
+
readonly memoryModes: readonly ["synced", "local"];
|
|
69
|
+
};
|
|
70
|
+
//# sourceMappingURL=local-config.d.ts.map
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `localConfig` — the non-secret shape of a bot's bot.json that the cloud
|
|
3
|
+
* stores alongside the agent record, so a reinstall (or a second Mac) can
|
|
4
|
+
* rebuild the same bot instead of re-creating it.
|
|
5
|
+
*
|
|
6
|
+
* The server treats it as opaque, caps it at 8 KB, and refuses any value that
|
|
7
|
+
* carries a key named secret/token/password/creds (case-insensitive). The CLI
|
|
8
|
+
* therefore builds it from an explicit allow-list of bot.json fields and
|
|
9
|
+
* checks the result before it leaves this machine: a bot's machine credentials
|
|
10
|
+
* live only in ~/.hq/bots/<name>/machine-creds.json and never travel here.
|
|
11
|
+
*/
|
|
12
|
+
import { BOT_KINDS, BOT_MEMORY_MODES, BOT_RUNTIMES, botMemoryMode, effectiveBotCompanies, effectiveBotKind, isBotKind, isBotMemoryMode, isBotRuntimeId, } from "./config.js";
|
|
13
|
+
/** Same limit the server enforces on the stored value. */
|
|
14
|
+
export const BOT_LOCAL_CONFIG_MAX_BYTES = 8 * 1024;
|
|
15
|
+
/** Key names the server rejects outright (case-insensitive, substring). */
|
|
16
|
+
export const BOT_LOCAL_CONFIG_FORBIDDEN_KEY_RE = /secret|token|password|creds/i;
|
|
17
|
+
/** Walks a value and throws when any key looks like it could carry a secret. */
|
|
18
|
+
export function assertNoSecretKeys(value, path = "localConfig") {
|
|
19
|
+
if (Array.isArray(value)) {
|
|
20
|
+
value.forEach((item, i) => assertNoSecretKeys(item, `${path}[${i}]`));
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
if (!value || typeof value !== "object")
|
|
24
|
+
return;
|
|
25
|
+
for (const [key, child] of Object.entries(value)) {
|
|
26
|
+
if (BOT_LOCAL_CONFIG_FORBIDDEN_KEY_RE.test(key)) {
|
|
27
|
+
throw Object.assign(new Error(`${path}.${key} looks like a credential; a bot's saved settings never carry one.`), {
|
|
28
|
+
expected: true,
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
assertNoSecretKeys(child, `${path}.${key}`);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* The non-secret settings of a bot, for the cloud to keep. Absolute paths
|
|
36
|
+
* (hqRoot, memoryDir, workerDir) are deliberately left out: they belong to the
|
|
37
|
+
* machine the bot was created on, and a restore rebuilds them locally.
|
|
38
|
+
*/
|
|
39
|
+
export function buildBotLocalConfig(config) {
|
|
40
|
+
const kind = effectiveBotKind(config);
|
|
41
|
+
const companies = effectiveBotCompanies(config);
|
|
42
|
+
const local = {
|
|
43
|
+
v: 1,
|
|
44
|
+
runtime: config.runtime,
|
|
45
|
+
workerSource: config.workerSource ?? "scaffold",
|
|
46
|
+
...(config.workerId ? { workerId: config.workerId } : {}),
|
|
47
|
+
...(config.companySlug ? { companySlug: config.companySlug } : {}),
|
|
48
|
+
...(config.model ? { model: config.model } : {}),
|
|
49
|
+
...(config.effort ? { effort: config.effort } : {}),
|
|
50
|
+
kind,
|
|
51
|
+
...(kind === "company" ? { companies } : {}),
|
|
52
|
+
...(config.autoApprove === false ? { autoApprove: false } : {}),
|
|
53
|
+
memory: botMemoryMode(config),
|
|
54
|
+
...(config.intro ? { intro: config.intro } : {}),
|
|
55
|
+
...(config.kickoff ? { kickoff: config.kickoff } : {}),
|
|
56
|
+
};
|
|
57
|
+
assertNoSecretKeys(local);
|
|
58
|
+
if (Buffer.byteLength(JSON.stringify(local), "utf8") > BOT_LOCAL_CONFIG_MAX_BYTES) {
|
|
59
|
+
throw Object.assign(new Error("This bot's settings are too large to save in the cloud (over 8 KB)."), {
|
|
60
|
+
expected: true,
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
return local;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Tolerant read of whatever the server handed back: an older bot has no
|
|
67
|
+
* localConfig at all, and a field HQ no longer understands is dropped rather
|
|
68
|
+
* than losing the bot. Returns null when nothing usable was stored.
|
|
69
|
+
*/
|
|
70
|
+
export function parseBotLocalConfig(value) {
|
|
71
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
72
|
+
return null;
|
|
73
|
+
const raw = value;
|
|
74
|
+
const str = (key) => {
|
|
75
|
+
const v = raw[key];
|
|
76
|
+
return typeof v === "string" && v.trim() ? v.trim() : undefined;
|
|
77
|
+
};
|
|
78
|
+
const out = { v: 1 };
|
|
79
|
+
const runtime = str("runtime");
|
|
80
|
+
if (isBotRuntimeId(runtime))
|
|
81
|
+
out.runtime = runtime;
|
|
82
|
+
const workerSource = str("workerSource");
|
|
83
|
+
if (workerSource === "worker" || workerSource === "scaffold")
|
|
84
|
+
out.workerSource = workerSource;
|
|
85
|
+
const workerId = str("workerId");
|
|
86
|
+
if (workerId)
|
|
87
|
+
out.workerId = workerId;
|
|
88
|
+
const companySlug = str("companySlug");
|
|
89
|
+
if (companySlug)
|
|
90
|
+
out.companySlug = companySlug;
|
|
91
|
+
const model = str("model");
|
|
92
|
+
if (model)
|
|
93
|
+
out.model = model;
|
|
94
|
+
const effort = str("effort");
|
|
95
|
+
if (effort)
|
|
96
|
+
out.effort = effort;
|
|
97
|
+
const kind = str("kind");
|
|
98
|
+
if (isBotKind(kind))
|
|
99
|
+
out.kind = kind;
|
|
100
|
+
if (Array.isArray(raw.companies)) {
|
|
101
|
+
const companies = raw.companies.filter((c) => typeof c === "string" && c.trim().length > 0).map((c) => c.trim());
|
|
102
|
+
if (companies.length > 0)
|
|
103
|
+
out.companies = [...new Set(companies)];
|
|
104
|
+
}
|
|
105
|
+
if (raw.autoApprove === false)
|
|
106
|
+
out.autoApprove = false;
|
|
107
|
+
const memory = str("memory");
|
|
108
|
+
if (isBotMemoryMode(memory))
|
|
109
|
+
out.memory = memory;
|
|
110
|
+
const intro = str("intro");
|
|
111
|
+
if (intro)
|
|
112
|
+
out.intro = intro;
|
|
113
|
+
const kickoff = str("kickoff");
|
|
114
|
+
if (kickoff)
|
|
115
|
+
out.kickoff = kickoff;
|
|
116
|
+
// A record with nothing recognisable in it is the same as none at all.
|
|
117
|
+
return Object.keys(out).length > 1 ? out : null;
|
|
118
|
+
}
|
|
119
|
+
/** What a restore falls back to for a bot created before localConfig existed. */
|
|
120
|
+
export const BOT_RESTORE_DEFAULTS = {
|
|
121
|
+
runtime: "claude",
|
|
122
|
+
memory: "synced",
|
|
123
|
+
kind: "personal",
|
|
124
|
+
};
|
|
125
|
+
/** Human summary of the settings a restore is about to apply. */
|
|
126
|
+
export function describeBotLocalConfig(local) {
|
|
127
|
+
if (!local) {
|
|
128
|
+
return `defaults (${BOT_RESTORE_DEFAULTS.runtime}, ${BOT_RESTORE_DEFAULTS.memory} memory, ${BOT_RESTORE_DEFAULTS.kind})`;
|
|
129
|
+
}
|
|
130
|
+
const bits = [
|
|
131
|
+
local.runtime ?? BOT_RESTORE_DEFAULTS.runtime,
|
|
132
|
+
`${local.memory ?? BOT_RESTORE_DEFAULTS.memory} memory`,
|
|
133
|
+
local.kind ?? BOT_RESTORE_DEFAULTS.kind,
|
|
134
|
+
];
|
|
135
|
+
if (local.workerId)
|
|
136
|
+
bits.push(`worker ${local.workerId}`);
|
|
137
|
+
if (local.model)
|
|
138
|
+
bits.push(local.model);
|
|
139
|
+
return bits.join(", ");
|
|
140
|
+
}
|
|
141
|
+
/** Exported so callers can present the accepted values without importing config.ts. */
|
|
142
|
+
export const BOT_LOCAL_CONFIG_VALUES = {
|
|
143
|
+
runtimes: BOT_RUNTIMES,
|
|
144
|
+
kinds: BOT_KINDS,
|
|
145
|
+
memoryModes: BOT_MEMORY_MODES,
|
|
146
|
+
};
|
|
147
|
+
//# sourceMappingURL=local-config.js.map
|
|
@@ -0,0 +1,54 @@
|
|
|
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 type { MyLocalBot } from "./api.js";
|
|
24
|
+
export interface LocalBotDirectory {
|
|
25
|
+
name: string;
|
|
26
|
+
dir: string;
|
|
27
|
+
/** The identity the directory holds, from bot.json or its credentials; null when it holds neither. */
|
|
28
|
+
agentUid: string | null;
|
|
29
|
+
}
|
|
30
|
+
/** A cloud name as a folder name here, or null when it cannot be one. */
|
|
31
|
+
export declare function botNameFromSlug(value: string | undefined | null): string | null;
|
|
32
|
+
/** Every bot directory on this computer, with the identity it holds. */
|
|
33
|
+
export declare function localBotDirectories(root?: string): LocalBotDirectory[];
|
|
34
|
+
/** The directory this identity already has here, however it is named. */
|
|
35
|
+
export declare function findLocalBotByAgentUid(agentUid: string, root?: string): LocalBotDirectory | null;
|
|
36
|
+
/**
|
|
37
|
+
* `qa-x-rg13gzm4` → `qa-x`, when `rg13gzm4` is this owner's suffix. The suffix
|
|
38
|
+
* is whatever tail of the owner's uid HQ appended, so it is recognised by
|
|
39
|
+
* matching the tail against the uid rather than by assuming its length.
|
|
40
|
+
* Anything unrecognised is left exactly as it is.
|
|
41
|
+
*/
|
|
42
|
+
export declare function stripOwnerSuffix(slug: string, ownerUid: string | null | undefined): string;
|
|
43
|
+
/**
|
|
44
|
+
* The folder name this cloud bot has (or should have) on this computer, or
|
|
45
|
+
* null when its name cannot be a folder name here at all.
|
|
46
|
+
*
|
|
47
|
+
* `ownerUid` is the signed-in account, used when the listing did not say who
|
|
48
|
+
* owns the bot; the listing's own `ownerUid` wins when it is there.
|
|
49
|
+
*/
|
|
50
|
+
export declare function localNameForRemoteBot(bot: MyLocalBot, opts?: {
|
|
51
|
+
ownerUid?: string | null;
|
|
52
|
+
root?: string;
|
|
53
|
+
}): string | null;
|
|
54
|
+
//# sourceMappingURL=local-name.d.ts.map
|