@indigoai-us/hq-cli 5.322.0 → 5.324.0

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 (90) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/assets/scaffold/core/scripts/derive-trigger-facts.sh +263 -0
  3. package/assets/scaffold/core/scripts/lib/transcript-tail.sh +98 -0
  4. package/assets/scaffold/core/scripts/lib/trigger-fact-text.awk +86 -0
  5. package/dist/bin/hq-auth-refresh.d.ts +5 -1
  6. package/dist/bin/hq-auth-refresh.js +33 -1
  7. package/dist/command-catalog.generated.d.ts +138 -6
  8. package/dist/command-catalog.generated.js +178 -6
  9. package/dist/command-registration-plan.d.ts +24 -0
  10. package/dist/command-registration-plan.js +4 -0
  11. package/dist/commands/anywhere-runtime-flag.d.ts +9 -0
  12. package/dist/commands/anywhere-runtime-flag.js +22 -0
  13. package/dist/commands/cloud.js +23 -4
  14. package/dist/commands/core-derive-trigger-facts-command.d.ts +3 -0
  15. package/dist/commands/core-derive-trigger-facts-command.js +47 -0
  16. package/dist/commands/core-derive-trigger-facts-native.d.ts +21 -0
  17. package/dist/commands/core-derive-trigger-facts-native.js +492 -0
  18. package/dist/commands/core.js +6 -0
  19. package/dist/commands/daemon.js +39 -2
  20. package/dist/commands/db-provision.js +6 -1
  21. package/dist/commands/doctor.js +8 -4
  22. package/dist/commands/feedback.d.ts +2 -0
  23. package/dist/commands/feedback.js +10 -0
  24. package/dist/commands/install-global.d.ts +46 -0
  25. package/dist/commands/install-global.js +674 -0
  26. package/dist/commands/link.d.ts +30 -0
  27. package/dist/commands/link.js +184 -0
  28. package/dist/commands/mcp-status.d.ts +6 -0
  29. package/dist/commands/mcp-status.js +21 -4
  30. package/dist/commands/pkg-install.js +20 -1
  31. package/dist/commands/update.d.ts +19 -0
  32. package/dist/commands/update.js +52 -6
  33. package/dist/fast-core.d.ts +2 -1
  34. package/dist/fast-core.js +12 -8
  35. package/dist/fast-scaffold.js +13 -0
  36. package/dist/index.js +9 -7
  37. package/dist/lib/cli-invite-nudge.d.ts +25 -0
  38. package/dist/lib/cli-invite-nudge.js +152 -0
  39. package/dist/lib/core-utils/timeout-guard-native-flags.d.ts +6 -0
  40. package/dist/lib/core-utils/timeout-guard-native-flags.js +30 -0
  41. package/dist/lib/core-utils/timeout-guard-native.d.ts +0 -3
  42. package/dist/lib/core-utils/timeout-guard-native.js +4 -30
  43. package/dist/lib/core-utils/timeout-guard.d.ts +2 -0
  44. package/dist/lib/core-utils/timeout-guard.js +2 -1
  45. package/dist/lib/daemon/convergence.d.ts +42 -0
  46. package/dist/lib/daemon/convergence.js +66 -0
  47. package/dist/lib/daemon/heartbeat.d.ts +43 -0
  48. package/dist/lib/daemon/heartbeat.js +70 -0
  49. package/dist/lib/daemon/ops/journal.d.ts +29 -0
  50. package/dist/lib/daemon/ops/journal.js +141 -0
  51. package/dist/lib/daemon/ops/memory.d.ts +20 -0
  52. package/dist/lib/daemon/ops/memory.js +65 -0
  53. package/dist/lib/daemon/ops/mesh.d.ts +38 -0
  54. package/dist/lib/daemon/ops/mesh.js +70 -0
  55. package/dist/lib/daemon/ops/policy.d.ts +78 -0
  56. package/dist/lib/daemon/ops/policy.js +283 -0
  57. package/dist/lib/daemon/protocol.d.ts +68 -0
  58. package/dist/lib/daemon/protocol.js +154 -0
  59. package/dist/lib/daemon/run.d.ts +10 -0
  60. package/dist/lib/daemon/run.js +85 -3
  61. package/dist/lib/daemon/server.d.ts +36 -0
  62. package/dist/lib/daemon/server.js +306 -0
  63. package/dist/lib/daemon/session-registry.d.ts +30 -0
  64. package/dist/lib/daemon/session-registry.js +42 -0
  65. package/dist/lib/doctor/checks/anywhere.d.ts +38 -0
  66. package/dist/lib/doctor/checks/anywhere.js +182 -0
  67. package/dist/lib/doctor/registry.js +6 -0
  68. package/dist/lib/first-sync-next-step.d.ts +10 -1
  69. package/dist/lib/flag-registry-worker-source.generated.d.ts +1 -1
  70. package/dist/lib/flag-registry-worker-source.generated.js +1 -1
  71. package/dist/lib/install/claude-targets.d.ts +47 -0
  72. package/dist/lib/install/claude-targets.js +203 -0
  73. package/dist/lib/install/codex-targets.d.ts +57 -0
  74. package/dist/lib/install/codex-targets.js +187 -0
  75. package/dist/lib/mesh/live/session-seq.js +62 -9
  76. package/dist/lib/registry/normalize-remote.d.ts +21 -0
  77. package/dist/lib/registry/normalize-remote.js +68 -0
  78. package/dist/lib/registry/registry.d.ts +75 -0
  79. package/dist/lib/registry/registry.js +261 -0
  80. package/dist/main.js +3 -1
  81. package/dist/utils/cli-telemetry.d.ts +11 -0
  82. package/dist/utils/cli-telemetry.js +76 -0
  83. package/dist/utils/cognito-session.d.ts +5 -1
  84. package/dist/utils/cognito-session.js +8 -12
  85. package/dist/utils/feedback-diagnostics.d.ts +2 -0
  86. package/dist/utils/run-bundled-script.d.ts +7 -4
  87. package/dist/utils/run-bundled-script.js +47 -12
  88. package/dist/utils/version-check.d.ts +2 -0
  89. package/dist/utils/version-check.js +7 -0
  90. package/package.json +2 -2
@@ -0,0 +1,154 @@
1
+ /**
2
+ * hqd wire protocol (hq-anywhere US-008).
3
+ *
4
+ * One Unix domain socket (a named pipe on Windows) carrying newline-delimited
5
+ * JSON. Requests are `{id, op, args}`; each gets exactly one response
6
+ * `{id, ok: true, result}` or `{id, ok: false, error}`. Responses may arrive
7
+ * out of order, so clients match them by `id`.
8
+ */
9
+ import * as net from "node:net";
10
+ import * as os from "node:os";
11
+ import * as path from "node:path";
12
+ export const HQD_SOCKET_NAME = "hqd.sock";
13
+ export const HQD_OPS = [
14
+ "ping",
15
+ "resolve",
16
+ "session.open",
17
+ "session.get",
18
+ "session.close",
19
+ "policy.check",
20
+ "policy.index",
21
+ "journal.append",
22
+ "memory.get",
23
+ "memory.put",
24
+ "mesh.presence",
25
+ "shutdown",
26
+ ];
27
+ /** A request line larger than this is rejected and the connection closed. */
28
+ export const HQD_MAX_LINE_BYTES = 1024 * 1024;
29
+ /**
30
+ * Socket path: HQ_HQD_SOCKET, else <HQ_REGISTRY_DIR or ~/.hq>/hqd.sock.
31
+ * On Windows the socket is a per-user named pipe.
32
+ */
33
+ export function hqdSocketPath(home = os.homedir(), env = process.env, platform = process.platform) {
34
+ const override = env.HQ_HQD_SOCKET?.trim();
35
+ if (override)
36
+ return override;
37
+ if (platform === "win32") {
38
+ const user = (env.USERNAME ?? os.userInfo().username).replace(/[^A-Za-z0-9_.-]/g, "-");
39
+ return `\\\\.\\pipe\\hqd-${user}`;
40
+ }
41
+ const dir = env.HQ_REGISTRY_DIR?.trim() ? path.resolve(env.HQ_REGISTRY_DIR.trim()) : path.join(home, ".hq");
42
+ return path.join(dir, HQD_SOCKET_NAME);
43
+ }
44
+ export function encodeMessage(message) {
45
+ return `${JSON.stringify(message)}\n`;
46
+ }
47
+ /** Validate one request line. Never throws. */
48
+ export function parseRequestLine(line) {
49
+ let raw;
50
+ try {
51
+ raw = JSON.parse(line);
52
+ }
53
+ catch {
54
+ return { ok: false, id: null, error: { code: "bad_json", message: "request is not valid JSON" } };
55
+ }
56
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
57
+ return { ok: false, id: null, error: { code: "bad_request", message: "request must be a JSON object" } };
58
+ }
59
+ const obj = raw;
60
+ const id = typeof obj.id === "string" || typeof obj.id === "number" ? obj.id : null;
61
+ if (id === null)
62
+ return { ok: false, id: null, error: { code: "bad_request", message: "request needs a string or number id" } };
63
+ if (typeof obj.op !== "string" || !obj.op)
64
+ return { ok: false, id, error: { code: "bad_request", message: "request needs an op" } };
65
+ if (obj.args !== undefined && (obj.args === null || typeof obj.args !== "object" || Array.isArray(obj.args))) {
66
+ return { ok: false, id, error: { code: "bad_request", message: "args must be an object" } };
67
+ }
68
+ return { ok: true, request: { id, op: obj.op, args: obj.args ?? {} } };
69
+ }
70
+ /**
71
+ * Split a byte stream into lines. Returns a push function that hands each
72
+ * complete line to `onLine`, and reports overflow when a partial line grows
73
+ * past `maxBytes`.
74
+ */
75
+ export function createLineSplitter(onLine, onOverflow, maxBytes = HQD_MAX_LINE_BYTES) {
76
+ let buffer = "";
77
+ let overflowed = false;
78
+ return (chunk) => {
79
+ if (overflowed)
80
+ return;
81
+ buffer += typeof chunk === "string" ? chunk : chunk.toString("utf8");
82
+ let nl = buffer.indexOf("\n");
83
+ while (nl !== -1) {
84
+ const line = buffer.slice(0, nl).replace(/\r$/, "");
85
+ buffer = buffer.slice(nl + 1);
86
+ if (Buffer.byteLength(line, "utf8") > maxBytes) {
87
+ overflowed = true;
88
+ buffer = "";
89
+ onOverflow();
90
+ return;
91
+ }
92
+ if (line.trim())
93
+ onLine(line);
94
+ nl = buffer.indexOf("\n");
95
+ }
96
+ if (Buffer.byteLength(buffer, "utf8") > maxBytes) {
97
+ overflowed = true;
98
+ buffer = "";
99
+ onOverflow();
100
+ }
101
+ };
102
+ }
103
+ export class HqdClientError extends Error {
104
+ code;
105
+ constructor(code, message) {
106
+ super(message);
107
+ this.code = code;
108
+ this.name = "HqdClientError";
109
+ }
110
+ }
111
+ let nextClientId = 0;
112
+ /**
113
+ * Send one request and wait for its response. A daemon that is not running
114
+ * fails fast with the socket error code (ENOENT or ECONNREFUSED).
115
+ */
116
+ export function hqdRequest(op, args = {}, opts = {}) {
117
+ const socketPath = opts.socketPath ?? hqdSocketPath();
118
+ const timeoutMs = opts.timeoutMs ?? 2_000;
119
+ const id = `c${process.pid}-${(nextClientId += 1)}`;
120
+ return new Promise((resolve, reject) => {
121
+ const socket = net.createConnection(socketPath);
122
+ let settled = false;
123
+ const finish = (fn) => {
124
+ if (settled)
125
+ return;
126
+ settled = true;
127
+ clearTimeout(timer);
128
+ socket.destroy();
129
+ fn();
130
+ };
131
+ const timer = setTimeout(() => finish(() => reject(new HqdClientError("ETIMEDOUT", `hqd did not answer ${op} within ${timeoutMs}ms`))), timeoutMs);
132
+ const push = createLineSplitter((line) => {
133
+ let msg;
134
+ try {
135
+ msg = JSON.parse(line);
136
+ }
137
+ catch {
138
+ finish(() => reject(new HqdClientError("bad_response", "hqd sent a line that is not JSON")));
139
+ return;
140
+ }
141
+ if (msg.id !== id)
142
+ return;
143
+ if (msg.ok)
144
+ finish(() => resolve(msg.result));
145
+ else
146
+ finish(() => reject(new HqdClientError(msg.error.code, msg.error.message)));
147
+ }, () => finish(() => reject(new HqdClientError("bad_response", "hqd response too large"))));
148
+ socket.on("connect", () => socket.write(encodeMessage({ id, op, args })));
149
+ socket.on("data", push);
150
+ socket.on("error", (err) => finish(() => reject(new HqdClientError(err.code ?? "ESOCKET", `hqd is not reachable at ${socketPath}: ${err.message}`))));
151
+ socket.on("close", () => finish(() => reject(new HqdClientError("ECONNRESET", "hqd closed the connection before answering"))));
152
+ });
153
+ }
154
+ //# sourceMappingURL=protocol.js.map
@@ -7,11 +7,21 @@
7
7
  */
8
8
  import { type UnitPlan } from "./catalog.js";
9
9
  import { type LegacyUnit } from "./legacy.js";
10
+ import { type DaemonLockResult } from "./lock.js";
10
11
  import { type MachineType } from "./machine-type.js";
11
12
  import { type HostApp } from "./supervisor.js";
12
13
  export declare const LOG_ROTATE_BYTES: number;
13
14
  /** Exit code for "restart me": non-zero so every service manager relaunches (Windows tasks only restart on failure). */
14
15
  export declare const RESTART_EXIT_CODE = 75;
16
+ export interface DaemonLockConflictOutcome {
17
+ exitCode: 0 | 1;
18
+ stdout?: string;
19
+ stderr?: string;
20
+ }
21
+ /** Preserve the legacy lock failure unless Anywhere explicitly enables the no-op behavior. */
22
+ export declare function daemonLockConflictOutcome(lock: DaemonLockResult, anywhereEnabled: boolean): DaemonLockConflictOutcome | null;
23
+ /** Node's Windows named-pipe API uses a default DACL that is not user-scoped. */
24
+ export declare function hqdStartupRefusal(platform: NodeJS.Platform, anywhereEnabled: boolean): string | null;
15
25
  /** Route an updater request through the daemon's existing graceful handoff. */
16
26
  export declare function handoffForPackageUpdateRequest(requested: boolean, restartDaemon: () => void): void;
17
27
  export interface RunDaemonOptions {
@@ -12,6 +12,7 @@ import * as os from "node:os";
12
12
  import * as path from "node:path";
13
13
  import { loadCachedTokens } from "@indigoai-us/hq-cloud";
14
14
  import { CLI_VERSION } from "../../cli-version.js";
15
+ import { anywhereRuntimeEnabled } from "../../commands/anywhere-runtime-flag.js";
15
16
  import { currentPackageUseUpdateRequested } from "../../utils/package-use-lease.js";
16
17
  import { isLocalDependencyInstall, resolveRunningInstall } from "../../utils/version-gate.js";
17
18
  import { agentKitPaths } from "../agent-kit/paths.js";
@@ -26,9 +27,13 @@ import { acquireDaemonLock, isProcessAlive, processCommandLine, releaseDaemonLoc
26
27
  import { retireLegacyMeshUnits } from "./migrate.js";
27
28
  import { daemonDir as meshDaemonDir, daemonPidPath as meshDaemonPidPath } from "../mesh/live/daemon/paths.js";
28
29
  import { readPidLock } from "../mesh/live/daemon/pid-lock.js";
30
+ import { registryDir, registryFile } from "../registry/registry.js";
29
31
  import { daemonRootRefusal } from "./install.js";
30
32
  import { detectMachineType } from "./machine-type.js";
31
33
  import { appendPrivate, daemonPaths, ensurePrivateDir, openPrivateAppend } from "./paths.js";
34
+ import { hqdSocketPath } from "./protocol.js";
35
+ import { startHqdServer } from "./server.js";
36
+ import { heartbeatFile, startHeartbeat } from "./heartbeat.js";
32
37
  import { createProtocolTail, ensureProtocolFifo, openProtocolWriter } from "./protocol-tail.js";
33
38
  import { takeControlRequests } from "./requests.js";
34
39
  import { currentHqCloud, hqCloudRunners } from "./hq-cloud.js";
@@ -40,6 +45,26 @@ import { userToolDirs } from "./tools.js";
40
45
  export const LOG_ROTATE_BYTES = 5 * 1024 * 1024;
41
46
  /** Exit code for "restart me": non-zero so every service manager relaunches (Windows tasks only restart on failure). */
42
47
  export const RESTART_EXIT_CODE = 75;
48
+ /** Preserve the legacy lock failure unless Anywhere explicitly enables the no-op behavior. */
49
+ export function daemonLockConflictOutcome(lock, anywhereEnabled) {
50
+ if (lock.ok)
51
+ return null;
52
+ if (lock.owner && anywhereEnabled) {
53
+ return { exitCode: 0, stdout: `hq daemon is already running (pid ${lock.owner.pid}).\n` };
54
+ }
55
+ return {
56
+ exitCode: 1,
57
+ stderr: lock.owner
58
+ ? `hq daemon is already running (pid ${lock.owner.pid}).\n`
59
+ : "another hq daemon is starting up.\n",
60
+ };
61
+ }
62
+ /** Node's Windows named-pipe API uses a default DACL that is not user-scoped. */
63
+ export function hqdStartupRefusal(platform, anywhereEnabled) {
64
+ if (platform !== "win32" || !anywhereEnabled)
65
+ return null;
66
+ return "hqd is disabled on Windows because the default named-pipe ACL grants read access to Everyone and Anonymous accounts.";
67
+ }
43
68
  /** Route an updater request through the daemon's existing graceful handoff. */
44
69
  export function handoffForPackageUpdateRequest(requested, restartDaemon) {
45
70
  if (requested)
@@ -280,9 +305,13 @@ export async function runDaemon(opts) {
280
305
  const paths = daemonPaths(home, env);
281
306
  const log = makeLogger(paths, opts.foreground === true);
282
307
  const lock = acquireDaemonLock(paths.lockPath);
283
- if (!lock.ok) {
284
- process.stderr.write(lock.owner ? `hq daemon is already running (pid ${lock.owner.pid}).\n` : "another hq daemon is starting up.\n");
285
- return 1;
308
+ const lockConflict = daemonLockConflictOutcome(lock, !lock.ok && lock.owner ? anywhereRuntimeEnabled(env) : false);
309
+ if (lockConflict) {
310
+ if (lockConflict.stdout)
311
+ process.stdout.write(lockConflict.stdout);
312
+ if (lockConflict.stderr)
313
+ process.stderr.write(lockConflict.stderr);
314
+ return lockConflict.exitCode;
286
315
  }
287
316
  const initial = readDaemonConfig(paths.configPath);
288
317
  const detected = detectMachineType(undefined, {
@@ -659,6 +688,45 @@ export async function runDaemon(opts) {
659
688
  clearTimeout(timer);
660
689
  return on;
661
690
  };
691
+ // hqd: the session/company socket every runtime talks to (hq-anywhere US-008).
692
+ let hqd = null;
693
+ let hqdShutdown = null;
694
+ if (anywhereRuntimeEnabled()) {
695
+ const refusal = hqdStartupRefusal(platform, true);
696
+ if (refusal) {
697
+ log("error", refusal);
698
+ }
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) {
712
+ log("error", `hqd socket did not start; services run without it: ${err.message}`);
713
+ }
714
+ }
715
+ }
716
+ // US-012: 60s presence heartbeat with version and session count.
717
+ const liveHqd = hqd;
718
+ const stopHeartbeat = liveHqd
719
+ ? startHeartbeat({
720
+ version: CLI_VERSION,
721
+ sessionCount: () => liveHqd.sessions.size,
722
+ file: heartbeatFile(home, env),
723
+ log,
724
+ })
725
+ : () => { };
726
+ const closeHqd = () => {
727
+ stopHeartbeat();
728
+ void hqd?.close().catch((err) => log("warn", `hqd socket did not close cleanly: ${err.message}`));
729
+ };
662
730
  return await new Promise((resolve) => {
663
731
  let stopping = false;
664
732
  let restartPending = false;
@@ -667,6 +735,7 @@ export async function runDaemon(opts) {
667
735
  return;
668
736
  stopping = true;
669
737
  log("info", `${why}; stopping services`);
738
+ closeHqd();
670
739
  // The supervisor escalates to SIGKILL after STOP_ESCALATION_MS; the extra
671
740
  // margin only covers a child that survives even that.
672
741
  let capTimer;
@@ -691,6 +760,7 @@ export async function runDaemon(opts) {
691
760
  if (stopping)
692
761
  return;
693
762
  stopping = true;
763
+ closeHqd();
694
764
  const released = supervisor.release();
695
765
  // What these readers have not read stays in the FIFO for the next daemon.
696
766
  for (const tail of protocolTails)
@@ -748,6 +818,7 @@ export async function runDaemon(opts) {
748
818
  return true;
749
819
  });
750
820
  };
821
+ hqdShutdown = () => shutdown("hqd shutdown request");
751
822
  process.on("SIGTERM", () => shutdown("received SIGTERM"));
752
823
  process.on("SIGINT", () => shutdown("received SIGINT"));
753
824
  if (platform !== "win32")
@@ -757,9 +828,20 @@ export async function runDaemon(opts) {
757
828
  }
758
829
  catch (err) {
759
830
  log("error", `supervisor failed to start: ${err.message}`);
831
+ closeHqd();
760
832
  releaseDaemonLock(paths.lockPath);
761
833
  resolve(1);
762
834
  }
763
835
  });
764
836
  }
837
+ /** Report an hqd op crash to the CLI's existing Sentry integration, tagged with the failing op. */
838
+ function reportHqdCrash(op, err) {
839
+ void import("../../sentry.js").then(({ Sentry }) => {
840
+ Sentry.withScope((scope) => {
841
+ scope.setTag("component", "hqd");
842
+ scope.setTag("hqd.op", op);
843
+ Sentry.captureException(err);
844
+ });
845
+ }, (importErr) => process.stderr.write(`hqd could not load Sentry to report a ${op} failure: ${String(importErr)}\n`));
846
+ }
765
847
  //# sourceMappingURL=run.js.map
@@ -0,0 +1,36 @@
1
+ /**
2
+ * hqd socket server (hq-anywhere US-008).
3
+ *
4
+ * Hosted inside the `hq daemon run` supervisor, which already runs the mesh
5
+ * drainer as one of its services, so there is still one resident process per
6
+ * machine. Sessions in any repo ask it which company they belong to and
7
+ * register themselves here.
8
+ */
9
+ import { type ResolveResult } from "../registry/registry.js";
10
+ import { SessionRegistry } from "./session-registry.js";
11
+ export interface HqdServerOptions {
12
+ socketPath: string;
13
+ /** registry.json to resolve against (US-004). */
14
+ registryPath?: string;
15
+ hqRoot?: string;
16
+ version?: string;
17
+ now?: () => Date;
18
+ /** Called after a `shutdown` request has been answered. */
19
+ onShutdown?: () => void;
20
+ log?: (level: "info" | "warn" | "error", message: string) => void;
21
+ /** Environment for policy scripts and the mesh spool (tests sandbox HOME here). */
22
+ env?: NodeJS.ProcessEnv;
23
+ /** Work-mesh root override for mesh.presence (tests). */
24
+ workMeshRoot?: string;
25
+ /** Crash reporter for internal op failures (US-012); run.ts wires Sentry here. */
26
+ reportError?: (op: string, err: unknown) => void;
27
+ }
28
+ export interface HqdServer {
29
+ socketPath: string;
30
+ sessions: SessionRegistry;
31
+ close(): Promise<void>;
32
+ }
33
+ /** Resolve cwd → company through the registry, cached until registry.json changes. */
34
+ export declare function createResolver(registryPath: string): (cwd: string) => ResolveResult;
35
+ export declare function startHqdServer(opts: HqdServerOptions): Promise<HqdServer>;
36
+ //# sourceMappingURL=server.d.ts.map
@@ -0,0 +1,306 @@
1
+ /**
2
+ * hqd socket server (hq-anywhere US-008).
3
+ *
4
+ * Hosted inside the `hq daemon run` supervisor, which already runs the mesh
5
+ * drainer as one of its services, so there is still one resident process per
6
+ * machine. Sessions in any repo ask it which company they belong to and
7
+ * register themselves here.
8
+ */
9
+ import * as fs from "node:fs";
10
+ import * as net from "node:net";
11
+ import * as path from "node:path";
12
+ import { CLI_VERSION } from "../../cli-version.js";
13
+ import { canonicalDir, readRegistry, registryFile, resolveCompanyForDir } from "../registry/registry.js";
14
+ import { createLineSplitter, encodeMessage, parseRequestLine } from "./protocol.js";
15
+ import { SessionRegistry } from "./session-registry.js";
16
+ import { journalAppend, JournalOpError } from "./ops/journal.js";
17
+ import { memoryGet, memoryPut, MemoryOpError } from "./ops/memory.js";
18
+ import { meshPresence, MeshOpError } from "./ops/mesh.js";
19
+ import { PolicyIndex, PolicyOpError, policyCheck } from "./ops/policy.js";
20
+ class OpError extends Error {
21
+ code;
22
+ constructor(code, message) {
23
+ super(message);
24
+ this.code = code;
25
+ }
26
+ }
27
+ function stringArg(args, name, required = true) {
28
+ const value = args[name];
29
+ if (typeof value === "string" && value.trim())
30
+ return value;
31
+ if (value === undefined && !required)
32
+ return undefined;
33
+ throw new OpError("bad_args", `${name} must be a non-empty string`);
34
+ }
35
+ /** Resolve cwd → company through the registry, cached until registry.json changes. */
36
+ export function createResolver(registryPath) {
37
+ const cache = new Map();
38
+ let stamp = "";
39
+ return (cwd) => {
40
+ let current = "missing";
41
+ try {
42
+ const st = fs.statSync(registryPath);
43
+ current = `${st.mtimeMs}:${st.size}:${st.ino}`;
44
+ }
45
+ catch (err) {
46
+ if (err.code !== "ENOENT")
47
+ throw err;
48
+ }
49
+ if (current !== stamp) {
50
+ cache.clear();
51
+ stamp = current;
52
+ }
53
+ const key = canonicalDir(cwd);
54
+ const hit = cache.get(key);
55
+ if (hit)
56
+ return hit;
57
+ const result = resolveCompanyForDir(readRegistry(registryPath), key);
58
+ cache.set(key, result);
59
+ return result;
60
+ };
61
+ }
62
+ /** Is something answering on this socket? */
63
+ function socketIsLive(socketPath) {
64
+ return new Promise((resolve) => {
65
+ const probe = net.createConnection(socketPath);
66
+ const done = (live) => {
67
+ probe.destroy();
68
+ resolve(live);
69
+ };
70
+ probe.once("connect", () => done(true));
71
+ probe.once("error", () => done(false));
72
+ setTimeout(() => done(false), 500).unref();
73
+ });
74
+ }
75
+ export async function startHqdServer(opts) {
76
+ const { socketPath } = opts;
77
+ const isPipe = process.platform === "win32";
78
+ const startedAtMs = (opts.now ?? (() => new Date()))().getTime();
79
+ const sessions = new SessionRegistry(opts.now);
80
+ const resolve = createResolver(opts.registryPath ?? registryFile());
81
+ const log = opts.log ?? (() => { });
82
+ const clock = opts.now ?? (() => new Date());
83
+ const env = opts.env ?? process.env;
84
+ const policyIndex = opts.hqRoot ? new PolicyIndex(opts.hqRoot, log) : null;
85
+ const requireRoot = () => {
86
+ if (!opts.hqRoot)
87
+ throw new OpError("no_hq_root", "hqd has no HQ root configured (set hqRoot in daemon config or HQ_ROOT)");
88
+ return opts.hqRoot;
89
+ };
90
+ /** Company for a call: explicit arg, else the open session's, else the registry's answer for cwd. */
91
+ const companyFor = (args) => {
92
+ const explicit = stringArg(args, "company", false);
93
+ if (explicit)
94
+ return explicit;
95
+ const runtime = stringArg(args, "runtime", false);
96
+ const sessionId = stringArg(args, "sessionId", false);
97
+ const session = runtime && sessionId ? sessions.get(runtime, sessionId) : null;
98
+ if (session?.company)
99
+ return session.company;
100
+ const cwd = stringArg(args, "cwd", false);
101
+ return cwd ? (resolve(cwd).company ?? undefined) : undefined;
102
+ };
103
+ if (!isPipe) {
104
+ fs.mkdirSync(path.dirname(socketPath), { recursive: true, mode: 0o700 });
105
+ if (fs.existsSync(socketPath)) {
106
+ if (await socketIsLive(socketPath))
107
+ throw new Error(`hqd is already listening on ${socketPath}`);
108
+ fs.unlinkSync(socketPath);
109
+ }
110
+ }
111
+ /** Synchronous ops answer in request order; only policy.check returns a promise. */
112
+ const handle = (req) => {
113
+ const args = req.args ?? {};
114
+ switch (req.op) {
115
+ case "ping":
116
+ return {
117
+ version: opts.version ?? CLI_VERSION,
118
+ hqRoot: opts.hqRoot ?? null,
119
+ uptimeMs: clock().getTime() - startedAtMs,
120
+ pid: process.pid,
121
+ sessions: sessions.size,
122
+ };
123
+ case "resolve": {
124
+ const cwd = stringArg(args, "cwd");
125
+ return resolve(cwd);
126
+ }
127
+ case "session.open": {
128
+ const runtime = stringArg(args, "runtime");
129
+ const sessionId = stringArg(args, "sessionId");
130
+ const cwd = stringArg(args, "cwd");
131
+ const explicit = stringArg(args, "company", false);
132
+ const company = explicit ?? resolve(cwd).company;
133
+ return sessions.open({ runtime, sessionId, cwd: canonicalDir(cwd), company });
134
+ }
135
+ case "session.get": {
136
+ const runtime = stringArg(args, "runtime", false);
137
+ const sessionId = stringArg(args, "sessionId", false);
138
+ if (!sessionId) {
139
+ const all = sessions.list();
140
+ return { sessions: runtime ? all.filter((s) => s.runtime === runtime) : all };
141
+ }
142
+ if (!runtime)
143
+ throw new OpError("bad_args", "runtime is required with sessionId");
144
+ return { session: sessions.get(runtime, sessionId) };
145
+ }
146
+ case "session.close": {
147
+ const runtime = stringArg(args, "runtime");
148
+ const sessionId = stringArg(args, "sessionId");
149
+ return { closed: sessions.close(runtime, sessionId) };
150
+ }
151
+ case "policy.check": {
152
+ const hqRoot = requireRoot();
153
+ const company = companyFor(args);
154
+ if (!company)
155
+ throw new OpError("bad_args", "company is required (or a cwd linked to a company)");
156
+ const input = args.input;
157
+ if (input !== undefined && (input === null || typeof input !== "object" || Array.isArray(input))) {
158
+ throw new OpError("bad_args", "input must be an object");
159
+ }
160
+ return policyCheck({
161
+ company,
162
+ event: stringArg(args, "event", false),
163
+ tool: stringArg(args, "tool"),
164
+ input: input,
165
+ sessionId: stringArg(args, "sessionId", false),
166
+ cwd: stringArg(args, "cwd", false),
167
+ }, { hqRoot, index: policyIndex, env, now: clock });
168
+ }
169
+ case "policy.index": {
170
+ requireRoot();
171
+ const company = stringArg(args, "company", false);
172
+ return { version: policyIndex.version, policies: policyIndex.list(company) };
173
+ }
174
+ case "journal.append": {
175
+ const company = companyFor(args);
176
+ if (!company)
177
+ throw new OpError("bad_args", "company is required (or a cwd linked to a company)");
178
+ return journalAppend({ company, project: stringArg(args, "project"), section: stringArg(args, "section"), text: stringArg(args, "text") }, requireRoot(), clock());
179
+ }
180
+ case "memory.get":
181
+ return memoryGet(requireRoot(), stringArg(args, "sessionId"), stringArg(args, "key", false));
182
+ case "memory.put":
183
+ return memoryPut(requireRoot(), stringArg(args, "sessionId"), stringArg(args, "key"), args.value, clock());
184
+ case "mesh.presence": {
185
+ const harness = stringArg(args, "harness", false) ?? stringArg(args, "runtime");
186
+ return meshPresence({
187
+ sessionId: stringArg(args, "sessionId"),
188
+ harness,
189
+ kind: stringArg(args, "kind"),
190
+ cwd: stringArg(args, "cwd", false),
191
+ company: companyFor(args),
192
+ project: stringArg(args, "project", false),
193
+ task: stringArg(args, "task", false),
194
+ taskId: stringArg(args, "taskId", false),
195
+ status: stringArg(args, "status", false),
196
+ summary: stringArg(args, "summary", false),
197
+ reason: stringArg(args, "reason", false),
198
+ }, { hqRoot: opts.hqRoot, workMeshRoot: opts.workMeshRoot, env, now: clock });
199
+ }
200
+ case "shutdown":
201
+ return { stopping: true };
202
+ default:
203
+ throw new OpError("unknown_op", `unknown op ${req.op}`);
204
+ }
205
+ };
206
+ const respond = (socket, msg) => {
207
+ if (!socket.destroyed)
208
+ socket.write(encodeMessage(msg));
209
+ };
210
+ const clients = new Set();
211
+ const server = net.createServer((socket) => {
212
+ clients.add(socket);
213
+ socket.on("close", () => clients.delete(socket));
214
+ socket.on("error", (err) => log("warn", `hqd client error: ${err.message}`));
215
+ const push = createLineSplitter((line) => {
216
+ const parsed = parseRequestLine(line);
217
+ if (!parsed.ok) {
218
+ respond(socket, { id: parsed.id, ok: false, error: parsed.error });
219
+ return;
220
+ }
221
+ const req = parsed.request;
222
+ const succeed = (result) => {
223
+ respond(socket, { id: req.id, ok: true, result });
224
+ if (req.op === "shutdown") {
225
+ log("info", "hqd received a shutdown request");
226
+ setImmediate(() => opts.onShutdown?.());
227
+ }
228
+ };
229
+ const fail = (err) => {
230
+ const error = err instanceof OpError ||
231
+ err instanceof PolicyOpError ||
232
+ err instanceof JournalOpError ||
233
+ err instanceof MemoryOpError ||
234
+ err instanceof MeshOpError
235
+ ? { code: err.code, message: err.message }
236
+ : { code: "internal", message: err instanceof Error ? err.message : String(err) };
237
+ if (error.code === "internal") {
238
+ log("error", `hqd ${req.op} failed: ${error.message}`);
239
+ try {
240
+ opts.reportError?.(req.op, err);
241
+ }
242
+ catch (reportErr) {
243
+ log("warn", `hqd could not report the ${req.op} failure: ${reportErr.message}`);
244
+ }
245
+ }
246
+ respond(socket, { id: req.id, ok: false, error });
247
+ };
248
+ let result;
249
+ try {
250
+ result = handle(req);
251
+ }
252
+ catch (err) {
253
+ fail(err);
254
+ return;
255
+ }
256
+ if (result instanceof Promise)
257
+ result.then(succeed, fail);
258
+ else
259
+ succeed(result);
260
+ }, () => {
261
+ respond(socket, { id: null, ok: false, error: { code: "too_large", message: "request line too large" } });
262
+ socket.end();
263
+ });
264
+ socket.on("data", push);
265
+ });
266
+ await new Promise((resolveListen, rejectListen) => {
267
+ server.once("error", rejectListen);
268
+ // Bind with a user-only umask so the socket is never briefly group/world accessible.
269
+ const prior = isPipe ? null : process.umask(0o177);
270
+ try {
271
+ server.listen(socketPath, () => {
272
+ server.off("error", rejectListen);
273
+ resolveListen();
274
+ });
275
+ }
276
+ finally {
277
+ if (prior !== null)
278
+ process.umask(prior);
279
+ }
280
+ });
281
+ if (!isPipe)
282
+ fs.chmodSync(socketPath, 0o600);
283
+ log("info", `hqd listening on ${socketPath}`);
284
+ let closing = null;
285
+ return {
286
+ socketPath,
287
+ sessions,
288
+ close: () => (closing ??= new Promise((done) => {
289
+ policyIndex?.close();
290
+ server.close(() => done());
291
+ // Answered-but-idle clients would otherwise hold close() open.
292
+ for (const client of clients)
293
+ client.destroy();
294
+ if (!isPipe) {
295
+ try {
296
+ fs.unlinkSync(socketPath);
297
+ }
298
+ catch (err) {
299
+ if (err.code !== "ENOENT")
300
+ log("warn", `could not remove ${socketPath}: ${err.message}`);
301
+ }
302
+ }
303
+ })),
304
+ };
305
+ }
306
+ //# sourceMappingURL=server.js.map