@indigoai-us/hq-cli 5.341.1 → 5.341.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/assets/hqd/windows-pipe-host.ps1 +282 -0
  3. package/dist/bin/hq-auth-refresh.d.ts +1 -1
  4. package/dist/bin/hq-auth-refresh.js +11 -2
  5. package/dist/commands/billing-sponsor.js +20 -1
  6. package/dist/commands/core-derive-trigger-facts-native.d.ts +2 -0
  7. package/dist/commands/core-derive-trigger-facts-native.js +92 -25
  8. package/dist/commands/core-migrate-policy-triggers-native.d.ts +1 -0
  9. package/dist/commands/core-migrate-policy-triggers-native.js +26 -12
  10. package/dist/commands/doctor.js +2 -2
  11. package/dist/index.d.ts +2 -1
  12. package/dist/index.js +17 -1
  13. package/dist/lib/aws-sdk-node-warning-output.js +2 -3
  14. package/dist/lib/billing/plan-lock.js +19 -2
  15. package/dist/lib/bot/owner-context.d.ts +2 -0
  16. package/dist/lib/bot/owner-context.js +19 -1
  17. package/dist/lib/daemon/flag-monitor.d.ts +23 -0
  18. package/dist/lib/daemon/flag-monitor.js +99 -0
  19. package/dist/lib/daemon/flag.d.ts +2 -0
  20. package/dist/lib/daemon/flag.js +10 -1
  21. package/dist/lib/daemon/protocol.d.ts +26 -3
  22. package/dist/lib/daemon/protocol.js +9 -5
  23. package/dist/lib/daemon/run.js +14 -16
  24. package/dist/lib/daemon/server.d.ts +23 -2
  25. package/dist/lib/daemon/server.js +397 -52
  26. package/dist/lib/daemon/windows-pipe-host.d.ts +17 -0
  27. package/dist/lib/daemon/windows-pipe-host.js +218 -0
  28. package/dist/lib/diagnostics.d.ts +9 -0
  29. package/dist/lib/diagnostics.js +12 -0
  30. package/dist/lib/doctor/checks/anywhere.d.ts +6 -1
  31. package/dist/lib/doctor/checks/anywhere.js +131 -13
  32. package/dist/lib/doctor/json-output.d.ts +2 -0
  33. package/dist/lib/doctor/json-output.js +1 -0
  34. package/dist/lib/doctor/registry.js +1 -3
  35. package/dist/lib/doctor/types.d.ts +4 -0
  36. package/dist/utils/package-use-lease.d.ts +4 -0
  37. package/dist/utils/package-use-lease.js +28 -0
  38. package/dist/utils/run-bundled-script.d.ts +2 -0
  39. package/dist/utils/run-bundled-script.js +10 -1
  40. package/dist/utils/vault-api.js +12 -12
  41. package/docs/anywhere-runtime.md +49 -5
  42. package/package.json +2 -1
@@ -57,16 +57,24 @@ function isDirectory(fileSystem, value) {
57
57
  return false;
58
58
  }
59
59
  }
60
- function executableInPath(name, env, fileSystem) {
60
+ export function executableInPath(name, env, fileSystem, platform = process.platform) {
61
+ const extensions = platform === "win32"
62
+ // spawnSync("flock", ...) without shell support can launch native images,
63
+ // but not batch files. Treating .cmd/.bat as available skips the fallback
64
+ // lock while the later spawn fails on Windows.
65
+ ? (env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").filter((extension) => /^\.(?:COM|EXE)$/i.test(extension))
66
+ : [""];
61
67
  for (const directory of (env.PATH ?? "").split(path.delimiter)) {
62
68
  if (!directory)
63
69
  continue;
64
- try {
65
- const stat = fileSystem.statSync(path.join(directory, name));
66
- if (stat.isFile() && (stat.mode & 0o111) !== 0)
67
- return true;
70
+ for (const extension of extensions) {
71
+ try {
72
+ const stat = fileSystem.statSync(path.join(directory, `${name}${extension}`));
73
+ if (stat.isFile() && (platform === "win32" || (stat.mode & 0o111) !== 0))
74
+ return true;
75
+ }
76
+ catch { /* continue through PATH */ }
68
77
  }
69
- catch { /* continue through PATH */ }
70
78
  }
71
79
  return false;
72
80
  }
@@ -79,7 +87,9 @@ function runNonblockingFlock(lockFd, cwd, pathValue) {
79
87
  }
80
88
  function frontmatterValue(content, key) {
81
89
  let depth = 0;
82
- for (const line of content.split("\n")) {
90
+ for (const rawLine of content.split("\n")) {
91
+ // Git for Windows' awk reads CRLF as a line ending; POSIX awk keeps the CR.
92
+ const line = process.platform === "win32" ? rawLine.replace(/\r$/, "") : rawLine;
83
93
  if (line === "---") {
84
94
  depth += 1;
85
95
  continue;
@@ -154,10 +164,11 @@ function deriveWhen(trigger, rawTags, enforcementRaw) {
154
164
  function writeAtomic(fileSystem, target, bytes) {
155
165
  const temporary = path.join(path.dirname(target), `.migrate-policy-triggers.${process.pid}.${Math.random().toString(16).slice(2)}`);
156
166
  // The shell writes through mktemp, whose private mode survives the rename on both platforms.
157
- const mode = 0o600;
167
+ const mode = process.platform === "win32" ? undefined : 0o600;
158
168
  try {
159
- fileSystem.writeFileSync(temporary, bytes, { encoding: "utf8", mode, flag: "wx" });
160
- fileSystem.chmodSync(temporary, mode);
169
+ fileSystem.writeFileSync(temporary, bytes, { encoding: "utf8", ...(mode === undefined ? {} : { mode }), flag: "wx" });
170
+ if (mode !== undefined)
171
+ fileSystem.chmodSync(temporary, mode);
161
172
  fileSystem.renameSync(temporary, target);
162
173
  return true;
163
174
  }
@@ -447,7 +458,7 @@ export function runMigratePolicyTriggers(args, deps) {
447
458
  continue;
448
459
  }
449
460
  const anchor = original.split("\n").some((line) => line.startsWith("trigger:")) ? /^trigger:/ : /^id:/;
450
- const lines = original.split("\n");
461
+ const lines = original.split("\n").map((line) => process.platform === "win32" ? line.replace(/\r$/, "") : line);
451
462
  let inserted = false;
452
463
  const rewritten = lines.flatMap((line) => {
453
464
  if (!inserted && anchor.test(line)) {
@@ -471,7 +482,10 @@ export function runMigratePolicyTriggers(args, deps) {
471
482
  if (!dryRun && stateReady && !errorCode) {
472
483
  const stampTmp = path.join(path.dirname(stamp), `.last-success.${process.pid}.${Math.random().toString(16).slice(2)}`);
473
484
  try {
474
- fileSystem.writeFileSync(stampTmp, `${nowSeconds()}\n`, { mode: 0o600, flag: "wx" });
485
+ fileSystem.writeFileSync(stampTmp, `${nowSeconds()}\n`, {
486
+ ...(process.platform === "win32" ? {} : { mode: 0o600 }),
487
+ flag: "wx",
488
+ });
475
489
  fileSystem.renameSync(stampTmp, stamp);
476
490
  }
477
491
  catch {
@@ -37,7 +37,6 @@ import { isCompanyJournalSlug } from "../lib/doctor/checks/sync-health.js";
37
37
  import { readCompaniesManifestMap } from "../lib/work-context/repo-remote.js";
38
38
  import * as readline from "node:readline";
39
39
  import { runSideEffectTests } from "../lib/doctor/deep/sandbox.js";
40
- import { anywhereRuntimeEnabled } from "./anywhere-runtime-flag.js";
41
40
  /**
42
41
  * Marker directories that identify an HQ tree root. A directory is an HQ root
43
42
  * only when it contains ALL of these. `.claude` + `core` are present in both
@@ -91,7 +90,7 @@ export async function runDoctor(options = {}) {
91
90
  const write = options.stdout ?? ((chunk) => void process.stdout.write(chunk));
92
91
  const writeErr = options.stderr ?? ((chunk) => void process.stderr.write(chunk));
93
92
  const hqRoot = options.root ?? resolveHqRoot(options.cwd);
94
- const standaloneAnywhere = !hqRoot && options.only?.length === 1 && options.only[0] === "anywhere" && anywhereRuntimeEnabled();
93
+ const standaloneAnywhere = !hqRoot && options.only?.length === 1 && options.only[0] === "anywhere";
95
94
  if (!hqRoot && !standaloneAnywhere) {
96
95
  const from = options.cwd ?? process.cwd();
97
96
  writeErr(`hq doctor: not inside an HQ tree.\n` +
@@ -126,6 +125,7 @@ export async function runDoctor(options = {}) {
126
125
  // Anywhere checks inspect ~/.hq and are intentionally runnable from a foreign
127
126
  // repo. No other family is selected in this standalone path.
128
127
  hqRoot: hqRoot ?? "",
128
+ cwd: options.cwd ?? process.cwd(),
129
129
  platform: { id: platform.id, evidence: platform.evidence },
130
130
  sessionId: options.sessionId,
131
131
  liveRuntimes: options.liveRuntimes === true,
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
  });
@@ -1,4 +1,5 @@
1
1
  import { companyErrorsSdkWarningEnabled } from "./company-errors-sdk-warning-flag.js";
2
+ import { diagnosticsEnabled } from "./diagnostics.js";
2
3
  export const AWS_SDK_NODE_VERSION_WARNING_ENV = "AWS_SDK_JS_NODE_VERSION_SUPPORT_WARNING_DISABLED";
3
4
  /**
4
5
  * Configure the AWS SDK's documented, warning-specific switch before loading
@@ -10,9 +11,7 @@ export function configureAwsSdkNodeVersionWarningOutput(options = {}) {
10
11
  const enabled = options.enabled ?? companyErrorsSdkWarningEnabled();
11
12
  if (!enabled)
12
13
  return;
13
- const verbose = argv.includes("--verbose");
14
- const debug = /^(1|true|yes)$/i.test(env.HQ_DEBUG?.trim() ?? "");
15
- if (verbose || debug) {
14
+ if (diagnosticsEnabled(env, argv)) {
16
15
  delete env[AWS_SDK_NODE_VERSION_WARNING_ENV];
17
16
  }
18
17
  else {
@@ -21,6 +21,7 @@
21
21
  * missing data (hq-absent-field-never-means-constraining-value).
22
22
  */
23
23
  import chalk from "chalk";
24
+ import { diagnosticsEnabled } from "../diagnostics.js";
24
25
  import { vaultApiFetch } from "../../utils/vault-api.js";
25
26
  import { parseFreeMonth } from "./free-month.js";
26
27
  import { selectPlanLimitUpgradeUrl } from "./plan-limit-upgrade-url.js";
@@ -212,6 +213,16 @@ function statusFromUsage(companySlug, companyUid, body, upgradeCompanySlug) {
212
213
  checkedAt: new Date().toISOString(),
213
214
  };
214
215
  }
216
+ function isSilentPlanLimitBodyError(error) {
217
+ return (error instanceof Error &&
218
+ (error.name === "AbortError" || error.name === "TimeoutError"));
219
+ }
220
+ function notePlanLimitBodyUnreadable(error) {
221
+ if (isSilentPlanLimitBodyError(error) || !diagnosticsEnabled())
222
+ return;
223
+ const errorName = error instanceof Error ? error.name : "Error";
224
+ console.error(`plan-limit body unreadable errorName=${errorName}`);
225
+ }
215
226
  /**
216
227
  * Read the live nag for one company from usage-limits. `/membership/me` is
217
228
  * used only to resolve slug → companyUid. Returns null when the server did
@@ -226,7 +237,10 @@ export async function fetchPlanLimitStatus(token, companyRef, opts = {}) {
226
237
  });
227
238
  if (!res.ok)
228
239
  return null;
229
- const selected = selectCompanyMembership(await res.json().catch(() => null), companyRef);
240
+ const selected = selectCompanyMembership(await res.json().catch((error) => {
241
+ notePlanLimitBodyUnreadable(error);
242
+ return null;
243
+ }), companyRef);
230
244
  if (!selected?.companyUid)
231
245
  return null;
232
246
  try {
@@ -238,7 +252,10 @@ export async function fetchPlanLimitStatus(token, companyRef, opts = {}) {
238
252
  });
239
253
  if (!usage.ok)
240
254
  return null;
241
- const body = await usage.json().catch(() => null);
255
+ const body = await usage.json().catch((error) => {
256
+ notePlanLimitBodyUnreadable(error);
257
+ return null;
258
+ });
242
259
  if (!body)
243
260
  return null;
244
261
  const selectedSlug = selected.companySlug ??
@@ -67,6 +67,8 @@ export declare class OwnerContextCache {
67
67
  private value;
68
68
  private at;
69
69
  private pending;
70
+ /** True only after the wait ended before this lookup, so a later rejection is the hidden one. */
71
+ private waitEnded;
70
72
  constructor(lookup: () => Promise<OwnerContext>, nowMs?: () => number, waitMs?: number, ownerUid?: string);
71
73
  get(): Promise<OwnerContext>;
72
74
  }
@@ -13,6 +13,7 @@
13
13
  * slugs, uids, roles) reaches the model; the owner's token never does. A failed
14
14
  * lookup is reported as "could not check", never as "no companies".
15
15
  */
16
+ import { diagnosticsEnabled } from "../diagnostics.js";
16
17
  import { ensureCognitoToken } from "../../utils/cognito-session.js";
17
18
  import { resolveCallerPersonUid, vaultApiFetch } from "../../utils/vault-api.js";
18
19
  function message(err) {
@@ -85,6 +86,12 @@ export const OWNER_CONTEXT_TTL_MS = 5 * 60_000;
85
86
  export const OWNER_CONTEXT_RETRY_MS = 60_000;
86
87
  /** A turn never waits longer than this for a refresh; the lookup keeps going in the background. */
87
88
  const OWNER_CONTEXT_WAIT_MS = 8_000;
89
+ function noteOwnerContextLookupRejected(error) {
90
+ if (!diagnosticsEnabled())
91
+ return;
92
+ const errorName = error instanceof Error ? error.name : "Error";
93
+ console.error(`owner-context lookup failed errorName=${errorName}`);
94
+ }
88
95
  export class OwnerContextCache {
89
96
  lookup;
90
97
  nowMs;
@@ -93,6 +100,8 @@ export class OwnerContextCache {
93
100
  value = null;
94
101
  at = 0;
95
102
  pending = null;
103
+ /** True only after the wait ended before this lookup, so a later rejection is the hidden one. */
104
+ waitEnded = false;
96
105
  constructor(lookup, nowMs = Date.now, waitMs = OWNER_CONTEXT_WAIT_MS, ownerUid = "") {
97
106
  this.lookup = lookup;
98
107
  this.nowMs = nowMs;
@@ -104,6 +113,7 @@ export class OwnerContextCache {
104
113
  if (this.value && age < (this.value.status === "ok" ? OWNER_CONTEXT_TTL_MS : OWNER_CONTEXT_RETRY_MS))
105
114
  return this.value;
106
115
  if (!this.pending) {
116
+ this.waitEnded = false;
107
117
  this.pending = this.lookup()
108
118
  .then((v) => {
109
119
  this.value = v;
@@ -113,7 +123,14 @@ export class OwnerContextCache {
113
123
  .finally(() => {
114
124
  this.pending = null;
115
125
  });
116
- this.pending.catch(() => undefined);
126
+ // An in-time rejection is rethrown by the race below and leaves
127
+ // waitEnded false, so it is not logged. A rejection that lands after
128
+ // the wait already returned is otherwise invisible.
129
+ void this.pending.catch((error) => {
130
+ if (!this.waitEnded)
131
+ return;
132
+ noteOwnerContextLookupRejected(error);
133
+ });
117
134
  }
118
135
  let timer;
119
136
  const slow = new Promise((resolve) => {
@@ -123,6 +140,7 @@ export class OwnerContextCache {
123
140
  const got = await Promise.race([this.pending, slow]);
124
141
  if (got)
125
142
  return got;
143
+ this.waitEnded = true;
126
144
  }
127
145
  finally {
128
146
  if (timer)
@@ -0,0 +1,23 @@
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
+ status(): {
12
+ enabled: boolean;
13
+ lastSuccessfulReadAgeMs: number | null;
14
+ heldThroughReadError: boolean;
15
+ };
16
+ close(): void;
17
+ }
18
+ /**
19
+ * Refreshes a long-running daemon's flag independently of incoming requests.
20
+ * A failed read keeps the last successful decision only until its 60s age limit.
21
+ */
22
+ export declare function createBoundedDaemonFlagMonitor(options: BoundedDaemonFlagMonitorOptions): BoundedDaemonFlagMonitor;
23
+ //# sourceMappingURL=flag-monitor.d.ts.map
@@ -0,0 +1,99 @@
1
+ import { performance } from "node:perf_hooks";
2
+ export const HQ_ANYWHERE_FLAG_REFRESH_INTERVAL_MS = 30_000;
3
+ export const HQ_ANYWHERE_FLAG_MAX_STALENESS_MS = 60_000;
4
+ /**
5
+ * Refreshes a long-running daemon's flag independently of incoming requests.
6
+ * A failed read keeps the last successful decision only until its 60s age limit.
7
+ */
8
+ export function createBoundedDaemonFlagMonitor(options) {
9
+ const now = options.now ?? (() => performance.now());
10
+ let value;
11
+ let lastSuccessAt;
12
+ let lastAttemptAt;
13
+ let lastReadFailed = false;
14
+ let successVersion = 0;
15
+ let refreshInFlight;
16
+ let expiryTimer;
17
+ let closed = false;
18
+ const publish = (next) => {
19
+ const changed = value !== next;
20
+ value = next;
21
+ if (changed)
22
+ options.onChange?.(next);
23
+ };
24
+ const expireIfStale = () => {
25
+ if (!closed && lastSuccessAt !== undefined && now() - lastSuccessAt >= HQ_ANYWHERE_FLAG_MAX_STALENESS_MS) {
26
+ publish(false);
27
+ }
28
+ };
29
+ const refresh = () => {
30
+ if (closed)
31
+ return Promise.resolve(false);
32
+ if (refreshInFlight)
33
+ return refreshInFlight;
34
+ lastAttemptAt = now();
35
+ refreshInFlight = Promise.resolve().then(options.read).then((next) => {
36
+ if (closed)
37
+ return false;
38
+ lastSuccessAt = now();
39
+ lastReadFailed = false;
40
+ const thisSuccessVersion = ++successVersion;
41
+ publish(next);
42
+ if (expiryTimer)
43
+ clearTimeout(expiryTimer);
44
+ // Expire from the elapsed timer itself, not a wall-clock comparison.
45
+ expiryTimer = setTimeout(() => {
46
+ if (!closed && successVersion === thisSuccessVersion)
47
+ publish(false);
48
+ }, HQ_ANYWHERE_FLAG_MAX_STALENESS_MS);
49
+ expiryTimer.unref?.();
50
+ return next;
51
+ }).catch((error) => {
52
+ lastReadFailed = true;
53
+ options.onReadError?.(error);
54
+ expireIfStale();
55
+ return value ?? false;
56
+ }).finally(() => {
57
+ refreshInFlight = undefined;
58
+ });
59
+ return refreshInFlight;
60
+ };
61
+ const refreshTimer = setInterval(() => {
62
+ expireIfStale();
63
+ void refresh();
64
+ }, HQ_ANYWHERE_FLAG_REFRESH_INTERVAL_MS);
65
+ refreshTimer.unref?.();
66
+ void refresh();
67
+ return {
68
+ async enabled() {
69
+ expireIfStale();
70
+ if (lastSuccessAt !== undefined && now() - lastSuccessAt >= HQ_ANYWHERE_FLAG_MAX_STALENESS_MS) {
71
+ void refresh();
72
+ return false;
73
+ }
74
+ if (value === undefined)
75
+ return refresh();
76
+ if (lastAttemptAt === undefined || now() - lastAttemptAt >= HQ_ANYWHERE_FLAG_REFRESH_INTERVAL_MS) {
77
+ void refresh();
78
+ }
79
+ return value;
80
+ },
81
+ status() {
82
+ const age = lastSuccessAt === undefined ? null : Math.max(0, now() - lastSuccessAt);
83
+ const fresh = age !== null && age < HQ_ANYWHERE_FLAG_MAX_STALENESS_MS;
84
+ return {
85
+ enabled: value === true && fresh,
86
+ lastSuccessfulReadAgeMs: age,
87
+ heldThroughReadError: lastReadFailed && fresh,
88
+ };
89
+ },
90
+ close() {
91
+ closed = true;
92
+ if (refreshTimer)
93
+ clearInterval(refreshTimer);
94
+ if (expiryTimer)
95
+ clearTimeout(expiryTimer);
96
+ },
97
+ };
98
+ }
99
+ //# 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 {
@@ -7,8 +7,31 @@
7
7
  * out of order, so clients match them by `id`.
8
8
  */
9
9
  export declare const HQD_SOCKET_NAME = "hqd.sock";
10
- export declare const HQD_OPS: readonly ["ping", "resolve", "session.open", "session.get", "session.close", "policy.check", "policy.index", "journal.append", "memory.get", "memory.put", "mesh.presence", "mcp.call", "auth.login", "auth.status", "shutdown"];
10
+ export declare const HQD_OPS: readonly ["ping", "health", "resolve", "session.open", "session.get", "session.close", "policy.check", "policy.index", "journal.append", "memory.get", "memory.put", "mesh.presence", "mcp.call", "auth.login", "auth.status", "shutdown"];
11
11
  export type HqdOp = (typeof HQD_OPS)[number];
12
+ export type HqdHealthFallbackReason = "registry_unavailable" | "manifest_missing_or_malformed" | "company_not_in_manifest" | "no_match";
13
+ export interface HqdHealthResult {
14
+ reachable: boolean;
15
+ version: string | null;
16
+ uptimeMs: number | null;
17
+ flag: {
18
+ enabled: boolean | null;
19
+ lastSuccessfulReadAgeMs: number | null;
20
+ heldThroughReadError: boolean;
21
+ };
22
+ resolution: {
23
+ scope: "company" | "personal" | "unknown";
24
+ company: string | null;
25
+ fallbackReason: HqdHealthFallbackReason | null;
26
+ };
27
+ socket: {
28
+ path: string;
29
+ transport: "unix_socket" | "windows_named_pipe";
30
+ mode: string | null;
31
+ ownerMatchesUser: boolean | null;
32
+ userOnly: boolean | null;
33
+ };
34
+ }
12
35
  /** A request line larger than this is rejected and the connection closed. */
13
36
  export declare const HQD_MAX_LINE_BYTES: number;
14
37
  export declare const HQD_DEFAULT_REQUEST_TIMEOUT_MS = 2000;
@@ -31,10 +54,10 @@ export type HqdResponse = {
31
54
  error: HqdError;
32
55
  };
33
56
  /**
34
- * Socket path: HQ_HQD_SOCKET, else <HQ_REGISTRY_DIR or ~/.hq>/hqd.sock.
57
+ * Socket path: HQ_HQD_SOCKET, else <HQ_REGISTRY_DIR or ~/.hq>/daemon/hqd.sock.
35
58
  * On Windows the socket is a per-user named pipe.
36
59
  */
37
- export declare function hqdSocketPath(home?: string, env?: NodeJS.ProcessEnv, platform?: NodeJS.Platform): string;
60
+ export declare function hqdSocketPath(home?: string, env?: NodeJS.ProcessEnv, platform?: NodeJS.Platform, currentUserSid?: () => string | null): string;
38
61
  export declare function encodeMessage(message: HqdRequest | HqdResponse): string;
39
62
  export type ParsedLine = {
40
63
  ok: true;
@@ -9,9 +9,11 @@
9
9
  import * as net from "node:net";
10
10
  import * as os from "node:os";
11
11
  import * as path from "node:path";
12
+ import { readCurrentUserSid } from "../work-context/windows-acl.js";
12
13
  export const HQD_SOCKET_NAME = "hqd.sock";
13
14
  export const HQD_OPS = [
14
15
  "ping",
16
+ "health",
15
17
  "resolve",
16
18
  "session.open",
17
19
  "session.get",
@@ -31,19 +33,21 @@ export const HQD_OPS = [
31
33
  export const HQD_MAX_LINE_BYTES = 1024 * 1024;
32
34
  export const HQD_DEFAULT_REQUEST_TIMEOUT_MS = 2_000;
33
35
  /**
34
- * Socket path: HQ_HQD_SOCKET, else <HQ_REGISTRY_DIR or ~/.hq>/hqd.sock.
36
+ * Socket path: HQ_HQD_SOCKET, else <HQ_REGISTRY_DIR or ~/.hq>/daemon/hqd.sock.
35
37
  * On Windows the socket is a per-user named pipe.
36
38
  */
37
- export function hqdSocketPath(home = os.homedir(), env = process.env, platform = process.platform) {
39
+ export function hqdSocketPath(home = os.homedir(), env = process.env, platform = process.platform, currentUserSid = readCurrentUserSid) {
38
40
  const override = env.HQ_HQD_SOCKET?.trim();
39
41
  if (override)
40
42
  return override;
41
43
  if (platform === "win32") {
42
- const user = (env.USERNAME ?? os.userInfo().username).replace(/[^A-Za-z0-9_.-]/g, "-");
43
- return `\\\\.\\pipe\\hqd-${user}`;
44
+ const userSid = currentUserSid();
45
+ if (!userSid)
46
+ throw new Error("Cannot determine the current Windows user SID for the hqd pipe name");
47
+ return `\\\\.\\pipe\\hqd-${userSid.toLowerCase()}`;
44
48
  }
45
49
  const dir = env.HQ_REGISTRY_DIR?.trim() ? path.resolve(env.HQ_REGISTRY_DIR.trim()) : path.join(home, ".hq");
46
- return path.join(dir, HQD_SOCKET_NAME);
50
+ return path.join(dir, "daemon", HQD_SOCKET_NAME);
47
51
  }
48
52
  export function encodeMessage(message) {
49
53
  return `${JSON.stringify(message)}\n`;
@@ -693,24 +693,22 @@ export async function runDaemon(opts) {
693
693
  let hqdShutdown = null;
694
694
  if (anywhereRuntimeEnabled()) {
695
695
  const refusal = hqdStartupRefusal(platform, true);
696
- if (refusal) {
697
- log("error", refusal);
696
+ try {
697
+ const startConfig = readDaemonConfig(paths.configPath);
698
+ hqd = await startHqdServer({
699
+ socketPath: hqdSocketPath(home, env),
700
+ registryPath: registryFile(registryDir(home, env)),
701
+ hqRoot: startConfig.hqRoot ?? (env.HQ_ROOT?.trim() || undefined),
702
+ log,
703
+ onShutdown: () => hqdShutdown?.(),
704
+ reportError: reportHqdCrash,
705
+ });
698
706
  }
699
- else {
700
- try {
701
- const startConfig = readDaemonConfig(paths.configPath);
702
- hqd = await startHqdServer({
703
- socketPath: hqdSocketPath(home, env),
704
- registryPath: registryFile(registryDir(home, env)),
705
- hqRoot: startConfig.hqRoot ?? (env.HQ_ROOT?.trim() || undefined),
706
- log,
707
- onShutdown: () => hqdShutdown?.(),
708
- reportError: reportHqdCrash,
709
- });
710
- }
711
- catch (err) {
707
+ catch (err) {
708
+ if (refusal && err.message === refusal)
709
+ log("error", refusal);
710
+ else
712
711
  log("error", `hqd socket did not start; services run without it: ${err.message}`);
713
- }
714
712
  }
715
713
  }
716
714
  // US-012: 60s presence heartbeat with version and session count.
@@ -6,12 +6,26 @@
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 * as fs from "node:fs";
10
+ import { readRegistry, type ResolveResult } from "../registry/registry.js";
11
+ import { readCompaniesManifestMap } from "../work-context/repo-remote.js";
10
12
  import { SessionRegistry } from "./session-registry.js";
11
13
  import { HqMcpBridge, type RunWorkerRequest } from "./ops/mcp.js";
12
14
  import { type StdinMachineCredentials } from "./machine-login.js";
15
+ import { type WindowsPipeAclReport, type WindowsPipeHost } from "./windows-pipe-host.js";
13
16
  export interface HqdServerOptions {
14
17
  socketPath: string;
18
+ /** Test seam for selecting the Windows transport without changing host behavior. */
19
+ platform?: NodeJS.Platform;
20
+ /** Test seam for verifying socket ownership without changing the host uid. */
21
+ socketLstat?: (path: string) => fs.Stats;
22
+ /** Test seam for helper startup and its OS-read ACL report. */
23
+ windowsPipeHostFactory?: (socketPath: string) => Promise<{
24
+ host: WindowsPipeHost;
25
+ acl: WindowsPipeAclReport;
26
+ }>;
27
+ /** Test seam for rejecting an invalid helper ACL report. */
28
+ windowsPipeAclCheck?: (report: WindowsPipeAclReport) => WindowsPipeAclReport;
15
29
  /** registry.json to resolve against (US-004). */
16
30
  registryPath?: string;
17
31
  hqRoot?: string;
@@ -26,6 +40,8 @@ export interface HqdServerOptions {
26
40
  workMeshRoot?: string;
27
41
  /** Test seam for the hq-anywhere-runtime default-off gate. */
28
42
  mcpEnabled?: () => boolean | Promise<boolean>;
43
+ /** Test seam for the live hq-flags read used by the daemon's bounded runtime monitor. */
44
+ runtimeFlagReader?: () => Promise<boolean>;
29
45
  /** Test seam for the hosted Streamable HTTP MCP transport. */
30
46
  mcpBridge?: HqMcpBridge;
31
47
  /** Test seam for machine login while keeping production credentials off disk. */
@@ -43,6 +59,11 @@ export interface HqdServer {
43
59
  close(): Promise<void>;
44
60
  }
45
61
  /** Resolve cwd → company through the registry, cached until registry.json changes. */
46
- export declare function createResolver(registryPath: string): (cwd: string) => ResolveResult;
62
+ export declare function createResolver(registryPath: string, options?: {
63
+ hqRoot?: string;
64
+ log?: HqdServerOptions["log"];
65
+ readRegistry?: typeof readRegistry;
66
+ readCompaniesManifest?: typeof readCompaniesManifestMap;
67
+ }): (cwd: string) => ResolveResult;
47
68
  export declare function startHqdServer(opts: HqdServerOptions): Promise<HqdServer>;
48
69
  //# sourceMappingURL=server.d.ts.map