@indigoai-us/hq-cli 5.324.0 → 5.326.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 (46) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/dist/bin/hq-auth-refresh.js +2 -3
  3. package/dist/command-catalog.generated.d.ts +10 -1
  4. package/dist/command-catalog.generated.js +11 -1
  5. package/dist/commands/anywhere-runtime-flag.d.ts +2 -2
  6. package/dist/commands/anywhere-runtime-flag.js +2 -2
  7. package/dist/commands/core-derive-trigger-facts-native.js +37 -3
  8. package/dist/commands/core-job-remote-launcher.js +2 -3
  9. package/dist/commands/daemon.js +1 -1
  10. package/dist/commands/install-global.d.ts +10 -0
  11. package/dist/commands/install-global.js +96 -41
  12. package/dist/commands/mcp-serve.d.ts +28 -0
  13. package/dist/commands/mcp-serve.js +165 -0
  14. package/dist/commands/mcp-status.js +7 -0
  15. package/dist/index.d.ts +3 -0
  16. package/dist/index.js +9 -0
  17. package/dist/lanes-drain-hook.js +2 -2
  18. package/dist/lib/anywhere-runtime-fast-flag.d.ts +3 -0
  19. package/dist/lib/anywhere-runtime-fast-flag.js +23 -0
  20. package/dist/lib/anywhere-runtime-flag-key.d.ts +3 -0
  21. package/dist/lib/anywhere-runtime-flag-key.js +3 -0
  22. package/dist/lib/daemon/ops/mcp.d.ts +54 -0
  23. package/dist/lib/daemon/ops/mcp.js +217 -0
  24. package/dist/lib/daemon/protocol.d.ts +2 -1
  25. package/dist/lib/daemon/protocol.js +3 -1
  26. package/dist/lib/daemon/server.d.ts +5 -0
  27. package/dist/lib/daemon/server.js +78 -11
  28. package/dist/lib/install/claude-targets.d.ts +11 -4
  29. package/dist/lib/install/claude-targets.js +21 -9
  30. package/dist/lib/install/codex-targets.d.ts +8 -4
  31. package/dist/lib/install/codex-targets.js +18 -9
  32. package/dist/lib/lanes/watcher-main.js +2 -4
  33. package/dist/lib/mcp/hosted-search-tools.d.ts +4 -0
  34. package/dist/lib/mcp/hosted-search-tools.js +28 -0
  35. package/dist/lib/mcp/hq-mcp-tools.generated.d.ts +3 -0
  36. package/dist/lib/mcp/hq-mcp-tools.generated.js +538 -0
  37. package/dist/lib/mcp/prompts.d.ts +30 -0
  38. package/dist/lib/mcp/prompts.js +48 -0
  39. package/dist/lib/mcp/tools.d.ts +22 -0
  40. package/dist/lib/mcp/tools.js +82 -0
  41. package/dist/lib/mcp/types.d.ts +6 -0
  42. package/dist/lib/mcp/types.js +2 -0
  43. package/dist/utils/is-main-module.d.ts +3 -0
  44. package/dist/utils/is-main-module.js +19 -0
  45. package/dist/utils/staged-npm-install.js +2 -1
  46. package/package.json +3 -1
@@ -0,0 +1,165 @@
1
+ /**
2
+ * `hq mcp serve` (hq-anywhere US-011): a stdio MCP server that forwards every
3
+ * tool call to hqd over its socket.
4
+ *
5
+ * It keeps no state between requests, so an orphaned instance costs nothing
6
+ * (policy mcp-process-cleanup). src/index.ts routes `hq mcp serve` straight to
7
+ * {@link runMcpServe} without loading the command graph, which keeps start-up
8
+ * under 100ms. The MCP SDK is not loaded either: the stdio transport is
9
+ * newline-delimited JSON-RPC 2.0 and is handled here directly.
10
+ *
11
+ * When hqd is unreachable every tool call returns an isError result naming
12
+ * `hq daemon status`, and the server keeps running.
13
+ */
14
+ import * as fs from "node:fs";
15
+ import * as os from "node:os";
16
+ import * as path from "node:path";
17
+ import { CLI_VERSION } from "../cli-version.js";
18
+ import { HQ_ANYWHERE_RUNTIME_FLAG } from "./anywhere-runtime-flag.js";
19
+ import { anywhereRuntimeEnabledFromSnapshot } from "../lib/anywhere-runtime-fast-flag.js";
20
+ import { createLineSplitter, HqdClientError, hqdRequest, hqdSocketPath } from "../lib/daemon/protocol.js";
21
+ export const MCP_PROTOCOL_VERSIONS = ["2025-06-18", "2025-03-26", "2024-11-05"];
22
+ const UNREACHABLE_CODES = new Set(["ENOENT", "ECONNREFUSED", "ECONNRESET", "EPIPE", "ETIMEDOUT", "ESOCKET", "EACCES"]);
23
+ /** HQ root for prompts: HQ_ROOT, else the ~/.hq/root pointer written by `hq install --global`. */
24
+ export function readHqRoot(env, home) {
25
+ const fromEnv = env.HQ_ROOT?.trim();
26
+ if (fromEnv)
27
+ return fromEnv;
28
+ const dir = env.HQ_REGISTRY_DIR?.trim() ? path.resolve(env.HQ_REGISTRY_DIR.trim()) : path.join(home, ".hq");
29
+ try {
30
+ return fs.readFileSync(path.join(dir, "root"), "utf8").trim() || null;
31
+ }
32
+ catch (err) {
33
+ if (err.code === "ENOENT")
34
+ return null;
35
+ throw err;
36
+ }
37
+ }
38
+ function toolError(code, message) {
39
+ const error = { code, message };
40
+ return { content: [{ type: "text", text: JSON.stringify({ error }, null, 2) }], structuredContent: { error }, isError: true };
41
+ }
42
+ /** Run the server until input ends. Resolves when stdin closes. */
43
+ export function runMcpServe(opts = {}) {
44
+ const input = opts.input ?? process.stdin;
45
+ const output = opts.output ?? process.stdout;
46
+ const env = opts.env ?? process.env;
47
+ const home = opts.home ?? os.homedir();
48
+ const cwd = opts.cwd ?? process.cwd();
49
+ const socketPath = hqdSocketPath(home, env);
50
+ const timeoutMs = opts.timeoutMs ?? 120_000;
51
+ if (!anywhereRuntimeEnabledFromSnapshot(env)) {
52
+ process.stderr.write(`HQ Anywhere is not enabled. Enable the ${HQ_ANYWHERE_RUNTIME_FLAG} flag to use hq mcp serve.\n`);
53
+ process.exitCode = 1;
54
+ return Promise.resolve();
55
+ }
56
+ const send = (msg) => output.write(`${JSON.stringify({ jsonrpc: "2.0", ...msg })}\n`);
57
+ const reply = (id, result) => send({ id, result });
58
+ const replyError = (id, code, message) => send({ id, error: { code, message } });
59
+ const callTool = async (params) => {
60
+ const { MCP_TOOLS } = await import("../lib/mcp/tools.js");
61
+ const name = params.name;
62
+ if (typeof name !== "string" || !MCP_TOOLS.some((t) => t.name === name)) {
63
+ return toolError("unknown_tool", `unknown tool ${String(name)}`);
64
+ }
65
+ const args = params.arguments && typeof params.arguments === "object" ? params.arguments : {};
66
+ try {
67
+ return await hqdRequest("mcp.call", { name, arguments: args, cwd, timeoutMs }, { socketPath, timeoutMs });
68
+ }
69
+ catch (err) {
70
+ if (!(err instanceof HqdClientError))
71
+ throw err;
72
+ if (UNREACHABLE_CODES.has(err.code)) {
73
+ return toolError("hqd_unreachable", `The HQ daemon (hqd) is not reachable at ${socketPath}. Run \`hq daemon status\` to check it, and \`hq daemon install\` if it is not installed. (${err.code})`);
74
+ }
75
+ return toolError(err.code, err.message);
76
+ }
77
+ };
78
+ const handle = async (msg) => {
79
+ const isRequest = msg.id !== undefined && msg.id !== null;
80
+ const id = msg.id ?? null;
81
+ const params = msg.params ?? {};
82
+ switch (msg.method) {
83
+ case "initialize": {
84
+ const requested = typeof params.protocolVersion === "string" ? params.protocolVersion : "";
85
+ const protocolVersion = MCP_PROTOCOL_VERSIONS.includes(requested) ? requested : MCP_PROTOCOL_VERSIONS[0];
86
+ reply(id, {
87
+ protocolVersion,
88
+ capabilities: { tools: { listChanged: false }, prompts: { listChanged: false } },
89
+ serverInfo: { name: "hq", version: CLI_VERSION },
90
+ instructions: "HQ tools, answered by the local HQ daemon. hq_resolve_company tells you which company the current repo belongs to; call it before company-scoped tools. If a tool returns hqd_unreachable, ask the user to run `hq daemon status`.",
91
+ });
92
+ return;
93
+ }
94
+ case "ping":
95
+ reply(id, {});
96
+ return;
97
+ case "tools/list":
98
+ reply(id, { tools: (await import("../lib/mcp/tools.js")).MCP_TOOLS });
99
+ return;
100
+ case "tools/call":
101
+ reply(id, await callTool(params));
102
+ return;
103
+ case "prompts/list":
104
+ reply(id, { prompts: (await import("../lib/mcp/prompts.js")).MCP_PROMPTS });
105
+ return;
106
+ case "prompts/get": {
107
+ const { getPrompt, PromptNotFoundError } = await import("../lib/mcp/prompts.js");
108
+ const args = params.arguments && typeof params.arguments === "object" ? params.arguments : {};
109
+ try {
110
+ reply(id, getPrompt(String(params.name), args, readHqRoot(env, home)));
111
+ }
112
+ catch (err) {
113
+ if (!(err instanceof PromptNotFoundError))
114
+ throw err;
115
+ replyError(id, -32602, err.message);
116
+ }
117
+ return;
118
+ }
119
+ default:
120
+ // Notifications (initialized, cancelled, …) need no answer.
121
+ if (isRequest)
122
+ replyError(id, -32601, `method not found: ${String(msg.method)}`);
123
+ }
124
+ };
125
+ return new Promise((resolve) => {
126
+ let inFlight = 0;
127
+ let ended = false;
128
+ const maybeDone = () => {
129
+ if (ended && inFlight === 0)
130
+ resolve();
131
+ };
132
+ const push = createLineSplitter((line) => {
133
+ let msg;
134
+ try {
135
+ msg = JSON.parse(line);
136
+ }
137
+ catch {
138
+ replyError(null, -32700, "parse error");
139
+ return;
140
+ }
141
+ if (!msg || typeof msg !== "object" || Array.isArray(msg)) {
142
+ replyError(null, -32600, "invalid request");
143
+ return;
144
+ }
145
+ inFlight += 1;
146
+ handle(msg)
147
+ .catch((err) => {
148
+ if (msg.id !== undefined && msg.id !== null)
149
+ replyError(msg.id, -32603, err instanceof Error ? err.message : String(err));
150
+ else
151
+ process.stderr.write(`hq mcp serve: ${err instanceof Error ? err.message : String(err)}\n`);
152
+ })
153
+ .finally(() => {
154
+ inFlight -= 1;
155
+ maybeDone();
156
+ });
157
+ }, () => replyError(null, -32600, "request line too large"), 16 * 1024 * 1024);
158
+ input.on("data", push);
159
+ input.on("end", () => {
160
+ ended = true;
161
+ maybeDone();
162
+ });
163
+ });
164
+ }
165
+ //# sourceMappingURL=mcp-serve.js.map
@@ -389,6 +389,13 @@ export function registerMcpCommand(program) {
389
389
  const mcp = program
390
390
  .command('mcp')
391
391
  .description('MCP pack observability (read-only) across Claude + Codex');
392
+ mcp
393
+ .command('serve')
394
+ .description('Run the HQ stdio MCP server; every tool call is answered by hqd (`hq daemon status`)')
395
+ .action(async () => {
396
+ const { runMcpServe } = await import('./mcp-serve.js');
397
+ await runMcpServe();
398
+ });
392
399
  mcp
393
400
  .command('status')
394
401
  .alias('list')
package/dist/index.d.ts CHANGED
@@ -14,6 +14,9 @@ export declare const __test__: {
14
14
  runCliEntry: typeof runCliEntry;
15
15
  isNpmPackageEntry: typeof isNpmPackageEntry;
16
16
  acquirePackageUseLease: (prefix: string, version: string, stateDirectory?: string) => Promise<import("./utils/package-use-lease.js").PackageUseLease>;
17
+ isMcpServeRequest: typeof isMcpServeRequest;
17
18
  };
19
+ /** Exactly `hq mcp serve` with no further arguments; `--help` uses the command graph. */
20
+ declare function isMcpServeRequest(argv: readonly string[]): boolean;
18
21
  export {};
19
22
  //# sourceMappingURL=index.d.ts.map
package/dist/index.js CHANGED
@@ -77,6 +77,10 @@ function dispatchCliEntry() {
77
77
  });
78
78
  });
79
79
  }
80
+ else if (isMcpServeRequest(process.argv)) {
81
+ // Keep the minimal startup route; runMcpServe applies the hq-flags gate.
82
+ void import("./commands/mcp-serve.js").then(({ runMcpServe }) => runMcpServe());
83
+ }
80
84
  else if (parsedFastCore) {
81
85
  // Hot relocated plumbing (`hq core hq-session`, `hq core checkpoint-stop-gate`)
82
86
  // dispatches straight to the bundled-script runner, so it never pays for the
@@ -163,5 +167,10 @@ export const __test__ = {
163
167
  const lease = await import("./utils/package-use-lease.js");
164
168
  return lease.acquirePackageUseLease(prefix, version, stateDirectory);
165
169
  },
170
+ isMcpServeRequest,
166
171
  };
172
+ /** Exactly `hq mcp serve` with no further arguments; `--help` uses the command graph. */
173
+ function isMcpServeRequest(argv) {
174
+ return argv.length === 4 && argv[2] === "mcp" && argv[3] === "serve";
175
+ }
167
176
  //# sourceMappingURL=index.js.map
@@ -1,8 +1,8 @@
1
1
  import { randomBytes } from "node:crypto";
2
2
  import * as fs from "node:fs";
3
3
  import * as path from "node:path";
4
- import { fileURLToPath } from "node:url";
5
4
  import { MESSAGE_REPLAY_STATE_NAME, readMessageReplayState, updateMessageReplayTranscript, writeMessageReplayState, } from "./lib/lanes/message-replay.js";
5
+ import { isMainModule } from "./utils/is-main-module.js";
6
6
  function parseHookInput(text) {
7
7
  if (text.trim() === "")
8
8
  return {};
@@ -193,7 +193,7 @@ async function main() {
193
193
  }
194
194
  finish(await runLaneDrainHook(process.env, input, inputError));
195
195
  }
196
- if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
196
+ if (isMainModule(import.meta.url)) {
197
197
  main().catch((err) => {
198
198
  process.stderr.write(`hq-lanes-drain: hook_runner_failed: ${err instanceof Error ? err.message : String(err)}\n`);
199
199
  // A hook runner failure must not stop the provider's requested tool call.
@@ -0,0 +1,3 @@
1
+ /** Resolve the Anywhere gate from hq-flags without loading the CLI's large worker bundle. */
2
+ export declare function anywhereRuntimeEnabledFromSnapshot(env?: NodeJS.ProcessEnv): boolean;
3
+ //# sourceMappingURL=anywhere-runtime-fast-flag.d.ts.map
@@ -0,0 +1,23 @@
1
+ import { readLocalOverride, resolveFlag } from "@indigoai-us/hq-flags-client";
2
+ import { resolveProcessFlagCompanyContext } from "./process-flag-company-context.js";
3
+ import { readFlagSnapshotFile } from "./flag-snapshot-file.js";
4
+ import { HQ_ANYWHERE_RUNTIME_FLAG } from "./anywhere-runtime-flag-key.js";
5
+ /** Resolve the Anywhere gate from hq-flags without loading the CLI's large worker bundle. */
6
+ export function anywhereRuntimeEnabledFromSnapshot(env = process.env) {
7
+ const local = readLocalOverride(HQ_ANYWHERE_RUNTIME_FLAG, env);
8
+ if (local !== null)
9
+ return local;
10
+ const company = resolveProcessFlagCompanyContext(env);
11
+ const persisted = company.companyUid ? readFlagSnapshotFile(company.companyUid, { env }) : null;
12
+ const snapshot = persisted
13
+ ? { version: 0, flags: persisted.flags }
14
+ : null;
15
+ return resolveFlag({
16
+ flagKey: HQ_ANYWHERE_RUNTIME_FLAG,
17
+ env,
18
+ snapshot,
19
+ companyIdentifiers: [company.companyUid, company.companySlug].filter((value) => Boolean(value)),
20
+ lookup: { fallback: false },
21
+ }).value;
22
+ }
23
+ //# sourceMappingURL=anywhere-runtime-fast-flag.js.map
@@ -0,0 +1,3 @@
1
+ /** hq-flags key for the staged HQ Anywhere runtime. The registry default is off. */
2
+ export declare const HQ_ANYWHERE_RUNTIME_FLAG = "hq-anywhere-runtime";
3
+ //# sourceMappingURL=anywhere-runtime-flag-key.d.ts.map
@@ -0,0 +1,3 @@
1
+ /** hq-flags key for the staged HQ Anywhere runtime. The registry default is off. */
2
+ export const HQ_ANYWHERE_RUNTIME_FLAG = "hq-anywhere-runtime";
3
+ //# sourceMappingURL=anywhere-runtime-flag-key.js.map
@@ -0,0 +1,54 @@
1
+ /** Hosted hq-mcp forwarding used by the hqd mcp.call operation. */
2
+ export declare class McpOpError extends Error {
3
+ readonly code: string;
4
+ constructor(code: string, message: string);
5
+ }
6
+ export interface McpToolResult {
7
+ content: Array<{
8
+ type: "text";
9
+ text: string;
10
+ }>;
11
+ structuredContent?: Record<string, unknown>;
12
+ isError?: boolean;
13
+ }
14
+ export declare function jsonResult(value: unknown): McpToolResult;
15
+ export declare const HQ_MCP_HTTP_TIMEOUT_MS = 30000;
16
+ /** Derive the hosted MCP sibling from the same hq-pro URL used by hq-cli. */
17
+ export declare function resolveHqMcpEndpoint(env?: NodeJS.ProcessEnv): URL;
18
+ export interface HqMcpBridgeOptions {
19
+ endpoint?: URL;
20
+ env?: NodeJS.ProcessEnv;
21
+ getToken?: () => Promise<string>;
22
+ fetchImpl?: typeof fetch;
23
+ timeoutMs?: number;
24
+ }
25
+ /** A short-lived Streamable HTTP MCP client; no local hq-mcp process is started. */
26
+ export declare class HqMcpBridge {
27
+ private readonly endpoint;
28
+ private readonly getToken;
29
+ private readonly fetchImpl;
30
+ private readonly timeoutMs;
31
+ private readonly activeClients;
32
+ constructor(options?: HqMcpBridgeOptions);
33
+ listTools(): Promise<Array<{
34
+ name: string;
35
+ description?: string;
36
+ inputSchema: Record<string, unknown>;
37
+ }>>;
38
+ callTool(name: string, args: Record<string, unknown>, timeoutMs?: number): Promise<unknown>;
39
+ close(): void;
40
+ private withClient;
41
+ private toSafeError;
42
+ }
43
+ export interface RunWorkerRequest {
44
+ worker: string;
45
+ skill?: string;
46
+ args?: string;
47
+ company?: string;
48
+ }
49
+ /**
50
+ * Dispatch `/run <worker> <skill> <args>` in a detached headless Claude Code
51
+ * session rooted at HQ_ROOT. HQ_RUN_WORKER_CMD overrides the binary (tests).
52
+ */
53
+ export declare function runWorker(req: RunWorkerRequest, hqRoot: string, env: NodeJS.ProcessEnv, now: Date): Promise<Record<string, unknown>>;
54
+ //# sourceMappingURL=mcp.d.ts.map
@@ -0,0 +1,217 @@
1
+ /** Hosted hq-mcp forwarding used by the hqd mcp.call operation. */
2
+ import { spawn } from "node:child_process";
3
+ import * as fs from "node:fs";
4
+ import * as path from "node:path";
5
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
6
+ import { StreamableHTTPClientTransport, StreamableHTTPError } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
7
+ import { ensureCognitoToken, isMachineIdentity } from "../../../utils/cognito-session.js";
8
+ import { DEFAULT_VAULT_API_URL } from "../../../utils/cognito-session.js";
9
+ import { ensurePrivateDir, openPrivateAppend } from "../paths.js";
10
+ export class McpOpError extends Error {
11
+ code;
12
+ constructor(code, message) {
13
+ super(message);
14
+ this.code = code;
15
+ this.name = "McpOpError";
16
+ }
17
+ }
18
+ export function jsonResult(value) {
19
+ const structured = value && typeof value === "object" && !Array.isArray(value) ? value : { value };
20
+ return { content: [{ type: "text", text: JSON.stringify(value, null, 2) }], structuredContent: structured };
21
+ }
22
+ export const HQ_MCP_HTTP_TIMEOUT_MS = 30_000;
23
+ /** Derive the hosted MCP sibling from the same hq-pro URL used by hq-cli. */
24
+ export function resolveHqMcpEndpoint(env = process.env) {
25
+ const apiUrl = env.HQ_VAULT_API_URL?.trim() || DEFAULT_VAULT_API_URL;
26
+ let api;
27
+ try {
28
+ api = new URL(apiUrl);
29
+ }
30
+ catch {
31
+ throw new McpOpError("hq_mcp_config_error", "HQ's configured API URL is invalid; check HQ_VAULT_API_URL.");
32
+ }
33
+ if (api.protocol !== "https:" || !api.hostname.startsWith("hqapi.")) {
34
+ throw new McpOpError("hq_mcp_config_error", "Hosted hq-mcp requires an HTTPS hqapi host in HQ_VAULT_API_URL.");
35
+ }
36
+ api.hostname = `hq-mcp.${api.hostname.slice("hqapi.".length)}`;
37
+ api.pathname = "/mcp";
38
+ api.search = "";
39
+ api.hash = "";
40
+ return api;
41
+ }
42
+ async function defaultMcpToken() {
43
+ if (!isMachineIdentity()) {
44
+ throw new McpOpError("hq_mcp_auth_required", "This HQ login is not an hq-mcp audience token. Sign in to the hosted connector with its DCR plus PKCE flow; hq login tokens cannot call hq-mcp yet.");
45
+ }
46
+ try {
47
+ // Send this box's existing machine token. The hosted hq-mcp audience check
48
+ // accepts it only when it was issued for hqMcpMachineClient; otherwise the
49
+ // server rejects it. This helper does not inspect the credential's client ID.
50
+ return await ensureCognitoToken({ tokenSource: "machine", interactive: false });
51
+ }
52
+ catch {
53
+ throw new McpOpError("hq_mcp_auth_required", "An hq-mcp machine token is unavailable. Configure this box with hq-pro's hqMcpMachineClient ID, then retry the daemon call.");
54
+ }
55
+ }
56
+ /** A short-lived Streamable HTTP MCP client; no local hq-mcp process is started. */
57
+ export class HqMcpBridge {
58
+ endpoint;
59
+ getToken;
60
+ fetchImpl;
61
+ timeoutMs;
62
+ activeClients = new Set();
63
+ constructor(options = {}) {
64
+ this.endpoint = options.endpoint ?? resolveHqMcpEndpoint(options.env ?? process.env);
65
+ this.getToken = options.getToken ?? defaultMcpToken;
66
+ this.fetchImpl = options.fetchImpl ?? fetch;
67
+ this.timeoutMs = options.timeoutMs ?? HQ_MCP_HTTP_TIMEOUT_MS;
68
+ }
69
+ async listTools() {
70
+ return this.withClient(async (client) => {
71
+ const result = await client.listTools();
72
+ return result.tools.map((tool) => ({
73
+ name: tool.name,
74
+ ...(tool.description ? { description: tool.description } : {}),
75
+ inputSchema: tool.inputSchema,
76
+ }));
77
+ });
78
+ }
79
+ async callTool(name, args, timeoutMs = this.timeoutMs) {
80
+ return this.withClient(async (client, token) => {
81
+ const { tools } = await client.listTools();
82
+ if (!tools.some((tool) => tool.name === name)) {
83
+ throw new McpOpError("hq_mcp_tool_unavailable", `Hosted hq-mcp does not expose ${name}.`);
84
+ }
85
+ try {
86
+ return await client.callTool({ name, arguments: args });
87
+ }
88
+ catch (error) {
89
+ throw this.toSafeError(error, token);
90
+ }
91
+ }, timeoutMs);
92
+ }
93
+ close() {
94
+ for (const client of this.activeClients) {
95
+ void client.close().catch(() => {
96
+ // The in-flight call is already being cancelled; shutdown owns its result.
97
+ });
98
+ }
99
+ }
100
+ async withClient(work, timeoutMs = this.timeoutMs) {
101
+ let token = "";
102
+ let transport;
103
+ let client;
104
+ let timeout;
105
+ let timedOut = false;
106
+ const abortController = new AbortController();
107
+ try {
108
+ token = await this.getToken();
109
+ if (!token.trim())
110
+ throw new McpOpError("hq_mcp_auth_required", "An hq-mcp audience token is required.");
111
+ transport = new StreamableHTTPClientTransport(this.endpoint, {
112
+ requestInit: {
113
+ headers: { Authorization: `Bearer ${token}` },
114
+ },
115
+ fetch: (input, init) => this.fetchImpl(input, {
116
+ ...init,
117
+ signal: init?.signal ? AbortSignal.any([abortController.signal, init.signal]) : abortController.signal,
118
+ }),
119
+ });
120
+ client = new Client({ name: "hqd", version: "1" }, { capabilities: {} });
121
+ this.activeClients.add(client);
122
+ timeout = setTimeout(() => {
123
+ timedOut = true;
124
+ abortController.abort();
125
+ void client?.close().catch(() => {
126
+ // The deadline already ended the request.
127
+ });
128
+ }, timeoutMs);
129
+ await client.connect(transport);
130
+ return await work(client, token);
131
+ }
132
+ catch (error) {
133
+ if (timedOut) {
134
+ throw new McpOpError("hq_mcp_timeout", `Hosted hq-mcp request did not finish within ${timeoutMs}ms.`);
135
+ }
136
+ throw this.toSafeError(error, token);
137
+ }
138
+ finally {
139
+ if (timeout)
140
+ clearTimeout(timeout);
141
+ if (client) {
142
+ this.activeClients.delete(client);
143
+ try {
144
+ await client.close();
145
+ }
146
+ catch {
147
+ // Closing a finished HTTP session cannot change the request result.
148
+ }
149
+ }
150
+ else if (transport) {
151
+ try {
152
+ await transport.close();
153
+ }
154
+ catch {
155
+ // Same as above; the transport owns no child process or token cache.
156
+ }
157
+ }
158
+ }
159
+ }
160
+ toSafeError(error, token) {
161
+ if (error instanceof McpOpError)
162
+ return error;
163
+ const status = error instanceof StreamableHTTPError ? error.code : undefined;
164
+ if (status === 401 || status === 403) {
165
+ return new McpOpError("hq_mcp_auth_required", "Hosted hq-mcp rejected this token. Use the DCR plus PKCE person flow or configure the hq-mcp machine client ID on this box.");
166
+ }
167
+ const message = error instanceof Error ? error.message : "request failed";
168
+ const safe = token ? message.split(token).join("[redacted]") : message;
169
+ return new McpOpError("hq_mcp_error", `Hosted hq-mcp request failed: ${safe}`);
170
+ }
171
+ }
172
+ const SLUG_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
173
+ /**
174
+ * Dispatch `/run <worker> <skill> <args>` in a detached headless Claude Code
175
+ * session rooted at HQ_ROOT. HQ_RUN_WORKER_CMD overrides the binary (tests).
176
+ */
177
+ export async function runWorker(req, hqRoot, env, now) {
178
+ if (!SLUG_RE.test(req.worker))
179
+ throw new McpOpError("bad_args", "worker must be a worker id (letters, digits, '.', '_', '-')");
180
+ if (req.skill !== undefined && !SLUG_RE.test(req.skill))
181
+ throw new McpOpError("bad_args", "skill must be a skill name");
182
+ const prompt = ["/run", req.worker, req.skill, req.args?.trim()].filter(Boolean).join(" ");
183
+ const logDir = path.join(hqRoot, "workspace", "orchestrator", "hqd-run-worker");
184
+ ensurePrivateDir(logDir);
185
+ const logPath = path.join(logDir, `${now.toISOString().replace(/[:.]/g, "-")}-${req.worker}.log`);
186
+ const fd = openPrivateAppend(logPath);
187
+ fs.chmodSync(logPath, 0o600);
188
+ const bin = env.HQ_RUN_WORKER_CMD?.trim() || "claude";
189
+ try {
190
+ const workerEnv = { ...env };
191
+ for (const key of Object.keys(workerEnv)) {
192
+ if (key.toUpperCase() === "HQ_SPAWN_COMPANY")
193
+ delete workerEnv[key];
194
+ }
195
+ if (req.company)
196
+ workerEnv.HQ_SPAWN_COMPANY = req.company;
197
+ const child = spawn(bin, ["-p", prompt], {
198
+ cwd: hqRoot,
199
+ env: workerEnv,
200
+ detached: true,
201
+ stdio: ["ignore", fd, fd],
202
+ });
203
+ await new Promise((resolve, reject) => {
204
+ child.once("spawn", resolve);
205
+ child.once("error", (err) => {
206
+ fs.appendFileSync(logPath, `\nhqd could not start ${bin}: ${err.message}\n`, { mode: 0o600 });
207
+ reject(new McpOpError("worker_spawn_failed", `could not start ${bin}: ${err.message}`));
208
+ });
209
+ });
210
+ child.unref();
211
+ return { dispatched: true, prompt, pid: child.pid ?? null, logPath, company: req.company ?? null };
212
+ }
213
+ finally {
214
+ fs.closeSync(fd);
215
+ }
216
+ }
217
+ //# sourceMappingURL=mcp.js.map
@@ -7,10 +7,11 @@
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", "shutdown"];
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", "shutdown"];
11
11
  export type HqdOp = (typeof HQD_OPS)[number];
12
12
  /** A request line larger than this is rejected and the connection closed. */
13
13
  export declare const HQD_MAX_LINE_BYTES: number;
14
+ export declare const HQD_DEFAULT_REQUEST_TIMEOUT_MS = 2000;
14
15
  export interface HqdRequest {
15
16
  id: string | number;
16
17
  op: string;
@@ -22,10 +22,12 @@ export const HQD_OPS = [
22
22
  "memory.get",
23
23
  "memory.put",
24
24
  "mesh.presence",
25
+ "mcp.call",
25
26
  "shutdown",
26
27
  ];
27
28
  /** A request line larger than this is rejected and the connection closed. */
28
29
  export const HQD_MAX_LINE_BYTES = 1024 * 1024;
30
+ export const HQD_DEFAULT_REQUEST_TIMEOUT_MS = 2_000;
29
31
  /**
30
32
  * Socket path: HQ_HQD_SOCKET, else <HQ_REGISTRY_DIR or ~/.hq>/hqd.sock.
31
33
  * On Windows the socket is a per-user named pipe.
@@ -115,7 +117,7 @@ let nextClientId = 0;
115
117
  */
116
118
  export function hqdRequest(op, args = {}, opts = {}) {
117
119
  const socketPath = opts.socketPath ?? hqdSocketPath();
118
- const timeoutMs = opts.timeoutMs ?? 2_000;
120
+ const timeoutMs = opts.timeoutMs ?? HQD_DEFAULT_REQUEST_TIMEOUT_MS;
119
121
  const id = `c${process.pid}-${(nextClientId += 1)}`;
120
122
  return new Promise((resolve, reject) => {
121
123
  const socket = net.createConnection(socketPath);
@@ -8,6 +8,7 @@
8
8
  */
9
9
  import { type ResolveResult } from "../registry/registry.js";
10
10
  import { SessionRegistry } from "./session-registry.js";
11
+ import { HqMcpBridge } from "./ops/mcp.js";
11
12
  export interface HqdServerOptions {
12
13
  socketPath: string;
13
14
  /** registry.json to resolve against (US-004). */
@@ -22,6 +23,10 @@ export interface HqdServerOptions {
22
23
  env?: NodeJS.ProcessEnv;
23
24
  /** Work-mesh root override for mesh.presence (tests). */
24
25
  workMeshRoot?: string;
26
+ /** Test seam for the hq-anywhere-runtime default-off gate. */
27
+ mcpEnabled?: () => boolean | Promise<boolean>;
28
+ /** Test seam for the hosted Streamable HTTP MCP transport. */
29
+ mcpBridge?: HqMcpBridge;
25
30
  /** Crash reporter for internal op failures (US-012); run.ts wires Sentry here. */
26
31
  reportError?: (op: string, err: unknown) => void;
27
32
  }