@indigoai-us/hq-cli 5.122.1 → 5.123.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 +53 -0
- package/README.md +46 -0
- package/dist/command-catalog.generated.d.ts +6 -3
- package/dist/command-catalog.generated.js +7 -3
- package/dist/commands/bot.d.ts +3 -0
- package/dist/commands/bot.js +14 -2
- package/dist/commands/dm.d.ts +54 -1
- package/dist/commands/dm.js +204 -8
- package/dist/commands/whoami.d.ts +2 -2
- package/dist/commands/whoami.js +18 -19
- package/dist/lib/billing/plan-lock.d.ts +64 -69
- package/dist/lib/billing/plan-lock.js +230 -161
- package/dist/lib/bot/api.d.ts +4 -1
- package/dist/lib/bot/api.js +20 -2
- package/dist/lib/bot/daemon.d.ts +11 -1
- package/dist/lib/bot/daemon.js +44 -2
- package/dist/lib/bot/run.d.ts +19 -1
- package/dist/lib/bot/run.js +51 -6
- package/dist/lib/bot/status.d.ts +5 -0
- package/dist/lib/doctor/checks/bots.d.ts +10 -0
- package/dist/lib/doctor/checks/bots.js +149 -0
- package/dist/lib/doctor/registry.js +4 -0
- package/dist/lib/mesh/api.js +96 -28
- package/dist/lib/mesh/live/daemon/board-refresh.d.ts +4 -1
- package/dist/lib/mesh/live/daemon/board-refresh.js +71 -30
- package/dist/lib/mesh/live/daemon/credentials.d.ts +6 -1
- package/dist/lib/mesh/live/daemon/credentials.js +20 -4
- package/dist/lib/mesh/live/daemon/doctor.d.ts +2 -0
- package/dist/lib/mesh/live/daemon/doctor.js +12 -0
- package/dist/lib/mesh/live/daemon/install.d.ts +7 -0
- package/dist/lib/mesh/live/daemon/install.js +17 -1
- package/dist/lib/mesh/live/daemon/run.js +6 -1
- package/dist/lib/mesh/live/daemon/state.d.ts +3 -0
- package/dist/utils/cli-telemetry.d.ts +21 -0
- package/dist/utils/cli-telemetry.js +77 -1
- package/package.json +3 -2
package/dist/lib/bot/daemon.js
CHANGED
|
@@ -13,7 +13,7 @@ import * as fs from "node:fs";
|
|
|
13
13
|
import * as os from "node:os";
|
|
14
14
|
import * as path from "node:path";
|
|
15
15
|
import { spawn } from "node:child_process";
|
|
16
|
-
import { resolveHqBinary, resolveNodeBinary } from "../mesh/live/daemon/install.js";
|
|
16
|
+
import { resolveHqBinary, resolveNodeBinary, preferStableHqBinary } from "../mesh/live/daemon/install.js";
|
|
17
17
|
import { botLogPath, launchdLabel } from "./paths.js";
|
|
18
18
|
export function detectBotPlatform(platform = process.platform) {
|
|
19
19
|
if (platform === "darwin")
|
|
@@ -192,7 +192,49 @@ function launchdLoaded(p, deps) {
|
|
|
192
192
|
return false;
|
|
193
193
|
return deps.launchctl(["print", `${launchdDomain(deps.uid)}/${p.label}`]).ok;
|
|
194
194
|
}
|
|
195
|
-
|
|
195
|
+
export function launchdPlistProgramArguments(plist) {
|
|
196
|
+
const block = plist.match(/<key>ProgramArguments<\/key>\s*<array>([\s\S]*?)<\/array>/);
|
|
197
|
+
if (!block)
|
|
198
|
+
return [];
|
|
199
|
+
return [...block[1].matchAll(/<string>([^<]*)<\/string>/g)].map((m) => m[1]);
|
|
200
|
+
}
|
|
201
|
+
/** The hq script path baked into an installed unit, or null if none. */
|
|
202
|
+
export function installedDaemonHqBinary(p, io = fs) {
|
|
203
|
+
if (io.existsSync(p.plistPath)) {
|
|
204
|
+
const args = launchdPlistProgramArguments(io.readFileSync(p.plistPath, "utf8"));
|
|
205
|
+
return args[1] ?? null;
|
|
206
|
+
}
|
|
207
|
+
if (io.existsSync(p.systemdUnitPath)) {
|
|
208
|
+
const unit = io.readFileSync(p.systemdUnitPath, "utf8");
|
|
209
|
+
const m = unit.match(/^ExecStart=\S+\s+(\S+)/m);
|
|
210
|
+
return m?.[1] ?? null;
|
|
211
|
+
}
|
|
212
|
+
return null;
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* Rewrite the unit when its hq path is missing or is the pre-move
|
|
216
|
+
* toolchain/node/bin/hq while toolchain/npm-global/bin/hq exists.
|
|
217
|
+
*/
|
|
218
|
+
export function repairBotDaemonIfStale(p, deps = {}) {
|
|
219
|
+
const io = deps.fsImpl ?? fs;
|
|
220
|
+
let current;
|
|
221
|
+
try {
|
|
222
|
+
current = installedDaemonHqBinary(p, {
|
|
223
|
+
existsSync: (q) => io.existsSync(q),
|
|
224
|
+
readFileSync: (q) => fs.readFileSync(q, "utf8"),
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
catch {
|
|
228
|
+
return null;
|
|
229
|
+
}
|
|
230
|
+
if (!current)
|
|
231
|
+
return null;
|
|
232
|
+
const missing = !io.existsSync(current);
|
|
233
|
+
const stale = preferStableHqBinary(current) !== current;
|
|
234
|
+
if (!missing && !stale)
|
|
235
|
+
return null;
|
|
236
|
+
return installBotDaemon(p, deps);
|
|
237
|
+
}
|
|
196
238
|
export function installBotDaemon(p, deps = {}) {
|
|
197
239
|
const platform = deps.platform ?? detectBotPlatform();
|
|
198
240
|
const io = deps.fsImpl ?? fs;
|
package/dist/lib/bot/run.d.ts
CHANGED
|
@@ -38,7 +38,7 @@
|
|
|
38
38
|
* Everything with a side effect is injectable so the loop is unit-testable.
|
|
39
39
|
*/
|
|
40
40
|
import { type OwnerContext } from "./owner-context.js";
|
|
41
|
-
import type
|
|
41
|
+
import { type BotApi, type InboxItem } from "./api.js";
|
|
42
42
|
import type { BotConfig, BotKind } from "./config.js";
|
|
43
43
|
import { type ProgressPosterOptions } from "./progress.js";
|
|
44
44
|
import { type BotLogger } from "./log.js";
|
|
@@ -46,6 +46,24 @@ import { type BotRuntime, type RuntimeTurnInput, type RuntimeTurnResult } from "
|
|
|
46
46
|
import { type BotState, type BotStatusFile } from "./status.js";
|
|
47
47
|
export declare const INBOX_POLL_INTERVAL_MS = 2000;
|
|
48
48
|
export declare const HEARTBEAT_INTERVAL_MS = 30000;
|
|
49
|
+
/** First 429/5xx wait when Retry-After is absent (inbox polls at 2s). */
|
|
50
|
+
export declare const API_BACKOFF_BASE_MS = 4000;
|
|
51
|
+
/** Ceiling for exponential poll/heartbeat backoff. */
|
|
52
|
+
export declare const API_BACKOFF_CAP_MS: number;
|
|
53
|
+
/** Honor Retry-After up to this; a bogus 30-day header must not park the bot. */
|
|
54
|
+
export declare const API_RETRY_AFTER_CAP_MS: number;
|
|
55
|
+
export declare const API_RETRY_AFTER_FLOOR_MS = 1000;
|
|
56
|
+
/**
|
|
57
|
+
* How long to wait after a failed inbox poll or heartbeat.
|
|
58
|
+
* 429/5xx (and transport errors) back off exponentially, honouring Retry-After
|
|
59
|
+
* when present. Other 4xx keep the normal interval so a 401 is not stretched.
|
|
60
|
+
*/
|
|
61
|
+
export declare function apiPollDelayMs(opts: {
|
|
62
|
+
err: unknown;
|
|
63
|
+
attempt: number;
|
|
64
|
+
intervalMs: number;
|
|
65
|
+
random?: () => number;
|
|
66
|
+
}): number;
|
|
49
67
|
export declare const FAILURE_WINDOW_MS: number;
|
|
50
68
|
export declare const FAILURE_LIMIT = 5;
|
|
51
69
|
export declare const MAX_ATTEMPTS_PER_MESSAGE = 3;
|
package/dist/lib/bot/run.js
CHANGED
|
@@ -44,6 +44,7 @@ import * as fs from "node:fs";
|
|
|
44
44
|
import * as path from "node:path";
|
|
45
45
|
import { fullJitterDelayMs } from "../mesh/live/backoff.js";
|
|
46
46
|
import { acquirePidLock, defaultPidLockDeps, releasePidLock } from "../mesh/live/daemon/pid-lock.js";
|
|
47
|
+
import { BotApiError } from "./api.js";
|
|
47
48
|
import { effectiveBotCompanies, effectiveBotKind, patchBotConfig, readBotConfig, SETUP_BOT_WORKER_ID } from "./config.js";
|
|
48
49
|
import { ensureBotSessionMeta } from "./company-bind.js";
|
|
49
50
|
import { readBotCredsIdentity } from "./creds.js";
|
|
@@ -62,6 +63,35 @@ import { clearBotSession, readBotSession, writeBotSession } from "./session.js";
|
|
|
62
63
|
import { defaultBotStatus, patchBotStatus, readBotStatus } from "./status.js";
|
|
63
64
|
export const INBOX_POLL_INTERVAL_MS = 2_000;
|
|
64
65
|
export const HEARTBEAT_INTERVAL_MS = 30_000;
|
|
66
|
+
/** First 429/5xx wait when Retry-After is absent (inbox polls at 2s). */
|
|
67
|
+
export const API_BACKOFF_BASE_MS = 4_000;
|
|
68
|
+
/** Ceiling for exponential poll/heartbeat backoff. */
|
|
69
|
+
export const API_BACKOFF_CAP_MS = 5 * 60_000;
|
|
70
|
+
/** Honor Retry-After up to this; a bogus 30-day header must not park the bot. */
|
|
71
|
+
export const API_RETRY_AFTER_CAP_MS = 15 * 60_000;
|
|
72
|
+
export const API_RETRY_AFTER_FLOOR_MS = 1_000;
|
|
73
|
+
/**
|
|
74
|
+
* How long to wait after a failed inbox poll or heartbeat.
|
|
75
|
+
* 429/5xx (and transport errors) back off exponentially, honouring Retry-After
|
|
76
|
+
* when present. Other 4xx keep the normal interval so a 401 is not stretched.
|
|
77
|
+
*/
|
|
78
|
+
export function apiPollDelayMs(opts) {
|
|
79
|
+
const intervalMs = Math.max(0, opts.intervalMs);
|
|
80
|
+
const status = opts.err instanceof BotApiError ? opts.err.status : undefined;
|
|
81
|
+
const retryable = status === 429 || (typeof status === "number" && status >= 500) || !(opts.err instanceof BotApiError);
|
|
82
|
+
if (!retryable)
|
|
83
|
+
return intervalMs;
|
|
84
|
+
const retryAfter = opts.err instanceof BotApiError ? opts.err.retryAfterMs : undefined;
|
|
85
|
+
const header = typeof retryAfter === "number" && Number.isFinite(retryAfter)
|
|
86
|
+
? Math.min(API_RETRY_AFTER_CAP_MS, Math.max(API_RETRY_AFTER_FLOOR_MS, retryAfter))
|
|
87
|
+
: 0;
|
|
88
|
+
const base = Math.max(intervalMs * 2, API_BACKOFF_BASE_MS);
|
|
89
|
+
const attempt = Math.max(0, opts.attempt);
|
|
90
|
+
const exp = Math.min(API_BACKOFF_CAP_MS, base * 2 ** attempt);
|
|
91
|
+
const random = opts.random ?? Math.random;
|
|
92
|
+
const jittered = Math.floor(exp * (1 + 0.2 * random()));
|
|
93
|
+
return Math.min(API_BACKOFF_CAP_MS, Math.max(intervalMs, exp, header, jittered));
|
|
94
|
+
}
|
|
65
95
|
export const FAILURE_WINDOW_MS = 10 * 60 * 1000;
|
|
66
96
|
export const FAILURE_LIMIT = 5;
|
|
67
97
|
export const MAX_ATTEMPTS_PER_MESSAGE = 3;
|
|
@@ -187,6 +217,7 @@ export function preflightCreds(dir, config) {
|
|
|
187
217
|
export async function runBot(deps) {
|
|
188
218
|
const now = deps.now ?? (() => new Date());
|
|
189
219
|
const sleep = deps.sleep ?? defaultSleep;
|
|
220
|
+
const random = deps.random ?? Math.random;
|
|
190
221
|
const log = deps.log ?? createBotLogger(deps.dir, now);
|
|
191
222
|
const pid = deps.pid ?? process.pid;
|
|
192
223
|
const { dir, config, api, runtime } = deps;
|
|
@@ -374,18 +405,23 @@ export async function runBot(deps) {
|
|
|
374
405
|
}
|
|
375
406
|
};
|
|
376
407
|
const heartbeatLoop = (async () => {
|
|
408
|
+
let heartbeatFails = 0;
|
|
377
409
|
while (!stopping && state === "running") {
|
|
378
410
|
try {
|
|
379
411
|
// The server's component vocabulary is the fleet one (sync/model/task…);
|
|
380
412
|
// runtime → model, inbox → task, sync → sync.
|
|
381
413
|
await api.heartbeat(config.agentUid, { model: modelHealth, task: "ok", sync: "ok" });
|
|
382
|
-
|
|
414
|
+
heartbeatFails = 0;
|
|
415
|
+
setStatus({ lastHeartbeatAt: now().toISOString(), lastHeartbeatOk: true, lastHeartbeatError: undefined });
|
|
416
|
+
await sleep(heartbeatIntervalMs);
|
|
383
417
|
}
|
|
384
418
|
catch (err) {
|
|
385
|
-
|
|
386
|
-
|
|
419
|
+
heartbeatFails += 1;
|
|
420
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
421
|
+
setStatus({ lastHeartbeatOk: false, lastHeartbeatError: message.slice(0, 400) });
|
|
422
|
+
log("warn", `heartbeat failed: ${message}`);
|
|
423
|
+
await sleep(apiPollDelayMs({ err, attempt: heartbeatFails - 1, intervalMs: heartbeatIntervalMs, random }));
|
|
387
424
|
}
|
|
388
|
-
await sleep(heartbeatIntervalMs);
|
|
389
425
|
}
|
|
390
426
|
})();
|
|
391
427
|
// ── Inbox loop ─────────────────────────────────────────────────────────────
|
|
@@ -1000,6 +1036,7 @@ export async function runBot(deps) {
|
|
|
1000
1036
|
}
|
|
1001
1037
|
}
|
|
1002
1038
|
await kickoff;
|
|
1039
|
+
let inboxFails = 0;
|
|
1003
1040
|
while (!stopping && state === "running") {
|
|
1004
1041
|
if (hasPromotionHold(dir))
|
|
1005
1042
|
break;
|
|
@@ -1015,13 +1052,21 @@ export async function runBot(deps) {
|
|
|
1015
1052
|
}
|
|
1016
1053
|
try {
|
|
1017
1054
|
const items = await api.pullInbox(config.agentUid);
|
|
1018
|
-
|
|
1055
|
+
inboxFails = 0;
|
|
1056
|
+
setStatus({ lastInboxPollAt: now().toISOString(), lastInboxPollOk: true, lastInboxError: undefined });
|
|
1019
1057
|
for (const item of items)
|
|
1020
1058
|
enqueue(item);
|
|
1021
1059
|
pump();
|
|
1022
1060
|
}
|
|
1023
1061
|
catch (err) {
|
|
1024
|
-
|
|
1062
|
+
inboxFails += 1;
|
|
1063
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1064
|
+
setStatus({ lastInboxPollOk: false, lastInboxError: message.slice(0, 400) });
|
|
1065
|
+
log("warn", `inbox poll failed: ${message}`);
|
|
1066
|
+
if (stopping || state !== "running")
|
|
1067
|
+
break;
|
|
1068
|
+
await sleep(apiPollDelayMs({ err, attempt: inboxFails - 1, intervalMs: inboxIntervalMs, random }));
|
|
1069
|
+
continue;
|
|
1025
1070
|
}
|
|
1026
1071
|
if (stopping || state !== "running")
|
|
1027
1072
|
break;
|
package/dist/lib/bot/status.d.ts
CHANGED
|
@@ -17,7 +17,12 @@ export interface BotStatusFile {
|
|
|
17
17
|
recentFailures: number;
|
|
18
18
|
lastHeartbeatAt?: string;
|
|
19
19
|
lastHeartbeatOk?: boolean;
|
|
20
|
+
/** Last heartbeat error, when lastHeartbeatOk is false. */
|
|
21
|
+
lastHeartbeatError?: string;
|
|
20
22
|
lastInboxPollAt?: string;
|
|
23
|
+
lastInboxPollOk?: boolean;
|
|
24
|
+
/** Last inbox poll error, when lastInboxPollOk is false. */
|
|
25
|
+
lastInboxError?: string;
|
|
21
26
|
/** Bounded, redacted stderr excerpt of the last model-CLI failure. */
|
|
22
27
|
lastError?: string;
|
|
23
28
|
/** Inbound DMs answered since start. */
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { CheckContext, CheckFamily, CheckResult } from "../types.js";
|
|
2
|
+
export declare const BOTS_FAMILY_ID = "bots";
|
|
3
|
+
export declare const BOTS_FAMILY_TITLE = "Local bots";
|
|
4
|
+
export interface BotsDoctorDeps {
|
|
5
|
+
home: () => string;
|
|
6
|
+
root: (home?: string) => string;
|
|
7
|
+
}
|
|
8
|
+
export declare const botsFamily: CheckFamily;
|
|
9
|
+
export declare function checkBots(_context: CheckContext, deps?: BotsDoctorDeps): CheckResult[];
|
|
10
|
+
//# sourceMappingURL=bots.d.ts.map
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local-bot doctor family: a running bot that cannot poll its inbox or
|
|
3
|
+
* heartbeat still looked healthy (`hq bot status` said online, `hq doctor`
|
|
4
|
+
* had no bot check). Offline, from ~/.hq/bots and installed LaunchAgents.
|
|
5
|
+
*/
|
|
6
|
+
import * as fs from "node:fs";
|
|
7
|
+
import * as os from "node:os";
|
|
8
|
+
import * as path from "node:path";
|
|
9
|
+
import { botsRoot, botDir, launchdLabel } from "../../bot/paths.js";
|
|
10
|
+
import { readBotConfig } from "../../bot/config.js";
|
|
11
|
+
import { isPidAlive, readBotStatus } from "../../bot/status.js";
|
|
12
|
+
export const BOTS_FAMILY_ID = "bots";
|
|
13
|
+
export const BOTS_FAMILY_TITLE = "Local bots";
|
|
14
|
+
const DEFAULT_DEPS = {
|
|
15
|
+
home: () => os.homedir(),
|
|
16
|
+
root: (home) => botsRoot(home ?? os.homedir()),
|
|
17
|
+
};
|
|
18
|
+
export const botsFamily = {
|
|
19
|
+
id: BOTS_FAMILY_ID,
|
|
20
|
+
title: BOTS_FAMILY_TITLE,
|
|
21
|
+
run: (context) => Promise.resolve(checkBots(context)),
|
|
22
|
+
};
|
|
23
|
+
export function checkBots(_context, deps = DEFAULT_DEPS) {
|
|
24
|
+
try {
|
|
25
|
+
return runChecks(deps);
|
|
26
|
+
}
|
|
27
|
+
catch (error) {
|
|
28
|
+
return [
|
|
29
|
+
{
|
|
30
|
+
status: "UNKNOWN",
|
|
31
|
+
checkId: "bots.error",
|
|
32
|
+
message: `local bot checks could not run: ${error.message}`,
|
|
33
|
+
},
|
|
34
|
+
];
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
function launchdPlistHqBinary(plist) {
|
|
38
|
+
const block = plist.match(/<key>ProgramArguments<\/key>\s*<array>([\s\S]*?)<\/array>/);
|
|
39
|
+
if (!block)
|
|
40
|
+
return null;
|
|
41
|
+
const args = [...block[1].matchAll(/<string>([^<]*)<\/string>/g)].map((m) => m[1]);
|
|
42
|
+
return args[1] ?? null;
|
|
43
|
+
}
|
|
44
|
+
function runChecks(deps) {
|
|
45
|
+
const home = deps.home();
|
|
46
|
+
const root = deps.root(home);
|
|
47
|
+
if (!fs.existsSync(root)) {
|
|
48
|
+
return [
|
|
49
|
+
{
|
|
50
|
+
status: "NA",
|
|
51
|
+
checkId: "bots.none",
|
|
52
|
+
message: "No local bots on this computer.",
|
|
53
|
+
},
|
|
54
|
+
];
|
|
55
|
+
}
|
|
56
|
+
const names = fs
|
|
57
|
+
.readdirSync(root, { withFileTypes: true })
|
|
58
|
+
.filter((d) => d.isDirectory() && readBotConfig(path.join(root, d.name)) !== null)
|
|
59
|
+
.map((d) => d.name)
|
|
60
|
+
.sort();
|
|
61
|
+
if (names.length === 0) {
|
|
62
|
+
return [
|
|
63
|
+
{
|
|
64
|
+
status: "NA",
|
|
65
|
+
checkId: "bots.none",
|
|
66
|
+
message: "No local bots on this computer.",
|
|
67
|
+
},
|
|
68
|
+
];
|
|
69
|
+
}
|
|
70
|
+
const results = [];
|
|
71
|
+
for (const name of names) {
|
|
72
|
+
const dir = botDir(name, root);
|
|
73
|
+
const config = readBotConfig(dir);
|
|
74
|
+
if (!config)
|
|
75
|
+
continue;
|
|
76
|
+
const status = readBotStatus(dir);
|
|
77
|
+
const alive = !!status && status.state === "running" && isPidAlive(status.pid);
|
|
78
|
+
if (alive && status.lastHeartbeatOk === false) {
|
|
79
|
+
results.push({
|
|
80
|
+
status: "FAIL",
|
|
81
|
+
checkId: `bots.${name}.heartbeat`,
|
|
82
|
+
target: dir,
|
|
83
|
+
message: `${name} is running but cannot heartbeat to HQ${status.lastHeartbeatError ? ` (${status.lastHeartbeatError})` : ""}.`,
|
|
84
|
+
remediation: `hq bot logs ${name}`,
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
else if (alive) {
|
|
88
|
+
results.push({
|
|
89
|
+
status: "PASS",
|
|
90
|
+
checkId: `bots.${name}.heartbeat`,
|
|
91
|
+
target: dir,
|
|
92
|
+
message: `${name} last heartbeat succeeded.`,
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
if (alive && status.lastInboxPollOk === false) {
|
|
96
|
+
results.push({
|
|
97
|
+
status: "FAIL",
|
|
98
|
+
checkId: `bots.${name}.inbox`,
|
|
99
|
+
target: dir,
|
|
100
|
+
message: `${name} is running but cannot receive messages${status.lastInboxError ? ` (${status.lastInboxError})` : ""}.`,
|
|
101
|
+
remediation: `hq bot logs ${name}`,
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
else if (alive) {
|
|
105
|
+
results.push({
|
|
106
|
+
status: "PASS",
|
|
107
|
+
checkId: `bots.${name}.inbox`,
|
|
108
|
+
target: dir,
|
|
109
|
+
message: `${name} last inbox poll succeeded.`,
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
const plistPath = path.join(home, "Library", "LaunchAgents", `${launchdLabel(name)}.plist`);
|
|
113
|
+
if (fs.existsSync(plistPath)) {
|
|
114
|
+
let hqPath;
|
|
115
|
+
try {
|
|
116
|
+
hqPath = launchdPlistHqBinary(fs.readFileSync(plistPath, "utf8"));
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
hqPath = null;
|
|
120
|
+
}
|
|
121
|
+
if (hqPath && !fs.existsSync(hqPath)) {
|
|
122
|
+
results.push({
|
|
123
|
+
status: "FAIL",
|
|
124
|
+
checkId: `bots.${name}.daemon-binary`,
|
|
125
|
+
target: hqPath,
|
|
126
|
+
message: `${name} startup agent points at a missing hq binary.`,
|
|
127
|
+
remediation: `hq bot daemon install ${name}`,
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
else if (hqPath) {
|
|
131
|
+
results.push({
|
|
132
|
+
status: "PASS",
|
|
133
|
+
checkId: `bots.${name}.daemon-binary`,
|
|
134
|
+
target: hqPath,
|
|
135
|
+
message: `${name} startup agent hq binary is present.`,
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
if (results.length === 0) {
|
|
141
|
+
results.push({
|
|
142
|
+
status: "NA",
|
|
143
|
+
checkId: "bots.idle",
|
|
144
|
+
message: `${names.length} local bot(s) are installed and not running.`,
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
return results;
|
|
148
|
+
}
|
|
149
|
+
//# sourceMappingURL=bots.js.map
|
|
@@ -22,6 +22,7 @@ import { runtimeHealthFamily } from "./checks/runtime-health.js";
|
|
|
22
22
|
import { integrationsFamily } from "./checks/integrations.js";
|
|
23
23
|
import { syncHealthFamily } from "./checks/sync-health.js";
|
|
24
24
|
import { workContextFamily } from "./checks/work-context.js";
|
|
25
|
+
import { botsFamily } from "./checks/bots.js";
|
|
25
26
|
import { fixtureCoverageFamily } from "./fixtures/discover.js";
|
|
26
27
|
import { checkClaudeWiring } from "./checks/claude-wiring.js";
|
|
27
28
|
/**
|
|
@@ -256,6 +257,9 @@ export function createDefaultRegistry() {
|
|
|
256
257
|
// Work context (US-007A): session state files, outbox depth/quarantine,
|
|
257
258
|
// and default-company mode. Local-only reads under ~/.hq/work-context.
|
|
258
259
|
registry.register(workContextFamily);
|
|
260
|
+
// Local bots: inbox/heartbeat reachability and a LaunchAgent whose hq
|
|
261
|
+
// path survived an HQ toolchain move. Local-only reads.
|
|
262
|
+
registry.register(botsFamily);
|
|
259
263
|
return registry;
|
|
260
264
|
}
|
|
261
265
|
//# sourceMappingURL=registry.js.map
|
package/dist/lib/mesh/api.js
CHANGED
|
@@ -139,38 +139,106 @@ function asRecord(value) {
|
|
|
139
139
|
? value
|
|
140
140
|
: null;
|
|
141
141
|
}
|
|
142
|
-
|
|
142
|
+
function threadRows(data) {
|
|
143
|
+
const rows = Array.isArray(data?.threads)
|
|
144
|
+
? data.threads
|
|
145
|
+
: Array.isArray(data?.items)
|
|
146
|
+
? data.items
|
|
147
|
+
: [];
|
|
148
|
+
const out = [];
|
|
149
|
+
for (const row of rows) {
|
|
150
|
+
const rec = asRecord(row);
|
|
151
|
+
if (!rec)
|
|
152
|
+
continue;
|
|
153
|
+
out.push(rec);
|
|
154
|
+
}
|
|
155
|
+
return out;
|
|
156
|
+
}
|
|
157
|
+
function sortThreadsByActivity(threads) {
|
|
158
|
+
return [...threads].sort((a, b) => String(b.lastActivityAt || b.createdAt || "").localeCompare(String(a.lastActivityAt || a.createdAt || "")));
|
|
159
|
+
}
|
|
160
|
+
function isActiveThread(thread) {
|
|
161
|
+
const status = String(thread.threadStatus || "").toLowerCase();
|
|
162
|
+
return !status || ACTIVE_STATUSES.has(status);
|
|
163
|
+
}
|
|
164
|
+
function threadKey(thread) {
|
|
165
|
+
const id = typeof thread.threadId === "string" ? thread.threadId.trim() : "";
|
|
166
|
+
return id || undefined;
|
|
167
|
+
}
|
|
168
|
+
async function listThreadsByQuery(token, query) {
|
|
169
|
+
const collected = [];
|
|
170
|
+
let cursor = "";
|
|
171
|
+
for (let page = 0; page < 10; page += 1) {
|
|
172
|
+
const pageQuery = { ...query, limit: "100" };
|
|
173
|
+
if (cursor)
|
|
174
|
+
pageQuery.cursor = cursor;
|
|
175
|
+
const data = asRecord(await meshJson(token, "/v1/work-mesh/threads", { query: pageQuery }));
|
|
176
|
+
collected.push(...threadRows(data));
|
|
177
|
+
cursor = typeof data?.nextCursor === "string" ? data.nextCursor : "";
|
|
178
|
+
if (!cursor)
|
|
179
|
+
break;
|
|
180
|
+
}
|
|
181
|
+
return collected;
|
|
182
|
+
}
|
|
183
|
+
async function listThreadsByStatus(token, companyUid) {
|
|
143
184
|
const collected = [];
|
|
144
185
|
for (const status of ACTIVE_STATUSES) {
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
break;
|
|
186
|
+
collected.push(...(await listThreadsByQuery(token, { companyUid, status })));
|
|
187
|
+
}
|
|
188
|
+
return collected;
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* Project threads are often missing from the status index. Prefer
|
|
192
|
+
* `projectId` + `status=open`; if that combined query is rejected, fall
|
|
193
|
+
* back to `projectId` alone.
|
|
194
|
+
*/
|
|
195
|
+
async function listProjectThreads(token, companyUid, projectId) {
|
|
196
|
+
try {
|
|
197
|
+
return await listThreadsByQuery(token, {
|
|
198
|
+
companyUid,
|
|
199
|
+
projectId,
|
|
200
|
+
status: "open",
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
catch {
|
|
204
|
+
try {
|
|
205
|
+
return await listThreadsByQuery(token, { companyUid, projectId });
|
|
206
|
+
}
|
|
207
|
+
catch {
|
|
208
|
+
return [];
|
|
169
209
|
}
|
|
170
210
|
}
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
211
|
+
}
|
|
212
|
+
function mergeProjectAndStatusThreads(projectThreads, statusThreads, projectId) {
|
|
213
|
+
const seen = new Set();
|
|
214
|
+
const merged = [];
|
|
215
|
+
for (const thread of sortThreadsByActivity(projectThreads.filter((t) => (!t.projectId || t.projectId === projectId) && isActiveThread(t)))) {
|
|
216
|
+
const key = threadKey(thread);
|
|
217
|
+
if (key) {
|
|
218
|
+
if (seen.has(key))
|
|
219
|
+
continue;
|
|
220
|
+
seen.add(key);
|
|
221
|
+
}
|
|
222
|
+
merged.push(thread);
|
|
223
|
+
}
|
|
224
|
+
for (const thread of sortThreadsByActivity(statusThreads.filter((t) => t.projectId === projectId && isActiveThread(t)))) {
|
|
225
|
+
const key = threadKey(thread);
|
|
226
|
+
if (key) {
|
|
227
|
+
if (seen.has(key))
|
|
228
|
+
continue;
|
|
229
|
+
seen.add(key);
|
|
230
|
+
}
|
|
231
|
+
merged.push(thread);
|
|
232
|
+
}
|
|
233
|
+
return merged;
|
|
234
|
+
}
|
|
235
|
+
export async function listActiveThreads(token, companyUid, projectId) {
|
|
236
|
+
const statusListed = await listThreadsByStatus(token, companyUid);
|
|
237
|
+
if (!projectId) {
|
|
238
|
+
return sortThreadsByActivity(statusListed);
|
|
239
|
+
}
|
|
240
|
+
const projectListed = await listProjectThreads(token, companyUid, projectId);
|
|
241
|
+
return mergeProjectAndStatusThreads(projectListed, statusListed, projectId);
|
|
174
242
|
}
|
|
175
243
|
export function clamp(value, max) {
|
|
176
244
|
return value.length <= max ? value : value.slice(0, max);
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Refresh ~/.hq/work-context/sessions/<sid>/board.md for bound sessions
|
|
3
|
-
* from
|
|
3
|
+
* from the ProjectView read route at most every 60s.
|
|
4
|
+
*
|
|
5
|
+
* Board JSON remains the fallback for older projects, but story status is
|
|
6
|
+
* authoritative in ProjectView because `hq mesh story` writes there.
|
|
4
7
|
* Metadata-only snapshot — never prompts or tokens.
|
|
5
8
|
*/
|
|
6
9
|
export declare const BOARD_REFRESH_INTERVAL_MS = 60000;
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Refresh ~/.hq/work-context/sessions/<sid>/board.md for bound sessions
|
|
3
|
-
* from
|
|
3
|
+
* from the ProjectView read route at most every 60s.
|
|
4
|
+
*
|
|
5
|
+
* Board JSON remains the fallback for older projects, but story status is
|
|
6
|
+
* authoritative in ProjectView because `hq mesh story` writes there.
|
|
4
7
|
* Metadata-only snapshot — never prompts or tokens.
|
|
5
8
|
*/
|
|
6
9
|
import * as fs from "node:fs";
|
|
@@ -115,9 +118,72 @@ export async function refreshBoundSessionBoards(deps) {
|
|
|
115
118
|
}
|
|
116
119
|
return { refreshed, skipped, errors };
|
|
117
120
|
}
|
|
121
|
+
function storiesFromProject(project) {
|
|
122
|
+
const storiesRaw = Array.isArray(project.stories) ? project.stories : [];
|
|
123
|
+
const stories = [];
|
|
124
|
+
for (const s of storiesRaw) {
|
|
125
|
+
if (!s || typeof s !== "object" || Array.isArray(s))
|
|
126
|
+
continue;
|
|
127
|
+
const row = s;
|
|
128
|
+
const id = typeof row.id === "string"
|
|
129
|
+
? row.id
|
|
130
|
+
: typeof row.storyId === "string"
|
|
131
|
+
? row.storyId
|
|
132
|
+
: typeof row.taskId === "string"
|
|
133
|
+
? row.taskId
|
|
134
|
+
: "";
|
|
135
|
+
if (!id)
|
|
136
|
+
continue;
|
|
137
|
+
stories.push({
|
|
138
|
+
id,
|
|
139
|
+
title: typeof row.title === "string"
|
|
140
|
+
? row.title.slice(0, 120)
|
|
141
|
+
: typeof row.name === "string"
|
|
142
|
+
? row.name.slice(0, 120)
|
|
143
|
+
: undefined,
|
|
144
|
+
status: typeof row.status === "string" ? row.status : undefined,
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
return stories;
|
|
148
|
+
}
|
|
149
|
+
function projectViewSnapshot(raw, companyUid, projectId) {
|
|
150
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw))
|
|
151
|
+
return null;
|
|
152
|
+
const project = raw;
|
|
153
|
+
if (project.companyUid !== companyUid || project.projectId !== projectId)
|
|
154
|
+
return null;
|
|
155
|
+
return {
|
|
156
|
+
projectId,
|
|
157
|
+
projectName: typeof project.name === "string"
|
|
158
|
+
? project.name
|
|
159
|
+
: typeof project.title === "string"
|
|
160
|
+
? project.title
|
|
161
|
+
: undefined,
|
|
162
|
+
stories: storiesFromProject(project),
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
function isMissingProject(error) {
|
|
166
|
+
// hq-pro's GET ProjectView handler emits PROJECT_VIEW_NOT_FOUND; retain the
|
|
167
|
+
// legacy PROJECT_NOT_FOUND code for compatibility with older producers.
|
|
168
|
+
return (error instanceof Error &&
|
|
169
|
+
/^404\s+(?:PROJECT_VIEW_NOT_FOUND|PROJECT_NOT_FOUND)\b/.test(error.message));
|
|
170
|
+
}
|
|
118
171
|
/** Build a BoardReader from meshJson-compatible fetch. */
|
|
119
172
|
export function createVaultBoardReader(opts) {
|
|
120
173
|
return async (companyUid, projectId) => {
|
|
174
|
+
// ProjectView is the live story-status source. Only use board.json when
|
|
175
|
+
// this project has not been migrated into ProjectView yet.
|
|
176
|
+
try {
|
|
177
|
+
const view = await opts.getJson(`/v1/work-mesh/projects/${encodeURIComponent(projectId)}?companyUid=${encodeURIComponent(companyUid)}`);
|
|
178
|
+
const snapshot = projectViewSnapshot(view, companyUid, projectId);
|
|
179
|
+
if (snapshot)
|
|
180
|
+
return snapshot;
|
|
181
|
+
throw new Error("ProjectView response did not match the requested company and project");
|
|
182
|
+
}
|
|
183
|
+
catch (error) {
|
|
184
|
+
if (!isMissingProject(error))
|
|
185
|
+
throw error;
|
|
186
|
+
}
|
|
121
187
|
const raw = await opts.getJson(`/companies/${encodeURIComponent(companyUid)}/board`);
|
|
122
188
|
if (!raw || typeof raw !== "object")
|
|
123
189
|
return null;
|
|
@@ -140,35 +206,10 @@ export function createVaultBoardReader(opts) {
|
|
|
140
206
|
const id = typeof p.id === "string" ? p.id : typeof p.projectId === "string" ? p.projectId : "";
|
|
141
207
|
if (id !== projectId)
|
|
142
208
|
continue;
|
|
143
|
-
const
|
|
144
|
-
|
|
145
|
-
: Array.isArray(p.tasks
|
|
146
|
-
|
|
147
|
-
: [];
|
|
148
|
-
const stories = [];
|
|
149
|
-
for (const s of storiesRaw) {
|
|
150
|
-
if (!s || typeof s !== "object" || Array.isArray(s))
|
|
151
|
-
continue;
|
|
152
|
-
const row = s;
|
|
153
|
-
const sid = typeof row.id === "string"
|
|
154
|
-
? row.id
|
|
155
|
-
: typeof row.storyId === "string"
|
|
156
|
-
? row.storyId
|
|
157
|
-
: typeof row.taskId === "string"
|
|
158
|
-
? row.taskId
|
|
159
|
-
: "";
|
|
160
|
-
if (!sid)
|
|
161
|
-
continue;
|
|
162
|
-
stories.push({
|
|
163
|
-
id: sid,
|
|
164
|
-
title: typeof row.title === "string"
|
|
165
|
-
? row.title.slice(0, 120)
|
|
166
|
-
: typeof row.name === "string"
|
|
167
|
-
? row.name.slice(0, 120)
|
|
168
|
-
: undefined,
|
|
169
|
-
status: typeof row.status === "string" ? row.status : undefined,
|
|
170
|
-
});
|
|
171
|
-
}
|
|
209
|
+
const stories = storiesFromProject({
|
|
210
|
+
...p,
|
|
211
|
+
stories: Array.isArray(p.stories) ? p.stories : p.tasks,
|
|
212
|
+
});
|
|
172
213
|
return {
|
|
173
214
|
projectId,
|
|
174
215
|
projectName: typeof p.name === "string"
|