@indigoai-us/hq-cli 5.345.203 → 5.345.204

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.
@@ -36,6 +36,7 @@ import * as os from "node:os";
36
36
  import * as path from "node:path";
37
37
  import { DIR_MODE, FILE_MODE, appendReadings, capacityRoot, EXPIRED_REASON, SIGN_IN_REVOKED_REASON, readCollectorMark, redactProviderTokens, } from "../ledger.js";
38
38
  import { hashClaudeAccountId } from "./claude-statusline.js";
39
+ import { REMOTE_LANE_READ_ENV } from "../../lanes/outpost-dispatch.js";
39
40
  export const CLAUDE_OAUTH_PARSER_VERSION = "claude-oauth-usage.1";
40
41
  export const CLAUDE_OAUTH_PROVIDER = "claude";
41
42
  export const CLAUDE_OAUTH_SOURCE = "oauth-usage";
@@ -136,7 +137,11 @@ export function mapClaudeOAuthUsage(body, options) {
136
137
  }
137
138
  return { readings, failures };
138
139
  }
139
- export async function collectClaudeOAuth(options = {}) {
140
+ export async function collectClaudeOAuth(inputOptions = {}) {
141
+ const options = {
142
+ ...inputOptions,
143
+ listLiveLanes: memoizeListLiveLanes(inputOptions.listLiveLanes ?? defaultListLiveLanes),
144
+ };
140
145
  const ledger = ledgerOptions(options);
141
146
  const root = ledger.root ?? capacityRoot(ledger.env, ledger.home);
142
147
  const scoped = { ...ledger, root };
@@ -1766,43 +1771,62 @@ export function resolveHqInvocation(argv = process.argv, execPath = process.exec
1766
1771
  }
1767
1772
  /** Output cap for the `hq ... --json` reads behind the refresh gates. */
1768
1773
  const HQ_JSON_MAX_BUFFER_BYTES = 64 * 1024 * 1024;
1769
- const HQ_JSON_TIMEOUT_MS = 15_000;
1770
- /** Exported for tests; `invocation` defaults to {@link resolveHqInvocation}. */
1774
+ const HQ_JSON_TIMEOUT_MS = 30_000;
1775
+ /**
1776
+ * Run `hq ... --json` and parse stdout. Async on purpose: the collector runs inside the mesh
1777
+ * daemon, and a synchronous read that takes 15-30 s on a loaded machine froze the daemon's
1778
+ * event loop (MQTT keepalive, owner heartbeat) for the whole read.
1779
+ * Exported for tests; `invocation` defaults to {@link resolveHqInvocation}.
1780
+ */
1771
1781
  export function execHqJson(args, invocation = resolveHqInvocation(), env = process.env) {
1772
- const result = spawnSync(invocation.command, [...invocation.args, ...args], {
1773
- env,
1774
- encoding: "utf8",
1775
- // The lane list takes ~2.5 s on a busy machine and grows with lane history; 5 s left too
1776
- // little headroom under load, and a timeout fails the gate closed.
1777
- timeout: HQ_JSON_TIMEOUT_MS,
1778
- // `hq lanes list --json` grows with lane history (1.7 MB on a busy machine). The spawnSync
1779
- // default of 1 MiB raised ENOBUFS, which made the live-lane gate fail closed on every collect.
1780
- maxBuffer: HQ_JSON_MAX_BUFFER_BYTES,
1781
- windowsHide: true,
1782
- stdio: ["ignore", "pipe", "pipe"],
1782
+ const label = `hq ${args.join(" ")}`;
1783
+ return new Promise((resolve, reject) => {
1784
+ execFile(invocation.command, [...invocation.args, ...args], {
1785
+ env,
1786
+ encoding: "utf8",
1787
+ // The lane list takes ~2.5 s on an idle machine and grows with lane history. Under heavy
1788
+ // load (load average ~95 on 18 cores) the same read took 16-18 s, so 15 s still failed the
1789
+ // gate closed on every collect and every Claude home stayed expired.
1790
+ timeout: HQ_JSON_TIMEOUT_MS,
1791
+ // `hq lanes list --json` grows with lane history (6.8 MB on a busy machine). The 1 MiB
1792
+ // default made the live-lane gate fail closed on every collect.
1793
+ maxBuffer: HQ_JSON_MAX_BUFFER_BYTES,
1794
+ // SIGTERM can be trapped, and execFile waits for the child to exit before calling back.
1795
+ killSignal: "SIGKILL",
1796
+ windowsHide: true,
1797
+ }, (error, stdout) => {
1798
+ if (error) {
1799
+ const failure = error;
1800
+ const code = failure.code;
1801
+ if (code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER") {
1802
+ reject(new Error(`${label} output exceeded ${HQ_JSON_MAX_BUFFER_BYTES} bytes`));
1803
+ }
1804
+ else if (failure.killed) {
1805
+ reject(new Error(`${label} timed out`));
1806
+ }
1807
+ else if (typeof code === "number") {
1808
+ reject(new Error(`${label} exit ${code}`));
1809
+ }
1810
+ else if (failure.signal) {
1811
+ reject(new Error(`${label} killed by ${failure.signal}`));
1812
+ }
1813
+ else {
1814
+ reject(new Error(`${label} failed to spawn: ${failure.message}`));
1815
+ }
1816
+ return;
1817
+ }
1818
+ if (!stdout) {
1819
+ reject(new Error(`${label} empty stdout`));
1820
+ return;
1821
+ }
1822
+ try {
1823
+ resolve(JSON.parse(stdout));
1824
+ }
1825
+ catch (parseError) {
1826
+ reject(new Error(`${label} invalid JSON: ${parseError.message}`));
1827
+ }
1828
+ });
1783
1829
  });
1784
- if (result.error) {
1785
- const code = result.error.code;
1786
- if (code === "ETIMEDOUT")
1787
- throw new Error(`hq ${args.join(" ")} timed out`);
1788
- if (code === "ENOBUFS")
1789
- throw new Error(`hq ${args.join(" ")} output exceeded ${HQ_JSON_MAX_BUFFER_BYTES} bytes`);
1790
- throw new Error(`hq ${args.join(" ")} failed to spawn: ${result.error.message}`);
1791
- }
1792
- if (result.signal)
1793
- throw new Error(`hq ${args.join(" ")} killed by ${result.signal}`);
1794
- if (typeof result.status !== "number")
1795
- throw new Error(`hq ${args.join(" ")} no exit status`);
1796
- if (result.status !== 0)
1797
- throw new Error(`hq ${args.join(" ")} exit ${result.status}`);
1798
- if (!result.stdout)
1799
- throw new Error(`hq ${args.join(" ")} empty stdout`);
1800
- try {
1801
- return JSON.parse(result.stdout);
1802
- }
1803
- catch (error) {
1804
- throw new Error(`hq ${args.join(" ")} invalid JSON: ${error.message}`);
1805
- }
1806
1830
  }
1807
1831
  /**
1808
1832
  * Parse `hq lanes list --json` payload into typed rows.
@@ -1889,12 +1913,31 @@ export function buildLiveLanesFromPayloads(lanes, accounts) {
1889
1913
  * Throws on hq call failure or unexpected JSON shape; callers skip the refresh.
1890
1914
  */
1891
1915
  const defaultListLiveLanes = async () => {
1892
- const lanesPayload = execHqJson(["lanes", "list", "--json"]);
1893
- const accountsPayload = execHqJson(["lanes", "accounts", "list", "--json"]);
1916
+ // The gate only needs lanes that use this host's Claude homes. Skip the Outpost mirror
1917
+ // refresh (a network round trip per Outpost); cached mirror rows are still listed.
1918
+ const env = { ...process.env, [REMOTE_LANE_READ_ENV]: "1" };
1919
+ const invocation = resolveHqInvocation();
1920
+ const [lanesPayload, accountsPayload] = await Promise.all([
1921
+ execHqJson(["lanes", "list", "--json"], invocation, env),
1922
+ execHqJson(["lanes", "accounts", "list", "--json"], invocation, env),
1923
+ ]);
1894
1924
  const lanes = parseLanesListPayload(lanesPayload);
1895
1925
  const accounts = parseAccountsListPayload(accountsPayload);
1896
1926
  return buildLiveLanesFromPayloads(lanes, accounts);
1897
1927
  };
1928
+ /**
1929
+ * Share one lane read across every home in a collect. Without this each expired home ran its
1930
+ * own pair of `hq lanes ... --json` reads, so one slow machine turned into N timeouts per
1931
+ * collect. A failure is shared too: the next home fails closed without waiting again.
1932
+ * Exported for tests.
1933
+ */
1934
+ export function memoizeListLiveLanes(list) {
1935
+ let pending = null;
1936
+ return () => {
1937
+ pending ??= list();
1938
+ return pending;
1939
+ };
1940
+ }
1898
1941
  /**
1899
1942
  * Parse `ps -axo pid=,command=` into rows that mention the `claude` executable.
1900
1943
  * Exported for tests. Returned rows carry `probe: "command-only"` so the gate
@@ -1926,14 +1969,145 @@ export function parsePsClaudeOutput(stdout) {
1926
1969
  }
1927
1970
  return out;
1928
1971
  }
1972
+ /** Pid to full command line, from `ps -ww -o pid=,command= -p <pids>`. Exported for tests. */
1973
+ export function parsePsCommandLines(stdout) {
1974
+ const lines = new Map();
1975
+ for (const rawLine of stdout.split("\n")) {
1976
+ const match = /^\s*(\d+) (.*)$/.exec(rawLine.replace(/\s+$/, ""));
1977
+ if (match)
1978
+ lines.set(Number(match[1]), match[2]);
1979
+ }
1980
+ return lines;
1981
+ }
1982
+ /**
1983
+ * Upgrade darwin command-only rows with each process's environment. `ps -wwE` prints a
1984
+ * same-user process's command line followed by its environment, unquoted and space separated.
1985
+ * Only the text after the exact command line (read separately with `ps -ww`) is treated as the
1986
+ * environment, so a prompt argument that mentions CLAUDE_CONFIG_DIR= or HOME= is never read as
1987
+ * env. A row becomes `probe: "env"` only when:
1988
+ * - the `-E` line starts with the command line plus a space, and the rest starts with NAME=;
1989
+ * - CLAUDE_CONFIG_DIR= appears at most once in that rest, and its value is absolute and
1990
+ * names an existing directory (the value runs to the next ` NAME=` or the end of line).
1991
+ * Anything else stays command-only, which blocks every home. A pid missing from the output
1992
+ * is dropped only when it is known to have exited; otherwise it stays command-only.
1993
+ * Exported for tests.
1994
+ */
1995
+ export function parsePsEnvOutput(envStdout, commandLines, rows, options = {}) {
1996
+ const isDirectory = options.isDirectory ?? defaultIsDirectory;
1997
+ const isAlive = options.isAlive ?? defaultIsAlive;
1998
+ const envLines = parsePsCommandLines(envStdout);
1999
+ const blocked = (pid) => ({ pid, env: {}, probe: "command-only" });
2000
+ const out = [];
2001
+ for (const row of rows) {
2002
+ const command = commandLines.get(row.pid);
2003
+ const line = envLines.get(row.pid);
2004
+ if (command === undefined || line === undefined) {
2005
+ if (isAlive(row.pid))
2006
+ out.push(blocked(row.pid));
2007
+ continue;
2008
+ }
2009
+ if (!line.startsWith(`${command} `)) {
2010
+ // No visible env (another user's or a protected process), or the pid was reused.
2011
+ out.push(blocked(row.pid));
2012
+ continue;
2013
+ }
2014
+ const env = line.slice(command.length + 1);
2015
+ if (!/^[A-Za-z_][A-Za-z0-9_]*=/.test(env)) {
2016
+ out.push(blocked(row.pid));
2017
+ continue;
2018
+ }
2019
+ const mentions = env.split("CLAUDE_CONFIG_DIR=").length - 1;
2020
+ const match = /(?:^| )CLAUDE_CONFIG_DIR=(.*?)(?= [A-Za-z_][A-Za-z0-9_]*=|$)/.exec(env);
2021
+ if (mentions === 0) {
2022
+ // The default config dir is HOME/.claude for this process. HOME must be one absolute
2023
+ // value; an unset HOME falls back to the collector's own home.
2024
+ const homes = [...env.matchAll(/(?:^| )HOME=(.*?)(?= [A-Za-z_][A-Za-z0-9_]*=|$)/g)].map((m) => m[1]);
2025
+ if (homes.length === 0) {
2026
+ out.push({ pid: row.pid, env: {}, probe: "env" });
2027
+ }
2028
+ else if (homes.length === 1 && path.isAbsolute(homes[0])) {
2029
+ out.push({ pid: row.pid, env: { HOME: homes[0] }, probe: "env" });
2030
+ }
2031
+ else {
2032
+ out.push(blocked(row.pid));
2033
+ }
2034
+ }
2035
+ else if (mentions === 1 && match && path.isAbsolute(match[1]) && isDirectory(match[1])) {
2036
+ out.push({ pid: row.pid, env: { CLAUDE_CONFIG_DIR: match[1] }, probe: "env" });
2037
+ }
2038
+ else {
2039
+ out.push(blocked(row.pid));
2040
+ }
2041
+ }
2042
+ return out;
2043
+ }
2044
+ /** Default config dir of an env-probed process with CLAUDE_CONFIG_DIR unset. Exported for tests. */
2045
+ export function processDefaultConfigDir(proc) {
2046
+ const home = typeof proc.env.HOME === "string" && path.isAbsolute(proc.env.HOME) ? proc.env.HOME : os.homedir();
2047
+ return path.join(home, ".claude");
2048
+ }
2049
+ function defaultIsAlive(pid) {
2050
+ try {
2051
+ process.kill(pid, 0);
2052
+ return true;
2053
+ }
2054
+ catch (error) {
2055
+ return error.code !== "ESRCH";
2056
+ }
2057
+ }
2058
+ /**
2059
+ * Canonical form of a home directory for comparison: symlinks resolved by the OS, and lower
2060
+ * case on darwin, where APFS is case-insensitive by default. Null when the path cannot be
2061
+ * resolved. Exported for tests.
2062
+ */
2063
+ export function canonicalHomePath(dir, platform = process.platform) {
2064
+ try {
2065
+ const real = fs.realpathSync.native(path.resolve(dir));
2066
+ return platform === "darwin" ? real.toLowerCase() : real;
2067
+ }
2068
+ catch {
2069
+ return null;
2070
+ }
2071
+ }
2072
+ function defaultIsDirectory(dir) {
2073
+ try {
2074
+ return fs.statSync(dir).isDirectory();
2075
+ }
2076
+ catch {
2077
+ return false;
2078
+ }
2079
+ }
2080
+ function runPs(args) {
2081
+ const result = spawnSync("ps", [...args], {
2082
+ encoding: "utf8",
2083
+ timeout: 5_000,
2084
+ // Full command lines for every process can pass the 1 MiB default on a busy machine.
2085
+ maxBuffer: HQ_JSON_MAX_BUFFER_BYTES,
2086
+ windowsHide: true,
2087
+ stdio: ["ignore", "pipe", "pipe"],
2088
+ });
2089
+ if (result.error)
2090
+ throw new Error(`ps failed to spawn: ${result.error.message}`);
2091
+ if (result.signal)
2092
+ throw new Error(`ps killed by ${result.signal}`);
2093
+ if (typeof result.status !== "number")
2094
+ throw new Error("ps no exit status");
2095
+ // `ps -p` exits 1 both when no pid still exists and on a real error. Accept exit 1 only with
2096
+ // empty stderr; parsePsEnvOutput then keeps any pid that is not known to be gone as blocking.
2097
+ const quietNoMatch = args.includes("-p") && result.status === 1 && !(result.stderr ?? "").trim();
2098
+ if (result.status !== 0 && !quietNoMatch)
2099
+ throw new Error(`ps exit ${result.status}`);
2100
+ return result.stdout ?? "";
2101
+ }
1929
2102
  /**
1930
2103
  * Fail-closed enumeration of running Claude Code processes.
1931
2104
  *
1932
2105
  * linux: /proc/<pid>/environ gives us per-process CLAUDE_CONFIG_DIR; absent
1933
2106
  * value means the process holds the default home.
1934
- * darwin: ps cannot read a child process environment, so we return
1935
- * `probe: "command-only"` rows. The gate treats such a row as holding the
1936
- * default home AND blocking every non-default home.
2107
+ * darwin: `ps -axo` finds claude processes, then `ps -wwE` reads their
2108
+ * environment (same user only). Rows whose environment cannot be read stay
2109
+ * `probe: "command-only"`; the gate treats such a row as holding the default
2110
+ * home AND blocking every non-default home.
1937
2111
  * other platforms: return []. The lane gate is authoritative there.
1938
2112
  *
1939
2113
  * Throws on ps/readdir failure so the caller skips refresh.
@@ -1975,32 +2149,23 @@ const defaultListProcesses = async () => {
1975
2149
  throw new Error(`read /proc/${entry}/environ failed`);
1976
2150
  }
1977
2151
  const match = /(?:^|\0)CLAUDE_CONFIG_DIR=([^\0]+)/.exec(envText);
1978
- out.push({
1979
- pid,
1980
- env: match && match[1] ? { CLAUDE_CONFIG_DIR: match[1] } : {},
1981
- probe: "env",
1982
- });
2152
+ const homeMatch = /(?:^|\0)HOME=([^\0]+)/.exec(envText);
2153
+ const procEnv = {};
2154
+ if (match && match[1])
2155
+ procEnv.CLAUDE_CONFIG_DIR = match[1];
2156
+ if (homeMatch && homeMatch[1])
2157
+ procEnv.HOME = homeMatch[1];
2158
+ out.push({ pid, env: procEnv, probe: "env" });
1983
2159
  }
1984
2160
  return out;
1985
2161
  }
1986
2162
  if (process.platform === "darwin") {
1987
- const result = spawnSync("ps", ["-axo", "pid=,command="], {
1988
- encoding: "utf8",
1989
- timeout: 5_000,
1990
- // Full command lines for every process can pass the 1 MiB default on a busy machine.
1991
- maxBuffer: HQ_JSON_MAX_BUFFER_BYTES,
1992
- windowsHide: true,
1993
- stdio: ["ignore", "pipe", "pipe"],
1994
- });
1995
- if (result.error)
1996
- throw new Error(`ps failed to spawn: ${result.error.message}`);
1997
- if (result.signal)
1998
- throw new Error(`ps killed by ${result.signal}`);
1999
- if (typeof result.status !== "number")
2000
- throw new Error("ps no exit status");
2001
- if (result.status !== 0)
2002
- throw new Error(`ps exit ${result.status}`);
2003
- return parsePsClaudeOutput(result.stdout ?? "");
2163
+ const rows = parsePsClaudeOutput(runPs(["-axo", "pid=,command="]));
2164
+ if (rows.length === 0)
2165
+ return rows;
2166
+ const pids = rows.map((row) => String(row.pid)).join(",");
2167
+ const commandLines = parsePsCommandLines(runPs(["-ww", "-o", "pid=,command=", "-p", pids]));
2168
+ return parsePsEnvOutput(runPs(["-wwE", "-o", "pid=,command=", "-p", pids]), commandLines, rows);
2004
2169
  }
2005
2170
  return [];
2006
2171
  };
@@ -2027,7 +2192,16 @@ async function attemptClaudeTokenRefresh(ctx) {
2027
2192
  return { outcome: "skipped-live-check-failed", errorMessage: message };
2028
2193
  }
2029
2194
  const resolvedHome = path.resolve(ctx.home);
2030
- const sameHome = (candidate) => typeof candidate === "string" && candidate.length > 0 && path.resolve(candidate) === resolvedHome;
2195
+ const targetHome = canonicalHomePath(ctx.home) ?? resolvedHome;
2196
+ // Exact match, for the auth-failed short circuit, which records the home as revoked.
2197
+ const sameHome = (candidate) => typeof candidate === "string" && candidate.length > 0 && canonicalHomePath(candidate) === targetHome;
2198
+ // Blocking match: a path that cannot be resolved might be this home, so it blocks.
2199
+ const mayHoldHome = (candidate) => {
2200
+ if (typeof candidate !== "string" || candidate.length === 0)
2201
+ return false;
2202
+ const canonical = canonicalHomePath(candidate);
2203
+ return canonical === null || canonical === targetHome;
2204
+ };
2031
2205
  // Addendum 1: if the lane watcher classified a 401/403 on this home as
2032
2206
  // provider_auth_failed, treat the account as already signed out and record
2033
2207
  // revoked immediately. No token endpoint call, no response body stored.
@@ -2046,7 +2220,7 @@ async function attemptClaudeTokenRefresh(ctx) {
2046
2220
  });
2047
2221
  return { outcome: "revoked", errorClass: "revoked" };
2048
2222
  }
2049
- if (liveLanes.some((lane) => lane.isLive && sameHome(lane.home))) {
2223
+ if (liveLanes.some((lane) => lane.isLive && mayHoldHome(lane.home))) {
2050
2224
  recordRefresh(ctx.root, key, { attemptedAt: new Date(ctx.nowMs).toISOString(), outcome: "skipped-live" });
2051
2225
  return { outcome: "skipped-live" };
2052
2226
  }
@@ -2065,15 +2239,21 @@ async function attemptClaudeTokenRefresh(ctx) {
2065
2239
  return { outcome: "skipped-process-check-failed", errorMessage: message };
2066
2240
  }
2067
2241
  const defaultHome = path.join(os.homedir(), ".claude");
2068
- const isDefaultHome = resolvedHome === path.resolve(defaultHome);
2242
+ const isDefaultHome = targetHome === (canonicalHomePath(defaultHome) ?? path.resolve(defaultHome));
2069
2243
  const procBlocks = procs.some((proc) => {
2070
2244
  if (proc.probe === "env") {
2071
2245
  const dir = typeof proc.env.CLAUDE_CONFIG_DIR === "string" ? proc.env.CLAUDE_CONFIG_DIR : "";
2072
- // On linux we have a real env read. Default home is held when
2073
- // CLAUDE_CONFIG_DIR is empty; non-default home only when it points here.
2074
- if (!dir)
2075
- return isDefaultHome;
2076
- return path.resolve(dir) === resolvedHome;
2246
+ // A real env read. With CLAUDE_CONFIG_DIR unset the process holds HOME/.claude for its
2247
+ // own HOME (the collector's home when HOME was not read). Claude Code adds no keychain
2248
+ // suffix without CLAUDE_CONFIG_DIR, so such a process may also use the default keychain
2249
+ // item; it keeps blocking the collector's default home too.
2250
+ // Compare the process's default dir the same way as the collector's: a dir that does not
2251
+ // exist yet is compared by its resolved path, so a missing ~/.claude does not block every home.
2252
+ if (!dir) {
2253
+ const procDefault = processDefaultConfigDir(proc);
2254
+ return isDefaultHome || targetHome === (canonicalHomePath(procDefault) ?? path.resolve(procDefault));
2255
+ }
2256
+ return mayHoldHome(dir);
2077
2257
  }
2078
2258
  // command-only (darwin): we cannot read the per-process env. Conservatively
2079
2259
  // treat the row as holding the default home AND blocking non-default homes.
@@ -211,8 +211,8 @@ function planService(service, ctx) {
211
211
  return [unit];
212
212
  }
213
213
  case "sync-core": {
214
- if (ctx.platform !== "darwin" && ctx.platform !== "linux") {
215
- return [unavailable(service, "the new sync engine runs on macOS and Linux only")];
214
+ if (ctx.platform !== "darwin" && ctx.platform !== "linux" && ctx.platform !== "win32") {
215
+ return [unavailable(service, "the new sync engine runs on macOS, Linux and Windows only")];
216
216
  }
217
217
  const reconcile = base(service, {
218
218
  id: SYNC_CORE_RECONCILE_UNIT,
@@ -82,7 +82,7 @@ export function syncCoreVaultPlans(home, hqRoot, platform) {
82
82
  release: state.release,
83
83
  ...(vault.legacyOffSince ? { legacyOffSince: vault.legacyOffSince } : {}),
84
84
  }, platform);
85
- const releaseHold = vault.phase === "full" ? fullModeHold(vault, state.release) : undefined;
85
+ const releaseHold = vault.phase === "full" ? fullModeHold(vault, state.release, platform) : undefined;
86
86
  return releaseHold ? { ...plan, blockedReason: releaseHold } : plan;
87
87
  });
88
88
  }
@@ -115,7 +115,7 @@ export function planUnitsWithSyncCoreInterlock(services, context, supervisor) {
115
115
  let restorePersonal = false;
116
116
  const restoreCompanies = {};
117
117
  for (const vault of context.syncCoreVaults ?? []) {
118
- if (vault.phase !== "full" || runningVaults.has(vault.uid) || !fullModeHold(vault, vault.release))
118
+ if (vault.phase !== "full" || runningVaults.has(vault.uid) || !fullModeHold(vault, vault.release, context.platform))
119
119
  continue;
120
120
  if (vault.kind === "personal") {
121
121
  restorePersonal = personalOff;
@@ -172,6 +172,14 @@ function optionalUnitInterval(value, field) {
172
172
  }
173
173
  return value;
174
174
  }
175
+ function optionalPercent(value, field) {
176
+ if (value === undefined)
177
+ return undefined;
178
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 0 || value > 100) {
179
+ throw new LanesConfigError("invalid_config", `${field} must be a number in [0, 100]`, { field, value });
180
+ }
181
+ return value;
182
+ }
175
183
  function parseAccount(provider, id, raw, deps) {
176
184
  if (!isPlainObject(raw)) {
177
185
  throw new LanesConfigError("invalid_config", `providers.${provider}.accounts.${id} must be an object`, { provider, account: id });
@@ -407,6 +415,11 @@ function parseThrottle(raw) {
407
415
  }
408
416
  result.min_available_mem_mb = obj.min_available_mem_mb;
409
417
  }
418
+ // darwin: a warn pressure level holds only at or below this free
419
+ // percentage. Unset means DEFAULT_MEMORY_WARN_MIN_FREE_PERCENT (20).
420
+ const warnMinFree = optionalPercent(obj.memory_warn_min_free_percent, "throttle.memory_warn_min_free_percent");
421
+ if (warnMinFree !== undefined)
422
+ result.memory_warn_min_free_percent = warnMinFree;
410
423
  return result;
411
424
  }
412
425
  function parseLiveness(raw) {
@@ -25,6 +25,17 @@ export declare const SYSCTL_BIN = "/usr/sbin/sysctl";
25
25
  * hw.memsize turns the percentage into MiB for min_available_mem_mb.
26
26
  */
27
27
  export declare const MEMORY_PRESSURE_SYSCTLS: readonly ["kern.memorystatus_level", "kern.memorystatus_vm_pressure_level", "hw.memsize"];
28
+ /**
29
+ * Default for throttle.memory_warn_min_free_percent. On darwin a warn pressure
30
+ * level holds admission only while the free percentage is at or below this.
31
+ */
32
+ export declare const DEFAULT_MEMORY_WARN_MIN_FREE_PERCENT = 20;
33
+ /**
34
+ * Whether a darwin memory_pressure level holds admission on its own. Critical
35
+ * always holds. Warn holds only at or below warnMinFreePercent free, or when
36
+ * the free percentage is unknown.
37
+ */
38
+ export declare function pressureLevelHolds(signal: MemorySignal, warnMinFreePercent?: number): boolean;
28
39
  export declare const LANES_CPU_BUSY_OVERRIDE = "HQ_LANES_CPU_BUSY_OVERRIDE";
29
40
  export declare const LANES_CPU_CHECK = "HQ_LANES_CPU_CHECK";
30
41
  export interface CpuTimes {
@@ -129,7 +140,7 @@ export declare function readMemorySignal(deps?: ThrottleDeps): MemorySignal | nu
129
140
  /** Available memory in MiB from readMemorySignal; null skips the memory gate. */
130
141
  export declare function readAvailableMemMb(deps?: ThrottleDeps): number | null;
131
142
  /** Plain explanation of a memory hold: which signal, its value, and the threshold. */
132
- export declare function formatMemoryHoldMessage(signal: MemorySignal, thresholdMb: number): string;
143
+ export declare function formatMemoryHoldMessage(signal: MemorySignal, thresholdMb: number, warnMinFreePercent?: number): string;
133
144
  export interface ThrottleState {
134
145
  consecutiveDeferrals: number;
135
146
  lastAdmissionAt: string | null;
@@ -34,6 +34,23 @@ export const MEMORY_PRESSURE_SYSCTLS = [
34
34
  "kern.memorystatus_vm_pressure_level",
35
35
  "hw.memsize",
36
36
  ];
37
+ /**
38
+ * Default for throttle.memory_warn_min_free_percent. On darwin a warn pressure
39
+ * level holds admission only while the free percentage is at or below this.
40
+ */
41
+ export const DEFAULT_MEMORY_WARN_MIN_FREE_PERCENT = 20;
42
+ /**
43
+ * Whether a darwin memory_pressure level holds admission on its own. Critical
44
+ * always holds. Warn holds only at or below warnMinFreePercent free, or when
45
+ * the free percentage is unknown.
46
+ */
47
+ export function pressureLevelHolds(signal, warnMinFreePercent = DEFAULT_MEMORY_WARN_MIN_FREE_PERCENT) {
48
+ if (signal.pressure_level === "critical")
49
+ return true;
50
+ if (signal.pressure_level !== "warn")
51
+ return false;
52
+ return signal.free_percent === undefined || signal.free_percent <= warnMinFreePercent;
53
+ }
37
54
  export const LANES_CPU_BUSY_OVERRIDE = "HQ_LANES_CPU_BUSY_OVERRIDE";
38
55
  export const LANES_CPU_CHECK = "HQ_LANES_CPU_CHECK";
39
56
  function defaultSleep(ms) {
@@ -340,11 +357,11 @@ function formatMb(mb) {
340
357
  return `${Math.round(mb)} MB`;
341
358
  }
342
359
  /** Plain explanation of a memory hold: which signal, its value, and the threshold. */
343
- export function formatMemoryHoldMessage(signal, thresholdMb) {
360
+ export function formatMemoryHoldMessage(signal, thresholdMb, warnMinFreePercent = DEFAULT_MEMORY_WARN_MIN_FREE_PERCENT) {
344
361
  if (signal.source === "memory_pressure") {
345
362
  const reading = `memory_pressure reports ${signal.free_percent}% free (about ${formatMb(signal.available_mb)}), pressure level ${signal.pressure_level}`;
346
- if (signal.pressure_level !== "normal") {
347
- return `Holding new lanes: ${reading}. Lanes wait while the level is warn or critical (throttle.min_available_mem_mb is ${thresholdMb}).`;
363
+ if (pressureLevelHolds(signal, warnMinFreePercent)) {
364
+ return `Holding new lanes: ${reading}. Lanes wait while the level is critical, or warn with ${warnMinFreePercent}% free or less (throttle.memory_warn_min_free_percent; throttle.min_available_mem_mb is ${thresholdMb}).`;
348
365
  }
349
366
  return `Holding new lanes: ${reading}, below throttle.min_available_mem_mb ${thresholdMb}.`;
350
367
  }
@@ -379,6 +396,8 @@ export function evaluateThrottle(input) {
379
396
  const pause = input.throttle.cpu_busy_pause;
380
397
  const resume = input.throttle.cpu_busy_resume;
381
398
  const minMem = input.throttle.min_available_mem_mb;
399
+ const warnMinFree = input.throttle.memory_warn_min_free_percent ??
400
+ DEFAULT_MEMORY_WARN_MIN_FREE_PERCENT;
382
401
  // Each gate is opt-in: an unset threshold never blocks admission.
383
402
  let cpuBlocks = false;
384
403
  if (pause !== undefined && input.cpuCheckEnabled && input.busy !== null) {
@@ -389,8 +408,9 @@ export function evaluateThrottle(input) {
389
408
  cpuBlocks = input.busy >= pause;
390
409
  }
391
410
  }
392
- // On darwin the signal is memory_pressure's: a warn or critical level holds
393
- // even above the threshold, and a normal level holds only below it.
411
+ // On darwin the signal is memory_pressure's: a critical level holds even
412
+ // above the threshold, a warn level holds only at or below
413
+ // memory_warn_min_free_percent free, and otherwise only below the threshold.
394
414
  let memBlocks = false;
395
415
  let memorySignal;
396
416
  if (minMem !== undefined && input.availableMemMb !== null) {
@@ -398,8 +418,7 @@ export function evaluateThrottle(input) {
398
418
  ? input.memory
399
419
  : { source: "meminfo", available_mb: input.availableMemMb };
400
420
  memBlocks = input.availableMemMb < minMem ||
401
- memorySignal.pressure_level === "warn" ||
402
- memorySignal.pressure_level === "critical";
421
+ pressureLevelHolds(memorySignal, warnMinFree);
403
422
  }
404
423
  if (!cpuBlocks && !memBlocks) {
405
424
  return {
@@ -420,7 +439,7 @@ export function evaluateThrottle(input) {
420
439
  memory_hold: {
421
440
  ...memorySignal,
422
441
  threshold_mb: minMem,
423
- message: formatMemoryHoldMessage(memorySignal, minMem),
442
+ message: formatMemoryHoldMessage(memorySignal, minMem, warnMinFree),
424
443
  },
425
444
  };
426
445
  }
@@ -1226,6 +1226,11 @@ export interface ThrottleConfig {
1226
1226
  cpu_busy_pause?: number;
1227
1227
  cpu_busy_resume?: number;
1228
1228
  min_available_mem_mb?: number;
1229
+ /**
1230
+ * darwin only: a warn memory_pressure level holds admission only at or
1231
+ * below this free percentage (0-100). Unset means 20. Critical always holds.
1232
+ */
1233
+ memory_warn_min_free_percent?: number;
1229
1234
  }
1230
1235
  /** Session liveness windows from spec §9. Missing keys keep these documented defaults. */
1231
1236
  export interface LivenessWindows {