@indigoai-us/hq-cli 5.341.1 → 5.341.2

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 CHANGED
@@ -2,6 +2,14 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.341.2] — 2026-10-03
6
+
7
+ - Preserve the original integrations response read error instead of returning a consumed response.
8
+
9
+ - The CLI no longer starts while a confirmed Desktop update is in progress after a lease I/O error.
10
+ - hqd refreshes `hq-anywhere-runtime` every 30 seconds, fails closed when the last successful read is older than 60 seconds, and clears its in-memory stdin login when the flag turns off.
11
+ - Hardened hqd company resolution so unreadable registries and registry links to companies missing from the configured HQ manifest fall back to personal context. Added adversarial coverage for symlinks, nested repositories, clones, path normalization, worktrees, and session bindings.
12
+
5
13
  ## [5.341.1] — 2026-10-03
6
14
  - Match native policy frontmatter and report bytes to the bundled shell on Windows, including paths, diagnostics, and JSON newlines.
7
15
  - Keep session-journal printable paths byte-compatible with the shell on Windows.
@@ -18,6 +18,6 @@ type RefreshSession = (options?: {
18
18
  minValiditySeconds?: number;
19
19
  }) => ReturnType<typeof refreshCachedSession>;
20
20
  export declare function runAuthRefresh(refreshSession?: RefreshSession): Promise<number>;
21
- export declare function startAuthRefresh(acquireLease?: typeof acquireCurrentPackageUseLease): Promise<number>;
21
+ export declare function startAuthRefresh(acquireLease?: typeof acquireCurrentPackageUseLease, updateRequestPending?: () => boolean): Promise<number>;
22
22
  export {};
23
23
  //# sourceMappingURL=hq-auth-refresh.d.ts.map
@@ -13,7 +13,7 @@
13
13
  // MUST be first: guard the Node version before any dependency that needs a
14
14
  // Node 20+ API (e.g. util.styleText) or a newer native ABI is evaluated.
15
15
  import "../node-preflight.js";
16
- import { acquireCurrentPackageUseLease } from "../utils/package-use-lease.js";
16
+ import { acquireCurrentPackageUseLease, currentPackageUseUpdatePending, } from "../utils/package-use-lease.js";
17
17
  import "../node-network-compat.js";
18
18
  import { isMainModule } from "../utils/is-main-module.js";
19
19
  const MIN_VALIDITY_FLAG = "--min-validity-seconds";
@@ -93,7 +93,7 @@ function packageUseErrorClass(error) {
93
93
  }
94
94
  return typeof value.name === "string" ? value.name : "unknown";
95
95
  }
96
- export async function startAuthRefresh(acquireLease = acquireCurrentPackageUseLease) {
96
+ export async function startAuthRefresh(acquireLease = acquireCurrentPackageUseLease, updateRequestPending = currentPackageUseUpdatePending) {
97
97
  try {
98
98
  acquireLease();
99
99
  }
@@ -102,6 +102,15 @@ export async function startAuthRefresh(acquireLease = acquireCurrentPackageUseLe
102
102
  process.stderr.write(`hq-auth-refresh: ${error instanceof Error ? error.message : "The HQ CLI package is being updated"}\n`);
103
103
  return 1;
104
104
  }
105
+ try {
106
+ if (updateRequestPending()) {
107
+ process.stderr.write("hq-auth-refresh: The HQ CLI package is being updated; retry after the update finishes\n");
108
+ return 1;
109
+ }
110
+ }
111
+ catch {
112
+ // A failed pending-request probe preserves the existing fail-open behavior.
113
+ }
105
114
  process.stderr.write(`hq-auth-refresh: package-use lease unavailable (${packageUseErrorClass(error)}); continuing without lease\n`);
106
115
  }
107
116
  const { initSentry } = await import("../sentry.js");
package/dist/index.d.ts CHANGED
@@ -4,8 +4,9 @@ import "./node-network-compat-fast.js";
4
4
  import { isVersionRequest } from "./version-request.js";
5
5
  import { isFastCoreRequest } from "./fast-scaffold.js";
6
6
  type PackageUseLeaseLoader = () => Promise<void>;
7
+ type PackageUseUpdateProbe = () => Promise<boolean>;
7
8
  declare function isNpmPackageEntry(entryUrl: string): boolean;
8
- declare function dispatchWithPackageUseLease(entryUrl: string, dispatch: () => void, loadLease?: PackageUseLeaseLoader): void | Promise<void>;
9
+ declare function dispatchWithPackageUseLease(entryUrl: string, dispatch: () => void, loadLease?: PackageUseLeaseLoader, updateRequestPending?: PackageUseUpdateProbe): void | Promise<void>;
9
10
  declare function runCliEntry(entryUrl: string, dispatch: () => void, loadLease?: PackageUseLeaseLoader): void | Promise<void>;
10
11
  export declare const __test__: {
11
12
  isVersionRequest: typeof isVersionRequest;
package/dist/index.js CHANGED
@@ -140,14 +140,30 @@ function packageUseErrorClass(error) {
140
140
  function dispatchWithPackageUseLease(entryUrl, dispatch, loadLease = async () => {
141
141
  const { acquireCurrentPackageUseLease } = await import("./utils/package-use-lease.js");
142
142
  acquireCurrentPackageUseLease();
143
+ }, updateRequestPending = async () => {
144
+ const { currentPackageUseUpdatePending } = await import("./utils/package-use-lease.js");
145
+ return currentPackageUseUpdatePending();
143
146
  }) {
144
147
  if (!isNpmPackageEntry(entryUrl)) {
145
148
  dispatch();
146
149
  return;
147
150
  }
148
- return loadLease().then(dispatch, (error) => {
151
+ return loadLease().then(dispatch, async (error) => {
149
152
  if (isPackageUseUpdatePendingError(error))
150
153
  throw error;
154
+ try {
155
+ if (await updateRequestPending()) {
156
+ const pending = new Error("The HQ CLI package is being updated; retry after the update finishes");
157
+ pending.name = "PackageUseUpdatePendingError";
158
+ throw pending;
159
+ }
160
+ }
161
+ catch (probeError) {
162
+ if (isPackageUseUpdatePendingError(probeError))
163
+ throw probeError;
164
+ // A lease probe failure must not make the CLI unavailable. Keep the
165
+ // existing fail-open path when no live update request could be confirmed.
166
+ }
151
167
  process.stderr.write(`hq-cli package-use lease unavailable (${packageUseErrorClass(error)}); continuing without lease\n`);
152
168
  dispatch();
153
169
  });
@@ -0,0 +1,18 @@
1
+ export declare const HQ_ANYWHERE_FLAG_REFRESH_INTERVAL_MS = 30000;
2
+ export declare const HQ_ANYWHERE_FLAG_MAX_STALENESS_MS = 60000;
3
+ export interface BoundedDaemonFlagMonitorOptions {
4
+ read: () => Promise<boolean>;
5
+ now?: () => number;
6
+ onChange?: (enabled: boolean) => void;
7
+ onReadError?: (error: unknown) => void;
8
+ }
9
+ export interface BoundedDaemonFlagMonitor {
10
+ enabled(): Promise<boolean>;
11
+ close(): void;
12
+ }
13
+ /**
14
+ * Refreshes a long-running daemon's flag independently of incoming requests.
15
+ * A failed read keeps the last successful decision only until its 60s age limit.
16
+ */
17
+ export declare function createBoundedDaemonFlagMonitor(options: BoundedDaemonFlagMonitorOptions): BoundedDaemonFlagMonitor;
18
+ //# sourceMappingURL=flag-monitor.d.ts.map
@@ -0,0 +1,86 @@
1
+ export const HQ_ANYWHERE_FLAG_REFRESH_INTERVAL_MS = 30_000;
2
+ export const HQ_ANYWHERE_FLAG_MAX_STALENESS_MS = 60_000;
3
+ /**
4
+ * Refreshes a long-running daemon's flag independently of incoming requests.
5
+ * A failed read keeps the last successful decision only until its 60s age limit.
6
+ */
7
+ export function createBoundedDaemonFlagMonitor(options) {
8
+ const now = options.now ?? Date.now;
9
+ let value;
10
+ let lastSuccessAt;
11
+ let lastAttemptAt;
12
+ let successVersion = 0;
13
+ let refreshInFlight;
14
+ let expiryTimer;
15
+ let closed = false;
16
+ const publish = (next) => {
17
+ const changed = value !== next;
18
+ value = next;
19
+ if (changed)
20
+ options.onChange?.(next);
21
+ };
22
+ const expireIfStale = () => {
23
+ if (!closed && lastSuccessAt !== undefined && now() - lastSuccessAt >= HQ_ANYWHERE_FLAG_MAX_STALENESS_MS) {
24
+ publish(false);
25
+ }
26
+ };
27
+ const refresh = () => {
28
+ if (closed)
29
+ return Promise.resolve(false);
30
+ if (refreshInFlight)
31
+ return refreshInFlight;
32
+ lastAttemptAt = now();
33
+ refreshInFlight = Promise.resolve().then(options.read).then((next) => {
34
+ if (closed)
35
+ return false;
36
+ lastSuccessAt = now();
37
+ const thisSuccessVersion = ++successVersion;
38
+ publish(next);
39
+ if (expiryTimer)
40
+ clearTimeout(expiryTimer);
41
+ // Expire from the elapsed timer itself, not a wall-clock comparison.
42
+ expiryTimer = setTimeout(() => {
43
+ if (!closed && successVersion === thisSuccessVersion)
44
+ publish(false);
45
+ }, HQ_ANYWHERE_FLAG_MAX_STALENESS_MS);
46
+ expiryTimer.unref?.();
47
+ return next;
48
+ }).catch((error) => {
49
+ options.onReadError?.(error);
50
+ expireIfStale();
51
+ return value ?? false;
52
+ }).finally(() => {
53
+ refreshInFlight = undefined;
54
+ });
55
+ return refreshInFlight;
56
+ };
57
+ const refreshTimer = setInterval(() => {
58
+ expireIfStale();
59
+ void refresh();
60
+ }, HQ_ANYWHERE_FLAG_REFRESH_INTERVAL_MS);
61
+ refreshTimer.unref?.();
62
+ void refresh();
63
+ return {
64
+ async enabled() {
65
+ expireIfStale();
66
+ if (lastSuccessAt !== undefined && now() - lastSuccessAt >= HQ_ANYWHERE_FLAG_MAX_STALENESS_MS) {
67
+ void refresh();
68
+ return false;
69
+ }
70
+ if (value === undefined)
71
+ return refresh();
72
+ if (lastAttemptAt === undefined || now() - lastAttemptAt >= HQ_ANYWHERE_FLAG_REFRESH_INTERVAL_MS) {
73
+ void refresh();
74
+ }
75
+ return value;
76
+ },
77
+ close() {
78
+ closed = true;
79
+ if (refreshTimer)
80
+ clearInterval(refreshTimer);
81
+ if (expiryTimer)
82
+ clearTimeout(expiryTimer);
83
+ },
84
+ };
85
+ }
86
+ //# sourceMappingURL=flag-monitor.js.map
@@ -20,6 +20,8 @@ export interface ReadDaemonFlagOptions {
20
20
  createClient?: (options: FlagClientOptions) => DaemonFlagClient;
21
21
  getToken?: () => Promise<string>;
22
22
  onError?: (error: unknown) => void;
23
+ /** Reject a failed registry read so the daemon monitor can distinguish it from an explicit OFF. */
24
+ throwOnError?: boolean;
23
25
  }
24
26
  export declare function readDaemonFlag(flagKey: string, options?: ReadDaemonFlagOptions): Promise<boolean>;
25
27
  //# sourceMappingURL=flag.d.ts.map
@@ -18,8 +18,10 @@ export async function readDaemonFlag(flagKey, options = {}) {
18
18
  if (override !== null)
19
19
  return override;
20
20
  let failed = false;
21
+ let reportedError;
21
22
  const onError = (error) => {
22
23
  failed = true;
24
+ reportedError ??= error;
23
25
  options.onError?.(error);
24
26
  };
25
27
  let client;
@@ -41,10 +43,17 @@ export async function readDaemonFlag(flagKey, options = {}) {
41
43
  });
42
44
  await client.ready();
43
45
  const resolution = client.explain(flagKey, { fallback: false });
46
+ if (failed && options.throwOnError) {
47
+ throw new Error("hq-flags registry read failed", { cause: reportedError });
48
+ }
44
49
  return !failed && resolution.source === "snapshot" && resolution.value === true;
45
50
  }
46
51
  catch (error) {
47
- onError(error);
52
+ if (!failed)
53
+ onError(error);
54
+ if (options.throwOnError && failed) {
55
+ throw new Error("hq-flags registry read failed", { cause: reportedError ?? error });
56
+ }
48
57
  return false;
49
58
  }
50
59
  finally {
@@ -6,7 +6,8 @@
6
6
  * machine. Sessions in any repo ask it which company they belong to and
7
7
  * register themselves here.
8
8
  */
9
- import { type ResolveResult } from "../registry/registry.js";
9
+ import { readRegistry, type ResolveResult } from "../registry/registry.js";
10
+ import { readCompaniesManifestMap } from "../work-context/repo-remote.js";
10
11
  import { SessionRegistry } from "./session-registry.js";
11
12
  import { HqMcpBridge, type RunWorkerRequest } from "./ops/mcp.js";
12
13
  import { type StdinMachineCredentials } from "./machine-login.js";
@@ -26,6 +27,8 @@ export interface HqdServerOptions {
26
27
  workMeshRoot?: string;
27
28
  /** Test seam for the hq-anywhere-runtime default-off gate. */
28
29
  mcpEnabled?: () => boolean | Promise<boolean>;
30
+ /** Test seam for the live hq-flags read used by the daemon's bounded runtime monitor. */
31
+ runtimeFlagReader?: () => Promise<boolean>;
29
32
  /** Test seam for the hosted Streamable HTTP MCP transport. */
30
33
  mcpBridge?: HqMcpBridge;
31
34
  /** Test seam for machine login while keeping production credentials off disk. */
@@ -43,6 +46,11 @@ export interface HqdServer {
43
46
  close(): Promise<void>;
44
47
  }
45
48
  /** Resolve cwd → company through the registry, cached until registry.json changes. */
46
- export declare function createResolver(registryPath: string): (cwd: string) => ResolveResult;
49
+ export declare function createResolver(registryPath: string, options?: {
50
+ hqRoot?: string;
51
+ log?: HqdServerOptions["log"];
52
+ readRegistry?: typeof readRegistry;
53
+ readCompaniesManifest?: typeof readCompaniesManifestMap;
54
+ }): (cwd: string) => ResolveResult;
47
55
  export declare function startHqdServer(opts: HqdServerOptions): Promise<HqdServer>;
48
56
  //# sourceMappingURL=server.d.ts.map
@@ -10,7 +10,8 @@ import * as fs from "node:fs";
10
10
  import * as net from "node:net";
11
11
  import * as path from "node:path";
12
12
  import { CLI_VERSION } from "../../cli-version.js";
13
- import { canonicalDir, readRegistry, registryFile, resolveCompanyForDir } from "../registry/registry.js";
13
+ import { canonicalDir, emptyRegistry, readRegistry, registryFile, resolveCompanyForDir } from "../registry/registry.js";
14
+ import { readCompaniesManifestMap } from "../work-context/repo-remote.js";
14
15
  import { createLineSplitter, encodeMessage, HQD_DEFAULT_REQUEST_TIMEOUT_MS, HQD_MAX_LINE_BYTES, parseRequestLine } from "./protocol.js";
15
16
  import { SessionRegistry } from "./session-registry.js";
16
17
  import { journalAppend, JournalOpError } from "./ops/journal.js";
@@ -20,7 +21,9 @@ import { PolicyIndex, PolicyOpError, policyCheck } from "./ops/policy.js";
20
21
  import { defaultMcpToken, HqMcpBridge, jsonResult, McpOpError, runWorker } from "./ops/mcp.js";
21
22
  import { authenticateMachineCredentials, machineTokenExpiresAt, MachineLoginError, parseStdinMachineCredentials } from "./machine-login.js";
22
23
  import { HQ_MCP_TOOL_NAMES, HQD_NATIVE_TOOL_NAMES } from "../mcp/tools.js";
23
- import { anywhereRuntimeEnabled, HQ_ANYWHERE_RUNTIME_FLAG } from "../../commands/anywhere-runtime-flag.js";
24
+ import { HQ_ANYWHERE_RUNTIME_FLAG } from "../../commands/anywhere-runtime-flag.js";
25
+ import { createBoundedDaemonFlagMonitor } from "./flag-monitor.js";
26
+ import { readDaemonFlag } from "./flag.js";
24
27
  // The allowlist is deliberately read-only. A newly added hosted MCP tool must
25
28
  // pass hq.policy_check until it is explicitly reviewed as read-only here.
26
29
  const MCP_READ_ONLY_TOOL_NAMES = new Set([
@@ -62,29 +65,89 @@ function stringArg(args, name, required = true) {
62
65
  throw new OpError("bad_args", `${name} must be a non-empty string`);
63
66
  }
64
67
  /** Resolve cwd → company through the registry, cached until registry.json changes. */
65
- export function createResolver(registryPath) {
68
+ export function createResolver(registryPath, options = {}) {
69
+ const loadRegistry = options.readRegistry ?? readRegistry;
70
+ const loadCompaniesManifest = options.readCompaniesManifest ?? readCompaniesManifestMap;
66
71
  const cache = new Map();
67
- let stamp = "";
72
+ let registryStamp = "";
73
+ let registry = emptyRegistry();
74
+ let registryLoadFailed = false;
75
+ let manifestStamp = "";
76
+ let companies = null;
77
+ let manifestLoadFailed = false;
68
78
  return (cwd) => {
69
- let current = "missing";
79
+ let currentRegistryStamp = "missing";
70
80
  try {
71
81
  const st = fs.statSync(registryPath);
72
- current = `${st.mtimeMs}:${st.size}:${st.ino}`;
82
+ currentRegistryStamp = `${st.mtimeMs}:${st.ctimeMs}:${st.size}:${st.ino}:${st.mode}`;
73
83
  }
74
84
  catch (err) {
75
- if (err.code !== "ENOENT")
76
- throw err;
85
+ const code = err.code;
86
+ if (code !== "ENOENT") {
87
+ cache.clear();
88
+ const detail = err instanceof Error ? err.message : String(err);
89
+ options.log?.("warn", `hqd registry unavailable; using personal context: ${detail}`);
90
+ return { company: null, reason: "HQ registry is unavailable; using personal context." };
91
+ }
77
92
  }
78
- if (current !== stamp) {
93
+ if (currentRegistryStamp !== registryStamp) {
79
94
  cache.clear();
80
- stamp = current;
95
+ registryStamp = currentRegistryStamp;
96
+ registryLoadFailed = false;
97
+ if (currentRegistryStamp === "missing") {
98
+ registry = emptyRegistry();
99
+ }
100
+ else {
101
+ try {
102
+ registry = loadRegistry(registryPath);
103
+ }
104
+ catch (err) {
105
+ registryLoadFailed = true;
106
+ const detail = err instanceof Error ? err.message : String(err);
107
+ options.log?.("warn", `hqd registry unavailable; using personal context: ${detail}`);
108
+ }
109
+ }
110
+ }
111
+ if (registryLoadFailed) {
112
+ return { company: null, reason: "HQ registry is unavailable; using personal context." };
81
113
  }
82
114
  const key = canonicalDir(cwd);
83
- const hit = cache.get(key);
84
- if (hit)
85
- return hit;
86
- const result = resolveCompanyForDir(readRegistry(registryPath), key);
115
+ const result = cache.get(key) ?? resolveCompanyForDir(registry, key);
87
116
  cache.set(key, result);
117
+ if (result.company && options.hqRoot) {
118
+ const manifestPath = path.join(options.hqRoot, "companies", "manifest.yaml");
119
+ let currentManifestStamp;
120
+ try {
121
+ const st = fs.statSync(manifestPath);
122
+ currentManifestStamp = `${st.mtimeMs}:${st.ctimeMs}:${st.size}:${st.ino}:${st.mode}`;
123
+ }
124
+ catch (err) {
125
+ const code = err.code;
126
+ currentManifestStamp = code === "ENOENT" ? "missing" : `unavailable:${code ?? "unknown"}`;
127
+ }
128
+ if (currentManifestStamp !== manifestStamp) {
129
+ manifestStamp = currentManifestStamp;
130
+ companies = null;
131
+ manifestLoadFailed = false;
132
+ if (currentManifestStamp === "missing" || currentManifestStamp.startsWith("unavailable:")) {
133
+ manifestLoadFailed = true;
134
+ }
135
+ else {
136
+ companies = loadCompaniesManifest(options.hqRoot);
137
+ manifestLoadFailed = companies === null;
138
+ }
139
+ if (manifestLoadFailed) {
140
+ options.log?.("warn", "hqd companies/manifest.yaml unavailable or malformed; using personal context");
141
+ }
142
+ }
143
+ if (manifestLoadFailed || !companies) {
144
+ return { company: null, reason: "HQ company manifest is unavailable; using personal context." };
145
+ }
146
+ if (!Object.hasOwn(companies, result.company)) {
147
+ options.log?.("warn", `hqd registry company ${result.company} is not present in companies/manifest.yaml; using personal context`);
148
+ return { company: null, reason: `Registry company ${result.company} is not present in companies/manifest.yaml; using personal context.` };
149
+ }
150
+ }
88
151
  return result;
89
152
  };
90
153
  }
@@ -106,12 +169,13 @@ export async function startHqdServer(opts) {
106
169
  const isPipe = process.platform === "win32";
107
170
  const startedAtMs = (opts.now ?? (() => new Date()))().getTime();
108
171
  const sessions = new SessionRegistry(opts.now);
109
- const resolve = createResolver(opts.registryPath ?? registryFile());
110
172
  const log = opts.log ?? (() => { });
173
+ const resolve = createResolver(opts.registryPath ?? registryFile(), { hqRoot: opts.hqRoot, log });
111
174
  const clock = opts.now ?? (() => new Date());
112
175
  const env = opts.env ?? process.env;
113
176
  const policyIndex = opts.hqRoot ? new PolicyIndex(opts.hqRoot, log) : null;
114
177
  let ephemeralMachineToken;
178
+ let flagRevocationGeneration = 0;
115
179
  const requireRoot = () => {
116
180
  if (!opts.hqRoot)
117
181
  throw new OpError("no_hq_root", "hqd has no HQ root configured (set hqRoot in daemon config or HQ_ROOT)");
@@ -138,7 +202,6 @@ export async function startHqdServer(opts) {
138
202
  return explicit ?? boundCompany;
139
203
  };
140
204
  let hqMcp = opts.mcpBridge;
141
- const mcpEnabled = opts.mcpEnabled ?? (() => anywhereRuntimeEnabled(env));
142
205
  if (!isPipe) {
143
206
  fs.mkdirSync(path.dirname(socketPath), { recursive: true, mode: 0o700 });
144
207
  if (fs.existsSync(socketPath)) {
@@ -147,6 +210,22 @@ export async function startHqdServer(opts) {
147
210
  fs.unlinkSync(socketPath);
148
211
  }
149
212
  }
213
+ const runtimeFlagMonitor = opts.mcpEnabled ? undefined : createBoundedDaemonFlagMonitor({
214
+ read: opts.runtimeFlagReader ?? (() => readDaemonFlag(HQ_ANYWHERE_RUNTIME_FLAG, {
215
+ env,
216
+ throwOnError: true,
217
+ })),
218
+ onChange: (enabled) => {
219
+ if (!enabled) {
220
+ flagRevocationGeneration += 1;
221
+ ephemeralMachineToken = undefined;
222
+ }
223
+ },
224
+ onReadError: (error) => {
225
+ log("warn", `hqd runtime flag refresh failed (${error instanceof Error ? error.name : "UnknownError"}); retaining the last decision only until its 60-second limit`);
226
+ },
227
+ });
228
+ const mcpEnabled = opts.mcpEnabled ?? (() => runtimeFlagMonitor.enabled());
150
229
  /** Synchronous ops answer in request order; policy.check and mcp.call may return a promise. */
151
230
  const handle = (req) => {
152
231
  const args = req.args ?? {};
@@ -162,7 +241,7 @@ export async function startHqdServer(opts) {
162
241
  case "auth.status":
163
242
  return Promise.resolve(mcpEnabled()).then((enabled) => {
164
243
  if (!enabled)
165
- throw new OpError("anywhere_disabled", `Daemon login is disabled by the ${HQ_ANYWHERE_RUNTIME_FLAG} flag.`);
244
+ ephemeralMachineToken = undefined;
166
245
  if (ephemeralMachineToken && ephemeralMachineToken.expiresAt <= clock().getTime())
167
246
  ephemeralMachineToken = undefined;
168
247
  return { signedIn: Boolean(ephemeralMachineToken), tokenSource: ephemeralMachineToken ? "stdin-memory" : null };
@@ -171,10 +250,12 @@ export async function startHqdServer(opts) {
171
250
  return Promise.resolve(mcpEnabled()).then(async (enabled) => {
172
251
  if (!enabled)
173
252
  throw new OpError("anywhere_disabled", `Daemon login is disabled by the ${HQ_ANYWHERE_RUNTIME_FLAG} flag.`);
253
+ const loginGeneration = flagRevocationGeneration;
174
254
  const credentials = parseStdinMachineCredentials(JSON.stringify(args.credentials) ?? "");
175
255
  const controller = new AbortController();
176
256
  const timeoutMs = opts.machineAuthTimeoutMs ?? 25_000;
177
257
  let timer;
258
+ let authenticatedToken;
178
259
  try {
179
260
  const authentication = (opts.machineAuthenticator ?? ((input, signal) => authenticateMachineCredentials(input, { env, signal })))(credentials, controller.signal);
180
261
  const deadline = new Promise((_, reject) => {
@@ -183,11 +264,10 @@ export async function startHqdServer(opts) {
183
264
  reject(new MachineLoginError("auth_timeout", "hqd sign-in timed out; try again"));
184
265
  }, timeoutMs);
185
266
  });
186
- const token = await Promise.race([authentication, deadline]);
187
- const expiresAt = machineTokenExpiresAt(token);
267
+ authenticatedToken = await Promise.race([authentication, deadline]);
268
+ const expiresAt = machineTokenExpiresAt(authenticatedToken);
188
269
  if (expiresAt <= clock().getTime())
189
270
  throw new MachineLoginError("auth_failed", "hqd sign-in failed; the identity provider returned an expired token");
190
- ephemeralMachineToken = { value: token, expiresAt };
191
271
  }
192
272
  catch {
193
273
  throw new MachineLoginError("auth_failed", "hqd sign-in failed; check the machine credentials and try again");
@@ -196,6 +276,10 @@ export async function startHqdServer(opts) {
196
276
  if (timer)
197
277
  clearTimeout(timer);
198
278
  }
279
+ if (loginGeneration !== flagRevocationGeneration || !(await mcpEnabled())) {
280
+ throw new OpError("anywhere_disabled", `Daemon login is disabled by the ${HQ_ANYWHERE_RUNTIME_FLAG} flag.`);
281
+ }
282
+ ephemeralMachineToken = { value: authenticatedToken, expiresAt: machineTokenExpiresAt(authenticatedToken) };
199
283
  return { signedIn: true, tokenSource: "stdin-memory" };
200
284
  });
201
285
  case "resolve": {
@@ -344,7 +428,7 @@ export async function startHqdServer(opts) {
344
428
  const deadline = requestStartedAt + timeoutMs;
345
429
  const forward = () => Promise.resolve(mcpEnabled()).then((enabled) => {
346
430
  if (!enabled) {
347
- throw new McpOpError("hq_mcp_unavailable", `Hosted hq-mcp forwarding is disabled by the ${HQ_ANYWHERE_RUNTIME_FLAG} flag.`);
431
+ throw new OpError("anywhere_disabled", `Hosted hq-mcp forwarding is disabled by the ${HQ_ANYWHERE_RUNTIME_FLAG} flag.`);
348
432
  }
349
433
  const remainingMs = Math.floor(deadline - Date.now());
350
434
  if (remainingMs <= 0) {
@@ -365,9 +449,14 @@ export async function startHqdServer(opts) {
365
449
  });
366
450
  return hqMcp.callTool(name, raw, remainingMs);
367
451
  });
368
- return !MCP_READ_ONLY_TOOL_NAMES.has(name)
369
- ? policyGuardedCall(forward)
370
- : forward();
452
+ return Promise.resolve(mcpEnabled()).then((enabled) => {
453
+ if (!enabled) {
454
+ throw new OpError("anywhere_disabled", `Hosted hq-mcp forwarding is disabled by the ${HQ_ANYWHERE_RUNTIME_FLAG} flag.`);
455
+ }
456
+ return !MCP_READ_ONLY_TOOL_NAMES.has(name)
457
+ ? policyGuardedCall(forward)
458
+ : forward();
459
+ });
371
460
  }
372
461
  }
373
462
  }
@@ -463,6 +552,7 @@ export async function startHqdServer(opts) {
463
552
  sessions,
464
553
  close: () => (closing ??= new Promise((done) => {
465
554
  policyIndex?.close();
555
+ runtimeFlagMonitor?.close();
466
556
  hqMcp?.close();
467
557
  server.close(() => done());
468
558
  // Answered-but-idle clients would otherwise hold close() open.
@@ -16,6 +16,10 @@ export declare function packageUseUpdateRequestPath(prefix: string, stateDirecto
16
16
  declare function waitForUpdateRequest(requestPath: string, timeoutMs: number): void;
17
17
  export declare function acquirePackageUseLease(prefix: string, hqVersion: string, stateDirectory?: string): PackageUseLease;
18
18
  export declare function acquireCurrentPackageUseLease(): PackageUseLease | null;
19
+ /** True only when a valid update request belongs to a live updater process. */
20
+ export declare function packageUseUpdatePending(prefix: string, stateDirectory?: string): boolean;
21
+ /** True while the desktop updater owns the per-prefix update request. */
22
+ export declare function currentPackageUseUpdatePending(): boolean;
19
23
  /** True while the desktop updater owns the per-prefix update request. */
20
24
  export declare function currentPackageUseUpdateRequested(): boolean;
21
25
  export declare const __test__: {
@@ -197,6 +197,34 @@ export function acquireCurrentPackageUseLease() {
197
197
  }
198
198
  return acquirePackageUseLease(prefix, pkg.version);
199
199
  }
200
+ /** True only when a valid update request belongs to a live updater process. */
201
+ export function packageUseUpdatePending(prefix, stateDirectory) {
202
+ try {
203
+ waitForUpdateRequest(packageUseUpdateRequestPath(prefix, stateDirectory), 0);
204
+ return false;
205
+ }
206
+ catch (error) {
207
+ if (error instanceof PackageUseUpdatePendingError)
208
+ return true;
209
+ throw error;
210
+ }
211
+ }
212
+ /** True while the desktop updater owns the per-prefix update request. */
213
+ export function currentPackageUseUpdatePending() {
214
+ const pkg = findCliPackage(path.dirname(fileURLToPath(import.meta.url)));
215
+ if (!pkg || isPnpmGlobal(pkg.root) || isBunGlobal(pkg.root))
216
+ return false;
217
+ const prefix = npmPrefix(pkg.root);
218
+ if (!prefix)
219
+ return false;
220
+ if (process.platform !== "win32") {
221
+ const parts = pkg.root.replace(/\\/g, "/").replace(/\/+$/, "").split("/").filter(Boolean);
222
+ const nodeModules = parts.lastIndexOf("node_modules");
223
+ if (nodeModules > 0 && parts[nodeModules - 1] !== "lib")
224
+ return false;
225
+ }
226
+ return packageUseUpdatePending(prefix);
227
+ }
200
228
  /** True while the desktop updater owns the per-prefix update request. */
201
229
  export function currentPackageUseUpdateRequested() {
202
230
  const pkg = findCliPackage(path.dirname(fileURLToPath(import.meta.url)));
@@ -95,20 +95,19 @@ function identityLookupTimeoutMs() {
95
95
  /**
96
96
  * Best-effort peek of a 2xx JSON body for plan-limit status (US-016).
97
97
  *
98
- * For ok JSON responses: read the body ONCE, record planLimits (best-effort),
99
- * and return a NEW Response built from the buffered body so callers can still
100
- * call .json()/.text()/.arrayBuffer(). Non-JSON and non-ok responses are
101
- * returned untouched (streamed binary downloads must never be buffered).
98
+ * For ok JSON responses: inspect a clone, record planLimits (best-effort),
99
+ * cancel the unused original tee branch, and return a NEW Response built from
100
+ * the buffered body so callers can still call .json()/.text()/.arrayBuffer().
101
+ * Non-JSON and non-ok responses are returned untouched (streamed binary
102
+ * downloads must never be buffered).
102
103
  *
103
104
  * On inspection errors, return the original response by default to preserve
104
105
  * existing behavior. Secret-load callers opt into rethrowing body read errors
105
106
  * so the load path can classify a broken stream for offline-cache fallback.
106
107
  *
107
- * Why not response.clone()? Cloning tees the undici body stream; when a
108
- * caller never consumes the original Response body (many commands only check
109
- * response.ok/status), the unused tee branch keeps the connection/handle
110
- * referenced and the process (or vitest worker / spawned CLI child) never
111
- * exits on Linux CI. Reading once + re-wrapping fully drains the stream.
108
+ * Cloning tees the undici body stream. Cancel the original branch after the
109
+ * clone has been read so callers can use the buffered replacement without an
110
+ * unread tee branch keeping the connection/handle referenced.
112
111
  */
113
112
  async function peekPlanLimitStatus(response, options = {}) {
114
113
  let bufferedBody;
@@ -123,8 +122,9 @@ async function peekPlanLimitStatus(response, options = {}) {
123
122
  if (!ct.includes('application/json') && !ct.includes('+json')) {
124
123
  return response;
125
124
  }
126
- const buf = await response.arrayBuffer();
125
+ const buf = await response.clone().arrayBuffer();
127
126
  bufferedBody = buf;
127
+ await response.body?.cancel();
128
128
  try {
129
129
  const text = new TextDecoder().decode(buf);
130
130
  try {
@@ -147,8 +147,8 @@ async function peekPlanLimitStatus(response, options = {}) {
147
147
  }
148
148
  catch (err) {
149
149
  // Only secret-load callers need the body-read error for offline fallback.
150
- // Other endpoints retain the best-effort peek behavior only while the
151
- // original body is still readable.
150
+ // Other endpoints retain the best-effort peek behavior. Reading the clone
151
+ // leaves the original body available when inspection fails.
152
152
  if (bufferedBody !== undefined) {
153
153
  // Retry with safe metadata: statusText can itself make Response
154
154
  // construction throw after the original stream has already been drained.
@@ -34,6 +34,23 @@ When the flag is enabled, the global hook uses hqd for events outside the HQ
34
34
  root. Inside the root, the project hook routes through hqd when it is running
35
35
  and uses the direct master hook when it is stopped.
36
36
 
37
+ ## Flag revocation
38
+
39
+ While hqd is running, it refreshes `hq-anywhere-runtime` every 30 seconds.
40
+ Successful reads are never trusted for more than 60 seconds. This gives a
41
+ healthy flag service a 30-second revocation window while limiting reads to two
42
+ per minute per daemon; the five-second flag request timeout bounds the work
43
+ when the service is slow. Staleness uses elapsed monotonic time, so wall-clock
44
+ corrections cannot extend an ON decision.
45
+
46
+ When a read fails, hqd keeps the last successful value only until it is 60
47
+ seconds old. After that, it fails closed. An OFF decision refuses new daemon
48
+ login and hosted MCP calls with `anywhere_disabled` and clears any stdin machine
49
+ token held in memory. `auth.status` remains available and reports signed out.
50
+ An authentication still in progress when revocation arrives fails cleanly;
51
+ hosted MCP calls already forwarded are allowed to finish or return their normal
52
+ request error. The daemon process itself does not terminate.
53
+
37
54
  ## Link a repository
38
55
 
39
56
  Run `hq link <company>` from a repository to associate it with a company. The
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.341.1",
3
+ "version": "5.341.2",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {