@indigoai-us/hq-cli 5.102.0 → 5.103.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 CHANGED
@@ -2,6 +2,19 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.103.0] — 2026-08-18
6
+
7
+ ### Added
8
+
9
+ - `hq doctor` now checks the AI runtimes themselves, not just their hook
10
+ wiring. A new "AI runtime health" family verifies the `claude`, `codex`, and
11
+ `grok` CLIs are on PATH (a pure filesystem scan — the default run stays
12
+ offline; a missing CLI is WARN, never FAIL). A new `--live-runtimes` flag
13
+ additionally reads each installed CLI's version and sends it a one-line
14
+ prompt from a temp directory, proving the full binary → login → subscription
15
+ → response path; without the flag those checks report UNTESTED, so a
16
+ logged-out or broken runtime can no longer hide behind green wiring checks.
17
+
5
18
  ## [5.102.0] — 2026-08-17
6
19
 
7
20
  ### Added
@@ -15,6 +15,9 @@
15
15
  * hq agents start|stop <uid> — EC2 start/stop
16
16
  * hq agents retry <uid> — resume setup from first non-done step
17
17
  * hq agents rm <uid> --yes — deprovision (destructive; flag-guarded)
18
+ * hq agents jobs list <uid> — off-box job roster (schedule + rate)
19
+ * hq agents jobs pause <uid> <jobId> — flip schedule State=DISABLED
20
+ * hq agents jobs cancel <uid> <jobId> — delete schedule + drop the job
18
21
  *
19
22
  * Agents are company-scoped. `--company <slug>` may sit on the group
20
23
  * (`hq agents --company acme list`) or on a subcommand
@@ -204,6 +207,46 @@ export declare function deprovisionAgent(token: string, agentUid: string): Promi
204
207
  setupState?: string;
205
208
  terminal?: boolean;
206
209
  }>;
210
+ /** EventBridge Scheduler State as the operator list/pause surface reports it. */
211
+ export type JobScheduleState = "ENABLED" | "DISABLED";
212
+ /** One operator list row from `GET /v1/agents/{uid}/jobs`. */
213
+ export interface AgentJobView {
214
+ jobId: string;
215
+ scheduleState: JobScheduleState | string;
216
+ rate: string;
217
+ lastRunOutcome: string | null;
218
+ status?: string;
219
+ nextRunAt?: string | null;
220
+ lastRunAt?: string | null;
221
+ prompt?: string;
222
+ }
223
+ export interface PauseJobResult {
224
+ ok: true;
225
+ jobId: string;
226
+ scheduleState: "DISABLED" | string;
227
+ changed: boolean;
228
+ previousState?: string;
229
+ }
230
+ export interface CancelJobResult {
231
+ ok: true;
232
+ jobId: string;
233
+ }
234
+ export declare function listAgentJobs(token: string, agentUid: string): Promise<AgentJobView[]>;
235
+ export declare function pauseAgentJob(token: string, agentUid: string, jobId: string): Promise<PauseJobResult>;
236
+ export declare function cancelAgentJob(token: string, agentUid: string, jobId: string): Promise<CancelJobResult>;
237
+ /**
238
+ * Map a jobs-control HTTP error to a single operator-facing line. Pure so the
239
+ * status/code → copy mapping is unit-tested without a process exit.
240
+ *
241
+ * 404 without `JOB_NOT_FOUND` is also the deploy-order-safe path: hq-cli
242
+ * shipped before the hq-pro endpoints exist (or the agents flag is OFF, or
243
+ * the agent is cross-company) must print a clear message and exit non-zero,
244
+ * never throw a stack trace.
245
+ */
246
+ export declare function formatJobsHttpError(err: AgentsHttpError, jobId?: string): string;
247
+ /** Stable padEnd table: jobId, scheduleState, rate, lastRunOutcome, status. */
248
+ export declare function formatJobsTable(jobs: AgentJobView[]): string;
249
+ export declare function formatPauseResult(result: PauseJobResult): string;
207
250
  /** Human-readable "hot-applied to the running box" vs "saved for next launch". */
208
251
  export declare function appliedHint(applied: boolean): string;
209
252
  /** One message in a DM thread, as returned by `GET /v1/notify/thread`. */
@@ -15,6 +15,9 @@
15
15
  * hq agents start|stop <uid> — EC2 start/stop
16
16
  * hq agents retry <uid> — resume setup from first non-done step
17
17
  * hq agents rm <uid> --yes — deprovision (destructive; flag-guarded)
18
+ * hq agents jobs list <uid> — off-box job roster (schedule + rate)
19
+ * hq agents jobs pause <uid> <jobId> — flip schedule State=DISABLED
20
+ * hq agents jobs cancel <uid> <jobId> — delete schedule + drop the job
18
21
  *
19
22
  * Agents are company-scoped. `--company <slug>` may sit on the group
20
23
  * (`hq agents --company acme list`) or on a subcommand
@@ -309,6 +312,79 @@ export async function deprovisionAgent(token, agentUid) {
309
312
  method: "DELETE",
310
313
  });
311
314
  }
315
+ export async function listAgentJobs(token, agentUid) {
316
+ const data = await agentsRequest({
317
+ token,
318
+ path: `/v1/agents/${encodeURIComponent(agentUid)}/jobs`,
319
+ });
320
+ return data.jobs ?? [];
321
+ }
322
+ export async function pauseAgentJob(token, agentUid, jobId) {
323
+ return agentsRequest({
324
+ token,
325
+ path: `/v1/agents/${encodeURIComponent(agentUid)}/jobs/${encodeURIComponent(jobId)}/pause`,
326
+ method: "POST",
327
+ });
328
+ }
329
+ export async function cancelAgentJob(token, agentUid, jobId) {
330
+ return agentsRequest({
331
+ token,
332
+ path: `/v1/agents/${encodeURIComponent(agentUid)}/jobs/${encodeURIComponent(jobId)}/cancel`,
333
+ method: "POST",
334
+ });
335
+ }
336
+ /**
337
+ * Map a jobs-control HTTP error to a single operator-facing line. Pure so the
338
+ * status/code → copy mapping is unit-tested without a process exit.
339
+ *
340
+ * 404 without `JOB_NOT_FOUND` is also the deploy-order-safe path: hq-cli
341
+ * shipped before the hq-pro endpoints exist (or the agents flag is OFF, or
342
+ * the agent is cross-company) must print a clear message and exit non-zero,
343
+ * never throw a stack trace.
344
+ */
345
+ export function formatJobsHttpError(err, jobId) {
346
+ if (err.status === 401)
347
+ return "Not authenticated — run `hq login`.";
348
+ if (err.status === 403)
349
+ return "You need owner/admin on this company.";
350
+ if (err.status === 404 && err.code === "JOB_NOT_FOUND") {
351
+ const id = jobId ?? extractQuotedJobId(err.message) ?? "unknown";
352
+ return `Job '${id}' not found.`;
353
+ }
354
+ if (err.status === 404)
355
+ return "Agent not found or not accessible.";
356
+ if (err.code === "JOBS_SCHEDULER_UNAVAILABLE" ||
357
+ err.code === "SCHEDULE_DELETE_FAILED") {
358
+ return `${err.code}: ${err.message}`;
359
+ }
360
+ return err.message;
361
+ }
362
+ function extractQuotedJobId(message) {
363
+ const m = message.match(/Job '([^']+)'/i);
364
+ return m?.[1];
365
+ }
366
+ /** Stable padEnd table: jobId, scheduleState, rate, lastRunOutcome, status. */
367
+ export function formatJobsTable(jobs) {
368
+ if (jobs.length === 0)
369
+ return "No jobs.";
370
+ const cols = ["JOB_ID", "STATE", "RATE", "LAST_RUN", "STATUS"];
371
+ const rows = jobs.map((j) => [
372
+ j.jobId,
373
+ String(j.scheduleState ?? ""),
374
+ j.rate ?? "",
375
+ j.lastRunOutcome ?? "-",
376
+ j.status ?? "",
377
+ ]);
378
+ const widths = cols.map((c, i) => Math.max(c.length, ...rows.map((r) => r[i].length)));
379
+ const render = (cells) => cells.map((cell, i) => cell.padEnd(widths[i])).join(" ");
380
+ return [chalk.bold(render(cols)), ...rows.map(render)].join("\n");
381
+ }
382
+ export function formatPauseResult(result) {
383
+ if (!result.changed)
384
+ return "Already paused";
385
+ const from = result.previousState ?? "ENABLED";
386
+ return `Paused ${result.jobId} (${from} → ${result.scheduleState})`;
387
+ }
312
388
  /** Human-readable "hot-applied to the running box" vs "saved for next launch". */
313
389
  export function appliedHint(applied) {
314
390
  return applied
@@ -394,6 +470,13 @@ function fail(err) {
394
470
  }
395
471
  process.exit(1);
396
472
  }
473
+ function failJobs(err, jobId) {
474
+ if (err instanceof AgentsHttpError) {
475
+ console.error(chalk.red(formatJobsHttpError(err, jobId)));
476
+ process.exit(1);
477
+ }
478
+ fail(err);
479
+ }
397
480
  export function registerAgentsCommand(program) {
398
481
  const agents = program
399
482
  .command("agents")
@@ -842,5 +925,59 @@ export function registerAgentsCommand(program) {
842
925
  fail(err);
843
926
  }
844
927
  });
928
+ // Off-box job control (US-003). Nested group so `hq agents jobs --help`
929
+ // lists list|pause|cancel. Same vaultApiFetch + person JWT as the rest of
930
+ // this file; keyed HQ_API_KEY rewrites /v1/agents → /v1/keys/agents.
931
+ const jobs = agents
932
+ .command("jobs")
933
+ .description("List, pause, or cancel an agent's scheduled jobs");
934
+ jobs
935
+ .command("list <agentUid>")
936
+ .description("List an agent's scheduled jobs")
937
+ .option("--json", "Emit raw JSON")
938
+ .action(async (agentUid, opts) => {
939
+ try {
940
+ const token = (await resolveVaultCredential()).token;
941
+ const roster = await listAgentJobs(token, agentUid);
942
+ if (opts.json) {
943
+ process.stdout.write(JSON.stringify(roster, null, 2) + "\n");
944
+ return;
945
+ }
946
+ if (roster.length === 0) {
947
+ console.log(chalk.gray("No jobs."));
948
+ return;
949
+ }
950
+ console.log(formatJobsTable(roster));
951
+ }
952
+ catch (err) {
953
+ failJobs(err);
954
+ }
955
+ });
956
+ jobs
957
+ .command("pause <agentUid> <jobId>")
958
+ .description("Pause a job's schedule (reversible)")
959
+ .action(async (agentUid, jobId) => {
960
+ try {
961
+ const token = (await resolveVaultCredential()).token;
962
+ const result = await pauseAgentJob(token, agentUid, jobId);
963
+ console.log(chalk.green(formatPauseResult(result)));
964
+ }
965
+ catch (err) {
966
+ failJobs(err, jobId);
967
+ }
968
+ });
969
+ jobs
970
+ .command("cancel <agentUid> <jobId>")
971
+ .description("Cancel a job and delete its schedule")
972
+ .action(async (agentUid, jobId) => {
973
+ try {
974
+ const token = (await resolveVaultCredential()).token;
975
+ const result = await cancelAgentJob(token, agentUid, jobId);
976
+ console.log(chalk.green(`Cancelled ${result.jobId}.`));
977
+ }
978
+ catch (err) {
979
+ failJobs(err, jobId);
980
+ }
981
+ });
845
982
  }
846
983
  //# sourceMappingURL=agents.js.map
@@ -8,7 +8,9 @@
8
8
  * - Running outside any HQ tree exits non-zero with a message naming exactly
9
9
  * what it looked for — never a throw, never a false PASS.
10
10
  * - The command performs no network calls and needs no authentication; it is
11
- * purely a function of the on-disk shape of the tree.
11
+ * purely a function of the on-disk shape of the tree. `--live-runtimes` is
12
+ * the one opt-in exception: it probes the installed AI CLIs (claude, codex,
13
+ * grok) with a version read and a one-line prompt.
12
14
  *
13
15
  * US-015 adds reporting, `--json`, and the exit-code contract: the exit code is
14
16
  * 0 unless some result is FAIL or UNKNOWN (WARN/UNTESTED/NA/KNOWN-DEFECT never
@@ -77,6 +79,13 @@ export interface RunDoctorOptions {
77
79
  * does — so a plain `hq doctor` stays purely a function of the on-disk shape.
78
80
  */
79
81
  deepTest?: boolean;
82
+ /**
83
+ * Additionally probe each installed AI CLI (claude, codex, grok) with a
84
+ * version read and a one-line prompt (`--live-runtimes`). Off by default:
85
+ * this is the doctor's only networked tier, and without it the runtime-health
86
+ * family reports UNTESTED rather than spawning anything.
87
+ */
88
+ liveRuntimes?: boolean;
80
89
  }
81
90
  /** The outcome of a doctor run, returned rather than thrown so it is testable. */
82
91
  export interface RunDoctorResult {
@@ -8,7 +8,9 @@
8
8
  * - Running outside any HQ tree exits non-zero with a message naming exactly
9
9
  * what it looked for — never a throw, never a false PASS.
10
10
  * - The command performs no network calls and needs no authentication; it is
11
- * purely a function of the on-disk shape of the tree.
11
+ * purely a function of the on-disk shape of the tree. `--live-runtimes` is
12
+ * the one opt-in exception: it probes the installed AI CLIs (claude, codex,
13
+ * grok) with a version read and a one-line prompt.
12
14
  *
13
15
  * US-015 adds reporting, `--json`, and the exit-code contract: the exit code is
14
16
  * 0 unless some result is FAIL or UNKNOWN (WARN/UNTESTED/NA/KNOWN-DEFECT never
@@ -99,6 +101,7 @@ export async function runDoctor(options = {}) {
99
101
  hqRoot,
100
102
  platform: { id: platform.id, evidence: platform.evidence },
101
103
  sessionId: options.sessionId,
104
+ liveRuntimes: options.liveRuntimes === true,
102
105
  };
103
106
  const families = await registry.run(context);
104
107
  // `--deep-test` (US-008): after the read-only tiers, actually fire pure-guard
@@ -147,12 +150,13 @@ export async function runDoctor(options = {}) {
147
150
  export function registerDoctorCommand(program) {
148
151
  program
149
152
  .command("doctor")
150
- .description("Verify HQ hook guardrails are wired and firing (read-only, offline).")
153
+ .description("Verify HQ hook guardrails are wired and firing (read-only, offline; --live-runtimes adds networked AI CLI probes).")
151
154
  .option("--json", "Emit the machine-readable JSON document (no colour).")
152
155
  .option("--verbose", "Also print every PASS result in text output.")
153
156
  .option("--no-color", "Disable ANSI colour even on a TTY.")
154
157
  .option("--session-id <id>", "Scope the runtime probe's ledger check to this exact session.")
155
158
  .option("--deep-test", "Also fire pure-guard hooks through the real gate under all three profiles (sandboxed).")
159
+ .option("--live-runtimes", "Also probe each installed AI CLI (claude, codex, grok) with a one-line prompt to verify login and subscription (networked; uses your subscriptions).")
156
160
  .option("--fix", "Apply the allowlisted safe repairs (backs up first; read-only without this flag).")
157
161
  .option("--yes", "Skip the interactive --fix confirmation (non-interactive use).")
158
162
  .option("--force", "Let --fix run despite uncommitted changes under .claude/, .codex/, or .grok/.")
@@ -199,6 +203,7 @@ export function registerDoctorCommand(program) {
199
203
  platform,
200
204
  sessionId: opts.sessionId,
201
205
  deepTest: opts.deepTest === true,
206
+ liveRuntimes: opts.liveRuntimes === true,
202
207
  });
203
208
  // Set the exit code rather than calling process.exit, so the CLI's
204
209
  // normal shutdown (telemetry flush) still runs. Non-zero means either an
@@ -0,0 +1,100 @@
1
+ /**
2
+ * AI runtime health: are the CLIs HQ orchestrates actually able to answer?
3
+ *
4
+ * Every hooks-family check verifies HQ's own wiring. This family verifies the
5
+ * other half of the contract: the Claude, Codex, and Grok CLIs themselves. A
6
+ * tree can pass every wiring check while `codex` is logged out, `grok` is not
7
+ * installed, or `claude` is broken by a bad update — and until this family
8
+ * existed `hq doctor` would still report all green.
9
+ *
10
+ * ## Two tiers, mirroring the doctor's offline contract
11
+ *
12
+ * 1. **Presence (always).** Resolving each binary against PATH is a pure
13
+ * filesystem scan — no process is spawned — so a plain `hq doctor` stays a
14
+ * function of the on-disk shape. A missing binary is WARN, not FAIL: HQ
15
+ * wires hooks for all three runtimes, but not every machine runs all three.
16
+ *
17
+ * 2. **Live probes (`--live-runtimes` only).** Reads the CLI's version and
18
+ * sends it a one-line prompt, verifying the full path: binary → auth →
19
+ * subscription → model → response. This is the doctor's ONLY networked
20
+ * tier and it never runs without the flag; without it the live checks
21
+ * report UNTESTED, never PASS — installed is not the same as working.
22
+ *
23
+ * Probes run from the OS temp directory, never the HQ tree, so a probe cannot
24
+ * trigger HQ's own hook stack or leave session state behind, and each one is
25
+ * bounded by a timeout. A timeout or spawn error is UNKNOWN (could not be
26
+ * determined), while a clean non-zero exit — the logged-out case — is FAIL.
27
+ */
28
+ import type { CheckContext, CheckFamily, CheckResult } from "../types.js";
29
+ /** The id of the AI-runtime-health family. */
30
+ export declare const RUNTIMES_FAMILY_ID = "runtimes";
31
+ /** Common id prefix for every result this family emits. */
32
+ export declare const RUNTIMES_PREFIX = "runtimes";
33
+ /**
34
+ * The deterministic one-line probe prompt. Health is proven by a round-trip
35
+ * (exit 0 plus non-empty output), not by exact-matching the reply, so a model
36
+ * that answers with anything at all still passes.
37
+ */
38
+ export declare const PROBE_PROMPT = "Reply with exactly: OK";
39
+ /** How long each live probe may run before it is killed and marked UNKNOWN. */
40
+ export declare const DEFAULT_PROBE_TIMEOUT_MS = 120000;
41
+ /** One AI runtime the doctor knows how to find and probe. */
42
+ export interface RuntimeSpec {
43
+ /** Stable key used in check ids, e.g. `runtimes.codex.binary`. */
44
+ key: string;
45
+ /** Human display name for messages. */
46
+ displayName: string;
47
+ /** The executable name resolved against PATH. */
48
+ binary: string;
49
+ /** Arguments that print the version and exit (offline, auth-free). */
50
+ versionArgs: string[];
51
+ /** Arguments for the non-interactive one-line prompt probe. */
52
+ probeArgs: string[];
53
+ /** Remediation hint when the binary is not on PATH. */
54
+ installHint: string;
55
+ }
56
+ /**
57
+ * The three runtimes HQ wires hooks for. Codex runs its probe under its own
58
+ * read-only sandbox and outside-a-repo mode so the probe can never write; the
59
+ * Claude and Grok print modes are non-interactive and make no edits.
60
+ */
61
+ export declare const AI_RUNTIMES: readonly RuntimeSpec[];
62
+ /** The outcome of one spawned probe, normalised so callers never throw. */
63
+ export interface ProbeOutcome {
64
+ /** True iff the process spawned and exited 0. */
65
+ ok: boolean;
66
+ /** Exit code, or null when the process never exited normally. */
67
+ code: number | null;
68
+ stdout: string;
69
+ stderr: string;
70
+ /** True when the probe was killed by the timeout. */
71
+ timedOut: boolean;
72
+ /** Present when the spawn itself failed (ENOENT, EACCES, …). */
73
+ spawnError?: string;
74
+ }
75
+ /** Injectable dependencies so the family is unit-testable without spawning. */
76
+ export interface RuntimeHealthDeps {
77
+ /** Resolve an executable against PATH; null when not found. */
78
+ resolveBinary?: (binary: string) => string | null;
79
+ /** Execute one probe. The default spawns from the OS temp dir. */
80
+ execProbe?: (file: string, args: string[], timeoutMs: number) => Promise<ProbeOutcome>;
81
+ /** Per-probe timeout. Default {@link DEFAULT_PROBE_TIMEOUT_MS}. */
82
+ probeTimeoutMs?: number;
83
+ }
84
+ /**
85
+ * The AI-runtime-health check family entry. Presence checks always run; the
86
+ * version and prompt probes run only when the context carries `liveRuntimes`
87
+ * (the `--live-runtimes` flag). Never rejects — an unexpected throw degrades
88
+ * to a single UNKNOWN result, mirroring the hooks family's safeTier.
89
+ */
90
+ export declare function checkRuntimeHealth(context: CheckContext, deps?: RuntimeHealthDeps): Promise<CheckResult[]>;
91
+ /** The registered family object. */
92
+ export declare const runtimeHealthFamily: CheckFamily;
93
+ /**
94
+ * Resolve an executable name against PATH with a pure filesystem scan — no
95
+ * process is spawned, keeping the doctor's default run a function of on-disk
96
+ * shape. First PATH entry containing an executable regular file wins, which is
97
+ * exactly the copy a shell would run.
98
+ */
99
+ export declare function resolveOnPath(binary: string): string | null;
100
+ //# sourceMappingURL=runtime-health.d.ts.map
@@ -0,0 +1,336 @@
1
+ /**
2
+ * AI runtime health: are the CLIs HQ orchestrates actually able to answer?
3
+ *
4
+ * Every hooks-family check verifies HQ's own wiring. This family verifies the
5
+ * other half of the contract: the Claude, Codex, and Grok CLIs themselves. A
6
+ * tree can pass every wiring check while `codex` is logged out, `grok` is not
7
+ * installed, or `claude` is broken by a bad update — and until this family
8
+ * existed `hq doctor` would still report all green.
9
+ *
10
+ * ## Two tiers, mirroring the doctor's offline contract
11
+ *
12
+ * 1. **Presence (always).** Resolving each binary against PATH is a pure
13
+ * filesystem scan — no process is spawned — so a plain `hq doctor` stays a
14
+ * function of the on-disk shape. A missing binary is WARN, not FAIL: HQ
15
+ * wires hooks for all three runtimes, but not every machine runs all three.
16
+ *
17
+ * 2. **Live probes (`--live-runtimes` only).** Reads the CLI's version and
18
+ * sends it a one-line prompt, verifying the full path: binary → auth →
19
+ * subscription → model → response. This is the doctor's ONLY networked
20
+ * tier and it never runs without the flag; without it the live checks
21
+ * report UNTESTED, never PASS — installed is not the same as working.
22
+ *
23
+ * Probes run from the OS temp directory, never the HQ tree, so a probe cannot
24
+ * trigger HQ's own hook stack or leave session state behind, and each one is
25
+ * bounded by a timeout. A timeout or spawn error is UNKNOWN (could not be
26
+ * determined), while a clean non-zero exit — the logged-out case — is FAIL.
27
+ */
28
+ import { spawn } from "node:child_process";
29
+ import * as fs from "node:fs";
30
+ import * as os from "node:os";
31
+ import * as path from "node:path";
32
+ /** The id of the AI-runtime-health family. */
33
+ export const RUNTIMES_FAMILY_ID = "runtimes";
34
+ /** Common id prefix for every result this family emits. */
35
+ export const RUNTIMES_PREFIX = "runtimes";
36
+ /**
37
+ * The deterministic one-line probe prompt. Health is proven by a round-trip
38
+ * (exit 0 plus non-empty output), not by exact-matching the reply, so a model
39
+ * that answers with anything at all still passes.
40
+ */
41
+ export const PROBE_PROMPT = "Reply with exactly: OK";
42
+ /** How long each live probe may run before it is killed and marked UNKNOWN. */
43
+ export const DEFAULT_PROBE_TIMEOUT_MS = 120_000;
44
+ /**
45
+ * The three runtimes HQ wires hooks for. Codex runs its probe under its own
46
+ * read-only sandbox and outside-a-repo mode so the probe can never write; the
47
+ * Claude and Grok print modes are non-interactive and make no edits.
48
+ */
49
+ export const AI_RUNTIMES = [
50
+ {
51
+ key: "claude",
52
+ displayName: "Claude Code",
53
+ binary: "claude",
54
+ versionArgs: ["--version"],
55
+ probeArgs: ["-p", PROBE_PROMPT],
56
+ installHint: "npm install -g @anthropic-ai/claude-code",
57
+ },
58
+ {
59
+ key: "codex",
60
+ displayName: "Codex CLI",
61
+ binary: "codex",
62
+ versionArgs: ["--version"],
63
+ probeArgs: [
64
+ "exec",
65
+ "--skip-git-repo-check",
66
+ "--sandbox",
67
+ "read-only",
68
+ PROBE_PROMPT,
69
+ ],
70
+ installHint: "npm install -g @openai/codex",
71
+ },
72
+ {
73
+ key: "grok",
74
+ displayName: "Grok CLI",
75
+ binary: "grok",
76
+ versionArgs: ["--version"],
77
+ probeArgs: ["-p", PROBE_PROMPT],
78
+ installHint: "install the Grok CLI and ensure `grok` is on PATH",
79
+ },
80
+ ];
81
+ /**
82
+ * The AI-runtime-health check family entry. Presence checks always run; the
83
+ * version and prompt probes run only when the context carries `liveRuntimes`
84
+ * (the `--live-runtimes` flag). Never rejects — an unexpected throw degrades
85
+ * to a single UNKNOWN result, mirroring the hooks family's safeTier.
86
+ */
87
+ export async function checkRuntimeHealth(context, deps = {}) {
88
+ try {
89
+ return await runRuntimeChecks(context, deps);
90
+ }
91
+ catch (error) {
92
+ return [
93
+ {
94
+ status: "UNKNOWN",
95
+ checkId: `${RUNTIMES_PREFIX}.error`,
96
+ message: `AI runtime checks could not run: ${error.message}`,
97
+ },
98
+ ];
99
+ }
100
+ }
101
+ async function runRuntimeChecks(context, deps) {
102
+ const resolved = {
103
+ resolveBinary: deps.resolveBinary ?? resolveOnPath,
104
+ execProbe: deps.execProbe ?? defaultExecProbe,
105
+ timeoutMs: deps.probeTimeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS,
106
+ live: context.liveRuntimes === true,
107
+ };
108
+ // Each runtime's checks are independent, so probe them concurrently: total
109
+ // wall time is the slowest single runtime, not the sum of all three.
110
+ const perRuntime = await Promise.all(AI_RUNTIMES.map((spec) => checkOneRuntime(spec, resolved)));
111
+ return perRuntime.flat();
112
+ }
113
+ async function checkOneRuntime(spec, deps) {
114
+ const idBase = `${RUNTIMES_PREFIX}.${spec.key}`;
115
+ const resolvedPath = deps.resolveBinary(spec.binary);
116
+ if (!resolvedPath) {
117
+ return [
118
+ {
119
+ status: "WARN",
120
+ checkId: `${idBase}.binary`,
121
+ target: spec.binary,
122
+ message: `${spec.displayName} (\`${spec.binary}\`) was not found on PATH; HQ's ${spec.displayName} hook wiring can never fire on this machine.`,
123
+ remediation: `If you use ${spec.displayName} here: ${spec.installHint}.`,
124
+ },
125
+ {
126
+ status: "NA",
127
+ checkId: `${idBase}.responds`,
128
+ target: spec.binary,
129
+ message: `${spec.displayName} cannot be probed — the binary is not installed.`,
130
+ },
131
+ ];
132
+ }
133
+ const results = [
134
+ {
135
+ status: "PASS",
136
+ checkId: `${idBase}.binary`,
137
+ target: resolvedPath,
138
+ message: `${spec.displayName} (\`${spec.binary}\`) is on PATH at ${resolvedPath}.`,
139
+ },
140
+ ];
141
+ if (!deps.live) {
142
+ results.push({
143
+ status: "UNTESTED",
144
+ checkId: `${idBase}.responds`,
145
+ target: spec.binary,
146
+ message: `${spec.displayName} is installed but has not been exercised — installed is not the same as logged in and answering.`,
147
+ remediation: "Run `hq doctor --live-runtimes` to send each installed AI CLI a one-line prompt (networked; uses your subscriptions).",
148
+ });
149
+ return results;
150
+ }
151
+ // Live tier: version first (offline, auth-free), then the real prompt. The
152
+ // two run concurrently — they are independent evidence, and a broken CLI
153
+ // fails both cheaply.
154
+ const [version, probe] = await Promise.all([
155
+ deps.execProbe(resolvedPath, spec.versionArgs, deps.timeoutMs),
156
+ deps.execProbe(resolvedPath, spec.probeArgs, deps.timeoutMs),
157
+ ]);
158
+ results.push(versionResult(spec, idBase, version));
159
+ results.push(probeResult(spec, idBase, probe, deps.timeoutMs));
160
+ return results;
161
+ }
162
+ function versionResult(spec, idBase, outcome) {
163
+ const checkId = `${idBase}.version`;
164
+ if (outcome.ok) {
165
+ return {
166
+ status: "PASS",
167
+ checkId,
168
+ target: spec.binary,
169
+ message: `${spec.displayName} reports version: ${excerpt(outcome.stdout) || "(no output)"}.`,
170
+ };
171
+ }
172
+ if (outcome.timedOut || outcome.spawnError) {
173
+ return {
174
+ status: "UNKNOWN",
175
+ checkId,
176
+ target: spec.binary,
177
+ message: `${spec.displayName} version could not be read: ${outcome.timedOut ? "the command timed out" : outcome.spawnError}.`,
178
+ };
179
+ }
180
+ return {
181
+ status: "FAIL",
182
+ checkId,
183
+ target: spec.binary,
184
+ message: `\`${spec.binary} ${spec.versionArgs.join(" ")}\` exited ${outcome.code}: ${excerpt(outcome.stderr) || "(no stderr)"}.`,
185
+ };
186
+ }
187
+ function probeResult(spec, idBase, outcome, timeoutMs) {
188
+ const checkId = `${idBase}.responds`;
189
+ if (outcome.ok && outcome.stdout.trim().length > 0) {
190
+ return {
191
+ status: "PASS",
192
+ checkId,
193
+ target: spec.binary,
194
+ message: `${spec.displayName} answered a live prompt: "${excerpt(outcome.stdout)}".`,
195
+ };
196
+ }
197
+ if (outcome.timedOut) {
198
+ return {
199
+ status: "UNKNOWN",
200
+ checkId,
201
+ target: spec.binary,
202
+ message: `${spec.displayName} did not answer within ${Math.round(timeoutMs / 1000)}s — it may be hung, waiting on interactive input, or on a very slow network.`,
203
+ remediation: `Run \`${spec.binary}\` interactively to see what it is waiting on.`,
204
+ };
205
+ }
206
+ if (outcome.spawnError) {
207
+ return {
208
+ status: "UNKNOWN",
209
+ checkId,
210
+ target: spec.binary,
211
+ message: `${spec.displayName} could not be executed: ${outcome.spawnError}.`,
212
+ };
213
+ }
214
+ if (outcome.ok) {
215
+ return {
216
+ status: "FAIL",
217
+ checkId,
218
+ target: spec.binary,
219
+ message: `${spec.displayName} exited 0 but produced no output for the probe prompt — the runtime is not returning responses.`,
220
+ remediation: `Run \`${spec.binary}\` interactively to check login and subscription state.`,
221
+ };
222
+ }
223
+ return {
224
+ status: "FAIL",
225
+ checkId,
226
+ target: spec.binary,
227
+ message: `${spec.displayName} could not answer a live prompt (exit ${outcome.code}): ${excerpt(outcome.stderr) || excerpt(outcome.stdout) || "(no output)"}.`,
228
+ remediation: `Run \`${spec.binary}\` interactively to check login and subscription state.`,
229
+ };
230
+ }
231
+ /** The registered family object. */
232
+ export const runtimeHealthFamily = {
233
+ id: RUNTIMES_FAMILY_ID,
234
+ title: "AI runtime health",
235
+ run: (context) => checkRuntimeHealth(context),
236
+ };
237
+ // --- default dependencies ----------------------------------------------------
238
+ /**
239
+ * Resolve an executable name against PATH with a pure filesystem scan — no
240
+ * process is spawned, keeping the doctor's default run a function of on-disk
241
+ * shape. First PATH entry containing an executable regular file wins, which is
242
+ * exactly the copy a shell would run.
243
+ */
244
+ export function resolveOnPath(binary) {
245
+ const pathVar = process.env.PATH ?? "";
246
+ for (const dir of pathVar.split(path.delimiter)) {
247
+ if (!dir)
248
+ continue;
249
+ const candidate = path.join(dir, binary);
250
+ try {
251
+ if (!fs.statSync(candidate).isFile())
252
+ continue;
253
+ fs.accessSync(candidate, fs.constants.X_OK);
254
+ return candidate;
255
+ }
256
+ catch {
257
+ continue;
258
+ }
259
+ }
260
+ return null;
261
+ }
262
+ /**
263
+ * Spawn one probe from the OS temp directory (never the HQ tree, so a probe
264
+ * cannot trigger HQ's hook stack or leave session state), bounded by the
265
+ * timeout. Never rejects — every failure mode is folded into the outcome.
266
+ *
267
+ * stdin is `ignore` (closed at /dev/null), not a pipe: `codex exec` — and any
268
+ * CLI that reads piped stdin in non-TTY mode — blocks forever on a pipe that
269
+ * never reaches EOF, which turns every probe into a timeout.
270
+ */
271
+ function defaultExecProbe(file, args, timeoutMs) {
272
+ return new Promise((resolve) => {
273
+ const child = spawn(file, args, {
274
+ cwd: os.tmpdir(),
275
+ stdio: ["ignore", "pipe", "pipe"],
276
+ });
277
+ const cap = 1024 * 1024;
278
+ let stdout = "";
279
+ let stderr = "";
280
+ let timedOut = false;
281
+ let settled = false;
282
+ child.stdout?.setEncoding("utf8");
283
+ child.stderr?.setEncoding("utf8");
284
+ child.stdout?.on("data", (chunk) => {
285
+ if (stdout.length < cap)
286
+ stdout += chunk;
287
+ });
288
+ child.stderr?.on("data", (chunk) => {
289
+ if (stderr.length < cap)
290
+ stderr += chunk;
291
+ });
292
+ // SIGTERM at the deadline, escalating to SIGKILL for a child that ignores
293
+ // it — a hung probe must never hang the doctor itself.
294
+ const killTimer = setTimeout(() => {
295
+ timedOut = true;
296
+ child.kill("SIGTERM");
297
+ setTimeout(() => {
298
+ if (!settled)
299
+ child.kill("SIGKILL");
300
+ }, 10_000).unref();
301
+ }, timeoutMs);
302
+ const settle = (outcome) => {
303
+ if (settled)
304
+ return;
305
+ settled = true;
306
+ clearTimeout(killTimer);
307
+ resolve(outcome);
308
+ };
309
+ child.on("error", (error) => settle({
310
+ ok: false,
311
+ code: null,
312
+ stdout,
313
+ stderr,
314
+ timedOut: false,
315
+ spawnError: error.message,
316
+ }));
317
+ child.on("close", (code) => settle({
318
+ ok: code === 0 && !timedOut,
319
+ code,
320
+ stdout,
321
+ stderr,
322
+ timedOut,
323
+ }));
324
+ });
325
+ }
326
+ /** Last non-empty line of output, whitespace-collapsed and capped for display. */
327
+ function excerpt(output) {
328
+ const line = output
329
+ .split("\n")
330
+ .map((l) => l.trim())
331
+ .filter((l) => l.length > 0)
332
+ .at(-1) ?? "";
333
+ const collapsed = line.replace(/\s+/g, " ");
334
+ return collapsed.length > 120 ? `${collapsed.slice(0, 117)}...` : collapsed;
335
+ }
336
+ //# sourceMappingURL=runtime-health.js.map
@@ -18,6 +18,7 @@ import * as path from "node:path";
18
18
  import { checkCodexWiring } from "./checks/codex-wiring.js";
19
19
  import { checkGrokWiring } from "./checks/grok-wiring.js";
20
20
  import { checkRuntimeProbe } from "./checks/runtime-probe.js";
21
+ import { runtimeHealthFamily } from "./checks/runtime-health.js";
21
22
  import { fixtureCoverageFamily } from "./fixtures/discover.js";
22
23
  import { checkClaudeWiring } from "./checks/claude-wiring.js";
23
24
  /**
@@ -171,6 +172,11 @@ export function createDefaultRegistry() {
171
172
  // it needs no change to the hooks tier, per the registry's extensibility
172
173
  // contract.
173
174
  registry.register(fixtureCoverageFamily);
175
+ // AI runtime health: are the Claude / Codex / Grok CLIs installed and (with
176
+ // --live-runtimes) actually answering? Hooks wiring can be perfect while a
177
+ // runtime is logged out or broken; this family closes that blind spot. Its
178
+ // default tier is a pure PATH scan, preserving the offline contract.
179
+ registry.register(runtimeHealthFamily);
174
180
  return registry;
175
181
  }
176
182
  //# sourceMappingURL=registry.js.map
@@ -59,6 +59,13 @@ export interface CheckContext {
59
59
  * Absent means "any session's ledger counts".
60
60
  */
61
61
  sessionId?: string;
62
+ /**
63
+ * When true, the AI-runtime-health family may execute live, networked probes
64
+ * of the installed AI CLIs (the `--live-runtimes` flag). Absent or false
65
+ * keeps the run offline: the live checks report UNTESTED instead of
66
+ * executing anything.
67
+ */
68
+ liveRuntimes?: boolean;
62
69
  }
63
70
  /**
64
71
  * A check family: an id, a human title, and an async `run` returning per-item
@@ -71,7 +71,7 @@ export declare function buildSelfUpdatePlan(install: RunningInstall): {
71
71
  * to stdout. Progress is summarised on stderr by the caller instead; captured
72
72
  * stderr is kept only to explain a failure.
73
73
  */
74
- export declare function runUpdateQuiet(cmd: string, args: string[]): UpdateResult;
74
+ export declare function runUpdateQuiet(cmd: string, args: string[], env?: NodeJS.ProcessEnv): UpdateResult;
75
75
  /**
76
76
  * Serialize self-updates across concurrent `hq` processes. Without this, a
77
77
  * machine running several HQ agents can fire many `npm install -g` at the same
@@ -90,7 +90,7 @@ export interface SelfUpdateDeps {
90
90
  currentVersion?: string;
91
91
  fetchLatest?: () => Promise<string | null>;
92
92
  resolveInstall?: () => RunningInstall;
93
- runner?: (cmd: string, args: string[]) => UpdateResult;
93
+ runner?: (cmd: string, args: string[], env?: NodeJS.ProcessEnv) => UpdateResult;
94
94
  reexec?: (argv: string[], env: NodeJS.ProcessEnv) => number | null;
95
95
  acquireLock?: () => (() => void) | null;
96
96
  }
@@ -38,7 +38,7 @@ import * as path from "node:path";
38
38
  import semver from "semver";
39
39
  import chalk from "chalk";
40
40
  import { CLI_NAME, CLI_VERSION } from "../cli-version.js";
41
- import { buildBunInstallArgv, buildPnpmInstallArgv, buildPrefixedInstallArgv, buildSpawnPlan, resolveRunningInstall, runUpdateCommand, } from "./version-gate.js";
41
+ import { buildBunInstallArgv, buildPnpmInstallArgv, buildPrefixedInstallArgv, buildSpawnPlan, pnpmUpdateEnv, resolveRunningInstall, runUpdateCommand, } from "./version-gate.js";
42
42
  /**
43
43
  * Set on the re-exec'd child so it can never self-update (and re-exec) again.
44
44
  * One update + one re-exec per user invocation, ever.
@@ -88,13 +88,14 @@ export function buildSelfUpdatePlan(install) {
88
88
  * to stdout. Progress is summarised on stderr by the caller instead; captured
89
89
  * stderr is kept only to explain a failure.
90
90
  */
91
- export function runUpdateQuiet(cmd, args) {
91
+ export function runUpdateQuiet(cmd, args, env) {
92
92
  try {
93
93
  const plan = buildSpawnPlan(cmd, args);
94
94
  const result = spawnSync(plan.cmd, plan.args, {
95
95
  stdio: ["ignore", "pipe", "pipe"],
96
96
  shell: plan.shell,
97
97
  encoding: "utf-8",
98
+ ...(env ? { env } : {}),
98
99
  });
99
100
  if (result.error) {
100
101
  const code = result.error.code;
@@ -200,10 +201,25 @@ async function updateAndReexec(argv, flavor, known, deps) {
200
201
  let result;
201
202
  const install = (deps.resolveInstall ?? resolveRunningInstall)();
202
203
  const plan = buildSelfUpdatePlan(install);
204
+ // A pnpm global install needs PNPM_HOME to find its global bin dir. A
205
+ // minimal-environment parent (systemd, cron, non-login shell) lacks it and
206
+ // `pnpm add -g` aborts with ERR_PNPM_NO_GLOBAL_BIN_DIR (exit 1), so the
207
+ // self-update never lands until the box is next touched from an interactive
208
+ // shell. Derive PNPM_HOME from the running install so the update works
209
+ // regardless of the parent environment. No-op for npm/bun and when the
210
+ // parent already sets PNPM_HOME.
211
+ const updateEnv = pnpmUpdateEnv(install, env);
203
212
  try {
204
213
  console.error(chalk.dim(`Updating hq-cli ${current} → ${latest}…`));
205
214
  const defaultRunner = flavor.verbose ? runUpdateCommand : runUpdateQuiet;
206
- result = (deps.runner ?? defaultRunner)(plan.cmd, plan.args);
215
+ const runner = deps.runner ?? defaultRunner;
216
+ // Forward env only when PNPM_HOME had to be injected, so the common path
217
+ // spawns with the inherited environment and keeps the two-argument runner
218
+ // call it has always made.
219
+ result =
220
+ updateEnv === undefined
221
+ ? runner(plan.cmd, plan.args)
222
+ : runner(plan.cmd, plan.args, updateEnv);
207
223
  }
208
224
  finally {
209
225
  releaseLock();
@@ -96,6 +96,35 @@ export declare function resolveRunningInstall(): RunningInstall;
96
96
  export declare function resolveRunningManager(): InstallManager;
97
97
  /** Convenience view of {@link resolveRunningInstall} for callers needing one field. */
98
98
  export declare function resolveRunningPrefix(): string | null;
99
+ /**
100
+ * Derive `PNPM_HOME` from a pnpm-managed install's own path. pnpm resolves its
101
+ * global bin directory from `PNPM_HOME` (or an explicit `global-bin-dir`), and
102
+ * `PNPM_HOME` is the path segment immediately preceding pnpm's global root, e.g.
103
+ *
104
+ * /home/u/.local/share/pnpm/global/5/… -> PNPM_HOME=/home/u/.local/share/pnpm
105
+ *
106
+ * Returns null for non-pnpm installs or a `packageRoot` with no `/global/`
107
+ * segment (nothing to derive from). Reading it from the running install is more
108
+ * accurate than trusting the inherited environment: it names the exact copy
109
+ * that is actually on PATH.
110
+ */
111
+ export declare function derivePnpmHome(install: RunningInstall): string | null;
112
+ /**
113
+ * Environment for the pnpm self-update spawn. `pnpm add -g` aborts with
114
+ * `ERR_PNPM_NO_GLOBAL_BIN_DIR` (exit 1) when neither `PNPM_HOME` nor a
115
+ * `global-bin-dir` setting is present — the exact failure seen when `hq`
116
+ * self-updates from a minimal-environment parent (a systemd `--user` service,
117
+ * cron, or any non-login shell that never sourced the profile exporting
118
+ * `PNPM_HOME`). When the running install is pnpm-managed and the parent lacks
119
+ * `PNPM_HOME`, inject one derived from the install's own path so the update can
120
+ * find the global bin dir it is about to rewrite.
121
+ *
122
+ * A no-op for npm/bun installs, and for any environment that already sets
123
+ * `PNPM_HOME` (never overridden — the caller's value wins). Returns `undefined`
124
+ * when nothing needs injecting, so callers can spawn with the inherited
125
+ * environment unchanged (and pass no `env` at all).
126
+ */
127
+ export declare function pnpmUpdateEnv(install: RunningInstall, base?: NodeJS.ProcessEnv): NodeJS.ProcessEnv | undefined;
99
128
  export declare function buildPrefixedInstallArgv(prefix: string): string[];
100
129
  /**
101
130
  * Argv for updating a pnpm-managed global install. `pnpm add -g` rewrites the
@@ -150,10 +179,10 @@ export type UpdateResult = {
150
179
  detail?: string;
151
180
  code?: string;
152
181
  };
153
- type UpdateRunner = (cmd: string, args: string[]) => UpdateResult;
182
+ type UpdateRunner = (cmd: string, args: string[], env?: NodeJS.ProcessEnv) => UpdateResult;
154
183
  export { buildSpawnPlan, quoteForWindowsShell };
155
- export declare function runUpdateCommand(cmd: string, args: string[]): UpdateResult;
156
- declare function performUpdateCommand(cmd: string, args: string[], runner?: UpdateRunner): UpdateResult;
184
+ export declare function runUpdateCommand(cmd: string, args: string[], env?: NodeJS.ProcessEnv): UpdateResult;
185
+ declare function performUpdateCommand(cmd: string, args: string[], runner?: UpdateRunner, env?: NodeJS.ProcessEnv): UpdateResult;
157
186
  declare function performUpdate(command: string, runner?: UpdateRunner): UpdateResult;
158
187
  /**
159
188
  * Soft notify when the server says we're below `latestVersion` but still ≥
@@ -218,8 +247,10 @@ export declare const __test__: {
218
247
  buildPrefixedInstallArgv: typeof buildPrefixedInstallArgv;
219
248
  buildSpawnPlan: typeof buildSpawnPlan;
220
249
  cleanStalePartialInstall: typeof cleanStalePartialInstall;
250
+ derivePnpmHome: typeof derivePnpmHome;
221
251
  enforceUpdateRequired: typeof enforceUpdateRequired;
222
252
  isBunManagedPackageDir: typeof isBunManagedPackageDir;
253
+ pnpmUpdateEnv: typeof pnpmUpdateEnv;
223
254
  isPnpmManagedPackageDir: typeof isPnpmManagedPackageDir;
224
255
  npmPrefixFromPackageDir: typeof npmPrefixFromPackageDir;
225
256
  nudgeUpdateRecommended: typeof nudgeUpdateRecommended;
@@ -167,6 +167,51 @@ export function resolveRunningManager() {
167
167
  export function resolveRunningPrefix() {
168
168
  return resolveRunningInstall().prefix;
169
169
  }
170
+ /**
171
+ * Derive `PNPM_HOME` from a pnpm-managed install's own path. pnpm resolves its
172
+ * global bin directory from `PNPM_HOME` (or an explicit `global-bin-dir`), and
173
+ * `PNPM_HOME` is the path segment immediately preceding pnpm's global root, e.g.
174
+ *
175
+ * /home/u/.local/share/pnpm/global/5/… -> PNPM_HOME=/home/u/.local/share/pnpm
176
+ *
177
+ * Returns null for non-pnpm installs or a `packageRoot` with no `/global/`
178
+ * segment (nothing to derive from). Reading it from the running install is more
179
+ * accurate than trusting the inherited environment: it names the exact copy
180
+ * that is actually on PATH.
181
+ */
182
+ export function derivePnpmHome(install) {
183
+ if (install.manager !== "pnpm" || !install.packageRoot)
184
+ return null;
185
+ const normalized = install.packageRoot.replace(/\\/g, "/");
186
+ const marker = normalized.indexOf("/global/");
187
+ if (marker === -1)
188
+ return null;
189
+ const home = normalized.slice(0, marker);
190
+ return home || null;
191
+ }
192
+ /**
193
+ * Environment for the pnpm self-update spawn. `pnpm add -g` aborts with
194
+ * `ERR_PNPM_NO_GLOBAL_BIN_DIR` (exit 1) when neither `PNPM_HOME` nor a
195
+ * `global-bin-dir` setting is present — the exact failure seen when `hq`
196
+ * self-updates from a minimal-environment parent (a systemd `--user` service,
197
+ * cron, or any non-login shell that never sourced the profile exporting
198
+ * `PNPM_HOME`). When the running install is pnpm-managed and the parent lacks
199
+ * `PNPM_HOME`, inject one derived from the install's own path so the update can
200
+ * find the global bin dir it is about to rewrite.
201
+ *
202
+ * A no-op for npm/bun installs, and for any environment that already sets
203
+ * `PNPM_HOME` (never overridden — the caller's value wins). Returns `undefined`
204
+ * when nothing needs injecting, so callers can spawn with the inherited
205
+ * environment unchanged (and pass no `env` at all).
206
+ */
207
+ export function pnpmUpdateEnv(install, base = process.env) {
208
+ if (install.manager !== "pnpm" || base.PNPM_HOME)
209
+ return undefined;
210
+ const home = derivePnpmHome(install);
211
+ if (!home)
212
+ return undefined;
213
+ return { ...base, PNPM_HOME: home };
214
+ }
170
215
  export function buildPrefixedInstallArgv(prefix) {
171
216
  return ["install", "-g", "--prefix", prefix, LATEST_PACKAGE_SPEC];
172
217
  }
@@ -307,12 +352,13 @@ async function fetchVersionDecision() {
307
352
  // public surface (and the `__test__` block below) stable for existing callers
308
353
  // and tests.
309
354
  export { buildSpawnPlan, quoteForWindowsShell };
310
- export function runUpdateCommand(cmd, args) {
355
+ export function runUpdateCommand(cmd, args, env) {
311
356
  try {
312
357
  const plan = buildSpawnPlan(cmd, args);
313
358
  const result = spawnSync(plan.cmd, plan.args, {
314
359
  stdio: "inherit",
315
360
  shell: plan.shell,
361
+ ...(env ? { env } : {}),
316
362
  });
317
363
  // spawnSync reports a missing executable via `error`, not a throw.
318
364
  if (result.error) {
@@ -335,8 +381,11 @@ export function runUpdateCommand(cmd, args) {
335
381
  };
336
382
  }
337
383
  }
338
- function performUpdateCommand(cmd, args, runner = runUpdateCommand) {
339
- return runner(cmd, args);
384
+ function performUpdateCommand(cmd, args, runner = runUpdateCommand, env) {
385
+ // Forward env only when there is one to forward, so the common path keeps the
386
+ // two-argument runner call it has always made (and existing runner spies see
387
+ // no phantom `undefined` third argument).
388
+ return env === undefined ? runner(cmd, args) : runner(cmd, args, env);
340
389
  }
341
390
  function performUpdate(command, runner = runUpdateCommand) {
342
391
  const parts = command.split(/\s+/).filter(Boolean);
@@ -442,11 +491,17 @@ function enforceUpdateRequired(decision, deps = {}) {
442
491
  primaryCmd = parts[0] ?? "";
443
492
  primaryArgs = parts.slice(1);
444
493
  }
494
+ // A pnpm global install needs PNPM_HOME to locate its global bin dir; a
495
+ // minimal-environment parent (systemd, cron, non-login shell) lacks it and
496
+ // `pnpm add -g` would abort with ERR_PNPM_NO_GLOBAL_BIN_DIR. Derive one from
497
+ // the running install so the update is not environment-dependent. No-op for
498
+ // npm/bun and when PNPM_HOME is already set.
499
+ const updateEnv = pnpmUpdateEnv(install);
445
500
  let result;
446
501
  if (isManagedOutsideNpm) {
447
502
  console.error(chalk.dim(` Detected a ${install.manager}-managed global install; updating with ${install.manager}`));
448
503
  console.error(chalk.dim(` Running: ${primaryCmd} ${primaryArgs.join(" ")}`));
449
- result = performUpdateCommand(primaryCmd, primaryArgs, runner);
504
+ result = performUpdateCommand(primaryCmd, primaryArgs, runner, updateEnv);
450
505
  }
451
506
  else if (prefix) {
452
507
  console.error(chalk.dim(` Installing into npm prefix: ${prefix}`));
@@ -558,8 +613,10 @@ export const __test__ = {
558
613
  buildPrefixedInstallArgv,
559
614
  buildSpawnPlan,
560
615
  cleanStalePartialInstall,
616
+ derivePnpmHome,
561
617
  enforceUpdateRequired,
562
618
  isBunManagedPackageDir,
619
+ pnpmUpdateEnv,
563
620
  isPnpmManagedPackageDir,
564
621
  npmPrefixFromPackageDir,
565
622
  nudgeUpdateRecommended,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.102.0",
3
+ "version": "5.103.0",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {