@indigoai-us/hq-cli 5.305.0 → 5.307.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/assets/scaffold/core/scripts/audit-log.sh +384 -0
  3. package/assets/scaffold/core/scripts/git-pack-extension-check.sh +164 -0
  4. package/assets/scaffold/core/scripts/hq-pin.sh +118 -0
  5. package/assets/scaffold/core/scripts/lib/portable.sh +211 -0
  6. package/assets/scaffold/core/scripts/refresh-vault-access.sh +188 -0
  7. package/dist/command-catalog.generated.d.ts +54 -2
  8. package/dist/command-catalog.generated.js +66 -2
  9. package/dist/commands/core-audit-log-native.d.ts +9 -0
  10. package/dist/commands/core-audit-log-native.js +257 -0
  11. package/dist/commands/core-git-pack-extension-check-native.d.ts +22 -0
  12. package/dist/commands/core-git-pack-extension-check-native.js +190 -0
  13. package/dist/commands/core-native-utilities-flag.d.ts +4 -0
  14. package/dist/commands/core-native-utilities-flag.js +7 -0
  15. package/dist/commands/core-native-utilities.d.ts +28 -0
  16. package/dist/commands/core-native-utilities.js +52 -0
  17. package/dist/commands/core-pin-native.d.ts +14 -0
  18. package/dist/commands/core-pin-native.js +236 -0
  19. package/dist/commands/core-refresh-vault-access-native.d.ts +20 -0
  20. package/dist/commands/core-refresh-vault-access-native.js +229 -0
  21. package/dist/commands/core.js +15 -0
  22. package/dist/commands/daemon.js +24 -23
  23. package/dist/commands/dm.js +6 -1
  24. package/dist/commands/lanes.js +43 -1
  25. package/dist/lib/agent-kit/fallback.d.ts +0 -7
  26. package/dist/lib/agent-kit/fallback.js +4 -4
  27. package/dist/lib/agent-kit/inbox-state.d.ts +0 -2
  28. package/dist/lib/agent-kit/inbox-state.js +2 -2
  29. package/dist/lib/agent-kit/kit-config.d.ts +0 -5
  30. package/dist/lib/agent-kit/kit-config.js +5 -5
  31. package/dist/lib/agent-kit/log.d.ts +0 -1
  32. package/dist/lib/agent-kit/log.js +1 -1
  33. package/dist/lib/agent-kit/mcp/jsonrpc.d.ts +3 -12
  34. package/dist/lib/agent-kit/mcp/jsonrpc.js +4 -4
  35. package/dist/lib/agent-kit/mcp/tools.d.ts +0 -2
  36. package/dist/lib/agent-kit/mcp/tools.js +2 -2
  37. package/dist/lib/agent-kit/services.d.ts +0 -1
  38. package/dist/lib/agent-kit/services.js +1 -1
  39. package/dist/lib/checkpoint-ontology/apply.d.ts +0 -49
  40. package/dist/lib/checkpoint-ontology/apply.js +5 -5
  41. package/dist/lib/checkpoint-ontology/identity.d.ts +2 -1
  42. package/dist/lib/checkpoint-ontology/identity.js +1 -1
  43. package/dist/lib/checkpoint-ontology/multi-writer.d.ts +4 -9
  44. package/dist/lib/checkpoint-ontology/multi-writer.js +6 -6
  45. package/dist/lib/checkpoint-ontology/plan.d.ts +4 -17
  46. package/dist/lib/checkpoint-ontology/plan.js +5 -5
  47. package/dist/lib/checkpoint-ontology/queued-touches.d.ts +0 -3
  48. package/dist/lib/checkpoint-ontology/queued-touches.js +3 -3
  49. package/dist/lib/daemon/catalog.js +6 -2
  50. package/dist/lib/daemon/remote-control.d.ts +19 -8
  51. package/dist/lib/daemon/remote-control.js +62 -12
  52. package/dist/lib/daemon/supervisor.d.ts +2 -0
  53. package/dist/lib/daemon/supervisor.js +11 -0
  54. package/dist/lib/db/control-plane.js +6 -0
  55. package/dist/lib/deploy-apps.js +5 -0
  56. package/dist/startup-registration.js +9 -3
  57. package/dist/utils/install-tree-torn.d.ts +13 -6
  58. package/dist/utils/install-tree-torn.js +42 -16
  59. package/dist/utils/sentry-fingerprint.js +1 -0
  60. package/package.json +1 -1
@@ -283,7 +283,9 @@ function planService(service, ctx) {
283
283
  base(service, {
284
284
  command: {
285
285
  type: "hq",
286
- args: ["daemon", "service", "codex-remote-control", "--script", script ?? "", "--codex", ctx.codexPath ?? ""],
286
+ // No codex path: the host finds codex itself, so a reinstall that
287
+ // briefly moves the link cannot change the plan and restart it.
288
+ args: ["daemon", "service", "codex-remote-control", "--script", script ?? ""],
287
289
  },
288
290
  // Restarting the host ends the tmux session people are using.
289
291
  keepAcrossCliUpdates: true,
@@ -303,7 +305,9 @@ function planService(service, ctx) {
303
305
  // Outposts keep the runner's session name and permission mode.
304
306
  const outpost = ctx.machineType === "outpost";
305
307
  const name = ctx.remoteControlName ?? (outpost ? "outpost-1" : ctx.hostName);
306
- const args = ["daemon", "service", "remote-control", "--claude", ctx.claudePath ?? "", "--name", name];
308
+ // No claude path: the host finds claude itself, so a reinstall that
309
+ // briefly moves the link cannot change the plan and restart the session.
310
+ const args = ["daemon", "service", "remote-control", "--name", name];
307
311
  if (outpost)
308
312
  args.push("--permission-mode", "bypassPermissions");
309
313
  return [
@@ -10,10 +10,12 @@
10
10
  * - the session ends -> the host exits 1 and the daemon restarts it with
11
11
  * backoff (replacing the runner's `while true` loop and its watchdogs);
12
12
  * - the daemon stops the host -> the host kills the session and exits 0;
13
- * - the tool's binary changes on disk (the updater installed a new version) ->
14
- * once the session has been quiet for 10 minutes, or after 2 hours at the
15
- * latest, the host kills the session and exits 0, and the daemon starts it
16
- * again on the new version.
13
+ * - a new version of the tool is installed (the updater, or the tool's own
14
+ * auto-updater) -> once the session has been quiet for 10 minutes, or after
15
+ * 2 hours at the latest, the host kills the session and exits 0, and the
16
+ * daemon starts it again on the new version. Reinstalling the same version
17
+ * (a long-running Claude session's auto-updater does that every few
18
+ * minutes) and hq-cli releases leave the session alone.
17
19
  *
18
20
  * The tmux server is a private socket (`-L hq-daemon`), so it never touches a
19
21
  * user's own tmux sessions.
@@ -32,11 +34,20 @@ export declare function remoteControlArgs(opts: {
32
34
  */
33
35
  export declare function runnerSessionName(script: string): string | null;
34
36
  /**
35
- * Fingerprint of what a host runs: the tool first, then other installs it
36
- * depends on (the hq-cli package). Null while the tool cannot be read; an
37
- * unreadable extra install is left out rather than hiding a tool update.
37
+ * Which version of a tool is installed: the npm package that installs the
38
+ * binary (`name@version`), else the binary's real path and size. A native
39
+ * install's link points at a versioned file, so its real path is the version.
40
+ * The mtime is left out on purpose: reinstalling the same version rewrites
41
+ * the file, and that must not restart the session. Null while the tool
42
+ * cannot be read.
38
43
  */
39
- export declare function installFingerprint(paths: readonly string[], fingerprint: (file: string) => string | null): string | null;
44
+ export declare function toolFingerprint(bin: string): string | null;
45
+ /**
46
+ * The tool a host runs: the path it was given, else the one found now. The
47
+ * daemon's plan leaves the path out, so a reinstall that briefly removes the
48
+ * tool's link cannot change the plan and restart the session.
49
+ */
50
+ export declare function serviceTool(name: string, given: string | undefined, find: (name: string) => string | null): string;
40
51
  /** A tool updated on disk restarts its session once quiet this long... */
41
52
  export declare const IDLE_BEFORE_RESTART_MS: number;
42
53
  /** ...or after this long, however busy the session is. */
@@ -10,14 +10,18 @@
10
10
  * - the session ends -> the host exits 1 and the daemon restarts it with
11
11
  * backoff (replacing the runner's `while true` loop and its watchdogs);
12
12
  * - the daemon stops the host -> the host kills the session and exits 0;
13
- * - the tool's binary changes on disk (the updater installed a new version) ->
14
- * once the session has been quiet for 10 minutes, or after 2 hours at the
15
- * latest, the host kills the session and exits 0, and the daemon starts it
16
- * again on the new version.
13
+ * - a new version of the tool is installed (the updater, or the tool's own
14
+ * auto-updater) -> once the session has been quiet for 10 minutes, or after
15
+ * 2 hours at the latest, the host kills the session and exits 0, and the
16
+ * daemon starts it again on the new version. Reinstalling the same version
17
+ * (a long-running Claude session's auto-updater does that every few
18
+ * minutes) and hq-cli releases leave the session alone.
17
19
  *
18
20
  * The tmux server is a private socket (`-L hq-daemon`), so it never touches a
19
21
  * user's own tmux sessions.
20
22
  */
23
+ import * as fs from "node:fs";
24
+ import * as path from "node:path";
21
25
  export const TMUX_SOCKET = "hq-daemon";
22
26
  export const TMUX_SESSION = "remote-control";
23
27
  export const CODEX_TMUX_SESSION = "codex-remote-control";
@@ -35,17 +39,63 @@ export function runnerSessionName(script) {
35
39
  const m = /remote-control\b[^\n]*?--name[= ]+"?([^\s"]+)"?/.exec(script);
36
40
  return m ? m[1] : null;
37
41
  }
42
+ /** How far above the binary to look for the package that installs it (bin/claude.exe sits one level down). */
43
+ const PACKAGE_SEARCH_DEPTH = 3;
38
44
  /**
39
- * Fingerprint of what a host runs: the tool first, then other installs it
40
- * depends on (the hq-cli package). Null while the tool cannot be read; an
41
- * unreadable extra install is left out rather than hiding a tool update.
45
+ * Which version of a tool is installed: the npm package that installs the
46
+ * binary (`name@version`), else the binary's real path and size. A native
47
+ * install's link points at a versioned file, so its real path is the version.
48
+ * The mtime is left out on purpose: reinstalling the same version rewrites
49
+ * the file, and that must not restart the session. Null while the tool
50
+ * cannot be read.
42
51
  */
43
- export function installFingerprint(paths, fingerprint) {
44
- const [tool, ...rest] = paths;
45
- const toolFp = tool === undefined ? null : fingerprint(tool);
46
- if (toolFp === null)
52
+ export function toolFingerprint(bin) {
53
+ let real;
54
+ let size;
55
+ try {
56
+ real = fs.realpathSync(bin);
57
+ size = fs.statSync(real).size;
58
+ }
59
+ catch {
60
+ return null;
61
+ }
62
+ let dir = path.dirname(real);
63
+ for (let i = 0; i < PACKAGE_SEARCH_DEPTH; i++) {
64
+ const version = packageVersionFor(dir, real);
65
+ if (version)
66
+ return version;
67
+ const parent = path.dirname(dir);
68
+ if (parent === dir)
69
+ break;
70
+ dir = parent;
71
+ }
72
+ return `${real}:${size}`;
73
+ }
74
+ /** `name@version` when `<dir>/package.json` declares `real` as one of its bins. */
75
+ function packageVersionFor(dir, real) {
76
+ let pkg;
77
+ try {
78
+ pkg = JSON.parse(fs.readFileSync(path.join(dir, "package.json"), "utf8"));
79
+ }
80
+ catch {
47
81
  return null;
48
- return [toolFp, ...rest.map(fingerprint).filter((fp) => fp !== null)].join("|");
82
+ }
83
+ if (typeof pkg.name !== "string" || typeof pkg.version !== "string")
84
+ return null;
85
+ const bins = typeof pkg.bin === "string" ? [pkg.bin] : pkg.bin && typeof pkg.bin === "object" ? Object.values(pkg.bin) : [];
86
+ const installs = bins.some((b) => typeof b === "string" && path.resolve(dir, b) === real);
87
+ return installs ? `${pkg.name}@${pkg.version}` : null;
88
+ }
89
+ /**
90
+ * The tool a host runs: the path it was given, else the one found now. The
91
+ * daemon's plan leaves the path out, so a reinstall that briefly removes the
92
+ * tool's link cannot change the plan and restart the session.
93
+ */
94
+ export function serviceTool(name, given, find) {
95
+ const tool = given?.trim() || find(name);
96
+ if (!tool)
97
+ throw new Error(`the ${name} CLI is not installed`);
98
+ return tool;
49
99
  }
50
100
  /** A tool updated on disk restarts its session once quiet this long... */
51
101
  export const IDLE_BEFORE_RESTART_MS = 10 * 60_000;
@@ -123,6 +123,8 @@ export interface Supervisor {
123
123
  /** Stop supervising without signalling any child; returns the long-running units left running. */
124
124
  release(): ReleasedUnit[];
125
125
  }
126
+ /** Plan reads in a row that must find a running remote-control host blocked before it is paused. */
127
+ export declare const BLOCKED_READS_BEFORE_PAUSE = 2;
126
128
  export declare function backoffMs(attempt: number): number;
127
129
  /**
128
130
  * What a unit's process was started from; a unit whose key changes is
@@ -33,6 +33,8 @@ const MESH_OWNER_LOG_INTERVAL_MS = 10 * 60_000;
33
33
  export const HANDOFF_STAGGER_MS = 15_000;
34
34
  /** How long a draining unit may take to finish its work before it gets SIGTERM. */
35
35
  export const HANDOFF_DRAIN_MAX_MS = 15 * 60_000;
36
+ /** Plan reads in a row that must find a running remote-control host blocked before it is paused. */
37
+ export const BLOCKED_READS_BEFORE_PAUSE = 2;
36
38
  export function backoffMs(attempt) {
37
39
  return Math.min(BACKOFF_MAX_MS, BACKOFF_BASE_MS * 2 ** Math.max(0, attempt));
38
40
  }
@@ -505,6 +507,15 @@ export function createSupervisor(deps) {
505
507
  continue;
506
508
  }
507
509
  next.set(plan.id, existing);
510
+ // A remote-control host is what people's sessions run in. Its tool can
511
+ // look missing for one plan read while it is being reinstalled, so it is
512
+ // only paused once the tool is missing on two reads in a row.
513
+ if (plan.blocked && existing.plan.blocked === undefined && existing.child && plan.keepAcrossCliUpdates) {
514
+ existing.blockedReads = (existing.blockedReads ?? 0) + 1;
515
+ if (existing.blockedReads < BLOCKED_READS_BEFORE_PAUSE)
516
+ continue;
517
+ }
518
+ existing.blockedReads = 0;
508
519
  const adopted = existing.adopted === true;
509
520
  existing.adopted = false;
510
521
  const key = planKey(plan, deps.cliVersion);
@@ -1,4 +1,8 @@
1
1
  import { planGateErrorFromPayload } from "../../utils/plan-gate-error.js";
2
+ // Provision may allocate remote resources, so allow two minutes and never retry.
3
+ const DB_PROVISION_TIMEOUT_MS = 120_000;
4
+ // Status is a read-only probe; 15 seconds is longer than its normal response time.
5
+ const DB_STATUS_TIMEOUT_MS = 15_000;
2
6
  function assertNoPostgresUrl(label, text) {
3
7
  if (/postgres:\/\//i.test(text) || /postgresql:\/\//i.test(text)) {
4
8
  throw new Error(`${label}: control plane returned a connection string (refusing to surface)`);
@@ -22,6 +26,7 @@ export class ControlPlaneDbClient {
22
26
  "Content-Type": "application/json",
23
27
  },
24
28
  body: JSON.stringify(input),
29
+ signal: AbortSignal.timeout(DB_PROVISION_TIMEOUT_MS),
25
30
  });
26
31
  const text = await res.text();
27
32
  assertNoPostgresUrl("provision", text);
@@ -53,6 +58,7 @@ export class ControlPlaneDbClient {
53
58
  const res = await this.fetchImpl(url, {
54
59
  method: "GET",
55
60
  headers: { Authorization: `Bearer ${token}` },
61
+ signal: AbortSignal.timeout(DB_STATUS_TIMEOUT_MS),
56
62
  });
57
63
  const text = await res.text();
58
64
  assertNoPostgresUrl("status", text);
@@ -7,6 +7,7 @@ export const MAX_DEPLOY_APPS_PAGES = 100;
7
7
  */
8
8
  export async function listDeployApps(fetchPage) {
9
9
  const apps = [];
10
+ const seenCursors = new Set();
10
11
  let cursor;
11
12
  for (let page = 0; page < MAX_DEPLOY_APPS_PAGES; page += 1) {
12
13
  const result = await fetchPage(cursor);
@@ -17,6 +18,10 @@ export async function listDeployApps(fetchPage) {
17
18
  if (typeof result.nextCursor !== "string" || result.nextCursor.length === 0) {
18
19
  throw new Error("hq-deploy returned an invalid apps-list cursor.");
19
20
  }
21
+ if (seenCursors.has(result.nextCursor)) {
22
+ throw new Error("hq-deploy returned a repeated apps list cursor.");
23
+ }
24
+ seenCursors.add(result.nextCursor);
20
25
  cursor = result.nextCursor;
21
26
  }
22
27
  throw new Error(`hq-deploy apps list exceeded the ${MAX_DEPLOY_APPS_PAGES}-page safety cap.`);
@@ -150,9 +150,15 @@ export async function registerCommandsWithRecovery(args) {
150
150
  // writer held the shared lock past its own ceiling — report 'lock-held'
151
151
  // (its remedy tells the user to let the update finish, never to reinstall
152
152
  // over it). Otherwise the tree genuinely never settled with no writer
153
- // visible. The settle probe only uses `stale_tree` when it could observe
154
- // an unchanged tree with no active writer or retired sibling.
155
- outcome: result.lockHeldAtEnd ? "lock-held" : result.staleTree ? "stale_tree" : "unsettled",
153
+ // visible. A partial retired-sibling scan gets its own outcome so we do
154
+ // not claim that an updater is absent when part of its tree was unreadable.
155
+ outcome: result.lockHeldAtEnd
156
+ ? "lock-held"
157
+ : result.retiredSiblingUnverified
158
+ ? "retired_unverified"
159
+ : result.staleTree
160
+ ? "stale_tree"
161
+ : "unsettled",
156
162
  treeState,
157
163
  attempt: 1,
158
164
  waitedMs: result.waitedMs,
@@ -274,6 +274,8 @@ export interface WaitForInstallTreeSettledResult {
274
274
  staleTree?: boolean;
275
275
  /** Presence on the final poll; null means the sibling directory was unreadable. */
276
276
  retiredSiblingPresent?: boolean | null;
277
+ /** A stable partial retired-sibling scan ended the wait without claiming the tree is stale. */
278
+ retiredSiblingUnverified?: boolean;
277
279
  }
278
280
  export declare const DEFAULT_INSTALL_TREE_STALE_PROBE_MS = 1500;
279
281
  /**
@@ -288,11 +290,14 @@ export declare const DEFAULT_INSTALL_TREE_STALE_PROBE_MS = 1500;
288
290
  * app, the box's update timer, another `hq`) cannot make a legitimately-in-progress
289
291
  * reinstall look stale:
290
292
  * - UNLOCKED time — wall time observed with no fresh foreign lock — is bounded
291
- * by `deadlineMs`. If the target is still missing and the observed tree,
292
- * including retired sibling names and mtimes, stays unchanged for
293
- * `staleProbeMs`, the wait returns `staleTree` early. Any observed tree
294
- * change keeps the ordinary settle deadline in force. `deadlineMs === 0`
295
- * evaluates readiness once.
293
+ * by `deadlineMs`. If the target is still missing and the observable tree
294
+ * signature stays unchanged for `staleProbeMs`, the wait returns `staleTree`
295
+ * early. When only a named retired npm sibling's deep scan is incomplete,
296
+ * a stable partial signature ends the wait early as `retiredSiblingUnverified`
297
+ * instead. That outcome preserves uncertainty and tells the user to wait for
298
+ * any installer before attempting a repair. Any observed signature change
299
+ * keeps the ordinary settle deadline in force. `deadlineMs === 0` evaluates
300
+ * readiness once.
296
301
  * - LOCKED time — wall time observed under a fresh foreign lock held by a live
297
302
  * pid — is bounded SEPARATELY by `lockWaitMs` (default the lock contract's own
298
303
  * 10-minute staleness window) and does NOT consume the settle budget. When
@@ -331,7 +336,7 @@ export interface InstallRewriteSignalArgs {
331
336
  */
332
337
  export declare function installRewriteInProgress(args: InstallRewriteSignalArgs): boolean;
333
338
  /** The recovery OUTCOME, which — not the dialect — decides capture. */
334
- export type InstallTreeTornOutcome = "guarded" | "unsettled" | "reexec-failed" | "lock-held" | "stale_tree";
339
+ export type InstallTreeTornOutcome = "guarded" | "unsettled" | "reexec-failed" | "lock-held" | "stale_tree" | "retired_unverified";
335
340
  export type InstallTreeState = "in_flight" | "stale";
336
341
  export type InstallTreeTornDiagnostics = {
337
342
  dialect: ModuleErrorDialect;
@@ -408,6 +413,8 @@ export declare function scrubHomeDirectoryPrefix(value: string, homeDirectory?:
408
413
  export declare const INSTALL_TREE_TORN_REMEDY: string;
409
414
  /** Fixed repair guidance for an unchanged tree with no active updater signal. */
410
415
  export declare const INSTALL_TREE_STALE_REMEDY: string;
416
+ /** Guidance when a retired package sibling's activity could not be verified. */
417
+ export declare const INSTALL_TREE_RETIRED_UNVERIFIED_REMEDY: string;
411
418
  /**
412
419
  * The fixed, input-free remedy for the 'lock-held' outcome: a cooperating writer
413
420
  * held the shared update lock past its own staleness ceiling. Telling the user to
@@ -700,7 +700,7 @@ function retiredSiblingActivitySignature(packageRoot, fs) {
700
700
  for (const sibling of siblings) {
701
701
  visit(path.join(parent, sibling), sibling, 0);
702
702
  }
703
- return { value: JSON.stringify(snapshots), observed };
703
+ return { value: JSON.stringify(snapshots), observed, present: siblings.length > 0 };
704
704
  }
705
705
  /** Whether packageRoot's own manifest is healthy (`name === CLI_NAME`). */
706
706
  function packageRootHealthy(packageRoot, fs) {
@@ -787,6 +787,8 @@ function installTreeActivitySignature(target, packageRoot, fs) {
787
787
  const activity = installTreeActivityPaths(target, packageRoot);
788
788
  const snapshots = [];
789
789
  let observed = true;
790
+ let nonRetiredObserved = true;
791
+ let retiredSiblingPresent = null;
790
792
  const errorCode = (err) => {
791
793
  if (err !== null && typeof err === "object" && "code" in err && typeof err.code === "string") {
792
794
  return err.code;
@@ -809,6 +811,7 @@ function installTreeActivitySignature(target, packageRoot, fs) {
809
811
  }
810
812
  catch (err) {
811
813
  observed = false;
814
+ nonRetiredObserved = false;
812
815
  snapshots.push([candidate, `unreadable:${errorCode(err)}`]);
813
816
  }
814
817
  }
@@ -820,6 +823,7 @@ function installTreeActivitySignature(target, packageRoot, fs) {
820
823
  }
821
824
  catch (err) {
822
825
  observed = false;
826
+ nonRetiredObserved = false;
823
827
  snapshots.push([directory, `entries-unreadable:${errorCode(err)}`]);
824
828
  }
825
829
  }
@@ -827,6 +831,7 @@ function installTreeActivitySignature(target, packageRoot, fs) {
827
831
  try {
828
832
  const retired = retiredSiblingActivitySignature(packageRoot, fs);
829
833
  snapshots.push(["retired-siblings", retired.value]);
834
+ retiredSiblingPresent = retired.present;
830
835
  if (!retired.observed)
831
836
  observed = false;
832
837
  }
@@ -835,7 +840,16 @@ function installTreeActivitySignature(target, packageRoot, fs) {
835
840
  snapshots.push(["retired-siblings-unreadable", errorCode(err)]);
836
841
  }
837
842
  }
838
- return { value: JSON.stringify(snapshots), observed };
843
+ return { value: JSON.stringify(snapshots), observed, nonRetiredObserved, retiredSiblingPresent };
844
+ }
845
+ function canProbeUnsettledRetiredSibling(snapshot) {
846
+ // A very large or unreadable retired npm tree can exceed the bounded recursive
847
+ // activity scan. Its stable partial signature is still useful when the live
848
+ // install paths are observable and npm's named retired sibling is present.
849
+ // This only selects the existing stale-tree repair outcome; it never removes
850
+ // files, and any observed signature change still suppresses stale classification.
851
+ return snapshot.observed ||
852
+ (snapshot.nonRetiredObserved && snapshot.retiredSiblingPresent === true);
839
853
  }
840
854
  export const DEFAULT_INSTALL_TREE_STALE_PROBE_MS = 1_500;
841
855
  /**
@@ -850,11 +864,14 @@ export const DEFAULT_INSTALL_TREE_STALE_PROBE_MS = 1_500;
850
864
  * app, the box's update timer, another `hq`) cannot make a legitimately-in-progress
851
865
  * reinstall look stale:
852
866
  * - UNLOCKED time — wall time observed with no fresh foreign lock — is bounded
853
- * by `deadlineMs`. If the target is still missing and the observed tree,
854
- * including retired sibling names and mtimes, stays unchanged for
855
- * `staleProbeMs`, the wait returns `staleTree` early. Any observed tree
856
- * change keeps the ordinary settle deadline in force. `deadlineMs === 0`
857
- * evaluates readiness once.
867
+ * by `deadlineMs`. If the target is still missing and the observable tree
868
+ * signature stays unchanged for `staleProbeMs`, the wait returns `staleTree`
869
+ * early. When only a named retired npm sibling's deep scan is incomplete,
870
+ * a stable partial signature ends the wait early as `retiredSiblingUnverified`
871
+ * instead. That outcome preserves uncertainty and tells the user to wait for
872
+ * any installer before attempting a repair. Any observed signature change
873
+ * keeps the ordinary settle deadline in force. `deadlineMs === 0` evaluates
874
+ * readiness once.
858
875
  * - LOCKED time — wall time observed under a fresh foreign lock held by a live
859
876
  * pid — is bounded SEPARATELY by `lockWaitMs` (default the lock contract's own
860
877
  * 10-minute staleness window) and does NOT consume the settle budget. When
@@ -882,7 +899,7 @@ export async function waitForInstallTreeSettled(args) {
882
899
  const staleProbeMs = Math.max(0, args.staleProbeMs ?? DEFAULT_INSTALL_TREE_STALE_PROBE_MS);
883
900
  const start = now();
884
901
  let lastActivitySnapshot = installTreeActivitySignature(args.target, args.packageRoot, fs);
885
- let staleObservationStartedAt = lastActivitySnapshot.observed ? start : null;
902
+ let staleObservationStartedAt = canProbeUnsettledRetiredSibling(lastActivitySnapshot) ? start : null;
886
903
  let sawTreeChange = false;
887
904
  let readySince = null;
888
905
  let sawLock = false;
@@ -909,16 +926,18 @@ export async function waitForInstallTreeSettled(args) {
909
926
  }
910
927
  if (!sawTreeChange) {
911
928
  const activitySnapshot = installTreeActivitySignature(args.target, args.packageRoot, fs);
912
- if (!activitySnapshot.observed) {
929
+ const previousCanProbe = canProbeUnsettledRetiredSibling(lastActivitySnapshot);
930
+ const currentCanProbe = canProbeUnsettledRetiredSibling(activitySnapshot);
931
+ if (previousCanProbe && currentCanProbe && activitySnapshot.value !== lastActivitySnapshot.value) {
932
+ sawTreeChange = true;
913
933
  staleObservationStartedAt = null;
914
934
  }
915
- else if (!lastActivitySnapshot.observed) {
916
- staleObservationStartedAt = t;
917
- }
918
- else if (activitySnapshot.value !== lastActivitySnapshot.value) {
919
- sawTreeChange = true;
935
+ else if (!currentCanProbe) {
920
936
  staleObservationStartedAt = null;
921
937
  }
938
+ else if (!previousCanProbe) {
939
+ staleObservationStartedAt = t;
940
+ }
922
941
  lastActivitySnapshot = activitySnapshot;
923
942
  }
924
943
  let retiredPresent = null;
@@ -985,8 +1004,9 @@ export async function waitForInstallTreeSettled(args) {
985
1004
  lockedMs,
986
1005
  lockTool,
987
1006
  sawTreeChange,
988
- staleTree: true,
1007
+ staleTree: lastActivitySnapshot.observed,
989
1008
  retiredSiblingPresent: retiredPresent,
1009
+ retiredSiblingUnverified: !lastActivitySnapshot.observed && lastActivitySnapshot.retiredSiblingPresent === true,
990
1010
  };
991
1011
  }
992
1012
  // Give up when the TREE has had a full unlocked budget with no writer visible,
@@ -1176,6 +1196,10 @@ export const INSTALL_TREE_STALE_REMEDY = "hq-cli's install tree is incomplete an
1176
1196
  "Reinstall with `npm install -g @indigoai-us/hq-cli@latest` " +
1177
1197
  "(or `pnpm add -g @indigoai-us/hq-cli@latest` / `bun add -g @indigoai-us/hq-cli@latest` " +
1178
1198
  "when that manager owns your install), then run the command again.";
1199
+ /** Guidance when a retired package sibling's activity could not be verified. */
1200
+ export const INSTALL_TREE_RETIRED_UNVERIFIED_REMEDY = "hq-cli found a retired install sibling but could not verify whether an installer is still using it. Wait for any npm or desktop CLI update to finish, then run the command again. If no install is running, repair with `npm install -g @indigoai-us/hq-cli@latest` " +
1201
+ "(or `pnpm add -g @indigoai-us/hq-cli@latest` / `bun add -g @indigoai-us/hq-cli@latest` " +
1202
+ "when that manager owns your install).";
1179
1203
  /**
1180
1204
  * The fixed, input-free remedy for the 'lock-held' outcome: a cooperating writer
1181
1205
  * held the shared update lock past its own staleness ceiling. Telling the user to
@@ -1198,7 +1222,9 @@ export function installTreeTornStderrLine(err) {
1198
1222
  ? INSTALL_TREE_LOCK_HELD_REMEDY
1199
1223
  : err.diagnostics.outcome === "stale_tree"
1200
1224
  ? INSTALL_TREE_STALE_REMEDY
1201
- : INSTALL_TREE_TORN_REMEDY;
1225
+ : err.diagnostics.outcome === "retired_unverified"
1226
+ ? INSTALL_TREE_RETIRED_UNVERIFIED_REMEDY
1227
+ : INSTALL_TREE_TORN_REMEDY;
1202
1228
  const specifier = err.diagnostics.specifier;
1203
1229
  return specifier ? `${remedy} (missing: ${specifier})` : remedy;
1204
1230
  }
@@ -189,6 +189,7 @@ const KNOWN_INSTALL_TREE_OUTCOMES = new Set([
189
189
  "reexec-failed",
190
190
  "lock-held",
191
191
  "stale_tree",
192
+ "retired_unverified",
192
193
  ]);
193
194
  function installTreeRecoveryOutcomeTag(value) {
194
195
  return typeof value === "string" && KNOWN_INSTALL_TREE_OUTCOMES.has(value)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.305.0",
3
+ "version": "5.307.0",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {