@indigoai-us/hq-cli 5.345.147 → 5.345.149

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 (168) hide show
  1. package/CHANGELOG.md +65 -0
  2. package/README.md +2 -0
  3. package/assets/bot-workers/setup/worker.yaml +11 -0
  4. package/dist/command-catalog.generated.d.ts +341 -11
  5. package/dist/command-catalog.generated.js +421 -11
  6. package/dist/command-help.js +2 -1
  7. package/dist/commands/access.d.ts +45 -2
  8. package/dist/commands/access.js +537 -223
  9. package/dist/commands/agent-inbox.js +2 -1
  10. package/dist/commands/billing-usage.d.ts +33 -2
  11. package/dist/commands/billing-usage.js +75 -4
  12. package/dist/commands/bot.js +2 -2
  13. package/dist/commands/channels.d.ts +27 -0
  14. package/dist/commands/channels.js +108 -4
  15. package/dist/commands/cloud.js +31 -1
  16. package/dist/commands/core-refresh-vault-access-native.d.ts +33 -0
  17. package/dist/commands/core-refresh-vault-access-native.js +356 -28
  18. package/dist/commands/core-work-mesh-bind-trusted-native.d.ts +9 -0
  19. package/dist/commands/core-work-mesh-bind-trusted-native.js +26 -19
  20. package/dist/commands/core.js +9 -0
  21. package/dist/commands/daemon.d.ts +7 -0
  22. package/dist/commands/daemon.js +37 -3
  23. package/dist/commands/dm.js +18 -3
  24. package/dist/commands/doctor.d.ts +11 -1
  25. package/dist/commands/doctor.js +56 -1
  26. package/dist/commands/install-global.d.ts +8 -8
  27. package/dist/commands/install-global.js +139 -61
  28. package/dist/commands/lanes-shared.d.ts +3 -1
  29. package/dist/commands/lanes.d.ts +4 -4
  30. package/dist/commands/lanes.js +380 -25
  31. package/dist/commands/library-add.d.ts +31 -1
  32. package/dist/commands/library-add.js +69 -2
  33. package/dist/commands/library.js +37 -3
  34. package/dist/commands/pack-install.d.ts +2 -2
  35. package/dist/commands/pack-install.js +133 -35
  36. package/dist/commands/reindex.js +19 -0
  37. package/dist/commands/sync-manifest.d.ts +2 -9
  38. package/dist/commands/sync-manifest.js +4 -15
  39. package/dist/hook-engine-path.d.ts +4 -0
  40. package/dist/hook-engine-path.js +43 -0
  41. package/dist/hq-cloud-sync-runner/bin/sync-runner.js +2783 -2354
  42. package/dist/hq-cloud-sync-runner/package.json +1 -1
  43. package/dist/index.d.ts +10 -0
  44. package/dist/index.js +76 -5
  45. package/dist/lazy-commands.js +2 -0
  46. package/dist/lib/agent-kit/inbox-redact.d.ts +44 -0
  47. package/dist/lib/agent-kit/inbox-redact.js +361 -0
  48. package/dist/lib/agent-kit/inbox-state.js +11 -2
  49. package/dist/lib/agent-kit/mcp/tools.js +2 -1
  50. package/dist/lib/agent-kit/run/inbox.js +36 -7
  51. package/dist/lib/anywhere-runtime-flag-key.d.ts +3 -0
  52. package/dist/lib/anywhere-runtime-flag-key.js +9 -0
  53. package/dist/lib/billing/usage-cost.d.ts +31 -1
  54. package/dist/lib/billing/usage-cost.js +46 -0
  55. package/dist/lib/company-skill-wrapper.js +6 -4
  56. package/dist/lib/daemon/install.d.ts +47 -1
  57. package/dist/lib/daemon/install.js +181 -15
  58. package/dist/lib/daemon/lock.d.ts +32 -0
  59. package/dist/lib/daemon/lock.js +55 -2
  60. package/dist/lib/db/local.d.ts +2 -2
  61. package/dist/lib/db/local.js +2 -2
  62. package/dist/lib/db/sql.js +2 -2
  63. package/dist/lib/db/sqlite-binding.d.ts +22 -0
  64. package/dist/lib/db/sqlite-binding.js +168 -0
  65. package/dist/lib/doctor/__testing__/run-doctor.js +4 -0
  66. package/dist/lib/doctor/checks/anywhere.d.ts +15 -0
  67. package/dist/lib/doctor/checks/anywhere.js +77 -11
  68. package/dist/lib/doctor/checks/cli-path.d.ts +12 -0
  69. package/dist/lib/doctor/checks/cli-path.js +46 -0
  70. package/dist/lib/doctor/checks/hook-engine.d.ts +4 -0
  71. package/dist/lib/doctor/checks/hook-engine.js +17 -0
  72. package/dist/lib/doctor/checks/sync-health.d.ts +7 -0
  73. package/dist/lib/doctor/checks/sync-health.js +38 -2
  74. package/dist/lib/doctor/checks/temp-homes.d.ts +19 -0
  75. package/dist/lib/doctor/checks/temp-homes.js +54 -0
  76. package/dist/lib/doctor/fix/apply.d.ts +90 -1
  77. package/dist/lib/doctor/fix/apply.js +176 -10
  78. package/dist/lib/doctor/fix/json-output.d.ts +4 -1
  79. package/dist/lib/doctor/fix/json-output.js +6 -0
  80. package/dist/lib/doctor/fix/remediation.d.ts +7 -1
  81. package/dist/lib/doctor/fix/remediation.js +23 -1
  82. package/dist/lib/doctor/registry.js +10 -0
  83. package/dist/lib/doctor/temp-home-sweeper.d.ts +149 -0
  84. package/dist/lib/doctor/temp-home-sweeper.js +418 -0
  85. package/dist/lib/flag-registry-worker-source.generated.d.ts +1 -1
  86. package/dist/lib/flag-registry-worker-source.generated.js +1 -1
  87. package/dist/lib/flag-registry.js +9 -1
  88. package/dist/lib/flag-snapshot-file.js +8 -3
  89. package/dist/lib/hook-engine/reconcile.d.ts +9 -0
  90. package/dist/lib/hook-engine/reconcile.js +134 -0
  91. package/dist/lib/hook-engine/registration.d.ts +17 -0
  92. package/dist/lib/hook-engine/registration.js +50 -0
  93. package/dist/lib/hook-engine/resolve.d.ts +31 -0
  94. package/dist/lib/hook-engine/resolve.js +134 -0
  95. package/dist/lib/install/claude-targets.d.ts +2 -1
  96. package/dist/lib/install/claude-targets.js +8 -3
  97. package/dist/lib/install/codex-targets.d.ts +3 -0
  98. package/dist/lib/install/codex-targets.js +15 -4
  99. package/dist/lib/install/hook-target.d.ts +9 -0
  100. package/dist/lib/install/hook-target.js +39 -0
  101. package/dist/lib/lanes/admission-host.d.ts +1 -0
  102. package/dist/lib/lanes/admission-host.js +8 -1
  103. package/dist/lib/lanes/admission.d.ts +4 -0
  104. package/dist/lib/lanes/admission.js +6 -2
  105. package/dist/lib/lanes/envelope-repair-dispatch.d.ts +5 -0
  106. package/dist/lib/lanes/envelope-repair-dispatch.js +12 -1
  107. package/dist/lib/lanes/index.d.ts +2 -2
  108. package/dist/lib/lanes/index.js +1 -1
  109. package/dist/lib/lanes/keep-alive.js +4 -1
  110. package/dist/lib/lanes/local-host-identity.d.ts +40 -0
  111. package/dist/lib/lanes/local-host-identity.js +314 -0
  112. package/dist/lib/lanes/reaper.d.ts +1 -1
  113. package/dist/lib/lanes/reaper.js +7 -0
  114. package/dist/lib/lanes/reconcile.d.ts +4 -0
  115. package/dist/lib/lanes/reconcile.js +16 -8
  116. package/dist/lib/lanes/spawn.d.ts +20 -0
  117. package/dist/lib/lanes/spawn.js +48 -2
  118. package/dist/lib/lanes/status-fields.js +6 -1
  119. package/dist/lib/lanes/store.js +8 -1
  120. package/dist/lib/lanes/throttle.d.ts +49 -6
  121. package/dist/lib/lanes/throttle.js +154 -17
  122. package/dist/lib/lanes/types.d.ts +22 -1
  123. package/dist/lib/lanes/types.js +27 -3
  124. package/dist/lib/lanes/watcher.js +27 -2
  125. package/dist/lib/lanes/worker-continuity.d.ts +2 -0
  126. package/dist/lib/lanes/worker-continuity.js +18 -11
  127. package/dist/lib/manifest-disabled.d.ts +13 -0
  128. package/dist/lib/manifest-disabled.js +19 -0
  129. package/dist/lib/service-manager/launchd.d.ts +1 -0
  130. package/dist/lib/service-manager/launchd.js +1 -1
  131. package/dist/lib/windows/console-encoding.d.ts +24 -0
  132. package/dist/lib/windows/console-encoding.js +83 -0
  133. package/dist/lib/windows/install-followup.d.ts +35 -0
  134. package/dist/lib/windows/install-followup.js +142 -0
  135. package/dist/lib/windows/user-path.d.ts +125 -0
  136. package/dist/lib/windows/user-path.js +275 -0
  137. package/dist/lib/work-context/auto-bind.d.ts +1 -1
  138. package/dist/lib/work-context/auto-bind.js +22 -3
  139. package/dist/lib/work-context/lane-bind.d.ts +31 -0
  140. package/dist/lib/work-context/lane-bind.js +101 -0
  141. package/dist/lib/work-context/organize.js +1 -0
  142. package/dist/lib/work-context/prd-sync.js +7 -0
  143. package/dist/lib/work-context/project.d.ts +36 -10
  144. package/dist/lib/work-context/project.js +130 -41
  145. package/dist/lib/work-context/reconcile.d.ts +6 -0
  146. package/dist/lib/work-context/reconcile.js +25 -5
  147. package/dist/lib/work-context/state.d.ts +6 -0
  148. package/dist/lib/work-context/state.js +29 -1
  149. package/dist/main.js +2 -1
  150. package/dist/unknown-command.d.ts +18 -0
  151. package/dist/unknown-command.js +88 -0
  152. package/dist/utils/cognito-session.d.ts +8 -0
  153. package/dist/utils/cognito-session.js +14 -2
  154. package/dist/utils/copy-tree.d.ts +61 -0
  155. package/dist/utils/copy-tree.js +249 -0
  156. package/dist/utils/environmental-error.js +7 -3
  157. package/dist/utils/hook-trust.d.ts +9 -0
  158. package/dist/utils/hook-trust.js +30 -2
  159. package/dist/utils/npm-invocation.d.ts +27 -0
  160. package/dist/utils/npm-invocation.js +54 -0
  161. package/dist/utils/qmd-native-binding-error.js +3 -1
  162. package/dist/utils/resolve-executable.d.ts +2 -0
  163. package/dist/utils/resolve-executable.js +6 -2
  164. package/dist/utils/safe-link.d.ts +50 -0
  165. package/dist/utils/safe-link.js +300 -0
  166. package/package.json +17 -5
  167. package/scripts/hook-engine-manifest.mjs +22 -0
  168. package/scripts/install-hook-engine.mjs +10 -0
@@ -29,6 +29,12 @@
29
29
  * keep`, no hook-file rewrite. Leftover shards
30
30
  * without a local tree stay unfixed (the check reports
31
31
  * NA).
32
+ * - `refresh-manifest` — re-run `hq sync manifest --scope <slug|personal>`
33
+ * for a manifest scope whose last upload is past the
34
+ * stale threshold. Upload-only metadata (file paths
35
+ * and hashes); it never writes the HQ tree. A company
36
+ * scope whose slug did not resolve locally stays
37
+ * manual-only.
32
38
  *
33
39
  * EVERYTHING else — content drift between platform copies, a missing script, a
34
40
  * missing Codex counterpart, an unquoted `$CLAUDE_PROJECT_DIR`, a stale
@@ -38,7 +44,7 @@
38
44
  * content-drift finding is manual-only, because that boundary is load-bearing:
39
45
  * deciding which of two diverged copies is correct needs human judgement.
40
46
  */
41
- import { isCompanyJournalSlug, isPersonalJournalSlug, journalSlugFromCheckId, } from "../checks/sync-health.js";
47
+ import { isCompanyJournalSlug, isSafeCompanySlug, isPersonalJournalSlug, journalSlugFromCheckId, } from "../checks/sync-health.js";
42
48
  /** Matches an exec-bit remediation command, quoted or not: `chmod +x <path>`. */
43
49
  const CHMOD_EXEC = /^chmod\s+\+x\s+(.+)$/;
44
50
  /**
@@ -171,6 +177,22 @@ export function deriveRemediation(result) {
171
177
  command: `hq sync pull --company ${journalSlug} --on-conflict keep`,
172
178
  };
173
179
  }
180
+ // refresh-manifest — a stale manifest upload. The check sets `target` to the
181
+ // `--scope` value only when it resolved one it can name safely.
182
+ if (checkId.startsWith("sync.manifest.") &&
183
+ result.status === "WARN" &&
184
+ result.reasonCode === "stale-threshold" &&
185
+ target &&
186
+ (target === "personal" || isSafeCompanySlug(target))) {
187
+ const scopeLabel = target === "personal" ? "the personal vault" : `company '${target}'`;
188
+ return {
189
+ autoFixable: true,
190
+ fixClass: "refresh-manifest",
191
+ fixTarget: target,
192
+ action: `Upload a fresh sync manifest for ${scopeLabel}.`,
193
+ command: `hq sync manifest --scope ${target}`,
194
+ };
195
+ }
174
196
  // Manual-only: content drift, missing scripts/counterparts, unquoted
175
197
  // expansions, stale allowed-divergence entries, invalid settings, etc. `--fix`
176
198
  // never touches these.
@@ -14,6 +14,7 @@
14
14
  * later stories add per-item results to this family without touching the engine.
15
15
  */
16
16
  import * as fs from "node:fs";
17
+ import { hookEngineFamily } from './checks/hook-engine.js';
17
18
  import * as path from "node:path";
18
19
  import { checkCodexWiring } from "./checks/codex-wiring.js";
19
20
  import { checkGrokWiring } from "./checks/grok-wiring.js";
@@ -27,6 +28,8 @@ import { anywhereFamily } from "./checks/anywhere.js";
27
28
  import { scaffoldCliFloorFamily } from "./checks/scaffold-cli-floor.js";
28
29
  import { strandedLanesFamily } from "./checks/stranded-lanes.js";
29
30
  import { releaseLinksFamily } from "./checks/release-links.js";
31
+ import { cliPathFamily } from "./checks/cli-path.js";
32
+ import { tempHomesFamily } from "./checks/temp-homes.js";
30
33
  import { fixtureCoverageFamily } from "./fixtures/discover.js";
31
34
  import { checkClaudeWiring } from "./checks/claude-wiring.js";
32
35
  /**
@@ -240,6 +243,7 @@ function hasCommandHook(settings, event) {
240
243
  export function createDefaultRegistry() {
241
244
  const registry = new DoctorRegistry();
242
245
  registry.register(hooksFamily);
246
+ registry.register(hookEngineFamily);
243
247
  registry.register(scaffoldCliFloorFamily);
244
248
  // Fixture coverage (US-007): reports UNTESTED for every registered hook with
245
249
  // no fixture and prints the tested/total coverage line. A separate family so
@@ -273,6 +277,12 @@ export function createDefaultRegistry() {
273
277
  registry.register(strandedLanesFamily);
274
278
  // Release symlinks left pointing at a deleted update staging folder.
275
279
  registry.register(releaseLinksFamily);
280
+ // Windows: the npm hq.cmd shim folder must be on PATH for a new terminal to
281
+ // find hq. Registry read only; prints the fix, never edits PATH.
282
+ registry.register(cliPathFamily);
283
+ // Idle tmp.* throwaway homes holding a Rust toolchain download. Dry-run
284
+ // scan only; `hq doctor --sweep-temp-homes` is the delete path.
285
+ registry.register(tempHomesFamily);
276
286
  return registry;
277
287
  }
278
288
  //# sourceMappingURL=registry.js.map
@@ -0,0 +1,149 @@
1
+ /**
2
+ * Sweeper for abandoned throwaway `$HOME` folders.
3
+ *
4
+ * Commands run as `HOME=$(mktemp -d) <cmd>` leave a `tmp.XXXXXXXX` folder in
5
+ * the temp dir. When the command touched Rust or pnpm tooling, that folder
6
+ * holds a full `.rustup` / `.cargo` download (1-2 GB) and nothing deletes it.
7
+ * On 2026-10-18 about 45 GB refilled within hours of a cleanup this way.
8
+ * `core/scripts/with-temp-home.sh` makes `hq-temp-home.XXXXXXXX` folders and
9
+ * removes them on exit, but a wrapper killed with SIGKILL leaves its folder.
10
+ *
11
+ * The sweep is deliberately narrow. A folder is removed only when ALL hold:
12
+ *
13
+ * - it is a direct child of a temp root (TMPDIR, os.tmpdir(), /tmp,
14
+ * /private/tmp, /var/folders/{a}/{b}/T) and its name starts with `tmp.` or
15
+ * `hq-temp-home.`;
16
+ * - `lstat` says it is a real directory (never a symlink) owned by this user;
17
+ * - a `tmp.*` folder holds a `.rustup` or `.cargo` directory (again by
18
+ * `lstat`). An `hq-temp-home.*` folder needs no marker: the name shows the
19
+ * wrapper made it;
20
+ * - nothing inside it, the folder itself included, was modified in the last
21
+ * 90 minutes;
22
+ * - no live process uses it: the wrapper's `.hq-temp-home.lock` does not name
23
+ * a live pid, and no process of this user has its cwd or an open file
24
+ * there (on Linux, or its `HOME` there either).
25
+ *
26
+ * The walk uses `lstat` throughout and never descends into a symlink, so a
27
+ * link inside a temp home that points elsewhere is neither measured nor
28
+ * followed. `fs.rmSync` unlinks symlinks rather than following them. A folder
29
+ * that cannot be fully read is kept, because an incomplete scan cannot prove
30
+ * the folder is idle. When the in-use probe fails, every candidate is kept.
31
+ *
32
+ * `shallow` mode is for plain `hq doctor`, which must stay fast. It checks
33
+ * only the folder and its direct children for recent changes, skips the size
34
+ * walk and the process probe, and reports no byte counts.
35
+ */
36
+ import * as fs from "node:fs";
37
+ /** Folders younger than this (by newest mtime anywhere inside) are kept. */
38
+ export declare const TEMP_HOME_MIN_IDLE_MS: number;
39
+ /** Names that mark a `tmp.*` temp home as holding a toolchain download. */
40
+ export declare const TEMP_HOME_TOOLCHAIN_MARKERS: readonly [".rustup", ".cargo"];
41
+ /** Folders made by `HOME=$(mktemp -d)`; swept only with a toolchain marker. */
42
+ export declare const TEMP_HOME_MKTEMP_PREFIX = "tmp.";
43
+ /** Folders made by `core/scripts/with-temp-home.sh`. */
44
+ export declare const TEMP_HOME_WRAPPER_PREFIX = "hq-temp-home.";
45
+ /** Lock file the wrapper writes into its temp home; holds the wrapper pid. */
46
+ export declare const TEMP_HOME_LOCK_FILE = ".hq-temp-home.lock";
47
+ export type TempHomeAction = "removed" | "would-remove" | "kept" | "failed";
48
+ export type TempHomeKeptReason = "symlink" | "not-directory" | "not-owned" | "no-toolchain-marker" | "recent" | "in-use" | "in-use-unknown" | "unreadable" | "changed-before-delete";
49
+ export interface TempHomeEntry {
50
+ path: string;
51
+ action: TempHomeAction;
52
+ /** On-disk bytes. Present when the folder was fully measured. */
53
+ bytes?: number;
54
+ /** Why the folder was kept. Present only for `kept`. */
55
+ reason?: TempHomeKeptReason;
56
+ /** Error message. Present for `failed`, and for some `kept` entries. */
57
+ error?: string;
58
+ }
59
+ export interface TempHomeSweepResult {
60
+ dryRun: boolean;
61
+ /** True when the sweep ran in shallow mode (no sizes, no process probe). */
62
+ shallow: boolean;
63
+ /** Real paths of the temp roots that were scanned, deduplicated. */
64
+ roots: string[];
65
+ /** Every matching child that was examined, in scan order. */
66
+ entries: TempHomeEntry[];
67
+ /** Bytes removed, or bytes that would be removed in a dry run. 0 when shallow. */
68
+ bytesFreed: number;
69
+ /** `bytesFreed` in GB (10^9 bytes), rounded to two decimals. */
70
+ gbFreed: number;
71
+ }
72
+ /** File system calls the sweeper uses; a seam for tests. */
73
+ export interface TempHomeSweepFs {
74
+ lstatSync(target: string): fs.Stats;
75
+ readdirSync(target: string): string[];
76
+ realpathSync(target: string): string;
77
+ readFileSync(target: string, encoding: "utf8"): string;
78
+ rmSync(target: string, options: {
79
+ recursive: true;
80
+ force: false;
81
+ }): void;
82
+ }
83
+ /**
84
+ * Returns the subset of `homes` that a live process uses: its cwd or an open
85
+ * file is inside the home (and on Linux, its `HOME` is the home). Throws when
86
+ * it cannot tell; the sweep then keeps every candidate.
87
+ */
88
+ export type TempHomeInUseProbe = (homes: readonly string[]) => Set<string>;
89
+ export interface TempHomeSweepOptions {
90
+ /** Temp roots to scan. Defaults to {@link defaultTempRoots}. */
91
+ roots?: readonly string[];
92
+ /** Report what would be removed without deleting anything. */
93
+ dryRun?: boolean;
94
+ /**
95
+ * Cheap scan for plain `hq doctor`: no size walk, no process probe, only
96
+ * the folder and its direct children checked for recent changes. Implies
97
+ * `dryRun`.
98
+ */
99
+ shallow?: boolean;
100
+ /** Current time in ms. Defaults to `Date.now()`. */
101
+ nowMs?: number;
102
+ /** Minimum idle time in ms. Defaults to {@link TEMP_HOME_MIN_IDLE_MS}. */
103
+ minIdleMs?: number;
104
+ /** Owner uid to require. Defaults to `process.getuid()` when it exists. */
105
+ uid?: number;
106
+ fs?: TempHomeSweepFs;
107
+ /** Live-process probe. Defaults to {@link defaultInUseProbe}. */
108
+ inUse?: TempHomeInUseProbe;
109
+ /** Whether a pid is alive. Defaults to `process.kill(pid, 0)`. */
110
+ isPidAlive?: (pid: number) => boolean;
111
+ }
112
+ /**
113
+ * The temp roots a throwaway home can land in: `$TMPDIR`, `os.tmpdir()`,
114
+ * `/tmp`, `/private/tmp`, and every per-user macOS temp dir
115
+ * `/var/folders/{a}/{b}/T`. Missing roots are dropped later by the sweep.
116
+ */
117
+ export declare function defaultTempRoots(env?: NodeJS.ProcessEnv, fsImpl?: Pick<TempHomeSweepFs, "readdirSync">): string[];
118
+ /** `process.kill(pid, 0)`: alive unless the pid does not exist (ESRCH). */
119
+ export declare function defaultIsPidAlive(pid: number): boolean;
120
+ /**
121
+ * True when `value` (a cwd or open-file path, or `HOME`) is `home` or a path
122
+ * inside it. Matched by folder name as a path segment, because the same temp
123
+ * dir is reachable as `/var/folders/...` and `/private/var/folders/...`; the
124
+ * random `mktemp` suffix makes the name unique. Over-matching only keeps more.
125
+ */
126
+ export declare function pathUsesHome(value: string, home: string): boolean;
127
+ /**
128
+ * Default in-use probe. Only processes of `uid` are checked; other users
129
+ * cannot own these folders' contents in a way the sweep would delete.
130
+ *
131
+ * - Linux: `/proc/{pid}/cwd`, every `/proc/{pid}/fd/*` link, and `HOME` from
132
+ * `/proc/{pid}/environ`. Environment values are matched in memory and never
133
+ * logged.
134
+ * - macOS: `lsof -u uid` for every file each process has open, its cwd and
135
+ * its executable included. macOS does not show other processes'
136
+ * environments (`ps -E` leaves them out), so `HOME` itself is not checked
137
+ * there. A process that has HOME set to a temp home but holds nothing open
138
+ * inside it is not seen; the wrapper lock and the 90-minute idle check
139
+ * cover that case.
140
+ */
141
+ export declare function defaultInUseProbe(uid: number | undefined): TempHomeInUseProbe;
142
+ /**
143
+ * Find and (unless `dryRun`) remove idle, unused temp homes. See the module
144
+ * comment for the exact rules.
145
+ */
146
+ export declare function sweepTempHomes(options?: TempHomeSweepOptions): TempHomeSweepResult;
147
+ /** Bytes to GB (10^9 bytes), rounded to two decimals. */
148
+ export declare function toGb(bytes: number): number;
149
+ //# sourceMappingURL=temp-home-sweeper.d.ts.map
@@ -0,0 +1,418 @@
1
+ /**
2
+ * Sweeper for abandoned throwaway `$HOME` folders.
3
+ *
4
+ * Commands run as `HOME=$(mktemp -d) <cmd>` leave a `tmp.XXXXXXXX` folder in
5
+ * the temp dir. When the command touched Rust or pnpm tooling, that folder
6
+ * holds a full `.rustup` / `.cargo` download (1-2 GB) and nothing deletes it.
7
+ * On 2026-10-18 about 45 GB refilled within hours of a cleanup this way.
8
+ * `core/scripts/with-temp-home.sh` makes `hq-temp-home.XXXXXXXX` folders and
9
+ * removes them on exit, but a wrapper killed with SIGKILL leaves its folder.
10
+ *
11
+ * The sweep is deliberately narrow. A folder is removed only when ALL hold:
12
+ *
13
+ * - it is a direct child of a temp root (TMPDIR, os.tmpdir(), /tmp,
14
+ * /private/tmp, /var/folders/{a}/{b}/T) and its name starts with `tmp.` or
15
+ * `hq-temp-home.`;
16
+ * - `lstat` says it is a real directory (never a symlink) owned by this user;
17
+ * - a `tmp.*` folder holds a `.rustup` or `.cargo` directory (again by
18
+ * `lstat`). An `hq-temp-home.*` folder needs no marker: the name shows the
19
+ * wrapper made it;
20
+ * - nothing inside it, the folder itself included, was modified in the last
21
+ * 90 minutes;
22
+ * - no live process uses it: the wrapper's `.hq-temp-home.lock` does not name
23
+ * a live pid, and no process of this user has its cwd or an open file
24
+ * there (on Linux, or its `HOME` there either).
25
+ *
26
+ * The walk uses `lstat` throughout and never descends into a symlink, so a
27
+ * link inside a temp home that points elsewhere is neither measured nor
28
+ * followed. `fs.rmSync` unlinks symlinks rather than following them. A folder
29
+ * that cannot be fully read is kept, because an incomplete scan cannot prove
30
+ * the folder is idle. When the in-use probe fails, every candidate is kept.
31
+ *
32
+ * `shallow` mode is for plain `hq doctor`, which must stay fast. It checks
33
+ * only the folder and its direct children for recent changes, skips the size
34
+ * walk and the process probe, and reports no byte counts.
35
+ */
36
+ import { spawnSync } from "node:child_process";
37
+ import * as fs from "node:fs";
38
+ import * as os from "node:os";
39
+ import * as path from "node:path";
40
+ /** Folders younger than this (by newest mtime anywhere inside) are kept. */
41
+ export const TEMP_HOME_MIN_IDLE_MS = 90 * 60 * 1000;
42
+ /** Names that mark a `tmp.*` temp home as holding a toolchain download. */
43
+ export const TEMP_HOME_TOOLCHAIN_MARKERS = [".rustup", ".cargo"];
44
+ /** Folders made by `HOME=$(mktemp -d)`; swept only with a toolchain marker. */
45
+ export const TEMP_HOME_MKTEMP_PREFIX = "tmp.";
46
+ /** Folders made by `core/scripts/with-temp-home.sh`. */
47
+ export const TEMP_HOME_WRAPPER_PREFIX = "hq-temp-home.";
48
+ /** Lock file the wrapper writes into its temp home; holds the wrapper pid. */
49
+ export const TEMP_HOME_LOCK_FILE = ".hq-temp-home.lock";
50
+ const BYTES_PER_GB = 1_000_000_000;
51
+ const nodeFs = {
52
+ lstatSync: (target) => fs.lstatSync(target),
53
+ readdirSync: (target) => fs.readdirSync(target),
54
+ realpathSync: (target) => fs.realpathSync(target),
55
+ readFileSync: (target, encoding) => fs.readFileSync(target, encoding),
56
+ rmSync: (target, options) => fs.rmSync(target, options),
57
+ };
58
+ /**
59
+ * The temp roots a throwaway home can land in: `$TMPDIR`, `os.tmpdir()`,
60
+ * `/tmp`, `/private/tmp`, and every per-user macOS temp dir
61
+ * `/var/folders/{a}/{b}/T`. Missing roots are dropped later by the sweep.
62
+ */
63
+ export function defaultTempRoots(env = process.env, fsImpl = nodeFs) {
64
+ const roots = [];
65
+ if (env.TMPDIR)
66
+ roots.push(env.TMPDIR);
67
+ roots.push(os.tmpdir(), "/tmp", "/private/tmp");
68
+ for (const a of safeReaddir(fsImpl, "/var/folders")) {
69
+ for (const b of safeReaddir(fsImpl, path.join("/var/folders", a))) {
70
+ roots.push(path.join("/var/folders", a, b, "T"));
71
+ }
72
+ }
73
+ return roots;
74
+ }
75
+ function safeReaddir(fsImpl, target) {
76
+ try {
77
+ return fsImpl.readdirSync(target);
78
+ }
79
+ catch {
80
+ return [];
81
+ }
82
+ }
83
+ function errorMessage(error) {
84
+ return error instanceof Error ? error.message : String(error);
85
+ }
86
+ /** `process.kill(pid, 0)`: alive unless the pid does not exist (ESRCH). */
87
+ export function defaultIsPidAlive(pid) {
88
+ try {
89
+ process.kill(pid, 0);
90
+ return true;
91
+ }
92
+ catch (error) {
93
+ return error.code === "EPERM";
94
+ }
95
+ }
96
+ /**
97
+ * True when `value` (a cwd or open-file path, or `HOME`) is `home` or a path
98
+ * inside it. Matched by folder name as a path segment, because the same temp
99
+ * dir is reachable as `/var/folders/...` and `/private/var/folders/...`; the
100
+ * random `mktemp` suffix makes the name unique. Over-matching only keeps more.
101
+ */
102
+ export function pathUsesHome(value, home) {
103
+ const name = path.basename(home);
104
+ if (value === name)
105
+ return true;
106
+ const marker = `/${name}`;
107
+ let index = value.indexOf(marker);
108
+ while (index !== -1) {
109
+ const next = value[index + marker.length];
110
+ if (next === undefined || next === "/")
111
+ return true;
112
+ index = value.indexOf(marker, index + 1);
113
+ }
114
+ return false;
115
+ }
116
+ function runProbe(command, args) {
117
+ const result = spawnSync(command, args, { encoding: "utf8", maxBuffer: 256 * 1024 * 1024 });
118
+ if (result.error)
119
+ throw result.error;
120
+ if (result.status !== 0 && !result.stdout) {
121
+ throw new Error(`${command} exited ${result.status ?? result.signal}`);
122
+ }
123
+ return result.stdout;
124
+ }
125
+ /**
126
+ * Default in-use probe. Only processes of `uid` are checked; other users
127
+ * cannot own these folders' contents in a way the sweep would delete.
128
+ *
129
+ * - Linux: `/proc/{pid}/cwd`, every `/proc/{pid}/fd/*` link, and `HOME` from
130
+ * `/proc/{pid}/environ`. Environment values are matched in memory and never
131
+ * logged.
132
+ * - macOS: `lsof -u uid` for every file each process has open, its cwd and
133
+ * its executable included. macOS does not show other processes'
134
+ * environments (`ps -E` leaves them out), so `HOME` itself is not checked
135
+ * there. A process that has HOME set to a temp home but holds nothing open
136
+ * inside it is not seen; the wrapper lock and the 90-minute idle check
137
+ * cover that case.
138
+ */
139
+ export function defaultInUseProbe(uid) {
140
+ return (homes) => {
141
+ const used = new Set();
142
+ if (homes.length === 0)
143
+ return used;
144
+ const values = [];
145
+ if (process.platform === "linux") {
146
+ for (const pid of fs.readdirSync("/proc").filter((name) => /^\d+$/.test(name))) {
147
+ try {
148
+ if (uid !== undefined && fs.statSync(`/proc/${pid}`).uid !== uid)
149
+ continue;
150
+ }
151
+ catch {
152
+ continue;
153
+ }
154
+ try {
155
+ values.push(fs.readlinkSync(`/proc/${pid}/cwd`));
156
+ }
157
+ catch {
158
+ // The process exited or the link is unreadable.
159
+ }
160
+ try {
161
+ for (const fd of fs.readdirSync(`/proc/${pid}/fd`)) {
162
+ try {
163
+ values.push(fs.readlinkSync(`/proc/${pid}/fd/${fd}`));
164
+ }
165
+ catch {
166
+ // The descriptor closed.
167
+ }
168
+ }
169
+ }
170
+ catch {
171
+ // Same.
172
+ }
173
+ try {
174
+ for (const entry of fs.readFileSync(`/proc/${pid}/environ`, "utf8").split("\0")) {
175
+ if (entry.startsWith("HOME="))
176
+ values.push(entry.slice(5));
177
+ }
178
+ }
179
+ catch {
180
+ // Same.
181
+ }
182
+ }
183
+ }
184
+ else {
185
+ const userArgs = uid !== undefined ? ["-u", String(uid)] : [];
186
+ // -w: no warnings; -n and -P: no host or port name lookups.
187
+ const lsof = runProbe("lsof", ["-w", "-n", "-P", "-Fn", ...userArgs]);
188
+ for (const line of lsof.split("\n")) {
189
+ if (line.startsWith("n"))
190
+ values.push(line.slice(1));
191
+ }
192
+ }
193
+ for (const home of homes) {
194
+ if (values.some((value) => pathUsesHome(value, home)))
195
+ used.add(home);
196
+ }
197
+ return used;
198
+ };
199
+ }
200
+ /** On-disk size of one lstat result: allocated blocks when known, else size. */
201
+ function diskBytes(stat) {
202
+ return typeof stat.blocks === "number" && stat.blocks >= 0 ? stat.blocks * 512 : stat.size;
203
+ }
204
+ /**
205
+ * Measure a folder with `lstat`, never descending into symlinks. Stops as soon
206
+ * as it sees an entry modified after `cutoffMs`, since such a folder is kept
207
+ * and its size is not needed. With `maxDepth` 1 it reads only direct children.
208
+ */
209
+ function walk(fsImpl, dir, dirStat, cutoffMs, maxDepth = Number.POSITIVE_INFINITY) {
210
+ if (dirStat.mtimeMs > cutoffMs)
211
+ return { complete: true, recent: true, bytes: 0 };
212
+ let bytes = diskBytes(dirStat);
213
+ const stack = [{ dir, depth: 0 }];
214
+ while (stack.length > 0) {
215
+ const current = stack.pop();
216
+ let names;
217
+ try {
218
+ names = fsImpl.readdirSync(current.dir);
219
+ }
220
+ catch {
221
+ return { complete: false, recent: false, bytes };
222
+ }
223
+ for (const name of names) {
224
+ const child = path.join(current.dir, name);
225
+ let stat;
226
+ try {
227
+ stat = fsImpl.lstatSync(child);
228
+ }
229
+ catch {
230
+ return { complete: false, recent: false, bytes };
231
+ }
232
+ if (stat.mtimeMs > cutoffMs)
233
+ return { complete: true, recent: true, bytes };
234
+ bytes += diskBytes(stat);
235
+ if (stat.isDirectory() && !stat.isSymbolicLink() && current.depth + 1 < maxDepth) {
236
+ stack.push({ dir: child, depth: current.depth + 1 });
237
+ }
238
+ }
239
+ }
240
+ return { complete: true, recent: false, bytes };
241
+ }
242
+ function hasToolchainMarker(fsImpl, dir) {
243
+ return TEMP_HOME_TOOLCHAIN_MARKERS.some((marker) => {
244
+ try {
245
+ const stat = fsImpl.lstatSync(path.join(dir, marker));
246
+ return stat.isDirectory() && !stat.isSymbolicLink();
247
+ }
248
+ catch {
249
+ return false;
250
+ }
251
+ });
252
+ }
253
+ /** True when the wrapper's lock file in `dir` names a live pid. */
254
+ function lockHeldByLiveProcess(fsImpl, dir, isPidAlive) {
255
+ let text;
256
+ try {
257
+ const lock = path.join(dir, TEMP_HOME_LOCK_FILE);
258
+ if (!fsImpl.lstatSync(lock).isFile())
259
+ return false;
260
+ text = fsImpl.readFileSync(lock, "utf8");
261
+ }
262
+ catch {
263
+ return false;
264
+ }
265
+ const pid = Number.parseInt(text.trim(), 10);
266
+ return Number.isInteger(pid) && pid > 0 && isPidAlive(pid);
267
+ }
268
+ function resolveRoots(fsImpl, roots) {
269
+ const seen = new Set();
270
+ const resolved = [];
271
+ for (const root of roots) {
272
+ let real;
273
+ try {
274
+ real = fsImpl.realpathSync(root);
275
+ if (!fsImpl.lstatSync(real).isDirectory())
276
+ continue;
277
+ }
278
+ catch {
279
+ continue;
280
+ }
281
+ if (seen.has(real))
282
+ continue;
283
+ seen.add(real);
284
+ resolved.push(real);
285
+ }
286
+ return resolved;
287
+ }
288
+ /**
289
+ * Find and (unless `dryRun`) remove idle, unused temp homes. See the module
290
+ * comment for the exact rules.
291
+ */
292
+ export function sweepTempHomes(options = {}) {
293
+ const fsImpl = options.fs ?? nodeFs;
294
+ const shallow = options.shallow === true;
295
+ const dryRun = shallow || options.dryRun === true;
296
+ const nowMs = options.nowMs ?? Date.now();
297
+ const cutoffMs = nowMs - (options.minIdleMs ?? TEMP_HOME_MIN_IDLE_MS);
298
+ const uid = options.uid ?? (typeof process.getuid === "function" ? process.getuid() : undefined);
299
+ const isPidAlive = options.isPidAlive ?? defaultIsPidAlive;
300
+ const roots = resolveRoots(fsImpl, options.roots ?? defaultTempRoots());
301
+ // Pass 1: decide per folder from the file system alone.
302
+ const entries = [];
303
+ const candidates = [];
304
+ for (const root of roots) {
305
+ const names = safeReaddir(fsImpl, root)
306
+ .filter((name) => name.startsWith(TEMP_HOME_MKTEMP_PREFIX) || name.startsWith(TEMP_HOME_WRAPPER_PREFIX))
307
+ .sort();
308
+ for (const name of names) {
309
+ const candidate = path.join(root, name);
310
+ let stat;
311
+ try {
312
+ stat = fsImpl.lstatSync(candidate);
313
+ }
314
+ catch (error) {
315
+ entries.push({ path: candidate, action: "kept", reason: "unreadable", error: errorMessage(error) });
316
+ continue;
317
+ }
318
+ if (stat.isSymbolicLink()) {
319
+ entries.push({ path: candidate, action: "kept", reason: "symlink" });
320
+ continue;
321
+ }
322
+ if (!stat.isDirectory()) {
323
+ entries.push({ path: candidate, action: "kept", reason: "not-directory" });
324
+ continue;
325
+ }
326
+ if (uid !== undefined && stat.uid !== uid) {
327
+ entries.push({ path: candidate, action: "kept", reason: "not-owned" });
328
+ continue;
329
+ }
330
+ if (name.startsWith(TEMP_HOME_MKTEMP_PREFIX) && !hasToolchainMarker(fsImpl, candidate)) {
331
+ entries.push({ path: candidate, action: "kept", reason: "no-toolchain-marker" });
332
+ continue;
333
+ }
334
+ const measured = walk(fsImpl, candidate, stat, cutoffMs, shallow ? 1 : undefined);
335
+ if (measured.recent) {
336
+ entries.push({ path: candidate, action: "kept", reason: "recent" });
337
+ continue;
338
+ }
339
+ if (!measured.complete) {
340
+ entries.push({ path: candidate, action: "kept", reason: "unreadable" });
341
+ continue;
342
+ }
343
+ if (lockHeldByLiveProcess(fsImpl, candidate, isPidAlive)) {
344
+ entries.push({ path: candidate, action: "kept", reason: "in-use" });
345
+ continue;
346
+ }
347
+ candidates.push({ path: candidate, stat, bytes: shallow ? undefined : measured.bytes });
348
+ }
349
+ }
350
+ // Pass 2: one process probe for every remaining candidate. Shallow mode
351
+ // only reports, so it skips the probe; the full sweep runs it.
352
+ let inUse = new Set();
353
+ if (!shallow && candidates.length > 0) {
354
+ const probe = options.inUse ?? defaultInUseProbe(uid);
355
+ try {
356
+ inUse = probe(candidates.map((candidate) => candidate.path));
357
+ }
358
+ catch (error) {
359
+ const message = errorMessage(error);
360
+ for (const candidate of candidates) {
361
+ entries.push({ path: candidate.path, action: "kept", reason: "in-use-unknown", error: message });
362
+ }
363
+ return finish(entries, roots, dryRun, shallow);
364
+ }
365
+ }
366
+ // Pass 3: remove, or report what would be removed.
367
+ for (const candidate of candidates) {
368
+ if (inUse.has(candidate.path)) {
369
+ entries.push({ path: candidate.path, action: "kept", reason: "in-use" });
370
+ continue;
371
+ }
372
+ if (dryRun) {
373
+ entries.push(withBytes({ path: candidate.path, action: "would-remove" }, candidate.bytes));
374
+ continue;
375
+ }
376
+ // Re-check right before deleting: the path must still be the same real
377
+ // directory, untouched since it was measured, and not newly locked.
378
+ let again;
379
+ try {
380
+ again = fsImpl.lstatSync(candidate.path);
381
+ }
382
+ catch (error) {
383
+ entries.push({ path: candidate.path, action: "kept", reason: "changed-before-delete", error: errorMessage(error) });
384
+ continue;
385
+ }
386
+ if (again.isSymbolicLink() ||
387
+ !again.isDirectory() ||
388
+ again.ino !== candidate.stat.ino ||
389
+ again.dev !== candidate.stat.dev ||
390
+ again.mtimeMs > cutoffMs ||
391
+ lockHeldByLiveProcess(fsImpl, candidate.path, isPidAlive)) {
392
+ entries.push({ path: candidate.path, action: "kept", reason: "changed-before-delete" });
393
+ continue;
394
+ }
395
+ try {
396
+ fsImpl.rmSync(candidate.path, { recursive: true, force: false });
397
+ entries.push(withBytes({ path: candidate.path, action: "removed" }, candidate.bytes));
398
+ }
399
+ catch (error) {
400
+ entries.push(withBytes({ path: candidate.path, action: "failed", error: errorMessage(error) }, candidate.bytes));
401
+ }
402
+ }
403
+ return finish(entries, roots, dryRun, shallow);
404
+ }
405
+ function withBytes(entry, bytes) {
406
+ return bytes === undefined ? entry : { ...entry, bytes };
407
+ }
408
+ function finish(entries, roots, dryRun, shallow) {
409
+ const bytesFreed = entries
410
+ .filter((entry) => entry.action === "removed" || entry.action === "would-remove")
411
+ .reduce((sum, entry) => sum + (entry.bytes ?? 0), 0);
412
+ return { dryRun, shallow, roots, entries, bytesFreed, gbFreed: toGb(bytesFreed) };
413
+ }
414
+ /** Bytes to GB (10^9 bytes), rounded to two decimals. */
415
+ export function toGb(bytes) {
416
+ return Math.round((bytes / BYTES_PER_GB) * 100) / 100;
417
+ }
418
+ //# sourceMappingURL=temp-home-sweeper.js.map