@indigoai-us/hq-cli 5.345.104 → 5.345.106

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 (83) hide show
  1. package/CHANGELOG.md +100 -0
  2. package/assets/bot-workers/setup/skills/add-a-pack.md +130 -0
  3. package/assets/bot-workers/setup/worker.yaml +3 -0
  4. package/dist/command-catalog.generated.d.ts +206 -0
  5. package/dist/command-catalog.generated.js +246 -0
  6. package/dist/commands/bot.d.ts +10 -0
  7. package/dist/commands/bot.js +20 -12
  8. package/dist/commands/daemon.js +385 -1
  9. package/dist/commands/integrations-connect.d.ts +6 -0
  10. package/dist/commands/integrations-connect.js +36 -0
  11. package/dist/commands/integrations-slack.d.ts +30 -0
  12. package/dist/commands/integrations-slack.js +81 -0
  13. package/dist/commands/integrations.js +2 -0
  14. package/dist/commands/logout.d.ts +6 -1
  15. package/dist/commands/logout.js +18 -2
  16. package/dist/commands/pack-install.d.ts +20 -1
  17. package/dist/commands/pack-install.js +80 -5
  18. package/dist/commands/pack-start-bot.d.ts +24 -0
  19. package/dist/commands/pack-start-bot.js +76 -0
  20. package/dist/commands/packs.d.ts +9 -0
  21. package/dist/commands/packs.js +26 -3
  22. package/dist/commands/pkg-install.js +11 -2
  23. package/dist/commands/secrets.d.ts +1 -5
  24. package/dist/commands/secrets.js +2 -92
  25. package/dist/fast-scaffold.d.ts +13 -0
  26. package/dist/fast-scaffold.js +136 -0
  27. package/dist/index.js +15 -1
  28. package/dist/lib/bot/config.d.ts +7 -0
  29. package/dist/lib/bot/daemon.js +2 -1
  30. package/dist/lib/bot/index.d.ts +2 -0
  31. package/dist/lib/bot/index.js +2 -0
  32. package/dist/lib/bot/lean-settings.d.ts +16 -0
  33. package/dist/lib/bot/lean-settings.js +34 -0
  34. package/dist/lib/bot/pack-link.d.ts +66 -0
  35. package/dist/lib/bot/pack-link.js +153 -0
  36. package/dist/lib/bot/pack-setup-driver.d.ts +37 -0
  37. package/dist/lib/bot/pack-setup-driver.js +83 -0
  38. package/dist/lib/bot/pack-setup.d.ts +62 -0
  39. package/dist/lib/bot/pack-setup.js +103 -0
  40. package/dist/lib/bot/prompt.d.ts +8 -0
  41. package/dist/lib/bot/prompt.js +5 -0
  42. package/dist/lib/bot/run.d.ts +18 -2
  43. package/dist/lib/bot/run.js +163 -25
  44. package/dist/lib/bot/runtime/claude.d.ts +27 -2
  45. package/dist/lib/bot/runtime/claude.js +49 -4
  46. package/dist/lib/bot/runtime/types.d.ts +5 -0
  47. package/dist/lib/bot/runtime-failure.d.ts +18 -2
  48. package/dist/lib/bot/runtime-failure.js +21 -1
  49. package/dist/lib/bot/toolchain.d.ts +25 -0
  50. package/dist/lib/bot/toolchain.js +33 -0
  51. package/dist/lib/bot/worker-source.d.ts +1 -0
  52. package/dist/lib/bot/worker-source.js +7 -1
  53. package/dist/lib/daemon/catalog.d.ts +15 -0
  54. package/dist/lib/daemon/catalog.js +49 -0
  55. package/dist/lib/daemon/paths.d.ts +1 -0
  56. package/dist/lib/daemon/paths.js +1 -0
  57. package/dist/lib/daemon/remote-control-forwarder.d.ts +14 -0
  58. package/dist/lib/daemon/remote-control-forwarder.js +104 -0
  59. package/dist/lib/daemon/remote-control-pools.d.ts +44 -0
  60. package/dist/lib/daemon/remote-control-pools.js +245 -0
  61. package/dist/lib/daemon/remote-control.d.ts +4 -0
  62. package/dist/lib/daemon/remote-control.js +17 -2
  63. package/dist/lib/daemon/run.js +126 -0
  64. package/dist/lib/doctor/fix/refresh-sync-progress.d.ts +5 -4
  65. package/dist/lib/doctor/fix/refresh-sync-progress.js +5 -4
  66. package/dist/lib/doctor/json-output.d.ts +5 -5
  67. package/dist/lib/doctor/json-output.js +2 -5
  68. package/dist/lib/packages/setup-block.d.ts +43 -0
  69. package/dist/lib/packages/setup-block.js +236 -0
  70. package/dist/lib/packages/setup-prompt.d.ts +34 -0
  71. package/dist/lib/packages/setup-prompt.js +128 -0
  72. package/dist/lib/packages/setup-takeover.d.ts +50 -0
  73. package/dist/lib/packages/setup-takeover.js +75 -0
  74. package/dist/lib/packages/start-bot.d.ts +47 -0
  75. package/dist/lib/packages/start-bot.js +66 -0
  76. package/dist/lib/session-company-lock.d.ts +7 -0
  77. package/dist/lib/session-company-lock.js +97 -0
  78. package/dist/types.d.ts +82 -0
  79. package/dist/utils/cognito-config.d.ts +5 -0
  80. package/dist/utils/cognito-config.js +14 -0
  81. package/dist/utils/cognito-revoke.d.ts +33 -0
  82. package/dist/utils/cognito-revoke.js +42 -0
  83. package/package.json +1 -1
@@ -19,6 +19,7 @@ import * as fs from "node:fs";
19
19
  import * as os from "node:os";
20
20
  import * as path from "node:path";
21
21
  import { messagesStreamEvents, messagesStreamFailure, messagesStreamResult } from "./messages-stream.js";
22
+ import { hqAppToolchainBin } from "../toolchain.js";
22
23
  import { resolveRuntimeBinary } from "./windows.js";
23
24
  export const CLAUDE_PERMISSION_MODE_ENV = "HQ_BOT_CLAUDE_PERMISSION_MODE";
24
25
  export const DEFAULT_CLAUDE_PERMISSION_MODE = "bypassPermissions";
@@ -104,18 +105,59 @@ export function bundledClaudeBin(homeDir = os.homedir()) {
104
105
  });
105
106
  return candidates.at(-1)?.bin ?? null;
106
107
  }
107
- export function resolveClaudeBinary(env, homeDir, platform = process.platform, cwd = process.cwd()) {
108
+ /**
109
+ * Where a bot looks for `claude`, in order, outside the PATH walk. The HQ
110
+ * desktop app's toolchain comes first: on a clean Mac it is the only copy, and
111
+ * it is the one the app keeps updated. Then the usual install prefixes, so a
112
+ * bot under launchd (no shell profile) still finds a Homebrew or npm global
113
+ * install even when its PATH is thin.
114
+ */
115
+ export const CLAUDE_SYSTEM_BIN_DIRS = ["/usr/local/bin", "/opt/homebrew/bin", "/usr/bin"];
116
+ function executableAt(file) {
117
+ try {
118
+ fs.accessSync(file, fs.constants.X_OK);
119
+ return fs.statSync(file).isFile();
120
+ }
121
+ catch {
122
+ return false;
123
+ }
124
+ }
125
+ /**
126
+ * The `claude` a bot spawns. Order (contract "Claude resolution for bots"):
127
+ *
128
+ * 1. `~/Library/Application Support/Indigo HQ/toolchain/npm-global/bin/claude`
129
+ * (the HQ app's own install; macOS only)
130
+ * 2. /usr/local/bin, /opt/homebrew/bin, /usr/bin
131
+ * 3. PATH (returned as the bare name, so the spawn uses the same lookup)
132
+ * 4. the Claude desktop app's bundled Claude Code
133
+ * 5. the bare name, so the failure reads "claude is not installed".
134
+ *
135
+ * Absolute paths come back for 1, 2 and 4 so the bot log can say which one
136
+ * ran.
137
+ */
138
+ export function resolveClaudeBinary(env, homeDir, platform = process.platform, cwd = process.cwd(), systemBinDirs = CLAUDE_SYSTEM_BIN_DIRS) {
108
139
  if (platform === "win32")
109
140
  return resolveRuntimeBinary("claude", env, platform, fs.existsSync, cwd);
141
+ const home = homeDir ?? env.HOME ?? os.homedir();
142
+ if (platform === "darwin") {
143
+ const toolchain = hqAppToolchainBin("claude", home);
144
+ if (executableAt(toolchain))
145
+ return toolchain;
146
+ }
147
+ for (const dir of systemBinDirs) {
148
+ const candidate = path.join(dir, "claude");
149
+ if (executableAt(candidate))
150
+ return candidate;
151
+ }
110
152
  if (onPath("claude", env))
111
153
  return "claude";
112
- return bundledClaudeBin(homeDir ?? env.HOME ?? os.homedir()) ?? "claude";
154
+ return bundledClaudeBin(home) ?? "claude";
113
155
  }
114
- export function createClaudeRuntime(env = process.env) {
156
+ export function createClaudeRuntime(env = process.env, opts = {}) {
115
157
  return {
116
158
  id: "claude",
117
159
  binary: "claude",
118
- resolveBinary: (turnEnv, cwd, platform) => resolveClaudeBinary(turnEnv, undefined, platform ?? process.platform, cwd),
160
+ resolveBinary: (turnEnv, cwd, platform) => resolveClaudeBinary(turnEnv, undefined, platform ?? process.platform, cwd, opts.systemBinDirs ?? CLAUDE_SYSTEM_BIN_DIRS),
119
161
  buildArgs(input, scratch) {
120
162
  const usesWindowsStdin = input.platform === "win32";
121
163
  const args = [
@@ -142,6 +184,9 @@ export function createClaudeRuntime(env = process.env) {
142
184
  }
143
185
  if (input.model)
144
186
  args.push("--model", input.model);
187
+ if (input.leanSettingsFile) {
188
+ args.push("--strict-mcp-config", "--setting-sources", "", "--settings", input.leanSettingsFile);
189
+ }
145
190
  if (input.sessionId)
146
191
  args.push("--resume", input.sessionId);
147
192
  else
@@ -33,6 +33,11 @@ export interface RuntimeTurnInput {
33
33
  sessionId?: string;
34
34
  /** Optional model override. */
35
35
  model?: string;
36
+ /**
37
+ * Claude only: a lean turn. The settings file holds HQ's tool hooks; the turn
38
+ * loads no other settings, plugins or MCP servers (BotConfig.lean).
39
+ */
40
+ leanSettingsFile?: string;
36
41
  /** Thinking level for the CLI (runtimes fall back to medium). */
37
42
  effort?: string;
38
43
  /**
@@ -89,7 +89,7 @@ export interface RuntimeRepairNotice {
89
89
  cls: RuntimeFailureClass;
90
90
  /** The plain DM body. */
91
91
  text: string;
92
- /** Absent for `not-installed` and `unknown`: those get the plain text only. */
92
+ /** Absent for `not-installed` and `unknown`: those get the plain text (plus a Retry suggestion in a DM). */
93
93
  richContent?: RuntimeRichContent;
94
94
  }
95
95
  export declare function runtimeRepairBlock(args: {
@@ -99,7 +99,23 @@ export declare function runtimeRepairBlock(args: {
99
99
  retryOf?: string;
100
100
  suggestedModel?: string;
101
101
  }): RuntimeRepairBlock;
102
- /** The plain body plus, for a repairable class, the repair block. */
102
+ /** The tap-to-answer button under a failure that carries no repair card. */
103
+ export declare const RETRY_SUGGESTION = "Retry";
104
+ /**
105
+ * The `hq-block` suggestions envelope the desktop draws as buttons (the same
106
+ * one the setup bot uses for its questions). Tapping sends the text back as
107
+ * the person's next message, so a failed turn is never a dead end.
108
+ */
109
+ export declare function suggestionsBlock(items: string[]): string;
110
+ /** `text` followed by a one-button Retry envelope. */
111
+ export declare function withRetrySuggestion(text: string): string;
112
+ /**
113
+ * The plain body plus, for a repairable class, the repair block. Classes
114
+ * without a card (`not-installed`, `unknown`) get the plain text; the DM post
115
+ * path adds a Retry suggestion under it (see run.ts postRepair): on 2026-10-11
116
+ * a fresh bot whose Claude Code was missing left the person with nothing to
117
+ * press.
118
+ */
103
119
  export declare function runtimeRepairNotice(args: {
104
120
  cls: RuntimeFailureClass;
105
121
  runtime: BotRuntimeId | string;
@@ -155,7 +155,27 @@ export function runtimeRepairBlock(args) {
155
155
  ...(args.suggestedModel ? { suggestedModel: args.suggestedModel.slice(0, 128) } : {}),
156
156
  };
157
157
  }
158
- /** The plain body plus, for a repairable class, the repair block. */
158
+ /** The tap-to-answer button under a failure that carries no repair card. */
159
+ export const RETRY_SUGGESTION = "Retry";
160
+ /**
161
+ * The `hq-block` suggestions envelope the desktop draws as buttons (the same
162
+ * one the setup bot uses for its questions). Tapping sends the text back as
163
+ * the person's next message, so a failed turn is never a dead end.
164
+ */
165
+ export function suggestionsBlock(items) {
166
+ return "```hq-block\n" + JSON.stringify({ v: 1, blocks: [{ kind: "suggestions", items }] }) + "\n```";
167
+ }
168
+ /** `text` followed by a one-button Retry envelope. */
169
+ export function withRetrySuggestion(text) {
170
+ return `${text}\n\n${suggestionsBlock([RETRY_SUGGESTION])}`;
171
+ }
172
+ /**
173
+ * The plain body plus, for a repairable class, the repair block. Classes
174
+ * without a card (`not-installed`, `unknown`) get the plain text; the DM post
175
+ * path adds a Retry suggestion under it (see run.ts postRepair): on 2026-10-11
176
+ * a fresh bot whose Claude Code was missing left the person with nothing to
177
+ * press.
178
+ */
159
179
  export function runtimeRepairNotice(args) {
160
180
  const text = args.text ?? runtimeFailureText(args.cls, args.runtime, args.context);
161
181
  if (!isRuntimeRepairClass(args.cls))
@@ -0,0 +1,25 @@
1
+ /**
2
+ * The HQ desktop app's managed toolchain.
3
+ *
4
+ * The app installs node, hq-cli and @anthropic-ai/claude-code under one
5
+ * prefix so a clean Mac can run bots without the person installing anything:
6
+ *
7
+ * ~/Library/Application Support/Indigo HQ/toolchain/
8
+ * node/bin/node
9
+ * npm-global/bin/hq
10
+ * npm-global/bin/claude
11
+ *
12
+ * The bot daemon's PATH, the hq-cli update watcher and the Claude Code
13
+ * resolver all key on the same root, so it lives here once.
14
+ */
15
+ /** Path segments under the home directory. macOS only; other platforms have no app toolchain. */
16
+ export declare const HQ_APP_TOOLCHAIN_SEGMENTS: readonly ["Library", "Application Support", "Indigo HQ", "toolchain"];
17
+ /** The npm prefix the app installs CLIs into, relative to the toolchain root. */
18
+ export declare const HQ_APP_TOOLCHAIN_NPM_GLOBAL_BIN: string;
19
+ /** `~/Library/Application Support/Indigo HQ/toolchain`. */
20
+ export declare function hqAppToolchainRoot(homeDir?: string): string;
21
+ /** `<toolchain>/npm-global/bin`, where `hq` and `claude` live. */
22
+ export declare function hqAppToolchainBinDir(homeDir?: string): string;
23
+ /** `<toolchain>/npm-global/bin/<name>`: where the app puts a CLI it manages. */
24
+ export declare function hqAppToolchainBin(name: string, homeDir?: string): string;
25
+ //# sourceMappingURL=toolchain.d.ts.map
@@ -0,0 +1,33 @@
1
+ /**
2
+ * The HQ desktop app's managed toolchain.
3
+ *
4
+ * The app installs node, hq-cli and @anthropic-ai/claude-code under one
5
+ * prefix so a clean Mac can run bots without the person installing anything:
6
+ *
7
+ * ~/Library/Application Support/Indigo HQ/toolchain/
8
+ * node/bin/node
9
+ * npm-global/bin/hq
10
+ * npm-global/bin/claude
11
+ *
12
+ * The bot daemon's PATH, the hq-cli update watcher and the Claude Code
13
+ * resolver all key on the same root, so it lives here once.
14
+ */
15
+ import * as os from "node:os";
16
+ import * as path from "node:path";
17
+ /** Path segments under the home directory. macOS only; other platforms have no app toolchain. */
18
+ export const HQ_APP_TOOLCHAIN_SEGMENTS = ["Library", "Application Support", "Indigo HQ", "toolchain"];
19
+ /** The npm prefix the app installs CLIs into, relative to the toolchain root. */
20
+ export const HQ_APP_TOOLCHAIN_NPM_GLOBAL_BIN = path.join("npm-global", "bin");
21
+ /** `~/Library/Application Support/Indigo HQ/toolchain`. */
22
+ export function hqAppToolchainRoot(homeDir = os.homedir()) {
23
+ return path.join(homeDir, ...HQ_APP_TOOLCHAIN_SEGMENTS);
24
+ }
25
+ /** `<toolchain>/npm-global/bin`, where `hq` and `claude` live. */
26
+ export function hqAppToolchainBinDir(homeDir = os.homedir()) {
27
+ return path.join(hqAppToolchainRoot(homeDir), HQ_APP_TOOLCHAIN_NPM_GLOBAL_BIN);
28
+ }
29
+ /** `<toolchain>/npm-global/bin/<name>`: where the app puts a CLI it manages. */
30
+ export function hqAppToolchainBin(name, homeDir = os.homedir()) {
31
+ return path.join(hqAppToolchainBinDir(homeDir), name);
32
+ }
33
+ //# sourceMappingURL=toolchain.js.map
@@ -48,6 +48,7 @@ export interface WorkerSourceFs {
48
48
  }) => Array<{
49
49
  name: string;
50
50
  isDirectory: () => boolean;
51
+ isSymbolicLink?: () => boolean;
51
52
  }>;
52
53
  }
53
54
  /** Collapse whitespace and take the first sentence, capped at `max` chars. */
@@ -111,7 +111,13 @@ function scanCoreWorkerDirs(hqRoot, io) {
111
111
  return;
112
112
  }
113
113
  for (const e of entries) {
114
- if (!e.isDirectory() || e.name.startsWith(".") || SKIP_DIRS.has(e.name))
114
+ if (e.name.startsWith(".") || SKIP_DIRS.has(e.name))
115
+ continue;
116
+ // A pack worker is a symlink (core/workers/public/<id> ->
117
+ // core/packages/<pack>/workers/<id>); a Dirent reports it as a link, not a
118
+ // directory, so follow it when it holds a worker.yaml.
119
+ const linkedWorker = !e.isDirectory() && e.isSymbolicLink?.() === true && io.existsSync(path.join(hqRoot, rel, e.name, "worker.yaml"));
120
+ if (!e.isDirectory() && !linkedWorker)
115
121
  continue;
116
122
  walk(path.join(rel, e.name), depth + 1);
117
123
  }
@@ -11,6 +11,7 @@
11
11
  */
12
12
  import type { MachineType } from "./machine-type.js";
13
13
  import type { SyncOwner } from "./config.js";
14
+ import type { RemoteControlPoolsConfig } from "./remote-control-pools.js";
14
15
  import { type SyncRunnerProcess } from "./sync-runner.js";
15
16
  export declare const SERVICE_NAMES: readonly ["sync", "mesh", "heartbeat", "inbox", "bots", "remote-control", "codex-remote-control", "session-host", "lanes-reconcile", "search-index", "jobs-reconcile", "updater", "housekeeping"];
16
17
  export type ServiceName = (typeof SERVICE_NAMES)[number];
@@ -125,6 +126,20 @@ export interface PlanContext {
125
126
  tmuxPath: string | null;
126
127
  /** Short host name, used to name this machine's remote-control session. */
127
128
  hostName: string;
129
+ /** Main A Claude config dir shared by the pool's control plane. */
130
+ claudeConfigDir?: string;
131
+ /** Optional host-local RC pool desired state; absent/empty keeps legacy behavior. */
132
+ remoteControlPools?: RemoteControlPoolsConfig;
133
+ /** Invalid host-local desired state remains visible instead of disappearing. */
134
+ remoteControlPoolsError?: string;
135
+ /** Secret names whose presence was verified through HQ Secrets. */
136
+ availablePoolSecrets?: ReadonlySet<string>;
137
+ /** Secret names whose presence check failed for a reason other than absence. */
138
+ unverifiablePoolSecrets?: ReadonlySet<string>;
139
+ /** Pool accounts whose configured personal service-session capability is invalid or missing. */
140
+ invalidPoolServiceSessions?: ReadonlySet<string>;
141
+ availablePoolFrontends?: ReadonlySet<string>;
142
+ unverifiablePoolFrontends?: ReadonlySet<string>;
128
143
  /** The session name the box's own runner used (`--name` in its script), kept when the daemon takes over. */
129
144
  remoteControlName: string | null;
130
145
  /** Absolute path of the login user's codex binary, or null. */
@@ -364,6 +364,55 @@ export function planUnits(services, ctx) {
364
364
  units.push(unit);
365
365
  }
366
366
  }
367
+ // Pool units are desired-state instances rather than service-name toggles.
368
+ // This leaves the existing A unit and no-pools plan byte-for-byte unchanged.
369
+ for (const pool of ctx.remoteControlPools?.pools ?? []) {
370
+ units.push(base("remote-control", {
371
+ id: `remote-control-pool-forwarder:${pool.accountId}`,
372
+ description: `keeps the account ${pool.accountId} inference forwarder on 127.0.0.1:${pool.forwarderPort}`,
373
+ command: { type: "hq", args: ["daemon", "service", "remote-control-pool-forwarder-host", "--account-id", pool.accountId] },
374
+ env: { ...(ctx.hqRoot ? { HQ_ROOT: ctx.hqRoot } : {}), HQ_POOL_CONFIG: JSON.stringify([pool, ctx.remoteControlPools?.frontendCaPath, ctx.remoteControlPools?.serviceSessionId]) },
375
+ keepAcrossCliUpdates: true,
376
+ blocked: firstBlock([
377
+ [ctx.platform !== "linux", "Remote Control account pools require Linux mount namespaces"],
378
+ [!ctx.hqRoot, NO_HQ_ROOT],
379
+ [ctx.invalidPoolServiceSessions?.has(pool.accountId) === true, "configured serviceSessionId must reference an existing personal-only scope capability"],
380
+ [ctx.unverifiablePoolSecrets?.has(pool.secretName) === true, "could not verify the configured HQ Secret"],
381
+ [ctx.availablePoolSecrets?.has(pool.secretName) !== true, "the configured HQ Secret is missing"],
382
+ ]),
383
+ }));
384
+ const unit = base("remote-control", {
385
+ id: `remote-control-pool:${pool.accountId}`,
386
+ description: `keeps Claude remote control running for account ${pool.accountId}`,
387
+ command: { type: "hq", args: ["daemon", "service", "remote-control-pool", "--account-id", pool.accountId] },
388
+ env: {
389
+ ...(ctx.hqRoot ? { HQ_ROOT: ctx.hqRoot } : {}),
390
+ ...(ctx.claudeConfigDir ? { CLAUDE_CONFIG_DIR: ctx.claudeConfigDir } : {}),
391
+ HQ_POOL_CONFIG: JSON.stringify([pool, ctx.remoteControlPools?.frontendCaPath, ctx.remoteControlPools?.serviceSessionId]),
392
+ },
393
+ keepAcrossCliUpdates: true,
394
+ blocked: firstBlock([
395
+ [ctx.platform !== "linux", "Remote Control account pools require Linux mount namespaces"],
396
+ [!ctx.claudePath, "the claude CLI is not installed"],
397
+ [!ctx.tmuxPath, "tmux is not installed (remote control runs Claude inside tmux)"],
398
+ [!ctx.hqRoot, NO_HQ_ROOT],
399
+ [ctx.invalidPoolServiceSessions?.has(pool.accountId) === true, "configured serviceSessionId must reference an existing personal-only scope capability"],
400
+ [ctx.unverifiablePoolSecrets?.has(pool.secretName) === true, "could not verify the configured HQ Secret"],
401
+ [ctx.availablePoolSecrets?.has(pool.secretName) !== true, "the configured HQ Secret is missing"],
402
+ [ctx.unverifiablePoolFrontends?.has(pool.accountId) === true, "could not verify the configured nginx frontend"],
403
+ [ctx.availablePoolFrontends?.has(pool.accountId) !== true, "the configured nginx frontend is not responding on HTTPS port 443"],
404
+ ]),
405
+ });
406
+ units.push(unit);
407
+ }
408
+ if (ctx.remoteControlPoolsError) {
409
+ units.push(base("remote-control", {
410
+ id: "remote-control-pools:config",
411
+ description: "validates Claude Remote Control account pool desired state",
412
+ command: null,
413
+ blocked: { state: "unavailable", reason: `invalid remote-control pool config: ${ctx.remoteControlPoolsError}` },
414
+ }));
415
+ }
367
416
  return units;
368
417
  }
369
418
  //# sourceMappingURL=catalog.js.map
@@ -6,6 +6,7 @@
6
6
  export interface DaemonPaths {
7
7
  root: string;
8
8
  configPath: string;
9
+ remoteControlPoolsPath: string;
9
10
  statePath: string;
10
11
  lockPath: string;
11
12
  logsDir: string;
@@ -12,6 +12,7 @@ export function daemonPaths(home = os.homedir(), env = process.env) {
12
12
  return {
13
13
  root,
14
14
  configPath: path.join(root, "config.json"),
15
+ remoteControlPoolsPath: path.join(root, "remote-control-pools.json"),
15
16
  statePath: path.join(root, "state.json"),
16
17
  lockPath: path.join(root, "daemon.pid"),
17
18
  logsDir,
@@ -0,0 +1,14 @@
1
+ /** A narrow, loopback-only Anthropic inference proxy for one RC account pool. */
2
+ import * as https from "node:https";
3
+ import { type IncomingMessage, type Server } from "node:http";
4
+ export declare const POOL_UPSTREAM_TIMEOUT_MS = 600000;
5
+ export declare const POOL_UPSTREAM_CONNECT_TIMEOUT_MS = 30000;
6
+ export declare function inferenceForwardHeaders(incoming: IncomingMessage["headers"], secret: string): Record<string, string | string[]>;
7
+ export declare function poolInferencePathAllowed(method: string | undefined, requestUrl: string | undefined): boolean;
8
+ export declare function createInferenceForwarder(options: {
9
+ port: number;
10
+ secret: () => string | undefined;
11
+ upstream?: typeof https.request;
12
+ upstreamTimeoutMs?: number;
13
+ }): Server;
14
+ //# sourceMappingURL=remote-control-forwarder.d.ts.map
@@ -0,0 +1,104 @@
1
+ /** A narrow, loopback-only Anthropic inference proxy for one RC account pool. */
2
+ import * as https from "node:https";
3
+ import * as dns from "node:dns";
4
+ import { createServer } from "node:http";
5
+ const STRIPPED_HEADERS = new Set([
6
+ "authorization", "x-api-key", "proxy-authorization", "proxy-authenticate", "proxy-connection", "host", "content-length",
7
+ "connection", "keep-alive", "te", "trailer", "transfer-encoding", "upgrade",
8
+ ]);
9
+ export const POOL_UPSTREAM_TIMEOUT_MS = 600_000;
10
+ export const POOL_UPSTREAM_CONNECT_TIMEOUT_MS = 30_000;
11
+ export function inferenceForwardHeaders(incoming, secret) {
12
+ if (!secret)
13
+ throw new Error("the configured HQ Secret is empty");
14
+ const headers = {};
15
+ const hopByHop = new Set(STRIPPED_HEADERS);
16
+ const connection = incoming.connection;
17
+ for (const value of Array.isArray(connection) ? connection : connection ? [connection] : []) {
18
+ for (const name of value.split(","))
19
+ if (name.trim())
20
+ hopByHop.add(name.trim().toLowerCase());
21
+ }
22
+ for (const [name, value] of Object.entries(incoming)) {
23
+ if (value === undefined || hopByHop.has(name.toLowerCase()))
24
+ continue;
25
+ headers[name] = value;
26
+ }
27
+ headers.host = "api.anthropic.com";
28
+ headers.authorization = `Bearer ${secret}`;
29
+ return headers;
30
+ }
31
+ export function poolInferencePathAllowed(method, requestUrl) {
32
+ if (method !== "POST")
33
+ return false;
34
+ const pathname = new URL(requestUrl ?? "/", "http://127.0.0.1").pathname;
35
+ return pathname === "/v1/messages" || pathname.startsWith("/v1/messages/");
36
+ }
37
+ function sendJson(response, status, message) {
38
+ response.writeHead(status, { "content-type": "application/json", connection: "close" });
39
+ response.end(JSON.stringify({ error: { type: "proxy_error", message } }));
40
+ }
41
+ export function createInferenceForwarder(options) {
42
+ const requestUpstream = options.upstream ?? https.request;
43
+ const server = createServer((incoming, response) => {
44
+ if (!poolInferencePathAllowed(incoming.method, incoming.url)) {
45
+ sendJson(response, 404, "only POST /v1/messages is available through this inference forwarder");
46
+ incoming.resume();
47
+ return;
48
+ }
49
+ const secret = options.secret();
50
+ if (!secret) {
51
+ sendJson(response, 503, "the pool inference secret is unavailable");
52
+ incoming.resume();
53
+ return;
54
+ }
55
+ const headers = inferenceForwardHeaders(incoming.headers, secret);
56
+ const upstream = requestUpstream({
57
+ protocol: "https:",
58
+ hostname: "api.anthropic.com",
59
+ lookup: (hostname, _options, callback) => {
60
+ // The host can map this name to A's local proxy in /etc/hosts. DNS
61
+ // resolution bypasses that mapping for the pool's outbound request.
62
+ dns.resolve4(hostname, (error, addresses) => {
63
+ if (_options.all)
64
+ callback(error, addresses?.map((address) => ({ address, family: 4 })) ?? []);
65
+ else
66
+ callback(error, addresses?.[0] ?? "", 4);
67
+ });
68
+ },
69
+ port: 443,
70
+ method: "POST",
71
+ path: incoming.url,
72
+ headers,
73
+ }, (upstreamResponse) => {
74
+ response.writeHead(upstreamResponse.statusCode ?? 502, upstreamResponse.headers);
75
+ upstreamResponse.pipe(response);
76
+ });
77
+ const connectTimeout = setTimeout(() => {
78
+ upstream.destroy(new Error("pool inference upstream connection timed out"));
79
+ }, POOL_UPSTREAM_CONNECT_TIMEOUT_MS);
80
+ upstream.once("socket", (socket) => {
81
+ socket.once("secureConnect", () => clearTimeout(connectTimeout));
82
+ });
83
+ upstream.setTimeout(options.upstreamTimeoutMs ?? POOL_UPSTREAM_TIMEOUT_MS, () => {
84
+ upstream.destroy(new Error("pool inference upstream timed out"));
85
+ });
86
+ upstream.on("error", () => {
87
+ clearTimeout(connectTimeout);
88
+ if (!response.headersSent && !response.destroyed)
89
+ sendJson(response, 502, "the pool inference backend is unavailable");
90
+ else
91
+ response.destroy();
92
+ });
93
+ upstream.once("close", () => clearTimeout(connectTimeout));
94
+ response.once("close", () => {
95
+ clearTimeout(connectTimeout);
96
+ if (!response.writableEnded)
97
+ upstream.destroy();
98
+ });
99
+ incoming.pipe(upstream);
100
+ });
101
+ server.listen(options.port, "127.0.0.1");
102
+ return server;
103
+ }
104
+ //# sourceMappingURL=remote-control-forwarder.js.map
@@ -0,0 +1,44 @@
1
+ /** Host-local desired state for opt-in Claude Remote Control account pools. */
2
+ export interface RemoteControlPool {
3
+ accountId: string;
4
+ capacity: number;
5
+ /** Private loopback address bound by the external nginx TLS listener. */
6
+ frontendAddress: string;
7
+ /** The Anthropic API hostname is HTTPS and the hosts overlay cannot set a port. */
8
+ frontendPort: 443;
9
+ /** Loopback-only inference forwarder owned by hq daemon. */
10
+ forwarderPort: number;
11
+ /** A real mount-point path, not a symlink, populated in a private namespace. */
12
+ mountPath: string;
13
+ /** HQ Secrets name; the secret value is never part of this file. */
14
+ secretName: string;
15
+ }
16
+ export interface RemoteControlPoolsConfig {
17
+ version: 1;
18
+ /** CA certificate trusted by Claude and used to verify the external nginx TLS listener. */
19
+ frontendCaPath?: string;
20
+ /** Stable HQ session whose scope capability is locked to personal for Secrets access. */
21
+ serviceSessionId?: string;
22
+ pools: RemoteControlPool[];
23
+ }
24
+ /** Parse and validate the complete desired state; unknown keys fail closed. */
25
+ export declare function parseRemoteControlPoolsConfig(value: unknown, knownAccountIds?: Iterable<string>, home?: string): RemoteControlPoolsConfig;
26
+ /** Override ambient session selection only for a Secrets child process. */
27
+ export declare function remoteControlPoolSecretsEnv(env: NodeJS.ProcessEnv, serviceSessionId: string): NodeJS.ProcessEnv;
28
+ /** Require an explicit, existing capability; never inherit the mutable .current session. */
29
+ export declare function assertPersonalServiceSessionCapability(root: string, serviceSessionId: string): void;
30
+ /** Hosts file overlay consumed inside this pool's private mount namespace. */
31
+ export declare function remoteControlPoolHostsOverlay(pool: RemoteControlPool): string;
32
+ export declare function poolHostsFile(baseHosts: string, pool: RemoteControlPool): string;
33
+ export declare function readRemoteControlPoolsConfig(file: string, knownAccountIds?: Iterable<string>, home?: string): RemoteControlPoolsConfig;
34
+ export declare function writeRemoteControlPoolsConfig(file: string, config: RemoteControlPoolsConfig, home?: string): void;
35
+ export declare function remoteControlPoolArgs(pool: RemoteControlPool): string[];
36
+ /** Reject an existing symlink anywhere in the requested path. */
37
+ export declare function assertNoSymlinkPathComponents(target: string): void;
38
+ /** Compare paths after resolving their real filesystem identities. */
39
+ export declare function assertDistinctRealMountPath(mountPath: string, hqRoot: string): void;
40
+ /** Validate the private readiness marker against the live forwarder process identity. */
41
+ export declare function isOwnedPoolForwarder(readyFile: string, accountId: string, isAlive: (pid: number) => boolean, commandLine: (pid: number) => string | null): boolean;
42
+ /** Fail the pool host when its separately supervised inference process disappears. */
43
+ export declare function monitorOwnedPoolForwarder(isOwned: () => boolean, signal: AbortSignal, sleep?: () => Promise<void>): Promise<void>;
44
+ //# sourceMappingURL=remote-control-pools.d.ts.map