@indigoai-us/hq-cli 5.153.0 → 5.155.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.
@@ -166,7 +166,7 @@ export declare const TERMINAL_LANE_STATES: readonly ["done", "failed", "interrup
166
166
  export type TerminalLaneState = (typeof TERMINAL_LANE_STATES)[number];
167
167
  export declare const LANE_PROVIDERS: readonly ["claude", "codex", "grok"];
168
168
  export type LaneProvider = (typeof LANE_PROVIDERS)[number];
169
- export declare const QUEUED_REASONS: readonly ["cap", "provider_cap", "account_cap", "profile_cap", "queue_position", "throttle", "provider_binary_missing"];
169
+ export declare const QUEUED_REASONS: readonly ["cap", "provider_cap", "account_cap", "profile_cap", "queue_position", "throttle", "provider_binary_missing", "account_retry"];
170
170
  export type QueuedReason = (typeof QUEUED_REASONS)[number];
171
171
  export declare function isQueuedReason(value: unknown): value is QueuedReason;
172
172
  export declare const HOST_KINDS: readonly ["local", "outpost"];
@@ -436,11 +436,11 @@ export interface AccountHistoryEntry {
436
436
  from: string | null;
437
437
  to: string;
438
438
  at: string;
439
- reason: "operator_move";
439
+ reason: "first_assignment" | "model_change" | "usage_limit" | "account_unavailable" | "operator_move";
440
440
  }
441
441
  export interface PendingResume {
442
442
  session_id: string;
443
- trigger: "message" | "resume";
443
+ trigger: "answered_questions" | "message" | "resume";
444
444
  resumed_from?: "interrupted" | "done" | "blocked";
445
445
  text?: string;
446
446
  message_id?: string;
@@ -461,6 +461,8 @@ export interface LaneResumeRecord {
461
461
  */
462
462
  resumed_from?: "interrupted" | "done" | "blocked";
463
463
  text?: string;
464
+ /** False when the continued turn explicitly skipped compaction. */
465
+ compact?: boolean;
464
466
  }
465
467
  export interface LaneRunUsage {
466
468
  cache_read_input_tokens: number | null;
@@ -575,6 +577,8 @@ export interface ProviderConfig {
575
577
  * Account pick is least-loaded among enabled, logged-in accounts.
576
578
  */
577
579
  default_account?: string;
580
+ /** Ordered account preferences used only for a lane's first assignment. */
581
+ preferred_accounts?: string[];
578
582
  /** Default model for this provider when the account does not set one. */
579
583
  model?: string;
580
584
  /** Default effort for this provider when the account does not set one. */
@@ -103,6 +103,7 @@ export const QUEUED_REASONS = [
103
103
  "queue_position",
104
104
  "throttle",
105
105
  "provider_binary_missing",
106
+ "account_retry",
106
107
  ];
107
108
  export function isQueuedReason(value) {
108
109
  return typeof value === "string" && QUEUED_REASONS.includes(value);
@@ -215,11 +216,11 @@ export const LANE_FIELD_WRITERS = {
215
216
  schema_version: ["creator"], lane_id: ["creator"], company: ["creator"],
216
217
  project_id: ["command"], story_id: ["command"], stories: ["creator", "command", "watcher", "reconcile", "spawn"], thread_id: ["creator", "command", "spawn"], mesh_company: ["creator"],
217
218
  depth: ["command"], senior: ["command"], senior_history: ["command"],
218
- state: ["creator", "command", "watcher", "reconcile", "spawn"], queued_reason: ["creator", "command", "spawn", "reconcile"], queued_at: ["creator", "command", "spawn"], admission_reserved_at: ["creator", "command", "reconcile", "spawn"],
219
+ state: ["creator", "command", "watcher", "reconcile", "spawn"], queued_reason: ["creator", "command", "spawn", "watcher", "reconcile"], queued_at: ["creator", "command", "spawn", "watcher", "reconcile"], admission_reserved_at: ["creator", "command", "reconcile", "spawn", "watcher"],
219
220
  pickup_validation_pending: ["creator", "command", "spawn", "reconcile"],
220
221
  pickup_validation_pending_since: ["creator", "command", "spawn", "reconcile"],
221
222
  priority: ["command"], worker: ["creator"], worker_path: ["creator"], worker_pack: ["creator"], worker_profile: ["creator"], worker_profile_hash: ["creator", "spawn"], worker_profile_ignored: ["creator", "spawn"],
222
- provider: ["creator"], account: ["creator", "spawn", "command", "reconcile"], admission_accounts: ["creator", "command"], account_history: ["command"], pending_resume: ["command", "spawn"], host: ["creator"], origin_host: ["creator"], origin_host_source: ["creator"],
223
+ provider: ["creator"], account: ["creator", "spawn", "command", "watcher", "reconcile"], admission_accounts: ["creator", "command"], account_history: ["command", "spawn", "watcher", "reconcile"], pending_resume: ["command", "spawn", "watcher", "reconcile"], host: ["creator"], origin_host: ["creator"], origin_host_source: ["creator"],
223
224
  version: ["creator"], created_at: ["creator"], updated_at: ["creator"], attribution_history: ["command"],
224
225
  last_envelope: ["watcher", "reconcile"], model: ["creator", "spawn", "command"], model_source: ["creator", "spawn", "command"], effort: ["creator", "spawn"], effort_source: ["creator", "spawn"], fast_mode: ["spawn"],
225
226
  last_message_at: ["command"], last_compaction: ["spawn"], story_complete: ["watcher", "reconcile"], story_complete_reason: ["watcher", "reconcile"], story_status: ["watcher", "reconcile"],
@@ -34,8 +34,9 @@
34
34
  import { type LaneEnvelope, type LaneRecord, type LaneState } from "./types.js";
35
35
  import { type QuotaSignal } from "./quota.js";
36
36
  import { type ProviderRejection } from "./provider-rejection.js";
37
+ import type { AdmissionHost } from "./admission.js";
37
38
  import { type LaneMesh } from "./mesh-events.js";
38
- import { type EnvelopeRepairSpawnInput } from "./spawn.js";
39
+ import { type EnvelopeRepairSpawnInput, type SpawnLaneDeps } from "./spawn.js";
39
40
  /** Spec §5.7: no signal for 20 minutes while the process is still alive. */
40
41
  export declare const SILENCE_MS: number;
41
42
  export declare const WATCHER_POLL_MS = 5000;
@@ -206,6 +207,12 @@ export interface WatcherLoopDeps {
206
207
  meshRequestTimeoutMs?: number;
207
208
  /** Spec §4 repair spawn. Default: `spawnEnvelopeRepair`. */
208
209
  spawnRepair?: (hqRoot: string, lane: LaneRecord, input: EnvelopeRepairSpawnInput) => number;
210
+ /** Rebind and respawn after a provider quota or authentication refusal. */
211
+ retryAccount?: (hqRoot: string, lane: LaneRecord, reason: "usage_limit" | "account_unavailable") => LaneRecord | Promise<LaneRecord>;
212
+ /** Shared process/Outpost identity used by command admission and watcher retries. */
213
+ resolveAdmissionHost?: () => AdmissionHost;
214
+ /** Spawn seams for account retry; host scope is supplied by the resolver above. */
215
+ retrySpawnDeps?: Omit<SpawnLaneDeps, "admissionHost">;
209
216
  }
210
217
  /**
211
218
  * Wrap one mesh call. A failed emit is logged as `mesh_event_failed` with
@@ -40,12 +40,13 @@ import { laneStories, storyStatusForEnvelope } from "./stories.js";
40
40
  import { readLane, retryUpdateLane } from "./store.js";
41
41
  import { readWorkerRunResult } from "./delivery.js";
42
42
  import { applyDefaultQuotaHold, quotaSignalFromFields, readQuotaSignal, recordAccountQuota, } from "./quota.js";
43
- import { PROVIDER_REJECTED_REASON, QUOTA_STATUS, providerRejectionDetails, readProviderRejection, } from "./provider-rejection.js";
43
+ import { PROVIDER_REJECTED_REASON, QUOTA_STATUS, isProviderLoginFailure, providerRejectionDetails, readProviderRejection, } from "./provider-rejection.js";
44
44
  import { readToolActivity, toolActivityPath } from "./dropbox.js";
45
45
  import { loadLanesConfig, providerProgressEventMinIntervalMs, providerQuotaDefaultHoldMs } from "./config.js";
46
+ import { resolveAdmissionHost as resolveProcessAdmissionHost } from "./admission-host.js";
46
47
  import { createLaneMesh, emitLaneDecisionEvents, emitLaneProgressEvent, progressEventDue, } from "./mesh-events.js";
47
48
  import { envelopeQuestionId } from "./questions.js";
48
- import { laneRunDir, missingPidProbe, probePidRecord, readPidRecord, readProcStarttime, readSpawnManifest, envelopeRepairMode, spawnEnvelopeRepair, } from "./spawn.js";
49
+ import { laneRunDir, missingPidProbe, probePidRecord, readPidRecord, readProcStarttime, readSpawnManifest, envelopeRepairMode, spawnEnvelopeRepair, retryLaneOnAlternateAccount, } from "./spawn.js";
49
50
  /** Spec §5.7: no signal for 20 minutes while the process is still alive. */
50
51
  export const SILENCE_MS = 20 * 60 * 1000;
51
52
  export const WATCHER_POLL_MS = 5_000;
@@ -89,7 +90,12 @@ export function classifyLaneExit(input) {
89
90
  throw new LanesError("unknown_envelope_decision", `Unknown envelope decision ${JSON.stringify(input.envelope.decision)}. Accepted: done, ask, blocked`, { accepted: ["done", "ask", "blocked"], value: input.envelope.decision });
90
91
  }
91
92
  if (input.providerRejection) {
92
- return { state: "failed", reason: PROVIDER_REJECTED_REASON };
93
+ return {
94
+ state: "failed",
95
+ reason: isProviderLoginFailure(input.providerRejection)
96
+ ? "account_not_logged_in"
97
+ : PROVIDER_REJECTED_REASON,
98
+ };
93
99
  }
94
100
  if (input.invalidEnvelope) {
95
101
  return { state: "failed", reason: ENVELOPE_INVALID_REASON };
@@ -613,6 +619,10 @@ export async function runWatcherLoop(hqRoot, laneId, deps = {}) {
613
619
  }
614
620
  const progressIntervalMs = deps.progressEventMinIntervalMs ?? readProgressIntervalMs(hqRoot, readLaneSoft(hqRoot, laneId));
615
621
  const spawnRepair = deps.spawnRepair ?? spawnEnvelopeRepair;
622
+ const retryAccount = deps.retryAccount ?? ((root, lane, reason) => retryLaneOnAlternateAccount(root, lane, reason, "watcher", {
623
+ ...deps.retrySpawnDeps,
624
+ admissionHost: (deps.resolveAdmissionHost ?? resolveProcessAdmissionHost)(),
625
+ }));
616
626
  let lastSignalMs = now();
617
627
  let lastSeenMtime = 0;
618
628
  let lastAlive = null;
@@ -836,6 +846,32 @@ export async function runWatcherLoop(hqRoot, laneId, deps = {}) {
836
846
  process.stderr.write(`hq lanes watcher: quota record failed for ${laneId}: ${err instanceof Error ? err.message : String(err)}\n`);
837
847
  }
838
848
  }
849
+ const accountRetryReason = quota
850
+ ? "usage_limit"
851
+ : isProviderLoginFailure(providerRejection)
852
+ ? "account_unavailable"
853
+ : null;
854
+ if (accountRetryReason && quotaLane?.account) {
855
+ try {
856
+ const retry = await retryAccount(hqRoot, quotaLane, accountRetryReason);
857
+ logDecision({
858
+ event: "account_failover",
859
+ at: tick.emit.at,
860
+ from: quotaLane.account,
861
+ to: retry.account ?? null,
862
+ reason: accountRetryReason,
863
+ state: retry.state,
864
+ });
865
+ return;
866
+ }
867
+ catch (err) {
868
+ const message = err instanceof Error ? err.message : String(err);
869
+ process.stderr.write(`hq lanes watcher: account failover failed for ${laneId}: ${message}\n`);
870
+ logDecision({ event: "account_failover_failed", at: tick.emit.at, reason: accountRetryReason, error: message });
871
+ if (readLane(hqRoot, laneId).state !== "running")
872
+ return;
873
+ }
874
+ }
839
875
  const terminalWrite = applyTerminalToLaneWithResult(hqRoot, laneId, classification, {
840
876
  envelope,
841
877
  envelopeRef: latest?.ref,
@@ -61,7 +61,13 @@ export declare function vaultApiFetchPublic(opts: {
61
61
  }): Promise<Response>;
62
62
  export declare function looksLikeCompanyUid(ref: string): boolean;
63
63
  export declare function looksLikePersonVaultUid(ref: string): boolean;
64
- export declare function getCompanyUid(token: string, companySlug: string | undefined): Promise<string>;
64
+ export declare function getCompanyUid(token: string, companySlug: string | undefined,
65
+ /**
66
+ * Control-plane origin for the lookups. Defaults to DEFAULT_VAULT_API_URL;
67
+ * pass it when the caller is bound to another API (an enrolled agent's
68
+ * `apiBaseUrl`) so its token never goes to the default host.
69
+ */
70
+ baseUrl?: string): Promise<string>;
65
71
  export declare function resolveCallerPersonUid(token: string, baseUrl?: string): Promise<string>;
66
72
  export declare function getEntityUid(token: string, opts: {
67
73
  personal?: boolean;
@@ -314,10 +314,11 @@ function raiseIfUnauthorized(res) {
314
314
  if (res.status === 401)
315
315
  throw new AuthError();
316
316
  }
317
- async function resolveCompanyByUid(token, uid) {
317
+ async function resolveCompanyByUid(token, uid, baseUrl) {
318
318
  const res = await vaultApiFetch({
319
319
  token,
320
320
  path: `/entity/${encodeURIComponent(uid)}`,
321
+ baseUrl,
321
322
  signal: AbortSignal.timeout(identityLookupTimeoutMs()),
322
323
  });
323
324
  if (!res.ok) {
@@ -370,11 +371,12 @@ function isTransientSlugLookupStatus(status) {
370
371
  * stranger's same-slug company is correctly invisible to resolution. This is
371
372
  * the same primitive hq-cloud's `context.ts` already resolves slugs through.
372
373
  */
373
- async function resolveSlugInCallerNamespace(token, slug) {
374
+ async function resolveSlugInCallerNamespace(token, slug, baseUrl) {
374
375
  const attempt = async () => {
375
376
  const res = await vaultApiFetch({
376
377
  token,
377
378
  path: "/entity/check-slug/me",
379
+ baseUrl,
378
380
  query: { type: "company", slug },
379
381
  signal: AbortSignal.timeout(identityLookupTimeoutMs()),
380
382
  });
@@ -411,10 +413,11 @@ async function resolveSlugInCallerNamespace(token, slug) {
411
413
  return first;
412
414
  return run();
413
415
  }
414
- async function fetchGlobalCompanyBySlug(token, ref) {
416
+ async function fetchGlobalCompanyBySlug(token, ref, baseUrl) {
415
417
  const attempt = () => vaultApiFetch({
416
418
  token,
417
419
  path: `/entity/by-slug/company/${encodeURIComponent(ref)}`,
420
+ baseUrl,
418
421
  signal: AbortSignal.timeout(identityLookupTimeoutMs()),
419
422
  });
420
423
  try {
@@ -429,10 +432,10 @@ async function fetchGlobalCompanyBySlug(token, ref) {
429
432
  }
430
433
  return attempt();
431
434
  }
432
- async function resolveCompanyUid(token, ref) {
435
+ async function resolveCompanyUid(token, ref, baseUrl) {
433
436
  // Silent escape hatch: a `cmp_` ref is a uid, not a slug — resolve directly.
434
437
  if (looksLikeCompanyUid(ref)) {
435
- return resolveCompanyByUid(token, ref);
438
+ return resolveCompanyByUid(token, ref, baseUrl);
436
439
  }
437
440
  // A `prs_` ref names a person's vault (DM attachments). There is no slug to
438
441
  // resolve and no company lookup that could succeed; pass it through as the
@@ -453,7 +456,7 @@ async function resolveCompanyUid(token, ref) {
453
456
  // hq-cli defect still throws, and the 401 AuthError that
454
457
  // `raiseIfUnauthorized` throws still short-circuits before any fallback
455
458
  // (Sentry HQ-CLI-1P / issue 7716090679).
456
- const mine = await resolveSlugInCallerNamespace(token, ref);
459
+ const mine = await resolveSlugInCallerNamespace(token, ref, baseUrl);
457
460
  if (mine.kind === "hit") {
458
461
  return mine.uid;
459
462
  }
@@ -462,7 +465,7 @@ async function resolveCompanyUid(token, ref) {
462
465
  // cross-company / admin resolutions that intentionally target a company the
463
466
  // caller isn't a member of (e.g. a group-grant target). A single global
464
467
  // match resolves cleanly.
465
- const res = await fetchGlobalCompanyBySlug(token, ref);
468
+ const res = await fetchGlobalCompanyBySlug(token, ref, baseUrl);
466
469
  if (!res.ok) {
467
470
  raiseIfUnauthorized(res);
468
471
  const body = (await res.json().catch(() => ({})));
@@ -494,10 +497,11 @@ async function resolveCompanyUid(token, ref) {
494
497
  const data = (await res.json());
495
498
  return data.entity.uid;
496
499
  }
497
- async function resolveCompanyFromMemberships(token) {
500
+ async function resolveCompanyFromMemberships(token, baseUrl) {
498
501
  const res = await vaultApiFetch({
499
502
  token,
500
503
  path: '/membership/me',
504
+ baseUrl,
501
505
  signal: AbortSignal.timeout(identityLookupTimeoutMs()),
502
506
  });
503
507
  if (!res.ok) {
@@ -519,7 +523,13 @@ async function resolveCompanyFromMemberships(token) {
519
523
  `pick one:\n` +
520
524
  picks.join('\n'));
521
525
  }
522
- export async function getCompanyUid(token, companySlug) {
526
+ export async function getCompanyUid(token, companySlug,
527
+ /**
528
+ * Control-plane origin for the lookups. Defaults to DEFAULT_VAULT_API_URL;
529
+ * pass it when the caller is bound to another API (an enrolled agent's
530
+ * `apiBaseUrl`) so its token never goes to the default host.
531
+ */
532
+ baseUrl) {
523
533
  // Vault API keys are company-bound. Entity slug resolution has no keyed
524
534
  // route, so under hqk_ we accept an explicit cmp_ uid (or HQ_COMPANY_UID)
525
535
  // and otherwise fall through to keyed GET /v1/keys/membership/me.
@@ -532,12 +542,12 @@ export async function getCompanyUid(token, companySlug) {
532
542
  }
533
543
  return ref;
534
544
  }
535
- return resolveCompanyFromMemberships(token);
545
+ return resolveCompanyFromMemberships(token, baseUrl);
536
546
  }
537
547
  if (companySlug) {
538
- return resolveCompanyUid(token, companySlug);
548
+ return resolveCompanyUid(token, companySlug, baseUrl);
539
549
  }
540
- return resolveCompanyFromMemberships(token);
550
+ return resolveCompanyFromMemberships(token, baseUrl);
541
551
  }
542
552
  // Same selection rule as the backend's `resolveCallerPersonUid`: ascending by
543
553
  // createdAt, tie-break by uid ascending. Returns the `prs_*` UID.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.153.0",
3
+ "version": "5.155.0",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {