@indigoai-us/hq-cli 5.298.0 → 5.299.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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,11 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.299.0] — 2026-09-30
6
+ - Add `hq lanes accounts clear-quota` and clear future quota marks after successful lane turns.
7
+ - On macOS, `hq daemon` retires a loaded legacy mesh LaunchAgent even when its plist is missing, and daemon uninstall boots the job out before deleting its plist. Mesh-owner wait logs now repeat at most every ten minutes.
8
+ - Restore npm command links after a failed hq-cli self-update and repair or report missing links after successful installs.
9
+
5
10
  ## [5.298.0] — 2026-09-30
6
11
  - [feedback_d4a080e9-de79-4290-81c3-06ba26990a38] Preserve raw substring matches while normalizing company path searches.
7
12
  - `hq agents` now prints the Slack app settings link and the console paste link whenever an agent is waiting on its Slack app-level token, and follows the pending actions and paste URL the server returns.
@@ -8990,6 +8990,29 @@ export declare const COMMAND_CATALOG: readonly [{
8990
8990
  readonly description: "JSON on stdout (always)";
8991
8991
  }];
8992
8992
  readonly subcommands: readonly [];
8993
+ }, {
8994
+ readonly name: "clear-quota";
8995
+ readonly description: "Clear quota reset marks for a provider account";
8996
+ readonly aliases: readonly [];
8997
+ readonly hidden: false;
8998
+ readonly usage: "[options] <provider> <account-id>";
8999
+ readonly arguments: readonly [{
9000
+ readonly name: "provider";
9001
+ readonly required: true;
9002
+ readonly variadic: false;
9003
+ }, {
9004
+ readonly name: "account-id";
9005
+ readonly required: true;
9006
+ readonly variadic: false;
9007
+ }];
9008
+ readonly options: readonly [{
9009
+ readonly flags: "--model <model>";
9010
+ readonly description: "Clear only this model's reset";
9011
+ }, {
9012
+ readonly flags: "--json";
9013
+ readonly description: "JSON on stdout (always)";
9014
+ }];
9015
+ readonly subcommands: readonly [];
8993
9016
  }, {
8994
9017
  readonly name: "enable";
8995
9018
  readonly description: "Enable an account without touching credentials";
@@ -11601,6 +11601,36 @@ export const COMMAND_CATALOG = [
11601
11601
  ],
11602
11602
  "subcommands": []
11603
11603
  },
11604
+ {
11605
+ "name": "clear-quota",
11606
+ "description": "Clear quota reset marks for a provider account",
11607
+ "aliases": [],
11608
+ "hidden": false,
11609
+ "usage": "[options] <provider> <account-id>",
11610
+ "arguments": [
11611
+ {
11612
+ "name": "provider",
11613
+ "required": true,
11614
+ "variadic": false
11615
+ },
11616
+ {
11617
+ "name": "account-id",
11618
+ "required": true,
11619
+ "variadic": false
11620
+ }
11621
+ ],
11622
+ "options": [
11623
+ {
11624
+ "flags": "--model <model>",
11625
+ "description": "Clear only this model's reset"
11626
+ },
11627
+ {
11628
+ "flags": "--json",
11629
+ "description": "JSON on stdout (always)"
11630
+ }
11631
+ ],
11632
+ "subcommands": []
11633
+ },
11604
11634
  {
11605
11635
  "name": "enable",
11606
11636
  "description": "Enable an account without touching credentials",
@@ -91,6 +91,7 @@ async function loadDefaults(over) {
91
91
  home: dir ?? home,
92
92
  existsSync: fs.existsSync,
93
93
  listDir: (dir) => fs.readdirSync(dir),
94
+ launchdJobLoaded: (label) => run("launchctl", ["print", `gui/${process.getuid?.() ?? 0}/${label}`]).status === 0,
94
95
  windowsTaskExists: (task) => run("schtasks.exe", ["/Query", "/TN", task]).status === 0,
95
96
  })),
96
97
  resolveHqRoot: over.resolveHqRoot ?? ((flag) => resolveDaemonHqRoot(flag, { env, cwd: process.cwd(), home })),
@@ -17,6 +17,9 @@ export declare function createLaneAccountsHandlers(deps: LaneAccountsHandlerDeps
17
17
  host?: string;
18
18
  company?: string;
19
19
  }) => Promise<void>;
20
+ runAccountsClearQuota: (ctx: Ctx, providerRaw: string, accountIdRaw: string, opts: {
21
+ model?: string;
22
+ }) => Promise<void>;
20
23
  runAccountsEnable: (ctx: Ctx, providerRaw: string, accountId: string) => Promise<void>;
21
24
  runAccountsDisable: (ctx: Ctx, providerRaw: string, accountId: string, opts: {
22
25
  reason?: string;
@@ -8,7 +8,7 @@ import { AccountResolutionError, DEFAULT_ACCOUNT_HOMES, LANE_PROVIDERS, PROVIDER
8
8
  import { assertUniqueAccountHomes, providerCompactAfterMs } from "../lib/lanes/config.js";
9
9
  import { resolveLaneEffort } from "../lib/lanes/effort.js";
10
10
  import { LanesError } from "../lib/lanes/errors.js";
11
- import { accountQuotaView, loadQuotaState } from "../lib/lanes/quota.js";
11
+ import { accountQuotaView, clearAccountQuota, loadQuotaState } from "../lib/lanes/quota.js";
12
12
  import { resolveLaneModel } from "../lib/lanes/spawn.js";
13
13
  export function createLaneAccountsHandlers(deps) {
14
14
  const { automaticBalanceMoveHistoryReason, ensureDir, laneWithRecoveredProviderSession, loadClaudeBalanceHostSnapshots, parseRemoteJson, prepareAccountMove, printJson, reconcileHostFromCtx, resolveCallerPath } = deps;
@@ -489,6 +489,29 @@ export function createLaneAccountsHandlers(deps) {
489
489
  home_source: home === account.home ? "default" : "host",
490
490
  });
491
491
  }
492
+ async function runAccountsClearQuota(ctx, providerRaw, accountIdRaw, opts) {
493
+ const provider = parseLaneProvider(providerRaw);
494
+ const account = accountIdRaw.trim();
495
+ if (!account) {
496
+ throw new AccountResolutionError("unknown_account", "account-id must be non-empty", { provider, account: accountIdRaw, enabled: [] });
497
+ }
498
+ const model = opts.model?.trim();
499
+ if (opts.model !== undefined && !model) {
500
+ throw new LanesError("invalid_model", "--model must be non-empty", {
501
+ provider,
502
+ account,
503
+ });
504
+ }
505
+ const cleared = clearAccountQuota(ctx.hqRoot(), provider, account, model === undefined ? {} : { model });
506
+ printJson({
507
+ ok: true,
508
+ action: "accounts.clear-quota",
509
+ provider,
510
+ account,
511
+ all_models_quota_reset_at: cleared.all_models_quota_reset_at,
512
+ model_quota_resets: cleared.model_quota_resets,
513
+ });
514
+ }
492
515
  async function runAccountsEnable(ctx, providerRaw, accountId) {
493
516
  const hqRoot = ctx.hqRoot();
494
517
  const provider = parseLaneProvider(providerRaw);
@@ -562,6 +585,7 @@ export function createLaneAccountsHandlers(deps) {
562
585
  runAccountsBalance,
563
586
  runAccountsLogin,
564
587
  runAccountsSetHome,
588
+ runAccountsClearQuota,
565
589
  runAccountsEnable,
566
590
  runAccountsDisable
567
591
  };
@@ -8453,6 +8453,16 @@ export function registerLanesCommand(parent, deps = {}) {
8453
8453
  .action(wrap(async (provider, accountId, opts) => {
8454
8454
  await accountHandlers.runAccountsLogin(ctx, provider, accountId, opts);
8455
8455
  }));
8456
+ accounts
8457
+ .command("clear-quota")
8458
+ .description("Clear quota reset marks for a provider account")
8459
+ .argument("<provider>", "claude | codex | grok")
8460
+ .argument("<account-id>", "Account id")
8461
+ .option("--model <model>", "Clear only this model's reset")
8462
+ .option("--json", "JSON on stdout (always)")
8463
+ .action(wrap(async (provider, accountId, opts) => {
8464
+ await accountHandlers.runAccountsClearQuota(ctx, provider, accountId, opts);
8465
+ }));
8456
8466
  accounts
8457
8467
  .command("enable")
8458
8468
  .description("Enable an account without touching credentials")
@@ -3,8 +3,9 @@
3
3
  * still runs a service, the daemon leaves that service alone instead of
4
4
  * starting a second copy, and `hq daemon migrate` lists it.
5
5
  *
6
- * Detection only reads the filesystem (and asks schtasks on Windows). Nothing
7
- * here stops or removes a unit.
6
+ * Detection reads unit files and checks for an orphaned loaded macOS mesh job
7
+ * whose plist was removed. Windows scheduled tasks are probed through
8
+ * schtasks. Nothing here stops or removes a unit.
8
9
  */
9
10
  import type { ServiceName } from "./catalog.js";
10
11
  export interface LegacyUnit {
@@ -22,6 +23,8 @@ export interface LegacyProbeDeps {
22
23
  home: string;
23
24
  existsSync: (p: string) => boolean;
24
25
  listDir: (dir: string) => string[];
26
+ /** True when launchctl can print the GUI-domain job despite a missing plist. */
27
+ launchdJobLoaded: (label: string) => boolean;
25
28
  windowsTaskExists: (taskName: string) => boolean;
26
29
  }
27
30
  export declare function describeLegacyUnit(unit: LegacyUnit): string;
@@ -3,8 +3,9 @@
3
3
  * still runs a service, the daemon leaves that service alone instead of
4
4
  * starting a second copy, and `hq daemon migrate` lists it.
5
5
  *
6
- * Detection only reads the filesystem (and asks schtasks on Windows). Nothing
7
- * here stops or removes a unit.
6
+ * Detection reads unit files and checks for an orphaned loaded macOS mesh job
7
+ * whose plist was removed. Windows scheduled tasks are probed through
8
+ * schtasks. Nothing here stops or removes a unit.
8
9
  */
9
10
  import * as path from "node:path";
10
11
  const KIT_SERVICES = ["sync", "inbox", "heartbeat"];
@@ -52,8 +53,9 @@ function darwinUnits(deps) {
52
53
  const units = [];
53
54
  const at = (label) => path.join(dir, `${label}.plist`);
54
55
  const add = (unitId, service, label, migratable = true) => units.push({ unitId, service, kind: "launchd", name: label, path: at(label), migratable });
55
- if (deps.existsSync(at("ai.getindigo.hq-mesh-daemon")))
56
- add("mesh", "mesh", "ai.getindigo.hq-mesh-daemon");
56
+ const meshLabel = "ai.getindigo.hq-mesh-daemon";
57
+ if (deps.existsSync(at(meshLabel)) || deps.launchdJobLoaded(meshLabel))
58
+ add("mesh", "mesh", meshLabel);
57
59
  for (const file of safeList(deps, dir).sort()) {
58
60
  const m = /^ai\.getindigo\.hq-bot\.(.+)\.plist$/.exec(file);
59
61
  if (m)
@@ -17,11 +17,13 @@
17
17
  * folder, so nobody but root decides what gets installed there.
18
18
  */
19
19
  import type { RunCommand } from "../service-manager/launchd.js";
20
- import type { LegacyUnit } from "./legacy.js";
20
+ import { type LegacyUnit } from "./legacy.js";
21
21
  export interface AdoptedUnit extends LegacyUnit {
22
22
  /** Where the unit's definition (plist, unit file, task XML) was moved. */
23
23
  movedTo: string;
24
24
  adoptedAt: number;
25
+ /** Launchd job was loaded, but its source plist no longer existed. */
26
+ definitionMissing?: true;
25
27
  }
26
28
  export interface MigrateDeps {
27
29
  run: RunCommand;
@@ -64,5 +66,12 @@ export interface RestoreResult {
64
66
  }
65
67
  export declare function readAdoptedUnits(recordPath: string): AdoptedUnit[];
66
68
  export declare function adoptLegacyUnits(units: readonly LegacyUnit[], deps: MigrateDeps): AdoptResult;
69
+ /** Retire the migratable legacy mesh job and emit the daemon's operator-facing result lines. */
70
+ export declare function retireLegacyMeshUnits(units: readonly LegacyUnit[], options: {
71
+ platform: NodeJS.Platform;
72
+ managed: boolean;
73
+ hosted: boolean;
74
+ meshEnabled: boolean;
75
+ }, deps: MigrateDeps, log: (level: "info" | "warn", message: string) => void): AdoptResult;
67
76
  export declare function restoreLegacyUnits(deps: MigrateDeps): RestoreResult;
68
77
  //# sourceMappingURL=migrate.d.ts.map
@@ -18,6 +18,7 @@
18
18
  */
19
19
  import * as fs from "node:fs";
20
20
  import * as path from "node:path";
21
+ import { legacyMeshUnitsToRetire } from "./legacy.js";
21
22
  import { ensurePrivateDir } from "./paths.js";
22
23
  export function readAdoptedUnits(recordPath) {
23
24
  let text;
@@ -99,8 +100,10 @@ function adoptOne(unit, deps) {
99
100
  if (bootout.status !== 0 && deps.run("launchctl", ["print", target]).status === 0) {
100
101
  must(deps.run, "launchctl", ["bootout", target]);
101
102
  }
103
+ if (!fs.existsSync(unit.path))
104
+ return { movedTo: unit.path, definitionMissing: true };
102
105
  moveFile(unit.path, to);
103
- return to;
106
+ return { movedTo: to };
104
107
  }
105
108
  case "systemd-user":
106
109
  case "systemd-system": {
@@ -109,7 +112,7 @@ function adoptOne(unit, deps) {
109
112
  must(deps.run, ...systemctl(unit, deps, ["disable", "--now", unit.name]));
110
113
  moveFile(unit.path, to);
111
114
  deps.run(...systemctl(unit, deps, ["daemon-reload"]));
112
- return to;
115
+ return { movedTo: to };
113
116
  }
114
117
  case "schtasks": {
115
118
  const xml = must(deps.run, "schtasks.exe", ["/Query", "/TN", unit.name, "/XML"]);
@@ -117,7 +120,7 @@ function adoptOne(unit, deps) {
117
120
  fs.writeFileSync(to, encodeTaskXml(xml), { mode: 0o600 });
118
121
  deps.run("schtasks.exe", ["/End", "/TN", unit.name]);
119
122
  must(deps.run, "schtasks.exe", ["/Delete", "/TN", unit.name, "/F"]);
120
- return to;
123
+ return { movedTo: to };
121
124
  }
122
125
  }
123
126
  }
@@ -141,15 +144,15 @@ export function adoptLegacyUnits(units, deps) {
141
144
  continue;
142
145
  }
143
146
  const store = storeFor(unit.kind, deps);
144
- let movedTo;
147
+ let adoptedFields;
145
148
  try {
146
- movedTo = adoptOne(unit, deps);
149
+ adoptedFields = adoptOne(unit, deps);
147
150
  }
148
151
  catch (err) {
149
152
  result.failed.push({ unit, error: err.message });
150
153
  continue;
151
154
  }
152
- const adopted = { ...unit, movedTo, adoptedAt: now() };
155
+ const adopted = { ...unit, ...adoptedFields, adoptedAt: now() };
153
156
  const next = [...recordOf(store), adopted];
154
157
  try {
155
158
  // Record each unit as soon as it is moved, so a failure later in the
@@ -165,7 +168,7 @@ export function adoptLegacyUnits(units, deps) {
165
168
  restoreOne(adopted, deps);
166
169
  }
167
170
  catch (restoreErr) {
168
- rollback = `; putting it back also failed (${restoreErr.message}), its definition is at ${movedTo}`;
171
+ rollback = `; putting it back also failed (${restoreErr.message}), its definition is at ${adopted.movedTo}`;
169
172
  }
170
173
  result.failed.push({ unit, error: `could not record the takeover: ${err.message}${rollback}` });
171
174
  }
@@ -192,6 +195,8 @@ function restoreOne(unit, deps) {
192
195
  case "launchd": {
193
196
  if (!unit.path)
194
197
  throw new Error(`no plist path recorded for ${unit.name}`);
198
+ if (unit.definitionMissing)
199
+ throw new Error("the original LaunchAgent plist is missing; this unit cannot be restored");
195
200
  moveBack(unit, unit.path);
196
201
  must(deps.run, "launchctl", ["bootstrap", `gui/${deps.uid}`, unit.path]);
197
202
  return;
@@ -216,6 +221,16 @@ function restoreOne(unit, deps) {
216
221
  return;
217
222
  }
218
223
  }
224
+ /** Retire the migratable legacy mesh job and emit the daemon's operator-facing result lines. */
225
+ export function retireLegacyMeshUnits(units, options, deps, log) {
226
+ const selected = legacyMeshUnitsToRetire(units, options);
227
+ const result = adoptLegacyUnits(selected, deps);
228
+ for (const unit of result.adopted)
229
+ log("info", `retired legacy ${unit.unitId} unit ${unit.name}; hq daemon now owns mesh`);
230
+ for (const failure of result.failed)
231
+ log("warn", `could not retire legacy ${failure.unit.unitId} unit ${failure.unit.name}: ${failure.error}`);
232
+ return result;
233
+ }
219
234
  function restoreFrom(record, deps, check, result) {
220
235
  const units = readAdoptedUnits(record);
221
236
  if (units.length === 0)
@@ -22,7 +22,7 @@ import { SUPERVISOR_HANDOFF_FLAG, readDaemonFlag } from "./flag.js";
22
22
  import { findLegacyUnits, legacyMeshUnitsToRetire, legacyOwnerLookup } from "./legacy.js";
23
23
  import { readLegacySyncEnv } from "./legacy-sync-env.js";
24
24
  import { acquireDaemonLock, isProcessAlive, processCommandLine, releaseDaemonLock } from "./lock.js";
25
- import { adoptLegacyUnits } from "./migrate.js";
25
+ import { retireLegacyMeshUnits } from "./migrate.js";
26
26
  import { daemonDir as meshDaemonDir, daemonPidPath as meshDaemonPidPath } from "../mesh/live/daemon/paths.js";
27
27
  import { readPidLock } from "../mesh/live/daemon/pid-lock.js";
28
28
  import { daemonRootRefusal } from "./install.js";
@@ -72,6 +72,17 @@ export function probeLegacyUnits(platform, home) {
72
72
  home,
73
73
  existsSync: fs.existsSync,
74
74
  listDir: (dir) => fs.readdirSync(dir),
75
+ launchdJobLoaded: (label) => {
76
+ if (platform !== "darwin")
77
+ return false;
78
+ const uid = process.getuid?.() ?? 0;
79
+ const result = spawnSync("launchctl", ["print", `gui/${uid}/${label}`], {
80
+ encoding: "utf8",
81
+ windowsHide: true,
82
+ timeout: 15_000,
83
+ });
84
+ return result.status === 0;
85
+ },
75
86
  windowsTaskExists,
76
87
  });
77
88
  }
@@ -344,7 +355,12 @@ export async function runDaemon(opts) {
344
355
  if (legacyMesh.length > 0) {
345
356
  try {
346
357
  const uid = process.getuid?.() ?? 0;
347
- const result = adoptLegacyUnits(legacyMesh, {
358
+ retireLegacyMeshUnits(legacyMesh, {
359
+ platform,
360
+ managed: opts.managed === true,
361
+ hosted: opts.host !== undefined,
362
+ meshEnabled: enabledServices(machineType, initial).includes("mesh"),
363
+ }, {
348
364
  run: (command, args) => {
349
365
  const r = spawnSync(command, args, { encoding: "utf8", windowsHide: true, timeout: 15_000 });
350
366
  return { status: r.status, stdout: r.stdout ?? "", stderr: r.stderr ?? "" };
@@ -356,11 +372,7 @@ export async function runDaemon(opts) {
356
372
  systemAdoptedDir: "/var/lib/hq/daemon/adopted",
357
373
  systemRecordPath: "/var/lib/hq/daemon/adopted.json",
358
374
  systemUnitDir: "/etc/systemd/system",
359
- });
360
- for (const unit of result.adopted)
361
- log("info", `retired legacy ${unit.unitId} unit ${unit.name}; hq daemon now owns mesh`);
362
- for (const failure of result.failed)
363
- log("warn", `could not retire legacy ${failure.unit.unitId} unit ${failure.unit.name}: ${failure.error}`);
375
+ }, (level, message) => log(level, message));
364
376
  }
365
377
  catch (err) {
366
378
  log("error", `could not retire legacy mesh units: ${err.message}`);
@@ -28,6 +28,7 @@ export const BACKOFF_MAX_MS = 60_000;
28
28
  export const HEALTHY_RUN_MS = 60_000;
29
29
  export const STOP_ESCALATION_MS = 10_000;
30
30
  const MESH_OWNER_RECHECK_MS = 15_000;
31
+ const MESH_OWNER_LOG_INTERVAL_MS = 10 * 60_000;
31
32
  /** After a handoff, how long a replaced unit must stay up before the next one is replaced. */
32
33
  export const HANDOFF_STAGGER_MS = 15_000;
33
34
  /** How long a draining unit may take to finish its work before it gets SIGTERM. */
@@ -297,11 +298,18 @@ export function createSupervisor(deps) {
297
298
  }
298
299
  const meshOwnerReason = slot.plan.service === "mesh" ? deps.meshOwnerReason?.() : undefined;
299
300
  if (meshOwnerReason) {
301
+ let lastLoggedReason;
302
+ let lastLoggedAt = Number.NEGATIVE_INFINITY;
300
303
  const waitForOwner = (reason) => {
301
304
  slot.state.status = "waiting";
302
305
  slot.state.reason = reason;
303
306
  slot.state.nextRunAt = now() + MESH_OWNER_RECHECK_MS;
304
- deps.log("warn", `${reason}; retrying mesh ownership check in ${MESH_OWNER_RECHECK_MS}ms`);
307
+ const at = now();
308
+ if (reason !== lastLoggedReason || at - lastLoggedAt >= MESH_OWNER_LOG_INTERVAL_MS) {
309
+ deps.log("warn", `${reason}; retrying mesh ownership check in ${MESH_OWNER_RECHECK_MS}ms`);
310
+ lastLoggedReason = reason;
311
+ lastLoggedAt = at;
312
+ }
305
313
  slot.timer = setT(() => {
306
314
  slot.timer = undefined;
307
315
  const current = deps.meshOwnerReason?.();
@@ -89,6 +89,25 @@ export declare function quotaSignalFromFields(fields: {
89
89
  }, model: string | undefined, now: Date): QuotaSignal | null;
90
90
  export declare function readQuotaSignal(runDir: string, model: string | undefined, now: Date, workerOutOffset?: number): QuotaSignal | null;
91
91
  export declare function recordAccountQuota(hqRoot: string, provider: LaneProvider, accountId: string, signal: QuotaSignal): QuotaState;
92
+ export interface ClearedModelQuotaReset {
93
+ model: string;
94
+ quota_reset_at: string;
95
+ }
96
+ export interface AccountQuotaClearResult {
97
+ all_models_quota_reset_at: string | null;
98
+ model_quota_resets: ClearedModelQuotaReset[];
99
+ }
100
+ export interface ClearAccountQuotaOptions {
101
+ /** Clear only this model's reset instead of every per-model reset. */
102
+ model?: string;
103
+ /** Clear only marks whose reset time is still ahead of now. */
104
+ futureOnly?: boolean;
105
+ now?: Date;
106
+ }
107
+ /** Clear quota reset marks and persist them with the quota file's atomic writer. */
108
+ export declare function clearAccountQuota(hqRoot: string, provider: LaneProvider, accountId: string, options?: ClearAccountQuotaOptions): AccountQuotaClearResult;
109
+ export declare function clearAccountQuotaAfterSuccessfulTurn(hqRoot: string, provider: LaneProvider, accountId: string, model: string, now: Date): AccountQuotaClearResult;
110
+ export declare function clearAccountQuotaAfterSuccessfulTurnBestEffort(hqRoot: string, provider: LaneProvider, accountId: string, model: string, now: Date): void;
92
111
  export interface QuotaBlock {
93
112
  blocked: boolean;
94
113
  reason?: "account_quota_exhausted" | "account_model_unavailable";
@@ -19,6 +19,7 @@
19
19
  */
20
20
  import * as fs from "node:fs";
21
21
  import * as path from "node:path";
22
+ import { withLaneRecordFileLock } from "./store.js";
22
23
  export const QUOTA_STATE_REL = path.join("personal", "lanes", "quota.json");
23
24
  export const QUOTA_SCHEMA_VERSION = "hq-lanes-quota.v1";
24
25
  /**
@@ -278,40 +279,116 @@ export function readQuotaSignal(runDir, model, now, workerOutOffset = 0) {
278
279
  return quotaSignalFromFields(fields, model, now);
279
280
  }
280
281
  export function recordAccountQuota(hqRoot, provider, accountId, signal) {
281
- const state = loadQuotaState(hqRoot);
282
- const accounts = state.providers[provider] ?? {};
283
- const current = accounts[accountId] ?? { models: {} };
284
- const models = { ...current.models };
285
- if (signal.model) {
286
- const prior = models[signal.model] ?? {};
287
- const next = { ...prior };
288
- if (signal.needs_credits) {
289
- next.needs_credits = true;
290
- next.marker = signal.marker ?? `needs credits for ${signal.model}`;
291
- delete next.quota_reset_at;
282
+ return withLaneRecordFileLock(quotaStatePath(hqRoot), () => {
283
+ const state = loadQuotaState(hqRoot);
284
+ const accounts = state.providers[provider] ?? {};
285
+ const current = accounts[accountId] ?? { models: {} };
286
+ const models = { ...current.models };
287
+ if (signal.model) {
288
+ const prior = models[signal.model] ?? {};
289
+ const next = { ...prior };
290
+ if (signal.needs_credits) {
291
+ next.needs_credits = true;
292
+ next.marker = signal.marker ?? `needs credits for ${signal.model}`;
293
+ delete next.quota_reset_at;
294
+ }
295
+ if (signal.quota_reset_at &&
296
+ !signal.needs_credits &&
297
+ shouldReplaceReset(next.quota_reset_at, signal.quota_reset_at)) {
298
+ next.quota_reset_at = signal.quota_reset_at;
299
+ }
300
+ models[signal.model] = next;
292
301
  }
293
- if (signal.quota_reset_at &&
294
- !signal.needs_credits &&
295
- shouldReplaceReset(next.quota_reset_at, signal.quota_reset_at)) {
296
- next.quota_reset_at = signal.quota_reset_at;
302
+ const account = { ...current, models };
303
+ if (signal.account_wide && signal.quota_reset_at) {
304
+ if (shouldReplaceReset(current.all_models_quota_reset_at, signal.quota_reset_at)) {
305
+ account.all_models_quota_reset_at = signal.quota_reset_at;
306
+ account.reset_source = signal.reset_source ?? "provider";
307
+ }
308
+ else if (current.all_models_quota_reset_at) {
309
+ account.all_models_quota_reset_at = current.all_models_quota_reset_at;
310
+ account.reset_source = current.reset_source ?? "provider";
311
+ }
297
312
  }
298
- models[signal.model] = next;
299
- }
300
- const account = { ...current, models };
301
- if (signal.account_wide && signal.quota_reset_at) {
302
- if (shouldReplaceReset(current.all_models_quota_reset_at, signal.quota_reset_at)) {
303
- account.all_models_quota_reset_at = signal.quota_reset_at;
304
- account.reset_source = signal.reset_source ?? "provider";
313
+ accounts[accountId] = account;
314
+ state.providers[provider] = accounts;
315
+ writeQuotaState(hqRoot, state);
316
+ return state;
317
+ });
318
+ }
319
+ /** Clear quota reset marks and persist them with the quota file's atomic writer. */
320
+ export function clearAccountQuota(hqRoot, provider, accountId, options = {}) {
321
+ return withLaneRecordFileLock(quotaStatePath(hqRoot), () => {
322
+ const cleared = {
323
+ all_models_quota_reset_at: null,
324
+ model_quota_resets: [],
325
+ };
326
+ const state = loadQuotaState(hqRoot);
327
+ const account = state.providers[provider]?.[accountId];
328
+ if (!account)
329
+ return cleared;
330
+ const now = options.now ?? new Date();
331
+ const shouldClear = (reset) => reset !== undefined &&
332
+ (!options.futureOnly || resetStillAhead(reset, now));
333
+ let changed = false;
334
+ const accountWideReset = account.all_models_quota_reset_at;
335
+ if (shouldClear(accountWideReset)) {
336
+ cleared.all_models_quota_reset_at = accountWideReset;
337
+ delete account.all_models_quota_reset_at;
338
+ delete account.reset_source;
339
+ changed = true;
340
+ }
341
+ const modelNames = options.model === undefined
342
+ ? Object.keys(account.models).sort()
343
+ : [options.model];
344
+ for (const model of modelNames) {
345
+ const entry = account.models[model];
346
+ const modelReset = entry?.quota_reset_at;
347
+ if (!entry || !shouldClear(modelReset))
348
+ continue;
349
+ cleared.model_quota_resets.push({
350
+ model,
351
+ quota_reset_at: modelReset,
352
+ });
353
+ delete entry.quota_reset_at;
354
+ if (Object.keys(entry).length === 0)
355
+ delete account.models[model];
356
+ changed = true;
305
357
  }
306
- else if (current.all_models_quota_reset_at) {
307
- account.all_models_quota_reset_at = current.all_models_quota_reset_at;
308
- account.reset_source = current.reset_source ?? "provider";
358
+ if (!changed)
359
+ return cleared;
360
+ if (account.all_models_quota_reset_at === undefined) {
361
+ delete account.reset_source;
309
362
  }
363
+ const accounts = state.providers[provider];
364
+ if (account.all_models_quota_reset_at === undefined &&
365
+ Object.keys(account.models).length === 0) {
366
+ delete accounts[accountId];
367
+ if (Object.keys(accounts).length === 0)
368
+ delete state.providers[provider];
369
+ }
370
+ else {
371
+ accounts[accountId] = account;
372
+ }
373
+ writeQuotaState(hqRoot, state);
374
+ return cleared;
375
+ });
376
+ }
377
+ export function clearAccountQuotaAfterSuccessfulTurn(hqRoot, provider, accountId, model, now) {
378
+ return clearAccountQuota(hqRoot, provider, accountId, {
379
+ model,
380
+ futureOnly: true,
381
+ now,
382
+ });
383
+ }
384
+ export function clearAccountQuotaAfterSuccessfulTurnBestEffort(hqRoot, provider, accountId, model, now) {
385
+ try {
386
+ clearAccountQuotaAfterSuccessfulTurn(hqRoot, provider, accountId, model, now);
387
+ }
388
+ catch (err) {
389
+ const detail = err instanceof Error ? err.message : String(err);
390
+ process.stderr.write(`hq lanes: quota cleanup after successful turn failed for provider=${provider} account=${accountId} model=${model}: ${detail}\n`);
310
391
  }
311
- accounts[accountId] = account;
312
- state.providers[provider] = accounts;
313
- writeQuotaState(hqRoot, state);
314
- return state;
315
392
  }
316
393
  function shouldReplaceReset(current, incoming) {
317
394
  if (!current)
@@ -27,7 +27,7 @@ import { appendDropboxMessage } from "./dropbox.js";
27
27
  import { DEFAULT_RUNAWAY_SEARCH_GUARD_CONFIG, listHostProcesses, runRunawayToolGuard, RUNAWAY_TOOL_GUARD_FLAG, } from "./runaway-tool-guard.js";
28
28
  import { LEAD_LANE_KEEP_ALIVE_FLAG, LEAD_LANE_KEEP_ALIVE_RESUME_BLOCKED_FLAG, LEAD_LANE_KEEP_ALIVE_BLOCKED_GRACE_MS, LEAD_LANE_KEEP_ALIVE_BLOCKED_RESUME_TEXT, queueLaneTerminalResume, LEAD_LANE_KEEP_ALIVE_INTERRUPTED_RESUME_TEXT, LEAD_LANE_KEEP_ALIVE_MAX_RESUMES, LEAD_LANE_KEEP_ALIVE_RESUME_TEXT, LANE_SUPERSESSION_LINEAGE_AWARE_FLAG, doneKeepAliveVerdict, hasPendingKeepAliveWork, keepAliveExclusion, loadRetiredLaneIds, leadLaneKeepAliveBackoffMs, undeliveredAnsweredQuestions, } from "./keep-alive.js";
29
29
  import { providerSupportsSessionResume } from "./delivery.js";
30
- import { applyDefaultQuotaHold, readQuotaSignal, recordAccountQuota } from "./quota.js";
30
+ import { applyDefaultQuotaHold, clearAccountQuotaAfterSuccessfulTurnBestEffort, readQuotaSignal, recordAccountQuota, } from "./quota.js";
31
31
  import { answeredQuestionEntries, questionRecordRefusalCode, raiseEnvelopeQuestions, resolveQuestionCompanyWithFallback, } from "./questions.js";
32
32
  import { SILENCE_MS, appendWatcherEvent, classifyLaneExit, isPidAlive, missingExpectedEnvelopeFile, readLatestEnvelopeFile, readLatestEnvelopeRecord, watcherEventsPath, watcherOutLogPath, writeWatcherDecision, } from "./watcher.js";
33
33
  import { createLaneMesh, emitLaneDecisionEvents, emitLaneTerminalStateEvent, laneMeshCompany, } from "./mesh-events.js";
@@ -1299,6 +1299,17 @@ async function heartbeatAndDeadHosts(hqRoot, lanes, summary, deps, now) {
1299
1299
  }
1300
1300
  return next;
1301
1301
  }, deps.dryRun);
1302
+ if (!deps.dryRun &&
1303
+ result.changed &&
1304
+ result.record.state === "done" &&
1305
+ terminalClassification.state === "done" &&
1306
+ terminalEnvelope?.decision === "done" &&
1307
+ !quota &&
1308
+ !providerRejection &&
1309
+ result.record.account &&
1310
+ result.record.model) {
1311
+ clearAccountQuotaAfterSuccessfulTurnBestEffort(hqRoot, result.record.provider, result.record.account, result.record.model, new Date(nowIso));
1312
+ }
1302
1313
  if (result.changed) {
1303
1314
  summary.dead_host.push({
1304
1315
  id: lane.lane_id,
@@ -43,6 +43,8 @@ export declare function sanitizeHostComponent(hostname: string): string;
43
43
  export declare function mintLaneId(deps?: Pick<StoreDeps, "now" | "hostname"> & Partial<Pick<StoreDeps, "generateLaneUlid">>): string;
44
44
  export declare function mintQuestionId(deps?: Pick<StoreDeps, "now" | "hostname">): string;
45
45
  export declare function appendCappedHistory<T>(history: readonly T[], entry: T, cap?: number): T[];
46
+ /** Run a lane-owned file read/modify/write transaction under its sidecar lock. */
47
+ export declare function withLaneRecordFileLock<T>(recordPath: string, fn: () => T): T;
46
48
  export declare function parseLaneRecord(raw: unknown, filePath?: string): LaneRecord;
47
49
  export declare function parseQuestionRecord(raw: unknown, filePath?: string): QuestionRecord;
48
50
  /**
@@ -222,6 +222,10 @@ function withRecordLock(recordPath, deps, fn) {
222
222
  }
223
223
  }
224
224
  }
225
+ /** Run a lane-owned file read/modify/write transaction under its sidecar lock. */
226
+ export function withLaneRecordFileLock(recordPath, fn) {
227
+ return withRecordLock(recordPath, defaultStoreDeps(), fn);
228
+ }
225
229
  function readJsonFile(filePath, deps) {
226
230
  let text;
227
231
  try {
@@ -40,7 +40,7 @@ import { laneStories, terminalEnvelopeStoryStatus } from "./stories.js";
40
40
  import { appendCappedHistory, listQuestionIds, readLane, readQuestion, retryUpdateLane, withLaneHireLocks } from "./store.js";
41
41
  import { enforceRegistryApprovalGate } from "./approval-gate.js";
42
42
  import { readWorkerRunResult } from "./delivery.js";
43
- import { applyDefaultQuotaHold, quotaSignalFromFields, readQuotaSignal, recordAccountQuota, } from "./quota.js";
43
+ import { applyDefaultQuotaHold, clearAccountQuotaAfterSuccessfulTurnBestEffort, quotaSignalFromFields, readQuotaSignal, recordAccountQuota, } from "./quota.js";
44
44
  import { PROVIDER_REJECTED_REASON, QUOTA_STATUS, isProviderLoginFailure, providerRejectionDetails, readProviderRejection, } from "./provider-rejection.js";
45
45
  import { appendDropboxMessageAtPath, dropboxInboxHasMessages, readToolActivity, toolActivityPath } from "./dropbox.js";
46
46
  import { loadLanesConfig, providerProgressEventMinIntervalMs, providerQuotaDefaultHoldMs } from "./config.js";
@@ -445,6 +445,15 @@ export function applyTerminalToLaneWithResult(hqRoot, laneId, classification, op
445
445
  }
446
446
  return next;
447
447
  });
448
+ if (updated.state === "done" &&
449
+ effectiveClassification.state === "done" &&
450
+ effectiveEnvelope?.decision === "done" &&
451
+ opts.quota == null &&
452
+ opts.providerRejection == null &&
453
+ updated.account &&
454
+ updated.model) {
455
+ clearAccountQuotaAfterSuccessfulTurnBestEffort(hqRoot, updated.provider, updated.account, updated.model, new Date(at));
456
+ }
448
457
  notifySlotFreed({
449
458
  hqRoot,
450
459
  provider: updated.provider,
@@ -307,6 +307,9 @@ export declare function uninstallDaemonService(opts: {
307
307
  unlinkSync?: typeof fs.unlinkSync;
308
308
  existsSync?: typeof fs.existsSync;
309
309
  deactivate?: (platform: DaemonInstallPlatform, dest: string) => void;
310
+ /** Service-manager runner for uninstall deactivation (tests inject; never the real binary). */
311
+ run?: RunCommand;
312
+ uid?: number;
310
313
  /** Windows: schtasks.exe runner (tests inject; never the real binary). */
311
314
  schtasks?: SchtasksRunner;
312
315
  /** Windows: terminate the recorded daemon pid after `schtasks /End` (tests inject). */
@@ -22,7 +22,7 @@ import { isInstallableHqRoot, readDaemonConfig, registerTrustedHqRoot, verifyHqR
22
22
  import { defaultPidLockDeps, pidLockStatus, readPidLock } from "./pid-lock.js";
23
23
  import { retireLegacyListen } from "./legacy-listen.js";
24
24
  import { readDaemonState } from "./state.js";
25
- import { activateLaunchd, launchdIsLoaded, launchdPlistProgramArguments, launchdProgramArgumentsDiffer, parseLaunchctlPrintArguments, } from "../../../service-manager/launchd.js";
25
+ import { activateLaunchd, defaultRunCommand, launchdIsLoaded, launchdServiceTarget, launchdPlistProgramArguments, launchdProgramArgumentsDiffer, parseLaunchctlPrintArguments, } from "../../../service-manager/launchd.js";
26
26
  import { activateSystemd, systemdIsActive } from "../../../service-manager/systemd.js";
27
27
  import { nodeBinaryProblem, preferStableNodeBinary } from "../../../node-binary.js";
28
28
  /**
@@ -955,7 +955,18 @@ export function uninstallDaemonService(opts) {
955
955
  }
956
956
  if (platform === "darwin") {
957
957
  if (existsSync(paths.plistPath)) {
958
- opts.deactivate?.(platform, paths.plistPath);
958
+ if (opts.deactivate) {
959
+ opts.deactivate(platform, paths.plistPath);
960
+ }
961
+ else {
962
+ const run = opts.run ?? defaultRunCommand;
963
+ const target = launchdServiceTarget(paths.plistPath, opts.uid);
964
+ const bootout = run("launchctl", ["bootout", target]);
965
+ if (bootout.status !== 0 && run("launchctl", ["print", target]).status === 0) {
966
+ const detail = (bootout.stderr || bootout.stdout).trim();
967
+ throw new Error(`launchctl bootout failed for ${target}${detail ? `: ${detail}` : ` (exit ${bootout.status})`}`);
968
+ }
969
+ }
959
970
  unlinkSync(paths.plistPath);
960
971
  }
961
972
  return {
@@ -131,6 +131,236 @@ function isPathInside(candidate, parent) {
131
131
  const relative = path.relative(path.resolve(parent), path.resolve(candidate));
132
132
  return relative !== "" && relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative);
133
133
  }
134
+ function readNpmBinMap(packageRoot) {
135
+ try {
136
+ const manifest = JSON.parse(fs.readFileSync(path.join(packageRoot, "package.json"), "utf8"));
137
+ if (manifest.bin === undefined)
138
+ return {};
139
+ const entries = typeof manifest.bin === "string"
140
+ ? [["hq", manifest.bin]]
141
+ : manifest.bin && typeof manifest.bin === "object" && !Array.isArray(manifest.bin)
142
+ ? Object.entries(manifest.bin)
143
+ : null;
144
+ if (!entries)
145
+ return null;
146
+ const bins = {};
147
+ for (const [name, target] of entries) {
148
+ if (!name || name === "." || name === ".." || name.includes("/") || name.includes("\\") ||
149
+ typeof target !== "string" || target.length === 0 || path.isAbsolute(target) ||
150
+ !isPathInside(path.resolve(packageRoot, target), packageRoot)) {
151
+ return null;
152
+ }
153
+ bins[name] = target;
154
+ }
155
+ return bins;
156
+ }
157
+ catch {
158
+ return null;
159
+ }
160
+ }
161
+ function symlinkTargetIsInsidePackage(linkPath, target, packageRoot) {
162
+ return isPathInside(path.resolve(path.dirname(linkPath), target), packageRoot);
163
+ }
164
+ function snapshotNpmBinLinks(prefix, packageRoot) {
165
+ if (process.platform === "win32")
166
+ return [];
167
+ const binMap = readNpmBinMap(packageRoot);
168
+ if (!binMap)
169
+ return [];
170
+ const binDirectory = path.join(prefix, "bin");
171
+ const links = [];
172
+ for (const name of Object.keys(binMap)) {
173
+ const linkPath = path.join(binDirectory, name);
174
+ try {
175
+ if (!fs.lstatSync(linkPath).isSymbolicLink())
176
+ continue;
177
+ const target = fs.readlinkSync(linkPath);
178
+ const resolved = fs.realpathSync(linkPath);
179
+ if (!isPathInside(resolved, packageRoot))
180
+ continue;
181
+ links.push({ path: linkPath, target });
182
+ }
183
+ catch {
184
+ // A missing or dangling entry was not a working npm bin link to snapshot.
185
+ }
186
+ }
187
+ return links;
188
+ }
189
+ function restoreNpmBinLinks(snapshot) {
190
+ if (!snapshot.npmBinDirectory || !snapshot.npmBinPackageRoot || !snapshot.npmBinLinks?.length)
191
+ return true;
192
+ if (process.platform === "win32")
193
+ return true;
194
+ let restored = true;
195
+ for (const bin of snapshot.npmBinLinks) {
196
+ if (path.dirname(bin.path) !== snapshot.npmBinDirectory) {
197
+ reportBinLinkConflict(bin.path, "the saved path is outside the npm bin directory");
198
+ restored = false;
199
+ continue;
200
+ }
201
+ let existing;
202
+ let existingTarget;
203
+ try {
204
+ existing = fs.lstatSync(bin.path);
205
+ }
206
+ catch (error) {
207
+ if (error.code !== "ENOENT") {
208
+ reportBinLinkConflict(bin.path, errorMessage(error));
209
+ restored = false;
210
+ continue;
211
+ }
212
+ }
213
+ if (existing) {
214
+ if (!existing.isSymbolicLink()) {
215
+ reportBinLinkConflict(bin.path, "a regular file owns this command name");
216
+ restored = false;
217
+ continue;
218
+ }
219
+ const currentTarget = fs.readlinkSync(bin.path);
220
+ existingTarget = currentTarget;
221
+ if (!symlinkTargetIsInsidePackage(bin.path, currentTarget, snapshot.npmBinPackageRoot)) {
222
+ reportBinLinkConflict(bin.path, "the existing symlink points outside hq-cli");
223
+ restored = false;
224
+ continue;
225
+ }
226
+ try {
227
+ if (currentTarget === bin.target &&
228
+ isPathInside(fs.realpathSync(bin.path), snapshot.npmBinPackageRoot))
229
+ continue;
230
+ }
231
+ catch {
232
+ // A dangling link into the package is safe to replace with the snapshot.
233
+ }
234
+ }
235
+ if (!symlinkTargetIsInsidePackage(bin.path, bin.target, snapshot.npmBinPackageRoot)) {
236
+ reportBinLinkConflict(bin.path, "the saved npm link target is outside the restored hq-cli package");
237
+ restored = false;
238
+ continue;
239
+ }
240
+ const temporary = `${bin.path}.hq-cli-restore-${process.pid}-${randomBytes(4).toString("hex")}`;
241
+ try {
242
+ fs.mkdirSync(snapshot.npmBinDirectory, { recursive: true });
243
+ if (!existing) {
244
+ // Creating the link directly is deliberately no-clobber: if another
245
+ // tool claimed the command name after lstat, EEXIST preserves it.
246
+ fs.symlinkSync(bin.target, bin.path);
247
+ }
248
+ else {
249
+ fs.symlinkSync(bin.target, temporary);
250
+ const current = fs.lstatSync(bin.path);
251
+ if (!current.isSymbolicLink() || fs.readlinkSync(bin.path) !== existingTarget) {
252
+ throw new Error("the bin entry changed while it was being restored");
253
+ }
254
+ fs.renameSync(temporary, bin.path);
255
+ }
256
+ try {
257
+ if (!isPathInside(fs.realpathSync(bin.path), snapshot.npmBinPackageRoot)) {
258
+ throw new Error("restored link does not resolve into the restored hq-cli package");
259
+ }
260
+ }
261
+ catch (error) {
262
+ fs.rmSync(bin.path, { force: true });
263
+ throw error;
264
+ }
265
+ }
266
+ catch (error) {
267
+ fs.rmSync(temporary, { force: true });
268
+ reportBinLinkConflict(bin.path, errorMessage(error));
269
+ restored = false;
270
+ }
271
+ }
272
+ return restored;
273
+ }
274
+ function reportBinLinkConflict(linkPath, reason) {
275
+ console.error(chalk.yellow(`⚠ Could not restore hq-cli bin link at ${linkPath}; left it untouched (${reason}).`));
276
+ }
277
+ function reportForeignNpmBin(linkPath, reason) {
278
+ console.error(chalk.yellow(`⚠ Leaving hq-cli bin entry at ${linkPath} untouched; another tool owns it (${reason}).`));
279
+ }
280
+ function errorMessage(error) {
281
+ return error instanceof Error ? error.message : String(error);
282
+ }
283
+ function verifyOrRepairNpmBins(prefix, packageRoot) {
284
+ if (process.platform === "win32")
285
+ return { ok: true };
286
+ let livePackageRoot;
287
+ try {
288
+ livePackageRoot = fs.realpathSync(packageRoot);
289
+ }
290
+ catch (error) {
291
+ return { ok: false, detail: `the installed hq-cli package is missing: ${errorMessage(error)}` };
292
+ }
293
+ const bins = readNpmBinMap(livePackageRoot);
294
+ if (!bins || typeof bins.hq !== "string" || bins.hq.length === 0) {
295
+ return { ok: false, detail: "the installed hq-cli package does not declare a valid hq bin" };
296
+ }
297
+ const binDirectory = path.join(prefix, "bin");
298
+ for (const [name, target] of Object.entries(bins)) {
299
+ const linkPath = path.join(binDirectory, name);
300
+ const expected = path.resolve(livePackageRoot, target);
301
+ let existing;
302
+ let existingTarget;
303
+ try {
304
+ existing = fs.lstatSync(linkPath);
305
+ }
306
+ catch (error) {
307
+ if (error.code !== "ENOENT") {
308
+ return { ok: false, detail: `could not inspect ${linkPath}: ${errorMessage(error)}` };
309
+ }
310
+ }
311
+ if (existing) {
312
+ if (!existing.isSymbolicLink()) {
313
+ if (name === "hq") {
314
+ return { ok: false, detail: `cannot repair ${linkPath}: a regular file owns this command name` };
315
+ }
316
+ reportForeignNpmBin(linkPath, "a regular file owns this command name");
317
+ continue;
318
+ }
319
+ const currentTarget = fs.readlinkSync(linkPath);
320
+ existingTarget = currentTarget;
321
+ if (!symlinkTargetIsInsidePackage(linkPath, currentTarget, livePackageRoot)) {
322
+ if (name === "hq") {
323
+ return { ok: false, detail: `cannot repair ${linkPath}: the existing symlink points outside hq-cli` };
324
+ }
325
+ reportForeignNpmBin(linkPath, "the existing symlink points outside hq-cli");
326
+ continue;
327
+ }
328
+ try {
329
+ if (fs.realpathSync(linkPath) === expected && fs.statSync(linkPath).isFile())
330
+ continue;
331
+ }
332
+ catch {
333
+ // Missing and dangling hq-cli links are repaired below.
334
+ }
335
+ }
336
+ const temporary = `${linkPath}.hq-cli-repair-${process.pid}-${randomBytes(4).toString("hex")}`;
337
+ try {
338
+ fs.mkdirSync(binDirectory, { recursive: true });
339
+ const targetPath = path.relative(binDirectory, expected);
340
+ if (!existing) {
341
+ // A direct symlink create does not replace a file that appeared after
342
+ // lstat; EEXIST is surfaced as an update failure instead.
343
+ fs.symlinkSync(targetPath, linkPath);
344
+ }
345
+ else {
346
+ fs.symlinkSync(targetPath, temporary);
347
+ const current = fs.lstatSync(linkPath);
348
+ if (!current.isSymbolicLink() || fs.readlinkSync(linkPath) !== existingTarget) {
349
+ throw new Error("the bin entry changed while it was being repaired");
350
+ }
351
+ fs.renameSync(temporary, linkPath);
352
+ }
353
+ if (fs.realpathSync(linkPath) !== expected || !fs.statSync(linkPath).isFile()) {
354
+ throw new Error("repaired link does not resolve to its declared package bin");
355
+ }
356
+ }
357
+ catch (error) {
358
+ fs.rmSync(temporary, { force: true });
359
+ return { ok: false, detail: `could not repair ${linkPath}: ${errorMessage(error)}` };
360
+ }
361
+ }
362
+ return { ok: true };
363
+ }
134
364
  function isInsideNodeModules(candidate) {
135
365
  const segments = path.resolve(candidate).replace(/\\/g, "/").split("/").filter(Boolean);
136
366
  const index = process.platform === "win32"
@@ -159,8 +389,14 @@ function snapshotPathsAreSafe(snapshot) {
159
389
  snapshot.movedPath,
160
390
  snapshot.resolvedTarget,
161
391
  snapshot.publicLinkPath,
392
+ snapshot.npmBinPackageRoot,
162
393
  ].filter((value) => value !== undefined);
163
- return paths.every(isInsideNodeModules);
394
+ if (!paths.every(isInsideNodeModules))
395
+ return false;
396
+ if (snapshot.npmBinLinks?.some((bin) => !snapshot.npmBinDirectory || path.dirname(bin.path) !== snapshot.npmBinDirectory ||
397
+ !isPathInside(bin.path, snapshot.npmBinDirectory)))
398
+ return false;
399
+ return true;
164
400
  }
165
401
  function snapshotInstall(install, renamePath = fs.renameSync) {
166
402
  const packageRoot = install.packageRoot;
@@ -200,6 +436,9 @@ function snapshotInstall(install, renamePath = fs.renameSync) {
200
436
  const pnpmPublicLinkPath = publicLink?.path ?? (entry.isSymbolicLink() ? packageRoot : undefined);
201
437
  if (backupKind === "pnpm-public-link" && !pnpmPublicLinkPath)
202
438
  return null;
439
+ const npmBinLinks = backupKind === "package" && install.manager === "npm" && install.prefix
440
+ ? snapshotNpmBinLinks(install.prefix, sourcePath)
441
+ : undefined;
203
442
  movedPath = backupKind === "pnpm-public-link" ? pnpmPublicLinkPath : sourcePath;
204
443
  backupPath = path.join(path.dirname(movedPath), `.hq-cli-backup-${process.pid}-${randomBytes(8).toString("hex")}`);
205
444
  renamePath(movedPath, backupPath);
@@ -215,6 +454,9 @@ function snapshotInstall(install, renamePath = fs.renameSync) {
215
454
  publicLinkTarget: backupKind === "pnpm-public-link"
216
455
  ? publicLink?.target ?? symlinkTarget
217
456
  : publicLink?.target,
457
+ npmBinDirectory: npmBinLinks ? path.join(install.prefix, "bin") : undefined,
458
+ npmBinPackageRoot: npmBinLinks ? sourcePath : undefined,
459
+ npmBinLinks,
218
460
  };
219
461
  }
220
462
  catch {
@@ -260,7 +502,7 @@ function restoreInstall(snapshot, renamePath = fs.renameSync) {
260
502
  fs.mkdirSync(path.dirname(snapshot.publicLinkPath), { recursive: true });
261
503
  fs.symlinkSync(snapshot.publicLinkTarget, snapshot.publicLinkPath, process.platform === "win32" ? "junction" : "dir");
262
504
  }
263
- return true;
505
+ return restoreNpmBinLinks(snapshot);
264
506
  }
265
507
  catch {
266
508
  reportFailedRestore(snapshot);
@@ -458,6 +700,11 @@ async function updateAndReexec(argv, flavor, known, deps) {
458
700
  else {
459
701
  result = runner(plan.cmd, plan.args, updateEnv);
460
702
  }
703
+ if (result.ok && !staged && install.manager === "npm" && install.prefix && install.packageRoot) {
704
+ const bins = verifyOrRepairNpmBins(install.prefix, install.packageRoot);
705
+ if (!bins.ok)
706
+ result = bins;
707
+ }
461
708
  if (!result.ok && installSnapshot) {
462
709
  restoredAfterFailure = restoreInstall(installSnapshot, renamePath);
463
710
  if (restoredAfterFailure)
@@ -89,7 +89,7 @@ export function stagedNpmInstall(install, version, deps = {}) {
89
89
  }
90
90
  return { ok: false, detail: `could not move hq-cli ${version} into place: ${message(error)}` };
91
91
  }
92
- if (!linkBins(prefix, live, readBins(backup), tag)) {
92
+ if (!linkBins(prefix, live, readBins(backup), tag, rename)) {
93
93
  // `hq` does not reach the new package, so put the old one back.
94
94
  try {
95
95
  rename(live, staged);
@@ -99,10 +99,10 @@ export function stagedNpmInstall(install, version, deps = {}) {
99
99
  keepBackup = true;
100
100
  return {
101
101
  ok: false,
102
- detail: `could not link hq to hq-cli ${version} or restore the previous install from ${backup} (${message(error)})`,
102
+ detail: `could not verify or relink every declared hq-cli bin for ${version}, or restore the previous install from ${backup} (${message(error)})`,
103
103
  };
104
104
  }
105
- return { ok: false, detail: `could not point ${path.join(prefix, "bin", "hq")} at hq-cli ${version}` };
105
+ return { ok: false, detail: `could not verify or relink every declared hq-cli bin for ${version}` };
106
106
  }
107
107
  try {
108
108
  postinstall(live);
@@ -136,11 +136,11 @@ export function stagedNpmInstall(install, version, deps = {}) {
136
136
  * links already point at the live path. A link into another package, or a
137
137
  * regular file (someone's wrapper), is left alone.
138
138
  *
139
- * `hq` is settled first. If it does not resolve to a file, its link is put
140
- * back as it was, no other bin is touched, and this returns false so the
141
- * caller can restore the old package.
139
+ * `hq` must resolve into the live package or the caller restores the old
140
+ * package. Other declared bins owned by another tool are left alone and warned
141
+ * about; bins we own or need to create must resolve into the live package.
142
142
  */
143
- function linkBins(prefix, live, previousBins, tag) {
143
+ function linkBins(prefix, live, previousBins, tag, rename) {
144
144
  const binDir = path.join(prefix, "bin");
145
145
  const ownedByHqCli = (link) => {
146
146
  try {
@@ -156,7 +156,7 @@ function linkBins(prefix, live, previousBins, tag) {
156
156
  try {
157
157
  fs.mkdirSync(binDir, { recursive: true });
158
158
  fs.symlinkSync(want, temporary);
159
- fs.renameSync(temporary, link);
159
+ rename(temporary, link);
160
160
  }
161
161
  catch {
162
162
  try {
@@ -185,30 +185,70 @@ function linkBins(prefix, live, previousBins, tag) {
185
185
  pointAt(link, want);
186
186
  };
187
187
  const bins = readBins(live);
188
- const hqLink = path.join(binDir, "hq");
189
- const hqBefore = symlinkTarget(hqLink);
190
- if (bins.hq)
191
- linkBin("hq", bins.hq);
192
- if (!resolvesToFile(hqLink)) {
193
- const hqAfter = symlinkTarget(hqLink);
194
- if (hqAfter !== hqBefore) {
195
- if (hqBefore === null) {
188
+ if (typeof bins.hq !== "string" || bins.hq.length === 0)
189
+ return false;
190
+ const foreignBins = new Set();
191
+ for (const [name, target] of Object.entries(bins)) {
192
+ if (!name || name === "." || name === ".." || name.includes("/") || name.includes("\\"))
193
+ return false;
194
+ const targetPath = path.resolve(live, target);
195
+ if (!targetPath.startsWith(`${path.resolve(live)}${path.sep}`) || !resolvesToFile(targetPath))
196
+ return false;
197
+ const link = path.join(binDir, name);
198
+ try {
199
+ const existing = fs.lstatSync(link, { throwIfNoEntry: false });
200
+ if (existing && (!existing.isSymbolicLink() || !ownedByHqCli(link))) {
201
+ if (name === "hq")
202
+ return false;
203
+ foreignBins.add(name);
204
+ console.error(`⚠ Leaving hq-cli bin entry at ${link} untouched; another tool owns it.`);
205
+ }
206
+ }
207
+ catch {
208
+ return false;
209
+ }
210
+ }
211
+ const changedBins = [];
212
+ const linkAndRecord = (name, target) => {
213
+ const link = path.join(binDir, name);
214
+ const before = symlinkTarget(link);
215
+ linkBin(name, target);
216
+ const after = symlinkTarget(link);
217
+ if (after !== before)
218
+ changedBins.push({ link, before });
219
+ };
220
+ const rollbackBinChanges = () => {
221
+ for (const change of changedBins.reverse()) {
222
+ const current = symlinkTarget(change.link);
223
+ if (current === null || !ownedByHqCli(change.link))
224
+ continue;
225
+ if (change.before === null) {
196
226
  try {
197
- fs.unlinkSync(hqLink);
227
+ fs.unlinkSync(change.link);
198
228
  }
199
229
  catch {
200
- // Nothing to undo.
230
+ // The staged update still fails; preserve the best-effort rollback result.
201
231
  }
202
232
  }
203
233
  else {
204
- pointAt(hqLink, hqBefore);
234
+ pointAt(change.link, change.before);
205
235
  }
206
236
  }
237
+ };
238
+ const hqLink = path.join(binDir, "hq");
239
+ linkAndRecord("hq", bins.hq);
240
+ if (!resolvesToPackageBin(hqLink, live, bins.hq)) {
241
+ rollbackBinChanges();
207
242
  return false;
208
243
  }
209
244
  for (const [name, target] of Object.entries(bins)) {
210
- if (name !== "hq")
211
- linkBin(name, target);
245
+ if (name === "hq" || foreignBins.has(name))
246
+ continue;
247
+ linkAndRecord(name, target);
248
+ if (!resolvesToPackageBin(path.join(binDir, name), live, target)) {
249
+ rollbackBinChanges();
250
+ return false;
251
+ }
212
252
  }
213
253
  for (const name of Object.keys(previousBins)) {
214
254
  if (name in bins)
@@ -240,6 +280,16 @@ function resolvesToFile(link) {
240
280
  return false;
241
281
  }
242
282
  }
283
+ function resolvesToPackageBin(link, packageRoot, target) {
284
+ try {
285
+ return fs.lstatSync(link).isSymbolicLink() &&
286
+ fs.realpathSync(link) === fs.realpathSync(path.resolve(packageRoot, target)) &&
287
+ fs.statSync(link).isFile();
288
+ }
289
+ catch {
290
+ return false;
291
+ }
292
+ }
243
293
  function readBins(packageRoot) {
244
294
  try {
245
295
  const pkg = JSON.parse(fs.readFileSync(path.join(packageRoot, "package.json"), "utf8"));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.298.0",
3
+ "version": "5.299.0",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {