@indigoai-us/hq-cli 5.108.17 → 5.108.18

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,8 +2,44 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.108.18] — 2026-09-07
6
+
7
+ ### Fixed
8
+
9
+ - A global `hq` reinstall running underneath a starting command no longer
10
+ crashes it (Sentry HQ-CLI-1G/1H/1J/1K — 7713386011, 7713482655, 7713783135,
11
+ 7714501738). `hq` resolves most of its command graph lazily as its first real
12
+ step, so when an external updater — the outpost `hq-cli-update` timer, an
13
+ agent box's self-update, or the desktop app — renames the installed package
14
+ aside and re-extracts it file by file (~16s every 6h on every box), a command
15
+ that started a moment earlier resolved a deferred import against a directory
16
+ that no longer held it and died with `ERR_MODULE_NOT_FOUND` /
17
+ `MODULE_NOT_FOUND`, filing a separate high-priority crash for each missing
18
+ path. hq-cli cannot stop the foreign updater, but the registration phase is
19
+ provably side-effect free (it runs before any command action), so a
20
+ resolution failure there is now caught: the CLI prints one informational line,
21
+ waits — bounded, default 90s, `HQ_INSTALL_SETTLE_TIMEOUT_MS` to override — for
22
+ the install to settle (shared update lock released, the missing target back,
23
+ the retired `.hq-cli-*` sibling gone, a healthy manifest), and then re-runs
24
+ the command once on the settled tree. A genuinely broken or incomplete install
25
+ still reports exactly once, with a reinstall remedy naming the missing module
26
+ and bounded diagnostics, so real packaging faults stay visible. The recovery
27
+ re-execs at most once per invocation and never runs a command twice.
28
+
5
29
  ## [5.108.17] — 2026-09-07
6
30
 
31
+ ### Fixed
32
+
33
+ - The Work Mesh Live daemon's transcript watcher no longer rescans every file
34
+ under `~/.claude/projects` and `~/.codex/sessions` every 15 seconds. It keeps a
35
+ per-directory mtime cache and only relists directories that changed, re-stats
36
+ recently active files, revalidates every cached directory at least every five
37
+ minutes (content appends do not change a directory's mtime, so a session that
38
+ resumes after idling is still picked up), backs the interval off from 30 s to
39
+ 120 s on busy machines, caps the entries examined per tick with per-root
40
+ budgets, never runs two ticks at once, and bounds its cache. Capped ticks skip
41
+ disappearance reconciliation so an unvisited session is never ended falsely.
42
+
7
43
  ## [5.108.16] — 2026-09-07
8
44
 
9
45
  ### Fixed
@@ -41,15 +77,6 @@
41
77
  at most every five minutes, and `held.jsonl` is capped at 20,000 lines with
42
78
  the oldest overflow dead-lettered as `HELD_OVERFLOW` (loss-free under failure
43
79
  and concurrent flushers). (#525)
44
- - The Work Mesh Live daemon's transcript watcher no longer rescans every file
45
- under `~/.claude/projects` and `~/.codex/sessions` every 15 seconds. It keeps a
46
- per-directory mtime cache and only relists directories that changed, re-stats
47
- recently active files, revalidates every cached directory at least every five
48
- minutes (content appends do not change a directory's mtime, so a session that
49
- resumes after idling is still picked up), backs the interval off from 30 s to
50
- 120 s on busy machines, caps the entries examined per tick with per-root
51
- budgets, never runs two ticks at once, and bounds its cache. Capped ticks skip
52
- disappearance reconciliation so an unvisited session is never ended falsely.
53
80
 
54
81
  ## [5.108.14] — 2026-09-07
55
82
 
@@ -81,15 +108,6 @@
81
108
  `incomplete_install` context so the next occurrence is attributable. The drop
82
109
  is wired both at the top-level boundary and in the shared `beforeSend`, so it
83
110
  covers every capture route.
84
- - The Work Mesh Live daemon's transcript watcher no longer rescans every file
85
- under `~/.claude/projects` and `~/.codex/sessions` every 15 seconds. It keeps a
86
- per-directory mtime cache and only relists directories that changed, re-stats
87
- recently active files, revalidates every cached directory at least every five
88
- minutes (content appends do not change a directory's mtime, so a session that
89
- resumes after idling is still picked up), backs the interval off from 30 s to
90
- 120 s on busy machines, caps the entries examined per tick with per-root
91
- budgets, never runs two ticks at once, and bounds its cache. Capped ticks skip
92
- disappearance reconciliation so an unvisited session is never ended falsely.
93
111
 
94
112
  ## [5.108.13] — 2026-09-06
95
113
 
package/dist/main.js CHANGED
@@ -6,6 +6,8 @@
6
6
  // Node 20+ API (e.g. util.styleText) or a newer native ABI is evaluated.
7
7
  import "./node-preflight.js";
8
8
  import "./node-network-compat.js";
9
+ import path from "node:path";
10
+ import { fileURLToPath } from "node:url";
9
11
  import { Command } from "commander";
10
12
  import { initSentry, Sentry } from "./sentry.js";
11
13
  import { sanitizeArgv } from "./utils/feedback-diagnostics.js";
@@ -46,10 +48,19 @@ import { settleWithin } from "./utils/settle-with-timeout.js";
46
48
  import { emitPlanLimitNag } from "./lib/plan-limit-nag.js";
47
49
  import { kickFlagRegistryReadiness } from "./lib/flag-registry.js";
48
50
  import { isPackageRootResolutionError, packageRootCaptureContext, } from "./utils/package-root-diagnostics.js";
51
+ import { registerCommandsWithRecovery } from "./startup-registration.js";
52
+ import { installTreeTornCaptureContext, installTreeTornStderrLine, isInstallTreeTornError, } from "./utils/install-tree-torn.js";
49
53
  import { isVaultAccessDeniedError, vaultAccessDeniedMessage, } from "./utils/vault-access-denied-error.js";
50
54
  import { fallbackOperatorMessage, unexpectedCliErrorMessage } from "./utils/unexpected-cli-error.js";
51
55
  /** Hard upper bound for non-user-visible release-health finalization. */
52
56
  const RELEASE_HEALTH_SETTLE_TIMEOUT_MS = 3_000;
57
+ /**
58
+ * The RUNNING install's own entrypoint (`<pkg>/dist/index.js`), used as the
59
+ * torn-install recovery re-exec target. It must be this resolved path — never
60
+ * `hq` from PATH — because a global reinstall leaves `bin/hq` absent for much of
61
+ * the rewrite window, while this path now holds the settled tree.
62
+ */
63
+ const RUNNING_ENTRY_PATH = path.join(path.dirname(fileURLToPath(import.meta.url)), "index.js");
53
64
  const defaultStreamErrorDependencies = {
54
65
  stderr: process.stderr,
55
66
  exit: (code) => process.exit(code),
@@ -160,13 +171,31 @@ export async function runCli() {
160
171
  // `--help`, a bare `hq`, an unknown command, any command not on the
161
172
  // manifest — falls back to the complete graph, so its behaviour is
162
173
  // unchanged. See register-all.ts for the measurements that motivated this.
163
- const lazy = findLazyCommand(process.argv);
164
- if (lazy) {
165
- await lazy.register(program);
166
- }
167
- else {
168
- const { registerAllCommands } = await import("./register-all.js");
169
- registerAllCommands(program);
174
+ // The same lazy/full registration as before, wrapped so it recovers ONCE if
175
+ // a global reinstall is tearing the install tree out from under these
176
+ // deferred imports (Sentry HQ-CLI-1G/1H/1J/1K). Which modules are imported,
177
+ // and in what order, is unchanged — only the failure handling is added.
178
+ const registration = await registerCommandsWithRecovery({
179
+ register: async () => {
180
+ const lazy = findLazyCommand(process.argv);
181
+ if (lazy) {
182
+ await lazy.register(program);
183
+ }
184
+ else {
185
+ const { registerAllCommands } = await import("./register-all.js");
186
+ registerAllCommands(program);
187
+ }
188
+ },
189
+ argv: process.argv,
190
+ env: process.env,
191
+ entryPath: RUNNING_ENTRY_PATH,
192
+ stderr: process.stderr,
193
+ });
194
+ if (registration.reexecStatus !== undefined) {
195
+ // The re-exec'd child ran the command on the settled tree; carry its exit
196
+ // status out (after the finally block), exactly as the self-update re-exec.
197
+ reexecStatus = registration.reexecStatus;
198
+ return;
170
199
  }
171
200
  await program.parseAsync();
172
201
  }
@@ -337,6 +366,21 @@ export async function handleTopLevelError(err, deps = defaultTopLevelErrorDepend
337
366
  });
338
367
  deps.setExitCode(1);
339
368
  }
369
+ else if (isInstallTreeTornError(err)) {
370
+ // HQ-CLI-1G/1H/1J/1K: a global reinstall tore the install tree out from
371
+ // under a starting `hq` and the bounded settle-wait + single re-exec could
372
+ // not recover it (a still-incomplete tree, a foreign updater, or a failed
373
+ // re-exec). Print the fixed reinstall remedy naming the bounded missing
374
+ // specifier, and STILL capture WITH bounded diagnostics — a genuinely
375
+ // pruned or incomplete install must stay visible. Mirrors the package-root
376
+ // branch's print-and-capture shape; a class-instance check disjoint from
377
+ // every neighbour, so no existing ordering changes. A SUCCESSFUL recovery
378
+ // never reaches here (it returns the child's status), so this line and this
379
+ // capture only ever mark a real, unrecovered break.
380
+ deps.stderr.write(`hq: ${installTreeTornStderrLine(err)}\n`);
381
+ deps.sentry.captureException(err, installTreeTornCaptureContext(err));
382
+ deps.setExitCode(1);
383
+ }
340
384
  else if (isVaultAccessDeniedError(err)) {
341
385
  // ARM B (Sentry 7709408531): `hq skill create`'s post-register vault sync
342
386
  // was DENIED by S3 — an AWS SDK v3 403 from an hq-pro IAM session-policy
@@ -0,0 +1,79 @@
1
+ /**
2
+ * The command-registration recovery seam for the "install tree torn out from
3
+ * under a starting `hq`" condition (Sentry HQ-CLI-1G/1H/1J/1K).
4
+ *
5
+ * `runCli` registers commands as its last step before `program.parseAsync()` —
6
+ * the lazy command import or `import("./register-all.js")` and everything those
7
+ * pull in. That is the ONE place a module-resolution failure is provably safe to
8
+ * recover from: it is inside hq-cli's own graph, it runs before `parseAsync` and
9
+ * before the preAction hook, so no command action, telemetry, or stdin read has
10
+ * happened yet, and a single re-exec can therefore never run a command twice.
11
+ *
12
+ * This wraps the existing `register()` call with exactly that recovery, driven by
13
+ * the classifier / probe / settle wait / carrier in ./utils/install-tree-torn.ts.
14
+ * It changes nothing about WHICH modules are imported or in what order — only the
15
+ * failure handling around the existing imports. Dependency-injected in the house
16
+ * style of handleTopLevelError so the whole seam is unit-testable without real
17
+ * spawns, waits, or a real install tree.
18
+ */
19
+ import { type WaitForInstallTreeSettledArgs, type WaitForInstallTreeSettledResult } from "./utils/install-tree-torn.js";
20
+ /**
21
+ * Set on the re-exec'd child so it can NEVER wait or re-exec again — distinct
22
+ * from self-update's HQ_RESCUE_SELF_UPDATED so the two recovery paths cannot
23
+ * shadow each other.
24
+ */
25
+ export declare const INSTALL_TREE_RECOVERY_GUARD_ENV = "HQ_INSTALL_TREE_RECOVERED";
26
+ /** Operator override for the settle wait's deadline. */
27
+ export declare const INSTALL_SETTLE_TIMEOUT_ENV = "HQ_INSTALL_SETTLE_TIMEOUT_MS";
28
+ /** Default settle deadline (ms). Overridable by {@link INSTALL_SETTLE_TIMEOUT_ENV}. */
29
+ export declare const DEFAULT_INSTALL_SETTLE_TIMEOUT_MS = 90000;
30
+ /**
31
+ * The single dim, informational line emitted when — and only when — a wait
32
+ * actually happens. It tells a human why the command paused and is the e2e's
33
+ * synchronization point; it is never a remedy and never sets an error exit code.
34
+ */
35
+ export declare const INSTALL_TREE_WAIT_NOTICE = "hq: the hq-cli install is being updated underneath this command; waiting for it to finish\u2026";
36
+ /** Resolve the settle deadline from the environment (0 = evaluate once; default on invalid). */
37
+ export declare function resolveSettleTimeoutMs(env: NodeJS.ProcessEnv): number;
38
+ /** Minimal view of a `spawnSync` result the seam relies on. */
39
+ export interface RecoverySpawnResult {
40
+ status: number | null;
41
+ error?: Error;
42
+ }
43
+ export interface RegisterRecoveryDeps {
44
+ spawn?: (command: string, args: string[], options: {
45
+ stdio: "inherit";
46
+ env: NodeJS.ProcessEnv;
47
+ }) => RecoverySpawnResult;
48
+ resolveInstall?: () => {
49
+ packageRoot: string | null;
50
+ };
51
+ lockPath?: () => string;
52
+ waitForSettled?: (args: WaitForInstallTreeSettledArgs) => Promise<WaitForInstallTreeSettledResult>;
53
+ /** node flags to forward to the re-exec child (defaults to process.execArgv). */
54
+ execArgv?: readonly string[];
55
+ }
56
+ export interface RegisterCommandsWithRecoveryArgs {
57
+ /** Runs the existing registration (lazy command import or register-all import). */
58
+ register: () => Promise<void>;
59
+ argv: readonly string[];
60
+ env: NodeJS.ProcessEnv;
61
+ /** The RUNNING install's own `dist/index.js` — the safe re-exec target. */
62
+ entryPath: string;
63
+ stderr: Pick<typeof process.stderr, "write">;
64
+ deps?: RegisterRecoveryDeps;
65
+ }
66
+ export interface RegisterCommandsWithRecoveryResult {
67
+ /** Set only when a re-exec ran: its exit status becomes this invocation's. */
68
+ reexecStatus?: number;
69
+ }
70
+ /**
71
+ * Register commands, recovering once from a torn-install module-resolution
72
+ * failure. On a clean registration this returns `{}` and touches nothing else.
73
+ * On a classified failure it waits (bounded) for the install to settle and
74
+ * re-execs the running install's own entrypoint once; a child (guarded), an
75
+ * unsettled wait, or a failed spawn each throws an {@link InstallTreeTornError}
76
+ * carrying bounded diagnostics so a genuinely broken install stays visible.
77
+ */
78
+ export declare function registerCommandsWithRecovery(args: RegisterCommandsWithRecoveryArgs): Promise<RegisterCommandsWithRecoveryResult>;
79
+ //# sourceMappingURL=startup-registration.d.ts.map
@@ -0,0 +1,134 @@
1
+ /**
2
+ * The command-registration recovery seam for the "install tree torn out from
3
+ * under a starting `hq`" condition (Sentry HQ-CLI-1G/1H/1J/1K).
4
+ *
5
+ * `runCli` registers commands as its last step before `program.parseAsync()` —
6
+ * the lazy command import or `import("./register-all.js")` and everything those
7
+ * pull in. That is the ONE place a module-resolution failure is provably safe to
8
+ * recover from: it is inside hq-cli's own graph, it runs before `parseAsync` and
9
+ * before the preAction hook, so no command action, telemetry, or stdin read has
10
+ * happened yet, and a single re-exec can therefore never run a command twice.
11
+ *
12
+ * This wraps the existing `register()` call with exactly that recovery, driven by
13
+ * the classifier / probe / settle wait / carrier in ./utils/install-tree-torn.ts.
14
+ * It changes nothing about WHICH modules are imported or in what order — only the
15
+ * failure handling around the existing imports. Dependency-injected in the house
16
+ * style of handleTopLevelError so the whole seam is unit-testable without real
17
+ * spawns, waits, or a real install tree.
18
+ */
19
+ import { spawnSync } from "node:child_process";
20
+ import { resolveRunningInstall } from "./utils/version-gate.js";
21
+ import { updateLockPath } from "./utils/update-lock.js";
22
+ import { classifyModuleNotFound, InstallTreeTornError, waitForInstallTreeSettled, } from "./utils/install-tree-torn.js";
23
+ /**
24
+ * Set on the re-exec'd child so it can NEVER wait or re-exec again — distinct
25
+ * from self-update's HQ_RESCUE_SELF_UPDATED so the two recovery paths cannot
26
+ * shadow each other.
27
+ */
28
+ export const INSTALL_TREE_RECOVERY_GUARD_ENV = "HQ_INSTALL_TREE_RECOVERED";
29
+ /** Operator override for the settle wait's deadline. */
30
+ export const INSTALL_SETTLE_TIMEOUT_ENV = "HQ_INSTALL_SETTLE_TIMEOUT_MS";
31
+ /** Default settle deadline (ms). Overridable by {@link INSTALL_SETTLE_TIMEOUT_ENV}. */
32
+ export const DEFAULT_INSTALL_SETTLE_TIMEOUT_MS = 90_000;
33
+ /**
34
+ * The single dim, informational line emitted when — and only when — a wait
35
+ * actually happens. It tells a human why the command paused and is the e2e's
36
+ * synchronization point; it is never a remedy and never sets an error exit code.
37
+ */
38
+ export const INSTALL_TREE_WAIT_NOTICE = "hq: the hq-cli install is being updated underneath this command; waiting for it to finish…";
39
+ /** Resolve the settle deadline from the environment (0 = evaluate once; default on invalid). */
40
+ export function resolveSettleTimeoutMs(env) {
41
+ const raw = env[INSTALL_SETTLE_TIMEOUT_ENV];
42
+ if (typeof raw !== "string" || raw.trim() === "")
43
+ return DEFAULT_INSTALL_SETTLE_TIMEOUT_MS;
44
+ if (!/^\d+$/.test(raw.trim()))
45
+ return DEFAULT_INSTALL_SETTLE_TIMEOUT_MS;
46
+ return Number.parseInt(raw.trim(), 10);
47
+ }
48
+ /**
49
+ * Register commands, recovering once from a torn-install module-resolution
50
+ * failure. On a clean registration this returns `{}` and touches nothing else.
51
+ * On a classified failure it waits (bounded) for the install to settle and
52
+ * re-execs the running install's own entrypoint once; a child (guarded), an
53
+ * unsettled wait, or a failed spawn each throws an {@link InstallTreeTornError}
54
+ * carrying bounded diagnostics so a genuinely broken install stays visible.
55
+ */
56
+ export async function registerCommandsWithRecovery(args) {
57
+ const { register, argv, env, entryPath, stderr } = args;
58
+ const deps = args.deps ?? {};
59
+ try {
60
+ await register();
61
+ return {};
62
+ }
63
+ catch (err) {
64
+ const classified = classifyModuleNotFound(err);
65
+ // Not a module-resolution failure — rethrow the SAME object to the existing
66
+ // boundary, preserving today's behaviour exactly.
67
+ if (!classified)
68
+ throw err;
69
+ const packageRoot = resolvePackageRoot(deps);
70
+ // A child that already recovered must never wait or re-exec again: report
71
+ // once and let the boundary capture it.
72
+ if (env[INSTALL_TREE_RECOVERY_GUARD_ENV] === "1") {
73
+ throw new InstallTreeTornError({
74
+ cause: err,
75
+ classified,
76
+ packageRoot,
77
+ outcome: "guarded",
78
+ attempt: 2,
79
+ waitedMs: 0,
80
+ sawLock: false,
81
+ sawRetired: false,
82
+ });
83
+ }
84
+ stderr.write(`${INSTALL_TREE_WAIT_NOTICE}\n`);
85
+ const lockPath = (deps.lockPath ?? updateLockPath)();
86
+ const waitFor = deps.waitForSettled ?? waitForInstallTreeSettled;
87
+ const result = await waitFor({
88
+ target: classified.target,
89
+ packageRoot,
90
+ lockPath,
91
+ deadlineMs: resolveSettleTimeoutMs(env),
92
+ });
93
+ if (!result.settled) {
94
+ throw new InstallTreeTornError({
95
+ cause: err,
96
+ classified,
97
+ packageRoot,
98
+ outcome: "unsettled",
99
+ attempt: 1,
100
+ waitedMs: result.waitedMs,
101
+ sawLock: result.sawLock,
102
+ sawRetired: result.sawRetired,
103
+ });
104
+ }
105
+ const spawn = deps.spawn ?? spawnSync;
106
+ const execArgv = deps.execArgv ?? process.execArgv;
107
+ const child = spawn(process.execPath, [...execArgv, entryPath, ...argv.slice(2)], { stdio: "inherit", env: { ...env, [INSTALL_TREE_RECOVERY_GUARD_ENV]: "1" } });
108
+ if (child.error) {
109
+ throw new InstallTreeTornError({
110
+ cause: err,
111
+ classified,
112
+ packageRoot,
113
+ outcome: "reexec-failed",
114
+ attempt: 1,
115
+ waitedMs: result.waitedMs,
116
+ sawLock: result.sawLock,
117
+ sawRetired: result.sawRetired,
118
+ });
119
+ }
120
+ // A signal-killed child has status null; surface it as a failure exit rather
121
+ // than pretending the command completed (mirrors self-update's reexecHq).
122
+ return { reexecStatus: child.status ?? 1 };
123
+ }
124
+ }
125
+ /** Resolve the running install's package dir, tolerating any resolver failure. */
126
+ function resolvePackageRoot(deps) {
127
+ try {
128
+ return (deps.resolveInstall ?? resolveRunningInstall)().packageRoot;
129
+ }
130
+ catch {
131
+ return null;
132
+ }
133
+ }
134
+ //# sourceMappingURL=startup-registration.js.map
@@ -0,0 +1,204 @@
1
+ /**
2
+ * Detection and bounded, recovery-safe reporting for the "install tree torn out
3
+ * from under a starting `hq`" condition (Sentry HQ-CLI-1G/1H/1J/1K —
4
+ * 7713386011, 7713482655, 7713783135, 7714501738).
5
+ *
6
+ * hq-cli resolves most of its module graph lazily INSIDE `runCli` (the lazy
7
+ * command module or `import("./register-all.js")`, plus everything those pull in
8
+ * — nested `@aws-sdk/*`, `js-yaml`, `@indigoai-us/hq-cloud`). An EXTERNAL global
9
+ * reinstall — the outpost `hq-cli-update` systemd timer every 6h, the agent
10
+ * boxes' `self_update`, the desktop app — renames the installed package dir
11
+ * aside (`.hq-cli-<rand>`), recreates it empty, and re-extracts it file by file
12
+ * over ~16s. A process that started just before that rename resolves its
13
+ * deferred imports against a directory that no longer holds them and dies with
14
+ * ERR_MODULE_NOT_FOUND / MODULE_NOT_FOUND. None of those writers is the process's
15
+ * OWN updater (the version-gate and self-updater install synchronously under the
16
+ * shared lock and never overlap their own imports), so hq-cli cannot stop them —
17
+ * but it CAN detect the condition at the one seam where it is provably
18
+ * side-effect free (the registration phase, before `parseAsync` and before the
19
+ * preAction hook), wait (bounded) for the install to settle, and re-run itself
20
+ * once on the settled tree.
21
+ *
22
+ * This module is the reusable core of that recovery: a STRUCTURAL, closed
23
+ * classifier for the loader-error dialects Node raises; a readiness probe that
24
+ * re-resolves the missing target exactly as Node's resolver does; a bounded
25
+ * settle wait keyed on the same signals the writer leaves behind; and the typed
26
+ * carrier + boundary helpers (house style of package-root-diagnostics.ts) that
27
+ * keep a genuinely-broken install VISIBLE in Sentry with bounded, hq-derived
28
+ * diagnostics. The recovery seam that drives them lives in
29
+ * ../startup-registration.ts; version-gate.ts / self-update.ts / update-lock.ts
30
+ * are only READ (their exported symbols), never modified.
31
+ */
32
+ /** The two loader-error `code`s that mean "a module could not be resolved". */
33
+ export type ModuleNotFoundCode = "ERR_MODULE_NOT_FOUND" | "MODULE_NOT_FOUND";
34
+ /**
35
+ * The closed set of resolution-failure dialects, verified on Node v22.23.1 (the
36
+ * @sentry/node import-in-the-middle hook does not change the shapes):
37
+ * - `esm-path`: ESM importer, an absolute file (or legacyMainResolve dir)
38
+ * target — `err.url` set, or `Cannot find package '<abs>'…`.
39
+ * - `esm-package`: ESM importer, a bare specifier — `Cannot find package
40
+ * '<name>' imported from <importer>`, no `err.url`.
41
+ * - `cjs-path`: CJS require of an absolute path — `Cannot find module
42
+ * '<abs>'` + `requireStack`.
43
+ * - `cjs-package`: CJS require of a bare specifier — `Cannot find module
44
+ * '<name>'` + `requireStack`.
45
+ * - `unknown`: a module-not-found whose message did not parse; recovery
46
+ * still waits on the lock / retired-dir / quiet signals.
47
+ */
48
+ export type ModuleErrorDialect = "esm-path" | "esm-package" | "cjs-path" | "cjs-package" | "unknown";
49
+ /**
50
+ * The missing thing, re-resolvable by the readiness probe:
51
+ * - `path`: an absolute filesystem path (a `.js` file, or a package dir).
52
+ * - `package`: a bare package `name` resolvable from directory `from` upward.
53
+ * - `unknown`: the message did not parse; treated as "present" by the probe so
54
+ * readiness turns only on the lock / retired-dir / quiet signals.
55
+ */
56
+ export type ModuleErrorTarget = {
57
+ kind: "path";
58
+ path: string;
59
+ } | {
60
+ kind: "package";
61
+ name: string;
62
+ from: string;
63
+ } | {
64
+ kind: "unknown";
65
+ };
66
+ export interface ClassifiedModuleError {
67
+ code: ModuleNotFoundCode;
68
+ dialect: ModuleErrorDialect;
69
+ /** The raw specifier the message named (absolute path or bare name), or "". */
70
+ specifier: string;
71
+ /** The importer the message named (from-clause or requireStack[0]), or "". */
72
+ importer: string;
73
+ target: ModuleErrorTarget;
74
+ }
75
+ /**
76
+ * Reduce a bare specifier to its PACKAGE name: `@scope/name/sub` → `@scope/name`,
77
+ * `name/sub` → `name`, `name` → `name`. This is the unit the readiness probe
78
+ * re-resolves (Node looks up `node_modules/<package>/package.json`, never the
79
+ * subpath, when it raises the bare-specifier dialects).
80
+ */
81
+ export declare function packageNameOf(specifier: string): string;
82
+ /**
83
+ * Classify a thrown value as a module-resolution failure and extract the missing
84
+ * target, or return `null` for anything that is not one. The decision is
85
+ * STRUCTURAL: `err.code` must be exactly `ERR_MODULE_NOT_FOUND` or
86
+ * `MODULE_NOT_FOUND` — no argv, env, or free text is ever consulted, and any
87
+ * other error (including an import-time throw of another class) returns `null`
88
+ * so it is rethrown to the existing boundary unchanged.
89
+ */
90
+ export declare function classifyModuleNotFound(err: unknown): ClassifiedModuleError | null;
91
+ /** The filesystem surface the probe and wait use, injectable for hermetic tests. */
92
+ export interface InstallTreeFs {
93
+ existsSync(p: string): boolean;
94
+ statSync(p: string): {
95
+ isDirectory(): boolean;
96
+ };
97
+ readFileSync(p: string, encoding: "utf-8"): string;
98
+ readdirSync(p: string): string[];
99
+ }
100
+ /**
101
+ * Whether the missing target is now present on disk — the readiness signal the
102
+ * settle wait polls. It re-resolves the SAME step Node's resolver took:
103
+ * - `path`: the file must exist; a directory target must additionally hold
104
+ * a `package.json` (the legacyMainResolve case).
105
+ * - `package`: walk from `from` toward the filesystem root and return true as
106
+ * soon as `<dir>/node_modules/<name>/package.json` exists — the
107
+ * exact ancestor walk `getPackageJSONURL` performs, done WITHOUT
108
+ * `createRequire` so an ESM-only / export-conditioned package
109
+ * cannot false-negative.
110
+ * - `unknown`: true (readiness turns on the other signals).
111
+ * Any filesystem error reads as "not present" rather than throwing.
112
+ */
113
+ export declare function installTargetPresent(target: ModuleErrorTarget, fs?: InstallTreeFs): boolean;
114
+ export interface WaitForInstallTreeSettledArgs {
115
+ target: ModuleErrorTarget;
116
+ /** The running install's package dir (from resolveRunningInstall), or null. */
117
+ packageRoot: string | null;
118
+ /** The shared update-lock path ($HOME/.hq/locks/cli-update.lock). */
119
+ lockPath: string;
120
+ now?: () => number;
121
+ sleep?: (ms: number) => Promise<void>;
122
+ fs?: InstallTreeFs;
123
+ isPidAlive?: (pid: number) => boolean;
124
+ pollMs?: number;
125
+ quietMs?: number;
126
+ deadlineMs: number;
127
+ }
128
+ export interface WaitForInstallTreeSettledResult {
129
+ settled: boolean;
130
+ waitedMs: number;
131
+ /** A FRESH update lock held by another pid was observed at least once. */
132
+ sawLock: boolean;
133
+ /** An npm retired/staging sibling (`.hq-cli-<rand>`) was observed at least once. */
134
+ sawRetired: boolean;
135
+ }
136
+ /**
137
+ * Poll until the install tree has been continuously READY for `quietMs`, or the
138
+ * `deadlineMs` passes. READY means: (i) no FRESH update lock held by another pid,
139
+ * (ii) the missing target is present, and — when `packageRoot` is known —
140
+ * (iii) `<packageRoot>/package.json` parses with `name === CLI_NAME` and
141
+ * (iv) no `.<leaf>-<rand>` retired sibling remains beside it. Every filesystem
142
+ * error makes its condition "not ready" rather than throwing. `deadlineMs === 0`
143
+ * evaluates readiness exactly once (no waiting); otherwise the whole wait is
144
+ * bounded, so the caller can never hang.
145
+ */
146
+ export declare function waitForInstallTreeSettled(args: WaitForInstallTreeSettledArgs): Promise<WaitForInstallTreeSettledResult>;
147
+ /** The recovery OUTCOME, which — not the dialect — decides capture. */
148
+ export type InstallTreeTornOutcome = "guarded" | "unsettled" | "reexec-failed";
149
+ export type InstallTreeTornDiagnostics = {
150
+ dialect: ModuleErrorDialect;
151
+ code: ModuleNotFoundCode;
152
+ specifier: string;
153
+ importer: string;
154
+ packageRoot: string;
155
+ outcome: InstallTreeTornOutcome;
156
+ attempt: 1 | 2;
157
+ waitedMs: number;
158
+ sawLock: boolean;
159
+ sawRetired: boolean;
160
+ node: string;
161
+ };
162
+ export interface InstallTreeTornInit {
163
+ /** The original loader error, carried unchanged as `cause`. */
164
+ cause: unknown;
165
+ classified: ClassifiedModuleError;
166
+ packageRoot: string | null;
167
+ outcome: InstallTreeTornOutcome;
168
+ attempt: 1 | 2;
169
+ waitedMs: number;
170
+ sawLock: boolean;
171
+ sawRetired: boolean;
172
+ }
173
+ /**
174
+ * A torn-install failure that stays VISIBLE in Sentry with bounded, hq-derived
175
+ * diagnostics. Fixed `name`/`message` (no argv, path, or free text in either),
176
+ * `cause` = the original loader error, and a bounded `diagnostics` object whose
177
+ * only interpolated strings are the hq-derived specifier/importer/packageRoot,
178
+ * each capped. Mirrors PackageRootResolutionError's construction discipline.
179
+ */
180
+ export declare class InstallTreeTornError extends Error {
181
+ readonly diagnostics: InstallTreeTornDiagnostics;
182
+ constructor(init: InstallTreeTornInit);
183
+ }
184
+ export declare function isInstallTreeTornError(err: unknown): err is InstallTreeTornError;
185
+ /** The exact Sentry `contexts` payload for a captured torn-install failure. */
186
+ export declare function installTreeTornCaptureContext(err: InstallTreeTornError): {
187
+ contexts: {
188
+ install_tree_torn: InstallTreeTornDiagnostics;
189
+ };
190
+ };
191
+ /**
192
+ * The fixed, input-free operator remedy. It names the likely writers (so a
193
+ * human knows this was not their command's fault) and the reinstall commands,
194
+ * and interpolates NOTHING — the only per-event value is the bounded specifier
195
+ * appended by {@link installTreeTornStderrLine}.
196
+ */
197
+ export declare const INSTALL_TREE_TORN_REMEDY: string;
198
+ /**
199
+ * The single actionable stderr line for a captured torn-install failure: the
200
+ * fixed remedy plus the bounded, hq-derived missing specifier — the only
201
+ * interpolated value, never argv.
202
+ */
203
+ export declare function installTreeTornStderrLine(err: InstallTreeTornError): string;
204
+ //# sourceMappingURL=install-tree-torn.d.ts.map
@@ -0,0 +1,359 @@
1
+ /**
2
+ * Detection and bounded, recovery-safe reporting for the "install tree torn out
3
+ * from under a starting `hq`" condition (Sentry HQ-CLI-1G/1H/1J/1K —
4
+ * 7713386011, 7713482655, 7713783135, 7714501738).
5
+ *
6
+ * hq-cli resolves most of its module graph lazily INSIDE `runCli` (the lazy
7
+ * command module or `import("./register-all.js")`, plus everything those pull in
8
+ * — nested `@aws-sdk/*`, `js-yaml`, `@indigoai-us/hq-cloud`). An EXTERNAL global
9
+ * reinstall — the outpost `hq-cli-update` systemd timer every 6h, the agent
10
+ * boxes' `self_update`, the desktop app — renames the installed package dir
11
+ * aside (`.hq-cli-<rand>`), recreates it empty, and re-extracts it file by file
12
+ * over ~16s. A process that started just before that rename resolves its
13
+ * deferred imports against a directory that no longer holds them and dies with
14
+ * ERR_MODULE_NOT_FOUND / MODULE_NOT_FOUND. None of those writers is the process's
15
+ * OWN updater (the version-gate and self-updater install synchronously under the
16
+ * shared lock and never overlap their own imports), so hq-cli cannot stop them —
17
+ * but it CAN detect the condition at the one seam where it is provably
18
+ * side-effect free (the registration phase, before `parseAsync` and before the
19
+ * preAction hook), wait (bounded) for the install to settle, and re-run itself
20
+ * once on the settled tree.
21
+ *
22
+ * This module is the reusable core of that recovery: a STRUCTURAL, closed
23
+ * classifier for the loader-error dialects Node raises; a readiness probe that
24
+ * re-resolves the missing target exactly as Node's resolver does; a bounded
25
+ * settle wait keyed on the same signals the writer leaves behind; and the typed
26
+ * carrier + boundary helpers (house style of package-root-diagnostics.ts) that
27
+ * keep a genuinely-broken install VISIBLE in Sentry with bounded, hq-derived
28
+ * diagnostics. The recovery seam that drives them lives in
29
+ * ../startup-registration.ts; version-gate.ts / self-update.ts / update-lock.ts
30
+ * are only READ (their exported symbols), never modified.
31
+ */
32
+ import * as nodeFs from "node:fs";
33
+ import * as path from "node:path";
34
+ import { fileURLToPath } from "node:url";
35
+ import { CLI_NAME } from "../cli-version.js";
36
+ import { boundedDiagnosticValue } from "./package-root-diagnostics.js";
37
+ import { isLockStale } from "./update-lock.js";
38
+ /** Byte caps for the (hq-derived, path-shaped) diagnostic strings. */
39
+ const SPECIFIER_BYTES = 256;
40
+ const IMPORTER_BYTES = 256;
41
+ const PACKAGE_ROOT_BYTES = 256;
42
+ const ESM_PACKAGE_RE = /^Cannot find package '([^']+)' imported from (.+)$/s;
43
+ const CJS_MODULE_RE = /^Cannot find module '([^']+)'/s;
44
+ const ESM_MODULE_IMPORTED_RE = /^Cannot find module '([^']+)' imported from (.+)$/s;
45
+ /**
46
+ * Reduce a bare specifier to its PACKAGE name: `@scope/name/sub` → `@scope/name`,
47
+ * `name/sub` → `name`, `name` → `name`. This is the unit the readiness probe
48
+ * re-resolves (Node looks up `node_modules/<package>/package.json`, never the
49
+ * subpath, when it raises the bare-specifier dialects).
50
+ */
51
+ export function packageNameOf(specifier) {
52
+ const parts = specifier.split("/");
53
+ if (specifier.startsWith("@"))
54
+ return parts.slice(0, 2).join("/");
55
+ return parts[0] ?? specifier;
56
+ }
57
+ /**
58
+ * Classify a thrown value as a module-resolution failure and extract the missing
59
+ * target, or return `null` for anything that is not one. The decision is
60
+ * STRUCTURAL: `err.code` must be exactly `ERR_MODULE_NOT_FOUND` or
61
+ * `MODULE_NOT_FOUND` — no argv, env, or free text is ever consulted, and any
62
+ * other error (including an import-time throw of another class) returns `null`
63
+ * so it is rethrown to the existing boundary unchanged.
64
+ */
65
+ export function classifyModuleNotFound(err) {
66
+ if (err === null || typeof err !== "object")
67
+ return null;
68
+ const record = err;
69
+ const code = record.code;
70
+ if (code !== "ERR_MODULE_NOT_FOUND" && code !== "MODULE_NOT_FOUND")
71
+ return null;
72
+ const message = typeof record.message === "string" ? record.message : "";
73
+ // (a) ESM path — `err.url` is a `file:` URL. This is checked first because the
74
+ // ESM path message ALSO matches the imported-from dialect (d); the url is the
75
+ // authoritative resolved target.
76
+ if (typeof record.url === "string" && record.url.startsWith("file:")) {
77
+ const target = fileURLToPath(record.url);
78
+ const importer = ESM_MODULE_IMPORTED_RE.exec(message)?.[2] ?? "";
79
+ return { code, dialect: "esm-path", specifier: target, importer, target: { kind: "path", path: target } };
80
+ }
81
+ // (b) ESM `Cannot find package '<spec>' imported from <importer>`. `<spec>` is
82
+ // a bare name for the ordinary case, or an ABSOLUTE `<dir>/index.js` token for
83
+ // the legacyMainResolve variant (a package dir that exists but has no usable
84
+ // package.json/main) — the latter is a path target.
85
+ const esmPkg = ESM_PACKAGE_RE.exec(message);
86
+ if (esmPkg) {
87
+ const spec = esmPkg[1];
88
+ const importer = esmPkg[2];
89
+ if (path.isAbsolute(spec)) {
90
+ return { code, dialect: "esm-path", specifier: spec, importer, target: { kind: "path", path: spec } };
91
+ }
92
+ return {
93
+ code,
94
+ dialect: "esm-package",
95
+ specifier: spec,
96
+ importer,
97
+ target: { kind: "package", name: spec, from: path.dirname(importer) },
98
+ };
99
+ }
100
+ // (c) CJS `Cannot find module '<spec>'` (+ Require stack). Absolute → a path
101
+ // target; bare → the package the require named, resolved from requireStack[0].
102
+ if (code === "MODULE_NOT_FOUND") {
103
+ const cjs = CJS_MODULE_RE.exec(message);
104
+ if (cjs) {
105
+ const spec = cjs[1];
106
+ const requireStack = Array.isArray(record.requireStack) ? record.requireStack : [];
107
+ const importer = typeof requireStack[0] === "string" ? requireStack[0] : "";
108
+ if (path.isAbsolute(spec)) {
109
+ return { code, dialect: "cjs-path", specifier: spec, importer, target: { kind: "path", path: spec } };
110
+ }
111
+ return {
112
+ code,
113
+ dialect: "cjs-package",
114
+ specifier: spec,
115
+ importer,
116
+ target: { kind: "package", name: packageNameOf(spec), from: path.dirname(importer) },
117
+ };
118
+ }
119
+ }
120
+ // (d) ESM `Cannot find module '<abs>' imported from <importer>` WITHOUT a url
121
+ // (defensive — the url arm normally wins). The specifier is the resolved
122
+ // absolute path.
123
+ const esmMod = ESM_MODULE_IMPORTED_RE.exec(message);
124
+ if (esmMod) {
125
+ const spec = esmMod[1];
126
+ return { code, dialect: "esm-path", specifier: spec, importer: esmMod[2], target: { kind: "path", path: spec } };
127
+ }
128
+ // (e) A module-not-found whose message did not parse — still recover, keyed on
129
+ // the lock / retired-dir / quiet signals only.
130
+ return { code, dialect: "unknown", specifier: "", importer: "", target: { kind: "unknown" } };
131
+ }
132
+ const nodeInstallTreeFs = {
133
+ existsSync: (p) => nodeFs.existsSync(p),
134
+ statSync: (p) => nodeFs.statSync(p),
135
+ readFileSync: (p, encoding) => nodeFs.readFileSync(p, encoding),
136
+ readdirSync: (p) => nodeFs.readdirSync(p),
137
+ };
138
+ /** Liveness probe with update-lock.ts's semantics (ESRCH dead, EPERM alive). */
139
+ function defaultIsPidAlive(pid) {
140
+ try {
141
+ process.kill(pid, 0);
142
+ return true;
143
+ }
144
+ catch (err) {
145
+ return err.code === "EPERM";
146
+ }
147
+ }
148
+ /**
149
+ * Whether the missing target is now present on disk — the readiness signal the
150
+ * settle wait polls. It re-resolves the SAME step Node's resolver took:
151
+ * - `path`: the file must exist; a directory target must additionally hold
152
+ * a `package.json` (the legacyMainResolve case).
153
+ * - `package`: walk from `from` toward the filesystem root and return true as
154
+ * soon as `<dir>/node_modules/<name>/package.json` exists — the
155
+ * exact ancestor walk `getPackageJSONURL` performs, done WITHOUT
156
+ * `createRequire` so an ESM-only / export-conditioned package
157
+ * cannot false-negative.
158
+ * - `unknown`: true (readiness turns on the other signals).
159
+ * Any filesystem error reads as "not present" rather than throwing.
160
+ */
161
+ export function installTargetPresent(target, fs = nodeInstallTreeFs) {
162
+ try {
163
+ if (target.kind === "unknown")
164
+ return true;
165
+ if (target.kind === "path") {
166
+ if (!fs.existsSync(target.path))
167
+ return false;
168
+ try {
169
+ if (fs.statSync(target.path).isDirectory()) {
170
+ return fs.existsSync(path.join(target.path, "package.json"));
171
+ }
172
+ }
173
+ catch {
174
+ return false;
175
+ }
176
+ return true;
177
+ }
178
+ // package: ancestor walk from `from`.
179
+ let dir = path.resolve(target.from);
180
+ for (;;) {
181
+ const manifest = path.join(dir, "node_modules", target.name, "package.json");
182
+ if (fs.existsSync(manifest))
183
+ return true;
184
+ const parent = path.dirname(dir);
185
+ if (parent === dir)
186
+ return false;
187
+ dir = parent;
188
+ }
189
+ }
190
+ catch {
191
+ return false;
192
+ }
193
+ }
194
+ /** The scope/leaf split of CLI_NAME, e.g. `@indigoai-us` / `hq-cli`. */
195
+ function cliNameParts() {
196
+ const slash = CLI_NAME.indexOf("/");
197
+ if (slash === -1)
198
+ return { scope: null, leaf: CLI_NAME };
199
+ return { scope: CLI_NAME.slice(0, slash), leaf: CLI_NAME.slice(slash + 1) };
200
+ }
201
+ /** Whether a FRESH update lock is held by a DIFFERENT live pid (blocks readiness). */
202
+ function freshForeignLockHeld(lockPath, nowMs, isPidAlive, fs) {
203
+ let raw;
204
+ try {
205
+ raw = fs.readFileSync(lockPath, "utf-8");
206
+ }
207
+ catch {
208
+ return false; // no lock (ENOENT) or unreadable — not a fresh foreign holder
209
+ }
210
+ if (isLockStale(raw, nowMs, isPidAlive))
211
+ return false;
212
+ try {
213
+ const info = JSON.parse(raw);
214
+ if (info.pid === process.pid)
215
+ return false; // our own lock never blocks us
216
+ }
217
+ catch {
218
+ return false; // isLockStale already treats unparseable as stale, unreachable
219
+ }
220
+ return true;
221
+ }
222
+ /** Whether an npm retired/staging sibling (`.<leaf>-*`) sits beside packageRoot. */
223
+ function retiredSiblingPresent(packageRoot, fs) {
224
+ const { leaf } = cliNameParts();
225
+ const stagingPrefix = `.${leaf}-`;
226
+ const parent = path.dirname(packageRoot);
227
+ const entries = fs.readdirSync(parent); // caller treats a throw as "not ready"
228
+ return entries.some((entry) => entry.startsWith(stagingPrefix));
229
+ }
230
+ /** Whether packageRoot's own manifest is healthy (`name === CLI_NAME`). */
231
+ function packageRootHealthy(packageRoot, fs) {
232
+ const manifest = JSON.parse(fs.readFileSync(path.join(packageRoot, "package.json"), "utf-8"));
233
+ return manifest.name === CLI_NAME;
234
+ }
235
+ /**
236
+ * Poll until the install tree has been continuously READY for `quietMs`, or the
237
+ * `deadlineMs` passes. READY means: (i) no FRESH update lock held by another pid,
238
+ * (ii) the missing target is present, and — when `packageRoot` is known —
239
+ * (iii) `<packageRoot>/package.json` parses with `name === CLI_NAME` and
240
+ * (iv) no `.<leaf>-<rand>` retired sibling remains beside it. Every filesystem
241
+ * error makes its condition "not ready" rather than throwing. `deadlineMs === 0`
242
+ * evaluates readiness exactly once (no waiting); otherwise the whole wait is
243
+ * bounded, so the caller can never hang.
244
+ */
245
+ export async function waitForInstallTreeSettled(args) {
246
+ const now = args.now ?? Date.now;
247
+ const sleep = args.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
248
+ const fs = args.fs ?? nodeInstallTreeFs;
249
+ const isPidAlive = args.isPidAlive ?? defaultIsPidAlive;
250
+ const pollMs = args.pollMs ?? 250;
251
+ const quietMs = args.quietMs ?? 1500;
252
+ const deadlineMs = args.deadlineMs;
253
+ const start = now();
254
+ let readySince = null;
255
+ let sawLock = false;
256
+ let sawRetired = false;
257
+ for (;;) {
258
+ const t = now();
259
+ const foreignLock = freshForeignLockHeld(args.lockPath, t, isPidAlive, fs);
260
+ if (foreignLock)
261
+ sawLock = true;
262
+ let ready = !foreignLock && installTargetPresent(args.target, fs);
263
+ if (ready && args.packageRoot) {
264
+ try {
265
+ if (!packageRootHealthy(args.packageRoot, fs))
266
+ ready = false;
267
+ }
268
+ catch {
269
+ ready = false;
270
+ }
271
+ if (ready) {
272
+ try {
273
+ if (retiredSiblingPresent(args.packageRoot, fs)) {
274
+ sawRetired = true;
275
+ ready = false;
276
+ }
277
+ }
278
+ catch {
279
+ ready = false;
280
+ }
281
+ }
282
+ }
283
+ if (ready) {
284
+ if (readySince === null)
285
+ readySince = t;
286
+ }
287
+ else {
288
+ readySince = null;
289
+ }
290
+ const waitedMs = t - start;
291
+ if (ready && (deadlineMs === 0 || t - readySince >= quietMs)) {
292
+ return { settled: true, waitedMs, sawLock, sawRetired };
293
+ }
294
+ if (waitedMs >= deadlineMs) {
295
+ return { settled: false, waitedMs, sawLock, sawRetired };
296
+ }
297
+ await sleep(pollMs);
298
+ }
299
+ }
300
+ /** The fixed, input-free carrier message (grouping cardinality stays bounded). */
301
+ const INSTALL_TREE_TORN_MESSAGE = "hq-cli install tree was being rewritten while this command started";
302
+ /**
303
+ * A torn-install failure that stays VISIBLE in Sentry with bounded, hq-derived
304
+ * diagnostics. Fixed `name`/`message` (no argv, path, or free text in either),
305
+ * `cause` = the original loader error, and a bounded `diagnostics` object whose
306
+ * only interpolated strings are the hq-derived specifier/importer/packageRoot,
307
+ * each capped. Mirrors PackageRootResolutionError's construction discipline.
308
+ */
309
+ export class InstallTreeTornError extends Error {
310
+ diagnostics;
311
+ constructor(init) {
312
+ super(INSTALL_TREE_TORN_MESSAGE);
313
+ this.name = "InstallTreeTornError";
314
+ this.cause = init.cause;
315
+ this.diagnostics = {
316
+ dialect: init.classified.dialect,
317
+ code: init.classified.code,
318
+ specifier: boundedDiagnosticValue(init.classified.specifier, SPECIFIER_BYTES),
319
+ importer: boundedDiagnosticValue(init.classified.importer, IMPORTER_BYTES),
320
+ packageRoot: boundedDiagnosticValue(init.packageRoot ?? "", PACKAGE_ROOT_BYTES),
321
+ outcome: init.outcome,
322
+ attempt: init.attempt,
323
+ waitedMs: init.waitedMs,
324
+ sawLock: init.sawLock,
325
+ sawRetired: init.sawRetired,
326
+ node: process.version,
327
+ };
328
+ Object.setPrototypeOf(this, new.target.prototype);
329
+ }
330
+ }
331
+ export function isInstallTreeTornError(err) {
332
+ return err instanceof InstallTreeTornError;
333
+ }
334
+ /** The exact Sentry `contexts` payload for a captured torn-install failure. */
335
+ export function installTreeTornCaptureContext(err) {
336
+ return { contexts: { install_tree_torn: err.diagnostics } };
337
+ }
338
+ /**
339
+ * The fixed, input-free operator remedy. It names the likely writers (so a
340
+ * human knows this was not their command's fault) and the reinstall commands,
341
+ * and interpolates NOTHING — the only per-event value is the bounded specifier
342
+ * appended by {@link installTreeTornStderrLine}.
343
+ */
344
+ export const INSTALL_TREE_TORN_REMEDY = "the hq-cli install was being updated by another process (the box's hq-cli-update " +
345
+ "timer, the desktop app, or another hq command) while this command started. " +
346
+ "Re-run your command; if it keeps failing, reinstall with " +
347
+ "`npm i -g @indigoai-us/hq-cli` (or `pnpm add -g @indigoai-us/hq-cli`).";
348
+ /**
349
+ * The single actionable stderr line for a captured torn-install failure: the
350
+ * fixed remedy plus the bounded, hq-derived missing specifier — the only
351
+ * interpolated value, never argv.
352
+ */
353
+ export function installTreeTornStderrLine(err) {
354
+ const specifier = err.diagnostics.specifier;
355
+ return specifier
356
+ ? `${INSTALL_TREE_TORN_REMEDY} (missing: ${specifier})`
357
+ : INSTALL_TREE_TORN_REMEDY;
358
+ }
359
+ //# sourceMappingURL=install-tree-torn.js.map
@@ -91,6 +91,18 @@ const KNOWN_ERROR_NAMES = new Set([
91
91
  // listed defensively so a future capture path cannot mint an unbounded group
92
92
  // (HQ-CLI-1A).
93
93
  "QmdWorkdirMissingError",
94
+ // A torn-install failure the bounded settle-wait + single re-exec could not
95
+ // recover (HQ-CLI-1G/1H/1J/1K). Fingerprinted on its own branch below by the
96
+ // bounded `diagnostics.outcome`, so the whole family groups into ≤3 issues.
97
+ "InstallTreeTornError",
98
+ ]);
99
+ /**
100
+ * The CLOSED set of InstallTreeTornError recovery outcomes worth their own
101
+ * group. Anything else collapses to `outcome:other`, so the axis stays finite;
102
+ * the carrier's message is fixed, so this outcome IS the only discriminator.
103
+ */
104
+ const KNOWN_INSTALL_TREE_OUTCOMES = new Set([
105
+ "guarded", "unsettled", "reexec-failed",
94
106
  ]);
95
107
  /** Fixed bucket for any error name outside the closed allowlist. */
96
108
  const FALLBACK_ERROR_NAME = "other";
@@ -200,6 +212,20 @@ export function sentryFingerprintFor(err) {
200
212
  qmdDispositionToken(record.status, record.signal),
201
213
  ];
202
214
  }
215
+ // A torn-install failure carries neither an rpcCode nor an HTTP status; its
216
+ // only bounded discriminator is the recovery `diagnostics.outcome`. Keyed
217
+ // BEFORE the rpc/status branches so the family groups into ≤3 predictable
218
+ // issues (guarded / unsettled / reexec-failed) rather than one fungible bucket.
219
+ if (rawName === "InstallTreeTornError") {
220
+ const diagnostics = record.diagnostics;
221
+ const outcome = diagnostics !== null && typeof diagnostics === "object"
222
+ ? diagnostics.outcome
223
+ : undefined;
224
+ const token = typeof outcome === "string" && KNOWN_INSTALL_TREE_OUTCOMES.has(outcome)
225
+ ? `outcome:${outcome}`
226
+ : "outcome:other";
227
+ return [DEFAULT_GROUPING, "InstallTreeTornError", token];
228
+ }
203
229
  const name = KNOWN_ERROR_NAMES.has(rawName) ? rawName : FALLBACK_ERROR_NAME;
204
230
  const rpcCode = record.rpcCode;
205
231
  if (typeof rpcCode === "number" && Number.isInteger(rpcCode)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.108.17",
3
+ "version": "5.108.18",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {