@indigoai-us/hq-cli 5.345.189 → 5.345.190

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,40 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.345.190] — 2026-11-01
6
+ - `hq files purge <path> --version-id <id>` permanently deletes one old version of a vault file, after a confirmation prompt.
7
+ - `hq bot create` / `hq bot start` on Windows: the shared HQ Fleet Runtime now
8
+ installs under `%USERPROFILE%\.hq-fleet\runtimes\<16 hex>\{v,s\r,s\d}` instead
9
+ of the 64-character identity plus `sources\runtime`, so the runtime's deepest
10
+ files stay under the 260-character Windows path limit when long paths are
11
+ off. Before, extraction stopped partway with "HQ Fleet Local runtime
12
+ extraction failed." Runtimes already installed under the old layout keep
13
+ working, and a half-extracted old one is removed. macOS and Linux paths are
14
+ unchanged.
15
+ - HQ Fleet Local install steps now include the last lines of the failing
16
+ command's stderr in their error, and on Windows the CLI checks the runtime
17
+ archive's longest path before extracting and names the path limit and
18
+ `LongPathsEnabled` setting when it would not fit.
19
+ - The CLI now warns on stderr when the HQ Fleet Local release manifest's
20
+ `compatibility.minimum_hq_cli_version` is newer than the running CLI. It does
21
+ not block the install.
22
+ - `hq-hook`: the `block-credential-file-read` port follows the fixed bash guard
23
+ (hq-core-staging #1753, sha256 7c6a8cfa) and its pin moves to that version.
24
+ Zero-padded brace ranges are read in base 10 and long or stepped ranges
25
+ block; letter ranges expand exactly; the mktemp scan replacement is literal
26
+ (no `patsub_replacement`) with capped loops; `function name { ... }` bodies
27
+ are analysed; extglob groups are an unknown word part; and `ls`, `stat` and
28
+ the other metadata commands are not trusted after an alias, hash, enable,
29
+ PATH change, a write into a bin directory, or when named by a path outside
30
+ /bin, /usr/bin, /sbin and /usr/sbin. The port also picks up the guard's
31
+ cygpath and `upstream-pin.env` changes. The vendored fixture and corpus are
32
+ refreshed. The port flag stays off.
33
+ - `hq sync now`: when the push leg holds files after an account sign-in change
34
+ on this computer, the push line now shows the held count, prints how to
35
+ release them (`hq sync held`, `hq sync release-held`), skips the "Sync now
36
+ complete" line and exits 1. Before, it printed "0 uploaded, 0 skipped" and a
37
+ success line (feedback_2356e54f).
38
+
5
39
  ## [5.345.189] — 2026-11-01
6
40
  - A local bot that cannot reach HQ when it starts now retries the identity check up to five times
7
41
  (waits of 5 s, 15 s, 45 s and 2 min) on a network error (connection refused, reset, DNS failure,
@@ -3243,6 +3243,35 @@ export declare const COMMAND_CATALOG: readonly [{
3243
3243
  readonly hidden: false;
3244
3244
  }];
3245
3245
  readonly subcommands: readonly [];
3246
+ }, {
3247
+ readonly name: "purge";
3248
+ readonly description: "Permanently delete ONE retained (noncurrent) version of an exact vault key, for example a version that held data which must not live in HQ. Owner only; audited; cannot be undone. Delete the file first if the version is current. Use --personal for your personal vault.";
3249
+ readonly aliases: readonly [];
3250
+ readonly hidden: false;
3251
+ readonly helpOption: true;
3252
+ readonly helpCommand: false;
3253
+ readonly usage: "[options] <path>";
3254
+ readonly helpText: readonly [];
3255
+ readonly arguments: readonly [{
3256
+ readonly name: "path";
3257
+ readonly required: true;
3258
+ readonly variadic: false;
3259
+ readonly description: "";
3260
+ }];
3261
+ readonly options: readonly [{
3262
+ readonly flags: "--personal";
3263
+ readonly description: "Target your own personal vault instead of a company vault (mutually exclusive with --company)";
3264
+ readonly hidden: false;
3265
+ }, {
3266
+ readonly flags: "--version-id <id>";
3267
+ readonly description: "Exact version ID to purge (see `hq files versions`)";
3268
+ readonly hidden: false;
3269
+ }, {
3270
+ readonly flags: "-y, --yes";
3271
+ readonly description: "Skip the confirmation prompt (for scripts)";
3272
+ readonly hidden: false;
3273
+ }];
3274
+ readonly subcommands: readonly [];
3246
3275
  }, {
3247
3276
  readonly name: "trash";
3248
3277
  readonly description: "List deleted vault keys retained as tombstones. Use --personal for your personal vault.";
@@ -3912,6 +3912,42 @@ export const COMMAND_CATALOG = [
3912
3912
  ],
3913
3913
  "subcommands": []
3914
3914
  },
3915
+ {
3916
+ "name": "purge",
3917
+ "description": "Permanently delete ONE retained (noncurrent) version of an exact vault key, for example a version that held data which must not live in HQ. Owner only; audited; cannot be undone. Delete the file first if the version is current. Use --personal for your personal vault.",
3918
+ "aliases": [],
3919
+ "hidden": false,
3920
+ "helpOption": true,
3921
+ "helpCommand": false,
3922
+ "usage": "[options] <path>",
3923
+ "helpText": [],
3924
+ "arguments": [
3925
+ {
3926
+ "name": "path",
3927
+ "required": true,
3928
+ "variadic": false,
3929
+ "description": ""
3930
+ }
3931
+ ],
3932
+ "options": [
3933
+ {
3934
+ "flags": "--personal",
3935
+ "description": "Target your own personal vault instead of a company vault (mutually exclusive with --company)",
3936
+ "hidden": false
3937
+ },
3938
+ {
3939
+ "flags": "--version-id <id>",
3940
+ "description": "Exact version ID to purge (see `hq files versions`)",
3941
+ "hidden": false
3942
+ },
3943
+ {
3944
+ "flags": "-y, --yes",
3945
+ "description": "Skip the confirmation prompt (for scripts)",
3946
+ "hidden": false
3947
+ }
3948
+ ],
3949
+ "subcommands": []
3950
+ },
3915
3951
  {
3916
3952
  "name": "trash",
3917
3953
  "description": "List deleted vault keys retained as tombstones. Use --personal for your personal vault.",
@@ -2351,12 +2351,19 @@ async function runNowSingle(hqRoot, company, personal, message, onConflict, mode
2351
2351
  }
2352
2352
  const pushProgressCompany = personalMode ? null : (pushSlug ?? null);
2353
2353
  const planLimitTracker = createPlanLimitRefusalTracker(companySlugForBilling(pushProgressCompany));
2354
+ // feedback_2356e54f: the push leg held files after an account switch and
2355
+ // still printed "0 uploaded, 0 skipped" and "Sync now complete".
2356
+ const pushIdentityHeld = createIdentityHeldTracker();
2357
+ const pushRecord = trackingProgressRecorder(planLimitTracker, pushProgressCompany);
2354
2358
  const pushResult = await share({
2355
2359
  paths: pushPaths,
2356
2360
  company: targetCompany,
2357
2361
  vaultConfig,
2358
2362
  hqRoot,
2359
- progressRecorder: trackingProgressRecorder(planLimitTracker, pushProgressCompany),
2363
+ progressRecorder: (event) => {
2364
+ pushIdentityHeld.observe(event);
2365
+ pushRecord(event);
2366
+ },
2360
2367
  skipUnchanged: true,
2361
2368
  serverOwnedPathPolicy: "skip",
2362
2369
  propagateDeletes: true,
@@ -2370,7 +2377,9 @@ async function runNowSingle(hqRoot, company, personal, message, onConflict, mode
2370
2377
  });
2371
2378
  const pushRefused = planLimitTracker.summary();
2372
2379
  const pushUploadFailed = readUploadFailures(pushResult);
2380
+ const pushHeldCount = pushIdentityHeld.count();
2373
2381
  const pushStatus = pushResult.aborted ||
2382
+ pushHeldCount > 0 ||
2374
2383
  pushResult.filesExcludedByScope > 0 ||
2375
2384
  (pushResult.filesRefusedStale ?? 0) > 0 ||
2376
2385
  pushUploadFailed.count > 0 ||
@@ -2380,6 +2389,7 @@ async function runNowSingle(hqRoot, company, personal, message, onConflict, mode
2380
2389
  console.log(` ${pushStatus} ` +
2381
2390
  `${pushResult.filesUploaded} uploaded, ${pushResult.filesSkipped} skipped, ` +
2382
2391
  `${pushResult.filesDeleted} deleted, ${pushResult.filesExcludedByScope} scope-excluded` +
2392
+ (pushHeldCount > 0 ? `, ${pushHeldCount} held` : "") +
2383
2393
  (pushResult.aborted ? " — aborted" : "") +
2384
2394
  (pushRefused
2385
2395
  ? ` — ${describeRefusedCount(pushRefused)} refused (new files paused)`
@@ -2397,6 +2407,9 @@ async function runNowSingle(hqRoot, company, personal, message, onConflict, mode
2397
2407
  for (const line of uploadFailedLines(pushUploadFailed.count, pushUploadFailed.paths)) {
2398
2408
  console.log(chalk.yellow(line));
2399
2409
  }
2410
+ for (const line of identityHeldLines(pushHeldCount)) {
2411
+ console.log(chalk.yellow(line));
2412
+ }
2400
2413
  if (pushResult.aborted) {
2401
2414
  console.log(chalk.yellow("\n⚠ Sync now aborted on push leg; pull skipped."));
2402
2415
  throw new SyncExitError(1);
@@ -2519,7 +2532,8 @@ async function runNowSingle(hqRoot, company, personal, message, onConflict, mode
2519
2532
  }
2520
2533
  if (pushResult.filesExcludedByScope > 0 ||
2521
2534
  (pushResult.filesRefusedStale ?? 0) > 0 ||
2522
- pushUploadFailed.count > 0) {
2535
+ pushUploadFailed.count > 0 ||
2536
+ pushHeldCount > 0) {
2523
2537
  console.log(chalk.yellow("\n⚠ Sync now finished, but one or more requested uploads were refused."));
2524
2538
  throw new SyncExitError(1, { health: "succeeded" });
2525
2539
  }
@@ -242,6 +242,24 @@ export declare function runFilesRestore(params: {
242
242
  }, deps?: {
243
243
  confirm?: ConfirmFn;
244
244
  }): Promise<FilesRestoreResponse | undefined>;
245
+ export interface FilesPurgeResponse {
246
+ key: string;
247
+ purgedVersionId: string;
248
+ }
249
+ /**
250
+ * feedback_fc711fc3: permanently delete one noncurrent version. The server
251
+ * (hq-pro POST /v1/files/restore, action "purge") enforces owner-only access,
252
+ * refuses the current version and delete markers, and audits before deleting.
253
+ */
254
+ export declare function runFilesPurge(params: {
255
+ key: string;
256
+ versionId: string;
257
+ yes: boolean;
258
+ personal: boolean;
259
+ companySlug: string | undefined;
260
+ }, deps?: {
261
+ confirm?: ConfirmFn;
262
+ }): Promise<FilesPurgeResponse | undefined>;
245
263
  export declare function runFilesTrash(params: {
246
264
  prefix: string;
247
265
  cursor?: string;
@@ -731,6 +731,30 @@ export function registerFilesCommand(program) {
731
731
  process.exit(1);
732
732
  }
733
733
  });
734
+ files
735
+ .command("purge <path>")
736
+ .description("Permanently delete ONE retained (noncurrent) version of an exact vault key, for example a version that held data which must not live in HQ. Owner only; audited; cannot be undone. Delete the file first if the version is current. Use --personal for your personal vault.")
737
+ .option("--personal", "Target your own personal vault instead of a company vault (mutually exclusive with --company)")
738
+ .requiredOption("--version-id <id>", "Exact version ID to purge (see `hq files versions`)")
739
+ .option("-y, --yes", "Skip the confirmation prompt (for scripts)")
740
+ .action(async (path, opts) => {
741
+ try {
742
+ const companySlug = files.opts().company;
743
+ const personal = opts.personal === true;
744
+ assertRecoveryScope(personal, companySlug);
745
+ await runFilesPurge({
746
+ key: path,
747
+ versionId: opts.versionId,
748
+ yes: opts.yes === true,
749
+ personal,
750
+ companySlug,
751
+ });
752
+ }
753
+ catch (err) {
754
+ console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
755
+ process.exit(1);
756
+ }
757
+ });
734
758
  files
735
759
  .command("trash")
736
760
  .description("List deleted vault keys retained as tombstones. Use --personal for your personal vault.")
@@ -1767,6 +1791,55 @@ export async function runFilesRestore(params, deps = {}) {
1767
1791
  console.log(chalk.dim("New version: " + data.newVersionId));
1768
1792
  return data;
1769
1793
  }
1794
+ /**
1795
+ * feedback_fc711fc3: permanently delete one noncurrent version. The server
1796
+ * (hq-pro POST /v1/files/restore, action "purge") enforces owner-only access,
1797
+ * refuses the current version and delete markers, and audits before deleting.
1798
+ */
1799
+ export async function runFilesPurge(params, deps = {}) {
1800
+ assertExactRecoveryKey(params.key);
1801
+ if (typeof params.versionId !== "string" || params.versionId.trim() === "") {
1802
+ throw new Error("--version-id is required and must not be empty.");
1803
+ }
1804
+ const confirm = deps.confirm ?? realConfirm;
1805
+ if (!params.yes) {
1806
+ const ok = await confirm("Permanently delete version '" + params.versionId + "' of '" + params.key + "'" +
1807
+ (params.personal ? " in your personal vault" : "") +
1808
+ "? This cannot be undone.");
1809
+ if (!ok) {
1810
+ console.log(chalk.dim("Aborted — nothing was purged."));
1811
+ return undefined;
1812
+ }
1813
+ }
1814
+ const scope = await resolveRecoveryScope(params);
1815
+ const body = scope.personal
1816
+ ? { personal: true, key: params.key }
1817
+ : { company: scope.companyUid, key: params.key };
1818
+ body.action = "purge";
1819
+ body.purgeVersionId = params.versionId;
1820
+ // Tripwire: a server without purge support rejects `versionId: null` with
1821
+ // 400 instead of ignoring `action` and restoring (re-publishing) the version.
1822
+ body.versionId = null;
1823
+ const res = await vaultApiFetch({
1824
+ token: scope.token,
1825
+ path: "/v1/files/restore",
1826
+ method: "POST",
1827
+ body,
1828
+ });
1829
+ if (!res.ok) {
1830
+ const errBody = (await res.json().catch(() => ({})));
1831
+ const err = new FilesRecoveryHttpError(res.status, errBody.message ?? errBody.error ?? res.statusText, errBody.code);
1832
+ throw new Error(formatFilesRecoveryError(err, params.key));
1833
+ }
1834
+ const data = (await res.json());
1835
+ // Belt and braces with the tripwire above: only a server that echoes the
1836
+ // purged version id has purged it.
1837
+ if (data.purgedVersionId !== params.versionId) {
1838
+ throw new Error("The server did not confirm the purge (it may not support purge yet). Check `hq files versions` before retrying.");
1839
+ }
1840
+ console.log(chalk.green("Purged version '" + data.purgedVersionId + "' of '" + params.key + "'."));
1841
+ return { key: data.key ?? params.key, purgedVersionId: data.purgedVersionId };
1842
+ }
1770
1843
  export async function runFilesTrash(params) {
1771
1844
  if (params.prefix.includes("*")) {
1772
1845
  throw new Error("Trash prefix must be literal; wildcard expansion is not supported.");
@@ -99,8 +99,34 @@ export interface FleetLocalBootstrapDeps {
99
99
  start?: boolean;
100
100
  python?: FleetPython;
101
101
  }
102
+ interface SharedFleetRuntime {
103
+ id: string;
104
+ root: string;
105
+ runtimeDir: string;
106
+ sourcesDir: string;
107
+ /** Extracted runtime source tree (`sources/runtime`, or `s/r` on Windows). */
108
+ runtimeSource: string;
109
+ /** Extracted distribution tree (`sources/distribution`, or `s/d` on Windows). */
110
+ distributionSource: string;
111
+ }
112
+ /**
113
+ * Hex characters of the runtime identity used for the shared runtime
114
+ * directory name on Windows. The full 64-character identity plus
115
+ * `sources\runtime` pushed the runtime's deepest files past MAX_PATH (260).
116
+ */
117
+ export declare const WINDOWS_RUNTIME_DIR_ID_LENGTH = 16;
118
+ /** Longest path Windows accepts when long paths are turned off (MAX_PATH minus the terminator). */
119
+ export declare const WINDOWS_MAX_PATH_CHARS = 259;
120
+ /**
121
+ * Where a shared runtime lives. macOS and Linux keep the original layout
122
+ * (`runtimes/<64 hex>/{runtime,sources/runtime,sources/distribution}`).
123
+ * Windows uses `runtimes/<16 hex>/{v,s\r,s\d}` so deep runtime files stay
124
+ * under MAX_PATH; `ready.json` records the full identity.
125
+ */
126
+ export declare function sharedRuntimeLayout(root: string, id: string, platform: NodeJS.Platform): SharedFleetRuntime;
102
127
  type FleetLocalProfileDeps = Pick<FleetLocalBootstrapDeps, "fleetHome" | "platform"> & {
103
128
  runtimeRoot?: string;
129
+ runtime?: SharedFleetRuntime;
104
130
  };
105
131
  export interface FleetLocalBootstrapResult {
106
132
  profile: string;
@@ -139,6 +165,12 @@ export declare const HQ_FLEET_LOCAL_RELEASE: FleetLocalReleasePin;
139
165
  export declare const HQ_FLEET_LOCAL_PUBLIC_KEY = "-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEASJoapvo0WMTg+Wu3Jvt0nzlXLOBEr+G1aJRi0JkEb4A=\n-----END PUBLIC KEY-----\n";
140
166
  export declare function fleetHome(home?: string, env?: NodeJS.ProcessEnv): string;
141
167
  export declare function verifyFleetLocalRelease(files: FleetLocalReleaseFiles, release?: FleetLocalReleasePin, publicKey?: string): void;
168
+ /**
169
+ * Warning text when the signed manifest asks for a newer hq CLI than this
170
+ * one, or null. Advisory only: the pinned runtime is still installed, since
171
+ * blocking would strand every user until that CLI version is published.
172
+ */
173
+ export declare function fleetLocalCliCompatibilityWarning(manifest: Buffer, release?: FleetLocalReleasePin, cliVersion?: string): string | null;
142
174
  /** `gh auth token` fallback for the release download token. */
143
175
  export declare const FLEET_RELEASE_TOKEN_TIMEOUT_MS = 15000;
144
176
  export declare function resolveFleetReleaseToken(env?: NodeJS.ProcessEnv, run?: FleetLocalExec): string;
@@ -172,6 +204,14 @@ export declare function writeFleetLocalSoul(agentUid: string, systemPrompt: stri
172
204
  export declare function scanFleetLocalInstructions(agentUid: string, text: string, deps?: FleetLocalControlDeps): string[];
173
205
  /** Checks the instructions with the runtime's scanner, then makes them the bot's persona. */
174
206
  export declare function refreshFleetLocalInstructions(agentUid: string, systemPrompt: string, deps?: FleetLocalControlDeps): string;
207
+ /** Last few non-empty stderr lines of a failed child process, trimmed for an error message. */
208
+ export declare function stderrTail(error: unknown): string;
209
+ /**
210
+ * Message for a Windows extraction that cannot fit under MAX_PATH, or null
211
+ * when it fits or long paths are on. `longest` is the longest member name in
212
+ * the archive; members land at `<destination>\<name>`.
213
+ */
214
+ export declare function windowsExtractionPathProblem(destination: string, longest: number, longPathsEnabled: number | null): string | null;
175
215
  /** Moves a fresh machine credential into the bot's secure store and deletes the file. */
176
216
  export declare function importFleetLocalMachineCredential(agentUid: string, machineCredentials: string, deps?: FleetLocalControlDeps): void;
177
217
  export declare function bootstrapFleetLocalBot(input: FleetLocalProfileInput & {
@@ -6,6 +6,8 @@ import * as path from "node:path";
6
6
  import { HQ_FLEET_RUNTIME_ENGINE_ID, } from "./config.js";
7
7
  import { withBotLock } from "./lock.js";
8
8
  import { ensureFleetPython, fleetUvEnv } from "./fleet-python.js";
9
+ import semver from "semver";
10
+ import { CLI_VERSION } from "../../cli-version.js";
9
11
  import { ensureCognitoToken } from "../../utils/cognito-session.js";
10
12
  import { vaultApiFetch } from "../../utils/vault-api.js";
11
13
  /** The model the HQ Fleet Runtime asks for when a Claude bot names none. */
@@ -34,6 +36,46 @@ export function fleetProviderForRuntime(runtime, model) {
34
36
  export function fleetProviderNeedsSignIn(provider) {
35
37
  return provider.id !== "anthropic";
36
38
  }
39
+ /**
40
+ * Hex characters of the runtime identity used for the shared runtime
41
+ * directory name on Windows. The full 64-character identity plus
42
+ * `sources\runtime` pushed the runtime's deepest files past MAX_PATH (260).
43
+ */
44
+ export const WINDOWS_RUNTIME_DIR_ID_LENGTH = 16;
45
+ /** Longest path Windows accepts when long paths are turned off (MAX_PATH minus the terminator). */
46
+ export const WINDOWS_MAX_PATH_CHARS = 259;
47
+ function legacySharedRuntimeLayout(root, id) {
48
+ const shared = path.join(root, "runtimes", id);
49
+ const sources = path.join(shared, "sources");
50
+ return {
51
+ id,
52
+ root: shared,
53
+ runtimeDir: path.join(shared, "runtime"),
54
+ sourcesDir: sources,
55
+ runtimeSource: path.join(sources, "runtime"),
56
+ distributionSource: path.join(sources, "distribution"),
57
+ };
58
+ }
59
+ /**
60
+ * Where a shared runtime lives. macOS and Linux keep the original layout
61
+ * (`runtimes/<64 hex>/{runtime,sources/runtime,sources/distribution}`).
62
+ * Windows uses `runtimes/<16 hex>/{v,s\r,s\d}` so deep runtime files stay
63
+ * under MAX_PATH; `ready.json` records the full identity.
64
+ */
65
+ export function sharedRuntimeLayout(root, id, platform) {
66
+ if (platform !== "win32")
67
+ return legacySharedRuntimeLayout(root, id);
68
+ const shared = path.join(root, "runtimes", id.slice(0, WINDOWS_RUNTIME_DIR_ID_LENGTH));
69
+ const sources = path.join(shared, "s");
70
+ return {
71
+ id,
72
+ root: shared,
73
+ runtimeDir: path.join(shared, "v"),
74
+ sourcesDir: sources,
75
+ runtimeSource: path.join(sources, "r"),
76
+ distributionSource: path.join(sources, "d"),
77
+ };
78
+ }
37
79
  export const HQ_FLEET_LOCAL_RELEASE = {
38
80
  repository: "indigoai-us/hq-fleet-local",
39
81
  tag: "v0.4.17",
@@ -105,6 +147,29 @@ export function verifyFleetLocalRelease(files, release = HQ_FLEET_LOCAL_RELEASE,
105
147
  throw expectedError(`HQ Fleet Local ${release.version} manifest signature is invalid.`);
106
148
  }
107
149
  }
150
+ /**
151
+ * Warning text when the signed manifest asks for a newer hq CLI than this
152
+ * one, or null. Advisory only: the pinned runtime is still installed, since
153
+ * blocking would strand every user until that CLI version is published.
154
+ */
155
+ export function fleetLocalCliCompatibilityWarning(manifest, release = HQ_FLEET_LOCAL_RELEASE, cliVersion = CLI_VERSION) {
156
+ let minimum;
157
+ try {
158
+ const parsed = JSON.parse(manifest.toString("utf8"));
159
+ minimum = parsed.compatibility?.minimum_hq_cli_version;
160
+ }
161
+ catch {
162
+ return null;
163
+ }
164
+ if (typeof minimum !== "string")
165
+ return null;
166
+ const required = semver.valid(semver.coerce(minimum));
167
+ const current = semver.valid(semver.coerce(cliVersion));
168
+ if (!required || !current || semver.gte(current, required))
169
+ return null;
170
+ return `HQ Fleet Local ${release.version} asks for hq CLI ${minimum} or newer; this is ${cliVersion}. ` +
171
+ "Update with `npm install -g @indigoai-us/hq-cli` if the bot misbehaves.";
172
+ }
108
173
  /** `gh auth token` fallback for the release download token. */
109
174
  export const FLEET_RELEASE_TOKEN_TIMEOUT_MS = 15_000;
110
175
  export function resolveFleetReleaseToken(env = process.env, run = execFileSync) {
@@ -305,8 +370,14 @@ export async function installPinnedFleetLocalDistribution(deps = {}) {
305
370
  const root = deps.fleetHome ?? fleetHome();
306
371
  const dir = path.join(root, "distributions", "hq-fleet-local", release.version);
307
372
  const current = installedFiles(dir, release);
373
+ const warnCompatibility = (manifest) => {
374
+ const warning = fleetLocalCliCompatibilityWarning(manifest, release);
375
+ if (warning)
376
+ process.stderr.write(`Warning: ${warning}\n`);
377
+ };
308
378
  if (current) {
309
379
  verifyFleetLocalRelease(current, release, publicKey);
380
+ warnCompatibility(current.manifest);
310
381
  }
311
382
  else {
312
383
  const fetcher = deps.fetch ?? fetch;
@@ -316,6 +387,7 @@ export async function installPinnedFleetLocalDistribution(deps = {}) {
316
387
  ? await downloadFleetLocalReleaseFromHq(release, fetcher, links, cachedRuntime)
317
388
  : await downloadFleetLocalRelease(release, fetcher, (deps.token ?? resolveFleetReleaseToken)(), cachedRuntime);
318
389
  verifyFleetLocalRelease(files, release, publicKey);
390
+ warnCompatibility(files.manifest);
319
391
  installFiles(dir, files, release, publicKey);
320
392
  }
321
393
  return {
@@ -377,18 +449,19 @@ function venvExecutable(runtimeDir, name, platform) {
377
449
  ? path.join(runtimeDir, "Scripts", `${name}.exe`)
378
450
  : path.join(runtimeDir, "bin", name);
379
451
  }
380
- function sharedRuntimeFromMarker(profileDir, root) {
452
+ function sharedRuntimeFromMarker(profileDir, root, platform = process.platform) {
381
453
  try {
382
454
  const marker = JSON.parse(fs.readFileSync(path.join(profileDir, "bootstrap.json"), "utf8"));
383
455
  if (marker.schemaVersion !== 2 || typeof marker.runtimeId !== "string" || !/^[a-f0-9]{64}$/.test(marker.runtimeId))
384
456
  return null;
385
- const sharedRoot = path.join(root, "runtimes", marker.runtimeId);
386
- return {
387
- id: marker.runtimeId,
388
- root: sharedRoot,
389
- runtimeDir: path.join(sharedRoot, "runtime"),
390
- sourcesDir: path.join(sharedRoot, "sources"),
391
- };
457
+ const layout = sharedRuntimeLayout(root, marker.runtimeId, platform);
458
+ if (platform === "win32" && !fs.existsSync(path.join(layout.root, "ready.json"))) {
459
+ // Bots installed before the short Windows layout keep using their runtime.
460
+ const legacy = legacySharedRuntimeLayout(root, marker.runtimeId);
461
+ if (fs.existsSync(path.join(legacy.root, "ready.json")))
462
+ return legacy;
463
+ }
464
+ return layout;
392
465
  }
393
466
  catch {
394
467
  return null;
@@ -426,7 +499,7 @@ function fleetLocalExecutables(agentUid, deps) {
426
499
  const root = deps.fleetHome ?? fleetHome();
427
500
  const platform = deps.platform ?? process.platform;
428
501
  const profileDir = profileRoot(agentUid, root);
429
- const shared = sharedRuntimeFromMarker(profileDir, root);
502
+ const shared = sharedRuntimeFromMarker(profileDir, root, platform);
430
503
  const runtimeDir = shared?.runtimeDir ?? path.join(profileDir, "runtime");
431
504
  const sourcesDir = shared?.sourcesDir ?? path.join(profileDir, "sources");
432
505
  const profile = path.join(profileDir, "profile.json");
@@ -484,12 +557,12 @@ export function fleetLocalProfileNeedsAdoption(agentUid, deps = {}) {
484
557
  if (!entries.includes(canonicalName))
485
558
  return true;
486
559
  const profileDir = path.join(profiles, canonicalName);
487
- const shared = sharedRuntimeFromMarker(profileDir, root);
560
+ const platform = deps.platform ?? process.platform;
561
+ const shared = sharedRuntimeFromMarker(profileDir, root, platform);
488
562
  if (!shared)
489
563
  return true;
490
564
  if (!profileEngineMatchesRelease(profileDir, deps.release ?? HQ_FLEET_LOCAL_RELEASE))
491
565
  return true;
492
- const platform = deps.platform ?? process.platform;
493
566
  if (!["hq-fleet", "hq-fleet-local", "python"].every((name) => fs.existsSync(venvExecutable(shared.runtimeDir, name, platform))))
494
567
  return true;
495
568
  if (hasEmbeddedRuntime(profileDir, platform) || fs.existsSync(path.join(profileDir, "sources")))
@@ -639,7 +712,7 @@ export function removeFleetLocalBot(agentUid, deps = {}) {
639
712
  }
640
713
  const platform = deps.platform ?? process.platform;
641
714
  const legacyRuntime = path.join(profileDir, "runtime");
642
- const shared = sharedRuntimeFromMarker(profileDir, deps.fleetHome ?? fleetHome());
715
+ const shared = sharedRuntimeFromMarker(profileDir, deps.fleetHome ?? fleetHome(), platform);
643
716
  const installedRuntime = shared?.runtimeDir ?? legacyRuntime;
644
717
  if (!["hq-fleet", "hq-fleet-local"].every((name) => fs.existsSync(venvExecutable(installedRuntime, name, platform)))) {
645
718
  fs.rmSync(profileDir, { recursive: true, force: true });
@@ -757,9 +830,11 @@ export function writeFleetLocalProfile(input, deps = {}) {
757
830
  }
758
831
  const root = deps.fleetHome ?? fleetHome();
759
832
  const dir = profileRoot(input.agentUid, root);
760
- const markerRuntime = sharedRuntimeFromMarker(dir, root);
761
- const runtimeRoot = deps.runtimeRoot ?? markerRuntime?.root ?? dir;
762
- const runtimeDir = path.join(runtimeRoot, "runtime");
833
+ const layout = deps.runtime ??
834
+ (deps.runtimeRoot ? undefined : sharedRuntimeFromMarker(dir, root, deps.platform ?? process.platform) ?? undefined);
835
+ const runtimeRoot = deps.runtimeRoot ?? dir;
836
+ const runtimeDir = layout?.runtimeDir ?? path.join(runtimeRoot, "runtime");
837
+ const distributionSource = layout?.distributionSource ?? path.join(runtimeRoot, "sources", "distribution");
763
838
  const destination = path.join(dir, "profile.json");
764
839
  const temporary = path.join(dir, `.profile-${process.pid}-${crypto.randomUUID()}`);
765
840
  const profile = {
@@ -772,7 +847,7 @@ export function writeFleetLocalProfile(input, deps = {}) {
772
847
  profileDir: path.resolve(dir),
773
848
  runtime: { commit: input.installation.release.runtimeCommit, path: path.resolve(runtimeDir) },
774
849
  paths: {
775
- pluginSource: path.resolve(path.join(runtimeRoot, "sources", "distribution", "src", "hq_fleet_local", "plugins", "hqdm")),
850
+ pluginSource: path.resolve(path.join(distributionSource, "src", "hq_fleet_local", "plugins", "hqdm")),
776
851
  statusFile: path.resolve(path.join(input.botDir, "status.json")),
777
852
  logsDir: path.resolve(path.join(input.botDir, "logs")),
778
853
  },
@@ -833,11 +908,11 @@ export function scanFleetLocalInstructions(agentUid, text, deps = {}) {
833
908
  const run = deps.run ?? execFileSync;
834
909
  const root = deps.fleetHome ?? fleetHome();
835
910
  const profileDir = profileRoot(agentUid, root);
836
- const shared = sharedRuntimeFromMarker(profileDir, root);
911
+ const platform = deps.platform ?? process.platform;
912
+ const shared = sharedRuntimeFromMarker(profileDir, root, platform);
837
913
  const runtimeDir = shared?.runtimeDir ?? path.join(profileDir, "runtime");
838
- const sourcesDir = shared?.sourcesDir ?? path.join(profileDir, "sources");
839
- const python = venvExecutable(runtimeDir, "python", deps.platform ?? process.platform);
840
- const source = path.join(sourcesDir, "runtime");
914
+ const python = venvExecutable(runtimeDir, "python", platform);
915
+ const source = shared?.runtimeSource ?? path.join(profileDir, "sources", "runtime");
841
916
  if (!fs.existsSync(python) || !fs.existsSync(source)) {
842
917
  throw expectedError("HQ Fleet Local is not installed for this bot, so its instructions could not be checked.");
843
918
  }
@@ -867,14 +942,82 @@ export function refreshFleetLocalInstructions(agentUid, systemPrompt, deps = {})
867
942
  }
868
943
  return writeFleetLocalSoul(agentUid, systemPrompt, deps);
869
944
  }
945
+ const STDERR_TAIL_LINES = 6;
946
+ const STDERR_TAIL_CHARS = 800;
947
+ /** Last few non-empty stderr lines of a failed child process, trimmed for an error message. */
948
+ export function stderrTail(error) {
949
+ const raw = error?.stderr;
950
+ const text = Buffer.isBuffer(raw) ? raw.toString("utf8") : typeof raw === "string" ? raw : "";
951
+ const lines = text.split(/\r?\n/).map((line) => line.trimEnd()).filter((line) => line.trim().length > 0);
952
+ const tail = lines.slice(-STDERR_TAIL_LINES).join("\n");
953
+ return tail.length > STDERR_TAIL_CHARS ? `...${tail.slice(-STDERR_TAIL_CHARS)}` : tail;
954
+ }
870
955
  function checkedRun(run, executable, args, options, label) {
956
+ // Keep stdout quiet but capture stderr, so a failure names its cause
957
+ // (for example a path-too-long error during extraction).
958
+ const stdio = options.stdio === "ignore" ? ["ignore", "ignore", "pipe"] : options.stdio;
871
959
  try {
872
- run(executable, args, { windowsHide: true, ...options });
960
+ run(executable, args, {
961
+ windowsHide: true,
962
+ maxBuffer: 16 * 1024 * 1024,
963
+ ...options,
964
+ ...(stdio === undefined ? {} : { stdio }),
965
+ });
873
966
  }
874
- catch {
875
- throw expectedError(`HQ Fleet Local ${label} failed.`);
967
+ catch (error) {
968
+ const tail = stderrTail(error);
969
+ throw expectedError(`HQ Fleet Local ${label} failed.${tail ? `\n${tail}` : ""}`);
876
970
  }
877
971
  }
972
+ const FLEET_TAR_PATH_PROBE = [
973
+ "import json, sys, tarfile",
974
+ "longest = 0",
975
+ "with tarfile.open(sys.argv[1]) as archive:",
976
+ " for member in archive:",
977
+ " longest = max(longest, len(member.name.rstrip('/')))",
978
+ "enabled = None",
979
+ "try:",
980
+ " import winreg",
981
+ " key = winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, r'SYSTEM\\CurrentControlSet\\Control\\FileSystem')",
982
+ " enabled = int(winreg.QueryValueEx(key, 'LongPathsEnabled')[0])",
983
+ "except Exception:",
984
+ " pass",
985
+ "print(json.dumps({'longest': longest, 'longPathsEnabled': enabled}))",
986
+ ].join("\n");
987
+ /**
988
+ * Message for a Windows extraction that cannot fit under MAX_PATH, or null
989
+ * when it fits or long paths are on. `longest` is the longest member name in
990
+ * the archive; members land at `<destination>\<name>`.
991
+ */
992
+ export function windowsExtractionPathProblem(destination, longest, longPathsEnabled) {
993
+ const needed = destination.length + 1 + longest;
994
+ if (needed <= WINDOWS_MAX_PATH_CHARS || longPathsEnabled === 1)
995
+ return null;
996
+ return `The HQ Fleet Runtime cannot be extracted on this PC: its deepest file needs a ${needed}-character path under ${destination}, ` +
997
+ `over the Windows limit of ${WINDOWS_MAX_PATH_CHARS}, and Windows long paths are turned off (LongPathsEnabled = ${longPathsEnabled ?? "unset"}). ` +
998
+ "Ask an administrator to set HKLM\\SYSTEM\\CurrentControlSet\\Control\\FileSystem\\LongPathsEnabled to 1, or set HQ_FLEET_HOME to a shorter folder such as C:\\hqf, then retry.";
999
+ }
1000
+ function assertWindowsExtractionFits(run, python, archive, destination) {
1001
+ let probe;
1002
+ try {
1003
+ const output = run(python, ["-I", "-c", FLEET_TAR_PATH_PROBE, archive], {
1004
+ windowsHide: true,
1005
+ encoding: "utf8",
1006
+ stdio: ["ignore", "pipe", "pipe"],
1007
+ maxBuffer: 1024 * 1024,
1008
+ });
1009
+ probe = JSON.parse((Buffer.isBuffer(output) ? output.toString("utf8") : output).trim());
1010
+ }
1011
+ catch {
1012
+ return; // The probe is advisory; extraction reports its own error.
1013
+ }
1014
+ if (typeof probe?.longest !== "number")
1015
+ return;
1016
+ const enabled = typeof probe.longPathsEnabled === "number" ? probe.longPathsEnabled : null;
1017
+ const problem = windowsExtractionPathProblem(destination, probe.longest, enabled);
1018
+ if (problem)
1019
+ throw expectedError(problem);
1020
+ }
878
1021
  /** Moves a fresh machine credential into the bot's secure store and deletes the file. */
879
1022
  export function importFleetLocalMachineCredential(agentUid, machineCredentials, deps = {}) {
880
1023
  const run = deps.run ?? execFileSync;
@@ -902,25 +1045,37 @@ async function installSharedFleetRuntime(installation, root, python, platform, r
902
1045
  platform,
903
1046
  extras: ["mcp", "anthropic"],
904
1047
  })));
905
- const shared = path.join(root, "runtimes", identity);
906
- const descriptor = () => ({
907
- id: identity,
908
- root: shared,
909
- runtimeDir: path.join(shared, "runtime"),
910
- sourcesDir: path.join(shared, "sources"),
911
- });
1048
+ const layout = sharedRuntimeLayout(root, identity, platform);
1049
+ const shared = layout.root;
912
1050
  return withBotLock(identity, async () => {
913
1051
  const marker = path.join(shared, "ready.json");
914
- const sources = path.join(shared, "sources");
915
- const distributionSource = path.join(sources, "distribution");
916
- const runtimeSource = path.join(sources, "runtime");
917
- const runtimeDir = path.join(shared, "runtime");
1052
+ const { distributionSource, runtimeSource, runtimeDir } = layout;
918
1053
  const executables = ["python", "hq-fleet", "hq-fleet-local"];
1054
+ if (platform === "win32") {
1055
+ const legacy = legacySharedRuntimeLayout(root, identity);
1056
+ if (fs.existsSync(path.join(legacy.root, "ready.json")) &&
1057
+ executables.every((name) => fs.existsSync(venvExecutable(legacy.runtimeDir, name, platform)))) {
1058
+ // A runtime installed before the short Windows layout keeps serving bots.
1059
+ return legacy;
1060
+ }
1061
+ // A half-extracted long-path runtime is never usable; clear it.
1062
+ fs.rmSync(legacy.root, { recursive: true, force: true });
1063
+ }
919
1064
  if (fs.existsSync(marker)) {
1065
+ let recordedId;
1066
+ try {
1067
+ recordedId = JSON.parse(fs.readFileSync(marker, "utf8")).runtimeId;
1068
+ }
1069
+ catch {
1070
+ recordedId = undefined;
1071
+ }
1072
+ if (recordedId !== undefined && recordedId !== identity) {
1073
+ throw expectedError(`The shared HQ Fleet Runtime folder ${shared} belongs to a different runtime. Remove it and retry.`);
1074
+ }
920
1075
  if (!executables.every((name) => fs.existsSync(venvExecutable(runtimeDir, name, platform)))) {
921
1076
  throw expectedError("The shared HQ Fleet Runtime is incomplete. Reinstall it before creating another bot.");
922
1077
  }
923
- return descriptor();
1078
+ return layout;
924
1079
  }
925
1080
  fs.rmSync(shared, { recursive: true, force: true });
926
1081
  fs.mkdirSync(distributionSource, { recursive: true, mode: 0o700 });
@@ -933,6 +1088,8 @@ async function installSharedFleetRuntime(installation, root, python, platform, r
933
1088
  installation.archive,
934
1089
  distributionSource,
935
1090
  ], { stdio: "ignore" }, "distribution extraction");
1091
+ if (platform === "win32")
1092
+ assertWindowsExtractionFits(run, python.python, installation.runtimeArchive, runtimeSource);
936
1093
  checkedRun(run, python.python, [
937
1094
  "-m",
938
1095
  "tarfile",
@@ -961,8 +1118,8 @@ async function installSharedFleetRuntime(installation, root, python, platform, r
961
1118
  if (!executables.every((name) => fs.existsSync(venvExecutable(runtimeDir, name, platform)))) {
962
1119
  throw expectedError("HQ Fleet Local installed without its runtime executables.");
963
1120
  }
964
- fs.writeFileSync(marker, `${JSON.stringify({ schemaVersion: 1, engine: installation.engine })}\n`, { mode: 0o600 });
965
- return descriptor();
1121
+ fs.writeFileSync(marker, `${JSON.stringify({ schemaVersion: 1, runtimeId: identity, engine: installation.engine })}\n`, { mode: 0o600 });
1122
+ return layout;
966
1123
  }, { root: path.join(root, "runtimes"), command: "HQ Fleet Runtime installation", waitMs: 20 * 60_000, staleMs: 2 * 60 * 60_000 });
967
1124
  }
968
1125
  function writeNativeProfileMarker(profileDir, shared, engine) {
@@ -988,7 +1145,7 @@ export async function bootstrapFleetLocalBot(input, deps = {}) {
988
1145
  const { systemPrompt, machineCredentials, ...profileInput } = input;
989
1146
  if (systemPrompt !== undefined)
990
1147
  refreshFleetLocalInstructions(input.agentUid, systemPrompt, { fleetHome: root, platform, run });
991
- const profile = writeFleetLocalProfile(profileInput, { fleetHome: root, platform, runtimeRoot: shared.root });
1148
+ const profile = writeFleetLocalProfile(profileInput, { fleetHome: root, platform, runtime: shared });
992
1149
  const helper = venvExecutable(shared.runtimeDir, "hq-fleet-local", platform);
993
1150
  const runtime = venvExecutable(shared.runtimeDir, "hq-fleet", platform);
994
1151
  if (!fs.existsSync(helper) || !fs.existsSync(runtime)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.345.189",
3
+ "version": "5.345.190",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -114,11 +114,11 @@
114
114
  "*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}": "eslint --cache --no-error-on-unmatched-pattern"
115
115
  },
116
116
  "optionalDependencies": {
117
- "@indigoai-us/hq-hook-win32-x64": "5.345.189",
118
- "@indigoai-us/hq-hook-win32-arm64": "5.345.189",
119
- "@indigoai-us/hq-hook-darwin-x64": "5.345.189",
120
- "@indigoai-us/hq-hook-darwin-arm64": "5.345.189",
121
- "@indigoai-us/hq-hook-linux-x64": "5.345.189",
122
- "@indigoai-us/hq-hook-linux-arm64": "5.345.189"
117
+ "@indigoai-us/hq-hook-win32-x64": "5.345.190",
118
+ "@indigoai-us/hq-hook-win32-arm64": "5.345.190",
119
+ "@indigoai-us/hq-hook-darwin-x64": "5.345.190",
120
+ "@indigoai-us/hq-hook-darwin-arm64": "5.345.190",
121
+ "@indigoai-us/hq-hook-linux-x64": "5.345.190",
122
+ "@indigoai-us/hq-hook-linux-arm64": "5.345.190"
123
123
  }
124
124
  }