@indigoai-us/hq-cli 5.109.11 → 5.109.12

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,27 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.109.12] — 2026-09-12
6
+
7
+ ### Fixed
8
+
9
+ - A background reinstall no longer turns into a crash report in two more shapes
10
+ it was still slipping through (HQ-CLI-1S, HQ-CLI-1T). When another program
11
+ reinstalls hq globally while a command is starting, it renames hq's files aside
12
+ and rewrites them file by file over a few seconds. Two failure shapes from that
13
+ window were still filed as crashes instead of being recovered: (1) a file that
14
+ Node checked a moment earlier vanished between its two resolve steps, surfacing
15
+ as a low-level `ENOENT … lstat` from inside the module resolver (HQ-CLI-1S);
16
+ and (2) a dependency file that was found but only half-written, so loading it
17
+ returned an empty module and its own code threw `TypeError: … is not a
18
+ function` (HQ-CLI-1T). Both are now recognized as "your install was mid-update,
19
+ not an hq bug": the command waits for the reinstall to settle and re-runs once
20
+ on the healed tree instead of reporting an error. The half-written-file case
21
+ only ever waits when a reinstall is actually in progress — a genuine, lasting
22
+ defect in a third-party dependency still reports exactly once and never pays a
23
+ wait, and a fault in hq's own shipped files is still reported. (The two related
24
+ shapes HQ-CLI-1V and HQ-CLI-1W are already covered by 5.109.8 and #561.)
25
+
5
26
  ## [5.109.11] — 2026-09-12
6
27
 
7
28
  ### Fixed
@@ -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,28 @@ 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;
45
53
  code: string;
46
54
  };
55
+ /**
56
+ * Bounded, scrubber-safe diagnostics for a module-EVALUATION throw that reached
57
+ * the boundary raw (HQ-CLI-1T) — a code-less TypeError/ReferenceError/SyntaxError
58
+ * whose throw site sits under the running install's node_modules, so the next
59
+ * occurrence names the offending third-party package instead of filing bare. Only
60
+ * the closed error name and the bounded package name are recorded; never the
61
+ * message, argv, or a full path.
62
+ */
63
+ export type IncompleteInstallEvalThrowDiagnostics = {
64
+ evalThrow: true;
65
+ errorName: string;
66
+ throwSitePackage: string;
67
+ packageRoot: string;
68
+ packageRootResolved: boolean;
69
+ resolver: PackageRootResolverSource;
70
+ };
47
71
  /**
48
72
  * Bounded, scrubber-safe diagnostics for a NOT-suppressed third-party bare miss
49
73
  * (HQ-CLI-1Q): a possible undeclared / peer-only dependency defect, made
@@ -82,7 +106,7 @@ export type IncompleteInstallRelativeDiagnostics = {
82
106
  * exactly one of the strict shapes above; the looseness is only at the read
83
107
  * boundary.
84
108
  */
85
- export type IncompleteInstallDiagnostics = Partial<IncompleteInstallEsmDiagnostics & IncompleteInstallBareDiagnostics & IncompleteInstallRelativeDiagnostics>;
109
+ export type IncompleteInstallDiagnostics = Partial<IncompleteInstallEsmDiagnostics & IncompleteInstallBareDiagnostics & IncompleteInstallRelativeDiagnostics & IncompleteInstallEvalThrowDiagnostics>;
86
110
  /**
87
111
  * When an incomplete-install failure reaches the capture path WITHOUT being
88
112
  * suppressed, return a bounded `contexts.incomplete_install` block so the next
@@ -94,10 +118,13 @@ export type IncompleteInstallDiagnostics = Partial<IncompleteInstallEsmDiagnosti
94
118
  * dist/ or assets/, outside the install, or under an unresolved root
95
119
  * (HQ-CLI-1N) → { code, relativeSpecifier:true, packageRoot,
96
120
  * 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 }.
121
+ * - a loader/resolver ENOENT whose path did not survive delivery (HQ-CLI-1M
122
+ * esm-load open, HQ-CLI-1S resolve-time lstat) where neither the exception
123
+ * value nor node_system_error carried a `path` → { packageRoot,
124
+ * packageJsonExists, nodeModulesExists, esmLoaderFrame, resolverFrame, code };
125
+ * - a module-EVALUATION throw that reached the boundary raw (HQ-CLI-1T) →
126
+ * { evalThrow, errorName, throwSitePackage, packageRoot, packageRootResolved,
127
+ * resolver }.
101
128
  * Built with the byte-capped, scrubber-safe discipline of
102
129
  * package-root-diagnostics.ts — never a caller argv, query, or user-minted
103
130
  * value. Never throws. main.ts attaches this on the generic capture path
@@ -64,7 +64,7 @@
64
64
  import * as fs from "fs";
65
65
  import * as path from "path";
66
66
  import { packageRoot, stringDerivedPackageRoot } from "./hq-roots.js";
67
- import { packageNameOf } from "./install-tree-torn.js";
67
+ import { evalThrowShape, packageNameOf } from "./install-tree-torn.js";
68
68
  import { boundedDiagnosticValue } from "./package-root-diagnostics.js";
69
69
  /**
70
70
  * The actionable remedy shown to the operator. Input-free — nothing from the
@@ -95,6 +95,16 @@ const RELATIVE_SPECIFIER = /^\.\.?[\\/]/;
95
95
  * `esm/loader` module.
96
96
  */
97
97
  const ESM_LOADER_FRAME = /node:internal[\\/]modules[\\/]esm[\\/]load\b/;
98
+ /**
99
+ * The Node module RESOLVER frame — `finalizeResolution` in
100
+ * `node:internal/modules/esm/resolve`, or the CJS `Module._findPath → toRealPath`
101
+ * route in `node:internal/modules/cjs/loader`. Alongside `syscall === 'lstat'`
102
+ * this proves a resolve-time ENOENT (HQ-CLI-1S) — a path component that vanished
103
+ * between the resolver's stat and its realpathSync lstat — so an ordinary
104
+ * `fs.lstatSync` ENOENT from hq's own code stays captured. Mirrors the frame
105
+ * matcher in install-tree-torn.ts (duplicated exactly as ESM_LOADER_FRAME is).
106
+ */
107
+ const RESOLVER_FRAME = /node:internal[\\/]modules[\\/](?:esm[\\/]resolve|cjs[\\/]loader)\b/;
98
108
  const ROOT_DIAGNOSTIC_BYTES = 256;
99
109
  const CODE_DIAGNOSTIC_BYTES = 32;
100
110
  // Package names live inside hq-cli's own dependency graph, so their universe is
@@ -306,6 +316,10 @@ function requirerDeclaresDependency(requiringFile, packageName, fileSystem) {
306
316
  function hasEsmLoaderFrame(stack) {
307
317
  return typeof stack === "string" && ESM_LOADER_FRAME.test(stack);
308
318
  }
319
+ /** True when `stack` carries a Node module RESOLVER frame. */
320
+ function hasResolverFrame(stack) {
321
+ return typeof stack === "string" && RESOLVER_FRAME.test(stack);
322
+ }
309
323
  /**
310
324
  * The ESM-loader ENOENT SIGNATURE, independent of whether a usable `path`
311
325
  * survived: `code === 'ENOENT'`, `syscall === 'open'`, and an esm-loader frame
@@ -321,10 +335,25 @@ function isEsmLoaderEnoent(err) {
321
335
  hasEsmLoaderFrame(record.stack));
322
336
  }
323
337
  /**
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.
338
+ * The resolve-time ENOENT SIGNATURE (HQ-CLI-1S): `code === 'ENOENT'`,
339
+ * `syscall === 'lstat'`, and a resolver frame independent of whether a usable
340
+ * `path` survived delivery. Sibling of {@link isEsmLoaderEnoent}; the message
341
+ * classifier additionally requires a `path` under node_modules.
342
+ */
343
+ function isResolveEnoent(err) {
344
+ if (err === null || typeof err !== "object")
345
+ return false;
346
+ const record = err;
347
+ return (record.code === "ENOENT" &&
348
+ record.syscall === "lstat" &&
349
+ hasResolverFrame(record.stack));
350
+ }
351
+ /**
352
+ * If `err` is an in-process incomplete-install module-load failure — the CJS
353
+ * relative-sibling shape (HQ-CLI-1N), the ESM vanished-at-read shape (HQ-CLI-1M),
354
+ * or the resolve-time lstat shape (HQ-CLI-1S), with the failing file confirmed
355
+ * under `<packageRoot>/node_modules/` — return the actionable, input-free reinstall
356
+ * remedy; otherwise return null.
328
357
  *
329
358
  * Mirrors qmdModuleMissingMessage so the top-level handler and beforeSend branch
330
359
  * the same way: a non-null result means print-the-remedy-and-skip-Sentry, null
@@ -375,17 +404,24 @@ export function incompleteInstallMessage(err, resolvePackageRoot = safePackageRo
375
404
  ? INCOMPLETE_INSTALL_REMEDY
376
405
  : null;
377
406
  }
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;
407
+ // Shape B (ESM load, HQ-CLI-1M): an ENOENT from the module LOADER for a file
408
+ // present at resolve and gone at read (syscall 'open' + an esm-loader frame).
409
+ if (record.syscall === "open" &&
410
+ typeof record.path === "string" &&
411
+ hasEsmLoaderFrame(record.stack)) {
412
+ return isUnderNodeModules(record.path, root) ? INCOMPLETE_INSTALL_REMEDY : null;
413
+ }
414
+ // Shape B' (RESOLVE time, HQ-CLI-1S): an ENOENT from the resolver's realpath
415
+ // step (syscall 'lstat' + a resolver frame) — a path component that a concurrent
416
+ // reinstall renamed away between the resolver's stat and its lstat. Same
417
+ // disposition as B: a vanished component under the running install's
418
+ // node_modules is a torn install, so print the remedy and skip capture.
419
+ if (record.syscall === "lstat" &&
420
+ typeof record.path === "string" &&
421
+ hasResolverFrame(record.stack)) {
422
+ return isUnderNodeModules(record.path, root) ? INCOMPLETE_INSTALL_REMEDY : null;
423
+ }
424
+ return null;
389
425
  }
390
426
  /** A readFileSync surface, defaulting to the real fs when the caller injects none. */
391
427
  function readFileFrom(fileSystem) {
@@ -513,10 +549,13 @@ function relativeSpecifierCaptureContext(err, resolvePackageRoot, fileSystem) {
513
549
  * dist/ or assets/, outside the install, or under an unresolved root
514
550
  * (HQ-CLI-1N) → { code, relativeSpecifier:true, packageRoot,
515
551
  * 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 }.
552
+ * - a loader/resolver ENOENT whose path did not survive delivery (HQ-CLI-1M
553
+ * esm-load open, HQ-CLI-1S resolve-time lstat) where neither the exception
554
+ * value nor node_system_error carried a `path` → { packageRoot,
555
+ * packageJsonExists, nodeModulesExists, esmLoaderFrame, resolverFrame, code };
556
+ * - a module-EVALUATION throw that reached the boundary raw (HQ-CLI-1T) →
557
+ * { evalThrow, errorName, throwSitePackage, packageRoot, packageRootResolved,
558
+ * resolver }.
520
559
  * Built with the byte-capped, scrubber-safe discipline of
521
560
  * package-root-diagnostics.ts — never a caller argv, query, or user-minted
522
561
  * value. Never throws. main.ts attaches this on the generic capture path
@@ -531,19 +570,63 @@ export function incompleteInstallCaptureContext(err, resolvePackageRoot = safePa
531
570
  const relative = relativeSpecifierCaptureContext(err, resolvePackageRoot, fileSystem);
532
571
  if (relative)
533
572
  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;
573
+ // (C) HQ-CLI-1M / HQ-CLI-1S — the path-less loader/resolver ENOENT, made
574
+ // attributable. An ENOENT whose `path` survived and sits under node_modules is
575
+ // classified and printed above, never captured; only the unattributable shape
576
+ // (no usable path, or a path outside node_modules) reaches here.
577
+ if (isEsmLoaderEnoent(err) || isResolveEnoent(err)) {
578
+ if (incompleteInstallMessage(err, resolvePackageRoot, readFileFrom(fileSystem)) === null) {
579
+ const record = err;
580
+ const code = typeof record.code === "string" ? record.code : "";
581
+ // The default resolver knows which strategy answered (manifest walk vs the
582
+ // string derivation that survives a torn tree); an injected resolver is
583
+ // opaque, so it is recorded as "injected" and its return used as the root.
584
+ const resolution = resolvePackageRoot === safePackageRoot
585
+ ? resolvePackageRootWithSource()
586
+ : {
587
+ root: resolveRootSafely(resolvePackageRoot),
588
+ source: "injected",
589
+ };
590
+ const root = resolution.root;
591
+ const diagnostics = {
592
+ packageRoot: boundedDiagnosticValue(root ?? "<unresolved>", ROOT_DIAGNOSTIC_BYTES),
593
+ packageRootResolved: root !== null,
594
+ resolver: resolution.source,
595
+ // Which route raised it — the esm-loader (source-read) frame for 1M, the
596
+ // resolver frame for 1S; either or both are recorded from the stack.
597
+ esmLoaderFrame: hasEsmLoaderFrame(record.stack),
598
+ resolverFrame: hasResolverFrame(record.stack),
599
+ code: boundedDiagnosticValue(code, CODE_DIAGNOSTIC_BYTES),
600
+ };
601
+ // Report the existence booleans ONLY when a root was resolved. Reported for
602
+ // an unresolved root they were uninitialised `false`s indistinguishable from
603
+ // "resolved but missing" — the instrumentation defect the prior fix shipped.
604
+ if (root !== null) {
605
+ diagnostics.packageJsonExists = safeExistsSync(fileSystem, path.join(root, "package.json"));
606
+ diagnostics.nodeModulesExists = safeExistsSync(fileSystem, path.join(root, "node_modules"));
607
+ }
608
+ return { incomplete_install: diagnostics };
609
+ }
541
610
  }
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.
611
+ // (D) HQ-CLI-1T — a module-EVALUATION throw that reached the boundary raw (the
612
+ // writer-signal gate found no reinstall in progress), made attributable.
613
+ return evalThrowCaptureContext(err, resolvePackageRoot);
614
+ }
615
+ /**
616
+ * When a module-EVALUATION throw (HQ-CLI-1T) reached the capture path raw — a
617
+ * code-less TypeError/ReferenceError/SyntaxError whose top-level code ran while a
618
+ * half-written dependency file returned an empty module — return a bounded
619
+ * `incomplete_install` block naming the offending third-party package so the next
620
+ * occurrence is attributable instead of bare; otherwise undefined. When the root
621
+ * resolves, the throw site MUST sit under its node_modules (an hq-cli `dist/` or
622
+ * `assets/` eval throw is hq's OWN defect and stays a bare capture); when the root
623
+ * is unresolved the block is still attached with `packageRootResolved:false`.
624
+ * Never throws; never emits the message, argv, or a full path.
625
+ */
626
+ function evalThrowCaptureContext(err, resolvePackageRoot) {
627
+ const shape = evalThrowShape(err);
628
+ if (!shape)
629
+ return undefined;
547
630
  const resolution = resolvePackageRoot === safePackageRoot
548
631
  ? resolvePackageRootWithSource()
549
632
  : {
@@ -551,21 +634,18 @@ export function incompleteInstallCaptureContext(err, resolvePackageRoot = safePa
551
634
  source: "injected",
552
635
  };
553
636
  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),
637
+ if (root !== null && !isUnderNodeModules(shape.throwSite, root))
638
+ return undefined;
639
+ return {
640
+ incomplete_install: {
641
+ evalThrow: true,
642
+ errorName: boundedDiagnosticValue(shape.errorName, CODE_DIAGNOSTIC_BYTES),
643
+ throwSitePackage: boundedDiagnosticValue(requiringPackage(shape.throwSite)?.name ?? "<unresolved>", PACKAGE_NAME_DIAGNOSTIC_BYTES),
644
+ packageRoot: boundedDiagnosticValue(root ?? "<unresolved>", ROOT_DIAGNOSTIC_BYTES),
645
+ packageRootResolved: root !== null,
646
+ resolver: resolution.source,
647
+ },
560
648
  };
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
649
  }
570
650
  /** existsSync that never throws — any filesystem error reads as "absent". */
571
651
  function safeExistsSync(fileSystem, target) {
@@ -31,10 +31,12 @@
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-1S
36
+ * resolve-time lstat); `EVAL_THROW` is the synthetic code for a module-EVALUATION
37
+ * throw that carries NO real `code` (HQ-CLI-1T) — see {@link ModuleErrorDialect}.
36
38
  */
37
- export type ModuleNotFoundCode = "ERR_MODULE_NOT_FOUND" | "MODULE_NOT_FOUND" | "ENOENT";
39
+ export type ModuleNotFoundCode = "ERR_MODULE_NOT_FOUND" | "MODULE_NOT_FOUND" | "ENOENT" | "EVAL_THROW";
38
40
  /**
39
41
  * The closed set of resolution-failure dialects, verified on Node v22.23.1 (the
40
42
  * @sentry/node import-in-the-middle hook does not change the shapes):
@@ -54,10 +56,20 @@ export type ModuleNotFoundCode = "ERR_MODULE_NOT_FOUND" | "MODULE_NOT_FOUND" | "
54
56
  * - `esm-enoent`: ESM loader ENOENT (HQ-CLI-1M) — a module present at RESOLVE
55
57
  * and gone at READ, so getSourceSync/openSync raises ENOENT
56
58
  * (not ERR_MODULE_NOT_FOUND). `err.path` is the vanished file.
59
+ * - `resolve-enoent`: ESM/CJS RESOLVE-time ENOENT (HQ-CLI-1S) — a path component
60
+ * stat'ed OK, then vanished before realpathSync's lstat, so the
61
+ * resolver (finalizeResolution / Module._findPath→toRealPath)
62
+ * raises ENOENT with syscall `lstat`. `err.path` is the vanished
63
+ * component (which may be a package/scope directory).
64
+ * - `eval-throw`: a module-EVALUATION throw (HQ-CLI-1T) — a dependency file was
65
+ * found but still half-written, so require() returned an empty
66
+ * module and its top-level code threw a code-LESS TypeError /
67
+ * ReferenceError / SyntaxError under Module._compile /
68
+ * esm/module_job. The specifier is the bounded throw-site path.
57
69
  * - `unknown`: a module-not-found whose message did not parse; recovery
58
70
  * still waits on the lock / retired-dir / quiet signals.
59
71
  */
60
- export type ModuleErrorDialect = "esm-path" | "esm-package" | "cjs-path" | "cjs-package" | "cjs-relative" | "esm-enoent" | "unknown";
72
+ export type ModuleErrorDialect = "esm-path" | "esm-package" | "cjs-path" | "cjs-package" | "cjs-relative" | "esm-enoent" | "resolve-enoent" | "eval-throw" | "unknown";
61
73
  /**
62
74
  * The missing thing, re-resolvable by the readiness probe:
63
75
  * - `path`: an absolute filesystem path (a `.js` file, or a package dir).
@@ -66,6 +78,12 @@ export type ModuleErrorDialect = "esm-path" | "esm-package" | "cjs-path" | "cjs-
66
78
  * Node's CJS resolver does from directory `from` —
67
79
  * `path.resolve(from, specifier)` plus the `.js`/`.json`/`.node`
68
80
  * and directory forms.
81
+ * - `exists`: an absolute path whose bare EXISTENCE is the readiness signal
82
+ * (HQ-CLI-1S) — the resolver lstat'ed this component, which may be
83
+ * a package/scope DIRECTORY whose contents precede a `package.json`,
84
+ * so the `path` kind's directory rule could stay false forever.
85
+ * Readiness still rides the lock / manifest-health / retired-sibling
86
+ * / quiet guards, exactly as for the loader-race dialects.
69
87
  * - `unknown`: the message did not parse; treated as "present" by the probe so
70
88
  * readiness turns only on the lock / retired-dir / quiet signals.
71
89
  */
@@ -80,6 +98,9 @@ export type ModuleErrorTarget = {
80
98
  kind: "relative";
81
99
  specifier: string;
82
100
  from: string;
101
+ } | {
102
+ kind: "exists";
103
+ path: string;
83
104
  } | {
84
105
  kind: "unknown";
85
106
  };
@@ -91,6 +112,8 @@ export interface ClassifiedModuleError {
91
112
  /** The importer the message named (from-clause or requireStack[0]), or "". */
92
113
  importer: string;
93
114
  target: ModuleErrorTarget;
115
+ /** The thrown error's `name` for the eval-throw dialect (HQ-CLI-1T), else absent. */
116
+ errorName?: string;
94
117
  }
95
118
  /**
96
119
  * Reduce a bare specifier to its PACKAGE name: `@scope/name/sub` → `@scope/name`,
@@ -109,6 +132,39 @@ export declare function packageNameOf(specifier: string): string;
109
132
  * `null` so it is rethrown to the existing boundary unchanged.
110
133
  */
111
134
  export declare function classifyModuleNotFound(err: unknown): ClassifiedModuleError | null;
135
+ /** The structural fingerprint of a module-EVALUATION throw (HQ-CLI-1T). */
136
+ export interface EvalThrowShape {
137
+ /** The thrown error's `name`, in {@link EVAL_THROW_NAMES}. */
138
+ errorName: string;
139
+ /** The absolute throw-site path (first path token in the stack). */
140
+ throwSite: string;
141
+ }
142
+ /**
143
+ * Detect a module-EVALUATION throw STRUCTURALLY, WITHOUT the node_modules
144
+ * boundary gate: an error with NO `code`, a `name` in the closed set
145
+ * {TypeError, ReferenceError, SyntaxError}, a CJS/ESM module-evaluation loader
146
+ * frame, and a resolvable throw-site path. This is the shape a half-written
147
+ * dependency file produces — require() read an empty module and returned `{}`, so
148
+ * calling one of its exports throws a code-less TypeError while the module's own
149
+ * top-level code runs. No message text is ever parsed. The caller applies its own
150
+ * packageRoot gate ({@link classifyEvalThrow} for recovery, the boundary for
151
+ * capture context), so this stays pure and reusable. Returns null for anything
152
+ * that is not this shape — a coded error (ERR_REQUIRE_ESM etc.), an Error /
153
+ * RangeError name, a throw with no loader frame, or no absolute throw site.
154
+ */
155
+ export declare function evalThrowShape(err: unknown): EvalThrowShape | null;
156
+ /**
157
+ * Classify a module-EVALUATION throw as a torn-install recovery candidate
158
+ * (HQ-CLI-1T) — the {@link evalThrowShape} fingerprint AND a throw site under
159
+ * `<packageRoot>/node_modules/`. A throw under the install's own `dist/` or
160
+ * `assets/`, outside the root, or with an unresolved root is NOT classified, so a
161
+ * genuine hq-cli code defect keeps its raw capture. Unlike the loader-race
162
+ * dialects the recovery seam gates this on a live writer signal before waiting.
163
+ * `packageRoot` comes from the seam's resolvePackageRoot (running-install → derived
164
+ * fallback). The target is `unknown` — an eval-throw carries no re-resolvable
165
+ * missing file, so recovery keys entirely on the writer signal + quiet window.
166
+ */
167
+ export declare function classifyEvalThrow(err: unknown, packageRoot: string | null): ClassifiedModuleError | null;
112
168
  /** The filesystem surface the probe and wait use, injectable for hermetic tests. */
113
169
  export interface InstallTreeFs {
114
170
  existsSync(p: string): boolean;
@@ -133,6 +189,8 @@ export interface InstallTreeFs {
133
189
  * or a directory whose package.json `main` (or that main's index)
134
190
  * or own `index.*` is a real file. A bare or partially-extracted
135
191
  * directory is NOT loadable and reads as not-present.
192
+ * - `exists`: the path exists at all (any file type) — presence only, because
193
+ * the vanished component may be a package/scope directory.
136
194
  * - `unknown`: true (readiness turns on the other signals).
137
195
  * Any filesystem error reads as "not present" rather than throwing.
138
196
  */
@@ -221,6 +279,27 @@ export interface WaitForInstallTreeSettledResult {
221
279
  * lockWaitMs`), so the caller can never hang.
222
280
  */
223
281
  export declare function waitForInstallTreeSettled(args: WaitForInstallTreeSettledArgs): Promise<WaitForInstallTreeSettledResult>;
282
+ export interface InstallRewriteSignalArgs {
283
+ /** The running install's package dir (from resolveRunningInstall), or null. */
284
+ packageRoot: string | null;
285
+ /** The shared update-lock path ($HOME/.hq/locks/cli-update.lock). */
286
+ lockPath: string;
287
+ now?: () => number;
288
+ fs?: InstallTreeFs;
289
+ isPidAlive?: (pid: number) => boolean;
290
+ }
291
+ /**
292
+ * Whether a global reinstall is PROVABLY rewriting the install tree right now —
293
+ * the positive writer signal the eval-throw gate requires before it will pay a
294
+ * settle wait. True iff (i) a fresh foreign update lock is held, (ii) packageRoot's
295
+ * manifest is absent or name-mismatched (mid-extraction), or (iii) a
296
+ * `.<leaf>-<hash>` retired sibling sits beside it. Crucially it is POSITIVE: a
297
+ * benign filesystem error that merely prevents READING the manifest or LISTING the
298
+ * parent (EACCES/EPERM) is NOT a signal, so a persistent third-party load-time
299
+ * defect on an otherwise-usable install never pays a spurious 90s wait. Every
300
+ * dependency is injectable so the recovery seam stays hermetic.
301
+ */
302
+ export declare function installRewriteInProgress(args: InstallRewriteSignalArgs): boolean;
224
303
  /** The recovery OUTCOME, which — not the dialect — decides capture. */
225
304
  export type InstallTreeTornOutcome = "guarded" | "unsettled" | "reexec-failed" | "lock-held";
226
305
  export type InstallTreeTornDiagnostics = {
@@ -241,6 +320,8 @@ export type InstallTreeTornDiagnostics = {
241
320
  /** The bounded `tool` of the last fresh foreign lock seen, or "". */
242
321
  lockTool: string;
243
322
  node: string;
323
+ /** The thrown error's `name` for the eval-throw dialect (HQ-CLI-1T), else "". */
324
+ errorName: string;
244
325
  };
245
326
  export interface InstallTreeTornInit {
246
327
  /** 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,96 @@ 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
+ * A stack LOCATION token — the absolute filesystem path in either an
87
+ * `at <fn> (<abs>:L:C)` frame or a bare `<abs>:L` header line (the source
88
+ * location a SyntaxError prepends). Anchored at line-start or an opening paren so
89
+ * a `node:internal/…` internal frame (never an absolute FS path) is skipped, and
90
+ * a Windows drive/UNC path is matched by shape so it classifies on any host.
91
+ */
92
+ const STACK_LOCATION = /(?:^|\()((?:\/|[A-Za-z]:[\\/]|\\\\)[^():\n]*?):\d+(?::\d+)?(?:\)|$)/;
93
+ /**
94
+ * A `file:` URL location in a stack frame — the form Node uses for an ESM module
95
+ * EVALUATION frame (`at file:///abs/mod.mjs:2:1`, or the same wrapped in parens).
96
+ * The whole non-space token is captured; the caller strips the trailing paren and
97
+ * `:line[:col]` and decodes it with fileURLToPath.
98
+ */
99
+ function fileUrlFromFrame(line) {
100
+ const token = line.match(/file:\/\/\/\S+/);
101
+ if (!token)
102
+ return null;
103
+ const url = token[0].replace(/\)+$/, "").replace(/:\d+(?::\d+)?$/, "");
104
+ try {
105
+ return fileURLToPath(url);
106
+ }
107
+ catch {
108
+ return null; // not a decodable file URL
109
+ }
110
+ }
111
+ /**
112
+ * The eval-throw THROW SITE — the FIRST absolute filesystem path token in the
113
+ * stack, scanning top-down: for an ordinary error that is the throwing frame (a
114
+ * raw path for a CJS module, or a `file:` URL for an ESM one); for a SyntaxError
115
+ * it is the `<abs>:L` header line the stack carries before the `Error:` line.
116
+ * Returns null when no absolute path token is present.
117
+ */
118
+ function evalThrowSite(stack) {
119
+ for (const rawLine of stack.split("\n")) {
120
+ const line = rawLine.trim();
121
+ const plain = STACK_LOCATION.exec(line);
122
+ if (plain)
123
+ return plain[1];
124
+ const fromUrl = fileUrlFromFrame(line);
125
+ if (fromUrl)
126
+ return fromUrl;
127
+ }
128
+ return null;
129
+ }
130
+ /**
131
+ * Lexical `<root>/node_modules/` containment for the eval-throw throw-site gate.
132
+ * Separator- and (Windows-shape-only) case-folded and anchored at a true
133
+ * directory boundary, so a sibling such as `<root>-old/node_modules/…` never
134
+ * matches. Mirrors incomplete-install-error.ts's isUnderNodeModules discipline;
135
+ * kept local because that module imports THIS one (a shared import would cycle).
136
+ */
137
+ function looksWin32(p) {
138
+ return /^[a-zA-Z]:[\\/]/.test(p) || /^\\\\/.test(p);
139
+ }
140
+ function foldForCompare(p) {
141
+ const folded = p.replace(/[\\/]+/g, "/").replace(/\/+$/, "");
142
+ return looksWin32(p) ? folded.toLowerCase() : folded;
143
+ }
144
+ function isUnderNodeModules(candidate, root) {
145
+ if (!candidate || !root)
146
+ return false;
147
+ return foldForCompare(candidate).startsWith(`${foldForCompare(root)}/node_modules/`);
148
+ }
57
149
  /**
58
150
  * A relative module specifier — `./x`, `../x`, `.\x`, `..\x`, or a bare `.`/`..`.
59
151
  * Mirrors the shape src/utils/incomplete-install-error.ts uses, extended with the
@@ -122,6 +214,28 @@ export function classifyModuleNotFound(err) {
122
214
  target: { kind: "path", path: record.path },
123
215
  };
124
216
  }
217
+ // resolve-time ENOENT (HQ-CLI-1S): Node's resolver stat'ed a path OK, then
218
+ // realpathSync lstat'ed a component that a concurrent reinstall renamed away
219
+ // between the two, throwing ENOENT with syscall `lstat` from a RESOLVER frame
220
+ // (finalizeResolution / Module._findPath→toRealPath). Gated on the FULL
221
+ // conjunction so an ordinary fs.lstatSync ENOENT from hq's own code (no
222
+ // resolver frame) and a rename/unlink ENOENT (syscall not `lstat`) both stay
223
+ // null. The vanished component may be a package/scope DIRECTORY whose bare
224
+ // existence precedes its `package.json`, so the target is `exists` (presence
225
+ // only); readiness still rides the lock / manifest-health / retired-sibling /
226
+ // quiet guards. Structurally a loader race like esm-enoent — no writer gate.
227
+ if (record.syscall === "lstat" &&
228
+ typeof record.path === "string" &&
229
+ typeof record.stack === "string" &&
230
+ RESOLVER_FRAME.test(record.stack)) {
231
+ return {
232
+ code,
233
+ dialect: "resolve-enoent",
234
+ specifier: record.path,
235
+ importer: "",
236
+ target: { kind: "exists", path: record.path },
237
+ };
238
+ }
125
239
  return null;
126
240
  }
127
241
  if (code !== "ERR_MODULE_NOT_FOUND" && code !== "MODULE_NOT_FOUND")
@@ -206,6 +320,61 @@ export function classifyModuleNotFound(err) {
206
320
  // the lock / retired-dir / quiet signals only.
207
321
  return { code, dialect: "unknown", specifier: "", importer: "", target: { kind: "unknown" } };
208
322
  }
323
+ /**
324
+ * Detect a module-EVALUATION throw STRUCTURALLY, WITHOUT the node_modules
325
+ * boundary gate: an error with NO `code`, a `name` in the closed set
326
+ * {TypeError, ReferenceError, SyntaxError}, a CJS/ESM module-evaluation loader
327
+ * frame, and a resolvable throw-site path. This is the shape a half-written
328
+ * dependency file produces — require() read an empty module and returned `{}`, so
329
+ * calling one of its exports throws a code-less TypeError while the module's own
330
+ * top-level code runs. No message text is ever parsed. The caller applies its own
331
+ * packageRoot gate ({@link classifyEvalThrow} for recovery, the boundary for
332
+ * capture context), so this stays pure and reusable. Returns null for anything
333
+ * that is not this shape — a coded error (ERR_REQUIRE_ESM etc.), an Error /
334
+ * RangeError name, a throw with no loader frame, or no absolute throw site.
335
+ */
336
+ export function evalThrowShape(err) {
337
+ if (err === null || typeof err !== "object")
338
+ return null;
339
+ const record = err;
340
+ if (record.code !== undefined)
341
+ return null;
342
+ const name = typeof record.name === "string" ? record.name : "";
343
+ if (!EVAL_THROW_NAMES.has(name))
344
+ return null;
345
+ if (typeof record.stack !== "string" || !hasModuleEvalFrame(record.stack))
346
+ return null;
347
+ const throwSite = evalThrowSite(record.stack);
348
+ if (throwSite === null)
349
+ return null;
350
+ return { errorName: name, throwSite };
351
+ }
352
+ /**
353
+ * Classify a module-EVALUATION throw as a torn-install recovery candidate
354
+ * (HQ-CLI-1T) — the {@link evalThrowShape} fingerprint AND a throw site under
355
+ * `<packageRoot>/node_modules/`. A throw under the install's own `dist/` or
356
+ * `assets/`, outside the root, or with an unresolved root is NOT classified, so a
357
+ * genuine hq-cli code defect keeps its raw capture. Unlike the loader-race
358
+ * dialects the recovery seam gates this on a live writer signal before waiting.
359
+ * `packageRoot` comes from the seam's resolvePackageRoot (running-install → derived
360
+ * fallback). The target is `unknown` — an eval-throw carries no re-resolvable
361
+ * missing file, so recovery keys entirely on the writer signal + quiet window.
362
+ */
363
+ export function classifyEvalThrow(err, packageRoot) {
364
+ const shape = evalThrowShape(err);
365
+ if (shape === null)
366
+ return null;
367
+ if (!packageRoot || !isUnderNodeModules(shape.throwSite, packageRoot))
368
+ return null;
369
+ return {
370
+ code: "EVAL_THROW",
371
+ dialect: "eval-throw",
372
+ specifier: shape.throwSite,
373
+ importer: "",
374
+ target: { kind: "unknown" },
375
+ errorName: shape.errorName,
376
+ };
377
+ }
209
378
  const nodeInstallTreeFs = {
210
379
  existsSync: (p) => nodeFs.existsSync(p),
211
380
  statSync: (p) => nodeFs.statSync(p),
@@ -289,6 +458,8 @@ function relativeTargetResolvable(base, fs) {
289
458
  * or a directory whose package.json `main` (or that main's index)
290
459
  * or own `index.*` is a real file. A bare or partially-extracted
291
460
  * directory is NOT loadable and reads as not-present.
461
+ * - `exists`: the path exists at all (any file type) — presence only, because
462
+ * the vanished component may be a package/scope directory.
292
463
  * - `unknown`: true (readiness turns on the other signals).
293
464
  * Any filesystem error reads as "not present" rather than throwing.
294
465
  */
@@ -296,6 +467,12 @@ export function installTargetPresent(target, fs = nodeInstallTreeFs) {
296
467
  try {
297
468
  if (target.kind === "unknown")
298
469
  return true;
470
+ // `exists` (resolve-enoent, HQ-CLI-1S): the lstat'ed component may be a
471
+ // package/scope DIRECTORY whose bare existence precedes its `package.json`, so
472
+ // presence alone is the signal here — the surrounding manifest-health /
473
+ // retired-sibling / quiet guards keep a premature re-exec bounded.
474
+ if (target.kind === "exists")
475
+ return fs.existsSync(target.path);
299
476
  if (target.kind === "path") {
300
477
  if (!fs.existsSync(target.path))
301
478
  return false;
@@ -509,6 +686,57 @@ export async function waitForInstallTreeSettled(args) {
509
686
  await sleep(pollMs);
510
687
  }
511
688
  }
689
+ /**
690
+ * Whether packageRoot's own manifest is ABSENT (ENOENT) or name-mismatched — a
691
+ * mid-extraction rewrite signal (npm has renamed the dir aside and is re-writing
692
+ * package.json). A read error OTHER than ENOENT (EACCES/EPERM) is a benign
693
+ * permission fault, not a rewrite, so it returns false rather than a false signal.
694
+ */
695
+ function manifestRewriteSignal(packageRoot, fs) {
696
+ let raw;
697
+ try {
698
+ raw = fs.readFileSync(path.join(packageRoot, "package.json"), "utf-8");
699
+ }
700
+ catch (err) {
701
+ return err?.code === "ENOENT";
702
+ }
703
+ try {
704
+ return JSON.parse(raw).name !== CLI_NAME;
705
+ }
706
+ catch {
707
+ return true; // present but unparseable → mid-write
708
+ }
709
+ }
710
+ /**
711
+ * Whether a global reinstall is PROVABLY rewriting the install tree right now —
712
+ * the positive writer signal the eval-throw gate requires before it will pay a
713
+ * settle wait. True iff (i) a fresh foreign update lock is held, (ii) packageRoot's
714
+ * manifest is absent or name-mismatched (mid-extraction), or (iii) a
715
+ * `.<leaf>-<hash>` retired sibling sits beside it. Crucially it is POSITIVE: a
716
+ * benign filesystem error that merely prevents READING the manifest or LISTING the
717
+ * parent (EACCES/EPERM) is NOT a signal, so a persistent third-party load-time
718
+ * defect on an otherwise-usable install never pays a spurious 90s wait. Every
719
+ * dependency is injectable so the recovery seam stays hermetic.
720
+ */
721
+ export function installRewriteInProgress(args) {
722
+ const now = (args.now ?? Date.now)();
723
+ const fs = args.fs ?? nodeInstallTreeFs;
724
+ const isPidAlive = args.isPidAlive ?? defaultIsPidAlive;
725
+ // #570 renamed freshForeignLockHeld → freshForeignLock, which returns the lock
726
+ // info (or null) instead of a boolean; a fresh foreign lock is held iff non-null.
727
+ if (freshForeignLock(args.lockPath, now, isPidAlive, fs) !== null)
728
+ return true;
729
+ if (!args.packageRoot)
730
+ return false;
731
+ if (manifestRewriteSignal(args.packageRoot, fs))
732
+ return true;
733
+ try {
734
+ return retiredSiblingPresent(args.packageRoot, fs);
735
+ }
736
+ catch {
737
+ return false; // parent not listable — benign, not a rewrite signal
738
+ }
739
+ }
512
740
  /** The fixed, input-free carrier message (grouping cardinality stays bounded). */
513
741
  const INSTALL_TREE_TORN_MESSAGE = "hq-cli install tree was being rewritten while this command started";
514
742
  /**
@@ -539,6 +767,7 @@ export class InstallTreeTornError extends Error {
539
767
  lockedMs: init.lockedMs ?? 0,
540
768
  lockTool: boundedDiagnosticValue(init.lockTool ?? "", LOCK_TOOL_BYTES),
541
769
  node: process.version,
770
+ errorName: boundedDiagnosticValue(init.classified.errorName ?? "", ERRORNAME_BYTES),
542
771
  };
543
772
  Object.setPrototypeOf(this, new.target.prototype);
544
773
  }
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.12",
4
4
  "description": "HQ by Indigo management CLI \u2014 modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {