@indigoai-us/hq-cli 5.109.8 → 5.109.11

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,41 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.109.11] — 2026-09-12
6
+
7
+ ### Fixed
8
+
9
+ - A torn-install recovery no longer gives up after 90 seconds while another hq
10
+ updater is still legitimately installing hq-cli. When a command starts just as
11
+ the desktop app (or the box's `hq-cli-update` timer, or another `hq`) is
12
+ rewriting the install, the recovery waits for that writer to finish — bounded by
13
+ the shared install lock's own 10-minute staleness window rather than the 90-second
14
+ settle deadline — and then re-runs once on the healed tree, so the common case
15
+ (an install that finishes within the window) no longer fails at all. A short
16
+ second line explains the pause while it waits. If a writer holds the lock past
17
+ that ceiling, the command still fails once, but with a "let the update finish,
18
+ then re-run — do not reinstall while it is running" remedy and a distinct
19
+ `lock-held` outcome, instead of advice to reinstall over a live installer
20
+ (Sentry HQ-CLI-1Y, HQ-CLI-23). The ceiling is overridable with
21
+ `HQ_INSTALL_SETTLE_LOCK_TIMEOUT_MS`.
22
+
23
+ ## [5.109.10] — 2026-09-12
24
+
25
+ ### Fixed
26
+
27
+ - A global reinstall that tears the install tree out mid-startup now recovers
28
+ even when the module that vanished is a RELATIVE sibling (for example
29
+ `@babel/runtime`'s `./typeof.js`, reached through the mqtt → worker-timers
30
+ chain). The torn-install classifier reduced a relative CJS specifier to the
31
+ package name `.`, producing a readiness probe that could never turn true — so
32
+ an affected command burned the entire 90-second settle budget, never re-ran on
33
+ the settled tree, and reported an error instead of recovering. Relative
34
+ specifiers are now re-resolved the way Node's CJS resolver does — from the
35
+ importer's directory, trying the file, its `.js`/`.json`/`.node` forms, and the
36
+ directory forms — so the command waits only as long as the reinstall actually
37
+ takes and then re-execs once on the healed tree. A genuinely missing sibling
38
+ still surfaces exactly one error naming the resolved path (Sentry HQ-CLI-1R).
39
+
5
40
  ## [5.109.8] — 2026-09-11
6
41
 
7
42
  ### Fixed
@@ -37,6 +37,28 @@ export declare const VALID_PROVIDERS: Set<string>;
37
37
  export declare const VALID_AUTH_MODES: Set<string>;
38
38
  /** Customer-facing agent size keys served by hq-pro's authoritative catalog. */
39
39
  export declare const VALID_AGENT_SIZE_KEYS: Set<string>;
40
+ /**
41
+ * `hq agents create` exits with this distinct status when payment is required.
42
+ * Scripts can distinguish the expected upgrade state from an ordinary command
43
+ * failure without parsing human output.
44
+ */
45
+ export declare const AGENT_CREATE_PAYMENT_REQUIRED_EXIT_CODE = 3;
46
+ export type AgentCreatePlanLimit = Readonly<{
47
+ requiredPlan: "agents-500";
48
+ amountMinor: number;
49
+ currency: string;
50
+ checkoutUrl?: string;
51
+ }>;
52
+ /**
53
+ * Decode only the priced response defined by the agent-create contract. A
54
+ * lookalike 403 must stay an ordinary API error rather than opening a browser
55
+ * or guessing at a price.
56
+ */
57
+ export declare function agentCreatePlanLimitFromPayload(status: number, body: unknown): AgentCreatePlanLimit | null;
58
+ /** Print a server-priced plan block without minting or rewriting its URL. */
59
+ export declare function surfaceAgentCreatePlanLimit(planLimit: AgentCreatePlanLimit, opts?: {
60
+ json?: boolean;
61
+ }): Promise<void>;
40
62
  /**
41
63
  * Resolve a closed-set option value, or exit(1) with a message naming the
42
64
  * offending input and the legal set.
@@ -82,7 +104,9 @@ export declare class AgentsHttpError extends Error {
82
104
  code?: string;
83
105
  /** hq-pro's billing envelope on a `402 billing_required` provision block. */
84
106
  billing?: BillingErrorPayload;
85
- constructor(status: number, message: string, code?: string, billing?: BillingErrorPayload);
107
+ /** Strictly decoded priced `403 AGENT_PLAN_LIMIT` create response. */
108
+ agentCreatePlanLimit?: AgentCreatePlanLimit;
109
+ constructor(status: number, message: string, code?: string, billing?: BillingErrorPayload, agentCreatePlanLimit?: AgentCreatePlanLimit);
86
110
  }
87
111
  /** Roster row from `GET /v1/agents` — a superset is returned; we keep what we render. */
88
112
  export interface CompanyAgentView {
@@ -194,7 +218,7 @@ export declare function formatAgentCreateSize(option: AgentCreateSizeOption): st
194
218
  /** Ask a TTY user to choose one of hq-pro's currently selectable quotes. */
195
219
  export declare function promptForAgentCreateSize(view: AgentCreateOptionsView): Promise<QuotedAgentCreateSizeOption>;
196
220
  /** Confirm creation using the server quote, preserving $0 as a real answer. */
197
- export declare function confirmAgentCreateQuoteOrExit(option: QuotedAgentCreateSizeOption, yes?: boolean): void;
221
+ export declare function confirmAgentCreateQuoteOrExit(option: QuotedAgentCreateSizeOption, yes?: boolean, quiet?: boolean): void;
198
222
  export declare function provisionAgent(token: string, input: ProvisionAgentInput): Promise<{
199
223
  uid?: string;
200
224
  slug?: string;
@@ -25,6 +25,7 @@
25
25
  * the caller's single active membership (same as `members.ts`).
26
26
  */
27
27
  import chalk from "chalk";
28
+ import open from "open";
28
29
  import { randomUUID } from "node:crypto";
29
30
  import * as readline from "node:readline";
30
31
  import { resolveVaultCredential } from "../utils/resolve-vault-credential.js";
@@ -50,6 +51,96 @@ export const VALID_PROVIDERS = new Set(["codex", "grok", "claude", "agents-v2"])
50
51
  export const VALID_AUTH_MODES = new Set(["subscription", "apiKey"]);
51
52
  /** Customer-facing agent size keys served by hq-pro's authoritative catalog. */
52
53
  export const VALID_AGENT_SIZE_KEYS = new Set(["basic", "power", "dev"]);
54
+ /**
55
+ * `hq agents create` exits with this distinct status when payment is required.
56
+ * Scripts can distinguish the expected upgrade state from an ordinary command
57
+ * failure without parsing human output.
58
+ */
59
+ export const AGENT_CREATE_PAYMENT_REQUIRED_EXIT_CODE = 3;
60
+ function isCheckoutUrl(value) {
61
+ if (typeof value !== "string" || !value.trim())
62
+ return false;
63
+ try {
64
+ const url = new URL(value);
65
+ return url.protocol === "https:" && !url.username && !url.password;
66
+ }
67
+ catch {
68
+ return false;
69
+ }
70
+ }
71
+ /**
72
+ * Decode only the priced response defined by the agent-create contract. A
73
+ * lookalike 403 must stay an ordinary API error rather than opening a browser
74
+ * or guessing at a price.
75
+ */
76
+ export function agentCreatePlanLimitFromPayload(status, body) {
77
+ if (status !== 403 || !body || typeof body !== "object")
78
+ return null;
79
+ const payload = body;
80
+ if (payload.code !== "AGENT_PLAN_LIMIT" ||
81
+ payload.reasonCode !== "plan_limit" ||
82
+ payload.requiredPlan !== "agents-500" ||
83
+ typeof payload.amountMinor !== "number" ||
84
+ !Number.isSafeInteger(payload.amountMinor) ||
85
+ payload.amountMinor < 0 ||
86
+ typeof payload.currency !== "string" ||
87
+ !/^[A-Za-z]{3}$/.test(payload.currency)) {
88
+ return null;
89
+ }
90
+ if (payload.checkoutUrl !== undefined && !isCheckoutUrl(payload.checkoutUrl)) {
91
+ return null;
92
+ }
93
+ return {
94
+ requiredPlan: "agents-500",
95
+ amountMinor: payload.amountMinor,
96
+ currency: payload.currency.toUpperCase(),
97
+ ...(typeof payload.checkoutUrl === "string"
98
+ ? { checkoutUrl: payload.checkoutUrl }
99
+ : {}),
100
+ };
101
+ }
102
+ function formatServerPrice(amountMinor, currency) {
103
+ return new Intl.NumberFormat("en-US", {
104
+ style: "currency",
105
+ currency,
106
+ }).format(amountMinor / 100);
107
+ }
108
+ function canOfferAgentCheckout(json) {
109
+ return json !== true &&
110
+ process.stdin.isTTY === true &&
111
+ process.stdout.isTTY === true &&
112
+ !process.env.CI;
113
+ }
114
+ async function offerAgentCheckout(url) {
115
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
116
+ try {
117
+ const answer = await new Promise((resolve) => rl.question("Open the payment page now? [y/N] ", resolve));
118
+ if (!/^y(?:es)?$/i.test(answer.trim()))
119
+ return;
120
+ }
121
+ finally {
122
+ rl.close();
123
+ }
124
+ try {
125
+ await open(url);
126
+ }
127
+ catch {
128
+ console.error("Could not open the payment page automatically. Use the URL above.");
129
+ }
130
+ }
131
+ /** Print a server-priced plan block without minting or rewriting its URL. */
132
+ export async function surfaceAgentCreatePlanLimit(planLimit, opts = {}) {
133
+ const price = `${formatServerPrice(planLimit.amountMinor, planLimit.currency)}/month`;
134
+ console.error(`Payment required: your plan does not include agents. Upgrade to ${planLimit.requiredPlan} costs ${price}.`);
135
+ if (!planLimit.checkoutUrl) {
136
+ console.error("Ask a company owner to upgrade before creating an agent.");
137
+ return;
138
+ }
139
+ // Deliberately plain and on its own line so terminals make this clickable.
140
+ console.log(planLimit.checkoutUrl);
141
+ if (canOfferAgentCheckout(opts.json))
142
+ await offerAgentCheckout(planLimit.checkoutUrl);
143
+ }
53
144
  /**
54
145
  * Resolve a closed-set option value, or exit(1) with a message naming the
55
146
  * offending input and the legal set.
@@ -112,12 +203,15 @@ export class AgentsHttpError extends Error {
112
203
  code;
113
204
  /** hq-pro's billing envelope on a `402 billing_required` provision block. */
114
205
  billing;
115
- constructor(status, message, code, billing) {
206
+ /** Strictly decoded priced `403 AGENT_PLAN_LIMIT` create response. */
207
+ agentCreatePlanLimit;
208
+ constructor(status, message, code, billing, agentCreatePlanLimit) {
116
209
  super(message);
117
210
  this.name = "AgentsHttpError";
118
211
  this.status = status;
119
212
  this.code = code;
120
213
  this.billing = billing;
214
+ this.agentCreatePlanLimit = agentCreatePlanLimit;
121
215
  }
122
216
  }
123
217
  /**
@@ -134,7 +228,7 @@ export async function agentsRequest(opts) {
134
228
  // decline copy ("Your card was declined…") while `error` is the generic
135
229
  // "payment required" — error-first would feed surfaceBillingBlocked the
136
230
  // generic string and lose the decline reason (mirrors outpostRequest).
137
- body.message ?? body.error ?? res.statusText, body.code, parseBillingPayload(body));
231
+ body.message ?? body.error ?? res.statusText, body.code, parseBillingPayload(body), agentCreatePlanLimitFromPayload(res.status, body) ?? undefined);
138
232
  }
139
233
  return (await res.json());
140
234
  }
@@ -242,7 +336,7 @@ export async function promptForAgentCreateSize(view) {
242
336
  }
243
337
  }
244
338
  /** Confirm creation using the server quote, preserving $0 as a real answer. */
245
- export function confirmAgentCreateQuoteOrExit(option, yes) {
339
+ export function confirmAgentCreateQuoteOrExit(option, yes, quiet = false) {
246
340
  if (option.notBilled || option.netMonthlyCents === 0) {
247
341
  const message = `${option.productName} is ${formatUsd(0)}/month for this company — ` +
248
342
  "there is no per-agent charge.";
@@ -250,14 +344,26 @@ export function confirmAgentCreateQuoteOrExit(option, yes) {
250
344
  console.error(chalk.yellow(`${message}\nRe-run with --yes to confirm agent provisioning.`));
251
345
  process.exit(1);
252
346
  }
253
- console.log(chalk.dim(`${message} Provisioning…`));
347
+ if (!quiet) {
348
+ console.log(chalk.dim(`${message} Provisioning…`));
349
+ }
254
350
  return;
255
351
  }
256
- confirmChargeOrExit({
257
- resource: "agent",
258
- unitCents: option.netMonthlyCents,
259
- yes,
260
- });
352
+ if (!quiet) {
353
+ confirmChargeOrExit({
354
+ resource: "agent",
355
+ unitCents: option.netMonthlyCents,
356
+ yes,
357
+ });
358
+ return;
359
+ }
360
+ if (!yes) {
361
+ confirmChargeOrExit({
362
+ resource: "agent",
363
+ unitCents: option.netMonthlyCents,
364
+ yes,
365
+ });
366
+ }
261
367
  }
262
368
  export async function provisionAgent(token, input) {
263
369
  return agentsRequest({
@@ -764,7 +870,7 @@ export function registerAgentsCommand(program) {
764
870
  });
765
871
  agents
766
872
  .command("provision <name>")
767
- .alias("new")
873
+ .aliases(["new", "create"])
768
874
  .description("Provision a new cloud agent (company-specific monthly price shown before creation)")
769
875
  .option("--company <slug>", "Company slug (resolves to companyUid)")
770
876
  .option("--slug <slug>", "Agent slug (defaults to a slug of <name>)")
@@ -775,6 +881,7 @@ export function registerAgentsCommand(program) {
775
881
  .option("--description <text>", "Short description / bio")
776
882
  .option("--size <size>", "Agent box size: basic | power | dev (omitted keeps the current default)")
777
883
  .option("--yes", "Confirm the quoted monthly cost and provision the agent")
884
+ .option("--json", "Emit machine-readable output and never open a browser")
778
885
  .action(async function (name, opts) {
779
886
  try {
780
887
  // Both of these previously fell back to their default on an
@@ -818,12 +925,12 @@ export function registerAgentsCommand(program) {
818
925
  : shouldChooseInteractively
819
926
  ? await promptForAgentCreateSize(createOptions)
820
927
  : requireDefaultAgentCreateSize(createOptions);
821
- if (!shouldChooseInteractively) {
928
+ if (!shouldChooseInteractively && !opts.json) {
822
929
  console.log(chalk.dim(formatAgentCreateSize(quotedSize)));
823
930
  }
824
931
  // Every path confirms hq-pro's company-specific quote. Omission still
825
932
  // leaves the POST size implicit, preserving the server-side default.
826
- confirmAgentCreateQuoteOrExit(quotedSize, opts.yes);
933
+ confirmAgentCreateQuoteOrExit(quotedSize, opts.yes, opts.json);
827
934
  const slug = opts.slug ?? slugifyAgentName(name);
828
935
  try {
829
936
  const result = await provisionAgent(token, {
@@ -849,10 +956,28 @@ export function registerAgentsCommand(program) {
849
956
  surface: CLI_AGENT_CREATE_SURFACE,
850
957
  });
851
958
  const uid = typeof result.uid === "string" ? result.uid : slug;
852
- console.log(chalk.green(`Provisioning started for agent "${name}".`));
853
- console.log(chalk.dim(`Track setup: hq agents status ${uid} --company <slug>`));
959
+ if (opts.json) {
960
+ console.log(JSON.stringify(result));
961
+ }
962
+ else {
963
+ console.log(chalk.green(`Provisioning started for agent "${name}".`));
964
+ console.log(chalk.dim(`Track setup: hq agents status ${uid} --company <slug>`));
965
+ }
854
966
  }
855
967
  catch (err) {
968
+ if (err instanceof AgentsHttpError &&
969
+ err.status === 403 &&
970
+ err.code === "AGENT_PLAN_LIMIT") {
971
+ if (!err.agentCreatePlanLimit) {
972
+ throw new Error("HQ Pro returned an unrecognized agent payment-required response.");
973
+ }
974
+ await surfaceAgentCreatePlanLimit(err.agentCreatePlanLimit, opts);
975
+ // Keep the payment-required status through normal CLI teardown.
976
+ // Unlike process.exit(), this is testable and cannot be caught by
977
+ // this action's outer error boundary as a generic failure.
978
+ process.exitCode = AGENT_CREATE_PAYMENT_REQUIRED_EXIT_CODE;
979
+ return;
980
+ }
856
981
  // No card on file → surface the shareable payment link instead of an
857
982
  // opaque 402, then exit non-zero so scripts can react.
858
983
  if (err instanceof AgentsHttpError &&
@@ -124,6 +124,32 @@ function registryText(now, entries) {
124
124
  function withoutTimestamp(content) {
125
125
  return content.split('\n').filter((line) => !line.startsWith('generated_at:')).join('\n');
126
126
  }
127
+ /**
128
+ * Worker roots hold two kinds of `worker.yaml` that must never reach the
129
+ * registry.
130
+ *
131
+ * The `_template` company and any `_overrides` directory are scaffolding —
132
+ * copies kept so a new tenant or a pack override can be stamped out from them.
133
+ *
134
+ * A checkout nested under a tenant's `repos/` is a source repository that tenant
135
+ * happens to keep inside HQ, and when that repository is HQ itself, its own
136
+ * `core/workers` and template trees are full of worker.yaml files. Indexing them
137
+ * treats product source as deployed workers: they claim ids real workers already
138
+ * own, and because duplicates resolve by path order — a tenant path sorts before
139
+ * both `core` and `personal` — the checkout wins. The operator's actual worker
140
+ * then silently vanishes from the registry every skill reads.
141
+ */
142
+ function isExcludedWorkerPath(relativeFile) {
143
+ const normalized = relativeFile.replaceAll('\\', '/');
144
+ const [root, tenant, nested] = normalized.split('/');
145
+ if (root !== 'companies')
146
+ return /(?:^|\/)_overrides(?:\/|$)/.test(normalized);
147
+ if (tenant === '_template')
148
+ return true;
149
+ if (nested === 'repos')
150
+ return true;
151
+ return /(?:^|\/)_overrides(?:\/|$)/.test(normalized);
152
+ }
127
153
  /**
128
154
  * Generate core/workers/registry.yaml from worker.yaml files. Invalid workers
129
155
  * are quarantined but do not prevent all valid workers from being registered.
@@ -138,7 +164,7 @@ export function generateWorkersRegistry(hqRoot, options = {}) {
138
164
  ...workerYamlFiles(hqRoot, 'personal/workers'),
139
165
  ].sort();
140
166
  for (const relativeFile of files) {
141
- if (relativeFile.startsWith('companies/_template/') || /(?:^|[/\\])_overrides(?:[/\\]|$)/.test(relativeFile))
167
+ if (isExcludedWorkerPath(relativeFile))
142
168
  continue;
143
169
  const fields = readWorkerFields(path.join(hqRoot, relativeFile));
144
170
  const missing = ['id', 'type', 'description'].filter((key) => !fields[key]);
@@ -35,6 +35,24 @@ export declare const DEFAULT_INSTALL_SETTLE_TIMEOUT_MS = 90000;
35
35
  export declare const INSTALL_TREE_WAIT_NOTICE = "hq: the hq-cli install is being updated underneath this command; waiting for it to finish\u2026";
36
36
  /** Resolve the settle deadline from the environment (0 = evaluate once; default on invalid). */
37
37
  export declare function resolveSettleTimeoutMs(env: NodeJS.ProcessEnv): number;
38
+ /** Operator override for the settle wait's LOCK ceiling (time under a live writer). */
39
+ export declare const INSTALL_SETTLE_LOCK_TIMEOUT_ENV = "HQ_INSTALL_SETTLE_LOCK_TIMEOUT_MS";
40
+ /**
41
+ * Default ceiling (ms) on the time the settle wait spends under a fresh foreign
42
+ * lock held by a live cooperating writer — the update-lock contract's own
43
+ * staleness window. Overridable by {@link INSTALL_SETTLE_LOCK_TIMEOUT_ENV}.
44
+ */
45
+ export declare const DEFAULT_INSTALL_SETTLE_LOCK_TIMEOUT_MS: number;
46
+ /**
47
+ * The single dim line emitted once when the wait continues PAST the settle
48
+ * deadline because a live updater still holds the shared install lock — so a human
49
+ * knows the pause is a running update, not a hang. Never a remedy, never an exit
50
+ * code; worded distinctly from {@link INSTALL_TREE_WAIT_NOTICE} so the e2e can
51
+ * synchronize on it.
52
+ */
53
+ export declare const INSTALL_TREE_LOCK_WAIT_NOTICE = "hq: another hq updater is still installing hq-cli; waiting for it to finish\u2026";
54
+ /** Resolve the lock ceiling from the environment (0 = do not extend; default on invalid). */
55
+ export declare function resolveSettleLockTimeoutMs(env: NodeJS.ProcessEnv): number;
38
56
  /** Minimal view of a `spawnSync` result the seam relies on. */
39
57
  export interface RecoverySpawnResult {
40
58
  status: number | null;
@@ -19,7 +19,7 @@
19
19
  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
- import { updateLockPath } from "./utils/update-lock.js";
22
+ import { updateLockPath, UPDATE_LOCK_STALE_MS } from "./utils/update-lock.js";
23
23
  import { classifyModuleNotFound, 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
@@ -46,6 +46,31 @@ export function resolveSettleTimeoutMs(env) {
46
46
  return DEFAULT_INSTALL_SETTLE_TIMEOUT_MS;
47
47
  return Number.parseInt(raw.trim(), 10);
48
48
  }
49
+ /** Operator override for the settle wait's LOCK ceiling (time under a live writer). */
50
+ export const INSTALL_SETTLE_LOCK_TIMEOUT_ENV = "HQ_INSTALL_SETTLE_LOCK_TIMEOUT_MS";
51
+ /**
52
+ * Default ceiling (ms) on the time the settle wait spends under a fresh foreign
53
+ * lock held by a live cooperating writer — the update-lock contract's own
54
+ * staleness window. Overridable by {@link INSTALL_SETTLE_LOCK_TIMEOUT_ENV}.
55
+ */
56
+ export const DEFAULT_INSTALL_SETTLE_LOCK_TIMEOUT_MS = UPDATE_LOCK_STALE_MS;
57
+ /**
58
+ * The single dim line emitted once when the wait continues PAST the settle
59
+ * deadline because a live updater still holds the shared install lock — so a human
60
+ * knows the pause is a running update, not a hang. Never a remedy, never an exit
61
+ * code; worded distinctly from {@link INSTALL_TREE_WAIT_NOTICE} so the e2e can
62
+ * synchronize on it.
63
+ */
64
+ export const INSTALL_TREE_LOCK_WAIT_NOTICE = "hq: another hq updater is still installing hq-cli; waiting for it to finish…";
65
+ /** Resolve the lock ceiling from the environment (0 = do not extend; default on invalid). */
66
+ export function resolveSettleLockTimeoutMs(env) {
67
+ const raw = env[INSTALL_SETTLE_LOCK_TIMEOUT_ENV];
68
+ if (typeof raw !== "string" || raw.trim() === "")
69
+ return DEFAULT_INSTALL_SETTLE_LOCK_TIMEOUT_MS;
70
+ if (!/^\d+$/.test(raw.trim()))
71
+ return DEFAULT_INSTALL_SETTLE_LOCK_TIMEOUT_MS;
72
+ return Number.parseInt(raw.trim(), 10);
73
+ }
49
74
  /**
50
75
  * Register commands, recovering once from a torn-install module-resolution
51
76
  * failure. On a clean registration this returns `{}` and touches nothing else.
@@ -90,17 +115,27 @@ export async function registerCommandsWithRecovery(args) {
90
115
  packageRoot,
91
116
  lockPath,
92
117
  deadlineMs: resolveSettleTimeoutMs(env),
118
+ lockWaitMs: resolveSettleLockTimeoutMs(env),
119
+ onLockExtended: () => stderr.write(`${INSTALL_TREE_LOCK_WAIT_NOTICE}\n`),
93
120
  });
94
121
  if (!result.settled) {
95
122
  throw new InstallTreeTornError({
96
123
  cause: err,
97
124
  classified,
98
125
  packageRoot,
99
- outcome: "unsettled",
126
+ // A fresh foreign lock still held at the end means a live cooperating
127
+ // writer held the shared lock past its own ceiling — report 'lock-held'
128
+ // (its remedy tells the user to let the update finish, never to reinstall
129
+ // over it). Otherwise the tree genuinely never settled with no writer
130
+ // visible, which keeps meaning 'unsettled'.
131
+ outcome: result.lockHeldAtEnd ? "lock-held" : "unsettled",
100
132
  attempt: 1,
101
133
  waitedMs: result.waitedMs,
102
134
  sawLock: result.sawLock,
103
135
  sawRetired: result.sawRetired,
136
+ lockHeldAtEnd: result.lockHeldAtEnd,
137
+ lockedMs: result.lockedMs,
138
+ lockTool: result.lockTool,
104
139
  });
105
140
  }
106
141
  const spawn = deps.spawn ?? spawnSync;
@@ -46,19 +46,28 @@ export type ModuleNotFoundCode = "ERR_MODULE_NOT_FOUND" | "MODULE_NOT_FOUND" | "
46
46
  * '<abs>'` + `requireStack`.
47
47
  * - `cjs-package`: CJS require of a bare specifier — `Cannot find module
48
48
  * '<name>'` + `requireStack`.
49
+ * - `cjs-relative`: CJS require of a RELATIVE specifier — `Cannot find module
50
+ * './x'` (or `../x`) + `requireStack`. Node reports the RAW
51
+ * relative token, never a resolved path, so it is re-resolved
52
+ * from the importer's directory rather than reduced by
53
+ * packageNameOf (which would yield the un-probeable name `.`).
49
54
  * - `esm-enoent`: ESM loader ENOENT (HQ-CLI-1M) — a module present at RESOLVE
50
55
  * and gone at READ, so getSourceSync/openSync raises ENOENT
51
56
  * (not ERR_MODULE_NOT_FOUND). `err.path` is the vanished file.
52
57
  * - `unknown`: a module-not-found whose message did not parse; recovery
53
58
  * still waits on the lock / retired-dir / quiet signals.
54
59
  */
55
- export type ModuleErrorDialect = "esm-path" | "esm-package" | "cjs-path" | "cjs-package" | "esm-enoent" | "unknown";
60
+ export type ModuleErrorDialect = "esm-path" | "esm-package" | "cjs-path" | "cjs-package" | "cjs-relative" | "esm-enoent" | "unknown";
56
61
  /**
57
62
  * The missing thing, re-resolvable by the readiness probe:
58
- * - `path`: an absolute filesystem path (a `.js` file, or a package dir).
59
- * - `package`: a bare package `name` resolvable from directory `from` upward.
60
- * - `unknown`: the message did not parse; treated as "present" by the probe so
61
- * readiness turns only on the lock / retired-dir / quiet signals.
63
+ * - `path`: an absolute filesystem path (a `.js` file, or a package dir).
64
+ * - `package`: a bare package `name` resolvable from directory `from` upward.
65
+ * - `relative`: a RELATIVE `specifier` (`./x`, `../x`) re-resolved the way
66
+ * Node's CJS resolver does from directory `from`
67
+ * `path.resolve(from, specifier)` plus the `.js`/`.json`/`.node`
68
+ * and directory forms.
69
+ * - `unknown`: the message did not parse; treated as "present" by the probe so
70
+ * readiness turns only on the lock / retired-dir / quiet signals.
62
71
  */
63
72
  export type ModuleErrorTarget = {
64
73
  kind: "path";
@@ -67,6 +76,10 @@ export type ModuleErrorTarget = {
67
76
  kind: "package";
68
77
  name: string;
69
78
  from: string;
79
+ } | {
80
+ kind: "relative";
81
+ specifier: string;
82
+ from: string;
70
83
  } | {
71
84
  kind: "unknown";
72
85
  };
@@ -115,10 +128,24 @@ export interface InstallTreeFs {
115
128
  * exact ancestor walk `getPackageJSONURL` performs, done WITHOUT
116
129
  * `createRequire` so an ESM-only / export-conditioned package
117
130
  * cannot false-negative.
131
+ * - `relative`: re-resolve `path.resolve(from, specifier)` as Node's CJS
132
+ * require would — a real file (exact / `.js` / `.json` / `.node`),
133
+ * or a directory whose package.json `main` (or that main's index)
134
+ * or own `index.*` is a real file. A bare or partially-extracted
135
+ * directory is NOT loadable and reads as not-present.
118
136
  * - `unknown`: true (readiness turns on the other signals).
119
137
  * Any filesystem error reads as "not present" rather than throwing.
120
138
  */
121
139
  export declare function installTargetPresent(target: ModuleErrorTarget, fs?: InstallTreeFs): boolean;
140
+ /**
141
+ * Default ceiling on the CUMULATIVE time the wait may spend observing a FRESH
142
+ * foreign lock held by a live pid. Equal to the shared update-lock contract's own
143
+ * staleness window ({@link UPDATE_LOCK_STALE_MS} — "no healthy npm install -g of
144
+ * this package runs 10 minutes"), so the wait trusts a live cooperating writer for
145
+ * exactly as long as the contract legitimises holding the lock, and never longer.
146
+ * Overridable by the seam (env HQ_INSTALL_SETTLE_LOCK_TIMEOUT_MS).
147
+ */
148
+ export declare const DEFAULT_INSTALL_SETTLE_LOCK_WAIT_MS: number;
122
149
  export interface WaitForInstallTreeSettledArgs {
123
150
  target: ModuleErrorTarget;
124
151
  /** The running install's package dir (from resolveRunningInstall), or null. */
@@ -132,6 +159,22 @@ export interface WaitForInstallTreeSettledArgs {
132
159
  pollMs?: number;
133
160
  quietMs?: number;
134
161
  deadlineMs: number;
162
+ /**
163
+ * Ceiling on the CUMULATIVE time observed under a FRESH foreign lock held by a
164
+ * live pid. Locked time does NOT consume `deadlineMs` (the budget for the tree
165
+ * to settle once no cooperating writer is visible); it is bounded separately by
166
+ * this ceiling so the wait can never hang. Defaults to
167
+ * {@link DEFAULT_INSTALL_SETTLE_LOCK_WAIT_MS}.
168
+ */
169
+ lockWaitMs?: number;
170
+ /**
171
+ * Invoked exactly once — at the first poll where the total wait has reached
172
+ * `deadlineMs` while a fresh foreign lock is still held (the moment the
173
+ * pre-lock-aware wait would have given up). Lets the seam print a one-line
174
+ * notice that the pause is a live updater, not a hang. Never invoked when no
175
+ * lock is seen or the lock clears before the deadline.
176
+ */
177
+ onLockExtended?: () => void;
135
178
  }
136
179
  export interface WaitForInstallTreeSettledResult {
137
180
  settled: boolean;
@@ -140,20 +183,46 @@ export interface WaitForInstallTreeSettledResult {
140
183
  sawLock: boolean;
141
184
  /** An npm retired/staging sibling (`.hq-cli-<rand>`) was observed at least once. */
142
185
  sawRetired: boolean;
186
+ /** A fresh foreign lock was held on the FINAL poll of an unsettled wait. */
187
+ lockHeldAtEnd: boolean;
188
+ /** Cumulative wall time observed under a fresh foreign lock held by a live pid. */
189
+ lockedMs: number;
190
+ /** The bounded `tool` of the last fresh foreign lock seen, or "" if none. */
191
+ lockTool: string;
143
192
  }
144
193
  /**
145
194
  * Poll until the install tree has been continuously READY for `quietMs`, or the
146
- * `deadlineMs` passes. READY means: (i) no FRESH update lock held by another pid,
147
- * (ii) the missing target is present, and — when `packageRoot` is known —
148
- * (iii) `<packageRoot>/package.json` parses with `name === CLI_NAME` and
149
- * (iv) no `.<leaf>-<rand>` retired sibling remains beside it. Every filesystem
150
- * error makes its condition "not ready" rather than throwing. `deadlineMs === 0`
151
- * evaluates readiness exactly once (no waiting); otherwise the whole wait is
152
- * bounded, so the caller can never hang.
195
+ * wait exhausts its bounds. READY means: (i) no FRESH update lock held by another
196
+ * pid, (ii) the missing target is present, and — when `packageRoot` is known —
197
+ * (iii) `<packageRoot>/package.json` parses with `name === CLI_NAME` and (iv) no
198
+ * `.<leaf>-<rand>` retired sibling remains beside it. Every filesystem error makes
199
+ * its condition "not ready" rather than throwing.
200
+ *
201
+ * The wait is LOCK-AWARE and bounded on TWO independent axes, so a live
202
+ * cooperating writer (the desktop app, the box's update timer, another `hq`)
203
+ * cannot make a legitimately-in-progress reinstall look like a torn install:
204
+ * - UNLOCKED time — wall time observed with no fresh foreign lock — is bounded
205
+ * by `deadlineMs`. That is the budget for the TREE to settle once no writer is
206
+ * visible; with no lock ever seen, unlocked time equals total wait time and
207
+ * the behaviour is byte-for-byte the pre-lock-aware one (unsettled at exactly
208
+ * `deadlineMs`, and `deadlineMs === 0` evaluates readiness exactly once).
209
+ * - LOCKED time — wall time observed under a fresh foreign lock held by a live
210
+ * pid — is bounded SEPARATELY by `lockWaitMs` (default the lock contract's own
211
+ * 10-minute staleness window) and does NOT consume the settle budget. When
212
+ * that ceiling is reached with the lock still held, the result is unsettled
213
+ * with `lockHeldAtEnd` true so the seam can report a distinct `lock-held`
214
+ * outcome and remedy rather than telling the user to reinstall over a live
215
+ * writer.
216
+ * Each poll's REAL elapsed interval (now() deltas) is attributed to the locked or
217
+ * unlocked bucket by whether a fresh foreign lock was held when the interval
218
+ * began, so a starved event loop only makes the wait coarser, never unbounded. A
219
+ * stale lock (dead pid, older than the staleness window, unparseable) or our own
220
+ * pid never extends anything. The whole wait is bounded (≤ `deadlineMs +
221
+ * lockWaitMs`), so the caller can never hang.
153
222
  */
154
223
  export declare function waitForInstallTreeSettled(args: WaitForInstallTreeSettledArgs): Promise<WaitForInstallTreeSettledResult>;
155
224
  /** The recovery OUTCOME, which — not the dialect — decides capture. */
156
- export type InstallTreeTornOutcome = "guarded" | "unsettled" | "reexec-failed";
225
+ export type InstallTreeTornOutcome = "guarded" | "unsettled" | "reexec-failed" | "lock-held";
157
226
  export type InstallTreeTornDiagnostics = {
158
227
  dialect: ModuleErrorDialect;
159
228
  code: ModuleNotFoundCode;
@@ -165,6 +234,12 @@ export type InstallTreeTornDiagnostics = {
165
234
  waitedMs: number;
166
235
  sawLock: boolean;
167
236
  sawRetired: boolean;
237
+ /** A fresh foreign lock was still held when the wait gave up (→ 'lock-held'). */
238
+ lockHeldAtEnd: boolean;
239
+ /** Cumulative wall time the wait spent under a fresh foreign lock. */
240
+ lockedMs: number;
241
+ /** The bounded `tool` of the last fresh foreign lock seen, or "". */
242
+ lockTool: string;
168
243
  node: string;
169
244
  };
170
245
  export interface InstallTreeTornInit {
@@ -177,6 +252,12 @@ export interface InstallTreeTornInit {
177
252
  waitedMs: number;
178
253
  sawLock: boolean;
179
254
  sawRetired: boolean;
255
+ /** Whether a fresh foreign lock was held when the wait gave up (default false). */
256
+ lockHeldAtEnd?: boolean;
257
+ /** Cumulative time under a fresh foreign lock (default 0). */
258
+ lockedMs?: number;
259
+ /** The last fresh foreign lock's `tool`, bounded on construction (default ""). */
260
+ lockTool?: string;
180
261
  }
181
262
  /**
182
263
  * A torn-install failure that stays VISIBLE in Sentry with bounded, hq-derived
@@ -203,10 +284,20 @@ export declare function installTreeTornCaptureContext(err: InstallTreeTornError)
203
284
  * appended by {@link installTreeTornStderrLine}.
204
285
  */
205
286
  export declare const INSTALL_TREE_TORN_REMEDY: string;
287
+ /**
288
+ * The fixed, input-free remedy for the 'lock-held' outcome: a cooperating writer
289
+ * held the shared update lock past its own staleness ceiling. Telling the user to
290
+ * `npm i -g` here would collide with a live installer mid-rename — the exact race
291
+ * update-lock.ts exists to prevent — so this remedy tells them to let the running
292
+ * update finish and NEVER to reinstall over it. Interpolates NOTHING.
293
+ */
294
+ export declare const INSTALL_TREE_LOCK_HELD_REMEDY: string;
206
295
  /**
207
296
  * The single actionable stderr line for a captured torn-install failure: the
208
- * fixed remedy plus the bounded, hq-derived missing specifier — the only
209
- * interpolated value, never argv.
297
+ * outcome's fixed remedy plus the bounded, hq-derived missing specifier — the only
298
+ * interpolated value, never argv. A 'lock-held' outcome selects the "let the
299
+ * update finish" remedy (never the reinstall advice); every other outcome keeps
300
+ * {@link INSTALL_TREE_TORN_REMEDY} byte-for-byte.
210
301
  */
211
302
  export declare function installTreeTornStderrLine(err: InstallTreeTornError): string;
212
303
  //# sourceMappingURL=install-tree-torn.d.ts.map
@@ -34,11 +34,13 @@ import * as path from "node:path";
34
34
  import { fileURLToPath } from "node:url";
35
35
  import { CLI_NAME } from "../cli-version.js";
36
36
  import { boundedDiagnosticValue } from "./package-root-diagnostics.js";
37
- import { isLockStale } from "./update-lock.js";
37
+ import { isLockStale, UPDATE_LOCK_STALE_MS } from "./update-lock.js";
38
38
  /** Byte caps for the (hq-derived, path-shaped) diagnostic strings. */
39
39
  const SPECIFIER_BYTES = 256;
40
40
  const IMPORTER_BYTES = 256;
41
41
  const PACKAGE_ROOT_BYTES = 256;
42
+ /** Byte cap for the lock holder's `tool` (the only foreign string this reads). */
43
+ const LOCK_TOOL_BYTES = 64;
42
44
  const ESM_PACKAGE_RE = /^Cannot find package '([^']+)' imported from (.+)$/s;
43
45
  const CJS_MODULE_RE = /^Cannot find module '([^']+)'/s;
44
46
  const ESM_MODULE_IMPORTED_RE = /^Cannot find module '([^']+)' imported from (.+)$/s;
@@ -52,6 +54,13 @@ const ESM_MODULE_IMPORTED_RE = /^Cannot find module '([^']+)' imported from (.+)
52
54
  * the settle wait. `load\b` also excludes the sibling `esm/loader` module.
53
55
  */
54
56
  const ESM_LOADER_FRAME = /node:internal[\\/]modules[\\/]esm[\\/]load\b/;
57
+ /**
58
+ * A relative module specifier — `./x`, `../x`, `.\x`, `..\x`, or a bare `.`/`..`.
59
+ * Mirrors the shape src/utils/incomplete-install-error.ts uses, extended with the
60
+ * bare-`.`/`..` case so a token that packageNameOf would reduce to the
61
+ * un-probeable name `.` is caught here and re-resolved instead.
62
+ */
63
+ const RELATIVE_CJS_SPECIFIER = /^\.\.?([\\/]|$)/;
55
64
  /**
56
65
  * Reduce a bare specifier to its PACKAGE name: `@scope/name/sub` → `@scope/name`,
57
66
  * `name/sub` → `name`, `name` → `name`. This is the unit the readiness probe
@@ -64,6 +73,20 @@ export function packageNameOf(specifier) {
64
73
  return parts.slice(0, 2).join("/");
65
74
  return parts[0] ?? specifier;
66
75
  }
76
+ /**
77
+ * Build a `package` target from an already-reduced name, UNLESS that name is not
78
+ * a plausible bare package: empty, a relative token reduced to `.`/`..`, or a
79
+ * subpath-imports token (`#internal`). Each of those reduces to a name whose
80
+ * `node_modules/<name>/package.json` ancestor probe can NEVER be true, so it
81
+ * falls back to `unknown` — readiness then turns on the lock / retired-dir /
82
+ * quiet signals rather than a probe that stays permanently false.
83
+ */
84
+ function barePackageTarget(name, from) {
85
+ if (name.length === 0 || name.startsWith(".") || name.startsWith("#")) {
86
+ return { kind: "unknown" };
87
+ }
88
+ return { kind: "package", name, from };
89
+ }
67
90
  /**
68
91
  * Classify a thrown value as a module-resolution failure and extract the missing
69
92
  * target, or return `null` for anything that is not one. The decision is
@@ -128,7 +151,7 @@ export function classifyModuleNotFound(err) {
128
151
  dialect: "esm-package",
129
152
  specifier: spec,
130
153
  importer,
131
- target: { kind: "package", name: spec, from: path.dirname(importer) },
154
+ target: barePackageTarget(spec, path.dirname(importer)),
132
155
  };
133
156
  }
134
157
  // (c) CJS `Cannot find module '<spec>'` (+ Require stack). Absolute → a path
@@ -142,12 +165,32 @@ export function classifyModuleNotFound(err) {
142
165
  if (path.isAbsolute(spec)) {
143
166
  return { code, dialect: "cjs-path", specifier: spec, importer, target: { kind: "path", path: spec } };
144
167
  }
168
+ // Relative specifier (`./x`, `../x`): Node reports the RAW token, so
169
+ // packageNameOf would reduce it to `.`/`..` and build a package target
170
+ // whose ancestor probe can never be true. Re-resolve it from the importer's
171
+ // directory, and report the RESOLVED path as the diagnostic specifier
172
+ // (actionable — the missing sibling's real location), mirroring esm-path.
173
+ if (RELATIVE_CJS_SPECIFIER.test(spec)) {
174
+ if (importer && path.isAbsolute(importer)) {
175
+ const from = path.dirname(importer);
176
+ return {
177
+ code,
178
+ dialect: "cjs-relative",
179
+ specifier: path.resolve(from, spec),
180
+ importer,
181
+ target: { kind: "relative", specifier: spec, from },
182
+ };
183
+ }
184
+ // No usable (absolute) importer survived — fall back to the lock /
185
+ // retired-dir / quiet signals rather than a probe that cannot be true.
186
+ return { code, dialect: "cjs-relative", specifier: spec, importer, target: { kind: "unknown" } };
187
+ }
145
188
  return {
146
189
  code,
147
190
  dialect: "cjs-package",
148
191
  specifier: spec,
149
192
  importer,
150
- target: { kind: "package", name: packageNameOf(spec), from: path.dirname(importer) },
193
+ target: barePackageTarget(packageNameOf(spec), path.dirname(importer)),
151
194
  };
152
195
  }
153
196
  }
@@ -179,6 +222,58 @@ function defaultIsPidAlive(pid) {
179
222
  return err.code === "EPERM";
180
223
  }
181
224
  }
225
+ /** CJS extensions Node's LOAD_AS_FILE / LOAD_INDEX try, in order. */
226
+ const CJS_FILE_EXTENSIONS = [".js", ".json", ".node"];
227
+ /** Whether `p` exists AND is a regular file (not a directory); any fs error → false. */
228
+ function isResolvableFile(p, fs) {
229
+ try {
230
+ return fs.existsSync(p) && !fs.statSync(p).isDirectory();
231
+ }
232
+ catch {
233
+ return false;
234
+ }
235
+ }
236
+ /** Node's LOAD_AS_FILE(X): X itself, then X + each CJS extension — each must be a file. */
237
+ function loadAsFile(base, fs) {
238
+ if (isResolvableFile(base, fs))
239
+ return true;
240
+ return CJS_FILE_EXTENSIONS.some((ext) => isResolvableFile(base + ext, fs));
241
+ }
242
+ /** Node's LOAD_INDEX(X): X/index.{js,json,node} — each must be a file. */
243
+ function loadIndex(dir, fs) {
244
+ return CJS_FILE_EXTENSIONS.some((ext) => isResolvableFile(path.join(dir, `index${ext}`), fs));
245
+ }
246
+ /**
247
+ * Whether a relative target's resolved `base` is loadable exactly as Node's CJS
248
+ * require would load it: LOAD_AS_FILE(base), else LOAD_AS_DIRECTORY(base) — its
249
+ * package.json `main` resolved as a file (or that main's index), else
250
+ * base/index.*. A bare or partially-extracted directory — one that holds neither
251
+ * a resolvable `main` nor an index file — is deliberately NOT loadable, so it
252
+ * reads as not-present and the single re-exec cannot fire on a still-torn tree
253
+ * (mirrors the MODULE_NOT_FOUND Node would still throw for that state).
254
+ */
255
+ function relativeTargetResolvable(base, fs) {
256
+ if (loadAsFile(base, fs))
257
+ return true;
258
+ const manifest = path.join(base, "package.json");
259
+ if (isResolvableFile(manifest, fs)) {
260
+ let main = null;
261
+ try {
262
+ const parsed = JSON.parse(fs.readFileSync(manifest, "utf-8"));
263
+ if (typeof parsed.main === "string" && parsed.main.trim() !== "")
264
+ main = parsed.main;
265
+ }
266
+ catch {
267
+ main = null; // unreadable / unparseable manifest — fall back to index.*
268
+ }
269
+ if (main !== null) {
270
+ const mainBase = path.resolve(base, main);
271
+ if (loadAsFile(mainBase, fs) || loadIndex(mainBase, fs))
272
+ return true;
273
+ }
274
+ }
275
+ return loadIndex(base, fs);
276
+ }
182
277
  /**
183
278
  * Whether the missing target is now present on disk — the readiness signal the
184
279
  * settle wait polls. It re-resolves the SAME step Node's resolver took:
@@ -189,6 +284,11 @@ function defaultIsPidAlive(pid) {
189
284
  * exact ancestor walk `getPackageJSONURL` performs, done WITHOUT
190
285
  * `createRequire` so an ESM-only / export-conditioned package
191
286
  * cannot false-negative.
287
+ * - `relative`: re-resolve `path.resolve(from, specifier)` as Node's CJS
288
+ * require would — a real file (exact / `.js` / `.json` / `.node`),
289
+ * or a directory whose package.json `main` (or that main's index)
290
+ * or own `index.*` is a real file. A bare or partially-extracted
291
+ * directory is NOT loadable and reads as not-present.
192
292
  * - `unknown`: true (readiness turns on the other signals).
193
293
  * Any filesystem error reads as "not present" rather than throwing.
194
294
  */
@@ -209,6 +309,15 @@ export function installTargetPresent(target, fs = nodeInstallTreeFs) {
209
309
  }
210
310
  return true;
211
311
  }
312
+ if (target.kind === "relative") {
313
+ // Re-resolve exactly as Node's CJS require would from `from`, so the probe
314
+ // turns true only when the missing module is ACTUALLY loadable again — a
315
+ // bare or partially-extracted directory must not read as present, or the
316
+ // single re-exec would fire on a still-torn tree. The extensionless case
317
+ // matters — a production sibling shape (`./Base/TreeIterator`, HQ-CLI-1N)
318
+ // carries no extension, so an exact-path-only probe would stay false for it.
319
+ return relativeTargetResolvable(path.resolve(target.from, target.specifier), fs);
320
+ }
212
321
  // package: ancestor walk from `from`.
213
322
  let dir = path.resolve(target.from);
214
323
  for (;;) {
@@ -225,6 +334,15 @@ export function installTargetPresent(target, fs = nodeInstallTreeFs) {
225
334
  return false;
226
335
  }
227
336
  }
337
+ /**
338
+ * Default ceiling on the CUMULATIVE time the wait may spend observing a FRESH
339
+ * foreign lock held by a live pid. Equal to the shared update-lock contract's own
340
+ * staleness window ({@link UPDATE_LOCK_STALE_MS} — "no healthy npm install -g of
341
+ * this package runs 10 minutes"), so the wait trusts a live cooperating writer for
342
+ * exactly as long as the contract legitimises holding the lock, and never longer.
343
+ * Overridable by the seam (env HQ_INSTALL_SETTLE_LOCK_TIMEOUT_MS).
344
+ */
345
+ export const DEFAULT_INSTALL_SETTLE_LOCK_WAIT_MS = UPDATE_LOCK_STALE_MS;
228
346
  /** The scope/leaf split of CLI_NAME, e.g. `@indigoai-us` / `hq-cli`. */
229
347
  function cliNameParts() {
230
348
  const slash = CLI_NAME.indexOf("/");
@@ -232,26 +350,32 @@ function cliNameParts() {
232
350
  return { scope: null, leaf: CLI_NAME };
233
351
  return { scope: CLI_NAME.slice(0, slash), leaf: CLI_NAME.slice(slash + 1) };
234
352
  }
235
- /** Whether a FRESH update lock is held by a DIFFERENT live pid (blocks readiness). */
236
- function freshForeignLockHeld(lockPath, nowMs, isPidAlive, fs) {
353
+ /**
354
+ * The FRESH update lock held by a DIFFERENT live pid, or null. A non-null result
355
+ * blocks readiness AND extends the wait under the lock ceiling; the lock file is
356
+ * only ever READ. Freshness is decided by {@link isLockStale} exactly as before
357
+ * (parseable, `startedAt` younger than {@link UPDATE_LOCK_STALE_MS}, pid alive),
358
+ * and our own pid never counts.
359
+ */
360
+ function freshForeignLock(lockPath, nowMs, isPidAlive, fs) {
237
361
  let raw;
238
362
  try {
239
363
  raw = fs.readFileSync(lockPath, "utf-8");
240
364
  }
241
365
  catch {
242
- return false; // no lock (ENOENT) or unreadable — not a fresh foreign holder
366
+ return null; // no lock (ENOENT) or unreadable — not a fresh foreign holder
243
367
  }
244
368
  if (isLockStale(raw, nowMs, isPidAlive))
245
- return false;
369
+ return null;
246
370
  try {
247
371
  const info = JSON.parse(raw);
248
372
  if (info.pid === process.pid)
249
- return false; // our own lock never blocks us
373
+ return null; // our own lock never blocks us
374
+ return info;
250
375
  }
251
376
  catch {
252
- return false; // isLockStale already treats unparseable as stale, unreachable
377
+ return null; // isLockStale already treats unparseable as stale, unreachable
253
378
  }
254
- return true;
255
379
  }
256
380
  /** Whether an npm retired/staging sibling (`.<leaf>-*`) sits beside packageRoot. */
257
381
  function retiredSiblingPresent(packageRoot, fs) {
@@ -268,13 +392,33 @@ function packageRootHealthy(packageRoot, fs) {
268
392
  }
269
393
  /**
270
394
  * Poll until the install tree has been continuously READY for `quietMs`, or the
271
- * `deadlineMs` passes. READY means: (i) no FRESH update lock held by another pid,
272
- * (ii) the missing target is present, and — when `packageRoot` is known —
273
- * (iii) `<packageRoot>/package.json` parses with `name === CLI_NAME` and
274
- * (iv) no `.<leaf>-<rand>` retired sibling remains beside it. Every filesystem
275
- * error makes its condition "not ready" rather than throwing. `deadlineMs === 0`
276
- * evaluates readiness exactly once (no waiting); otherwise the whole wait is
277
- * bounded, so the caller can never hang.
395
+ * wait exhausts its bounds. READY means: (i) no FRESH update lock held by another
396
+ * pid, (ii) the missing target is present, and — when `packageRoot` is known —
397
+ * (iii) `<packageRoot>/package.json` parses with `name === CLI_NAME` and (iv) no
398
+ * `.<leaf>-<rand>` retired sibling remains beside it. Every filesystem error makes
399
+ * its condition "not ready" rather than throwing.
400
+ *
401
+ * The wait is LOCK-AWARE and bounded on TWO independent axes, so a live
402
+ * cooperating writer (the desktop app, the box's update timer, another `hq`)
403
+ * cannot make a legitimately-in-progress reinstall look like a torn install:
404
+ * - UNLOCKED time — wall time observed with no fresh foreign lock — is bounded
405
+ * by `deadlineMs`. That is the budget for the TREE to settle once no writer is
406
+ * visible; with no lock ever seen, unlocked time equals total wait time and
407
+ * the behaviour is byte-for-byte the pre-lock-aware one (unsettled at exactly
408
+ * `deadlineMs`, and `deadlineMs === 0` evaluates readiness exactly once).
409
+ * - LOCKED time — wall time observed under a fresh foreign lock held by a live
410
+ * pid — is bounded SEPARATELY by `lockWaitMs` (default the lock contract's own
411
+ * 10-minute staleness window) and does NOT consume the settle budget. When
412
+ * that ceiling is reached with the lock still held, the result is unsettled
413
+ * with `lockHeldAtEnd` true so the seam can report a distinct `lock-held`
414
+ * outcome and remedy rather than telling the user to reinstall over a live
415
+ * writer.
416
+ * Each poll's REAL elapsed interval (now() deltas) is attributed to the locked or
417
+ * unlocked bucket by whether a fresh foreign lock was held when the interval
418
+ * began, so a starved event loop only makes the wait coarser, never unbounded. A
419
+ * stale lock (dead pid, older than the staleness window, unparseable) or our own
420
+ * pid never extends anything. The whole wait is bounded (≤ `deadlineMs +
421
+ * lockWaitMs`), so the caller can never hang.
278
422
  */
279
423
  export async function waitForInstallTreeSettled(args) {
280
424
  const now = args.now ?? Date.now;
@@ -284,16 +428,32 @@ export async function waitForInstallTreeSettled(args) {
284
428
  const pollMs = args.pollMs ?? 250;
285
429
  const quietMs = args.quietMs ?? 1500;
286
430
  const deadlineMs = args.deadlineMs;
431
+ const lockWaitMs = args.lockWaitMs ?? DEFAULT_INSTALL_SETTLE_LOCK_WAIT_MS;
287
432
  const start = now();
288
433
  let readySince = null;
289
434
  let sawLock = false;
290
435
  let sawRetired = false;
436
+ let lockedMs = 0;
437
+ let unlockedMs = 0;
438
+ let lockTool = "";
439
+ let prevT = start;
440
+ let prevForeignLockHeld = false;
441
+ let lockExtendedFired = false;
291
442
  for (;;) {
292
443
  const t = now();
293
- const foreignLock = freshForeignLockHeld(args.lockPath, t, isPidAlive, fs);
294
- if (foreignLock)
444
+ // Attribute the interval that just elapsed [prevT, t) to the bucket for the
445
+ // lock state that held when it BEGAN (real now() deltas, never nominal pollMs).
446
+ if (prevForeignLockHeld)
447
+ lockedMs += t - prevT;
448
+ else
449
+ unlockedMs += t - prevT;
450
+ const lock = freshForeignLock(args.lockPath, t, isPidAlive, fs);
451
+ const foreignLockHeld = lock !== null;
452
+ if (foreignLockHeld) {
295
453
  sawLock = true;
296
- let ready = !foreignLock && installTargetPresent(args.target, fs);
454
+ lockTool = boundedDiagnosticValue(lock.tool, LOCK_TOOL_BYTES);
455
+ }
456
+ let ready = !foreignLockHeld && installTargetPresent(args.target, fs);
297
457
  if (ready && args.packageRoot) {
298
458
  try {
299
459
  if (!packageRootHealthy(args.packageRoot, fs))
@@ -323,11 +483,29 @@ export async function waitForInstallTreeSettled(args) {
323
483
  }
324
484
  const waitedMs = t - start;
325
485
  if (ready && (deadlineMs === 0 || t - readySince >= quietMs)) {
326
- return { settled: true, waitedMs, sawLock, sawRetired };
486
+ // Settling requires no foreign lock, so it is never held at the end here.
487
+ return { settled: true, waitedMs, sawLock, sawRetired, lockHeldAtEnd: false, lockedMs, lockTool };
488
+ }
489
+ // Give up when the TREE has had a full unlocked budget with no writer visible,
490
+ // OR a writer is STILL holding the lock and has held it past its own staleness
491
+ // ceiling. The lock ceiling is gated on a lock being held RIGHT NOW so that a
492
+ // ceiling of 0 (HQ_INSTALL_SETTLE_LOCK_TIMEOUT_MS=0, "do not extend under a
493
+ // lock") disables only the extension and does not collapse the ordinary
494
+ // deadlineMs settle when no lock exists — `lockedMs` is 0 ≥ 0 on every poll.
495
+ // `deadlineMs` 0 makes the unlocked test fire on the first poll (evaluate-once),
496
+ // matching the pre-lock-aware contract; `lockHeldAtEnd` distinguishes the causes.
497
+ if (unlockedMs >= deadlineMs || (foreignLockHeld && lockedMs >= lockWaitMs)) {
498
+ return { settled: false, waitedMs, sawLock, sawRetired, lockHeldAtEnd: foreignLockHeld, lockedMs, lockTool };
327
499
  }
328
- if (waitedMs >= deadlineMs) {
329
- return { settled: false, waitedMs, sawLock, sawRetired };
500
+ // The first poll at which the wait has reached `deadlineMs` while a lock is
501
+ // still held is exactly where the pre-lock-aware wait gave up — announce the
502
+ // extension once, and only when we are genuinely going to keep waiting.
503
+ if (!lockExtendedFired && foreignLockHeld && waitedMs >= deadlineMs) {
504
+ lockExtendedFired = true;
505
+ args.onLockExtended?.();
330
506
  }
507
+ prevT = t;
508
+ prevForeignLockHeld = foreignLockHeld;
331
509
  await sleep(pollMs);
332
510
  }
333
511
  }
@@ -357,6 +535,9 @@ export class InstallTreeTornError extends Error {
357
535
  waitedMs: init.waitedMs,
358
536
  sawLock: init.sawLock,
359
537
  sawRetired: init.sawRetired,
538
+ lockHeldAtEnd: init.lockHeldAtEnd ?? false,
539
+ lockedMs: init.lockedMs ?? 0,
540
+ lockTool: boundedDiagnosticValue(init.lockTool ?? "", LOCK_TOOL_BYTES),
360
541
  node: process.version,
361
542
  };
362
543
  Object.setPrototypeOf(this, new.target.prototype);
@@ -379,15 +560,26 @@ export const INSTALL_TREE_TORN_REMEDY = "the hq-cli install was being updated by
379
560
  "timer, the desktop app, or another hq command) while this command started. " +
380
561
  "Re-run your command; if it keeps failing, reinstall with " +
381
562
  "`npm i -g @indigoai-us/hq-cli` (or `pnpm add -g @indigoai-us/hq-cli`).";
563
+ /**
564
+ * The fixed, input-free remedy for the 'lock-held' outcome: a cooperating writer
565
+ * held the shared update lock past its own staleness ceiling. Telling the user to
566
+ * `npm i -g` here would collide with a live installer mid-rename — the exact race
567
+ * update-lock.ts exists to prevent — so this remedy tells them to let the running
568
+ * update finish and NEVER to reinstall over it. Interpolates NOTHING.
569
+ */
570
+ export const INSTALL_TREE_LOCK_HELD_REMEDY = "another hq updater (the desktop app, the box's hq-cli-update timer, or another " +
571
+ "hq command) was still installing hq-cli when this command stopped waiting. Let " +
572
+ "that update finish, then re-run your command; do not reinstall while it is running.";
382
573
  /**
383
574
  * The single actionable stderr line for a captured torn-install failure: the
384
- * fixed remedy plus the bounded, hq-derived missing specifier — the only
385
- * interpolated value, never argv.
575
+ * outcome's fixed remedy plus the bounded, hq-derived missing specifier — the only
576
+ * interpolated value, never argv. A 'lock-held' outcome selects the "let the
577
+ * update finish" remedy (never the reinstall advice); every other outcome keeps
578
+ * {@link INSTALL_TREE_TORN_REMEDY} byte-for-byte.
386
579
  */
387
580
  export function installTreeTornStderrLine(err) {
581
+ const remedy = err.diagnostics.outcome === "lock-held" ? INSTALL_TREE_LOCK_HELD_REMEDY : INSTALL_TREE_TORN_REMEDY;
388
582
  const specifier = err.diagnostics.specifier;
389
- return specifier
390
- ? `${INSTALL_TREE_TORN_REMEDY} (missing: ${specifier})`
391
- : INSTALL_TREE_TORN_REMEDY;
583
+ return specifier ? `${remedy} (missing: ${specifier})` : remedy;
392
584
  }
393
585
  //# sourceMappingURL=install-tree-torn.js.map
@@ -92,8 +92,8 @@ const KNOWN_ERROR_NAMES = new Set([
92
92
  // (HQ-CLI-1A).
93
93
  "QmdWorkdirMissingError",
94
94
  // A torn-install failure the bounded settle-wait + single re-exec could not
95
- // recover (HQ-CLI-1G/1H/1J/1K). Fingerprinted on its own branch below by the
96
- // bounded `diagnostics.outcome`, so the whole family groups into ≤3 issues.
95
+ // recover (HQ-CLI-1G/1H/1J/1K, HQ-CLI-1Y). Fingerprinted on its own branch below
96
+ // by the bounded `diagnostics.outcome`, so the whole family groups into ≤4 issues.
97
97
  "InstallTreeTornError",
98
98
  ]);
99
99
  /**
@@ -102,7 +102,7 @@ const KNOWN_ERROR_NAMES = new Set([
102
102
  * the carrier's message is fixed, so this outcome IS the only discriminator.
103
103
  */
104
104
  const KNOWN_INSTALL_TREE_OUTCOMES = new Set([
105
- "guarded", "unsettled", "reexec-failed",
105
+ "guarded", "unsettled", "reexec-failed", "lock-held",
106
106
  ]);
107
107
  /** Fixed bucket for any error name outside the closed allowlist. */
108
108
  const FALLBACK_ERROR_NAME = "other";
@@ -214,8 +214,8 @@ export function sentryFingerprintFor(err) {
214
214
  }
215
215
  // A torn-install failure carries neither an rpcCode nor an HTTP status; its
216
216
  // only bounded discriminator is the recovery `diagnostics.outcome`. Keyed
217
- // BEFORE the rpc/status branches so the family groups into ≤3 predictable
218
- // issues (guarded / unsettled / reexec-failed) rather than one fungible bucket.
217
+ // BEFORE the rpc/status branches so the family groups into ≤4 predictable issues
218
+ // (guarded / unsettled / reexec-failed / lock-held) rather than one fungible bucket.
219
219
  if (rawName === "InstallTreeTornError") {
220
220
  const diagnostics = record.diagnostics;
221
221
  const outcome = diagnostics !== null && typeof diagnostics === "object"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.109.8",
3
+ "version": "5.109.11",
4
4
  "description": "HQ by Indigo management CLI \u2014 modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {