@indigoai-us/hq-cli 5.345.41 → 5.345.43

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.
@@ -37,7 +37,7 @@ import { laneDepthCap } from "../lib/lanes/depth-cap.js";
37
37
  import { isSsmRunCommandProcess } from "../lib/lanes/ssm-command-context.js";
38
38
  import { runResidentCommandWithPackageLease } from "../utils/resident-package-preload.js";
39
39
  import { readLanesFlagLocalOverride, readLanesRegistryFlag, readLanesRegistryFlagFailOff } from "../lib/lanes/registry-flag.js";
40
- import { LANE_HIRE_GRANT_FINISHED_LANES_FLAG, LANE_LIST_SKIP_REMOTE_REFRESH_FLAG, LANE_MESSAGE_ROUTE_OWNER_HOST_FLAG, LANE_ORPHANED_BARE_ID_DROPBOX_REPAIR_FLAG, LANE_REUSE_REFRESHES_PROFILE_FLAG, } from "../lib/lanes/flag-keys.js";
40
+ import { LANE_HIRE_GRANT_FINISHED_LANES_FLAG, LANE_LIST_SKIP_REMOTE_REFRESH_FLAG, LANE_MESSAGE_ROUTE_OWNER_HOST_FLAG, LANE_ORPHANED_BARE_ID_DROPBOX_REPAIR_FLAG, LANE_PROFILE_PIN_EXHAUSTED_FALLBACK_FLAG, LANE_REUSE_REFRESHES_PROFILE_FLAG, } from "../lib/lanes/flag-keys.js";
41
41
  import { configuredCodexRolloutHomes, retainCodexRollouts } from "../lib/lanes/disk-guard.js";
42
42
  import { isControllerAliasOutpostId } from "../lib/lanes/admission-host.js";
43
43
  import { runLeadAutopilotPass } from "../lib/lanes/autopilot.js";
@@ -4221,12 +4221,99 @@ function authorizeFailedResume(ctx, lane, encodedContext) {
4221
4221
  }
4222
4222
  return context.actor;
4223
4223
  }
4224
- const callerAuthorization = callerSeniorAuthorization(ctx, lane.senior);
4224
+ const callerAuthorization = failedResumeCallerAuthorization(ctx, lane);
4225
4225
  if (!callerAuthorization.authorized) {
4226
- throw new LanesError("failed_resume_unauthorized", appendCallerSeniorReason("Only the lane's senior may resume a failed lane with --from-failed", callerAuthorization), { lane_id: lane.lane_id, reason: callerAuthorization.reason });
4226
+ throw new LanesError("failed_resume_unauthorized", appendCallerSeniorReason("Only an ancestor in the lane's senior history or its company manager may resume a failed lane with --from-failed", callerAuthorization), { lane_id: lane.lane_id, reason: callerAuthorization.reason });
4227
4227
  }
4228
4228
  return resumeProfileRepinActor(ctx);
4229
4229
  }
4230
+ function failedResumeCallerAuthorization(ctx, lane) {
4231
+ const direct = callerSeniorAuthorization(ctx, lane.senior);
4232
+ if (direct.authorized)
4233
+ return direct;
4234
+ const hqRoot = ctx.hqRoot();
4235
+ const targetCompany = resolveLaneCompany(hqRoot, lane).company;
4236
+ const visited = new Set();
4237
+ const pending = [
4238
+ lane.senior,
4239
+ ...lane.senior_history.flatMap((entry) => [entry.from, entry.to]),
4240
+ ];
4241
+ while (pending.length > 0) {
4242
+ const ref = pending.pop();
4243
+ const key = seniorKey(ref);
4244
+ if (visited.has(key))
4245
+ continue;
4246
+ visited.add(key);
4247
+ if (ref.kind === "user") {
4248
+ if (targetCompany === null && callerSeniorAuthorization(ctx, ref).authorized) {
4249
+ return { authorized: true };
4250
+ }
4251
+ continue;
4252
+ }
4253
+ if (!ref.id)
4254
+ continue;
4255
+ if (ref.kind === "lane") {
4256
+ const ancestor = tryReadLane(hqRoot, ref.id);
4257
+ if (!ancestor)
4258
+ continue;
4259
+ const ancestorCompany = resolveLaneCompany(hqRoot, ancestor).company;
4260
+ if (!sameLaneCompany(targetCompany, ancestorCompany))
4261
+ continue;
4262
+ if (callerSeniorAuthorization(ctx, ref).authorized)
4263
+ return { authorized: true };
4264
+ pending.push(ancestor.senior, ...ancestor.senior_history.flatMap((entry) => [entry.from, entry.to]));
4265
+ continue;
4266
+ }
4267
+ if (ref.kind === "session") {
4268
+ const loaded = loadSessionMeta(hqRoot, ref.id, ctx);
4269
+ if (loaded.status !== "ok")
4270
+ continue;
4271
+ const sessionCompany = companyBindingFromSessionMeta(loaded.doc);
4272
+ const sessionCompanyRef = sessionCompany.binding === "company"
4273
+ ? { slug: sessionCompany.slug ?? "", uid: sessionCompany.uid ?? "" }
4274
+ : null;
4275
+ if (targetCompany === null) {
4276
+ if (sessionCompany.binding !== "none")
4277
+ continue;
4278
+ }
4279
+ else if (sessionCompany.binding !== "company" ||
4280
+ !sameLaneCompany(targetCompany, sessionCompanyRef)) {
4281
+ continue;
4282
+ }
4283
+ if (callerSeniorAuthorization(ctx, ref).authorized)
4284
+ return { authorized: true };
4285
+ const value = sessionSeniorValue(loaded.doc);
4286
+ if (typeof value === "string" && value.trim() !== "") {
4287
+ try {
4288
+ pending.push(parseSeniorRef(value));
4289
+ }
4290
+ catch {
4291
+ continue;
4292
+ }
4293
+ }
4294
+ else if (value !== null && typeof value === "object" && !Array.isArray(value)) {
4295
+ const senior = value;
4296
+ if (senior.kind === "user")
4297
+ continue;
4298
+ if ((senior.kind === "lane" || senior.kind === "session") &&
4299
+ typeof senior.id === "string" && senior.id.trim() !== "") {
4300
+ pending.push({ kind: senior.kind, id: senior.id });
4301
+ }
4302
+ }
4303
+ }
4304
+ }
4305
+ const callerLaneId = ctx.env.HQ_LANE_ID?.trim();
4306
+ const callerLane = callerLaneId ? tryReadLane(hqRoot, callerLaneId) : null;
4307
+ if (callerLane && targetCompany !== null) {
4308
+ const callerCompany = resolveLaneCompany(hqRoot, callerLane).company;
4309
+ if (callerCompany !== null &&
4310
+ sameLaneCompany(targetCompany, callerCompany) &&
4311
+ resolveManagerStatusTarget(hqRoot, callerLane).manager) {
4312
+ return { authorized: true };
4313
+ }
4314
+ }
4315
+ return { authorized: false, reason: "caller is not an ancestor lane or session, nor the lane's same-company manager" };
4316
+ }
4230
4317
  function workerProfileHashMismatch(lane, profile, callerAuthorization) {
4231
4318
  const message = `Worker profile ${JSON.stringify(profile.id)} on Outpost ${lane.host.id} does not match the hash recorded by the lane`;
4232
4319
  return new LanesError("worker_profile_hash_mismatch", callerAuthorization && !callerAuthorization.authorized
@@ -6836,7 +6923,7 @@ async function prepareAccountMove(ctx, lane, input) {
6836
6923
  account: target,
6837
6924
  ...(input.accountSource !== undefined ? { account_source: input.accountSource } : {}),
6838
6925
  ...(input.accountSelection !== undefined ? { account_selection: input.accountSelection } : {}),
6839
- admission_accounts: undefined,
6926
+ admission_accounts: input.preserveAdmissionAccounts ? current.admission_accounts : undefined,
6840
6927
  provider_session_id: sessionId,
6841
6928
  provider_session_id_absent: undefined,
6842
6929
  ...(input.cliModel !== undefined
@@ -6866,6 +6953,9 @@ async function prepareAccountMove(ctx, lane, input) {
6866
6953
  ...(input.refreshProfileSettings === true ? { refreshProfileSettings: true } : {}),
6867
6954
  at,
6868
6955
  });
6956
+ if (input.preserveAdmissionAccounts && current.admission_accounts !== undefined) {
6957
+ next.admission_accounts = [...current.admission_accounts];
6958
+ }
6869
6959
  if (input.deferTurnStart) {
6870
6960
  next.state = current.state;
6871
6961
  }
@@ -6989,53 +7079,112 @@ async function selectAutomaticClaudeResumeAccount(ctx, lane, workerProfile, opti
6989
7079
  });
6990
7080
  }
6991
7081
  catch (err) {
6992
- const accountReasons = err instanceof LanesError &&
6993
- err.error === "claude_account_balance_no_eligible_account" &&
6994
- Array.isArray(err.details.account_reasons)
6995
- ? err.details.account_reasons.filter((reason) => reason !== null && typeof reason === "object" && !Array.isArray(reason))
6996
- : [];
7082
+ if (!(err instanceof LanesError) || err.error !== "claude_account_balance_no_eligible_account")
7083
+ throw err;
6997
7084
  const unavailablePinReasons = new Set(["disabled", "home_missing", "not_logged_in", "quota_held"]);
6998
- const everyProfileAccountUnavailable = profilePin !== undefined && profilePin.length > 0 &&
6999
- profilePin.every((account) => accountReasons.some((reason) => reason.account === account && typeof reason.reason === "string" && unavailablePinReasons.has(reason.reason)));
7000
- if (!everyProfileAccountUnavailable || lane.account === undefined ||
7001
- (lane.admission_accounts !== undefined && !lane.admission_accounts.includes(lane.account)))
7085
+ if (profilePin === undefined || profilePin.length === 0)
7002
7086
  throw err;
7003
- const recordedSelection = selectClaudeAccount({
7004
- ...selectionInput,
7005
- allowedAccounts: [lane.account],
7006
- });
7087
+ // Admission scope can hide the profile accounts from the first selection's
7088
+ // diagnostics. Re-evaluate only the pin so exhaustion means an unavailable
7089
+ // account, not merely an empty intersection with admission_accounts.
7090
+ let pinnedAccountReasons;
7091
+ try {
7092
+ selectClaudeAccount({ ...selectionInput, allowedAccounts: profilePin });
7093
+ throw err;
7094
+ }
7095
+ catch (pinErr) {
7096
+ if (pinErr === err)
7097
+ throw pinErr;
7098
+ if (!(pinErr instanceof LanesError) || pinErr.error !== "claude_account_balance_no_eligible_account")
7099
+ throw err;
7100
+ pinnedAccountReasons = Array.isArray(pinErr.details.account_reasons)
7101
+ ? pinErr.details.account_reasons.filter((reason) => reason !== null && typeof reason === "object" && !Array.isArray(reason))
7102
+ : [];
7103
+ }
7104
+ pinnedAccountReasons = pinnedAccountReasons.filter((reason) => typeof reason.account === "string" && profilePin.includes(reason.account));
7105
+ const everyProfileAccountUnavailable = profilePin.every((account) => pinnedAccountReasons.some((reason) => reason.account === account && typeof reason.reason === "string" && unavailablePinReasons.has(reason.reason)));
7106
+ if (!everyProfileAccountUnavailable)
7107
+ throw err;
7108
+ // Refreshing profile settings is allowed to change the account pin. Keep
7109
+ // that operation on its existing selection path instead of applying this
7110
+ // fallback against a profile snapshot that is being refreshed.
7111
+ if (options.refreshProfileSettings === true)
7112
+ throw err;
7113
+ let fallbackSelection;
7114
+ if (lane.account !== undefined && lane.admission_accounts?.includes(lane.account) !== false) {
7115
+ try {
7116
+ fallbackSelection = selectClaudeAccount({ ...selectionInput, allowedAccounts: [lane.account] });
7117
+ }
7118
+ catch (recordedErr) {
7119
+ if (!(recordedErr instanceof LanesError) || recordedErr.error !== "claude_account_balance_no_eligible_account") {
7120
+ throw recordedErr;
7121
+ }
7122
+ }
7123
+ }
7124
+ if (fallbackSelection === undefined) {
7125
+ const resolution = resolveLaneCompany(hqRoot, lane);
7126
+ const company = resolution.company ?? undefined;
7127
+ const exhaustedPinFallbackEnabled = await readLanesRegistryFlagFailOff(LANE_PROFILE_PIN_EXHAUSTED_FALLBACK_FLAG, {
7128
+ company,
7129
+ ...(company ? { companyUidSource: laneCompanyUidSource(resolution.source) } : {}),
7130
+ env: ctx.env,
7131
+ }, readLanesRegistryFlag);
7132
+ if (!exhaustedPinFallbackEnabled)
7133
+ throw err;
7134
+ try {
7135
+ fallbackSelection = selectClaudeAccount({
7136
+ ...selectionInput,
7137
+ ...(lane.admission_accounts !== undefined ? { allowedAccounts: lane.admission_accounts } : {}),
7138
+ });
7139
+ }
7140
+ catch (balanceErr) {
7141
+ if (!(balanceErr instanceof LanesError) || balanceErr.error !== "claude_account_balance_no_eligible_account") {
7142
+ throw balanceErr;
7143
+ }
7144
+ const balancerAccountReasons = Array.isArray(balanceErr.details.account_reasons)
7145
+ ? balanceErr.details.account_reasons.filter((reason) => reason !== null && typeof reason === "object" && !Array.isArray(reason) &&
7146
+ reason.reason !== "not_allowed_by_lane")
7147
+ : [];
7148
+ throw new LanesError(balanceErr.error, balanceErr.message, {
7149
+ ...balanceErr.details,
7150
+ account_reasons: balancerAccountReasons,
7151
+ profile_pin_reasons: pinnedAccountReasons,
7152
+ balancer_account_reasons: balancerAccountReasons,
7153
+ });
7154
+ }
7155
+ }
7007
7156
  selected = {
7008
- ...recordedSelection,
7009
- considered: recordedSelection.considered.map((entry) => {
7010
- const pinnedReason = profilePin.includes(entry.account)
7011
- ? accountReasons.find((reason) => reason.account === entry.account)?.reason
7012
- : undefined;
7157
+ ...fallbackSelection,
7158
+ considered: fallbackSelection.considered.map((entry) => {
7159
+ const pinnedReason = pinnedAccountReasons.find((reason) => reason.account === entry.account)?.reason;
7013
7160
  return typeof pinnedReason === "string"
7014
7161
  ? { ...entry, eligible: false, ineligible_reason: pinnedReason }
7015
7162
  : entry;
7016
7163
  }),
7017
7164
  skipped_accounts: [
7018
- ...recordedSelection.skipped_accounts.filter((entry) => !profilePin.includes(entry.account)),
7165
+ ...fallbackSelection.skipped_accounts.filter((entry) => !profilePin.includes(entry.account)),
7019
7166
  ...profilePin.map((account) => ({
7020
7167
  account,
7021
7168
  host: lane.host,
7022
- reason: String(accountReasons.find((entry) => entry.account === account)?.reason),
7169
+ reason: String(pinnedAccountReasons.find((entry) => entry.account === account)?.reason),
7023
7170
  })),
7024
7171
  ],
7025
7172
  };
7026
7173
  profilePinUnavailable = true;
7027
7174
  }
7028
- const historyReason = lane.account === undefined
7029
- ? "load_balance"
7030
- : currentConfig?.enabled !== true
7031
- ? "account_unavailable"
7032
- : currentLogin === false
7033
- ? "recorded_account_logged_out"
7034
- : currentLogin !== true
7035
- ? "account_unavailable"
7036
- : quotaBlockFor(loadQuotaState(hqRoot), "claude", lane.account, model, ctx.now()).blocked
7037
- ? "usage_limit"
7038
- : "load_balance";
7175
+ const historyReason = profilePinUnavailable
7176
+ ? "profile_pin_exhausted"
7177
+ : lane.account === undefined
7178
+ ? "load_balance"
7179
+ : currentConfig?.enabled !== true
7180
+ ? "account_unavailable"
7181
+ : currentLogin === false
7182
+ ? "recorded_account_logged_out"
7183
+ : currentLogin !== true
7184
+ ? "account_unavailable"
7185
+ : quotaBlockFor(loadQuotaState(hqRoot), "claude", lane.account, model, ctx.now()).blocked
7186
+ ? "usage_limit"
7187
+ : "load_balance";
7039
7188
  return {
7040
7189
  selection: {
7041
7190
  ...selected,
@@ -7224,19 +7373,26 @@ function assertLaneNotRunningForReparent(lane) {
7224
7373
  throw new LanesError("lane_running", `Cannot re-parent running lane ${lane.lane_id}; wait until it stops first`, { lane_id: lane.lane_id, state: lane.state });
7225
7374
  }
7226
7375
  }
7227
- function runReparentLocked(ctx, laneId, opts, finishedChildrenDoNotCount = false) {
7376
+ async function runReparentLocked(ctx, laneId, opts, finishedChildrenDoNotCount = false) {
7228
7377
  const hqRoot = ctx.hqRoot();
7229
7378
  if (!opts.senior) {
7230
7379
  throw new LanesError("missing_senior", "--senior is required", {});
7231
7380
  }
7232
7381
  const nextSenior = parseSeniorRef(opts.senior);
7233
7382
  assertSeniorExists(hqRoot, nextSenior, ctx);
7234
- const lane = readLane(hqRoot, laneId);
7383
+ let lane = readLane(hqRoot, laneId);
7235
7384
  assertLaneRunsHereForOperatorMutation(ctx, lane, "reparent");
7385
+ assertLaneNotRunningForReparent(lane);
7236
7386
  const seniorCompany = companyOfSenior(hqRoot, nextSenior, ctx);
7237
7387
  if (lane.company === null && seniorCompany.binding === "company") {
7238
- const company = seniorCompany.slug ?? seniorCompany.uid ?? "<company>";
7239
- throw new LanesError("lane_company_missing", `Lane ${laneId} has no company attribution. Repair it with hq lanes repair-company ${laneId} --company ${company}, then retry reparent.`, { lane_id: laneId, senior: nextSenior, senior_company: serializeSeniorCompany(seniorCompany) });
7388
+ const company = seniorCompany.slug ?? (seniorCompany.uid
7389
+ ? canonicalCompanySlugForUid(hqRoot, seniorCompany.uid, nextSenior)
7390
+ : undefined);
7391
+ if (!company) {
7392
+ throw new LanesError("lane_company_missing", `Lane ${laneId} has no company attribution and the destination senior's company cannot be resolved`, { lane_id: laneId, senior: nextSenior, senior_company: serializeSeniorCompany(seniorCompany) });
7393
+ }
7394
+ await repairCompanyOnOwner(ctx, laneId, { company }, false);
7395
+ lane = readLane(hqRoot, laneId);
7240
7396
  }
7241
7397
  assertSameTenant(lane.company, nextSenior, seniorCompany, { lane_id: laneId });
7242
7398
  if (nextSenior.kind === "lane" && seniorKey(lane.senior) !== seniorKey(nextSenior)) {
@@ -7372,7 +7528,7 @@ async function runRepairCompany(ctx, laneId, opts) {
7372
7528
  }
7373
7529
  await repairCompanyOnOwner(ctx, laneId, opts);
7374
7530
  }
7375
- async function repairCompanyOnOwner(ctx, laneId, opts) {
7531
+ async function repairCompanyOnOwner(ctx, laneId, opts, emitResult = true) {
7376
7532
  const hqRoot = ctx.hqRoot();
7377
7533
  const companySlug = opts.company?.trim();
7378
7534
  if (!companySlug) {
@@ -7450,7 +7606,9 @@ async function repairCompanyOnOwner(ctx, laneId, opts) {
7450
7606
  const repairedLane = readLane(hqRoot, laneId);
7451
7607
  syncLaneToProjectStoryLedger(hqRoot, repairedLane, migratedQuestionRefs);
7452
7608
  removeLaneFromProjectStoryLedger(hqRoot, initial);
7453
- printJson({ ok: true, action: "repair-company", lane_id: laneId, lane: repairedLane, migrated_pending_questions: migratedQuestionRefs.length });
7609
+ if (emitResult) {
7610
+ printJson({ ok: true, action: "repair-company", lane_id: laneId, lane: repairedLane, migrated_pending_questions: migratedQuestionRefs.length });
7611
+ }
7454
7612
  }
7455
7613
  async function runRepairCompanyOwnerTarget(ctx, laneId, opts) {
7456
7614
  if (!ctx.isSsmOwnerDispatch()) {
@@ -8476,7 +8634,8 @@ async function runReconcile(ctx, opts, recoverQueuedMessages) {
8476
8634
  const moved = await prepareAccountMove(ctx, recoveredLane, {
8477
8635
  account: selection.account,
8478
8636
  cliModel: pendingModel,
8479
- accountHistoryReason: "keep_alive_resume",
8637
+ accountHistoryReason: automatic.historyReason === "profile_pin_exhausted" ? automatic.historyReason : "keep_alive_resume",
8638
+ ...(automatic.historyReason === "profile_pin_exhausted" ? { preserveAdmissionAccounts: true } : {}),
8480
8639
  ...(pendingModel === undefined && automatic.preservedModel !== undefined
8481
8640
  ? { preservedModel: automatic.preservedModel }
8482
8641
  : {}),
@@ -9185,10 +9344,10 @@ export function registerLanesCommand(parent, deps = {}) {
9185
9344
  }));
9186
9345
  lanes
9187
9346
  .command("resume")
9188
- .description("Resume an interrupted, done or blocked lane, or an awaiting_input lane with no answerable question; recover failed lanes with the senior-only --from-failed option")
9347
+ .description("Resume an interrupted, done or blocked lane, or an awaiting_input lane with no answerable question; recover failed lanes with --from-failed as a senior ancestor or same-company manager")
9189
9348
  .argument("<lane-id>", "Lane id")
9190
9349
  .option("--text <msg>", "Operator message for the resumed turn")
9191
- .option("--from-failed", "Explicitly resume a failed lane as its senior")
9350
+ .option("--from-failed", "Explicitly resume a failed lane as an ancestor or same-company manager")
9192
9351
  .option("--reason <text>", "Required explanation for resuming from failed")
9193
9352
  .option("--project <slug>", "Re-point project attribution; a changed story is named in the resumed prompt")
9194
9353
  .option("--story <id>", "Re-point story attribution; a changed story is named in the resumed prompt")
@@ -1,5 +1,7 @@
1
1
  export interface ReadHqAnywherePersonSettingOptions {
2
2
  readSetting?: () => Promise<boolean | null>;
3
+ readFlag?: (env: NodeJS.ProcessEnv) => boolean;
4
+ refreshFlag?: () => Promise<void>;
3
5
  getPersonToken?: () => Promise<string>;
4
6
  fetchImpl?: typeof fetch;
5
7
  apiBaseUrl?: string;
@@ -33,7 +33,8 @@ export function evaluateAnywhereRuntime(flagEnabled, setting) {
33
33
  }
34
34
  /** The operator flag always wins; the person preference is fetched only while it is on. */
35
35
  export async function anywhereRuntimeAllowed(env = process.env, options = {}) {
36
- if (!anywhereRuntimeEnabledFromSnapshot(env))
36
+ await options.refreshFlag?.();
37
+ if (!(options.readFlag ?? anywhereRuntimeEnabledFromSnapshot)(env))
37
38
  return false;
38
39
  const setting = options.readSetting ? await options.readSetting() : await readHqAnywherePersonSetting({ ...options, env });
39
40
  return evaluateAnywhereRuntime(true, setting).enabled;
@@ -1,6 +1,6 @@
1
1
  import { readLocalOverride, resolveFlag } from "@indigoai-us/hq-flags-client";
2
2
  import { resolveProcessFlagCompanyContext } from "./process-flag-company-context.js";
3
- import { readFlagSnapshotFile } from "./flag-snapshot-file.js";
3
+ import { readFlagSnapshotFile, readPersonFlagSnapshotFile } from "./flag-snapshot-file.js";
4
4
  import { HQ_ANYWHERE_RUNTIME_FLAG } from "./anywhere-runtime-flag-key.js";
5
5
  /** Resolve the Anywhere gate from hq-flags without loading the CLI's large worker bundle. */
6
6
  export function anywhereRuntimeEnabledFromSnapshot(env = process.env) {
@@ -8,10 +8,16 @@ export function anywhereRuntimeEnabledFromSnapshot(env = process.env) {
8
8
  if (local !== null)
9
9
  return local;
10
10
  const company = resolveProcessFlagCompanyContext(env);
11
- const persisted = company.companyUid ? readFlagSnapshotFile(company.companyUid, { env }) : null;
11
+ const hasCompanyContext = Boolean(company.companyUid || company.companySlug);
12
+ const persisted = company.companyUid
13
+ ? readFlagSnapshotFile(company.companyUid, { env })
14
+ : hasCompanyContext
15
+ ? null
16
+ : readPersonFlagSnapshotFile({ env });
12
17
  const snapshot = persisted
13
18
  ? { version: 0, flags: persisted.flags }
14
19
  : null;
20
+ // HQ Anywhere authorizes a capability, so a missing or stale snapshot fails closed.
15
21
  return resolveFlag({
16
22
  flagKey: HQ_ANYWHERE_RUNTIME_FLAG,
17
23
  env,
@@ -0,0 +1,16 @@
1
+ import type { HqdServer } from "./server.js";
2
+ export interface HqdRuntimeListenerOptions {
3
+ isEnabled: () => Promise<boolean>;
4
+ startServer: () => Promise<HqdServer>;
5
+ onServerChange?: (server: HqdServer | null) => void;
6
+ intervalMs?: number;
7
+ schedule?: (callback: () => void, delayMs: number) => () => void;
8
+ log?: (error: unknown) => void;
9
+ }
10
+ export interface HqdRuntimeListener {
11
+ refresh(): Promise<void>;
12
+ close(): Promise<void>;
13
+ }
14
+ /** Keep the local hqd listener aligned with the live flag and person setting. */
15
+ export declare function startHqdRuntimeListener(options: HqdRuntimeListenerOptions): Promise<HqdRuntimeListener>;
16
+ //# sourceMappingURL=anywhere-listener.d.ts.map
@@ -0,0 +1,79 @@
1
+ /** Keep the local hqd listener aligned with the live flag and person setting. */
2
+ export async function startHqdRuntimeListener(options) {
3
+ const intervalMs = options.intervalMs ?? 60_000;
4
+ const log = options.log ?? (() => { });
5
+ const schedule = options.schedule ?? ((callback, delayMs) => {
6
+ const timer = setInterval(callback, delayMs);
7
+ timer.unref();
8
+ return () => clearInterval(timer);
9
+ });
10
+ let server = null;
11
+ let closed = false;
12
+ let inFlight = null;
13
+ const refresh = () => {
14
+ if (closed)
15
+ return Promise.resolve();
16
+ if (inFlight)
17
+ return inFlight;
18
+ inFlight = (async () => {
19
+ let enabled = false;
20
+ try {
21
+ enabled = await options.isEnabled();
22
+ }
23
+ catch (error) {
24
+ log(error);
25
+ }
26
+ if (closed || enabled === (server !== null))
27
+ return;
28
+ if (enabled) {
29
+ try {
30
+ const opened = await options.startServer();
31
+ if (closed) {
32
+ await opened.close();
33
+ return;
34
+ }
35
+ server = opened;
36
+ options.onServerChange?.(server);
37
+ }
38
+ catch (error) {
39
+ log(error);
40
+ }
41
+ return;
42
+ }
43
+ const current = server;
44
+ server = null;
45
+ options.onServerChange?.(null);
46
+ if (current) {
47
+ try {
48
+ await current.close();
49
+ }
50
+ catch (error) {
51
+ log(error);
52
+ }
53
+ }
54
+ })().finally(() => {
55
+ inFlight = null;
56
+ });
57
+ return inFlight;
58
+ };
59
+ await refresh();
60
+ if (closed)
61
+ return { refresh, close: async () => { } };
62
+ const cancel = schedule(() => { void refresh(); }, intervalMs);
63
+ return {
64
+ refresh,
65
+ async close() {
66
+ if (closed)
67
+ return;
68
+ closed = true;
69
+ cancel();
70
+ await inFlight;
71
+ const current = server;
72
+ server = null;
73
+ options.onServerChange?.(null);
74
+ if (current)
75
+ await current.close();
76
+ },
77
+ };
78
+ }
79
+ //# sourceMappingURL=anywhere-listener.js.map
@@ -35,6 +35,7 @@ import { detectMachineType } from "./machine-type.js";
35
35
  import { appendPrivate, daemonPaths, ensurePrivateDir, openPrivateAppend } from "./paths.js";
36
36
  import { hqdSocketPath } from "./protocol.js";
37
37
  import { startHqdServer } from "./server.js";
38
+ import { startHqdRuntimeListener } from "./anywhere-listener.js";
38
39
  import { heartbeatFile, startHeartbeat } from "./heartbeat.js";
39
40
  import { createProtocolTail, ensureProtocolFifo, openProtocolWriter } from "./protocol-tail.js";
40
41
  import { takeControlRequests } from "./requests.js";
@@ -835,14 +836,19 @@ export async function runDaemon(opts) {
835
836
  return on;
836
837
  };
837
838
  // hqd: the session/company socket every runtime talks to (hq-anywhere US-008).
838
- let hqd = null;
839
839
  let hqdShutdown = null;
840
- if (shouldStartHqd(await anywhereRuntimeAllowed(env, {
841
- onError: (name) => log("warn", `hqd person setting read failed (${name}); runtime remains off`),
842
- }))) {
843
- try {
840
+ let stopHeartbeat = () => { };
841
+ const hqdListener = await startHqdRuntimeListener({
842
+ isEnabled: async () => shouldStartHqd(await anywhereRuntimeAllowed(env, {
843
+ refreshFlag: async () => {
844
+ const { refreshFlagRegistrySnapshot } = await import("../flag-registry.js");
845
+ await refreshFlagRegistrySnapshot(5_000);
846
+ },
847
+ onError: (name) => log("warn", `hqd person setting read failed (${name}); runtime remains off`),
848
+ })),
849
+ startServer: async () => {
844
850
  const startConfig = readDaemonConfig(paths.configPath);
845
- hqd = await startHqdServer({
851
+ return startHqdServer({
846
852
  socketPath: hqdSocketPath(home, env),
847
853
  registryPath: registryFile(registryDir(home, env)),
848
854
  hqRoot: startConfig.hqRoot ?? (env.HQ_ROOT?.trim() || undefined),
@@ -850,24 +856,25 @@ export async function runDaemon(opts) {
850
856
  onShutdown: () => hqdShutdown?.(),
851
857
  reportError: reportHqdCrash,
852
858
  });
853
- }
854
- catch (err) {
855
- log("error", `hqd socket did not start; services run without it: ${err.message}`);
856
- }
857
- }
858
- // US-012: 60s presence heartbeat with version and session count.
859
- const liveHqd = hqd;
860
- const stopHeartbeat = liveHqd
861
- ? startHeartbeat({
862
- version: CLI_VERSION,
863
- sessionCount: () => liveHqd.sessions.size,
864
- file: heartbeatFile(home, env),
865
- log,
866
- })
867
- : () => { };
859
+ },
860
+ onServerChange: (server) => {
861
+ stopHeartbeat();
862
+ stopHeartbeat = () => { };
863
+ if (!server)
864
+ return;
865
+ // US-012: 60s presence heartbeat with version and session count.
866
+ stopHeartbeat = startHeartbeat({
867
+ version: CLI_VERSION,
868
+ sessionCount: () => server.sessions.size,
869
+ file: heartbeatFile(home, env),
870
+ log,
871
+ });
872
+ },
873
+ intervalMs: 60_000,
874
+ log: (error) => log("error", `hqd socket listener update failed: ${error.message ?? String(error)}`),
875
+ });
868
876
  const closeHqd = () => {
869
- stopHeartbeat();
870
- void hqd?.close().catch((err) => log("warn", `hqd socket did not close cleanly: ${err.message}`));
877
+ void hqdListener.close().catch((err) => log("warn", `hqd socket did not close cleanly: ${err.message}`));
871
878
  };
872
879
  return await new Promise((resolve) => {
873
880
  let stopping = false;
@@ -24,6 +24,38 @@ export interface RemoteStatusResponse {
24
24
  secretRef: string;
25
25
  } | null;
26
26
  }
27
+ export type RemoteAppLimitState = "ok" | "warn" | "overage" | "locked";
28
+ /** One app that owns its cluster. Cents are whole US cents; `estimate` is null for an exempt company. */
29
+ export interface RemoteAppUsage {
30
+ appId: string;
31
+ period: string;
32
+ measuredAt: string | null;
33
+ ended: boolean;
34
+ state: RemoteAppLimitState;
35
+ locked: boolean;
36
+ exempt: boolean;
37
+ dpu: {
38
+ used: number;
39
+ included: number;
40
+ overage: number;
41
+ ceiling: number;
42
+ pct: number;
43
+ };
44
+ storage: {
45
+ usedBytes: number;
46
+ billedBytes: number;
47
+ includedBytes: number;
48
+ overageGbMonth: number;
49
+ ceilingBytes: number;
50
+ pct: number;
51
+ };
52
+ estimate: {
53
+ baseCents: number;
54
+ dpuOverageCents: number;
55
+ storageOverageCents: number;
56
+ totalCents: number;
57
+ } | null;
58
+ }
27
59
  export interface RemoteUsageResponse {
28
60
  companyUid: string;
29
61
  dpuUsed: number;
@@ -34,7 +66,20 @@ export interface RemoteUsageResponse {
34
66
  pctStorage: number;
35
67
  period: string;
36
68
  measuredAt: string | null;
69
+ /** False when every app owns its own cluster and there is no shared company database. */
70
+ companyCluster?: boolean;
71
+ apps?: RemoteAppUsage[];
37
72
  }
73
+ /** Answer for one app: its own numbers, or the company numbers when it shares the company cluster. */
74
+ export type RemoteAppUsageResponse = (RemoteAppUsage & {
75
+ companyUid: string;
76
+ mode: "app-cluster";
77
+ status?: string;
78
+ }) | (RemoteUsageResponse & {
79
+ appId: string;
80
+ mode: "company-cluster";
81
+ exempt?: boolean;
82
+ });
38
83
  /**
39
84
  * Scoped temporary AWS credentials for one app's database role. The control
40
85
  * plane would issue these from `POST /v1/db/apps/{appId}/session`.
@@ -74,6 +119,7 @@ export declare class ControlPlaneDbClient {
74
119
  region?: string;
75
120
  }): Promise<RemoteProvisionResponse>;
76
121
  status(companyUid: string): Promise<RemoteStatusResponse>;
122
+ appUsage(companyUid: string, appId: string): Promise<RemoteAppUsageResponse>;
77
123
  usage(companyUid: string): Promise<RemoteUsageResponse>;
78
124
  appSession(input: {
79
125
  companyUid: string;
@@ -78,6 +78,20 @@ export class ControlPlaneDbClient {
78
78
  }
79
79
  return JSON.parse(text);
80
80
  }
81
+ async appUsage(companyUid, appId) {
82
+ const token = await this.getAccessToken();
83
+ const query = `companyUid=${encodeURIComponent(companyUid)}&appId=${encodeURIComponent(appId)}`;
84
+ const res = await this.fetchImpl(`${this.baseUrl}/v1/db/usage?${query}`, {
85
+ method: "GET",
86
+ headers: { Authorization: `Bearer ${token}` },
87
+ signal: AbortSignal.timeout(DB_STATUS_TIMEOUT_MS),
88
+ });
89
+ const text = await res.text();
90
+ assertNoPostgresUrl("usage", text);
91
+ if (!res.ok)
92
+ throw httpError("usage", res.status, text);
93
+ return JSON.parse(text);
94
+ }
81
95
  async usage(companyUid) {
82
96
  const token = await this.getAccessToken();
83
97
  const url = `${this.baseUrl}/v1/db/usage?companyUid=${encodeURIComponent(companyUid)}`;