@indigoai-us/hq-cli 5.328.0 → 5.330.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 (54) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/dist/command-catalog.generated.d.ts +3699 -3
  3. package/dist/command-catalog.generated.js +5462 -1759
  4. package/dist/command-help.d.ts +2 -0
  5. package/dist/command-help.js +13 -0
  6. package/dist/commands/cloud.d.ts +5 -0
  7. package/dist/commands/cloud.js +51 -1
  8. package/dist/commands/install-global.d.ts +21 -0
  9. package/dist/commands/install-global.js +267 -32
  10. package/dist/commands/lanes-message.js +59 -6
  11. package/dist/commands/lanes-resume.js +66 -15
  12. package/dist/commands/lanes-shared.d.ts +9 -2
  13. package/dist/commands/lanes.js +97 -8
  14. package/dist/commands/mcp-status.js +58 -1
  15. package/dist/commands/pack-install.d.ts +3 -1
  16. package/dist/commands/pack-install.js +17 -4
  17. package/dist/commands/packs.js +17 -0
  18. package/dist/commands/pkg-install.js +13 -1
  19. package/dist/fast-scaffold.d.ts +2 -0
  20. package/dist/fast-scaffold.js +16 -0
  21. package/dist/index.js +19 -2
  22. package/dist/lazy-commands.d.ts +1 -0
  23. package/dist/lazy-commands.js +64 -4
  24. package/dist/lib/cli-flag-keys.d.ts +3 -0
  25. package/dist/lib/cli-flag-keys.js +3 -0
  26. package/dist/lib/daemon/ops/mcp.js +11 -10
  27. package/dist/lib/daemon/ops/policy.d.ts +3 -2
  28. package/dist/lib/daemon/ops/policy.js +12 -6
  29. package/dist/lib/daemon/server.d.ts +3 -1
  30. package/dist/lib/daemon/server.js +63 -7
  31. package/dist/lib/doctor/checks/anywhere.js +58 -4
  32. package/dist/lib/install/chatgpt-targets.d.ts +8 -0
  33. package/dist/lib/install/chatgpt-targets.js +15 -0
  34. package/dist/lib/install/codex-pack-contributions.d.ts +8 -0
  35. package/dist/lib/install/codex-pack-contributions.js +205 -0
  36. package/dist/lib/install/codex-targets.d.ts +1 -1
  37. package/dist/lib/install/codex-targets.js +2 -1
  38. package/dist/lib/install/grok-targets.d.ts +8 -0
  39. package/dist/lib/install/grok-targets.js +15 -0
  40. package/dist/lib/lanes/flag-keys.d.ts +2 -0
  41. package/dist/lib/lanes/flag-keys.js +2 -0
  42. package/dist/lib/lanes/keep-alive-audit.d.ts +3 -2
  43. package/dist/lib/lanes/keep-alive-audit.js +36 -8
  44. package/dist/lib/lanes/spawn.js +14 -10
  45. package/dist/lib/lanes/store.js +29 -2
  46. package/dist/lib/lanes/types.d.ts +10 -0
  47. package/dist/lib/lanes/types.js +1 -1
  48. package/dist/lib/sync-invalid-entity-warning.d.ts +32 -0
  49. package/dist/lib/sync-invalid-entity-warning.js +205 -0
  50. package/dist/main.d.ts +2 -6
  51. package/dist/main.js +3 -41
  52. package/dist/stream-error-boundary.d.ts +7 -0
  53. package/dist/stream-error-boundary.js +33 -0
  54. package/package.json +1 -1
@@ -55,6 +55,7 @@ import { formatApiKeyCapabilityDenial, peekHqApiKey, } from '../utils/resolve-va
55
55
  import { redactSecrets, SECRET_REDACTION, isMcpRegistrationEnabled, registerMcpServers, McpManifestError, } from './mcp-registration.js';
56
56
  import { listSecretCacheScopes } from '../utils/secrets-cache.js';
57
57
  import { loadRevealedSecrets } from './secrets.js';
58
+ import { syncCodexPackHooks, syncCodexPackSkills } from '../lib/install/codex-pack-contributions.js';
58
59
  const PACK_UPDATE_CACHE_TTL_MS = 12 * 60 * 60 * 1000;
59
60
  const PACK_UPDATE_FETCH_TIMEOUT_MS = 3_000;
60
61
  const gitLsRemoteMemo = new Map();
@@ -969,7 +970,7 @@ export function validateMcpManifest(payloadDir, item) {
969
970
  // ---------------------------------------------------------------------------
970
971
  // Manifest validation (spec §Validation, 10 checks)
971
972
  // ---------------------------------------------------------------------------
972
- export function validateManifest(payloadDir, hqVersion) {
973
+ export function validateManifest(payloadDir, hqVersion, options = {}) {
973
974
  // 1. parse
974
975
  const manifestPath = path.join(payloadDir, 'package.yaml');
975
976
  if (!fs.existsSync(manifestPath)) {
@@ -990,8 +991,9 @@ export function validateManifest(payloadDir, hqVersion) {
990
991
  throw new Error('package.yaml must be a YAML mapping');
991
992
  }
992
993
  // 2. name
993
- if (!m.name || !/^hq-pack-[a-z0-9][a-z0-9-]*$/.test(m.name)) {
994
- throw new Error(`Invalid pack name "${m.name}" — must match ^hq-pack-[a-z0-9][a-z0-9-]*$`);
994
+ const reservedNameAllowed = m.name === 'hq-anywhere' && options.allowReservedName === true;
995
+ if (!m.name || (!reservedNameAllowed && !/^hq-pack-[a-z0-9][a-z0-9-]*$/.test(m.name))) {
996
+ throw new Error(`Invalid pack name "${m.name}" — must match ^hq-pack-[a-z0-9][a-z0-9-]*$ or the reserved hq-anywhere pack name`);
995
997
  }
996
998
  // 3. version
997
999
  if (!m.version || !semverValid(m.version)) {
@@ -1702,7 +1704,10 @@ export async function installPack(source, opts = {}) {
1702
1704
  });
1703
1705
  break;
1704
1706
  }
1705
- const pkg = validateManifest(fetched.payloadDir, hqVersion);
1707
+ // Only an explicit local directory source may use the reserved bundled
1708
+ // pack name. Publisher is self-declared package metadata and is not proof
1709
+ // of registry identity, so remote transports never receive this exception.
1710
+ const pkg = validateManifest(fetched.payloadDir, hqVersion, { allowReservedName: transport === 'local' });
1706
1711
  // M0 — pack-to-pack dependency pre-flight. Runs BEFORE any prompts or writes
1707
1712
  // so a missing/unsatisfied `requires.packs` aborts with no partial state, and
1708
1713
  // before we bother the operator with hook/MCP trust prompts for a pack that
@@ -1753,6 +1758,14 @@ export async function installPack(source, opts = {}) {
1753
1758
  const stampedSource = transport === 'marketplace' ? fetched.resolvedSource : source;
1754
1759
  stampInstallSource(destDir, stampedSource);
1755
1760
  runScanPackages(hqRoot, { quiet: opts.quiet });
1761
+ // US-014: Codex user-scope contributions are opt-in and owned by the pack.
1762
+ // Existing Claude/HQ scan-packages behavior remains unchanged when the flag is off.
1763
+ const codexHome = process.env.HOME ?? os.homedir();
1764
+ const packHooks = pkg.contributes.hooks ?? [];
1765
+ const skillWarnings = syncCodexPackSkills(destDir, pkg.name, pkg.contributes.skills ?? [], false, codexHome);
1766
+ syncCodexPackHooks(destDir, packHooks, false, codexHome);
1767
+ for (const warning of skillWarnings)
1768
+ say(chalk.yellow(` ! ${warning}`));
1756
1769
  // US-007 — register `contributes.mcp` servers into the shared Claude/Codex
1757
1770
  // agent configs. Fires HERE (after installToPackages + runScanPackages, i.e.
1758
1771
  // pack on disk + symlinks wired) and only once the confirmMcp gate above
@@ -17,6 +17,7 @@
17
17
  * Spec: knowledge/public/hq-core/package-yaml-spec.md.
18
18
  */
19
19
  import * as fs from 'fs';
20
+ import * as os from 'os';
20
21
  import * as path from 'path';
21
22
  import * as readline from 'readline';
22
23
  import { spawnSync } from 'child_process';
@@ -26,6 +27,7 @@ import { findHqRoot } from '../utils/manifest.js';
26
27
  import { resolveHqRoot as resolveDetectedHqRoot } from './doctor.js';
27
28
  import { classify, resolveLatest, resolveLatestMarketplace, runScanPackages, installPack, } from './pack-install.js';
28
29
  import { contributionLinks, linkStatus, listInstalledPacks, findDependentPacks, readPackManifest, unwirePack, unwirePackMcp, readHqVersion, readRecommendedPackages, packagesDir, } from '../utils/pack-contributions.js';
30
+ import { syncCodexPackHooks, syncCodexPackSkills } from '../lib/install/codex-pack-contributions.js';
29
31
  function resolveRoot(opts) {
30
32
  return opts.hqRoot ? path.resolve(opts.hqRoot) : findHqRoot();
31
33
  }
@@ -247,6 +249,11 @@ async function runUpdate(name, opts) {
247
249
  continue;
248
250
  }
249
251
  try {
252
+ // Remove Codex registrations and skills from the old manifest before the
253
+ // pack directory is replaced. The replacement may rename its commands or
254
+ // drop contributions entirely, so the new manifest cannot clean those.
255
+ syncCodexPackHooks(pack.dir, m.contributes?.hooks ?? [], true);
256
+ syncCodexPackSkills(pack.dir, pname, m.contributes?.skills ?? [], true);
250
257
  // Un-wire old contributions first so a contribution dropped by the new
251
258
  // version doesn't leave a dangling host symlink. installPack re-wires.
252
259
  unwirePack(hqRoot, pack.dir, m.contributes ?? {});
@@ -303,6 +310,16 @@ async function runUninstall(name, opts) {
303
310
  }
304
311
  // 1. Un-wire only our symlinks.
305
312
  const { unlinked, skipped } = unwirePack(hqRoot, packDir, contributes);
313
+ // Remove Codex user-scope contributions only when their installed ownership
314
+ // marker/absolute command still points at this pack.
315
+ try {
316
+ const home = process.env.HOME ?? os.homedir();
317
+ syncCodexPackHooks(packDir, contributes.hooks ?? [], true, home);
318
+ warnings.push(...syncCodexPackSkills(packDir, name, contributes.skills ?? [], true, home));
319
+ }
320
+ catch (e) {
321
+ warnings.push(`Codex contribution removal encountered an error: ${e.message}`);
322
+ }
306
323
  // 1b. Un-register the pack's MCP (`wire: 'merge'`) servers — invisible to the
307
324
  // symlink unwire above. Provenance-scoped: removes ONLY entries stamped with this
308
325
  // pack's `_hqPack`, and skip-and-warns on any foreign/unstamped same-named entry.
@@ -44,7 +44,9 @@ export function registerPackageInstallCommand(parent) {
44
44
  .option('--allow-mcp', 'Skip the MCP server confirmation prompt (content-pack flow)')
45
45
  .option('--branch', 'Follow a ref instead of SHA-pinning (git content-pack flow)')
46
46
  .option('--global', 'Install HQ into an agent runtime at user scope (requires the hq-anywhere-runtime flag)')
47
- .option('--runtime <runtime>', 'Runtime for --global (claude | codex)', 'claude')
47
+ .option('--runtime <runtime>', 'Runtime for --global (claude | codex | chatgpt | grok)', 'claude')
48
+ .option('--via <mode>', 'With --global Claude: install directly or via plugin (direct | plugin)', 'direct')
49
+ .option('--marketplace-dir <dir>', 'With --via plugin: local Claude Code marketplace directory (or HQ_CLAUDE_PLUGIN_MARKETPLACE)')
48
50
  .option('--dry-run', 'With --global: print every file and config diff without writing')
49
51
  .option('--hq-root <dir>', 'With --global: HQ checkout to install from (default: walk up from cwd)')
50
52
  .action(async (source, opts) => {
@@ -55,6 +57,16 @@ export function registerPackageInstallCommand(parent) {
55
57
  return;
56
58
  }
57
59
  const { runInstallGlobal } = await import('./install-global.js');
60
+ if (opts.via !== 'direct' && opts.via !== 'plugin') {
61
+ console.error("error: --via must be 'direct' or 'plugin'");
62
+ process.exitCode = 1;
63
+ return;
64
+ }
65
+ if (opts.via === 'plugin' && (opts.runtime ?? 'claude') !== 'claude') {
66
+ console.error('error: --via plugin is currently supported only with --runtime claude');
67
+ process.exitCode = 1;
68
+ return;
69
+ }
58
70
  runInstallGlobal(opts);
59
71
  return;
60
72
  }
@@ -85,4 +85,6 @@ export declare function parseSecretsEnvFastRequest(argv: readonly string[]): Par
85
85
  export declare function parseFastCore(argv: readonly string[]): ParsedFastCore | null;
86
86
  /** Whether this invocation is a fast-path scaffold dispatch. */
87
87
  export declare function isFastCoreRequest(argv: readonly string[]): boolean;
88
+ /** Metadata-only help can render without importing the selected command graph. */
89
+ export declare function isCommandHelpRequest(argv: readonly string[]): boolean;
88
90
  //# sourceMappingURL=fast-scaffold.d.ts.map
@@ -421,4 +421,20 @@ export function parseFastCore(argv) {
421
421
  export function isFastCoreRequest(argv) {
422
422
  return parseFastCore(argv) !== null;
423
423
  }
424
+ /** Metadata-only help can render without importing the selected command graph. */
425
+ export function isCommandHelpRequest(argv) {
426
+ const args = argv.slice(2);
427
+ const delimiter = args.indexOf("--");
428
+ const beforeDelimiter = delimiter === -1 ? args : args.slice(0, delimiter);
429
+ const commandWords = beforeDelimiter.filter((token) => !token.startsWith("-"));
430
+ const explicitHelp = beforeDelimiter.some((token) => token === "--help" || token === "-h");
431
+ if (!explicitHelp && commandWords[0] !== "help")
432
+ return false;
433
+ if (commandWords[0] === "help")
434
+ return commandWords.length <= 2;
435
+ // Native core commands have their own pass-through help and stay on their established path.
436
+ if (commandWords[0] === "core" || commandWords[0] === "run")
437
+ return false;
438
+ return commandWords.length <= 1;
439
+ }
424
440
  //# sourceMappingURL=fast-scaffold.js.map
package/dist/index.js CHANGED
@@ -6,10 +6,12 @@ import "./node-network-compat-fast.js";
6
6
  import { isVersionRequest, wantsLabelledVersionReport } from "./version-request.js";
7
7
  // Dependency-light: a pure parser + table, no command graph. Safe to load on
8
8
  // every path (including --version) without reintroducing the heavy startup.
9
- import { isFastCoreRequest, parseFastCore, parseSecretsEnvFastRequest, } from "./fast-scaffold.js";
9
+ import { isCommandHelpRequest, isFastCoreRequest, parseFastCore, parseSecretsEnvFastRequest, } from "./fast-scaffold.js";
10
+ import { CLI_COMMAND_HELP_CATALOG_FLAG } from "./lib/cli-flag-keys.js";
10
11
  // Also dependency-light: registers a process-level rejection boundary; its only
11
12
  // heavy import (main.js) is lazy and reached only when a rejection fires.
12
13
  import { installProcessRejectionBoundary } from "./unhandled-rejection-boundary.js";
14
+ import { installFastPathStreamErrorHandlers } from "./utils/fast-stream-errors.js";
13
15
  // Install BEFORE any dispatch so a floated rejection on ANY path (the command
14
16
  // graph or the fast-core forwarder) becomes a non-zero exit. @sentry/node's
15
17
  // onUnhandledRejection integration defaults to 'warn' — it captures but does
@@ -17,9 +19,11 @@ import { installProcessRejectionBoundary } from "./unhandled-rejection-boundary.
17
19
  // command never run (HQ-CLI-W). Sets process.exitCode only, never process.exit,
18
20
  // so runCli's finally still finalizes release health and flushes Sentry.
19
21
  installProcessRejectionBoundary();
22
+ installFastPathStreamErrorHandlers();
20
23
  function dispatchCliEntry() {
21
24
  const versionRequest = isVersionRequest(process.argv);
22
- const parsedFastCore = versionRequest ? null : parseFastCore(process.argv);
25
+ const helpRequest = !versionRequest && isCommandHelpRequest(process.argv);
26
+ const parsedFastCore = versionRequest || helpRequest ? null : parseFastCore(process.argv);
23
27
  if (versionRequest) {
24
28
  // Bare `--version` is the CLI semver only (the contract field updaters
25
29
  // compare). `hq version` and `--json` print the labelled HQ/CLI report.
@@ -38,6 +42,19 @@ function dispatchCliEntry() {
38
42
  });
39
43
  }
40
44
  }
45
+ else if (helpRequest) {
46
+ // The generated-catalog renderer is a default-off startup optimization.
47
+ // When disabled or unavailable, preserve the established main.ts behavior.
48
+ void import("./native-fast-route.js").then(async ({ fastFlagEnabled }) => {
49
+ if (await fastFlagEnabled(CLI_COMMAND_HELP_CATALOG_FLAG)) {
50
+ const { runCommandHelp } = await import("./command-help.js");
51
+ await runCommandHelp(process.argv);
52
+ return;
53
+ }
54
+ const { runCli } = await import("./main.js");
55
+ await runCli();
56
+ });
57
+ }
41
58
  else if (parsedFastCore?.entry.kind === "native" &&
42
59
  (process.env.HQ_FLAG_HQ_CORE_NATIVE_FAST_ROUTE === "true" ||
43
60
  process.env.HQ_FLAG_HQ_CORE_NATIVE_FAST_ROUTE === "1")) {
@@ -17,6 +17,7 @@ export declare const LAZY_COMMANDS: readonly LazyCommand[];
17
17
  * replaces the installed tree between startup and command registration.
18
18
  */
19
19
  export declare function registerCommandRoot(program: Command, root: string): Promise<boolean>;
20
+ export declare function findRuntimeHqRoot(start: string, parentOf?: (directory: string) => string, isRoot?: (directory: string) => boolean): string;
20
21
  /**
21
22
  * Register only generated root metadata. This is enough for root help and
22
23
  * Commander suggestions, without evaluating an implementation module.
@@ -1,4 +1,7 @@
1
- import { Command } from "commander";
1
+ import { Argument, Command, Option } from "commander";
2
+ import { existsSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { dirname, join, resolve } from "node:path";
2
5
  import { COMMAND_CATALOG } from "./command-catalog.generated.js";
3
6
  const commandCatalog = COMMAND_CATALOG;
4
7
  /**
@@ -26,18 +29,75 @@ export async function registerCommandRoot(program, root) {
26
29
  function commandDefinition(command) {
27
30
  const stub = new Command(command.name)
28
31
  .description(command.description);
32
+ if (command.helpOption === false)
33
+ stub.helpOption(false);
29
34
  for (const alias of command.aliases)
30
35
  stub.alias(alias);
31
36
  for (const argument of command.arguments) {
32
37
  const name = `${argument.name}${argument.variadic ? "..." : ""}`;
33
- stub.argument(argument.required ? `<${name}>` : `[${name}]`);
38
+ const definition = new Argument(argument.required ? `<${name}>` : `[${name}]`, argument.description || undefined);
39
+ if ("defaultValue" in argument)
40
+ definition.default(restoreRuntimePath(argument.defaultValue));
41
+ stub.addArgument(definition);
42
+ }
43
+ for (const option of command.options) {
44
+ const description = restoreRuntimePath(option.description);
45
+ const definition = new Option(option.flags, description);
46
+ if (option.hidden)
47
+ definition.hideHelp();
48
+ if (option.choices)
49
+ definition.choices([...option.choices]);
50
+ if ("defaultValue" in option) {
51
+ definition.default(restoreRuntimePath(option.defaultValue));
52
+ }
53
+ stub.addOption(definition);
34
54
  }
35
- for (const option of command.options)
36
- stub.option(option.flags, option.description);
37
55
  if (command.hidden)
38
56
  stub._hidden = true;
57
+ for (const child of command.subcommands)
58
+ stub.addCommand(commandDefinition(child));
59
+ if (command.helpCommand !== undefined)
60
+ stub.addHelpCommand(command.helpCommand);
61
+ stub.usage(command.usage);
62
+ for (const helpText of command.helpText ?? [])
63
+ stub.addHelpText(helpText.position, helpText.text);
39
64
  return stub;
40
65
  }
66
+ function restoreRuntimePath(value) {
67
+ if (typeof value === "string") {
68
+ let restored = value.replaceAll("<runtime-home>", homedir());
69
+ if (restored.includes("<runtime-path>"))
70
+ restored = restored.replaceAll("<runtime-path>", runtimeHqRoot());
71
+ return restored;
72
+ }
73
+ if (Array.isArray(value))
74
+ return value.map(restoreRuntimePath);
75
+ if (value && typeof value === "object") {
76
+ return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, restoreRuntimePath(entry)]));
77
+ }
78
+ return value;
79
+ }
80
+ function runtimeHqRoot() {
81
+ const args = process.argv.slice(2);
82
+ const rootIndex = args.findIndex((arg) => arg === "--hq-root");
83
+ const explicit = rootIndex >= 0 ? args[rootIndex + 1] : args.find((arg) => arg.startsWith("--hq-root="))?.slice("--hq-root=".length);
84
+ const configured = explicit || process.env.HQ_ROOT || process.env.CLAUDE_PROJECT_DIR;
85
+ if (configured)
86
+ return resolve(configured);
87
+ return findRuntimeHqRoot(resolve(process.cwd()));
88
+ }
89
+ export function findRuntimeHqRoot(start, parentOf = dirname, isRoot = (directory) => existsSync(join(directory, "companies")) &&
90
+ (existsSync(join(directory, ".claude")) || existsSync(join(directory, "core")) || existsSync(join(directory, "personal")))) {
91
+ let directory = start;
92
+ for (;;) {
93
+ if (isRoot(directory))
94
+ return directory;
95
+ const parent = parentOf(directory);
96
+ if (parent === directory)
97
+ return start;
98
+ directory = parent;
99
+ }
100
+ }
41
101
  /**
42
102
  * Register only generated root metadata. This is enough for root help and
43
103
  * Commander suggestions, without evaluating an implementation module.
@@ -0,0 +1,3 @@
1
+ /** hq-flags keys used by CLI-wide routes. */
2
+ export declare const CLI_COMMAND_HELP_CATALOG_FLAG = "cli.command-help-catalog";
3
+ //# sourceMappingURL=cli-flag-keys.d.ts.map
@@ -0,0 +1,3 @@
1
+ /** hq-flags keys used by CLI-wide routes. */
2
+ export const CLI_COMMAND_HELP_CATALOG_FLAG = "cli.command-help-catalog";
3
+ //# sourceMappingURL=cli-flag-keys.js.map
@@ -74,7 +74,7 @@ export class HqMcpBridge {
74
74
  });
75
75
  }
76
76
  async callTool(name, args, timeoutMs = this.timeoutMs) {
77
- return this.withClient(async (client, token) => {
77
+ return this.withClient(async (client) => {
78
78
  const { tools } = await client.listTools();
79
79
  if (!tools.some((tool) => tool.name === name)) {
80
80
  throw new McpOpError("hq_mcp_tool_unavailable", `Hosted hq-mcp does not expose ${name}.`);
@@ -93,10 +93,16 @@ export class HqMcpBridge {
93
93
  let token = "";
94
94
  let transport;
95
95
  let client;
96
- let timeout;
97
96
  let timedOut = false;
98
97
  const abortController = new AbortController();
99
98
  let httpErrorClass;
99
+ const timeout = setTimeout(() => {
100
+ timedOut = true;
101
+ abortController.abort();
102
+ void client?.close().catch(() => {
103
+ // The deadline already ended the request.
104
+ });
105
+ }, timeoutMs);
100
106
  try {
101
107
  const [clientModule, httpModule] = await Promise.all([
102
108
  import("@modelcontextprotocol/sdk/client/index.js"),
@@ -104,6 +110,8 @@ export class HqMcpBridge {
104
110
  ]);
105
111
  httpErrorClass = httpModule.StreamableHTTPError;
106
112
  token = await this.getToken();
113
+ if (timedOut)
114
+ throw new McpOpError("hq_mcp_timeout", `Hosted hq-mcp request did not finish within ${timeoutMs}ms.`);
107
115
  if (!token.trim())
108
116
  throw new McpOpError("hq_mcp_auth_required", "An hq-mcp audience token is required.");
109
117
  transport = new httpModule.StreamableHTTPClientTransport(this.endpoint, {
@@ -117,15 +125,8 @@ export class HqMcpBridge {
117
125
  });
118
126
  client = new clientModule.Client({ name: "hqd", version: "1" }, { capabilities: {} });
119
127
  this.activeClients.add(client);
120
- timeout = setTimeout(() => {
121
- timedOut = true;
122
- abortController.abort();
123
- void client?.close().catch(() => {
124
- // The deadline already ended the request.
125
- });
126
- }, timeoutMs);
127
128
  await client.connect(transport);
128
- return await work(client, token);
129
+ return await work(client);
129
130
  }
130
131
  catch (error) {
131
132
  if (timedOut) {
@@ -43,7 +43,8 @@ export declare class PolicyIndex {
43
43
  private rewatch;
44
44
  }
45
45
  export interface PolicyCheckArgs {
46
- company: string;
46
+ /** Absent means personal/core scope only. */
47
+ company?: string;
47
48
  event?: string;
48
49
  tool: string;
49
50
  input?: Record<string, unknown>;
@@ -66,7 +67,7 @@ export interface PolicyCheckResult {
66
67
  * session id of its own per company + caller, so a check never borrows the
67
68
  * binding of an unrelated session.
68
69
  */
69
- export declare function bindCheckSession(hqRoot: string, company: string, callerSession: string | undefined, now: Date): string;
70
+ export declare function bindCheckSession(hqRoot: string, company: string | undefined, callerSession: string | undefined, now: Date): string;
70
71
  export interface PolicyCheckDeps {
71
72
  hqRoot: string;
72
73
  index: PolicyIndex;
@@ -162,12 +162,13 @@ function runScript(script, payload, env, cwd, timeoutMs) {
162
162
  */
163
163
  export function bindCheckSession(hqRoot, company, callerSession, now) {
164
164
  const caller = (callerSession ?? "default").replace(/[^A-Za-z0-9._-]/g, "_").slice(0, 80) || "default";
165
- const sid = `hqd-${company}-${caller}`;
165
+ const scope = company ?? "personal";
166
+ const sid = `hqd-${scope}-${caller}`;
166
167
  if (!SESSION_RE.test(sid))
167
168
  throw new PolicyOpError("bad_args", "sessionId has no usable characters");
168
169
  const dir = path.join(hqRoot, "workspace", "sessions", sid);
169
170
  const meta = path.join(dir, "meta.yaml");
170
- const body = `session_id: ${sid}\nstarted_at: ${now.toISOString()}\nsenior: user\ncompany_slug: ${company}\nsource: hqd\n`;
171
+ const body = `session_id: ${sid}\nstarted_at: ${now.toISOString()}\nsenior: user\n${company ? `company_slug: ${company}\n` : ""}source: hqd\n`;
171
172
  let current = "";
172
173
  try {
173
174
  current = fs.readFileSync(meta, "utf8");
@@ -176,7 +177,9 @@ export function bindCheckSession(hqRoot, company, callerSession, now) {
176
177
  if (err.code !== "ENOENT")
177
178
  throw err;
178
179
  }
179
- if (!current.includes(`\ncompany_slug: ${company}\n`)) {
180
+ if (company
181
+ ? !current.includes(`\ncompany_slug: ${company}\n`)
182
+ : !current.includes(`session_id: ${sid}\n`) || current.includes("\ncompany_slug:")) {
180
183
  fs.mkdirSync(dir, { recursive: true });
181
184
  const tmp = `${meta}.${process.pid}.tmp`;
182
185
  fs.writeFileSync(tmp, body);
@@ -214,9 +217,9 @@ function blockFromJson(stdout) {
214
217
  }
215
218
  export async function policyCheck(args, deps) {
216
219
  const { hqRoot } = deps;
217
- if (!SLUG_RE.test(args.company))
220
+ if (args.company !== undefined && !SLUG_RE.test(args.company))
218
221
  throw new PolicyOpError("bad_args", "company must be a company slug");
219
- if (!isDir(path.join(hqRoot, "companies", args.company))) {
222
+ if (args.company !== undefined && !isDir(path.join(hqRoot, "companies", args.company))) {
220
223
  throw new PolicyOpError("unknown_company", `no companies/${args.company} under ${hqRoot}`);
221
224
  }
222
225
  const authorizer = path.join(hqRoot, SCOPE_AUTHORIZER);
@@ -237,9 +240,12 @@ export async function policyCheck(args, deps) {
237
240
  ...(deps.env ?? process.env),
238
241
  HQ_ROOT: hqRoot,
239
242
  CLAUDE_PROJECT_DIR: hqRoot,
240
- HQ_POLICY_COMPANY: args.company,
241
243
  BASH_ENV: "/dev/null",
242
244
  };
245
+ if (args.company === undefined)
246
+ delete env.HQ_POLICY_COMPANY;
247
+ else
248
+ env.HQ_POLICY_COMPANY = args.company;
243
249
  delete env.HQ_HOOK_SESSION_ID;
244
250
  delete env.HQ_HOOK_TOOL_NAME;
245
251
  const timeoutMs = deps.timeoutMs ?? 15_000;
@@ -8,7 +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
+ import { HqMcpBridge, type RunWorkerRequest } from "./ops/mcp.js";
12
12
  export interface HqdServerOptions {
13
13
  socketPath: string;
14
14
  /** registry.json to resolve against (US-004). */
@@ -27,6 +27,8 @@ export interface HqdServerOptions {
27
27
  mcpEnabled?: () => boolean | Promise<boolean>;
28
28
  /** Test seam for the hosted Streamable HTTP MCP transport. */
29
29
  mcpBridge?: HqMcpBridge;
30
+ /** Test seam for reduced host parity runs without spawning an agent process. */
31
+ workerDispatch?: (request: RunWorkerRequest, hqRoot: string, env: NodeJS.ProcessEnv, now: Date) => Promise<Record<string, unknown>>;
30
32
  /** Crash reporter for internal op failures (US-012); run.ts wires Sentry here. */
31
33
  reportError?: (op: string, err: unknown) => void;
32
34
  }
@@ -20,6 +20,31 @@ import { PolicyIndex, PolicyOpError, policyCheck } from "./ops/policy.js";
20
20
  import { HqMcpBridge, jsonResult, McpOpError, runWorker } from "./ops/mcp.js";
21
21
  import { HQ_MCP_TOOL_NAMES, HQD_NATIVE_TOOL_NAMES } from "../mcp/tools.js";
22
22
  import { anywhereRuntimeEnabled, HQ_ANYWHERE_RUNTIME_FLAG } from "../../commands/anywhere-runtime-flag.js";
23
+ // The allowlist is deliberately read-only. A newly added hosted MCP tool must
24
+ // pass hq.policy_check until it is explicitly reviewed as read-only here.
25
+ const MCP_READ_ONLY_TOOL_NAMES = new Set([
26
+ "search",
27
+ "fetch",
28
+ "hq_companies_list",
29
+ "hq_company_ping",
30
+ "hq_exec_check",
31
+ "hq_files_list",
32
+ "hq_files_read",
33
+ "hq_knowledge_get",
34
+ "hq_knowledge_list",
35
+ "hq_knowledge_search",
36
+ "hq_ping",
37
+ "hq_policies_list",
38
+ "hq_policy_get",
39
+ "hq_project_get",
40
+ "hq_project_status",
41
+ "hq_projects_list",
42
+ "hq_run_list",
43
+ "hq_secrets_list",
44
+ "hq_secrets_schema",
45
+ "hq_version",
46
+ "hq_whoami",
47
+ ]);
23
48
  class OpError extends Error {
24
49
  code;
25
50
  constructor(code, message) {
@@ -165,14 +190,15 @@ export async function startHqdServer(opts) {
165
190
  case "policy.check": {
166
191
  const hqRoot = requireRoot();
167
192
  const company = companyFor(args);
168
- if (!company)
193
+ const personalCapture = args.personalScope === true && args.tool === "hq_personal_capture";
194
+ if (!company && !personalCapture)
169
195
  throw new OpError("bad_args", "company is required (or a cwd linked to a company)");
170
196
  const input = args.input;
171
197
  if (input !== undefined && (input === null || typeof input !== "object" || Array.isArray(input))) {
172
198
  throw new OpError("bad_args", "input must be an object");
173
199
  }
174
200
  return policyCheck({
175
- company,
201
+ ...(company ? { company } : {}),
176
202
  event: stringArg(args, "event", false),
177
203
  tool: stringArg(args, "tool"),
178
204
  input: input,
@@ -212,6 +238,7 @@ export async function startHqdServer(opts) {
212
238
  }, { hqRoot: opts.hqRoot, workMeshRoot: opts.workMeshRoot, env, now: clock });
213
239
  }
214
240
  case "mcp.call": {
241
+ const requestStartedAt = Date.now();
215
242
  const name = stringArg(args, "name");
216
243
  const raw = args.arguments ?? {};
217
244
  if (raw === null || typeof raw !== "object" || Array.isArray(raw))
@@ -229,21 +256,42 @@ export async function startHqdServer(opts) {
229
256
  companyArgs.sessionId = sessionId;
230
257
  const call = (op, a) => handle({ id: req.id, op, args: a });
231
258
  const asResult = (value) => (value instanceof Promise ? value.then(jsonResult) : jsonResult(value));
259
+ const policyGuardedCall = (run) => {
260
+ const company = companyFor(companyArgs);
261
+ const personalCapture = name === "hq_personal_capture" && !company;
262
+ if (!company && !personalCapture)
263
+ throw new OpError("bad_args", "company is required for a write-capable MCP tool");
264
+ return Promise.resolve(call("policy.check", {
265
+ ...(company ? { company } : { personalScope: true }),
266
+ cwd: typeof toolArgs.cwd === "string" ? toolArgs.cwd : process.cwd(),
267
+ ...(runtime ? { runtime } : {}),
268
+ ...(sessionId ? { sessionId } : {}),
269
+ tool: name,
270
+ input: raw,
271
+ })).then((verdict) => {
272
+ const decision = verdict;
273
+ if (decision.decision !== "allow") {
274
+ const reason = Array.isArray(decision.reasons) ? decision.reasons.filter((r) => typeof r === "string").join("; ") : "policy check did not allow this tool";
275
+ throw new OpError("policy_blocked", reason || "policy check did not allow this tool");
276
+ }
277
+ return run();
278
+ });
279
+ };
232
280
  switch (name) {
233
281
  case "hq_resolve_company":
234
282
  return asResult(call("resolve", { cwd: toolArgs.cwd ?? process.cwd() }));
235
283
  case "hq_policy_check":
236
284
  return asResult(call("policy.check", companyArgs));
237
285
  case "hq_journal_append":
238
- return asResult(call("journal.append", companyArgs));
286
+ return policyGuardedCall(() => call("journal.append", companyArgs)).then(jsonResult);
239
287
  case "hq_run_worker": {
240
288
  const tool = companyArgs;
241
- return runWorker({
289
+ return policyGuardedCall(() => (opts.workerDispatch ?? runWorker)({
242
290
  worker: stringArg(tool, "worker"),
243
291
  skill: stringArg(tool, "skill", false),
244
292
  args: typeof tool.args === "string" ? tool.args : undefined,
245
293
  company: companyFor(tool),
246
- }, requireRoot(), env, clock()).then(jsonResult);
294
+ }, requireRoot(), env, clock())).then(jsonResult);
247
295
  }
248
296
  default: {
249
297
  if (HQD_NATIVE_TOOL_NAMES.has(name) || !HQ_MCP_TOOL_NAMES.has(name))
@@ -252,13 +300,21 @@ export async function startHqdServer(opts) {
252
300
  const timeoutMs = typeof args.timeoutMs === "number" && Number.isFinite(args.timeoutMs)
253
301
  ? Math.max(1, Math.min(args.timeoutMs, 120_000))
254
302
  : HQD_DEFAULT_REQUEST_TIMEOUT_MS;
255
- return Promise.resolve(mcpEnabled()).then((enabled) => {
303
+ const deadline = requestStartedAt + timeoutMs;
304
+ const forward = () => Promise.resolve(mcpEnabled()).then((enabled) => {
256
305
  if (!enabled) {
257
306
  throw new McpOpError("hq_mcp_unavailable", `Hosted hq-mcp forwarding is disabled by the ${HQ_ANYWHERE_RUNTIME_FLAG} flag.`);
258
307
  }
308
+ const remainingMs = Math.floor(deadline - Date.now());
309
+ if (remainingMs <= 0) {
310
+ throw new McpOpError("hq_mcp_timeout", `Hosted hq-mcp request exceeded its ${timeoutMs}ms end-to-end deadline before forwarding.`);
311
+ }
259
312
  hqMcp ??= new HqMcpBridge({ env });
260
- return hqMcp.callTool(name, raw, timeoutMs);
313
+ return hqMcp.callTool(name, raw, remainingMs);
261
314
  });
315
+ return !MCP_READ_ONLY_TOOL_NAMES.has(name)
316
+ ? policyGuardedCall(forward)
317
+ : forward();
262
318
  }
263
319
  }
264
320
  }