@indigoai-us/hq-cli 5.345.42 → 5.345.44

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.
Files changed (46) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/dist/command-catalog.generated.d.ts +2 -2
  3. package/dist/command-catalog.generated.js +2 -2
  4. package/dist/commands/db-remote-deps.js +4 -0
  5. package/dist/commands/install-global.d.ts +8 -1
  6. package/dist/commands/install-global.js +128 -25
  7. package/dist/commands/lanes-checks.d.ts +1 -0
  8. package/dist/commands/lanes-checks.js +6 -1
  9. package/dist/commands/lanes-message.js +1 -0
  10. package/dist/commands/lanes-report.d.ts +2 -1
  11. package/dist/commands/lanes-report.js +63 -6
  12. package/dist/commands/lanes-resume.js +3 -0
  13. package/dist/commands/lanes.js +218 -49
  14. package/dist/index.js +43 -7
  15. package/dist/lib/anywhere-person-setting.d.ts +2 -0
  16. package/dist/lib/anywhere-person-setting.js +2 -1
  17. package/dist/lib/anywhere-runtime-fast-flag.js +8 -2
  18. package/dist/lib/core-utils/sentry-report.js +8 -1
  19. package/dist/lib/daemon/anywhere-listener.d.ts +16 -0
  20. package/dist/lib/daemon/anywhere-listener.js +79 -0
  21. package/dist/lib/daemon/run.js +30 -23
  22. package/dist/lib/db/control-plane.d.ts +5 -0
  23. package/dist/lib/db/control-plane.js +7 -0
  24. package/dist/lib/doctor/checks/anywhere.d.ts +10 -0
  25. package/dist/lib/doctor/checks/anywhere.js +45 -17
  26. package/dist/lib/flag-registry.d.ts +4 -0
  27. package/dist/lib/flag-registry.js +43 -2
  28. package/dist/lib/flag-snapshot-file.d.ts +17 -0
  29. package/dist/lib/flag-snapshot-file.js +153 -37
  30. package/dist/lib/lanes/flag-keys.d.ts +5 -0
  31. package/dist/lib/lanes/flag-keys.js +5 -0
  32. package/dist/lib/lanes/grants.js +1 -1
  33. package/dist/lib/lanes/reconcile.d.ts +2 -0
  34. package/dist/lib/lanes/reconcile.js +39 -1
  35. package/dist/lib/lanes/spawn.d.ts +4 -0
  36. package/dist/lib/lanes/spawn.js +132 -6
  37. package/dist/lib/lanes/store.js +35 -0
  38. package/dist/lib/lanes/tracker-report.d.ts +46 -0
  39. package/dist/lib/lanes/tracker-report.js +85 -1
  40. package/dist/lib/lanes/types.d.ts +3 -1
  41. package/dist/lib/lanes/types.js +1 -0
  42. package/dist/main.js +5 -1
  43. package/dist/startup-registration.d.ts +3 -0
  44. package/dist/startup-registration.js +11 -4
  45. package/dist/utils/resident-package-preload.js +5 -1
  46. package/package.json +1 -1
@@ -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;
@@ -130,4 +130,9 @@ export declare class ControlPlaneDbClient {
130
130
  appId: string;
131
131
  }): Promise<RemoteDestroyAppResponse>;
132
132
  }
133
+ export type ControlPlaneHttpError = Error & {
134
+ status?: number;
135
+ code?: string;
136
+ reason?: string;
137
+ };
133
138
  //# sourceMappingURL=control-plane.d.ts.map
@@ -48,6 +48,8 @@ export class ControlPlaneDbClient {
48
48
  err.status = res.status;
49
49
  if (typeof payload?.code === "string")
50
50
  err.code = payload.code;
51
+ if (typeof payload?.reason === "string")
52
+ err.reason = payload.reason;
51
53
  throw err;
52
54
  }
53
55
  return JSON.parse(text);
@@ -145,12 +147,15 @@ export class ControlPlaneDbClient {
145
147
  function httpError(label, status, text) {
146
148
  let msg = `${label} failed (${status})`;
147
149
  let code;
150
+ let reason;
148
151
  try {
149
152
  const j = JSON.parse(text);
150
153
  if (typeof j.error === "string")
151
154
  msg = j.error;
152
155
  if (typeof j.code === "string")
153
156
  code = j.code;
157
+ if (typeof j.reason === "string")
158
+ reason = j.reason;
154
159
  }
155
160
  catch {
156
161
  /* keep */
@@ -159,6 +164,8 @@ function httpError(label, status, text) {
159
164
  err.status = status;
160
165
  if (code)
161
166
  err.code = code;
167
+ if (reason)
168
+ err.reason = reason;
162
169
  return err;
163
170
  }
164
171
  //# sourceMappingURL=control-plane.js.map
@@ -13,6 +13,8 @@ import type { CheckFamily } from "../types.js";
13
13
  export declare const REMEDIATION: {
14
14
  readonly installClaude: "hq install --global --runtime claude";
15
15
  readonly installCodex: "hq install --global --runtime codex";
16
+ readonly repairClaudeSkills: "hq uninstall --global --runtime claude && hq install --global --runtime claude";
17
+ readonly repairCodexSkills: "hq uninstall --global --runtime codex && hq install --global --runtime codex";
16
18
  readonly daemonInstall: "hq daemon install";
17
19
  readonly daemonRestart: "hq daemon restart --daemon";
18
20
  readonly link: "hq link <repo-dir> <company>";
@@ -22,6 +24,13 @@ interface DaemonServiceState {
22
24
  loaded: boolean;
23
25
  detail: string;
24
26
  }
27
+ export interface DaemonServiceProbeOptions {
28
+ platform?: NodeJS.Platform;
29
+ uid?: number;
30
+ launchdProbe?: (target: string) => boolean;
31
+ /** Test seam for the desktop app's live managed daemon state. */
32
+ hostedDaemonProbe?: () => boolean;
33
+ }
25
34
  export interface AnywhereDeps {
26
35
  home?: string;
27
36
  env?: NodeJS.ProcessEnv;
@@ -40,5 +49,6 @@ export interface AnywhereDeps {
40
49
  }
41
50
  export declare function createAnywhereFamily(deps?: AnywhereDeps): CheckFamily;
42
51
  export declare const anywhereFamily: CheckFamily;
52
+ export declare function defaultServiceState(home: string, options?: DaemonServiceProbeOptions): DaemonServiceState;
43
53
  export {};
44
54
  //# sourceMappingURL=anywhere.d.ts.map
@@ -21,6 +21,8 @@ import { GROK_MODE } from "../../install/grok-targets.js";
21
21
  import { HqdClientError, hqdRequest } from "../../daemon/protocol.js";
22
22
  import { daemonPresenceLabel, heartbeatFile, readHeartbeat } from "../../daemon/heartbeat.js";
23
23
  import { DAEMON_LAUNCHD_LABEL, DAEMON_SYSTEMD_UNIT, DAEMON_WINDOWS_TASK } from "../../daemon/install.js";
24
+ import { hostedDaemonRunning } from "../../daemon/handoff.js";
25
+ import { daemonPaths } from "../../daemon/paths.js";
24
26
  import { hqdSocketPath } from "../../daemon/protocol.js";
25
27
  import { readRegistry, registryDir, registryFile } from "../../registry/registry.js";
26
28
  import { anywhereRuntimeEnabled } from "../../../commands/anywhere-runtime-flag.js";
@@ -29,6 +31,8 @@ const ANYWHERE_FAMILY_ID = "anywhere";
29
31
  export const REMEDIATION = {
30
32
  installClaude: "hq install --global --runtime claude",
31
33
  installCodex: "hq install --global --runtime codex",
34
+ repairClaudeSkills: "hq uninstall --global --runtime claude && hq install --global --runtime claude",
35
+ repairCodexSkills: "hq uninstall --global --runtime codex && hq install --global --runtime codex",
32
36
  daemonInstall: "hq daemon install",
33
37
  daemonRestart: "hq daemon restart --daemon",
34
38
  link: "hq link <repo-dir> <company>",
@@ -137,13 +141,14 @@ async function runAnywhereChecks(context, deps = {}) {
137
141
  else
138
142
  out.push(result("PASS", "daemon-installed", `daemon service is installed and loaded (${service.detail})`));
139
143
  const running = health.reachable ? health.version : null;
144
+ const daemonFix = service.installed ? REMEDIATION.daemonRestart : REMEDIATION.daemonInstall;
140
145
  if (health.reachable)
141
146
  out.push(result("PASS", "socket", `hqd answered on ${socketPath}`, undefined, socketPath));
142
147
  else
143
- out.push(result("FAIL", "socket", `hqd is not reachable on ${socketPath}`, REMEDIATION.daemonInstall, socketPath));
148
+ out.push(result("FAIL", "socket", `hqd is not reachable on ${socketPath}`, daemonFix, socketPath));
144
149
  const cliVersion = deps.cliVersion ?? CLI_VERSION;
145
150
  if (running === null)
146
- out.push(result("FAIL", "version", `cannot compare versions: hqd did not answer (hq-cli is ${cliVersion})`, REMEDIATION.daemonInstall));
151
+ out.push(result("FAIL", "version", `cannot compare versions: hqd did not answer (hq-cli is ${cliVersion})`, daemonFix));
147
152
  else if (running !== cliVersion)
148
153
  out.push(result("FAIL", "version", `hqd runs ${running} but hq-cli ${cliVersion} is installed`, REMEDIATION.daemonRestart));
149
154
  else
@@ -159,9 +164,9 @@ async function runAnywhereChecks(context, deps = {}) {
159
164
  if (pluginInstall)
160
165
  out.push(result("PASS", "claude-skills", `HQ Claude skills are provided by plugin ${pluginInstall.pluginId}`));
161
166
  else
162
- out.push(checkSkillLinks("claude", claude.skillsDir, REMEDIATION.installClaude));
167
+ out.push(checkSkillLinks("claude", claude.skillsDir, REMEDIATION.installClaude, REMEDIATION.repairClaudeSkills));
163
168
  if (mcp.runtimes.codex.installed)
164
- out.push(checkSkillLinks("codex", codex.skillsDir, REMEDIATION.installCodex));
169
+ out.push(checkSkillLinks("codex", codex.skillsDir, REMEDIATION.installCodex, REMEDIATION.repairCodexSkills));
165
170
  return out;
166
171
  }
167
172
  function unreachableHealth(socketPath) {
@@ -321,7 +326,7 @@ function checkRegistration(runtimeName, status, runtimeState, fix) {
321
326
  return result("FAIL", id, `${runtimeName} hq MCP entry does not use absolute paths that exist (${status.target ?? ""})`, fix);
322
327
  return result("PASS", id, `hq MCP server registered in ${runtimeName} with absolute paths`);
323
328
  }
324
- function checkSkillLinks(runtime, dir, fix) {
329
+ function checkSkillLinks(runtime, dir, fix, repair = fix) {
325
330
  const id = `${runtime}-skills`;
326
331
  let names;
327
332
  try {
@@ -335,19 +340,42 @@ function checkSkillLinks(runtime, dir, fix) {
335
340
  return result("WARN", id, `no hq-* skill links in ${dir}`, fix, dir);
336
341
  const broken = links.filter((n) => !fs.existsSync(path.join(dir, n)));
337
342
  if (broken.length > 0)
338
- return result("FAIL", id, `${broken.length} broken skill links in ${dir}: ${broken.slice(0, 5).join(", ")}`, fix, dir);
343
+ return result("FAIL", id, `${broken.length} broken skill links in ${dir}: ${broken.slice(0, 5).join(", ")}`, repair, dir);
339
344
  return result("PASS", id, `${links.length} hq-* skill links in ${dir} resolve`, undefined, dir);
340
345
  }
341
- function defaultServiceState(home) {
342
- if (process.platform === "darwin") {
343
- const plist = path.join(home, "Library", "LaunchAgents", `${DAEMON_LAUNCHD_LABEL}.plist`);
344
- if (!fs.existsSync(plist))
345
- return { installed: false, loaded: false, detail: `no ${plist}` };
346
- const uid = process.getuid?.() ?? 0;
347
- const probe = spawnSync("launchctl", ["print", `gui/${uid}/${DAEMON_LAUNCHD_LABEL}`], { encoding: "utf8", timeout: 5_000 });
348
- return { installed: true, loaded: probe.status === 0, detail: `launchd ${DAEMON_LAUNCHD_LABEL}` };
346
+ export function defaultServiceState(home, options = {}) {
347
+ const platform = options.platform ?? process.platform;
348
+ if (platform === "darwin") {
349
+ const launchAgents = path.join(home, "Library", "LaunchAgents");
350
+ const nativePlist = path.join(launchAgents, `${DAEMON_LAUNCHD_LABEL}.plist`);
351
+ const desktopLabel = "ai.indigo.hq-sync-menubar";
352
+ const desktopPlist = path.join(launchAgents, `${desktopLabel}.plist`);
353
+ const uid = options.uid ?? process.getuid?.() ?? 0;
354
+ const probe = options.launchdProbe ?? ((target) => spawnSync("launchctl", ["print", target], { encoding: "utf8", timeout: 5_000 }).status === 0);
355
+ const hasNative = fs.existsSync(nativePlist);
356
+ const hasDesktop = fs.existsSync(desktopPlist);
357
+ const nativeTarget = `gui/${uid}/${DAEMON_LAUNCHD_LABEL}`;
358
+ if (hasNative && probe(nativeTarget)) {
359
+ return { installed: true, loaded: true, detail: `launchd ${DAEMON_LAUNCHD_LABEL}` };
360
+ }
361
+ const desktopTarget = `gui/${uid}/${desktopLabel}`;
362
+ const desktopHostedDaemon = options.hostedDaemonProbe
363
+ ? options.hostedDaemonProbe()
364
+ : hostedDaemonRunning(daemonPaths(home));
365
+ if (desktopHostedDaemon) {
366
+ return { installed: true, loaded: true, detail: "HQ Desktop-hosted hqd is running (host=desktop)" };
367
+ }
368
+ if (hasNative)
369
+ return { installed: true, loaded: false, detail: `launchd ${DAEMON_LAUNCHD_LABEL}` };
370
+ if (!hasDesktop)
371
+ return { installed: false, loaded: false, detail: `no ${nativePlist} or live desktop-hosted hqd` };
372
+ const desktopAgentLoaded = hasDesktop && probe(desktopTarget);
373
+ const desktopDetail = hasDesktop
374
+ ? `HQ Desktop LaunchAgent ${desktopLabel}${desktopAgentLoaded ? " is loaded" : " is not loaded"}, but no live desktop-hosted hqd was found`
375
+ : "no live desktop-hosted hqd was found";
376
+ return { installed: false, loaded: false, detail: `no ${nativePlist}; ${desktopDetail}` };
349
377
  }
350
- if (process.platform === "linux") {
378
+ if (platform === "linux") {
351
379
  const unit = path.join(home, ".config", "systemd", "user", DAEMON_SYSTEMD_UNIT);
352
380
  const systemUnit = path.join("/etc/systemd/system", DAEMON_SYSTEMD_UNIT);
353
381
  if (!fs.existsSync(unit) && !fs.existsSync(systemUnit))
@@ -356,13 +384,13 @@ function defaultServiceState(home) {
356
384
  const probe = spawnSync("systemctl", [...(systemScope ? [] : ["--user"]), "is-active", DAEMON_SYSTEMD_UNIT], { encoding: "utf8", timeout: 5_000 });
357
385
  return { installed: true, loaded: probe.status === 0, detail: `systemd ${systemScope ? "system" : "user"} ${DAEMON_SYSTEMD_UNIT}` };
358
386
  }
359
- if (process.platform === "win32") {
387
+ if (platform === "win32") {
360
388
  const probe = spawnSync("schtasks.exe", ["/Query", "/TN", DAEMON_WINDOWS_TASK, "/FO", "CSV", "/NH"], { encoding: "utf8", timeout: 5_000 });
361
389
  if (probe.status !== 0)
362
390
  return { installed: false, loaded: false, detail: `no Scheduled Task ${DAEMON_WINDOWS_TASK}` };
363
391
  const loaded = /,\s*"?Running"?\s*$/i.test(probe.stdout.trim());
364
392
  return { installed: true, loaded, detail: `Scheduled Task ${DAEMON_WINDOWS_TASK}${loaded ? " is running" : " is not running"}` };
365
393
  }
366
- return { installed: false, loaded: false, detail: `${process.platform}: service probe not available` };
394
+ return { installed: false, loaded: false, detail: `${platform}: service probe not available` };
367
395
  }
368
396
  //# sourceMappingURL=anywhere.js.map
@@ -31,6 +31,8 @@ export interface FlagRegistryDependencies {
31
31
  sentry?: FlagRegistrySentry;
32
32
  /** Test seam for exercising the no-source fail-open path. */
33
33
  workerSource?: string;
34
+ /** Start another bounded one-shot worker after an earlier process refresh. */
35
+ forceRefresh?: boolean;
34
36
  captureWorkerFailure?: (error: Error, tags: Record<string, string>) => void;
35
37
  }
36
38
  export interface CompanyFlagGateDependencies {
@@ -54,6 +56,8 @@ export declare function createProcessSnapshotReader(companyIdentifiers: string[]
54
56
  * null and the caller's exact legacy fallback decides the gate.
55
57
  */
56
58
  export declare function kickFlagRegistryReadiness(dependencies?: FlagRegistryDependencies): FlagReader | null;
59
+ /** Refresh the registry snapshot for a long-running process and wait briefly for its result. */
60
+ export declare function refreshFlagRegistrySnapshot(timeoutMs?: number): Promise<void>;
57
61
  /**
58
62
  * Evaluate a registry-backed gate without changing its outage default.
59
63
  * `isEnabled` itself is synchronous and snapshot-only; any reader failure
@@ -11,7 +11,7 @@ import { resolveFlagRegistryEndpoint, } from "./flag-registry-endpoint.js";
11
11
  import { FLAG_REGISTRY_WORKER_SOURCE } from "./flag-registry-worker-source.generated.js";
12
12
  import { resolveProcessFlagCompanyContext } from "./process-flag-company-context.js";
13
13
  import { registerFlagRegistryRefreshWaiter } from "./flag-registry-refresh-wait.js";
14
- import { flagSnapshotWriteFailureTags, readFlagSnapshotFile, writeFlagSnapshotFile, } from "./flag-snapshot-file.js";
14
+ import { flagSnapshotWriteFailureTags, readFlagSnapshotFile, writePersonFlagSnapshotFile, writeFlagSnapshotFile, } from "./flag-snapshot-file.js";
15
15
  function captureFlagRegistryException(error, tags, sentry) {
16
16
  if (!sentry)
17
17
  return;
@@ -50,6 +50,7 @@ let refreshCompanyUid;
50
50
  let refreshHasCompanyContext = false;
51
51
  let refreshPendingSnapshot = null;
52
52
  let refreshSentry;
53
+ let refreshDependencies = {};
53
54
  /** Wait briefly for the current company refresh to persist or report completion. */
54
55
  export async function waitForFlagRegistryRefresh(timeoutMs = 500, companyUid) {
55
56
  const resolvedCompanyUid = normalizedCompanyUid(companyUid);
@@ -70,6 +71,21 @@ export async function waitForFlagRegistryRefresh(timeoutMs = 500, companyUid) {
70
71
  completeRefresh?.();
71
72
  completeRefresh = null;
72
73
  }
74
+ else if (refreshPendingSnapshot && !refreshHasCompanyContext) {
75
+ try {
76
+ writePersonFlagSnapshotFile(refreshPendingSnapshot.flags);
77
+ }
78
+ catch (error) {
79
+ captureFlagRegistryException(error instanceof Error ? error : new Error(String(error)), {
80
+ component: "flag-snapshot-cache-write",
81
+ company_uid_present: "false",
82
+ ...flagSnapshotWriteFailureTags(error),
83
+ }, refreshSentry);
84
+ }
85
+ refreshPendingSnapshot = null;
86
+ completeRefresh?.();
87
+ completeRefresh = null;
88
+ }
73
89
  if (!completeRefresh)
74
90
  return;
75
91
  const worker = refreshWorker;
@@ -123,8 +139,10 @@ function processSnapshotReader(companyIdentifiers) {
123
139
  * null and the caller's exact legacy fallback decides the gate.
124
140
  */
125
141
  export function kickFlagRegistryReadiness(dependencies = {}) {
126
- if (processReader)
142
+ if (processReader && !dependencies.forceRefresh)
127
143
  return processReader;
144
+ if (!dependencies.forceRefresh)
145
+ refreshDependencies = { ...dependencies };
128
146
  const endpoint = resolveFlagRegistryEndpoint(dependencies.endpoint, process.env);
129
147
  const companyScope = dependencies.companyScope ?? explicitCompanyScopeFromArgv(dependencies.argv ?? []);
130
148
  const companyEnv = companyScope
@@ -233,6 +251,16 @@ export function kickFlagRegistryReadiness(dependencies = {}) {
233
251
  finishRefresh();
234
252
  }
235
253
  else if (!refreshHasCompanyContext) {
254
+ try {
255
+ writePersonFlagSnapshotFile(value.flags);
256
+ }
257
+ catch (error) {
258
+ captureFlagRegistryException(error instanceof Error ? error : new Error(String(error)), {
259
+ component: "flag-snapshot-cache-write",
260
+ company_uid_present: "false",
261
+ ...flagSnapshotWriteFailureTags(error),
262
+ }, sentry);
263
+ }
236
264
  refreshPendingSnapshot = null;
237
265
  finishRefresh();
238
266
  }
@@ -258,6 +286,18 @@ export function kickFlagRegistryReadiness(dependencies = {}) {
258
286
  }
259
287
  return processReader;
260
288
  }
289
+ /** Refresh the registry snapshot for a long-running process and wait briefly for its result. */
290
+ export async function refreshFlagRegistrySnapshot(timeoutMs = 5_000) {
291
+ const refreshIsPending = () => completeRefresh !== null;
292
+ if (refreshIsPending()) {
293
+ await waitForFlagRegistryRefresh(timeoutMs);
294
+ if (refreshIsPending())
295
+ return;
296
+ }
297
+ processReader = null;
298
+ kickFlagRegistryReadiness({ ...refreshDependencies, forceRefresh: true });
299
+ await waitForFlagRegistryRefresh(timeoutMs);
300
+ }
261
301
  /**
262
302
  * Evaluate a registry-backed gate without changing its outage default.
263
303
  * `isEnabled` itself is synchronous and snapshot-only; any reader failure
@@ -348,5 +388,6 @@ export function _resetFlagRegistryForTests() {
348
388
  refreshHasCompanyContext = false;
349
389
  refreshPendingSnapshot = null;
350
390
  refreshSentry = undefined;
391
+ refreshDependencies = {};
351
392
  }
352
393
  //# sourceMappingURL=flag-registry.js.map
@@ -7,8 +7,25 @@ export interface PersistedFlagSnapshot {
7
7
  fetched_at: string;
8
8
  flags: Record<string, boolean>;
9
9
  }
10
+ export interface PersistedPersonFlagSnapshot {
11
+ scope: "person";
12
+ fetched_at: string;
13
+ flags: Record<string, boolean>;
14
+ }
10
15
  export declare function _resetFlagSnapshotCacheForTests(): void;
11
16
  export declare function flagSnapshotPath(companyUid: string, env?: NodeJS.ProcessEnv): string | undefined;
17
+ export declare function personFlagSnapshotPath(env?: NodeJS.ProcessEnv): string;
18
+ /** Persist the no-company flag resolution without storing identity or token data. */
19
+ export declare function writePersonFlagSnapshotFile(flags: Readonly<Record<string, unknown>>, options?: {
20
+ env?: NodeJS.ProcessEnv;
21
+ fetchedAt?: string;
22
+ }): string;
23
+ /** Read only a fresh, correctly scoped person snapshot. Every invalid state is a cache miss. */
24
+ export declare function readPersonFlagSnapshotFile(options?: {
25
+ env?: NodeJS.ProcessEnv;
26
+ now?: number;
27
+ maxAgeMs?: number;
28
+ }): PersistedPersonFlagSnapshot | null;
12
29
  /** Write just the validated flag decisions and company scope, never auth data. */
13
30
  export declare function writeFlagSnapshotFile(companyUid: string, flags: Readonly<Record<string, unknown>>, options?: {
14
31
  env?: NodeJS.ProcessEnv;
@@ -102,19 +102,169 @@ function fallbackSnapshotPath(companyUid, create) {
102
102
  }
103
103
  return path.join(directories[2], `snapshot-${companyUid}.json`);
104
104
  }
105
+ function fallbackPersonSnapshotPath(create) {
106
+ if (typeof process.getuid !== "function")
107
+ return undefined;
108
+ const uid = process.getuid();
109
+ const root = path.join(os.tmpdir(), `hq-cli-flag-snapshots-${uid}`);
110
+ const directories = [root, path.join(root, "hq"), path.join(root, "hq", "flags")];
111
+ for (const directory of directories) {
112
+ try {
113
+ if (create)
114
+ fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
115
+ const stat = fs.lstatSync(directory);
116
+ if (!stat.isDirectory() || stat.isSymbolicLink() || stat.uid !== uid)
117
+ return undefined;
118
+ if (create)
119
+ fs.chmodSync(directory, 0o700);
120
+ else if ((stat.mode & 0o077) !== 0)
121
+ return undefined;
122
+ }
123
+ catch {
124
+ return undefined;
125
+ }
126
+ }
127
+ return path.join(directories[2], "snapshot-person.json");
128
+ }
105
129
  export function flagSnapshotPath(companyUid, env = process.env) {
106
130
  if (!COMPANY_UID_RE.test(companyUid))
107
131
  return undefined;
108
132
  const cacheHome = env.XDG_CACHE_HOME?.trim() || path.join(os.homedir(), ".cache");
109
133
  return path.join(cacheHome, "hq", "flags", `snapshot-${companyUid}.json`);
110
134
  }
135
+ export function personFlagSnapshotPath(env = process.env) {
136
+ const cacheHome = env.XDG_CACHE_HOME?.trim() || path.join(os.homedir(), ".cache");
137
+ return path.join(cacheHome, "hq", "flags", "snapshot-person.json");
138
+ }
139
+ function normalizedFlagValues(flags) {
140
+ return Object.fromEntries(Object.entries(flags).filter((entry) => Boolean(entry[0].trim()) && typeof entry[1] === "boolean"));
141
+ }
142
+ function writeSnapshotAt(destination, snapshot, cacheRootKind) {
143
+ const directory = path.dirname(destination);
144
+ const context = (step) => ({
145
+ step,
146
+ cacheRootKind,
147
+ directory,
148
+ });
149
+ atWriteStep(context("mkdir"), () => fs.mkdirSync(directory, { recursive: true, mode: 0o700 }));
150
+ const temporary = `${destination}.${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}.tmp`;
151
+ let fd;
152
+ try {
153
+ fd = atWriteStep(context("open_tmp"), () => fs.openSync(temporary, "wx", 0o600));
154
+ atWriteStep(context("write_tmp"), () => {
155
+ fs.writeFileSync(fd, `${JSON.stringify(snapshot)}\n`, "utf8");
156
+ fs.fsyncSync(fd);
157
+ fs.closeSync(fd);
158
+ fd = undefined;
159
+ });
160
+ atWriteStep(context("rename"), () => fs.renameSync(temporary, destination));
161
+ }
162
+ catch (error) {
163
+ if (fd !== undefined)
164
+ fs.closeSync(fd);
165
+ try {
166
+ fs.unlinkSync(temporary);
167
+ }
168
+ catch (cleanupError) {
169
+ if (cleanupError.code !== "ENOENT")
170
+ throw cleanupError;
171
+ }
172
+ throw error;
173
+ }
174
+ return destination;
175
+ }
176
+ function writePersonFallback(snapshot, originalError) {
177
+ const fallbackFile = fallbackPersonSnapshotPath(true);
178
+ if (!fallbackFile) {
179
+ if (originalError) {
180
+ markFallbackFailed(originalError);
181
+ throw originalError;
182
+ }
183
+ const error = Object.assign(new Error("Flag snapshot fallback directory is unavailable"), { code: "EACCES" });
184
+ markFallbackFailed(error);
185
+ throw error;
186
+ }
187
+ try {
188
+ const written = writeSnapshotAt(fallbackFile, snapshot, "home");
189
+ fallbackCacheUsed = true;
190
+ return written;
191
+ }
192
+ catch (fallbackError) {
193
+ if (originalError) {
194
+ markFallbackFailed(originalError);
195
+ throw originalError;
196
+ }
197
+ markFallbackFailed(fallbackError);
198
+ throw fallbackError;
199
+ }
200
+ }
201
+ /** Persist the no-company flag resolution without storing identity or token data. */
202
+ export function writePersonFlagSnapshotFile(flags, options = {}) {
203
+ const env = options.env ?? process.env;
204
+ const snapshot = {
205
+ scope: "person",
206
+ fetched_at: options.fetchedAt ?? new Date().toISOString(),
207
+ flags: normalizedFlagValues(flags),
208
+ };
209
+ if (!Number.isFinite(Date.parse(snapshot.fetched_at))) {
210
+ throw new Error("Cannot persist a flag snapshot with an invalid fetched_at timestamp");
211
+ }
212
+ if (fallbackCacheUsed)
213
+ return writePersonFallback(snapshot);
214
+ try {
215
+ return writeSnapshotAt(personFlagSnapshotPath(env), snapshot, env.XDG_CACHE_HOME?.trim() ? "xdg" : "home");
216
+ }
217
+ catch (error) {
218
+ if (!isPermissionFailure(error))
219
+ throw error;
220
+ return writePersonFallback(snapshot, error);
221
+ }
222
+ }
223
+ /** Read only a fresh, correctly scoped person snapshot. Every invalid state is a cache miss. */
224
+ export function readPersonFlagSnapshotFile(options = {}) {
225
+ const env = options.env ?? process.env;
226
+ const files = [personFlagSnapshotPath(env)];
227
+ const fallback = fallbackPersonSnapshotPath(false);
228
+ if (fallbackCacheUsed && fallback)
229
+ files.unshift(fallback);
230
+ else if (fallback)
231
+ files.push(fallback);
232
+ for (const file of files) {
233
+ let parsed;
234
+ try {
235
+ parsed = JSON.parse(fs.readFileSync(file, "utf8"));
236
+ }
237
+ catch {
238
+ continue;
239
+ }
240
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
241
+ continue;
242
+ const value = parsed;
243
+ if (Object.keys(value).sort().join(",") !== "fetched_at,flags,scope" ||
244
+ value.scope !== "person" ||
245
+ typeof value.fetched_at !== "string" ||
246
+ !value.flags || typeof value.flags !== "object" || Array.isArray(value.flags))
247
+ continue;
248
+ const fetchedAt = Date.parse(value.fetched_at);
249
+ const age = (options.now ?? Date.now()) - fetchedAt;
250
+ if (!Number.isFinite(fetchedAt) || age < 0 || age > (options.maxAgeMs ?? FLAG_SNAPSHOT_MAX_AGE_MS))
251
+ continue;
252
+ const flags = value.flags;
253
+ if (!Object.values(flags).every((enabled) => typeof enabled === "boolean"))
254
+ continue;
255
+ if (file === fallback && fallback)
256
+ fallbackCacheUsed = true;
257
+ return { scope: "person", fetched_at: value.fetched_at, flags: flags };
258
+ }
259
+ return null;
260
+ }
111
261
  /** Write just the validated flag decisions and company scope, never auth data. */
112
262
  export function writeFlagSnapshotFile(companyUid, flags, options = {}) {
113
263
  const env = options.env ?? process.env;
114
264
  const file = flagSnapshotPath(companyUid, env);
115
265
  if (!file)
116
266
  throw new Error("Cannot persist a flag snapshot without a valid company uid");
117
- const normalizedFlags = Object.fromEntries(Object.entries(flags).filter((entry) => Boolean(entry[0].trim()) && typeof entry[1] === "boolean"));
267
+ const normalizedFlags = normalizedFlagValues(flags);
118
268
  const snapshot = {
119
269
  company_uid: companyUid,
120
270
  fetched_at: options.fetchedAt ?? new Date().toISOString(),
@@ -124,40 +274,6 @@ export function writeFlagSnapshotFile(companyUid, flags, options = {}) {
124
274
  throw new Error("Cannot persist a flag snapshot with an invalid fetched_at timestamp");
125
275
  }
126
276
  const cacheRootKind = env.XDG_CACHE_HOME?.trim() ? "xdg" : "home";
127
- const writeAt = (destination) => {
128
- const directory = path.dirname(destination);
129
- const context = (step) => ({
130
- step,
131
- cacheRootKind,
132
- directory,
133
- });
134
- atWriteStep(context("mkdir"), () => fs.mkdirSync(directory, { recursive: true, mode: 0o700 }));
135
- const temporary = `${destination}.${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}.tmp`;
136
- let fd;
137
- try {
138
- fd = atWriteStep(context("open_tmp"), () => fs.openSync(temporary, "wx", 0o600));
139
- atWriteStep(context("write_tmp"), () => {
140
- fs.writeFileSync(fd, `${JSON.stringify(snapshot)}\n`, "utf8");
141
- fs.fsyncSync(fd);
142
- fs.closeSync(fd);
143
- fd = undefined;
144
- });
145
- atWriteStep(context("rename"), () => fs.renameSync(temporary, destination));
146
- }
147
- catch (error) {
148
- if (fd !== undefined)
149
- fs.closeSync(fd);
150
- try {
151
- fs.unlinkSync(temporary);
152
- }
153
- catch (cleanupError) {
154
- if (cleanupError.code !== "ENOENT")
155
- throw cleanupError;
156
- }
157
- throw error;
158
- }
159
- return destination;
160
- };
161
277
  const writeFallback = (originalError) => {
162
278
  const fallbackFile = fallbackSnapshotPath(companyUid, true);
163
279
  if (!fallbackFile) {
@@ -170,7 +286,7 @@ export function writeFlagSnapshotFile(companyUid, flags, options = {}) {
170
286
  throw error;
171
287
  }
172
288
  try {
173
- const written = writeAt(fallbackFile);
289
+ const written = writeSnapshotAt(fallbackFile, snapshot, cacheRootKind);
174
290
  fallbackCacheUsed = true;
175
291
  return written;
176
292
  }
@@ -186,7 +302,7 @@ export function writeFlagSnapshotFile(companyUid, flags, options = {}) {
186
302
  if (fallbackCacheUsed)
187
303
  return writeFallback();
188
304
  try {
189
- return writeAt(file);
305
+ return writeSnapshotAt(file, snapshot, cacheRootKind);
190
306
  }
191
307
  catch (error) {
192
308
  if (!isPermissionFailure(error))
@@ -1,5 +1,8 @@
1
1
  /** hq-flags keys used by lane command behavior. */
2
+ export declare const LANE_FOCUS_DASHBOARD_FLAG = "lanes-focus-dashboard";
2
3
  export declare const LANE_OUTPOST_LOAD_BALANCING_FLAG = "lanes.outpost-load-balancing";
4
+ /** Enables same-provider automatic lane recovery after provider quota exhaustion. */
5
+ export declare const LANE_QUOTA_ACCOUNT_FAILOVER_FLAG = "lanes.quota-account-failover";
3
6
  /** Gives live senior lanes Claude admission precedence and reserves host capacity for them. */
4
7
  export declare const LANE_LEAD_ADMISSION_PRIORITY_FLAG = "lanes.lead-admission-priority";
5
8
  /** Refresh provider/model/effort from a worker profile when a done lane takes a new story. */
@@ -24,4 +27,6 @@ export declare const LEAD_AUTOPILOT_HEADLINE_STALENESS_FLAG = "lanes.autopilot-h
24
27
  export declare const LEAD_AUTOPILOT_OUTPOST_NATIVE_LEADS_FLAG = "lanes.autopilot-outpost-native-leads";
25
28
  /** Allows applying an operator-reviewed repair of messages stranded in bare-ULID dropboxes. */
26
29
  export declare const LANE_ORPHANED_BARE_ID_DROPBOX_REPAIR_FLAG = "lanes.orphaned-bare-id-dropbox-repair";
30
+ /** Allows a Claude lane to leave an exhausted profile account pin for an eligible account. */
31
+ export declare const LANE_PROFILE_PIN_EXHAUSTED_FALLBACK_FLAG = "lanes.profile-pin-exhausted-fallback";
27
32
  //# sourceMappingURL=flag-keys.d.ts.map