@indigoai-us/hq-cli 5.341.2 → 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 (33) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/assets/hqd/windows-pipe-host.ps1 +282 -0
  3. package/dist/commands/billing-sponsor.js +20 -1
  4. package/dist/commands/core-derive-trigger-facts-native.d.ts +2 -0
  5. package/dist/commands/core-derive-trigger-facts-native.js +92 -25
  6. package/dist/commands/core-migrate-policy-triggers-native.d.ts +1 -0
  7. package/dist/commands/core-migrate-policy-triggers-native.js +26 -12
  8. package/dist/commands/doctor.js +2 -2
  9. package/dist/lib/aws-sdk-node-warning-output.js +2 -3
  10. package/dist/lib/billing/plan-lock.js +19 -2
  11. package/dist/lib/bot/owner-context.d.ts +2 -0
  12. package/dist/lib/bot/owner-context.js +19 -1
  13. package/dist/lib/daemon/flag-monitor.d.ts +5 -0
  14. package/dist/lib/daemon/flag-monitor.js +14 -1
  15. package/dist/lib/daemon/protocol.d.ts +26 -3
  16. package/dist/lib/daemon/protocol.js +9 -5
  17. package/dist/lib/daemon/run.js +14 -16
  18. package/dist/lib/daemon/server.d.ts +13 -0
  19. package/dist/lib/daemon/server.js +283 -28
  20. package/dist/lib/daemon/windows-pipe-host.d.ts +17 -0
  21. package/dist/lib/daemon/windows-pipe-host.js +218 -0
  22. package/dist/lib/diagnostics.d.ts +9 -0
  23. package/dist/lib/diagnostics.js +12 -0
  24. package/dist/lib/doctor/checks/anywhere.d.ts +6 -1
  25. package/dist/lib/doctor/checks/anywhere.js +131 -13
  26. package/dist/lib/doctor/json-output.d.ts +2 -0
  27. package/dist/lib/doctor/json-output.js +1 -0
  28. package/dist/lib/doctor/registry.js +1 -3
  29. package/dist/lib/doctor/types.d.ts +4 -0
  30. package/dist/utils/run-bundled-script.d.ts +2 -0
  31. package/dist/utils/run-bundled-script.js +10 -1
  32. package/docs/anywhere-runtime.md +32 -5
  33. package/package.json +2 -1
@@ -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,
@@ -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)
@@ -8,6 +8,11 @@ export interface BoundedDaemonFlagMonitorOptions {
8
8
  }
9
9
  export interface BoundedDaemonFlagMonitor {
10
10
  enabled(): Promise<boolean>;
11
+ status(): {
12
+ enabled: boolean;
13
+ lastSuccessfulReadAgeMs: number | null;
14
+ heldThroughReadError: boolean;
15
+ };
11
16
  close(): void;
12
17
  }
13
18
  /**
@@ -1,3 +1,4 @@
1
+ import { performance } from "node:perf_hooks";
1
2
  export const HQ_ANYWHERE_FLAG_REFRESH_INTERVAL_MS = 30_000;
2
3
  export const HQ_ANYWHERE_FLAG_MAX_STALENESS_MS = 60_000;
3
4
  /**
@@ -5,10 +6,11 @@ export const HQ_ANYWHERE_FLAG_MAX_STALENESS_MS = 60_000;
5
6
  * A failed read keeps the last successful decision only until its 60s age limit.
6
7
  */
7
8
  export function createBoundedDaemonFlagMonitor(options) {
8
- const now = options.now ?? Date.now;
9
+ const now = options.now ?? (() => performance.now());
9
10
  let value;
10
11
  let lastSuccessAt;
11
12
  let lastAttemptAt;
13
+ let lastReadFailed = false;
12
14
  let successVersion = 0;
13
15
  let refreshInFlight;
14
16
  let expiryTimer;
@@ -34,6 +36,7 @@ export function createBoundedDaemonFlagMonitor(options) {
34
36
  if (closed)
35
37
  return false;
36
38
  lastSuccessAt = now();
39
+ lastReadFailed = false;
37
40
  const thisSuccessVersion = ++successVersion;
38
41
  publish(next);
39
42
  if (expiryTimer)
@@ -46,6 +49,7 @@ export function createBoundedDaemonFlagMonitor(options) {
46
49
  expiryTimer.unref?.();
47
50
  return next;
48
51
  }).catch((error) => {
52
+ lastReadFailed = true;
49
53
  options.onReadError?.(error);
50
54
  expireIfStale();
51
55
  return value ?? false;
@@ -74,6 +78,15 @@ export function createBoundedDaemonFlagMonitor(options) {
74
78
  }
75
79
  return value;
76
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
+ },
77
90
  close() {
78
91
  closed = true;
79
92
  if (refreshTimer)
@@ -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,13 +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 * as fs from "node:fs";
9
10
  import { readRegistry, type ResolveResult } from "../registry/registry.js";
10
11
  import { readCompaniesManifestMap } from "../work-context/repo-remote.js";
11
12
  import { SessionRegistry } from "./session-registry.js";
12
13
  import { HqMcpBridge, type RunWorkerRequest } from "./ops/mcp.js";
13
14
  import { type StdinMachineCredentials } from "./machine-login.js";
15
+ import { type WindowsPipeAclReport, type WindowsPipeHost } from "./windows-pipe-host.js";
14
16
  export interface HqdServerOptions {
15
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;
16
29
  /** registry.json to resolve against (US-004). */
17
30
  registryPath?: string;
18
31
  hqRoot?: string;