@indigoai-us/hq-cli 5.345.202 → 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.
- package/CHANGELOG.md +18 -0
- package/dist/command-catalog.generated.d.ts +24 -0
- package/dist/command-catalog.generated.js +26 -0
- package/dist/commands/bot.js +7 -2
- package/dist/commands/capacity.js +10 -2
- package/dist/commands/cloud.d.ts +14 -2
- package/dist/commands/cloud.js +25 -16
- package/dist/commands/daemon.d.ts +2 -1
- package/dist/commands/daemon.js +225 -4
- package/dist/commands/integrations-core.d.ts +3 -0
- package/dist/commands/integrations-core.js +11 -0
- package/dist/commands/integrations-manage.js +2 -2
- package/dist/commands/integrations.js +5 -5
- package/dist/commands/sync-core.js +4 -1
- package/dist/lib/bot/claude-login.d.ts +7 -1
- package/dist/lib/bot/claude-login.js +56 -16
- package/dist/lib/bot/executive-settings.d.ts +1 -1
- package/dist/lib/bot/executive-settings.js +8 -6
- package/dist/lib/bot/jobs/meeting-detect.js +2 -2
- package/dist/lib/bot/jobs/meeting-rider.js +2 -2
- package/dist/lib/bot/run.js +3 -3
- package/dist/lib/capacity/codex-availability.d.ts +6 -0
- package/dist/lib/capacity/codex-availability.js +33 -0
- package/dist/lib/capacity/collectors/claude-oauth.d.ts +53 -5
- package/dist/lib/capacity/collectors/claude-oauth.js +251 -71
- package/dist/lib/capacity/ledger.d.ts +0 -11
- package/dist/lib/capacity/ledger.js +30 -3
- package/dist/lib/capacity/route.js +11 -5
- package/dist/lib/daemon/catalog.d.ts +3 -0
- package/dist/lib/daemon/catalog.js +17 -4
- package/dist/lib/daemon/handoff.d.ts +2 -0
- package/dist/lib/daemon/handoff.js +9 -2
- package/dist/lib/daemon/install.d.ts +99 -0
- package/dist/lib/daemon/install.js +316 -7
- package/dist/lib/daemon/migrate.d.ts +3 -1
- package/dist/lib/daemon/migrate.js +14 -2
- package/dist/lib/daemon/plist-refresh.d.ts +34 -0
- package/dist/lib/daemon/plist-refresh.js +62 -0
- package/dist/lib/daemon/run.js +27 -2
- package/dist/lib/daemon/selfcheck.d.ts +71 -0
- package/dist/lib/daemon/selfcheck.js +150 -0
- package/dist/lib/daemon/supervisor.d.ts +31 -0
- package/dist/lib/daemon/supervisor.js +130 -6
- package/dist/lib/daemon/unit-holds.js +2 -1
- package/dist/lib/lanes/config.js +13 -0
- package/dist/lib/lanes/throttle.d.ts +12 -1
- package/dist/lib/lanes/throttle.js +27 -8
- package/dist/lib/lanes/types.d.ts +5 -0
- package/dist/lib/service-manager/launchd.d.ts +25 -0
- package/dist/lib/service-manager/launchd.js +51 -3
- package/dist/lib/service-manager/migration.d.ts +14 -0
- package/dist/lib/service-manager/migration.js +34 -0
- package/dist/lib/service-manager/systemd.d.ts +13 -0
- package/dist/lib/service-manager/systemd.js +19 -0
- package/dist/lib/service-manager/types.d.ts +8 -0
- package/dist/lib/signals/steps/extract.d.ts +110 -4
- package/dist/lib/signals/steps/extract.js +520 -39
- package/dist/lib/sync-core/engine-home.d.ts +2 -1
- package/dist/lib/sync-core/engine-home.js +25 -8
- package/dist/lib/sync-core/reconcile.d.ts +7 -2
- package/dist/lib/sync-core/reconcile.js +32 -9
- package/dist/lib/sync-core/release.d.ts +35 -3
- package/dist/lib/sync-core/release.js +95 -12
- package/package.json +7 -7
|
@@ -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(
|
|
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 =
|
|
1770
|
-
/**
|
|
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
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
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
|
-
|
|
1893
|
-
|
|
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
|
|
1935
|
-
*
|
|
1936
|
-
*
|
|
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
|
-
|
|
1979
|
-
|
|
1980
|
-
|
|
1981
|
-
|
|
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
|
|
1988
|
-
|
|
1989
|
-
|
|
1990
|
-
|
|
1991
|
-
|
|
1992
|
-
|
|
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
|
|
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 &&
|
|
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 =
|
|
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
|
-
//
|
|
2073
|
-
//
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
|
|
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.
|
|
@@ -1,14 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Personal capacity ledger under ~/.hq/capacity/.
|
|
3
|
-
*
|
|
4
|
-
* Daily files events-YYYY-MM-DD.jsonl are the append-only event log (one
|
|
5
|
-
* O_APPEND write under 4 KB). rebuildNow() replays those files, plus a
|
|
6
|
-
* legacy events.jsonl if a fixture dropped one, into now.json. The now.json
|
|
7
|
-
* rewrite holds a TTL lock reclaimed after 30 seconds.
|
|
8
|
-
*
|
|
9
|
-
* Root is HQ_CAPACITY_ROOT, or an explicit option, so tests never touch the
|
|
10
|
-
* real home directory. No network, child process, or provider call.
|
|
11
|
-
*/
|
|
12
1
|
export declare const CAPACITY_DIRNAME = "capacity";
|
|
13
2
|
export declare const CAPACITY_LOCK_TTL_MS = 30000;
|
|
14
3
|
export declare const CAPACITY_LOCK_NAME = "now.lock";
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { codexSnapshotStale, hasCodexCreditRoom } from "./codex-availability.js";
|
|
1
2
|
/**
|
|
2
3
|
* Personal capacity ledger under ~/.hq/capacity/.
|
|
3
4
|
*
|
|
@@ -857,11 +858,26 @@ function materialize(events, ctx) {
|
|
|
857
858
|
return a.reading.limit.limitId.localeCompare(b.reading.limit.limitId);
|
|
858
859
|
})
|
|
859
860
|
.map((limit) => toNowLimit(limit, ctx.nowMs));
|
|
861
|
+
if (provider === "codex") {
|
|
862
|
+
for (const limit of limits) {
|
|
863
|
+
if (codexSnapshotStale(limit, limits)) {
|
|
864
|
+
limit.state = "unknown";
|
|
865
|
+
limit.usedPercent = null;
|
|
866
|
+
limit.burnRatePercentPerHour = null;
|
|
867
|
+
limit.lastError = "superseded by a newer Codex snapshot";
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
}
|
|
860
871
|
const failed = bucket.failure !== null && bucket.failure.at >= bucket.latestReadingAt;
|
|
861
872
|
let state = "ok";
|
|
862
873
|
for (const limit of limits) {
|
|
863
|
-
if (
|
|
864
|
-
|
|
874
|
+
if (provider === "codex" && codexSnapshotStale(limit, limits))
|
|
875
|
+
continue;
|
|
876
|
+
const availableState = provider === "codex" && limit.state === "exhausted"
|
|
877
|
+
&& hasCodexCreditRoom(limits.filter((row) => row.accountId === limit.accountId), ctx.nowMs)
|
|
878
|
+
? "ok" : limit.state;
|
|
879
|
+
if (STATE_RANK[availableState] > STATE_RANK[state])
|
|
880
|
+
state = availableState;
|
|
865
881
|
}
|
|
866
882
|
if (failed)
|
|
867
883
|
state = "unknown";
|
|
@@ -878,6 +894,15 @@ function materialize(events, ctx) {
|
|
|
878
894
|
}
|
|
879
895
|
const unscopedFailure = bucket.failure !== null && bucket.failure.at >= bucket.latestReadingAt;
|
|
880
896
|
attachAccounts(entry, events, unscopedFailure);
|
|
897
|
+
if (provider === "codex" && !failed && entry.accounts?.length) {
|
|
898
|
+
for (const account of entry.accounts) {
|
|
899
|
+
if (account.authState === "ok" && account.state === "exhausted"
|
|
900
|
+
&& hasCodexCreditRoom(account.limits, ctx.nowMs))
|
|
901
|
+
account.state = "ok";
|
|
902
|
+
}
|
|
903
|
+
const live = entry.accounts.filter((account) => account.authState !== "expired" && account.authState !== "signed_out" && !account.signedOut);
|
|
904
|
+
entry.state = live.length === 0 ? "unknown" : live.reduce((state, account) => STATE_RANK[account.state] > STATE_RANK[state] ? account.state : state, "ok");
|
|
905
|
+
}
|
|
881
906
|
return entry;
|
|
882
907
|
});
|
|
883
908
|
return {
|
|
@@ -1039,6 +1064,8 @@ function clusterAccounts(provider, events) {
|
|
|
1039
1064
|
if (!blocked) {
|
|
1040
1065
|
state = "ok";
|
|
1041
1066
|
for (const limit of limits) {
|
|
1067
|
+
if (provider.provider === "codex" && codexSnapshotStale(limit, limits))
|
|
1068
|
+
continue;
|
|
1042
1069
|
if (STATE_RANK[limit.state] > STATE_RANK[state])
|
|
1043
1070
|
state = limit.state;
|
|
1044
1071
|
}
|
|
@@ -1182,7 +1209,7 @@ function deriveLimit(reading, nowMs) {
|
|
|
1182
1209
|
// A passed reset beats both a refusal and a 100% reading. The newest
|
|
1183
1210
|
// observedAt wins in materialize, so a later fetched or observed percent
|
|
1184
1211
|
// below 100 replaces this refusal before the reset.
|
|
1185
|
-
if (!Number.isNaN(resetsAtMs) && resetsAtMs
|
|
1212
|
+
if (!Number.isNaN(resetsAtMs) && resetsAtMs <= nowMs) {
|
|
1186
1213
|
return { state: "reset-pending", usedPercent: null, secondsToReset: null, lastError: null };
|
|
1187
1214
|
}
|
|
1188
1215
|
const percent = reading.limit.usedPercent;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { codexSnapshotStale, hasCodexCreditRoom } from "./codex-availability.js";
|
|
1
2
|
/**
|
|
2
3
|
* Pure router for `hq capacity route` and `hq lanes create --account auto`.
|
|
3
4
|
*
|
|
@@ -368,7 +369,7 @@ export function pickAccount(input) {
|
|
|
368
369
|
if (resetPassed) {
|
|
369
370
|
return { limit, used: 0, readAge, resetPassed: true };
|
|
370
371
|
}
|
|
371
|
-
if (readAge > LIMIT_STALE_MS) {
|
|
372
|
+
if (readAge > LIMIT_STALE_MS || (providerId === "codex" && codexSnapshotStale(limit, account.limits ?? []))) {
|
|
372
373
|
return { limit, used: null, readAge, resetPassed: false };
|
|
373
374
|
}
|
|
374
375
|
const used = typeof limit.usedPercent === "number" ? limit.usedPercent : null;
|
|
@@ -393,14 +394,18 @@ export function pickAccount(input) {
|
|
|
393
394
|
}
|
|
394
395
|
const exhausted = viewRows.find((row) => !row.resetPassed
|
|
395
396
|
&& row.readAge <= LIMIT_STALE_MS
|
|
397
|
+
&& !(providerId === "codex" && codexSnapshotStale(row.limit, account.limits ?? []))
|
|
396
398
|
&& (row.limit.state === "exhausted" || (typeof row.used === "number" && row.used >= 100)));
|
|
397
399
|
if (exhausted) {
|
|
400
|
+
const codexCredits = providerId === "codex" && hasCodexCreditRoom(account.limits ?? [], nowMs);
|
|
398
401
|
const extraRoom = providerId === "claude" && hasExtraUsageRoom(account);
|
|
399
|
-
if (extraRoom && input.allowExtraUsage === true) {
|
|
402
|
+
if (codexCredits || (extraRoom && input.allowExtraUsage === true)) {
|
|
400
403
|
const live = liveLaneCount(lanes, providerId, laneMatch.id);
|
|
401
404
|
const weekly = applicable.find((l) => l.kind === "weekly_all");
|
|
402
405
|
const whyParts = [
|
|
403
|
-
|
|
406
|
+
codexCredits
|
|
407
|
+
? `${labelLimit(exhausted.limit)} used up; runs on available Codex credits`
|
|
408
|
+
: `${labelLimit(exhausted.limit)} used up; runs on paid extra usage (--allow-extra-usage)`,
|
|
404
409
|
];
|
|
405
410
|
if (live)
|
|
406
411
|
whyParts.push(`${live} live lane${live === 1 ? "" : "s"} already on it`);
|
|
@@ -515,7 +520,8 @@ export function pickAccount(input) {
|
|
|
515
520
|
});
|
|
516
521
|
}
|
|
517
522
|
}
|
|
518
|
-
ranked.sort((a, b) =>
|
|
523
|
+
ranked.sort((a, b) => Number(a.extra_usage === true) - Number(b.extra_usage === true)
|
|
524
|
+
|| b.score - a.score
|
|
519
525
|
|| b.headroom - a.headroom
|
|
520
526
|
|| a.reading_age_h - b.reading_age_h
|
|
521
527
|
|| a.provider.localeCompare(b.provider));
|
|
@@ -524,7 +530,7 @@ export function pickAccount(input) {
|
|
|
524
530
|
if (!requested.includes(p))
|
|
525
531
|
continue;
|
|
526
532
|
const best = ranked.find((r) => r.provider === p);
|
|
527
|
-
if (best && best.headroom >= floor) {
|
|
533
|
+
if (best && !best.extra_usage && best.headroom >= floor) {
|
|
528
534
|
pick = best;
|
|
529
535
|
break;
|
|
530
536
|
}
|
|
@@ -98,6 +98,9 @@ export declare const FLEET_BOTS_UPDATE_INITIAL_DELAY_SEC = 120;
|
|
|
98
98
|
export declare const SYNC_CORE_RECONCILE_UNIT = "sync-core";
|
|
99
99
|
export declare const SYNC_CORE_RECONCILE_INTERVAL_SEC = 600;
|
|
100
100
|
export declare const SYNC_CORE_MANIFEST_UNIT = "sync-core-manifest";
|
|
101
|
+
export declare const SYNC_CORE_SELFCHECK_UNIT = "sync-core-selfcheck";
|
|
102
|
+
/** The self-check looks hourly; it DMs the owner at most once a day for the same units (selfcheck.ts). */
|
|
103
|
+
export declare const SYNC_CORE_SELFCHECK_INTERVAL_SEC = 3600;
|
|
101
104
|
export declare const SYNC_CORE_MANIFEST_INTERVAL_SEC: number;
|
|
102
105
|
export declare const SYNC_CORE_LEGACY_SETTLE_MS: number;
|
|
103
106
|
export interface PlanContext {
|
|
@@ -98,6 +98,9 @@ export const FLEET_BOTS_UPDATE_INITIAL_DELAY_SEC = 120;
|
|
|
98
98
|
export const SYNC_CORE_RECONCILE_UNIT = "sync-core";
|
|
99
99
|
export const SYNC_CORE_RECONCILE_INTERVAL_SEC = 600;
|
|
100
100
|
export const SYNC_CORE_MANIFEST_UNIT = "sync-core-manifest";
|
|
101
|
+
export const SYNC_CORE_SELFCHECK_UNIT = "sync-core-selfcheck";
|
|
102
|
+
/** The self-check looks hourly; it DMs the owner at most once a day for the same units (selfcheck.ts). */
|
|
103
|
+
export const SYNC_CORE_SELFCHECK_INTERVAL_SEC = 3600;
|
|
101
104
|
export const SYNC_CORE_MANIFEST_INTERVAL_SEC = 24 * 3600;
|
|
102
105
|
export const SYNC_CORE_LEGACY_SETTLE_MS = 10 * 60 * 1000;
|
|
103
106
|
function base(service, over = {}) {
|
|
@@ -208,8 +211,8 @@ function planService(service, ctx) {
|
|
|
208
211
|
return [unit];
|
|
209
212
|
}
|
|
210
213
|
case "sync-core": {
|
|
211
|
-
if (ctx.platform !== "darwin" && ctx.platform !== "linux") {
|
|
212
|
-
return [unavailable(service, "the new sync engine runs on macOS and
|
|
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")];
|
|
213
216
|
}
|
|
214
217
|
const reconcile = base(service, {
|
|
215
218
|
id: SYNC_CORE_RECONCILE_UNIT,
|
|
@@ -265,9 +268,19 @@ function planService(service, ctx) {
|
|
|
265
268
|
]),
|
|
266
269
|
});
|
|
267
270
|
});
|
|
271
|
+
const selfcheck = engines.length === 0 ? [] : [base(service, {
|
|
272
|
+
id: SYNC_CORE_SELFCHECK_UNIT,
|
|
273
|
+
description: "tells the signed-in person by DM when a sync engine unit has been held or failing for over 30 minutes",
|
|
274
|
+
kind: "scheduled",
|
|
275
|
+
intervalSec: SYNC_CORE_SELFCHECK_INTERVAL_SEC,
|
|
276
|
+
initialDelaySec: 35 * 60,
|
|
277
|
+
command: { type: "hq", args: ["daemon", "service", "sync-core-selfcheck"] },
|
|
278
|
+
env: { HQ_NO_UPDATE_CHECK: "1" },
|
|
279
|
+
blocked: firstBlock([[!ctx.signedIn, NOT_SIGNED_IN]]),
|
|
280
|
+
})];
|
|
268
281
|
const manifestScopes = engineOwnedManifestScopes(ctx.vaultSync, ctx.syncCoreVaults);
|
|
269
282
|
if (manifestScopes.length === 0)
|
|
270
|
-
return [reconcile, ...engines];
|
|
283
|
+
return [reconcile, ...engines, ...selfcheck];
|
|
271
284
|
const manifest = base(service, {
|
|
272
285
|
id: SYNC_CORE_MANIFEST_UNIT,
|
|
273
286
|
description: `uploads a daily sync-audit manifest for ${manifestScopes.map((scope) => scope.kind === "personal" ? "the personal vault" : `the ${scope.slug} vault`).join(", ")}`,
|
|
@@ -286,7 +299,7 @@ function planService(service, ctx) {
|
|
|
286
299
|
[!ctx.signedIn, NOT_SIGNED_IN],
|
|
287
300
|
]),
|
|
288
301
|
});
|
|
289
|
-
return [reconcile, ...engines, manifest];
|
|
302
|
+
return [reconcile, ...engines, manifest, ...selfcheck];
|
|
290
303
|
}
|
|
291
304
|
case "mesh": {
|
|
292
305
|
const args = ["mesh", "daemon", "run", ...(ctx.hqRoot ? ["--hq-root", ctx.hqRoot] : [])];
|
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
import { type ServiceName } from "./catalog.js";
|
|
8
8
|
import { type MachineType } from "./machine-type.js";
|
|
9
9
|
import { type DaemonPaths } from "./paths.js";
|
|
10
|
+
/** The updater's postinstall may run in the short gap before the desktop app relaunches its daemon. */
|
|
11
|
+
export declare function recentlyHostedDaemon(paths: DaemonPaths, alive?: (pid: number) => boolean): boolean;
|
|
10
12
|
/** A live daemon runs `unitId` (or held it after it was stopped from outside). */
|
|
11
13
|
export declare function daemonSupervises(paths: DaemonPaths, unitId: string, alive?: (pid: number) => boolean): boolean;
|
|
12
14
|
/** Current supervisor hold, including its explicit recovery command; never infer one from stale state. */
|
|
@@ -20,10 +20,14 @@ import { writeControlRequest } from "./requests.js";
|
|
|
20
20
|
const OWNED = new Set(["running", "backoff", "exited", "idle", "stopped", "held"]);
|
|
21
21
|
const RECENT_HOSTED_DAEMON_MS = 5 * 60_000;
|
|
22
22
|
/** The updater's postinstall may run in the short gap before the desktop app relaunches its daemon. */
|
|
23
|
-
function recentlyHostedDaemon(paths) {
|
|
23
|
+
export function recentlyHostedDaemon(paths, alive = (pid) => isDaemonProcess(pid)) {
|
|
24
24
|
if (!fs.existsSync(paths.statePath))
|
|
25
25
|
return false;
|
|
26
26
|
const state = JSON.parse(fs.readFileSync(paths.statePath, "utf8"));
|
|
27
|
+
// A different live daemon holds the lock: the hosted daemon that wrote this state is gone, not restarting.
|
|
28
|
+
const lockPid = runningDaemonPid(paths.lockPath, alive);
|
|
29
|
+
if (lockPid !== null && state.pid !== lockPid)
|
|
30
|
+
return false;
|
|
27
31
|
const age = Date.now() - (state.updatedAt ?? 0);
|
|
28
32
|
return state.host === "desktop" && age >= 0 && age <= RECENT_HOSTED_DAEMON_MS;
|
|
29
33
|
}
|
|
@@ -85,7 +89,10 @@ export function askDaemonToStop(paths, unitId) {
|
|
|
85
89
|
/** A live daemon runs as the desktop app's child, which counts as installed even though no OS unit exists. */
|
|
86
90
|
export function hostedDaemonRunning(paths, alive = (pid) => isDaemonProcess(pid)) {
|
|
87
91
|
try {
|
|
88
|
-
|
|
92
|
+
// state.json outlives the daemon that wrote it: count it only when it is the lock holder's.
|
|
93
|
+
const lockPid = runningDaemonPid(paths.lockPath, alive);
|
|
94
|
+
const state = liveState(paths, alive);
|
|
95
|
+
return state?.host === "desktop" && state.pid === lockPid;
|
|
89
96
|
}
|
|
90
97
|
catch {
|
|
91
98
|
return false;
|