@indigoai-us/hq-cli 5.109.11 → 5.109.13

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,48 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.109.13] — 2026-09-13
6
+
7
+ ### Fixed
8
+
9
+ - A background reinstall no longer turns into a crash report in the CommonJS twin
10
+ of the shape 5.109.12 fixed for ES modules (HQ-CLI-20). When another program
11
+ reinstalls hq globally while a command is starting, it rewrites hq's files one
12
+ by one over a few seconds. If a bundled CommonJS dependency (here `ajv`, reached
13
+ while `hq core timeout-guard` registers its commands) had just resolved one of
14
+ its own files and that file vanished before Node read it, the low-level `ENOENT …
15
+ open` came from inside Node's CommonJS module loader — a shape the recovery only
16
+ recognized for ES modules, so it was still filed as a crash. It is now recognized
17
+ as "your install was mid-update, not an hq bug": the command waits for the
18
+ reinstall to settle and re-runs once on the healed tree, and the same noise is
19
+ dropped on the routes that bypass the top-level handler. The gate is deliberately
20
+ narrow — it fires only when Node's own loader was reading a module's source (the
21
+ frame directly beneath the file read is a `cjs/loader` frame, never the
22
+ dependency's own code) — so a third-party package whose own startup code reads a
23
+ missing file, or a fault in hq's own shipped files, still reports exactly once
24
+ (Sentry HQ-CLI-20).
25
+
26
+ ## [5.109.12] — 2026-09-12
27
+
28
+ ### Fixed
29
+
30
+ - A background reinstall no longer turns into a crash report in two more shapes
31
+ it was still slipping through (HQ-CLI-1S, HQ-CLI-1T). When another program
32
+ reinstalls hq globally while a command is starting, it renames hq's files aside
33
+ and rewrites them file by file over a few seconds. Two failure shapes from that
34
+ window were still filed as crashes instead of being recovered: (1) a file that
35
+ Node checked a moment earlier vanished between its two resolve steps, surfacing
36
+ as a low-level `ENOENT … lstat` from inside the module resolver (HQ-CLI-1S);
37
+ and (2) a dependency file that was found but only half-written, so loading it
38
+ returned an empty module and its own code threw `TypeError: … is not a
39
+ function` (HQ-CLI-1T). Both are now recognized as "your install was mid-update,
40
+ not an hq bug": the command waits for the reinstall to settle and re-runs once
41
+ on the healed tree instead of reporting an error. The half-written-file case
42
+ only ever waits when a reinstall is actually in progress — a genuine, lasting
43
+ defect in a third-party dependency still reports exactly once and never pays a
44
+ wait, and a fault in hq's own shipped files is still reported. (The two related
45
+ shapes HQ-CLI-1V and HQ-CLI-1W are already covered by 5.109.8 and #561.)
46
+
5
47
  ## [5.109.11] — 2026-09-12
6
48
 
7
49
  ### Fixed
package/dist/main.js CHANGED
@@ -565,8 +565,9 @@ export async function handleTopLevelError(err, deps = defaultTopLevelErrorDepend
565
565
  // install left a bundled file unwritten (HQ-CLI-1N, a CJS relative-sibling
566
566
  // MODULE_NOT_FOUND; HQ-CLI-1Q, a CJS BARE-specifier MODULE_NOT_FOUND for a
567
567
  // dependency the requiring third-party package's own manifest declares), or
568
- // a concurrent global install rewrote the running tree so an ESM module
569
- // present at resolve was gone at read (HQ-CLI-1M, an esm-loader ENOENT).
568
+ // a concurrent global install rewrote the running tree so a module present
569
+ // at resolve was gone at read (HQ-CLI-1M, an esm-loader ENOENT; HQ-CLI-20,
570
+ // its CJS loader-read twin).
570
571
  // All carry no hq-cli frames and reached the final else, filing a bare
571
572
  // crash and an unactionable line. An incomplete install is the caller's
572
573
  // machine, the disposition HQ-CLI-Y already established for the qmd CHILD —
@@ -574,11 +575,12 @@ export async function handleTopLevelError(err, deps = defaultTopLevelErrorDepend
574
575
  // environmental family (after the typed qmd carriers and the hq state-write
575
576
  // carrier, before environmentalFsErrorMessage): the signatures are disjoint
576
577
  // — ENVIRONMENTAL_FS_CODES is only ENOSPC/EDQUOT/EROFS (never
577
- // ENOENT/MODULE_NOT_FOUND), no qmd carrier sets requireStack or an
578
- // esm-loader frame, and the classified file must sit under
579
- // <packageRoot>/node_modules — so ordering changes nothing that exists. The
578
+ // ENOENT/MODULE_NOT_FOUND), no qmd carrier sets requireStack or a loader
579
+ // source-read frame (esm/load or cjs/loader), and the classified file must
580
+ // sit under <packageRoot>/node_modules — so ordering changes nothing that
581
+ // exists. The
580
582
  // UNATTRIBUTABLE shapes are deliberately NOT suppressed and are captured
581
- // WITH bounded context on the generic path below: an esm-loader ENOENT
583
+ // WITH bounded context on the generic path below: a loader/resolver ENOENT
582
584
  // whose path did not survive, and a bare miss the requirer does NOT declare
583
585
  // (a possible undeclared/peer dependency, now named in the context).
584
586
  const incompleteInstallMsg = qmdMsg || collectionMsg || terminatedMsg || llmDisabledMsg || moduleMissingMsg || storeMissingMsg || storeUnopenableMsg || queryDocumentMsg || modelDownloadMsg || workdirMissingMsg || stateWriteMsg
@@ -679,9 +681,10 @@ export async function handleTopLevelError(err, deps = defaultTopLevelErrorDepend
679
681
  // A genuinely unclassified fault is still captured exactly once. Two
680
682
  // shapes attach bounded, hq-derived context so the next occurrence
681
683
  // carries the evidence this one lacked: a qmd spawn-level failure the fix
682
- // could not attribute (HQ-CLI-1A), and an esm-loader ENOENT whose path
683
- // did not survive delivery (HQ-CLI-1M the unattributable incomplete-
684
- // install shape). Every other error captures bare, exactly as before.
684
+ // could not attribute (HQ-CLI-1A), and a loader/resolver ENOENT whose path
685
+ // did not survive delivery (HQ-CLI-1M esm-load, HQ-CLI-1S resolve-time,
686
+ // HQ-CLI-20 CJS load-time the unattributable incomplete-install shape).
687
+ // Every other error captures bare, exactly as before.
685
688
  const spawnContext = qmdSpawnFailureCaptureContext(err);
686
689
  const installContext = incompleteInstallCaptureContext(err);
687
690
  if (spawnContext || installContext) {
package/dist/sentry.js CHANGED
@@ -37,7 +37,9 @@ export function epipeAwareBeforeSend(event, hint) {
37
37
  // (HQ-CLI-1N, a CJS relative-sibling MODULE_NOT_FOUND under its bundled
38
38
  // node_modules; HQ-CLI-1Q, a CJS BARE-specifier MODULE_NOT_FOUND the requiring
39
39
  // third-party package's own manifest declares; HQ-CLI-1M, an esm-loader ENOENT
40
- // for a file present at resolve and gone at read). handleTopLevelError already
40
+ // for a file present at resolve and gone at read; HQ-CLI-20, its CJS loader-read
41
+ // twin — a bundled CJS dep gone between resolve and the loader's source read).
42
+ // handleTopLevelError already
41
43
  // prints the reinstall remedy for the top-level route; dropping the event here
42
44
  // suppresses the fatal regardless of route — the unhandled-rejection boundary,
43
45
  // the command-level captureException sites, and bin/hq-auth-refresh —
@@ -50,7 +52,7 @@ export function epipeAwareBeforeSend(event, hint) {
50
52
  if (incompleteInstallMessage(hint?.originalException))
51
53
  return null;
52
54
  // An incomplete-install shape that is NOT suppressed (a bare miss the requirer
53
- // does not declare, or a path-less esm-loader ENOENT) should still arrive
55
+ // does not declare, or a path-less loader/resolver ENOENT) should still arrive
54
56
  // ATTRIBUTABLE on every capture route, not only the top-level boundary:
55
57
  // handleTopLevelError in main.ts is the ONLY caller of
56
58
  // incompleteInstallCaptureContext, so events reaching this hook directly — the
@@ -16,7 +16,7 @@
16
16
  * style of handleTopLevelError so the whole seam is unit-testable without real
17
17
  * spawns, waits, or a real install tree.
18
18
  */
19
- import { type WaitForInstallTreeSettledArgs, type WaitForInstallTreeSettledResult } from "./utils/install-tree-torn.js";
19
+ import { type InstallRewriteSignalArgs, type WaitForInstallTreeSettledArgs, type WaitForInstallTreeSettledResult } from "./utils/install-tree-torn.js";
20
20
  /**
21
21
  * Set on the re-exec'd child so it can NEVER wait or re-exec again — distinct
22
22
  * from self-update's HQ_RESCUE_SELF_UPDATED so the two recovery paths cannot
@@ -70,6 +70,8 @@ export interface RegisterRecoveryDeps {
70
70
  deriveInstallRoot?: () => string | null;
71
71
  lockPath?: () => string;
72
72
  waitForSettled?: (args: WaitForInstallTreeSettledArgs) => Promise<WaitForInstallTreeSettledResult>;
73
+ /** Positive writer-signal probe for the eval-throw gate (defaults to installRewriteInProgress). */
74
+ rewriteInProgress?: (args: InstallRewriteSignalArgs) => boolean;
73
75
  /** node flags to forward to the re-exec child (defaults to process.execArgv). */
74
76
  execArgv?: readonly string[];
75
77
  }
@@ -20,7 +20,7 @@ import { spawnSync } from "node:child_process";
20
20
  import { resolveRunningInstall } from "./utils/version-gate.js";
21
21
  import { stringDerivedPackageRoot } from "./utils/hq-roots.js";
22
22
  import { updateLockPath, UPDATE_LOCK_STALE_MS } from "./utils/update-lock.js";
23
- import { classifyModuleNotFound, InstallTreeTornError, waitForInstallTreeSettled, } from "./utils/install-tree-torn.js";
23
+ import { classifyEvalThrow, classifyModuleNotFound, installRewriteInProgress, InstallTreeTornError, waitForInstallTreeSettled, } from "./utils/install-tree-torn.js";
24
24
  /**
25
25
  * Set on the re-exec'd child so it can NEVER wait or re-exec again — distinct
26
26
  * from self-update's HQ_RESCUE_SELF_UPDATED so the two recovery paths cannot
@@ -87,12 +87,32 @@ export async function registerCommandsWithRecovery(args) {
87
87
  return {};
88
88
  }
89
89
  catch (err) {
90
- const classified = classifyModuleNotFound(err);
91
- // Not a module-resolution failure rethrow the SAME object to the existing
92
- // boundary, preserving today's behaviour exactly.
90
+ // The packageRoot bounds the eval-throw classifier's throw site and feeds the
91
+ // settle wait's manifest-health / retired-sibling guards; resolved once here.
92
+ const packageRoot = resolvePackageRoot(deps);
93
+ // Classify the loader failure. classifyModuleNotFound covers the resolve/load
94
+ // dialects (incl. HQ-CLI-1S resolve-enoent); classifyEvalThrow covers a
95
+ // module-EVALUATION throw (HQ-CLI-1T). Anything unclassified is rethrown as the
96
+ // SAME object to the existing boundary, preserving today's behaviour exactly.
97
+ const classified = classifyModuleNotFound(err) ?? classifyEvalThrow(err, packageRoot);
93
98
  if (!classified)
94
99
  throw err;
95
- const packageRoot = resolvePackageRoot(deps);
100
+ const lockPath = (deps.lockPath ?? updateLockPath)();
101
+ const waitFor = deps.waitForSettled ?? waitForInstallTreeSettled;
102
+ // Writer-signal gate for the eval-throw dialect ONLY. A module-evaluation
103
+ // throw is indistinguishable from a persistent third-party load-time defect
104
+ // unless a global reinstall is provably in progress, so require a POSITIVE
105
+ // writer signal — a fresh foreign lock, an absent/name-mismatched manifest, or
106
+ // a retired `.hq-cli-<hash>` sibling — before entering recovery. With no such
107
+ // signal the SAME error is rethrown raw (today's immediate capture) and never
108
+ // pays a settle wait; a benign fs error (an unreadable manifest or unlistable
109
+ // parent) is deliberately NOT read as a signal, so a real defect never waits.
110
+ // The loader-race dialects need no such gate. Injected for a hermetic seam.
111
+ if (classified.dialect === "eval-throw") {
112
+ const rewriteInProgress = deps.rewriteInProgress ?? installRewriteInProgress;
113
+ if (!rewriteInProgress({ packageRoot, lockPath }))
114
+ throw err;
115
+ }
96
116
  // A child that already recovered must never wait or re-exec again: report
97
117
  // once and let the boundary capture it.
98
118
  if (env[INSTALL_TREE_RECOVERY_GUARD_ENV] === "1") {
@@ -108,8 +128,6 @@ export async function registerCommandsWithRecovery(args) {
108
128
  });
109
129
  }
110
130
  stderr.write(`${INSTALL_TREE_WAIT_NOTICE}\n`);
111
- const lockPath = (deps.lockPath ?? updateLockPath)();
112
- const waitFor = deps.waitForSettled ?? waitForInstallTreeSettled;
113
131
  const result = await waitFor({
114
132
  target: classified.target,
115
133
  packageRoot,
@@ -13,10 +13,11 @@ export type PackageRootResolver = () => string | null;
13
13
  /** Which strategy produced the running install's root, recorded in diagnostics. */
14
14
  export type PackageRootResolverSource = "manifest-walk" | "string-derivation" | "unresolved" | "injected";
15
15
  /**
16
- * If `err` is an in-process incomplete-install module-load failure — either the
17
- * CJS relative-sibling shape (HQ-CLI-1N) or the ESM vanished-file shape
18
- * (HQ-CLI-1M), with the failing file confirmed under `<packageRoot>/node_modules/`
19
- * — return the actionable, input-free reinstall remedy; otherwise return null.
16
+ * If `err` is an in-process incomplete-install module-load failure — the CJS
17
+ * relative-sibling shape (HQ-CLI-1N), the ESM vanished-at-read shape (HQ-CLI-1M),
18
+ * or the resolve-time lstat shape (HQ-CLI-1S), with the failing file confirmed
19
+ * under `<packageRoot>/node_modules/` — return the actionable, input-free reinstall
20
+ * remedy; otherwise return null.
20
21
  *
21
22
  * Mirrors qmdModuleMissingMessage so the top-level handler and beforeSend branch
22
23
  * the same way: a non-null result means print-the-remedy-and-skip-Sentry, null
@@ -24,7 +25,11 @@ export type PackageRootResolverSource = "manifest-walk" | "string-derivation" |
24
25
  * fails yields null.
25
26
  */
26
27
  export declare function incompleteInstallMessage(err: unknown, resolvePackageRoot?: PackageRootResolver, fileSystem?: Pick<typeof fs, "readFileSync">): string | null;
27
- /** Bounded, scrubber-safe diagnostics for an unattributable esm-loader ENOENT (HQ-CLI-1M). */
28
+ /**
29
+ * Bounded, scrubber-safe diagnostics for an unattributable loader/resolver ENOENT
30
+ * whose `path` did not survive delivery — the esm-loader open shape (HQ-CLI-1M) or
31
+ * the resolve-time lstat shape (HQ-CLI-1S). The two frame booleans say which.
32
+ */
28
33
  export type IncompleteInstallEsmDiagnostics = {
29
34
  /** The resolved install root, or the literal `<unresolved>` when none was found. */
30
35
  packageRoot: string;
@@ -41,9 +46,30 @@ export type IncompleteInstallEsmDiagnostics = {
41
46
  */
42
47
  packageJsonExists?: boolean;
43
48
  nodeModulesExists?: boolean;
49
+ /** The stack carried an esm-loader (source-read) frame — the HQ-CLI-1M route. */
44
50
  esmLoaderFrame: boolean;
51
+ /** The stack carried a module-resolver frame — the HQ-CLI-1S resolve-time route. */
52
+ resolverFrame: boolean;
53
+ /** The stack carried a CJS loader (source-read) frame — the HQ-CLI-20 route. */
54
+ cjsLoaderFrame: boolean;
45
55
  code: string;
46
56
  };
57
+ /**
58
+ * Bounded, scrubber-safe diagnostics for a module-EVALUATION throw that reached
59
+ * the boundary raw (HQ-CLI-1T) — a code-less TypeError/ReferenceError/SyntaxError
60
+ * whose throw site sits under the running install's node_modules, so the next
61
+ * occurrence names the offending third-party package instead of filing bare. Only
62
+ * the closed error name and the bounded package name are recorded; never the
63
+ * message, argv, or a full path.
64
+ */
65
+ export type IncompleteInstallEvalThrowDiagnostics = {
66
+ evalThrow: true;
67
+ errorName: string;
68
+ throwSitePackage: string;
69
+ packageRoot: string;
70
+ packageRootResolved: boolean;
71
+ resolver: PackageRootResolverSource;
72
+ };
47
73
  /**
48
74
  * Bounded, scrubber-safe diagnostics for a NOT-suppressed third-party bare miss
49
75
  * (HQ-CLI-1Q): a possible undeclared / peer-only dependency defect, made
@@ -82,7 +108,7 @@ export type IncompleteInstallRelativeDiagnostics = {
82
108
  * exactly one of the strict shapes above; the looseness is only at the read
83
109
  * boundary.
84
110
  */
85
- export type IncompleteInstallDiagnostics = Partial<IncompleteInstallEsmDiagnostics & IncompleteInstallBareDiagnostics & IncompleteInstallRelativeDiagnostics>;
111
+ export type IncompleteInstallDiagnostics = Partial<IncompleteInstallEsmDiagnostics & IncompleteInstallBareDiagnostics & IncompleteInstallRelativeDiagnostics & IncompleteInstallEvalThrowDiagnostics>;
86
112
  /**
87
113
  * When an incomplete-install failure reaches the capture path WITHOUT being
88
114
  * suppressed, return a bounded `contexts.incomplete_install` block so the next
@@ -94,10 +120,14 @@ export type IncompleteInstallDiagnostics = Partial<IncompleteInstallEsmDiagnosti
94
120
  * dist/ or assets/, outside the install, or under an unresolved root
95
121
  * (HQ-CLI-1N) → { code, relativeSpecifier:true, packageRoot,
96
122
  * packageRootResolved, resolver, requirerScope };
97
- * - an esm-loader ENOENT whose path did not survive delivery (HQ-CLI-1M) — the
98
- * shape the delivered payload arrived in, where neither the exception value
99
- * nor node_system_error carried a `path` → { packageRoot, packageJsonExists,
100
- * nodeModulesExists, esmLoaderFrame, code }.
123
+ * - a loader/resolver ENOENT whose path did not survive delivery (HQ-CLI-1M
124
+ * esm-load open, HQ-CLI-1S resolve-time lstat, HQ-CLI-20 CJS load-time open)
125
+ * where neither the exception value nor node_system_error carried a `path` →
126
+ * { packageRoot, packageJsonExists, nodeModulesExists, esmLoaderFrame,
127
+ * resolverFrame, cjsLoaderFrame, code };
128
+ * - a module-EVALUATION throw that reached the boundary raw (HQ-CLI-1T) →
129
+ * { evalThrow, errorName, throwSitePackage, packageRoot, packageRootResolved,
130
+ * resolver }.
101
131
  * Built with the byte-capped, scrubber-safe discipline of
102
132
  * package-root-diagnostics.ts — never a caller argv, query, or user-minted
103
133
  * value. Never throws. main.ts attaches this on the generic capture path
@@ -7,7 +7,7 @@
7
7
  // failure inside THIS process rather than a qmd child's captured stderr, which
8
8
  // is the gap HQ-CLI-Y's classifier cannot cover.
9
9
  //
10
- // Three shapes, one cause — a partial/interrupted global install left a file
10
+ // Four shapes, one cause — a partial/interrupted global install left a file
11
11
  // unwritten, or a concurrent global install rewrote the running tree
12
12
  // underneath a command:
13
13
  //
@@ -36,6 +36,14 @@
36
36
  // ERR_MODULE_NOT_FOUND at resolve, never ENOENT at load; an ENOENT at load
37
37
  // proves the file vanished between resolve and read.
38
38
  //
39
+ // HQ-CLI-20 (Sentry 7724280783) — CJS load, in-process. The CJS twin of
40
+ // HQ-CLI-1M: `hq core timeout-guard` registers the full command graph, which
41
+ // reaches a bundled CommonJS dependency (ajv) whose internal require() resolved
42
+ // a sibling from Module._pathCache and then read its source after a concurrent
43
+ // reinstall had vanished it. Node raised `ENOENT` with syscall 'open' from the
44
+ // CJS loader's source read (defaultLoadImpl/loadSource) — a cjs/loader frame but
45
+ // NO esm/load frame, so the esm-loader gate missed it and it filed a bare crash.
46
+ //
39
47
  // All shapes carry no hq-cli frames, reach the boundary's final `else`, and —
40
48
  // before this classifier — filed a bare captureException plus an unactionable
41
49
  // `hq: <fallback>` line. The disposition is the one HQ-CLI-Y already
@@ -59,12 +67,15 @@
59
67
  // optional-dependency miss (not guaranteed installed), and an undeclared or
60
68
  // peer-only miss (`Cannot find module 'mqtt'` from a requirer that does not
61
69
  // declare it) all stay loud — the last two enriched with both package names.
62
- // 3. The ESM shape additionally requires an esm-loader frame — an ordinary
63
- // `fs.readFileSync` ENOENT written by hq's own code stays captured.
70
+ // 3. The loader shapes additionally require a loader SOURCE-READ frame — the ESM
71
+ // shape an `esm/load` frame, the CJS shape (HQ-CLI-20) a cjs/loader frame
72
+ // immediately beneath the node:fs read. An ordinary `fs.readFileSync` ENOENT
73
+ // written by hq's own code, or one escaping a third-party module's own
74
+ // EVALUATION, carries no such frame and stays captured.
64
75
  import * as fs from "fs";
65
76
  import * as path from "path";
66
77
  import { packageRoot, stringDerivedPackageRoot } from "./hq-roots.js";
67
- import { packageNameOf } from "./install-tree-torn.js";
78
+ import { evalThrowShape, hasCjsLoaderSourceReadFrame, packageNameOf } from "./install-tree-torn.js";
68
79
  import { boundedDiagnosticValue } from "./package-root-diagnostics.js";
69
80
  /**
70
81
  * The actionable remedy shown to the operator. Input-free — nothing from the
@@ -95,6 +106,16 @@ const RELATIVE_SPECIFIER = /^\.\.?[\\/]/;
95
106
  * `esm/loader` module.
96
107
  */
97
108
  const ESM_LOADER_FRAME = /node:internal[\\/]modules[\\/]esm[\\/]load\b/;
109
+ /**
110
+ * The Node module RESOLVER frame — `finalizeResolution` in
111
+ * `node:internal/modules/esm/resolve`, or the CJS `Module._findPath → toRealPath`
112
+ * route in `node:internal/modules/cjs/loader`. Alongside `syscall === 'lstat'`
113
+ * this proves a resolve-time ENOENT (HQ-CLI-1S) — a path component that vanished
114
+ * between the resolver's stat and its realpathSync lstat — so an ordinary
115
+ * `fs.lstatSync` ENOENT from hq's own code stays captured. Mirrors the frame
116
+ * matcher in install-tree-torn.ts (duplicated exactly as ESM_LOADER_FRAME is).
117
+ */
118
+ const RESOLVER_FRAME = /node:internal[\\/]modules[\\/](?:esm[\\/]resolve|cjs[\\/]loader)\b/;
98
119
  const ROOT_DIAGNOSTIC_BYTES = 256;
99
120
  const CODE_DIAGNOSTIC_BYTES = 32;
100
121
  // Package names live inside hq-cli's own dependency graph, so their universe is
@@ -306,6 +327,10 @@ function requirerDeclaresDependency(requiringFile, packageName, fileSystem) {
306
327
  function hasEsmLoaderFrame(stack) {
307
328
  return typeof stack === "string" && ESM_LOADER_FRAME.test(stack);
308
329
  }
330
+ /** True when `stack` carries a Node module RESOLVER frame. */
331
+ function hasResolverFrame(stack) {
332
+ return typeof stack === "string" && RESOLVER_FRAME.test(stack);
333
+ }
309
334
  /**
310
335
  * The ESM-loader ENOENT SIGNATURE, independent of whether a usable `path`
311
336
  * survived: `code === 'ENOENT'`, `syscall === 'open'`, and an esm-loader frame
@@ -321,10 +346,44 @@ function isEsmLoaderEnoent(err) {
321
346
  hasEsmLoaderFrame(record.stack));
322
347
  }
323
348
  /**
324
- * If `err` is an in-process incomplete-install module-load failure — either the
325
- * CJS relative-sibling shape (HQ-CLI-1N) or the ESM vanished-file shape
326
- * (HQ-CLI-1M), with the failing file confirmed under `<packageRoot>/node_modules/`
327
- * return the actionable, input-free reinstall remedy; otherwise return null.
349
+ * The resolve-time ENOENT SIGNATURE (HQ-CLI-1S): `code === 'ENOENT'`,
350
+ * `syscall === 'lstat'`, and a resolver frame independent of whether a usable
351
+ * `path` survived delivery. Sibling of {@link isEsmLoaderEnoent}; the message
352
+ * classifier additionally requires a `path` under node_modules.
353
+ */
354
+ function isResolveEnoent(err) {
355
+ if (err === null || typeof err !== "object")
356
+ return false;
357
+ const record = err;
358
+ return (record.code === "ENOENT" &&
359
+ record.syscall === "lstat" &&
360
+ hasResolverFrame(record.stack));
361
+ }
362
+ /** True when `stack` is a CJS loader SOURCE-READ stack (HQ-CLI-20). */
363
+ function hasCjsLoaderFrame(stack) {
364
+ return typeof stack === "string" && hasCjsLoaderSourceReadFrame(stack);
365
+ }
366
+ /**
367
+ * The CJS loader-read ENOENT SIGNATURE (HQ-CLI-20): `code === 'ENOENT'`,
368
+ * `syscall === 'open'`, and a CJS loader SOURCE-READ stack (the positional gate
369
+ * shared from install-tree-torn.ts) — the CJS twin of {@link isEsmLoaderEnoent},
370
+ * independent of whether a usable `path` survived. The message classifier
371
+ * additionally requires a `path` under node_modules.
372
+ */
373
+ function isCjsLoaderEnoent(err) {
374
+ if (err === null || typeof err !== "object")
375
+ return false;
376
+ const record = err;
377
+ return (record.code === "ENOENT" &&
378
+ record.syscall === "open" &&
379
+ hasCjsLoaderFrame(record.stack));
380
+ }
381
+ /**
382
+ * If `err` is an in-process incomplete-install module-load failure — the CJS
383
+ * relative-sibling shape (HQ-CLI-1N), the ESM vanished-at-read shape (HQ-CLI-1M),
384
+ * or the resolve-time lstat shape (HQ-CLI-1S), with the failing file confirmed
385
+ * under `<packageRoot>/node_modules/` — return the actionable, input-free reinstall
386
+ * remedy; otherwise return null.
328
387
  *
329
388
  * Mirrors qmdModuleMissingMessage so the top-level handler and beforeSend branch
330
389
  * the same way: a non-null result means print-the-remedy-and-skip-Sentry, null
@@ -375,17 +434,35 @@ export function incompleteInstallMessage(err, resolvePackageRoot = safePackageRo
375
434
  ? INCOMPLETE_INSTALL_REMEDY
376
435
  : null;
377
436
  }
378
- // Shape B (ESM load, HQ-CLI-1M): an ENOENT from the module loader for a file
379
- // that was present at resolve and gone at read.
380
- if (record.syscall !== "open")
381
- return null;
382
- if (typeof record.path !== "string")
383
- return null;
384
- if (!hasEsmLoaderFrame(record.stack))
385
- return null;
386
- return isUnderNodeModules(record.path, root)
387
- ? INCOMPLETE_INSTALL_REMEDY
388
- : null;
437
+ // Shape B (ESM load, HQ-CLI-1M): an ENOENT from the module LOADER for a file
438
+ // present at resolve and gone at read (syscall 'open' + an esm-loader frame).
439
+ if (record.syscall === "open" &&
440
+ typeof record.path === "string" &&
441
+ hasEsmLoaderFrame(record.stack)) {
442
+ return isUnderNodeModules(record.path, root) ? INCOMPLETE_INSTALL_REMEDY : null;
443
+ }
444
+ // Shape B' (RESOLVE time, HQ-CLI-1S): an ENOENT from the resolver's realpath
445
+ // step (syscall 'lstat' + a resolver frame) — a path component that a concurrent
446
+ // reinstall renamed away between the resolver's stat and its lstat. Same
447
+ // disposition as B: a vanished component under the running install's
448
+ // node_modules is a torn install, so print the remedy and skip capture.
449
+ if (record.syscall === "lstat" &&
450
+ typeof record.path === "string" &&
451
+ hasResolverFrame(record.stack)) {
452
+ return isUnderNodeModules(record.path, root) ? INCOMPLETE_INSTALL_REMEDY : null;
453
+ }
454
+ // Shape B'' (CJS load-time, HQ-CLI-20): the CJS twin of Shape B — an ENOENT from
455
+ // the CJS loader's source read (syscall 'open' + a CJS loader SOURCE-READ stack,
456
+ // NO esm/load frame) for a bundled dependency file present at resolve and gone at
457
+ // read. Same disposition as B: a vanished file under the running install's
458
+ // node_modules is a torn install, so print the remedy and skip capture; a miss
459
+ // under the install's own dist/ or assets/ stays captured.
460
+ if (record.syscall === "open" &&
461
+ typeof record.path === "string" &&
462
+ hasCjsLoaderFrame(record.stack)) {
463
+ return isUnderNodeModules(record.path, root) ? INCOMPLETE_INSTALL_REMEDY : null;
464
+ }
465
+ return null;
389
466
  }
390
467
  /** A readFileSync surface, defaulting to the real fs when the caller injects none. */
391
468
  function readFileFrom(fileSystem) {
@@ -513,10 +590,14 @@ function relativeSpecifierCaptureContext(err, resolvePackageRoot, fileSystem) {
513
590
  * dist/ or assets/, outside the install, or under an unresolved root
514
591
  * (HQ-CLI-1N) → { code, relativeSpecifier:true, packageRoot,
515
592
  * packageRootResolved, resolver, requirerScope };
516
- * - an esm-loader ENOENT whose path did not survive delivery (HQ-CLI-1M) — the
517
- * shape the delivered payload arrived in, where neither the exception value
518
- * nor node_system_error carried a `path` → { packageRoot, packageJsonExists,
519
- * nodeModulesExists, esmLoaderFrame, code }.
593
+ * - a loader/resolver ENOENT whose path did not survive delivery (HQ-CLI-1M
594
+ * esm-load open, HQ-CLI-1S resolve-time lstat, HQ-CLI-20 CJS load-time open)
595
+ * where neither the exception value nor node_system_error carried a `path` →
596
+ * { packageRoot, packageJsonExists, nodeModulesExists, esmLoaderFrame,
597
+ * resolverFrame, cjsLoaderFrame, code };
598
+ * - a module-EVALUATION throw that reached the boundary raw (HQ-CLI-1T) →
599
+ * { evalThrow, errorName, throwSitePackage, packageRoot, packageRootResolved,
600
+ * resolver }.
520
601
  * Built with the byte-capped, scrubber-safe discipline of
521
602
  * package-root-diagnostics.ts — never a caller argv, query, or user-minted
522
603
  * value. Never throws. main.ts attaches this on the generic capture path
@@ -531,19 +612,66 @@ export function incompleteInstallCaptureContext(err, resolvePackageRoot = safePa
531
612
  const relative = relativeSpecifierCaptureContext(err, resolvePackageRoot, fileSystem);
532
613
  if (relative)
533
614
  return relative;
534
- // (C) HQ-CLI-1M — the path-less esm-loader ENOENT, unchanged.
535
- if (!isEsmLoaderEnoent(err))
536
- return undefined;
537
- // Only instrument what we did NOT already confidently suppress: a path under
538
- // node_modules is classified and printed above, never captured.
539
- if (incompleteInstallMessage(err, resolvePackageRoot, readFileFrom(fileSystem)) !== null) {
540
- return undefined;
615
+ // (C) HQ-CLI-1M / HQ-CLI-1S / HQ-CLI-20 — the path-less loader/resolver ENOENT
616
+ // (esm-load open, resolve-time lstat, or CJS load-time open), made attributable.
617
+ // An ENOENT whose `path` survived and sits under node_modules is classified and
618
+ // printed above, never captured; only the unattributable shape (no usable path,
619
+ // or a path outside node_modules) reaches here.
620
+ if (isEsmLoaderEnoent(err) || isResolveEnoent(err) || isCjsLoaderEnoent(err)) {
621
+ if (incompleteInstallMessage(err, resolvePackageRoot, readFileFrom(fileSystem)) === null) {
622
+ const record = err;
623
+ const code = typeof record.code === "string" ? record.code : "";
624
+ // The default resolver knows which strategy answered (manifest walk vs the
625
+ // string derivation that survives a torn tree); an injected resolver is
626
+ // opaque, so it is recorded as "injected" and its return used as the root.
627
+ const resolution = resolvePackageRoot === safePackageRoot
628
+ ? resolvePackageRootWithSource()
629
+ : {
630
+ root: resolveRootSafely(resolvePackageRoot),
631
+ source: "injected",
632
+ };
633
+ const root = resolution.root;
634
+ const diagnostics = {
635
+ packageRoot: boundedDiagnosticValue(root ?? "<unresolved>", ROOT_DIAGNOSTIC_BYTES),
636
+ packageRootResolved: root !== null,
637
+ resolver: resolution.source,
638
+ // Which route raised it — the esm-loader (source-read) frame for 1M, the
639
+ // resolver frame for 1S, the CJS loader source-read frame for HQ-CLI-20;
640
+ // any are recorded from the stack.
641
+ esmLoaderFrame: hasEsmLoaderFrame(record.stack),
642
+ resolverFrame: hasResolverFrame(record.stack),
643
+ cjsLoaderFrame: hasCjsLoaderFrame(record.stack),
644
+ code: boundedDiagnosticValue(code, CODE_DIAGNOSTIC_BYTES),
645
+ };
646
+ // Report the existence booleans ONLY when a root was resolved. Reported for
647
+ // an unresolved root they were uninitialised `false`s indistinguishable from
648
+ // "resolved but missing" — the instrumentation defect the prior fix shipped.
649
+ if (root !== null) {
650
+ diagnostics.packageJsonExists = safeExistsSync(fileSystem, path.join(root, "package.json"));
651
+ diagnostics.nodeModulesExists = safeExistsSync(fileSystem, path.join(root, "node_modules"));
652
+ }
653
+ return { incomplete_install: diagnostics };
654
+ }
541
655
  }
542
- const record = err;
543
- const code = typeof record.code === "string" ? record.code : "";
544
- // The default resolver knows which strategy answered (manifest walk vs the
545
- // string derivation that survives a torn tree); an injected resolver is
546
- // opaque, so it is recorded as "injected" and its return used as the root.
656
+ // (D) HQ-CLI-1T — a module-EVALUATION throw that reached the boundary raw (the
657
+ // writer-signal gate found no reinstall in progress), made attributable.
658
+ return evalThrowCaptureContext(err, resolvePackageRoot);
659
+ }
660
+ /**
661
+ * When a module-EVALUATION throw (HQ-CLI-1T) reached the capture path raw — a
662
+ * code-less TypeError/ReferenceError/SyntaxError whose top-level code ran while a
663
+ * half-written dependency file returned an empty module — return a bounded
664
+ * `incomplete_install` block naming the offending third-party package so the next
665
+ * occurrence is attributable instead of bare; otherwise undefined. When the root
666
+ * resolves, the throw site MUST sit under its node_modules (an hq-cli `dist/` or
667
+ * `assets/` eval throw is hq's OWN defect and stays a bare capture); when the root
668
+ * is unresolved the block is still attached with `packageRootResolved:false`.
669
+ * Never throws; never emits the message, argv, or a full path.
670
+ */
671
+ function evalThrowCaptureContext(err, resolvePackageRoot) {
672
+ const shape = evalThrowShape(err);
673
+ if (!shape)
674
+ return undefined;
547
675
  const resolution = resolvePackageRoot === safePackageRoot
548
676
  ? resolvePackageRootWithSource()
549
677
  : {
@@ -551,21 +679,18 @@ export function incompleteInstallCaptureContext(err, resolvePackageRoot = safePa
551
679
  source: "injected",
552
680
  };
553
681
  const root = resolution.root;
554
- const diagnostics = {
555
- packageRoot: boundedDiagnosticValue(root ?? "<unresolved>", ROOT_DIAGNOSTIC_BYTES),
556
- packageRootResolved: root !== null,
557
- resolver: resolution.source,
558
- esmLoaderFrame: true,
559
- code: boundedDiagnosticValue(code, CODE_DIAGNOSTIC_BYTES),
682
+ if (root !== null && !isUnderNodeModules(shape.throwSite, root))
683
+ return undefined;
684
+ return {
685
+ incomplete_install: {
686
+ evalThrow: true,
687
+ errorName: boundedDiagnosticValue(shape.errorName, CODE_DIAGNOSTIC_BYTES),
688
+ throwSitePackage: boundedDiagnosticValue(requiringPackage(shape.throwSite)?.name ?? "<unresolved>", PACKAGE_NAME_DIAGNOSTIC_BYTES),
689
+ packageRoot: boundedDiagnosticValue(root ?? "<unresolved>", ROOT_DIAGNOSTIC_BYTES),
690
+ packageRootResolved: root !== null,
691
+ resolver: resolution.source,
692
+ },
560
693
  };
561
- // Report the existence booleans ONLY when a root was resolved. Reported for an
562
- // unresolved root they were uninitialised `false`s indistinguishable from
563
- // "resolved but missing" — the instrumentation defect the prior fix shipped.
564
- if (root !== null) {
565
- diagnostics.packageJsonExists = safeExistsSync(fileSystem, path.join(root, "package.json"));
566
- diagnostics.nodeModulesExists = safeExistsSync(fileSystem, path.join(root, "node_modules"));
567
- }
568
- return { incomplete_install: diagnostics };
569
694
  }
570
695
  /** existsSync that never throws — any filesystem error reads as "absent". */
571
696
  function safeExistsSync(fileSystem, target) {
@@ -31,10 +31,13 @@
31
31
  */
32
32
  /**
33
33
  * The loader-error `code`s recovery classifies. The first two mean "a module
34
- * could not be resolved"; `ENOENT` is the esm-loader dialect where a module was
35
- * present at resolve and gone at read (HQ-CLI-1M) see {@link ModuleErrorDialect}.
34
+ * could not be resolved"; `ENOENT` is the loader/resolver dialect where a module
35
+ * was present at one step and gone at the next (HQ-CLI-1M esm-load open, HQ-CLI-20
36
+ * cjs-load open, HQ-CLI-1S resolve-time lstat); `EVAL_THROW` is the synthetic code
37
+ * for a module-EVALUATION throw that carries NO real `code` (HQ-CLI-1T) — see
38
+ * {@link ModuleErrorDialect}.
36
39
  */
37
- export type ModuleNotFoundCode = "ERR_MODULE_NOT_FOUND" | "MODULE_NOT_FOUND" | "ENOENT";
40
+ export type ModuleNotFoundCode = "ERR_MODULE_NOT_FOUND" | "MODULE_NOT_FOUND" | "ENOENT" | "EVAL_THROW";
38
41
  /**
39
42
  * The closed set of resolution-failure dialects, verified on Node v22.23.1 (the
40
43
  * @sentry/node import-in-the-middle hook does not change the shapes):
@@ -54,10 +57,27 @@ export type ModuleNotFoundCode = "ERR_MODULE_NOT_FOUND" | "MODULE_NOT_FOUND" | "
54
57
  * - `esm-enoent`: ESM loader ENOENT (HQ-CLI-1M) — a module present at RESOLVE
55
58
  * and gone at READ, so getSourceSync/openSync raises ENOENT
56
59
  * (not ERR_MODULE_NOT_FOUND). `err.path` is the vanished file.
60
+ * - `cjs-enoent`: CJS loader ENOENT (HQ-CLI-20) — the CJS twin of esm-enoent: a
61
+ * require() whose target was present at RESOLVE (its filename
62
+ * held in Module._pathCache) and gone at READ, so the CJS
63
+ * loader's source read (defaultLoadImpl/loadSource, or the older
64
+ * Module._extensions..js handler) raises ENOENT with syscall
65
+ * 'open' and a cjs/loader frame but NO esm/load frame. `err.path`
66
+ * is the vanished file.
67
+ * - `resolve-enoent`: ESM/CJS RESOLVE-time ENOENT (HQ-CLI-1S) — a path component
68
+ * stat'ed OK, then vanished before realpathSync's lstat, so the
69
+ * resolver (finalizeResolution / Module._findPath→toRealPath)
70
+ * raises ENOENT with syscall `lstat`. `err.path` is the vanished
71
+ * component (which may be a package/scope directory).
72
+ * - `eval-throw`: a module-EVALUATION throw (HQ-CLI-1T) — a dependency file was
73
+ * found but still half-written, so require() returned an empty
74
+ * module and its top-level code threw a code-LESS TypeError /
75
+ * ReferenceError / SyntaxError under Module._compile /
76
+ * esm/module_job. The specifier is the bounded throw-site path.
57
77
  * - `unknown`: a module-not-found whose message did not parse; recovery
58
78
  * still waits on the lock / retired-dir / quiet signals.
59
79
  */
60
- export type ModuleErrorDialect = "esm-path" | "esm-package" | "cjs-path" | "cjs-package" | "cjs-relative" | "esm-enoent" | "unknown";
80
+ export type ModuleErrorDialect = "esm-path" | "esm-package" | "cjs-path" | "cjs-package" | "cjs-relative" | "esm-enoent" | "cjs-enoent" | "resolve-enoent" | "eval-throw" | "unknown";
61
81
  /**
62
82
  * The missing thing, re-resolvable by the readiness probe:
63
83
  * - `path`: an absolute filesystem path (a `.js` file, or a package dir).
@@ -66,6 +86,12 @@ export type ModuleErrorDialect = "esm-path" | "esm-package" | "cjs-path" | "cjs-
66
86
  * Node's CJS resolver does from directory `from` —
67
87
  * `path.resolve(from, specifier)` plus the `.js`/`.json`/`.node`
68
88
  * and directory forms.
89
+ * - `exists`: an absolute path whose bare EXISTENCE is the readiness signal
90
+ * (HQ-CLI-1S) — the resolver lstat'ed this component, which may be
91
+ * a package/scope DIRECTORY whose contents precede a `package.json`,
92
+ * so the `path` kind's directory rule could stay false forever.
93
+ * Readiness still rides the lock / manifest-health / retired-sibling
94
+ * / quiet guards, exactly as for the loader-race dialects.
69
95
  * - `unknown`: the message did not parse; treated as "present" by the probe so
70
96
  * readiness turns only on the lock / retired-dir / quiet signals.
71
97
  */
@@ -80,6 +106,9 @@ export type ModuleErrorTarget = {
80
106
  kind: "relative";
81
107
  specifier: string;
82
108
  from: string;
109
+ } | {
110
+ kind: "exists";
111
+ path: string;
83
112
  } | {
84
113
  kind: "unknown";
85
114
  };
@@ -91,7 +120,10 @@ export interface ClassifiedModuleError {
91
120
  /** The importer the message named (from-clause or requireStack[0]), or "". */
92
121
  importer: string;
93
122
  target: ModuleErrorTarget;
123
+ /** The thrown error's `name` for the eval-throw dialect (HQ-CLI-1T), else absent. */
124
+ errorName?: string;
94
125
  }
126
+ export declare function hasCjsLoaderSourceReadFrame(stack: string): boolean;
95
127
  /**
96
128
  * Reduce a bare specifier to its PACKAGE name: `@scope/name/sub` → `@scope/name`,
97
129
  * `name/sub` → `name`, `name` → `name`. This is the unit the readiness probe
@@ -109,6 +141,39 @@ export declare function packageNameOf(specifier: string): string;
109
141
  * `null` so it is rethrown to the existing boundary unchanged.
110
142
  */
111
143
  export declare function classifyModuleNotFound(err: unknown): ClassifiedModuleError | null;
144
+ /** The structural fingerprint of a module-EVALUATION throw (HQ-CLI-1T). */
145
+ export interface EvalThrowShape {
146
+ /** The thrown error's `name`, in {@link EVAL_THROW_NAMES}. */
147
+ errorName: string;
148
+ /** The absolute throw-site path (first path token in the stack). */
149
+ throwSite: string;
150
+ }
151
+ /**
152
+ * Detect a module-EVALUATION throw STRUCTURALLY, WITHOUT the node_modules
153
+ * boundary gate: an error with NO `code`, a `name` in the closed set
154
+ * {TypeError, ReferenceError, SyntaxError}, a CJS/ESM module-evaluation loader
155
+ * frame, and a resolvable throw-site path. This is the shape a half-written
156
+ * dependency file produces — require() read an empty module and returned `{}`, so
157
+ * calling one of its exports throws a code-less TypeError while the module's own
158
+ * top-level code runs. No message text is ever parsed. The caller applies its own
159
+ * packageRoot gate ({@link classifyEvalThrow} for recovery, the boundary for
160
+ * capture context), so this stays pure and reusable. Returns null for anything
161
+ * that is not this shape — a coded error (ERR_REQUIRE_ESM etc.), an Error /
162
+ * RangeError name, a throw with no loader frame, or no absolute throw site.
163
+ */
164
+ export declare function evalThrowShape(err: unknown): EvalThrowShape | null;
165
+ /**
166
+ * Classify a module-EVALUATION throw as a torn-install recovery candidate
167
+ * (HQ-CLI-1T) — the {@link evalThrowShape} fingerprint AND a throw site under
168
+ * `<packageRoot>/node_modules/`. A throw under the install's own `dist/` or
169
+ * `assets/`, outside the root, or with an unresolved root is NOT classified, so a
170
+ * genuine hq-cli code defect keeps its raw capture. Unlike the loader-race
171
+ * dialects the recovery seam gates this on a live writer signal before waiting.
172
+ * `packageRoot` comes from the seam's resolvePackageRoot (running-install → derived
173
+ * fallback). The target is `unknown` — an eval-throw carries no re-resolvable
174
+ * missing file, so recovery keys entirely on the writer signal + quiet window.
175
+ */
176
+ export declare function classifyEvalThrow(err: unknown, packageRoot: string | null): ClassifiedModuleError | null;
112
177
  /** The filesystem surface the probe and wait use, injectable for hermetic tests. */
113
178
  export interface InstallTreeFs {
114
179
  existsSync(p: string): boolean;
@@ -133,6 +198,8 @@ export interface InstallTreeFs {
133
198
  * or a directory whose package.json `main` (or that main's index)
134
199
  * or own `index.*` is a real file. A bare or partially-extracted
135
200
  * directory is NOT loadable and reads as not-present.
201
+ * - `exists`: the path exists at all (any file type) — presence only, because
202
+ * the vanished component may be a package/scope directory.
136
203
  * - `unknown`: true (readiness turns on the other signals).
137
204
  * Any filesystem error reads as "not present" rather than throwing.
138
205
  */
@@ -221,6 +288,27 @@ export interface WaitForInstallTreeSettledResult {
221
288
  * lockWaitMs`), so the caller can never hang.
222
289
  */
223
290
  export declare function waitForInstallTreeSettled(args: WaitForInstallTreeSettledArgs): Promise<WaitForInstallTreeSettledResult>;
291
+ export interface InstallRewriteSignalArgs {
292
+ /** The running install's package dir (from resolveRunningInstall), or null. */
293
+ packageRoot: string | null;
294
+ /** The shared update-lock path ($HOME/.hq/locks/cli-update.lock). */
295
+ lockPath: string;
296
+ now?: () => number;
297
+ fs?: InstallTreeFs;
298
+ isPidAlive?: (pid: number) => boolean;
299
+ }
300
+ /**
301
+ * Whether a global reinstall is PROVABLY rewriting the install tree right now —
302
+ * the positive writer signal the eval-throw gate requires before it will pay a
303
+ * settle wait. True iff (i) a fresh foreign update lock is held, (ii) packageRoot's
304
+ * manifest is absent or name-mismatched (mid-extraction), or (iii) a
305
+ * `.<leaf>-<hash>` retired sibling sits beside it. Crucially it is POSITIVE: a
306
+ * benign filesystem error that merely prevents READING the manifest or LISTING the
307
+ * parent (EACCES/EPERM) is NOT a signal, so a persistent third-party load-time
308
+ * defect on an otherwise-usable install never pays a spurious 90s wait. Every
309
+ * dependency is injectable so the recovery seam stays hermetic.
310
+ */
311
+ export declare function installRewriteInProgress(args: InstallRewriteSignalArgs): boolean;
224
312
  /** The recovery OUTCOME, which — not the dialect — decides capture. */
225
313
  export type InstallTreeTornOutcome = "guarded" | "unsettled" | "reexec-failed" | "lock-held";
226
314
  export type InstallTreeTornDiagnostics = {
@@ -241,6 +329,8 @@ export type InstallTreeTornDiagnostics = {
241
329
  /** The bounded `tool` of the last fresh foreign lock seen, or "". */
242
330
  lockTool: string;
243
331
  node: string;
332
+ /** The thrown error's `name` for the eval-throw dialect (HQ-CLI-1T), else "". */
333
+ errorName: string;
244
334
  };
245
335
  export interface InstallTreeTornInit {
246
336
  /** The original loader error, carried unchanged as `cause`. */
@@ -41,6 +41,8 @@ const IMPORTER_BYTES = 256;
41
41
  const PACKAGE_ROOT_BYTES = 256;
42
42
  /** Byte cap for the lock holder's `tool` (the only foreign string this reads). */
43
43
  const LOCK_TOOL_BYTES = 64;
44
+ /** The eval-throw error name is a short closed-set token; the cap is only a belt. */
45
+ const ERRORNAME_BYTES = 64;
44
46
  const ESM_PACKAGE_RE = /^Cannot find package '([^']+)' imported from (.+)$/s;
45
47
  const CJS_MODULE_RE = /^Cannot find module '([^']+)'/s;
46
48
  const ESM_MODULE_IMPORTED_RE = /^Cannot find module '([^']+)' imported from (.+)$/s;
@@ -54,6 +56,141 @@ const ESM_MODULE_IMPORTED_RE = /^Cannot find module '([^']+)' imported from (.+)
54
56
  * the settle wait. `load\b` also excludes the sibling `esm/loader` module.
55
57
  */
56
58
  const ESM_LOADER_FRAME = /node:internal[\\/]modules[\\/]esm[\\/]load\b/;
59
+ /**
60
+ * The Node module RESOLVER frame — `finalizeResolution` in
61
+ * `node:internal/modules/esm/resolve` (the observed HQ-CLI-1S route, where
62
+ * realpathSync lstat's a component that vanished after the earlier stat), or the
63
+ * CJS `Module._findPath → toRealPath` route in `node:internal/modules/cjs/loader`
64
+ * (the same stat-then-lstat TOCTOU, accepted for symmetry). Requiring a resolver
65
+ * frame — alongside `syscall === 'lstat'` — is what proves the ENOENT came from
66
+ * the resolver's realpath step, so an ordinary `fs.lstatSync` ENOENT written by
67
+ * hq's own code (no resolver frame) is NOT misclassified as a torn install.
68
+ */
69
+ const RESOLVER_FRAME = /node:internal[\\/]modules[\\/](?:esm[\\/]resolve|cjs[\\/]loader)\b/;
70
+ /** The closed set of error `name`s the eval-throw dialect accepts (HQ-CLI-1T). */
71
+ const EVAL_THROW_NAMES = new Set(["TypeError", "ReferenceError", "SyntaxError"]);
72
+ /**
73
+ * A CJS module-EVALUATION frame — `Module._compile` / `wrapSafe` in
74
+ * `node:internal/modules/cjs/loader`, where a required module's top-level code
75
+ * runs (and where a SyntaxError is raised at compile). NOT the resolve frame
76
+ * `Module._resolveFilename`, so a resolution failure is never read as an eval.
77
+ */
78
+ const CJS_EVAL_FRAME = /\bat (?:Module\._compile|wrapSafe) \(node:internal[\\/]modules[\\/]cjs[\\/]loader/;
79
+ /** An ESM module-EVALUATION frame — `ModuleJob` run/evaluate in esm/module_job. */
80
+ const ESM_EVAL_FRAME = /node:internal[\\/]modules[\\/]esm[\\/]module_job\b/;
81
+ /** Whether `stack` carries a CJS or ESM module-EVALUATION loader frame. */
82
+ function hasModuleEvalFrame(stack) {
83
+ return CJS_EVAL_FRAME.test(stack) || ESM_EVAL_FRAME.test(stack);
84
+ }
85
+ /**
86
+ * The Node CJS loader's SOURCE-READ position (HQ-CLI-20) — the CJS twin of
87
+ * {@link ESM_LOADER_FRAME}. A CJS require whose target was present at RESOLVE (its
88
+ * filename cached in Module._pathCache) and gone at READ fails inside the loader's
89
+ * source read: `readFileSync (node:fs)` called by `defaultLoadImpl`/`loadSource`
90
+ * (Node ≥22) or, on older Node, the `Module._extensions..js` extension handler —
91
+ * all frames in `node:internal/modules/cjs/loader`, and (unlike HQ-CLI-1M) with NO
92
+ * `esm/load` frame.
93
+ *
94
+ * The discriminator is POSITIONAL: the innermost `node:fs` read frame's
95
+ * IMMEDIATELY-following (caller) frame must be a cjs/loader frame that is NOT a
96
+ * module-EVALUATION frame (Module._compile / wrapSafe). That is what proves the
97
+ * LOADER was reading the module's source. An ordinary `fs.readFileSync`/`openSync`
98
+ * ENOENT that merely ESCAPES a third-party module's own EVALUATION has the
99
+ * third-party FILE directly beneath `node:fs`, not a cjs/loader frame, so it stays
100
+ * captured (both shapes reproduced frame-for-frame on Node v22.23.1). The
101
+ * positional rule is the PRIMARY discriminator; the source-read helper NAMES are
102
+ * an accepted closed set, not the sole requirement, so a renamed helper still
103
+ * classifies.
104
+ *
105
+ * The whole-stack presence of Module._compile is deliberately NOT a rejection
106
+ * signal: under a raised Error.stackTraceLimit the torn shape ALSO carries the
107
+ * requiring module's own Module._compile frame — always at depth 11, past the
108
+ * default limit of 10 — so only the frame immediately beneath the node:fs read is
109
+ * consulted. Exported so incomplete-install-error.ts (which already imports from
110
+ * this module) shares the exact gate rather than duplicating the positional logic,
111
+ * mirroring how {@link evalThrowShape} and {@link packageNameOf} are shared.
112
+ */
113
+ const NODE_FS_READ_FRAME = /^at (?:[\w$.]+\.)?(?:readFileSync|openSync) \(node:fs\b/;
114
+ const CJS_LOADER_FRAME = /node:internal[\\/]modules[\\/]cjs[\\/]loader\b/;
115
+ export function hasCjsLoaderSourceReadFrame(stack) {
116
+ const frames = stack
117
+ .split("\n")
118
+ .map((line) => line.trim())
119
+ .filter((line) => line.startsWith("at "));
120
+ for (let i = 0; i < frames.length; i++) {
121
+ if (!NODE_FS_READ_FRAME.test(frames[i]))
122
+ continue;
123
+ const caller = frames[i + 1];
124
+ if (caller === undefined)
125
+ return false;
126
+ return CJS_LOADER_FRAME.test(caller) && !CJS_EVAL_FRAME.test(caller);
127
+ }
128
+ return false;
129
+ }
130
+ /**
131
+ * A stack LOCATION token — the absolute filesystem path in either an
132
+ * `at <fn> (<abs>:L:C)` frame or a bare `<abs>:L` header line (the source
133
+ * location a SyntaxError prepends). Anchored at line-start or an opening paren so
134
+ * a `node:internal/…` internal frame (never an absolute FS path) is skipped, and
135
+ * a Windows drive/UNC path is matched by shape so it classifies on any host.
136
+ */
137
+ const STACK_LOCATION = /(?:^|\()((?:\/|[A-Za-z]:[\\/]|\\\\)[^():\n]*?):\d+(?::\d+)?(?:\)|$)/;
138
+ /**
139
+ * A `file:` URL location in a stack frame — the form Node uses for an ESM module
140
+ * EVALUATION frame (`at file:///abs/mod.mjs:2:1`, or the same wrapped in parens).
141
+ * The whole non-space token is captured; the caller strips the trailing paren and
142
+ * `:line[:col]` and decodes it with fileURLToPath.
143
+ */
144
+ function fileUrlFromFrame(line) {
145
+ const token = line.match(/file:\/\/\/\S+/);
146
+ if (!token)
147
+ return null;
148
+ const url = token[0].replace(/\)+$/, "").replace(/:\d+(?::\d+)?$/, "");
149
+ try {
150
+ return fileURLToPath(url);
151
+ }
152
+ catch {
153
+ return null; // not a decodable file URL
154
+ }
155
+ }
156
+ /**
157
+ * The eval-throw THROW SITE — the FIRST absolute filesystem path token in the
158
+ * stack, scanning top-down: for an ordinary error that is the throwing frame (a
159
+ * raw path for a CJS module, or a `file:` URL for an ESM one); for a SyntaxError
160
+ * it is the `<abs>:L` header line the stack carries before the `Error:` line.
161
+ * Returns null when no absolute path token is present.
162
+ */
163
+ function evalThrowSite(stack) {
164
+ for (const rawLine of stack.split("\n")) {
165
+ const line = rawLine.trim();
166
+ const plain = STACK_LOCATION.exec(line);
167
+ if (plain)
168
+ return plain[1];
169
+ const fromUrl = fileUrlFromFrame(line);
170
+ if (fromUrl)
171
+ return fromUrl;
172
+ }
173
+ return null;
174
+ }
175
+ /**
176
+ * Lexical `<root>/node_modules/` containment for the eval-throw throw-site gate.
177
+ * Separator- and (Windows-shape-only) case-folded and anchored at a true
178
+ * directory boundary, so a sibling such as `<root>-old/node_modules/…` never
179
+ * matches. Mirrors incomplete-install-error.ts's isUnderNodeModules discipline;
180
+ * kept local because that module imports THIS one (a shared import would cycle).
181
+ */
182
+ function looksWin32(p) {
183
+ return /^[a-zA-Z]:[\\/]/.test(p) || /^\\\\/.test(p);
184
+ }
185
+ function foldForCompare(p) {
186
+ const folded = p.replace(/[\\/]+/g, "/").replace(/\/+$/, "");
187
+ return looksWin32(p) ? folded.toLowerCase() : folded;
188
+ }
189
+ function isUnderNodeModules(candidate, root) {
190
+ if (!candidate || !root)
191
+ return false;
192
+ return foldForCompare(candidate).startsWith(`${foldForCompare(root)}/node_modules/`);
193
+ }
57
194
  /**
58
195
  * A relative module specifier — `./x`, `../x`, `.\x`, `..\x`, or a bare `.`/`..`.
59
196
  * Mirrors the shape src/utils/incomplete-install-error.ts uses, extended with the
@@ -122,6 +259,53 @@ export function classifyModuleNotFound(err) {
122
259
  target: { kind: "path", path: record.path },
123
260
  };
124
261
  }
262
+ // cjs-loader source-read ENOENT (HQ-CLI-20): the CJS twin of esm-enoent — a
263
+ // require() whose target was present at RESOLVE (its filename held in
264
+ // Module._pathCache) and gone at READ, so the CJS loader's source read raises
265
+ // ENOENT with syscall 'open' and a cjs/loader frame but NO esm/load frame
266
+ // (which is why arm (0) missed it and classifyModuleNotFound returned null,
267
+ // filing the bare HQ-CLI-20 capture). Gated on the FULL conjunction — code
268
+ // ENOENT AND syscall 'open' AND a string path AND the POSITIONAL CJS
269
+ // source-read gate — so a third-party module's own top-level fs.readFileSync
270
+ // ENOENT (module EVALUATION, its own frame directly beneath node:fs) stays null
271
+ // and keeps its capture. Ordered AFTER the esm-enoent arm so HQ-CLI-1M keeps
272
+ // its byte-for-byte classification. Target 'path': the vanished file is a
273
+ // regular .js/.json file Node's CJS LOAD_AS_FILE step re-resolves, so the probe
274
+ // and settle wait reuse the same machinery as esm-enoent, unchanged.
275
+ if (record.syscall === "open" &&
276
+ typeof record.path === "string" &&
277
+ typeof record.stack === "string" &&
278
+ hasCjsLoaderSourceReadFrame(record.stack)) {
279
+ return {
280
+ code,
281
+ dialect: "cjs-enoent",
282
+ specifier: record.path,
283
+ importer: "",
284
+ target: { kind: "path", path: record.path },
285
+ };
286
+ }
287
+ // resolve-time ENOENT (HQ-CLI-1S): Node's resolver stat'ed a path OK, then
288
+ // realpathSync lstat'ed a component that a concurrent reinstall renamed away
289
+ // between the two, throwing ENOENT with syscall `lstat` from a RESOLVER frame
290
+ // (finalizeResolution / Module._findPath→toRealPath). Gated on the FULL
291
+ // conjunction so an ordinary fs.lstatSync ENOENT from hq's own code (no
292
+ // resolver frame) and a rename/unlink ENOENT (syscall not `lstat`) both stay
293
+ // null. The vanished component may be a package/scope DIRECTORY whose bare
294
+ // existence precedes its `package.json`, so the target is `exists` (presence
295
+ // only); readiness still rides the lock / manifest-health / retired-sibling /
296
+ // quiet guards. Structurally a loader race like esm-enoent — no writer gate.
297
+ if (record.syscall === "lstat" &&
298
+ typeof record.path === "string" &&
299
+ typeof record.stack === "string" &&
300
+ RESOLVER_FRAME.test(record.stack)) {
301
+ return {
302
+ code,
303
+ dialect: "resolve-enoent",
304
+ specifier: record.path,
305
+ importer: "",
306
+ target: { kind: "exists", path: record.path },
307
+ };
308
+ }
125
309
  return null;
126
310
  }
127
311
  if (code !== "ERR_MODULE_NOT_FOUND" && code !== "MODULE_NOT_FOUND")
@@ -206,6 +390,61 @@ export function classifyModuleNotFound(err) {
206
390
  // the lock / retired-dir / quiet signals only.
207
391
  return { code, dialect: "unknown", specifier: "", importer: "", target: { kind: "unknown" } };
208
392
  }
393
+ /**
394
+ * Detect a module-EVALUATION throw STRUCTURALLY, WITHOUT the node_modules
395
+ * boundary gate: an error with NO `code`, a `name` in the closed set
396
+ * {TypeError, ReferenceError, SyntaxError}, a CJS/ESM module-evaluation loader
397
+ * frame, and a resolvable throw-site path. This is the shape a half-written
398
+ * dependency file produces — require() read an empty module and returned `{}`, so
399
+ * calling one of its exports throws a code-less TypeError while the module's own
400
+ * top-level code runs. No message text is ever parsed. The caller applies its own
401
+ * packageRoot gate ({@link classifyEvalThrow} for recovery, the boundary for
402
+ * capture context), so this stays pure and reusable. Returns null for anything
403
+ * that is not this shape — a coded error (ERR_REQUIRE_ESM etc.), an Error /
404
+ * RangeError name, a throw with no loader frame, or no absolute throw site.
405
+ */
406
+ export function evalThrowShape(err) {
407
+ if (err === null || typeof err !== "object")
408
+ return null;
409
+ const record = err;
410
+ if (record.code !== undefined)
411
+ return null;
412
+ const name = typeof record.name === "string" ? record.name : "";
413
+ if (!EVAL_THROW_NAMES.has(name))
414
+ return null;
415
+ if (typeof record.stack !== "string" || !hasModuleEvalFrame(record.stack))
416
+ return null;
417
+ const throwSite = evalThrowSite(record.stack);
418
+ if (throwSite === null)
419
+ return null;
420
+ return { errorName: name, throwSite };
421
+ }
422
+ /**
423
+ * Classify a module-EVALUATION throw as a torn-install recovery candidate
424
+ * (HQ-CLI-1T) — the {@link evalThrowShape} fingerprint AND a throw site under
425
+ * `<packageRoot>/node_modules/`. A throw under the install's own `dist/` or
426
+ * `assets/`, outside the root, or with an unresolved root is NOT classified, so a
427
+ * genuine hq-cli code defect keeps its raw capture. Unlike the loader-race
428
+ * dialects the recovery seam gates this on a live writer signal before waiting.
429
+ * `packageRoot` comes from the seam's resolvePackageRoot (running-install → derived
430
+ * fallback). The target is `unknown` — an eval-throw carries no re-resolvable
431
+ * missing file, so recovery keys entirely on the writer signal + quiet window.
432
+ */
433
+ export function classifyEvalThrow(err, packageRoot) {
434
+ const shape = evalThrowShape(err);
435
+ if (shape === null)
436
+ return null;
437
+ if (!packageRoot || !isUnderNodeModules(shape.throwSite, packageRoot))
438
+ return null;
439
+ return {
440
+ code: "EVAL_THROW",
441
+ dialect: "eval-throw",
442
+ specifier: shape.throwSite,
443
+ importer: "",
444
+ target: { kind: "unknown" },
445
+ errorName: shape.errorName,
446
+ };
447
+ }
209
448
  const nodeInstallTreeFs = {
210
449
  existsSync: (p) => nodeFs.existsSync(p),
211
450
  statSync: (p) => nodeFs.statSync(p),
@@ -289,6 +528,8 @@ function relativeTargetResolvable(base, fs) {
289
528
  * or a directory whose package.json `main` (or that main's index)
290
529
  * or own `index.*` is a real file. A bare or partially-extracted
291
530
  * directory is NOT loadable and reads as not-present.
531
+ * - `exists`: the path exists at all (any file type) — presence only, because
532
+ * the vanished component may be a package/scope directory.
292
533
  * - `unknown`: true (readiness turns on the other signals).
293
534
  * Any filesystem error reads as "not present" rather than throwing.
294
535
  */
@@ -296,6 +537,12 @@ export function installTargetPresent(target, fs = nodeInstallTreeFs) {
296
537
  try {
297
538
  if (target.kind === "unknown")
298
539
  return true;
540
+ // `exists` (resolve-enoent, HQ-CLI-1S): the lstat'ed component may be a
541
+ // package/scope DIRECTORY whose bare existence precedes its `package.json`, so
542
+ // presence alone is the signal here — the surrounding manifest-health /
543
+ // retired-sibling / quiet guards keep a premature re-exec bounded.
544
+ if (target.kind === "exists")
545
+ return fs.existsSync(target.path);
299
546
  if (target.kind === "path") {
300
547
  if (!fs.existsSync(target.path))
301
548
  return false;
@@ -509,6 +756,57 @@ export async function waitForInstallTreeSettled(args) {
509
756
  await sleep(pollMs);
510
757
  }
511
758
  }
759
+ /**
760
+ * Whether packageRoot's own manifest is ABSENT (ENOENT) or name-mismatched — a
761
+ * mid-extraction rewrite signal (npm has renamed the dir aside and is re-writing
762
+ * package.json). A read error OTHER than ENOENT (EACCES/EPERM) is a benign
763
+ * permission fault, not a rewrite, so it returns false rather than a false signal.
764
+ */
765
+ function manifestRewriteSignal(packageRoot, fs) {
766
+ let raw;
767
+ try {
768
+ raw = fs.readFileSync(path.join(packageRoot, "package.json"), "utf-8");
769
+ }
770
+ catch (err) {
771
+ return err?.code === "ENOENT";
772
+ }
773
+ try {
774
+ return JSON.parse(raw).name !== CLI_NAME;
775
+ }
776
+ catch {
777
+ return true; // present but unparseable → mid-write
778
+ }
779
+ }
780
+ /**
781
+ * Whether a global reinstall is PROVABLY rewriting the install tree right now —
782
+ * the positive writer signal the eval-throw gate requires before it will pay a
783
+ * settle wait. True iff (i) a fresh foreign update lock is held, (ii) packageRoot's
784
+ * manifest is absent or name-mismatched (mid-extraction), or (iii) a
785
+ * `.<leaf>-<hash>` retired sibling sits beside it. Crucially it is POSITIVE: a
786
+ * benign filesystem error that merely prevents READING the manifest or LISTING the
787
+ * parent (EACCES/EPERM) is NOT a signal, so a persistent third-party load-time
788
+ * defect on an otherwise-usable install never pays a spurious 90s wait. Every
789
+ * dependency is injectable so the recovery seam stays hermetic.
790
+ */
791
+ export function installRewriteInProgress(args) {
792
+ const now = (args.now ?? Date.now)();
793
+ const fs = args.fs ?? nodeInstallTreeFs;
794
+ const isPidAlive = args.isPidAlive ?? defaultIsPidAlive;
795
+ // #570 renamed freshForeignLockHeld → freshForeignLock, which returns the lock
796
+ // info (or null) instead of a boolean; a fresh foreign lock is held iff non-null.
797
+ if (freshForeignLock(args.lockPath, now, isPidAlive, fs) !== null)
798
+ return true;
799
+ if (!args.packageRoot)
800
+ return false;
801
+ if (manifestRewriteSignal(args.packageRoot, fs))
802
+ return true;
803
+ try {
804
+ return retiredSiblingPresent(args.packageRoot, fs);
805
+ }
806
+ catch {
807
+ return false; // parent not listable — benign, not a rewrite signal
808
+ }
809
+ }
512
810
  /** The fixed, input-free carrier message (grouping cardinality stays bounded). */
513
811
  const INSTALL_TREE_TORN_MESSAGE = "hq-cli install tree was being rewritten while this command started";
514
812
  /**
@@ -539,6 +837,7 @@ export class InstallTreeTornError extends Error {
539
837
  lockedMs: init.lockedMs ?? 0,
540
838
  lockTool: boundedDiagnosticValue(init.lockTool ?? "", LOCK_TOOL_BYTES),
541
839
  node: process.version,
840
+ errorName: boundedDiagnosticValue(init.classified.errorName ?? "", ERRORNAME_BYTES),
542
841
  };
543
842
  Object.setPrototypeOf(this, new.target.prototype);
544
843
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.109.11",
3
+ "version": "5.109.13",
4
4
  "description": "HQ by Indigo management CLI \u2014 modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {