@indigoai-us/hq-cli 5.345.21 → 5.345.22

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,7 +2,12 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.345.22] — 2026-10-06
6
+ - Fix Claude quota holds so time-only reset messages keep accounts unavailable until the provider's stated UTC reset, and expose the hold state in host capacity output.
7
+
5
8
  ## [5.345.21] — 2026-10-06
9
+ - `hq sync push --personal <path>` now waits up to 500 ms for the startup flag registry snapshot before evaluating `cli.personal-targeted-push-paths`, while preserving the legacy refusal when the registry is unavailable.
10
+
6
11
  - Hook timeout reports now show elapsed time against the configured budget and include available phase and hook attribution.
7
12
  - Reconcile hire-grant carve accounting across resume, reparent and grant updates; decreases no longer fail because sibling grants exceed the senior allocation.
8
13
  - Route eligible Linux pull-request checks and local release guards through the registered ARM64 self-hosted runner. Keep the shared npm/OIDC publish job on its existing runner.
@@ -840,7 +840,7 @@ export function registerCloudCommands(program) {
840
840
  "to have already resolved entity + credentials. Pick one.");
841
841
  }
842
842
  // Closes hq-cli#25 — see `assertNoPersonalPositionalPaths` doc-block.
843
- const targetedPersonalPathsEnabled = Boolean(options.personal && paths && paths.length > 0 && personalTargetedPushPathsEnabled());
843
+ const targetedPersonalPathsEnabled = Boolean(options.personal && paths && paths.length > 0 && await personalTargetedPushPathsEnabled());
844
844
  assertNoPersonalPositionalPaths(options, paths, targetedPersonalPathsEnabled);
845
845
  if (targetedPersonalPathsEnabled) {
846
846
  explicitPersonalPaths = resolveExplicitPersonalPushPaths(options.hqRoot, paths);
@@ -5306,6 +5306,7 @@ async function runCapacity(ctx) {
5306
5306
  env: ctx.env,
5307
5307
  hostname: ctx.hostname(),
5308
5308
  now: ctx.now(),
5309
+ quotaState: loadQuotaState(hqRoot),
5309
5310
  });
5310
5311
  printJson({
5311
5312
  ...snapshot,
@@ -1,4 +1,4 @@
1
1
  export declare const CLI_PERSONAL_TARGETED_PUSH_PATHS_FLAG = "cli.personal-targeted-push-paths";
2
2
  /** The established full-scope behavior remains in force until an explicit hq-flags enablement. */
3
- export declare function personalTargetedPushPathsEnabled(): boolean;
3
+ export declare function personalTargetedPushPathsEnabled(): Promise<boolean>;
4
4
  //# sourceMappingURL=personal-targeted-push-flag.d.ts.map
@@ -1,7 +1,11 @@
1
- import { resolveProcessFlagGate } from "../lib/flag-registry.js";
1
+ import { resolveProcessFlagGate, waitForFlagRegistryRefresh, } from "../lib/flag-registry.js";
2
2
  export const CLI_PERSONAL_TARGETED_PUSH_PATHS_FLAG = "cli.personal-targeted-push-paths";
3
3
  /** The established full-scope behavior remains in force until an explicit hq-flags enablement. */
4
- export function personalTargetedPushPathsEnabled() {
4
+ export async function personalTargetedPushPathsEnabled() {
5
+ // This command reads the gate immediately after startup. Give the existing
6
+ // unref'd registry worker one bounded window to publish its company snapshot.
7
+ // An unavailable registry leaves resolveProcessFlagGate on the legacy false.
8
+ await waitForFlagRegistryRefresh(500);
5
9
  return resolveProcessFlagGate(CLI_PERSONAL_TARGETED_PUSH_PATHS_FLAG, { fallback: false }, () => false);
6
10
  }
7
11
  //# sourceMappingURL=personal-targeted-push-flag.js.map
@@ -1,4 +1,5 @@
1
1
  import { type AuthState } from "./accounts.js";
2
+ import { type QuotaState } from "./quota.js";
2
3
  import type { LaneHost, LaneProvider, LaneRecord, LanesConfig } from "./types.js";
3
4
  export interface HostCapacityAccount {
4
5
  enabled: boolean;
@@ -8,6 +9,9 @@ export interface HostCapacityAccount {
8
9
  max_running: number;
9
10
  running: number;
10
11
  queued: number;
12
+ /** Account-wide quota status when this snapshot is produced on its host. */
13
+ quota_held?: boolean;
14
+ quota_reset_at?: string;
11
15
  /** Exact lane states used for Claude account balancing; absent on older Outposts. */
12
16
  lane_counts?: {
13
17
  running: number;
@@ -54,6 +58,7 @@ export declare function buildHostCapacitySnapshot(input: {
54
58
  env?: NodeJS.ProcessEnv;
55
59
  hostname?: string;
56
60
  now: Date;
61
+ quotaState?: QuotaState;
57
62
  }): HostCapacitySnapshot;
58
63
  /** Reject malformed remote snapshots before they can influence host selection. */
59
64
  export declare function parseHostCapacitySnapshot(raw: unknown): HostCapacitySnapshot;
@@ -2,6 +2,7 @@ import { accountStatus } from "./accounts.js";
2
2
  import { accountMaxRunning } from "./config.js";
3
3
  import { laneOccupiesRunningSlot, laneRunsOnHost, } from "./admission.js";
4
4
  import { LanesError } from "./errors.js";
5
+ import { quotaBlockFor } from "./quota.js";
5
6
  function admissionHost(host) {
6
7
  return host.kind === "outpost"
7
8
  ? { hostname: "", outpostId: host.id }
@@ -33,6 +34,10 @@ export function buildHostCapacitySnapshot(input) {
33
34
  });
34
35
  const accountLanes = providerLanes.filter((lane) => lane.account === id);
35
36
  const liveLanes = accountLanes.filter((lane) => lane.state === "running" || lane.state === "queued" || lane.state === "awaiting_input");
37
+ const quotaBlock = input.quotaState
38
+ ? quotaBlockFor(input.quotaState, provider, id, undefined, input.now)
39
+ : undefined;
40
+ const quotaResetAt = input.quotaState?.providers[provider]?.[id]?.all_models_quota_reset_at;
36
41
  accounts[id] = {
37
42
  enabled: account.enabled,
38
43
  logged_in: status.logged_in,
@@ -40,6 +45,12 @@ export function buildHostCapacitySnapshot(input) {
40
45
  max_running: accountMaxRunning(providerConfig, id),
41
46
  running: running.filter((lane) => lane.account === id).length,
42
47
  queued: queued.filter((lane) => lane.account === id).length,
48
+ ...(quotaBlock !== undefined
49
+ ? {
50
+ quota_held: quotaBlock.blocked,
51
+ ...(quotaResetAt ? { quota_reset_at: quotaResetAt } : {}),
52
+ }
53
+ : {}),
43
54
  lane_counts: {
44
55
  running: liveLanes.filter((lane) => lane.state === "running").length,
45
56
  queued: liveLanes.filter((lane) => lane.state === "queued").length,
@@ -108,6 +119,9 @@ export function parseHostCapacitySnapshot(raw) {
108
119
  if (id.trim() === "" || account === undefined ||
109
120
  typeof account.enabled !== "boolean" ||
110
121
  !(account.logged_in === null || typeof account.logged_in === "boolean") ||
122
+ (account.quota_held !== undefined && typeof account.quota_held !== "boolean") ||
123
+ (account.quota_reset_at !== undefined &&
124
+ (typeof account.quota_reset_at !== "string" || !Number.isFinite(Date.parse(account.quota_reset_at)))) ||
111
125
  (account.auth_state !== undefined &&
112
126
  !["file", "status", "absent", "unknown", "home_missing"].includes(account.auth_state))) {
113
127
  throw new LanesError("host_capacity_invalid", `Malformed ${provider} account capacity`, {
@@ -124,6 +138,10 @@ export function parseHostCapacitySnapshot(raw) {
124
138
  max_running: requireCount(account.max_running, `${provider}.${id}.max_running`, true),
125
139
  running: requireCount(account.running, `${provider}.${id}.running`),
126
140
  queued: requireCount(account.queued, `${provider}.${id}.queued`),
141
+ ...(account.quota_held !== undefined ? { quota_held: account.quota_held } : {}),
142
+ ...(account.quota_reset_at !== undefined
143
+ ? { quota_reset_at: account.quota_reset_at }
144
+ : {}),
127
145
  ...(account.lane_counts !== undefined
128
146
  ? { lane_counts: parseLaneCounts(account.lane_counts, `${provider}.${id}.lane_counts`) }
129
147
  : {}),
@@ -36,6 +36,8 @@ export interface ProviderRejection {
36
36
  retryable: boolean;
37
37
  /** Which event on the stream carried the failure. */
38
38
  event: "codex.turn.failed" | "claude.result.is_error" | "claude.result.login_failure" | "grok.error";
39
+ /** Filesystem time when the provider output was appended, when read from worker.out. */
40
+ output_at?: string;
39
41
  }
40
42
  export declare function isProviderLoginFailure(failure: ProviderRejection | null): boolean;
41
43
  /**
@@ -271,9 +271,21 @@ export function parseProviderRejection(provider, text) {
271
271
  export function readProviderRejection(runDir, provider, workerOutOffset = 0) {
272
272
  const outPath = path.join(runDir, "logs", "worker.out");
273
273
  let text;
274
+ let outputAt;
274
275
  try {
275
276
  const bytes = fs.readFileSync(outPath);
276
277
  text = bytes.subarray(Math.min(workerOutOffset, bytes.length)).toString("utf8");
278
+ const outputMtime = fs.statSync(outPath).mtime;
279
+ let spawnMtime;
280
+ try {
281
+ spawnMtime = fs.statSync(path.join(runDir, "spawn.json")).mtime;
282
+ }
283
+ catch (err) {
284
+ if (err.code !== "ENOENT")
285
+ throw err;
286
+ }
287
+ if (!spawnMtime || outputMtime >= spawnMtime)
288
+ outputAt = outputMtime.toISOString();
277
289
  }
278
290
  catch (err) {
279
291
  const code = err.code;
@@ -281,6 +293,9 @@ export function readProviderRejection(runDir, provider, workerOutOffset = 0) {
281
293
  return null;
282
294
  throw err;
283
295
  }
284
- return parseProviderRejection(provider, text);
296
+ const failure = parseProviderRejection(provider, text);
297
+ return failure
298
+ ? { ...failure, ...(outputAt ? { output_at: outputAt } : {}) }
299
+ : null;
285
300
  }
286
301
  //# sourceMappingURL=provider-rejection.js.map
@@ -26,13 +26,13 @@ export interface ModelQuota {
26
26
  /** Operator-facing marker, e.g. "needs credits for claude-fable-5-1". */
27
27
  marker?: string;
28
28
  }
29
- export type QuotaResetSource = "provider" | "default_hold";
29
+ export type QuotaResetSource = "parsed" | "derived";
30
30
  export interface AccountQuota {
31
31
  /** Set when the 429 is an account-wide usage limit with a reset time. */
32
32
  all_models_quota_reset_at?: string;
33
33
  /**
34
- * Where `all_models_quota_reset_at` came from: the provider's own message,
35
- * or the configured default hold applied to a 429 that named no reset.
34
+ * Whether `all_models_quota_reset_at` came from a parsed provider reset or
35
+ * the configured default hold applied to a 429 with no parseable reset.
36
36
  */
37
37
  reset_source?: QuotaResetSource;
38
38
  models: Record<string, ModelQuota>;
@@ -57,8 +57,8 @@ export interface QuotaSignal {
57
57
  * otherwise record no hold at all, and the next lane on the account would
58
58
  * hit the same 429. Give such a signal the provider's configured default
59
59
  * hold (`quota_default_hold_ms`, fifteen minutes unless set) as its reset,
60
- * marked `default_hold`; a signal that already carries a reset keeps it,
61
- * marked `provider`. A per-model credits refusal is untouched.
60
+ * marked `derived`; a signal that already carries a reset keeps it, marked
61
+ * `parsed`. A per-model credits refusal is untouched.
62
62
  */
63
63
  export declare function applyDefaultQuotaHold(signal: QuotaSignal, defaultHoldMs: number, now: Date): QuotaSignal;
64
64
  export declare function quotaStatePath(hqRoot: string): string;
@@ -27,19 +27,19 @@ export const QUOTA_SCHEMA_VERSION = "hq-lanes-quota.v1";
27
27
  * otherwise record no hold at all, and the next lane on the account would
28
28
  * hit the same 429. Give such a signal the provider's configured default
29
29
  * hold (`quota_default_hold_ms`, fifteen minutes unless set) as its reset,
30
- * marked `default_hold`; a signal that already carries a reset keeps it,
31
- * marked `provider`. A per-model credits refusal is untouched.
30
+ * marked `derived`; a signal that already carries a reset keeps it, marked
31
+ * `parsed`. A per-model credits refusal is untouched.
32
32
  */
33
33
  export function applyDefaultQuotaHold(signal, defaultHoldMs, now) {
34
34
  if (signal.reason !== "account_quota_exhausted" || signal.needs_credits)
35
35
  return signal;
36
36
  if (signal.quota_reset_at)
37
- return { ...signal, reset_source: "provider" };
37
+ return { ...signal, reset_source: "parsed" };
38
38
  return {
39
39
  ...signal,
40
40
  quota_reset_at: new Date(now.getTime() + defaultHoldMs).toISOString(),
41
41
  account_wide: true,
42
- reset_source: "default_hold",
42
+ reset_source: "derived",
43
43
  };
44
44
  }
45
45
  const MONTHS = {
@@ -119,8 +119,13 @@ export function loadQuotaState(hqRoot) {
119
119
  if (typeof raw.all_models_quota_reset_at === "string" &&
120
120
  raw.all_models_quota_reset_at.length > 0) {
121
121
  account.all_models_quota_reset_at = raw.all_models_quota_reset_at;
122
- if (raw.reset_source === "provider" || raw.reset_source === "default_hold") {
123
- account.reset_source = raw.reset_source;
122
+ // Normalize values written by older CLI versions while preserving the
123
+ // distinction between provider-parsed and configured fallback times.
124
+ if (raw.reset_source === "provider" || raw.reset_source === "parsed") {
125
+ account.reset_source = "parsed";
126
+ }
127
+ else if (raw.reset_source === "default_hold" || raw.reset_source === "derived") {
128
+ account.reset_source = "derived";
124
129
  }
125
130
  }
126
131
  accounts[id] = account;
@@ -145,6 +150,22 @@ function writeQuotaState(hqRoot, state) {
145
150
  * An explicit provider year is kept as stated.
146
151
  */
147
152
  export function parseProviderResetTime(text, now) {
153
+ const timeOnlyMatch = /\bresets\s+(\d{1,2})(?::(\d{2}))?\s*(am|pm)\s*\(UTC\)/i.exec(text);
154
+ if (timeOnlyMatch) {
155
+ let hour = Number(timeOnlyMatch[1]);
156
+ const minute = Number(timeOnlyMatch[2] ?? 0);
157
+ const meridiem = (timeOnlyMatch[3] ?? "").toLowerCase();
158
+ if (!Number.isInteger(hour) || hour < 1 || hour > 12 || minute < 0 || minute > 59)
159
+ return null;
160
+ if (meridiem === "pm" && hour < 12)
161
+ hour += 12;
162
+ if (meridiem === "am" && hour === 12)
163
+ hour = 0;
164
+ const candidate = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate(), hour, minute, 0, 0));
165
+ if (candidate.getTime() <= now.getTime())
166
+ candidate.setUTCDate(candidate.getUTCDate() + 1);
167
+ return candidate.toISOString();
168
+ }
148
169
  const resetMatch = /\bresets\s+([A-Za-z]+)\s+(\d{1,2})(?:st|nd|rd|th)?(?:,\s*(?:(\d{4})\s+)?(\d{1,2})(?::(\d{2}))?\s*(am|pm))?(?:\s*\((UTC)\))?/i.exec(text);
149
170
  const codexMatch = /\btry\s+again\s+at\s+([A-Za-z]+)\s+(\d{1,2})(?:st|nd|rd|th)?,\s*(\d{4})\s+(\d{1,2})(?::(\d{2}))?\s*(am|pm)(?:\s*\((UTC)\))?/i.exec(text);
150
171
  const match = resetMatch ?? codexMatch;
@@ -263,9 +284,23 @@ export function quotaSignalFromFields(fields, model, now) {
263
284
  export function readQuotaSignal(runDir, model, now, workerOutOffset = 0) {
264
285
  const outPath = path.join(runDir, "logs", "worker.out");
265
286
  let text;
287
+ let outputAt;
266
288
  try {
267
289
  const bytes = fs.readFileSync(outPath);
268
290
  text = bytes.subarray(Math.min(workerOutOffset, bytes.length)).toString("utf8");
291
+ // worker.out is appended as provider output arrives. Its mtime anchors a
292
+ // time-only reset to the output event, not to a later reconcile pass.
293
+ outputAt = fs.statSync(outPath).mtime;
294
+ let spawnAt;
295
+ try {
296
+ spawnAt = fs.statSync(path.join(runDir, "spawn.json")).mtime;
297
+ }
298
+ catch (err) {
299
+ if (err.code !== "ENOENT")
300
+ throw err;
301
+ }
302
+ if (outputAt > now || (spawnAt && outputAt < spawnAt))
303
+ outputAt = now;
269
304
  }
270
305
  catch (err) {
271
306
  const code = err.code;
@@ -276,7 +311,8 @@ export function readQuotaSignal(runDir, model, now, workerOutOffset = 0) {
276
311
  const fields = quotaFieldsFromOutput(text);
277
312
  if (!fields)
278
313
  return null;
279
- return quotaSignalFromFields(fields, model, now);
314
+ const observedAt = Number.isFinite(outputAt.getTime()) ? outputAt : now;
315
+ return quotaSignalFromFields(fields, model, observedAt);
280
316
  }
281
317
  export function recordAccountQuota(hqRoot, provider, accountId, signal) {
282
318
  return withLaneRecordFileLock(quotaStatePath(hqRoot), () => {
@@ -303,11 +339,11 @@ export function recordAccountQuota(hqRoot, provider, accountId, signal) {
303
339
  if (signal.account_wide && signal.quota_reset_at) {
304
340
  if (shouldReplaceReset(current.all_models_quota_reset_at, signal.quota_reset_at)) {
305
341
  account.all_models_quota_reset_at = signal.quota_reset_at;
306
- account.reset_source = signal.reset_source ?? "provider";
342
+ account.reset_source = signal.reset_source ?? "parsed";
307
343
  }
308
344
  else if (current.all_models_quota_reset_at) {
309
345
  account.all_models_quota_reset_at = current.all_models_quota_reset_at;
310
- account.reset_source = current.reset_source ?? "provider";
346
+ account.reset_source = current.reset_source ?? "parsed";
311
347
  }
312
348
  }
313
349
  accounts[accountId] = account;
@@ -19,7 +19,7 @@ import { type LaneCompanyRef, type LaneProvider, type LaneRecord, type AccountHi
19
19
  export declare const LANES_RUNS_REL: string;
20
20
  /** Written by reconcile before a resume spawn; the worker prompt names this path. */
21
21
  export declare const ANSWERED_QUESTIONS_FILE = "answered-questions.json";
22
- export declare const SPAWN_TERMINAL_REASONS: readonly ["account_not_logged_in", "unknown_account", "account_disabled", "no_enabled_account", "spawn_failed", "not_detached", "watcher_not_armed", "model_unsupported"];
22
+ export declare const SPAWN_TERMINAL_REASONS: readonly ["account_not_logged_in", "unknown_account", "account_disabled", "no_enabled_account", "spawn_failed", "not_detached", "watcher_not_armed", "model_unsupported", "account_quota_exhausted"];
23
23
  export type SpawnTerminalReason = (typeof SPAWN_TERMINAL_REASONS)[number];
24
24
  declare const PROVIDER_COMMAND: Record<LaneProvider, string>;
25
25
  export declare function providerCommandOnPath(command: string, pathEnv: string | undefined): boolean;
@@ -50,6 +50,7 @@ export const SPAWN_TERMINAL_REASONS = [
50
50
  "not_detached",
51
51
  "watcher_not_armed",
52
52
  "model_unsupported",
53
+ "account_quota_exhausted",
53
54
  ];
54
55
  const PROVIDER_COMMAND = {
55
56
  claude: "claude",
@@ -108,18 +108,6 @@ export declare function classifyLaneExit(input: {
108
108
  */
109
109
  providerRejection?: ProviderRejection | null;
110
110
  }): TerminalClassification;
111
- /**
112
- * One decision for an exited worker's output. An explicit provider status
113
- * wins over the quota text heuristics: a 429 from any provider (including
114
- * codex `turn.failed` and grok error objects, which the claude-shaped quota
115
- * reader never sees) becomes a quota signal; any other explicit status is a
116
- * rejection even when its text sounds like a quota message (a 402 "requires
117
- * usage credits" is a rejection, not a hold). A failure with no status at
118
- * all is matched against the quota text heuristics first: a status-less
119
- * "usage limit" / "rate limit" turn.failed still creates the account hold;
120
- * any other status-less message is a rejection. Only when the stream
121
- * carries no failure object does the quota reader's own read apply.
122
- */
123
111
  export declare function routeProviderFailure(failure: ProviderRejection | null, readQuota: () => QuotaSignal | null, model: string | undefined, now: Date): {
124
112
  quota: QuotaSignal | null;
125
113
  rejection: ProviderRejection | null;
@@ -139,15 +139,21 @@ export function classifyLaneExit(input) {
139
139
  * any other status-less message is a rejection. Only when the stream
140
140
  * carries no failure object does the quota reader's own read apply.
141
141
  */
142
+ function validFailureOutputTime(value, fallback) {
143
+ const parsed = new Date(value);
144
+ return Number.isFinite(parsed.getTime()) && parsed.getTime() <= fallback.getTime()
145
+ ? parsed
146
+ : fallback;
147
+ }
142
148
  export function routeProviderFailure(failure, readQuota, model, now) {
143
149
  if (failure && failure.status === QUOTA_STATUS) {
144
150
  return {
145
- quota: quotaSignalFromFields({ api_error_status: QUOTA_STATUS, is_error: true, result: failure.message }, model, now),
151
+ quota: quotaSignalFromFields({ api_error_status: QUOTA_STATUS, is_error: true, result: failure.message }, model, failure.output_at ? validFailureOutputTime(failure.output_at, now) : now),
146
152
  rejection: null,
147
153
  };
148
154
  }
149
155
  if (failure && failure.status === null) {
150
- const quota = quotaSignalFromFields({ api_error_status: undefined, is_error: true, result: failure.message }, model, now);
156
+ const quota = quotaSignalFromFields({ api_error_status: undefined, is_error: true, result: failure.message }, model, failure.output_at ? validFailureOutputTime(failure.output_at, now) : now);
151
157
  if (quota)
152
158
  return { quota, rejection: null };
153
159
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.345.21",
3
+ "version": "5.345.22",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {