@indigoai-us/hq-cli 5.275.0 → 5.277.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.
Files changed (120) hide show
  1. package/CHANGELOG.md +112 -0
  2. package/README.md +12 -0
  3. package/assets/bot-workers/setup/context/USER-GUIDE.md +4 -4
  4. package/assets/scaffold/core/scripts/hq-delegate-pickup.sh +99 -0
  5. package/assets/scaffold/core/scripts/hq-delegate-repo.sh +158 -0
  6. package/assets/scaffold/core/scripts/hq-delegate-resolve.sh +145 -0
  7. package/assets/scaffold/core/scripts/hq-delegate-secrets.sh +129 -0
  8. package/assets/scaffold/core/scripts/hq-delegate-transfer.sh +175 -0
  9. package/assets/scaffold/core/scripts/lib/secret-patterns.sh +58 -0
  10. package/dist/command-catalog.generated.d.ts +239 -1
  11. package/dist/command-catalog.generated.js +302 -1
  12. package/dist/commands/core-checkpoint.d.ts +4 -2
  13. package/dist/commands/core-checkpoint.js +34 -3
  14. package/dist/commands/core-command-catalog.d.ts +4 -2
  15. package/dist/commands/core-command-catalog.js +5 -2
  16. package/dist/commands/core-delegate.d.ts +6 -0
  17. package/dist/commands/core-delegate.js +987 -0
  18. package/dist/commands/core-ontology.js +3 -3
  19. package/dist/commands/core.js +11 -1
  20. package/dist/commands/daemon.d.ts +4 -0
  21. package/dist/commands/daemon.js +175 -2
  22. package/dist/commands/dm.d.ts +2 -0
  23. package/dist/commands/dm.js +11 -4
  24. package/dist/commands/feedback.d.ts +6 -0
  25. package/dist/commands/feedback.js +86 -37
  26. package/dist/commands/lanes-accounts.js +44 -2
  27. package/dist/commands/lanes-message.js +69 -12
  28. package/dist/commands/lanes-resume.js +2 -0
  29. package/dist/commands/lanes-shared.d.ts +3 -0
  30. package/dist/commands/lanes-shared.js +12 -2
  31. package/dist/commands/lanes.d.ts +5 -1
  32. package/dist/commands/lanes.js +402 -49
  33. package/dist/commands/run-feature-telemetry.d.ts +3 -0
  34. package/dist/commands/run-feature-telemetry.js +6 -0
  35. package/dist/commands/run.js +3 -2
  36. package/dist/commands/secrets.d.ts +16 -1
  37. package/dist/commands/secrets.js +80 -45
  38. package/dist/commands/sync-mode.d.ts +11 -0
  39. package/dist/commands/sync-mode.js +30 -0
  40. package/dist/commands/whoami.d.ts +4 -6
  41. package/dist/commands/whoami.js +39 -8
  42. package/dist/fast-core.d.ts +6 -7
  43. package/dist/fast-core.js +22 -8
  44. package/dist/fast-scaffold.d.ts +33 -7
  45. package/dist/fast-scaffold.js +104 -8
  46. package/dist/index.js +32 -1
  47. package/dist/lanes-drain-hook.js +154 -7
  48. package/dist/lib/agent-kit/run/inbox.js +3 -2
  49. package/dist/lib/billing/plan-lock.d.ts +3 -1
  50. package/dist/lib/billing/plan-lock.js +7 -1
  51. package/dist/lib/checkpoint-ontology/apply.d.ts +6 -0
  52. package/dist/lib/checkpoint-ontology/apply.js +60 -3
  53. package/dist/lib/checkpoint-ontology/company.d.ts +8 -4
  54. package/dist/lib/checkpoint-ontology/company.js +15 -7
  55. package/dist/lib/checkpoint-ontology/plan.d.ts +36 -0
  56. package/dist/lib/checkpoint-ontology/plan.js +66 -4
  57. package/dist/lib/checkpoint-ontology/queued-touches.d.ts +32 -0
  58. package/dist/lib/checkpoint-ontology/queued-touches.js +193 -0
  59. package/dist/lib/checkpoint-ontology/tree.d.ts +2 -0
  60. package/dist/lib/checkpoint-ontology/tree.js +34 -0
  61. package/dist/lib/core-utils/jq-json-serializer.d.ts +5 -0
  62. package/dist/lib/core-utils/jq-json-serializer.js +10 -0
  63. package/dist/lib/core-utils/secret-patterns.d.ts +41 -0
  64. package/dist/lib/core-utils/secret-patterns.js +33 -0
  65. package/dist/lib/core-utils/timeout-guard-native.d.ts +7 -0
  66. package/dist/lib/core-utils/timeout-guard-native.js +40 -0
  67. package/dist/lib/core-utils/timeout-guard.d.ts +3 -3
  68. package/dist/lib/core-utils/timeout-guard.js +28 -13
  69. package/dist/lib/daemon/catalog.d.ts +18 -2
  70. package/dist/lib/daemon/catalog.js +18 -2
  71. package/dist/lib/daemon/config.d.ts +6 -0
  72. package/dist/lib/daemon/config.js +15 -2
  73. package/dist/lib/daemon/requests.d.ts +4 -0
  74. package/dist/lib/daemon/requests.js +2 -0
  75. package/dist/lib/daemon/run.js +94 -8
  76. package/dist/lib/daemon/supervisor.d.ts +8 -0
  77. package/dist/lib/daemon/supervisor.js +101 -1
  78. package/dist/lib/daemon/sync-runner.d.ts +58 -0
  79. package/dist/lib/daemon/sync-runner.js +277 -0
  80. package/dist/lib/doctor/__testing__/run-doctor.d.ts +8 -0
  81. package/dist/lib/doctor/__testing__/run-doctor.js +52 -0
  82. package/dist/lib/flag-registry-worker-source.generated.d.ts +1 -1
  83. package/dist/lib/flag-registry-worker-source.generated.js +1 -1
  84. package/dist/lib/flag-registry.d.ts +12 -7
  85. package/dist/lib/flag-registry.js +66 -75
  86. package/dist/lib/flag-snapshot-file.d.ts +20 -0
  87. package/dist/lib/flag-snapshot-file.js +91 -0
  88. package/dist/lib/lanes/claude-balance.d.ts +6 -1
  89. package/dist/lib/lanes/claude-balance.js +46 -8
  90. package/dist/lib/lanes/config.js +15 -0
  91. package/dist/lib/lanes/hire-grant-dispatch.d.ts +2 -0
  92. package/dist/lib/lanes/hire-grant-dispatch.js +41 -0
  93. package/dist/lib/lanes/index.d.ts +1 -1
  94. package/dist/lib/lanes/keep-alive.d.ts +3 -0
  95. package/dist/lib/lanes/keep-alive.js +3 -0
  96. package/dist/lib/lanes/outpost-dispatch.d.ts +4 -0
  97. package/dist/lib/lanes/outpost-dispatch.js +11 -2
  98. package/dist/lib/lanes/reconcile.js +20 -4
  99. package/dist/lib/lanes/spawn.js +17 -10
  100. package/dist/lib/lanes/store.js +45 -1
  101. package/dist/lib/lanes/types.d.ts +30 -1
  102. package/dist/lib/lanes/types.js +2 -2
  103. package/dist/lib/lanes/watcher.js +3 -2
  104. package/dist/lib/process-flag-company-context.d.ts +8 -0
  105. package/dist/lib/process-flag-company-context.js +67 -0
  106. package/dist/main.js +3 -1
  107. package/dist/native-fast-route.d.ts +3 -0
  108. package/dist/native-fast-route.js +39 -0
  109. package/dist/secrets-env-fast-path.d.ts +5 -0
  110. package/dist/secrets-env-fast-path.js +16 -0
  111. package/dist/secrets-env-fast-route.d.ts +18 -0
  112. package/dist/secrets-env-fast-route.js +54 -0
  113. package/dist/utils/cli-telemetry.d.ts +26 -2
  114. package/dist/utils/cli-telemetry.js +178 -3
  115. package/dist/utils/hq-root-resolver.d.ts +13 -0
  116. package/dist/utils/hq-root-resolver.js +103 -0
  117. package/dist/utils/scaffold-fast-import-guard.js +6 -1
  118. package/dist/utils/vault-api.d.ts +8 -0
  119. package/dist/utils/vault-api.js +13 -1
  120. package/package.json +2 -2
@@ -6,7 +6,9 @@
6
6
  * keep a short-lived, offline CLI alive until the client's network deadline.
7
7
  */
8
8
  import { type WorkerOptions } from "node:worker_threads";
9
- import { type FlagClient, type FlagLookupOptions, type FlagSnapshot } from "@indigoai-us/hq-flags-client";
9
+ import { createFlagClient, type FlagClient, type FlagLookupOptions, type FlagSnapshot } from "@indigoai-us/hq-flags-client";
10
+ export { resolveProcessFlagCompanyContext } from "./process-flag-company-context.js";
11
+ export type { ProcessFlagCompanyContext } from "./process-flag-company-context.js";
10
12
  export { FLAG_REGISTRY_ENDPOINT_ENV } from "./flag-registry-endpoint.js";
11
13
  /** The synchronous capability that command gate call sites need. */
12
14
  export type FlagReader = Pick<FlagClient, "isEnabled">;
@@ -22,12 +24,13 @@ export interface FlagRegistryDependencies {
22
24
  workerSource?: string;
23
25
  captureWorkerFailure?: (error: Error, tags: Record<string, string>) => void;
24
26
  }
25
- export interface ProcessFlagCompanyContext {
26
- companyUid?: string;
27
- companySlug?: string;
27
+ export interface CompanyFlagGateDependencies {
28
+ endpoint?: string;
29
+ requestTimeoutMs?: number;
30
+ createClient?: typeof createFlagClient;
28
31
  }
29
- /** Resolve the same company identity used by the process-wide flag reader. */
30
- export declare function resolveProcessFlagCompanyContext(env?: NodeJS.ProcessEnv): ProcessFlagCompanyContext;
32
+ /** Create the same snapshot-backed reader used by process gates. */
33
+ export declare function createProcessSnapshotReader(companyIdentifiers: string[], getSnapshot?: () => FlagSnapshot | null, env?: NodeJS.ProcessEnv): FlagReader;
31
34
  /**
32
35
  * Begin one registry refresh without awaiting it or retaining the CLI process.
33
36
  *
@@ -43,7 +46,9 @@ export declare function kickFlagRegistryReadiness(dependencies?: FlagRegistryDep
43
46
  * `isEnabled` itself is synchronous and snapshot-only; any reader failure
44
47
  * falls through to the exact legacy predicate owned by the caller.
45
48
  */
46
- export declare function resolveFlagGate(reader: FlagReader | null | undefined, flagKey: string, lookup: FlagLookupOptions, legacyFallback: () => boolean): boolean;
49
+ export declare function resolveFlagGate(reader: FlagReader | null | undefined, flagKey: string, lookup: FlagLookupOptions, legacyFallback: () => boolean, env?: NodeJS.ProcessEnv): boolean;
50
+ /** Resolve one explicit company's flag without relying on process-wide binding. */
51
+ export declare function resolveCompanyFlagGate(companyUid: string, idToken: string, flagKey: string, lookup: FlagLookupOptions, legacyFallback: () => boolean, dependencies?: CompanyFlagGateDependencies): Promise<boolean>;
47
52
  /** Resolve a process gate against the shared snapshot reader, when configured. */
48
53
  export declare function resolveProcessFlagGate(flagKey: string, lookup: FlagLookupOptions, legacyFallback: () => boolean): boolean;
49
54
  /**
@@ -7,99 +7,43 @@
7
7
  */
8
8
  import { Worker } from "node:worker_threads";
9
9
  import * as Sentry from "@sentry/node";
10
- import { isFlagSnapshot, readLocalOverride, resolveFlag, } from "@indigoai-us/hq-flags-client";
11
- import { readMachineCompanyBinding } from "./work-context/company.js";
12
- import { readSessionState } from "./work-context/state.js";
13
- import { workContextRoot } from "./work-context/paths.js";
14
- import { readCompaniesManifestMap } from "./work-context/repo-remote.js";
15
- import { resolveDefaultHqRoot } from "../utils/cognito-session.js";
10
+ import { createFlagClient, isFlagSnapshot, readLocalOverride, resolveFlag, } from "@indigoai-us/hq-flags-client";
16
11
  import { resolveFlagRegistryEndpoint, } from "./flag-registry-endpoint.js";
17
12
  import { FLAG_REGISTRY_WORKER_SOURCE } from "./flag-registry-worker-source.generated.js";
13
+ import { resolveProcessFlagCompanyContext } from "./process-flag-company-context.js";
14
+ import { writeFlagSnapshotFile } from "./flag-snapshot-file.js";
15
+ export { resolveProcessFlagCompanyContext } from "./process-flag-company-context.js";
18
16
  export { FLAG_REGISTRY_ENDPOINT_ENV } from "./flag-registry-endpoint.js";
19
17
  const COMPANY_UID_RE = /^cmp_[A-Za-z0-9]{3,128}$/;
20
- const COMPANY_SLUG_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
21
- let heldSnapshot = null;
22
- let processReader = null;
23
- function processLocalOverride(flagKey) {
24
- return readLocalOverride(flagKey, process.env);
25
- }
26
- function localOverrideOrFallback(flagKey, legacyFallback) {
27
- return processLocalOverride(flagKey) ?? legacyFallback();
28
- }
29
18
  function normalizedCompanyUid(value) {
30
19
  const candidate = value?.trim() ?? "";
31
20
  return COMPANY_UID_RE.test(candidate) ? candidate : undefined;
32
21
  }
33
- function normalizedCompanySlug(value) {
34
- const candidate = value?.trim() ?? "";
35
- return COMPANY_SLUG_RE.test(candidate) ? candidate : undefined;
36
- }
37
- /**
38
- * Resolve only an explicit machine/session company binding. Device defaults
39
- * and cwd/repository heuristics do not scope process-wide feature flags.
40
- */
41
- function processCompanyContext(env = process.env) {
42
- const explicitUid = normalizedCompanyUid(env.HQ_COMPANY_UID);
43
- const explicitSlug = normalizedCompanySlug(env.HQ_COMPANY_SLUG);
44
- if (explicitUid || explicitSlug) {
45
- return { companyUid: explicitUid, companySlug: explicitSlug };
46
- }
47
- const machine = readMachineCompanyBinding(env);
48
- const spawnValue = env.HQ_SPAWN_COMPANY?.trim();
49
- const spawnSlug = normalizedCompanySlug(spawnValue);
50
- const machineContext = spawnValue && COMPANY_UID_RE.test(spawnValue)
51
- ? { companyUid: spawnValue }
52
- : spawnSlug
53
- ? { companySlug: spawnSlug }
54
- : {
55
- companyUid: normalizedCompanyUid(machine?.uid),
56
- companySlug: normalizedCompanySlug(machine?.slug),
57
- };
58
- if (machineContext.companyUid || machineContext.companySlug)
59
- return machineContext;
60
- const sessionId = env.HQ_SESSION_ID?.trim();
61
- if (!sessionId)
62
- return {};
63
- const session = readSessionState(sessionId, workContextRoot(undefined, env));
64
- if (session?.contextStatus !== "bound" || session.companyConfidence === "device_default") {
65
- return {};
66
- }
67
- return {
68
- companyUid: normalizedCompanyUid(session.companyUid),
69
- companySlug: normalizedCompanySlug(session.companySlug),
70
- };
71
- }
72
- function companyUidForContext(context, env = process.env) {
73
- if (context.companyUid)
74
- return context.companyUid;
75
- if (!context.companySlug)
76
- return undefined;
77
- const hqRoot = resolveDefaultHqRoot({ env });
78
- const manifestUid = readCompaniesManifestMap(hqRoot)?.[context.companySlug]?.cloud_uid;
79
- return typeof manifestUid === "string" ? normalizedCompanyUid(manifestUid) : undefined;
22
+ let heldSnapshot = null;
23
+ let processReader = null;
24
+ function processLocalOverride(flagKey, env = process.env) {
25
+ return readLocalOverride(flagKey, env);
80
26
  }
81
- /** Resolve the same company identity used by the process-wide flag reader. */
82
- export function resolveProcessFlagCompanyContext(env = process.env) {
83
- const context = processCompanyContext(env);
84
- const companyUid = companyUidForContext(context, env);
85
- return {
86
- ...(companyUid ? { companyUid } : {}),
87
- ...(context.companySlug ? { companySlug: context.companySlug } : {}),
88
- };
27
+ function localOverrideOrFallback(flagKey, legacyFallback, env = process.env) {
28
+ return processLocalOverride(flagKey, env) ?? legacyFallback();
89
29
  }
90
- function processSnapshotReader(companyIdentifiers) {
30
+ /** Create the same snapshot-backed reader used by process gates. */
31
+ export function createProcessSnapshotReader(companyIdentifiers, getSnapshot = () => heldSnapshot, env = process.env) {
91
32
  return {
92
33
  isEnabled(flagKey, lookup = {}) {
93
34
  return resolveFlag({
94
35
  flagKey,
95
- env: process.env,
96
- snapshot: heldSnapshot,
36
+ env,
37
+ snapshot: getSnapshot(),
97
38
  companyIdentifiers,
98
39
  lookup,
99
40
  }).value;
100
41
  },
101
42
  };
102
43
  }
44
+ function processSnapshotReader(companyIdentifiers) {
45
+ return createProcessSnapshotReader(companyIdentifiers);
46
+ }
103
47
  /**
104
48
  * Begin one registry refresh without awaiting it or retaining the CLI process.
105
49
  *
@@ -150,6 +94,19 @@ export function kickFlagRegistryReadiness(dependencies = {}) {
150
94
  worker.on("message", (value) => {
151
95
  if (isFlagSnapshot(value)) {
152
96
  heldSnapshot = value;
97
+ if (companyUid) {
98
+ try {
99
+ writeFlagSnapshotFile(companyUid, value.flags);
100
+ }
101
+ catch (error) {
102
+ Sentry.captureException(error, {
103
+ tags: {
104
+ component: "flag-snapshot-cache-write",
105
+ company_uid_present: "true",
106
+ },
107
+ });
108
+ }
109
+ }
153
110
  }
154
111
  });
155
112
  // A failed background refresh must stay invisible to an offline CLI.
@@ -169,15 +126,49 @@ export function kickFlagRegistryReadiness(dependencies = {}) {
169
126
  * `isEnabled` itself is synchronous and snapshot-only; any reader failure
170
127
  * falls through to the exact legacy predicate owned by the caller.
171
128
  */
172
- export function resolveFlagGate(reader, flagKey, lookup, legacyFallback) {
129
+ export function resolveFlagGate(reader, flagKey, lookup, legacyFallback, env = process.env) {
173
130
  if (!reader)
174
- return localOverrideOrFallback(flagKey, legacyFallback);
131
+ return localOverrideOrFallback(flagKey, legacyFallback, env);
175
132
  try {
176
133
  return reader.isEnabled(flagKey, lookup);
177
134
  }
178
135
  catch {
136
+ return localOverrideOrFallback(flagKey, legacyFallback, env);
137
+ }
138
+ }
139
+ /** Resolve one explicit company's flag without relying on process-wide binding. */
140
+ export async function resolveCompanyFlagGate(companyUid, idToken, flagKey, lookup, legacyFallback, dependencies = {}) {
141
+ const localOverride = processLocalOverride(flagKey);
142
+ if (localOverride !== null)
143
+ return localOverride;
144
+ const targetCompanyUid = normalizedCompanyUid(companyUid);
145
+ if (!targetCompanyUid || idToken.trim() === "") {
179
146
  return localOverrideOrFallback(flagKey, legacyFallback);
180
147
  }
148
+ let client;
149
+ try {
150
+ client = (dependencies.createClient ?? createFlagClient)({
151
+ endpoint: resolveFlagRegistryEndpoint(dependencies.endpoint, process.env),
152
+ getToken: () => idToken,
153
+ companyUid: targetCompanyUid,
154
+ refreshIntervalMs: 0,
155
+ requestTimeoutMs: dependencies.requestTimeoutMs ?? 1_200,
156
+ onError: (error) => {
157
+ const errorClass = error instanceof Error ? error.name : "unknown";
158
+ console.error(`hq: Could not resolve a company flag (${errorClass}); using its fallback.`);
159
+ },
160
+ });
161
+ await client.ready();
162
+ return resolveFlagGate(client, flagKey, lookup, legacyFallback);
163
+ }
164
+ catch (error) {
165
+ const errorClass = error instanceof Error ? error.name : "unknown";
166
+ console.error(`hq: Could not resolve a company flag (${errorClass}); using its fallback.`);
167
+ return localOverrideOrFallback(flagKey, legacyFallback);
168
+ }
169
+ finally {
170
+ client?.close();
171
+ }
181
172
  }
182
173
  /** Resolve a process gate against the shared snapshot reader, when configured. */
183
174
  export function resolveProcessFlagGate(flagKey, lookup, legacyFallback) {
@@ -0,0 +1,20 @@
1
+ /** Private, synchronous, offline snapshot used by the native CLI fast route. */
2
+ export declare const FLAG_SNAPSHOT_MAX_AGE_MS: number;
3
+ export interface PersistedFlagSnapshot {
4
+ company_uid: string;
5
+ fetched_at: string;
6
+ flags: Record<string, boolean>;
7
+ }
8
+ export declare function flagSnapshotPath(companyUid: string, env?: NodeJS.ProcessEnv): string | undefined;
9
+ /** Write just the validated flag decisions and company scope, never auth data. */
10
+ export declare function writeFlagSnapshotFile(companyUid: string, flags: Readonly<Record<string, unknown>>, options?: {
11
+ env?: NodeJS.ProcessEnv;
12
+ fetchedAt?: string;
13
+ }): string;
14
+ /** Return null for every miss, malformed file, company mismatch, or stale file. */
15
+ export declare function readFlagSnapshotFile(companyUid: string, options?: {
16
+ env?: NodeJS.ProcessEnv;
17
+ now?: number;
18
+ maxAgeMs?: number;
19
+ }): PersistedFlagSnapshot | null;
20
+ //# sourceMappingURL=flag-snapshot-file.d.ts.map
@@ -0,0 +1,91 @@
1
+ /** Private, synchronous, offline snapshot used by the native CLI fast route. */
2
+ import * as fs from "node:fs";
3
+ import * as os from "node:os";
4
+ import * as path from "node:path";
5
+ export const FLAG_SNAPSHOT_MAX_AGE_MS = 24 * 60 * 60 * 1000;
6
+ const COMPANY_UID_RE = /^cmp_[A-Za-z0-9]{3,128}$/;
7
+ export function flagSnapshotPath(companyUid, env = process.env) {
8
+ if (!COMPANY_UID_RE.test(companyUid))
9
+ return undefined;
10
+ const cacheHome = env.XDG_CACHE_HOME?.trim() || path.join(os.homedir(), ".cache");
11
+ return path.join(cacheHome, "hq", "flags", `snapshot-${companyUid}.json`);
12
+ }
13
+ /** Write just the validated flag decisions and company scope, never auth data. */
14
+ export function writeFlagSnapshotFile(companyUid, flags, options = {}) {
15
+ const file = flagSnapshotPath(companyUid, options.env);
16
+ if (!file)
17
+ throw new Error("Cannot persist a flag snapshot without a valid company uid");
18
+ const normalizedFlags = Object.fromEntries(Object.entries(flags).filter((entry) => Boolean(entry[0].trim()) && typeof entry[1] === "boolean"));
19
+ const snapshot = {
20
+ company_uid: companyUid,
21
+ fetched_at: options.fetchedAt ?? new Date().toISOString(),
22
+ flags: normalizedFlags,
23
+ };
24
+ if (!Number.isFinite(Date.parse(snapshot.fetched_at))) {
25
+ throw new Error("Cannot persist a flag snapshot with an invalid fetched_at timestamp");
26
+ }
27
+ const directory = path.dirname(file);
28
+ fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
29
+ fs.chmodSync(directory, 0o700);
30
+ const temporary = `${file}.${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}.tmp`;
31
+ let fd;
32
+ try {
33
+ fd = fs.openSync(temporary, "wx", 0o600);
34
+ fs.writeFileSync(fd, `${JSON.stringify(snapshot)}\n`, "utf8");
35
+ fs.fsyncSync(fd);
36
+ fs.closeSync(fd);
37
+ fd = undefined;
38
+ fs.renameSync(temporary, file);
39
+ fs.chmodSync(file, 0o600);
40
+ }
41
+ catch (error) {
42
+ if (fd !== undefined)
43
+ fs.closeSync(fd);
44
+ try {
45
+ fs.unlinkSync(temporary);
46
+ }
47
+ catch (cleanupError) {
48
+ if (cleanupError.code !== "ENOENT")
49
+ throw cleanupError;
50
+ }
51
+ throw error;
52
+ }
53
+ return file;
54
+ }
55
+ /** Return null for every miss, malformed file, company mismatch, or stale file. */
56
+ export function readFlagSnapshotFile(companyUid, options = {}) {
57
+ const file = flagSnapshotPath(companyUid, options.env);
58
+ if (!file)
59
+ return null;
60
+ let parsed;
61
+ try {
62
+ parsed = JSON.parse(fs.readFileSync(file, "utf8"));
63
+ }
64
+ catch {
65
+ return null;
66
+ }
67
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
68
+ return null;
69
+ const value = parsed;
70
+ if (Object.keys(value).sort().join(",") !== "company_uid,fetched_at,flags" ||
71
+ value.company_uid !== companyUid ||
72
+ typeof value.fetched_at !== "string" ||
73
+ !value.flags || typeof value.flags !== "object" || Array.isArray(value.flags))
74
+ return null; // Expected on missing, malformed, unreadable, or cross-company cache data.
75
+ const fetchedAt = Date.parse(value.fetched_at);
76
+ const age = (options.now ?? Date.now()) - fetchedAt;
77
+ if (!Number.isFinite(fetchedAt) || age < 0 || age > (options.maxAgeMs ?? FLAG_SNAPSHOT_MAX_AGE_MS)) {
78
+ return null;
79
+ }
80
+ const flags = value.flags;
81
+ for (const enabled of Object.values(flags)) {
82
+ if (typeof enabled !== "boolean")
83
+ return null;
84
+ }
85
+ return {
86
+ company_uid: companyUid,
87
+ fetched_at: value.fetched_at,
88
+ flags: flags,
89
+ };
90
+ }
91
+ //# sourceMappingURL=flag-snapshot-file.js.map
@@ -3,6 +3,7 @@ import type { HostCapacityAccount, HostCapacitySnapshot } from "./host-capacity.
3
3
  import type { LaneHost, LaneRecord, LanesConfig } from "./types.js";
4
4
  export declare const BALANCE_LIVE_STATES: readonly ["running", "queued", "awaiting_input"];
5
5
  export declare const BALANCE_MOVABLE_STATES: readonly ["interrupted", "done", "awaiting_input"];
6
+ export declare const CLAUDE_RECENT_USAGE_WINDOW_MS: number;
6
7
  export interface BalanceHostSnapshot {
7
8
  capacity: HostCapacitySnapshot;
8
9
  lanes: readonly LaneRecord[];
@@ -39,11 +40,12 @@ export interface ClaudeAccountLoad {
39
40
  live_lanes: number;
40
41
  counts_complete: boolean;
41
42
  usage: NonNullable<HostCapacityAccount["usage"]> | null;
43
+ recent_usage_at: string | null;
42
44
  hosts: AccountHostLoad[];
43
45
  }
44
46
  export interface ClaudeAccountSelection {
45
47
  account: string;
46
- signal: "usage" | "lane_count" | "explicit";
48
+ signal: "usage" | "recent_usage" | "lane_count" | "explicit";
47
49
  reason: "least_loaded" | "explicit";
48
50
  target_host: LaneHost;
49
51
  considered: ClaudeAccountLoad[];
@@ -72,6 +74,9 @@ export declare function selectClaudeAccount(input: {
72
74
  model?: string;
73
75
  /** Keep the recorded account eligible for its already-running CLI model. */
74
76
  recordedModelAccount?: string;
77
+ /** Recent completed-run tiebreak; callers pass the default-off hq-flags result. */
78
+ recentUsageEnabled?: boolean;
79
+ now?: Date;
75
80
  explicitAccount?: string;
76
81
  unavailableHosts?: readonly BalanceHostGap[];
77
82
  }): ClaudeAccountSelection;
@@ -1,6 +1,7 @@
1
1
  import { LanesError } from "./errors.js";
2
2
  export const BALANCE_LIVE_STATES = ["running", "queued", "awaiting_input"];
3
3
  export const BALANCE_MOVABLE_STATES = ["interrupted", "done", "awaiting_input"];
4
+ export const CLAUDE_RECENT_USAGE_WINDOW_MS = 5 * 60 * 60 * 1000;
4
5
  function sameHost(a, b) {
5
6
  return a.kind === b.kind && a.id === b.id;
6
7
  }
@@ -74,10 +75,31 @@ function usageScore(usage) {
74
75
  return null;
75
76
  return Math.max(usage.five_hour_percent, usage.weekly_percent);
76
77
  }
77
- function selectionSignal(loads) {
78
- return loads.length > 0 && loads.every((load) => usageScore(load.usage) !== null)
79
- ? "usage"
80
- : "lane_count";
78
+ function recentUsageAt(hosts, account, now) {
79
+ if (hosts.some((host) => !host.laneListAvailable))
80
+ return null;
81
+ const nowMs = now.getTime();
82
+ const cutoff = nowMs - CLAUDE_RECENT_USAGE_WINDOW_MS;
83
+ let latestAt = null;
84
+ let latestMs = cutoff - 1;
85
+ for (const host of hosts) {
86
+ for (const lane of host.lanes) {
87
+ if (lane.provider !== "claude" || lane.account !== account ||
88
+ lane.last_run_usage === undefined || lane.last_run_usage_at === undefined)
89
+ continue;
90
+ const atMs = Date.parse(lane.last_run_usage_at);
91
+ if (!Number.isFinite(atMs) || atMs < cutoff || atMs > nowMs || atMs <= latestMs)
92
+ continue;
93
+ latestMs = atMs;
94
+ latestAt = lane.last_run_usage_at;
95
+ }
96
+ }
97
+ return latestAt;
98
+ }
99
+ function selectionSignal(loads, recentUsageEnabled, laneSnapshotsComplete) {
100
+ if (loads.length > 0 && loads.every((load) => usageScore(load.usage) !== null))
101
+ return "usage";
102
+ return recentUsageEnabled && laneSnapshotsComplete ? "recent_usage" : "lane_count";
81
103
  }
82
104
  function targetIneligibilityReason(accountId, globalEnabled, allowedAccounts, target) {
83
105
  if (!globalEnabled)
@@ -120,6 +142,7 @@ function buildLoads(input) {
120
142
  live_lanes: knownCounts.reduce((sum, count) => sum + count, 0),
121
143
  counts_complete: hosts.every((host) => host.live_lanes !== null && host.count_source !== "capacity_partial"),
122
144
  usage: input.target.capacity.providers.claude.accounts[account]?.usage ?? null,
145
+ recent_usage_at: recentUsageAt(input.hosts, account, input.now),
123
146
  hosts,
124
147
  };
125
148
  });
@@ -133,7 +156,8 @@ export function selectClaudeAccount(input) {
133
156
  unavailable_hosts: input.unavailableHosts ?? [],
134
157
  });
135
158
  }
136
- const { loads, skipped } = buildLoads({ ...input, target });
159
+ const now = input.now ?? new Date();
160
+ const { loads, skipped } = buildLoads({ ...input, target, now });
137
161
  if (input.explicitAccount !== undefined) {
138
162
  const selected = loads.find((load) => load.account === input.explicitAccount);
139
163
  if (!selected || !selected.eligible) {
@@ -173,14 +197,28 @@ export function selectClaudeAccount(input) {
173
197
  unavailable_hosts: input.unavailableHosts ?? [],
174
198
  });
175
199
  }
176
- const signal = selectionSignal(eligible);
200
+ const signal = selectionSignal(eligible, input.recentUsageEnabled === true, input.hosts.every((host) => host.laneListAvailable) && (input.unavailableHosts?.length ?? 0) === 0);
177
201
  eligible.sort((a, b) => {
178
202
  if (signal === "usage") {
179
203
  const byUsage = usageScore(a.usage) - usageScore(b.usage);
180
204
  if (byUsage !== 0)
181
205
  return byUsage;
182
206
  }
183
- return a.live_lanes - b.live_lanes || a.account.localeCompare(b.account);
207
+ const byLiveLanes = a.live_lanes - b.live_lanes;
208
+ if (byLiveLanes !== 0)
209
+ return byLiveLanes;
210
+ if (signal === "recent_usage") {
211
+ if (a.recent_usage_at === null && b.recent_usage_at !== null)
212
+ return -1;
213
+ if (a.recent_usage_at !== null && b.recent_usage_at === null)
214
+ return 1;
215
+ if (a.recent_usage_at !== null && b.recent_usage_at !== null) {
216
+ const byRecentUsage = Date.parse(a.recent_usage_at) - Date.parse(b.recent_usage_at);
217
+ if (byRecentUsage !== 0)
218
+ return byRecentUsage;
219
+ }
220
+ }
221
+ return a.account.localeCompare(b.account);
184
222
  });
185
223
  return {
186
224
  account: eligible[0].account,
@@ -196,7 +234,7 @@ export function accountLoadsForReport(input) {
196
234
  const first = input.hosts[0];
197
235
  if (!first)
198
236
  return [];
199
- return buildLoads({ hosts: input.hosts, target: first, config: input.config }).loads.map((load) => ({
237
+ return buildLoads({ hosts: input.hosts, target: first, config: input.config, now: new Date() }).loads.map((load) => ({
200
238
  ...load,
201
239
  eligible: false,
202
240
  ineligible_reason: undefined,
@@ -252,6 +252,21 @@ function parsePreferredAccounts(provider, value, accounts) {
252
252
  return [...preferred];
253
253
  }
254
254
  export function assertUniqueAccountHomes(provider, accounts) {
255
+ // An override must not let one account reuse another account's shared default home.
256
+ const declaredHomes = new Map();
257
+ for (const [id, account] of Object.entries(accounts)) {
258
+ const homes = new Set([
259
+ account.home,
260
+ ...Object.values(account.hosts ?? {}).map(({ home }) => home),
261
+ ]);
262
+ for (const home of homes) {
263
+ const prior = declaredHomes.get(home);
264
+ if (prior !== undefined && prior !== id) {
265
+ throw new LanesConfigError("duplicate_account_home", `providers.${provider} accounts ${JSON.stringify(prior)} and ${JSON.stringify(id)} declare the same home ${home}`, { provider, accounts: [prior, id], home });
266
+ }
267
+ declaredHomes.set(home, id);
268
+ }
269
+ }
255
270
  const hostnames = new Set(Object.values(accounts).flatMap((account) => Object.keys(account.hosts ?? {})));
256
271
  const check = (hostname) => {
257
272
  const seen = new Map();
@@ -18,6 +18,8 @@ export interface OutpostHireGrantContext {
18
18
  superseded_message_caller_id?: string;
19
19
  superseded_message_session_id?: string;
20
20
  }
21
+ /** Select the owner records needed to reproduce the primary's grant hierarchy. */
22
+ export declare function selectOutpostHireGrantHierarchy(targetLaneId: string, lanes: readonly LaneRecord[]): LaneRecord[];
21
23
  /** Encode only the owner-relevant hierarchy from the primary's complete store. */
22
24
  export declare function createOutpostHireGrantContext(target: LaneRecord, lanes: readonly LaneRecord[], supersededMessageCallerId?: string, supersededMessageSessionId?: string): string;
23
25
  /** Decode and validate an owner grant context, binding it to the target lane. */
@@ -15,6 +15,47 @@ function copySenior(senior) {
15
15
  ? { kind: senior.kind }
16
16
  : { kind: senior.kind, id: senior.id };
17
17
  }
18
+ // Terminal relatives are included so the owner's final state overwrites a stale
19
+ // primary copy; the context builder counts only alive children afterwards.
20
+ function directChildren(lanes, parentLaneId) {
21
+ return lanes.filter((lane) => lane.senior.kind === "lane" && lane.senior.id === parentLaneId);
22
+ }
23
+ /** Select the owner records needed to reproduce the primary's grant hierarchy. */
24
+ export function selectOutpostHireGrantHierarchy(targetLaneId, lanes) {
25
+ const current = lanes.find((lane) => lane.lane_id === targetLaneId);
26
+ if (!current)
27
+ return [];
28
+ const relevant = new Set([current.lane_id]);
29
+ if (current.senior.kind === "lane" && current.senior.id) {
30
+ relevant.add(current.senior.id);
31
+ for (const sibling of directChildren(lanes, current.senior.id)) {
32
+ relevant.add(sibling.lane_id);
33
+ }
34
+ }
35
+ for (const child of directChildren(lanes, current.lane_id)) {
36
+ relevant.add(child.lane_id);
37
+ }
38
+ const companyUid = current.company?.uid;
39
+ const projectId = current.project_id;
40
+ if (companyUid && projectId) {
41
+ // A superseded-message caller must be checked against the same project and
42
+ // company rows that the complete hierarchy builder sees.
43
+ for (const candidate of lanes) {
44
+ if (candidate.company?.uid === companyUid && candidate.project_id === projectId) {
45
+ relevant.add(candidate.lane_id);
46
+ }
47
+ }
48
+ }
49
+ if (current.senior.kind === "session" && companyUid) {
50
+ for (const candidate of lanes) {
51
+ if (candidate.company?.uid === companyUid && ((current.worker_profile !== undefined && candidate.worker_profile === current.worker_profile) ||
52
+ (projectId !== undefined && candidate.project_id === projectId && (candidate.hire_grant ?? 0) > 0))) {
53
+ relevant.add(candidate.lane_id);
54
+ }
55
+ }
56
+ }
57
+ return lanes.filter((lane) => relevant.has(lane.lane_id));
58
+ }
18
59
  function snapshot(lane) {
19
60
  return {
20
61
  lane_id: lane.lane_id,
@@ -10,7 +10,7 @@ export type { AdmissionCapReason, AdmissionHost } from "./admission.js";
10
10
  export { buildHostCapacitySnapshot, chooseHostForLane, parseHostCapacitySnapshot, } from "./host-capacity.js";
11
11
  export type { HostCapacityAccount, HostCapacityProvider, HostCapacitySnapshot, HostPlacementChoice, } from "./host-capacity.js";
12
12
  export { CONFIG_SCHEMA_VERSION, ENVELOPE_DECISIONS, ENVELOPE_SCHEMA_VERSION, HISTORY_CAP, HOST_KINDS, LANE_PROVIDERS, LANE_SCHEMA_VERSION, LANE_STORY_STATUSES, LANE_STATES, LIVENESS_STATES, QUESTION_BODY_STORES, QUESTION_SCHEMA_VERSION, QUESTION_STATUSES, QUEUED_REASONS, RAISED_VIA, ROUTING_REASONS, SENIOR_KINDS, STORY_STATUSES, TERMINAL_LANE_STATES, isEnvelopeDecision, isHostKind, isLaneProvider, isLaneState, isLivenessState, isQuestionStatus, isSeniorKind, isTerminalLaneState, parseLaneEnvelope, parseLaneProvider, parseLaneState, parseLivenessState, parseMajorSchema, parseQuestionStatus, parseSeniorKind, } from "./types.js";
13
- export type { AccountConfig, AttributionHistoryEntry, EnvelopeDecision, EnvelopeQuestion, HostKind, LaneCompanyRef, LaneAccountSelection, LaneAccountSelectionConsidered, LaneAccountSelectionHostCount, LaneEnvelope, LaneHost, LaneProvider, LanePlacementPolicy, LaneRecord, LaneStory, LaneStoryStatus, PendingResume, AccountHistoryEntry, LaneState, LanesConfig, LastEnvelopeRef, LivenessState, LivenessWindows, ProviderConfig, QuestionAnswer, QuestionBodyRef, QuestionRecord, QuestionStatus, QueuedReason, RaisedVia, RoutingHistoryEntry, RoutingReason, SeniorHistoryEntry, SeniorKind, SeniorRef, WorkerProfileRepinHistoryEntry, StoryStatus, TerminalLaneState, ThrottleConfig, } from "./types.js";
13
+ export type { AccountConfig, AttributionHistoryEntry, EnvelopeDecision, EnvelopeQuestion, HostKind, LaneCompanyRef, LaneAccountSelection, LaneAccountSelectionConsidered, LaneAccountSelectionHostCount, LaneEnvelope, LaneHost, LaneProvider, LanePlacementPolicy, LaneRecord, LaneStory, LaneStoryStatus, PendingResume, AccountHistoryEntry, LaneState, LanesConfig, LastEnvelopeRef, LivenessState, LivenessWindows, ProviderConfig, QuestionAnswer, QuestionBodyRef, QuestionRecord, QuestionStatus, QueuedReason, RaisedVia, RoutingHistoryEntry, RoutingReason, SeniorHistoryEntry, SeniorKind, SeniorRef, WorkerProfileRepinHistoryEntry, LaneSettingsHistoryEntry, StoryStatus, TerminalLaneState, ThrottleConfig, } from "./types.js";
14
14
  export { ACTIVE_LANE_STORY_STATUSES, activeLaneStories, laneStories, laneStory, storyKey, storyStatusForEnvelope, storyWarning, withCurrentStory, withLaneStories, } from "./stories.js";
15
15
  export { DEFAULT_ACCOUNT_HOMES, DEFAULT_AUTO_COMPACT_TOKENS, DEFAULT_COMPACT_AFTER_MS, DEFAULT_CPU_BUSY_PAUSE, DEFAULT_CPU_BUSY_RESUME, DEFAULT_LIVE_WITHIN_MS, DEFAULT_LIVENESS_WINDOWS, DEFAULT_MAX_QUEUED, DEFAULT_MIN_AVAILABLE_MEM_MB, DEFAULT_PROVIDER_MAX_RUNNING, DEFAULT_SILENT_LOST_MS, DEFAULT_TURN_START_LOST_MS, LANES_CONFIG_REL, accountMaxRunning, canonicalizeHome, documentedDefaultConfig, expandHome, lanesConfigPath, loadLanesConfig, parseLanesConfig, providerCompactAfterMs, providerAutoCompactTokens, providerQuotaDefaultHoldMs, DEFAULT_QUOTA_HOLD_MS, writeLanesConfig, } from "./config.js";
16
16
  export type { ConfigLoadDeps, ConfigWriteDeps } from "./config.js";
@@ -4,6 +4,9 @@ export declare const LEAD_LANE_KEEP_ALIVE_DEFAULT_ENABLED = false;
4
4
  /** Missing or unreadable registry values retain the legacy supersession behavior. */
5
5
  export declare const LANE_SUPERSESSION_LINEAGE_AWARE_FLAG = "lanes.supersession-lineage-aware";
6
6
  export declare const LANE_SUPERSESSION_LINEAGE_AWARE_DEFAULT_ENABLED = false;
7
+ /** Missing or unreadable registry values keep rejecting superseded messages. */
8
+ export declare const LANE_RUNNING_SUPERSEDED_MESSAGE_DELIVERY_FLAG = "lanes.running-superseded-message-delivery";
9
+ export declare const LANE_RUNNING_SUPERSEDED_MESSAGE_DELIVERY_DEFAULT_ENABLED = false;
7
10
  export declare const LEAD_LANE_KEEP_ALIVE_MAX_RESUMES = 24;
8
11
  export declare const LEAD_LANE_KEEP_ALIVE_BASE_BACKOFF_MS: number;
9
12
  export declare const LEAD_LANE_KEEP_ALIVE_MAX_BACKOFF_MS: number;
@@ -8,6 +8,9 @@ export const LEAD_LANE_KEEP_ALIVE_DEFAULT_ENABLED = false;
8
8
  /** Missing or unreadable registry values retain the legacy supersession behavior. */
9
9
  export const LANE_SUPERSESSION_LINEAGE_AWARE_FLAG = "lanes.supersession-lineage-aware";
10
10
  export const LANE_SUPERSESSION_LINEAGE_AWARE_DEFAULT_ENABLED = false;
11
+ /** Missing or unreadable registry values keep rejecting superseded messages. */
12
+ export const LANE_RUNNING_SUPERSEDED_MESSAGE_DELIVERY_FLAG = "lanes.running-superseded-message-delivery";
13
+ export const LANE_RUNNING_SUPERSEDED_MESSAGE_DELIVERY_DEFAULT_ENABLED = false;
11
14
  export const LEAD_LANE_KEEP_ALIVE_MAX_RESUMES = 24;
12
15
  export const LEAD_LANE_KEEP_ALIVE_BASE_BACKOFF_MS = 5 * 60 * 1000;
13
16
  export const LEAD_LANE_KEEP_ALIVE_MAX_BACKOFF_MS = 60 * 60 * 1000;
@@ -82,6 +82,10 @@ export declare function buildOutpostCapacityCommand(): string;
82
82
  /** Read lane records owned by an Outpost, using its configured HQ binary when set. */
83
83
  export declare function buildOutpostLaneListCommand(input?: {
84
84
  hqBinary?: string;
85
+ laneIds?: readonly string[];
86
+ active?: boolean;
87
+ hierarchy?: string;
88
+ snapshot?: boolean;
85
89
  }): string;
86
90
  /** Read questions on one Outpost, scoped to a company when supplied. */
87
91
  export declare function buildOutpostQuestionListCommand(input?: {
@@ -67,12 +67,21 @@ export function buildOutpostCapacityCommand() {
67
67
  }
68
68
  /** Read lane records owned by an Outpost, using its configured HQ binary when set. */
69
69
  export function buildOutpostLaneListCommand(input = {}) {
70
- return withRemoteHqDir(joinCommandParts([
70
+ const args = [
71
71
  input.hqBinary?.trim() || "hq",
72
72
  "lanes",
73
73
  "list",
74
74
  "--json",
75
- ]));
75
+ ];
76
+ for (const laneId of input.laneIds ?? [])
77
+ args.push("--lane", laneId);
78
+ if (input.active)
79
+ args.push("--active");
80
+ if (input.hierarchy !== undefined)
81
+ args.push("--hierarchy", input.hierarchy);
82
+ if (input.snapshot)
83
+ args.push("--snapshot");
84
+ return withRemoteHqDir(joinCommandParts(args));
76
85
  }
77
86
  /** Read questions on one Outpost, scoped to a company when supplied. */
78
87
  export function buildOutpostQuestionListCommand(input = {}) {