@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
@@ -0,0 +1,245 @@
1
+ /** Host-local desired state for opt-in Claude Remote Control account pools. */
2
+ import * as fs from "node:fs";
3
+ import * as path from "node:path";
4
+ import * as os from "node:os";
5
+ import { isIPv4 } from "node:net";
6
+ import { readSessionCompanyLock } from "../session-company-lock.js";
7
+ import { ensurePrivateDir } from "./paths.js";
8
+ const ACCOUNT_ID = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/;
9
+ const SECRET_NAME = /^[A-Z][A-Z0-9_]{0,127}$/;
10
+ const CAPACITY_MIN = 1;
11
+ const CAPACITY_MAX = 100;
12
+ const PORT_MIN = 1024;
13
+ const PORT_MAX = 65535;
14
+ function object(value) {
15
+ return !!value && typeof value === "object" && !Array.isArray(value);
16
+ }
17
+ function absoluteRealPath(value, field) {
18
+ if (typeof value !== "string" || !path.isAbsolute(value) || value.includes("\0")) {
19
+ throw new Error(`${field} must be an absolute path`);
20
+ }
21
+ return path.normalize(value);
22
+ }
23
+ function port(value, field) {
24
+ if (!Number.isInteger(value) || value < PORT_MIN || value > PORT_MAX) {
25
+ throw new Error(`${field} must be an integer from ${PORT_MIN} to ${PORT_MAX}`);
26
+ }
27
+ return value;
28
+ }
29
+ function frontendAddress(value, field) {
30
+ if (typeof value !== "string" || !isIPv4(value) || !value.startsWith("127.")) {
31
+ throw new Error(`${field} must be a distinct 127.0.0.0/8 IPv4 loopback address`);
32
+ }
33
+ return value;
34
+ }
35
+ /** Parse and validate the complete desired state; unknown keys fail closed. */
36
+ export function parseRemoteControlPoolsConfig(value, knownAccountIds, home = os.homedir()) {
37
+ if (!object(value))
38
+ throw new Error("remote-control pools config must be a JSON object");
39
+ if (Object.keys(value).some((key) => !["version", "pools", "frontendCaPath", "serviceSessionId"].includes(key))) {
40
+ throw new Error("remote-control pools config contains an unknown field");
41
+ }
42
+ if (value.version !== 1)
43
+ throw new Error("remote-control pools config version must be 1");
44
+ if (!Array.isArray(value.pools))
45
+ throw new Error("remote-control pools config pools must be an array");
46
+ let frontendCaPath;
47
+ if (value.frontendCaPath !== undefined)
48
+ frontendCaPath = absoluteRealPath(value.frontendCaPath, "frontendCaPath");
49
+ if (value.pools.length > 0 && !frontendCaPath)
50
+ throw new Error("frontendCaPath is required when remote-control pools are configured");
51
+ let serviceSessionId;
52
+ if (value.serviceSessionId !== undefined) {
53
+ if (typeof value.serviceSessionId !== "string" || !/^[A-Za-z0-9._-]+$/.test(value.serviceSessionId) || value.serviceSessionId === "." || value.serviceSessionId === "..") {
54
+ throw new Error("serviceSessionId must be a valid HQ session ID");
55
+ }
56
+ serviceSessionId = value.serviceSessionId;
57
+ }
58
+ if (value.pools.length > 0 && !serviceSessionId) {
59
+ throw new Error("serviceSessionId is required when remote-control pools are configured");
60
+ }
61
+ const seenAccounts = new Set();
62
+ const known = knownAccountIds === undefined ? null : new Set(knownAccountIds);
63
+ const seenEndpoints = new Set();
64
+ const seenPaths = new Set();
65
+ const pools = value.pools.map((raw, index) => {
66
+ const prefix = `pools[${index}]`;
67
+ if (!object(raw))
68
+ throw new Error(`${prefix} must be a JSON object`);
69
+ const allowed = ["accountId", "capacity", "frontendAddress", "frontendPort", "forwarderPort", "mountPath", "secretName"];
70
+ if (Object.keys(raw).some((key) => !allowed.includes(key)))
71
+ throw new Error(`${prefix} contains an unknown field`);
72
+ if (typeof raw.accountId !== "string" || !ACCOUNT_ID.test(raw.accountId)) {
73
+ throw new Error(`${prefix}.accountId is invalid`);
74
+ }
75
+ if (known && !known.has(raw.accountId))
76
+ throw new Error(`${prefix}.accountId "${raw.accountId}" is not configured in hq lanes accounts`);
77
+ if (seenAccounts.has(raw.accountId))
78
+ throw new Error(`duplicate accountId "${raw.accountId}"`);
79
+ seenAccounts.add(raw.accountId);
80
+ if (!Number.isInteger(raw.capacity) || raw.capacity < CAPACITY_MIN || raw.capacity > CAPACITY_MAX) {
81
+ throw new Error(`${prefix}.capacity must be an integer from ${CAPACITY_MIN} to ${CAPACITY_MAX}`);
82
+ }
83
+ const address = frontendAddress(raw.frontendAddress, `${prefix}.frontendAddress`);
84
+ if (raw.frontendPort !== 443)
85
+ throw new Error(`${prefix}.frontendPort must be 443`);
86
+ const frontendPort = 443;
87
+ const forwarderPort = port(raw.forwarderPort, `${prefix}.forwarderPort`);
88
+ const forwarderEndpoint = `127.0.0.1:${forwarderPort}`;
89
+ if (seenEndpoints.has(forwarderEndpoint))
90
+ throw new Error(`duplicate pool endpoint ${forwarderEndpoint}`);
91
+ seenEndpoints.add(forwarderEndpoint);
92
+ const endpoint = `${address}:${frontendPort}`;
93
+ if (seenEndpoints.has(endpoint))
94
+ throw new Error(`duplicate pool frontend endpoint ${endpoint}`);
95
+ seenEndpoints.add(endpoint);
96
+ const mountPath = absoluteRealPath(raw.mountPath, `${prefix}.mountPath`);
97
+ if (mountPath !== path.join(home, ".local", "state", "hq-remote-control", raw.accountId)) {
98
+ throw new Error(`${prefix}.mountPath must be ${path.join(home, ".local", "state", "hq-remote-control", raw.accountId)}`);
99
+ }
100
+ if (seenPaths.has(mountPath))
101
+ throw new Error(`duplicate mountPath "${mountPath}"`);
102
+ seenPaths.add(mountPath);
103
+ if (typeof raw.secretName !== "string" || !SECRET_NAME.test(raw.secretName)) {
104
+ throw new Error(`${prefix}.secretName is invalid`);
105
+ }
106
+ return {
107
+ accountId: raw.accountId,
108
+ capacity: raw.capacity,
109
+ frontendAddress: address,
110
+ frontendPort,
111
+ forwarderPort,
112
+ mountPath,
113
+ secretName: raw.secretName,
114
+ };
115
+ });
116
+ return { version: 1, ...(frontendCaPath ? { frontendCaPath } : {}), ...(serviceSessionId ? { serviceSessionId } : {}), pools };
117
+ }
118
+ /** Override ambient session selection only for a Secrets child process. */
119
+ export function remoteControlPoolSecretsEnv(env, serviceSessionId) {
120
+ return { ...env, HQ_SESSION_ID: serviceSessionId };
121
+ }
122
+ /** Require an explicit, existing capability; never inherit the mutable .current session. */
123
+ export function assertPersonalServiceSessionCapability(root, serviceSessionId) {
124
+ if (!/^[A-Za-z0-9._-]+$/.test(serviceSessionId) || serviceSessionId === "." || serviceSessionId === "..") {
125
+ throw new Error("configured serviceSessionId is not a valid HQ session ID");
126
+ }
127
+ const capabilityPath = path.join(root, "workspace", "sessions", serviceSessionId, "scope-capability.json");
128
+ let stat;
129
+ try {
130
+ stat = fs.lstatSync(capabilityPath);
131
+ }
132
+ catch {
133
+ throw new Error("configured serviceSessionId has no readable scope capability");
134
+ }
135
+ if (!stat.isFile() || stat.isSymbolicLink()) {
136
+ throw new Error("configured serviceSessionId has no readable scope capability");
137
+ }
138
+ const lock = readSessionCompanyLock({ ...process.env, HQ_ROOT: root, HQ_SESSION_ID: serviceSessionId });
139
+ if (!lock || lock.sessionId !== serviceSessionId || lock.companies.length !== 1 || lock.companies[0] !== "personal") {
140
+ throw new Error("configured serviceSessionId must have a personal-only scope capability");
141
+ }
142
+ }
143
+ /** Hosts file overlay consumed inside this pool's private mount namespace. */
144
+ export function remoteControlPoolHostsOverlay(pool) {
145
+ return `${pool.frontendAddress} api.anthropic.com\n`;
146
+ }
147
+ export function poolHostsFile(baseHosts, pool) {
148
+ const retained = baseHosts
149
+ .split(/\r?\n/)
150
+ .filter((line) => !/(^|\s)api\.anthropic\.com(?:\s|$)/.test(line))
151
+ .filter((line) => line.trim() !== "");
152
+ return `${retained.join("\n")}\n${remoteControlPoolHostsOverlay(pool)}`;
153
+ }
154
+ export function readRemoteControlPoolsConfig(file, knownAccountIds, home = os.homedir()) {
155
+ let parsed;
156
+ try {
157
+ parsed = JSON.parse(fs.readFileSync(file, "utf8"));
158
+ }
159
+ catch (error) {
160
+ if (error.code === "ENOENT")
161
+ return { version: 1, pools: [] };
162
+ throw new Error(`could not read remote-control pools config: ${error.message}`);
163
+ }
164
+ return parseRemoteControlPoolsConfig(parsed, knownAccountIds, home);
165
+ }
166
+ export function writeRemoteControlPoolsConfig(file, config, home = os.homedir()) {
167
+ const validated = parseRemoteControlPoolsConfig(config, undefined, home);
168
+ ensurePrivateDir(path.dirname(file));
169
+ const temp = `${file}.${process.pid}.tmp`;
170
+ fs.writeFileSync(temp, `${JSON.stringify(validated, null, 2)}\n`, { mode: 0o600 });
171
+ fs.renameSync(temp, file);
172
+ fs.chmodSync(file, 0o600);
173
+ }
174
+ export function remoteControlPoolArgs(pool) {
175
+ return [
176
+ "remote-control",
177
+ "--no-create-session-in-dir",
178
+ "--capacity",
179
+ String(pool.capacity),
180
+ "--name",
181
+ `hq-${pool.accountId}`,
182
+ "--spawn=same-dir",
183
+ "--permission-mode",
184
+ "bypassPermissions",
185
+ ];
186
+ }
187
+ /** Reject an existing symlink anywhere in the requested path. */
188
+ export function assertNoSymlinkPathComponents(target) {
189
+ const parsed = path.parse(target);
190
+ let cursor = parsed.root;
191
+ for (const part of target.slice(parsed.root.length).split(path.sep).filter(Boolean)) {
192
+ cursor = path.join(cursor, part);
193
+ try {
194
+ if (fs.lstatSync(cursor).isSymbolicLink())
195
+ throw new Error("pool mountPath cannot contain symlinks");
196
+ }
197
+ catch (error) {
198
+ if (error.code !== "ENOENT")
199
+ throw error;
200
+ }
201
+ }
202
+ }
203
+ /** Compare paths after resolving their real filesystem identities. */
204
+ export function assertDistinctRealMountPath(mountPath, hqRoot) {
205
+ const realRoot = fs.realpathSync(hqRoot);
206
+ assertNoSymlinkPathComponents(mountPath);
207
+ let resolvedMount;
208
+ try {
209
+ resolvedMount = fs.realpathSync(mountPath);
210
+ }
211
+ catch (error) {
212
+ if (error.code !== "ENOENT")
213
+ throw error;
214
+ const parent = path.dirname(mountPath);
215
+ resolvedMount = path.join(fs.realpathSync(parent), path.basename(mountPath));
216
+ }
217
+ if (resolvedMount === realRoot || resolvedMount.startsWith(`${realRoot}${path.sep}`) || realRoot.startsWith(`${resolvedMount}${path.sep}`)) {
218
+ throw new Error("pool mountPath must be distinct from the HQ root after realpath resolution");
219
+ }
220
+ }
221
+ /** Validate the private readiness marker against the live forwarder process identity. */
222
+ export function isOwnedPoolForwarder(readyFile, accountId, isAlive, commandLine) {
223
+ try {
224
+ const marker = JSON.parse(fs.readFileSync(readyFile, "utf8"));
225
+ if (marker.accountId !== accountId || !Number.isSafeInteger(marker.pid) || marker.pid <= 0)
226
+ return false;
227
+ const pid = marker.pid;
228
+ const line = isAlive(pid) ? commandLine(pid) : null;
229
+ const args = line?.split(/\s+/) ?? [];
230
+ const accountFlag = args.indexOf("--account-id");
231
+ return args.includes("remote-control-pool-forwarder") && accountFlag >= 0 && args[accountFlag + 1] === accountId;
232
+ }
233
+ catch {
234
+ return false;
235
+ }
236
+ }
237
+ /** Fail the pool host when its separately supervised inference process disappears. */
238
+ export async function monitorOwnedPoolForwarder(isOwned, signal, sleep = () => new Promise((resolve) => setTimeout(resolve, 500))) {
239
+ while (!signal.aborted) {
240
+ if (!isOwned())
241
+ throw new Error("the owned pool inference forwarder exited or lost its listener");
242
+ await sleep();
243
+ }
244
+ }
245
+ //# sourceMappingURL=remote-control-pools.js.map
@@ -53,6 +53,10 @@ export declare const IDLE_BEFORE_RESTART_MS: number;
53
53
  export declare const MAX_RESTART_WAIT_MS: number;
54
54
  export interface RemoteControlHostDeps {
55
55
  tmux: string;
56
+ /** Optional private tmux server per managed pool; A keeps the legacy socket. */
57
+ tmuxSocket?: string;
58
+ /** Environment applied only to this private tmux server before its session starts. */
59
+ sessionEnv?: Record<string, string | null>;
56
60
  /** tmux session name on the daemon's private socket. */
57
61
  session: string;
58
62
  /** Folder the command starts in (the HQ folder). */
@@ -204,7 +204,7 @@ function safePaneTail(output) {
204
204
  .join("\n");
205
205
  }
206
206
  export async function runRemoteControlHost(deps) {
207
- const tmux = (...args) => deps.run(deps.tmux, ["-L", TMUX_SOCKET, ...args]);
207
+ const tmux = (...args) => deps.run(deps.tmux, ["-L", deps.tmuxSocket ?? TMUX_SOCKET, ...args]);
208
208
  const invokerPid = process.pid;
209
209
  const killSession = () => {
210
210
  if (tmux("has-session", "-t", deps.session).status !== 0)
@@ -279,11 +279,26 @@ export async function runRemoteControlHost(deps) {
279
279
  await deps.sleep(deps.pollMs);
280
280
  }
281
281
  // Start the replacement only after no live foreign Claude owner remains.
282
- const created = tmux("new-session", "-d", "-s", deps.session, "-x", "200", "-y", "50", "-c", deps.cwd);
282
+ // A private pool socket has no server until its first session is created.
283
+ // Keep that first pane inert, then set only this session's environment before
284
+ // replacing it with the actual command. The ordinary A service keeps its
285
+ // existing shell startup path when no session environment is configured.
286
+ const bootstrap = deps.sessionEnv ? ["/usr/bin/sleep", "2147483647"] : [];
287
+ const created = tmux("new-session", "-d", "-s", deps.session, "-x", "200", "-y", "50", "-c", deps.cwd, ...bootstrap);
283
288
  if (created.status !== 0) {
284
289
  deps.log(`could not create the tmux session: ${created.stderr.trim() || `tmux exited ${created.status}`}`);
285
290
  return 1;
286
291
  }
292
+ for (const [name, value] of Object.entries(deps.sessionEnv ?? {})) {
293
+ const result = value === null
294
+ ? tmux("set-environment", "-u", "-t", deps.session, name)
295
+ : tmux("set-environment", "-t", deps.session, name, value);
296
+ if (result.status !== 0) {
297
+ deps.log(`could not configure the private tmux environment for ${name}`);
298
+ tmux("kill-session", "-t", deps.session);
299
+ return 1;
300
+ }
301
+ }
287
302
  const creatorRecord = JSON.stringify({ pid: invokerPid, cwd: deps.cwd });
288
303
  const recorded = tmux("set-option", "-t", deps.session, TMUX_CREATOR_OPTION, creatorRecord);
289
304
  if (recorded.status !== 0) {
@@ -22,6 +22,8 @@ import { readBotConfig } from "../bot/config.js";
22
22
  import { botsRoot } from "../bot/paths.js";
23
23
  import { CLAUDE_RUNNER_SCRIPT, CODEX_RUNNER_SCRIPT, enabledServices, planUnits } from "./catalog.js";
24
24
  import { effectiveSyncOwner, readDaemonConfig } from "./config.js";
25
+ import { assertPersonalServiceSessionCapability, readRemoteControlPoolsConfig, remoteControlPoolSecretsEnv, } from "./remote-control-pools.js";
26
+ import { loadLanesConfig } from "../lanes/config.js";
25
27
  import { SUPERVISOR_HANDOFF_FLAG, readDaemonFlag } from "./flag.js";
26
28
  import { findLegacyUnits, legacyMeshUnitsToRetire, legacyOwnerLookup } from "./legacy.js";
27
29
  import { readLegacySyncEnv } from "./legacy-sync-env.js";
@@ -636,6 +638,45 @@ export async function runDaemon(opts) {
636
638
  };
637
639
  let warnedMissingPin = null;
638
640
  let warnedMissingSyncRunner = false;
641
+ const poolProbeCache = new Map();
642
+ const poolProbeProcess = (file, args, options) => new Promise((resolve) => {
643
+ const child = spawn(file, args, { cwd: options.cwd, env: options.env, windowsHide: true, stdio: ["ignore", "pipe", "ignore"] });
644
+ let stdout = "";
645
+ let error = false;
646
+ child.stdout?.on("data", (chunk) => { stdout += chunk.toString().slice(0, 256); });
647
+ child.on("error", () => { error = true; });
648
+ const timer = setTimeout(() => child.kill("SIGKILL"), options.timeout);
649
+ child.once("close", (status) => { clearTimeout(timer); resolve({ status, stdout, error }); });
650
+ });
651
+ const poolProbe = (key, probe) => {
652
+ const cached = poolProbeCache.get(key);
653
+ if (cached && (cached.pending || Date.now() - cached.at < 30_000))
654
+ return cached.value;
655
+ let result;
656
+ try {
657
+ result = probe();
658
+ }
659
+ catch (error) {
660
+ log("error", `remote-control pool probe ${key} failed: ${error.message}`);
661
+ poolProbeCache.set(key, { at: Date.now(), value: "unavailable" });
662
+ return "unavailable";
663
+ }
664
+ if (typeof result === "string") {
665
+ poolProbeCache.set(key, { at: Date.now(), value: result });
666
+ return result;
667
+ }
668
+ const pending = { at: Date.now(), value: cached?.value ?? "unavailable", pending: true };
669
+ poolProbeCache.set(key, pending);
670
+ result.then((value) => {
671
+ if (poolProbeCache.get(key) === pending)
672
+ poolProbeCache.set(key, { at: Date.now(), value });
673
+ }, (error) => {
674
+ log("error", `remote-control pool probe ${key} failed: ${error.message}`);
675
+ if (poolProbeCache.get(key) === pending)
676
+ poolProbeCache.set(key, { at: Date.now(), value: "unavailable" });
677
+ });
678
+ return pending.value;
679
+ };
639
680
  const daemonSyncRunnerPids = new Set();
640
681
  let supervisor = null;
641
682
  const plan = () => {
@@ -643,6 +684,83 @@ export async function runDaemon(opts) {
643
684
  const legacy = legacyUnits();
644
685
  const configured = config.hqRoot ?? (env.HQ_ROOT?.trim() || undefined);
645
686
  const hqRoot = configured && fs.existsSync(configured) ? configured : undefined;
687
+ let remoteControlPools = { version: 1, pools: [] };
688
+ let remoteControlPoolsError;
689
+ try {
690
+ if (fs.existsSync(paths.remoteControlPoolsPath)) {
691
+ const knownAccounts = hqRoot ? Object.keys(loadLanesConfig(hqRoot).providers.claude.accounts) : undefined;
692
+ remoteControlPools = readRemoteControlPoolsConfig(paths.remoteControlPoolsPath, knownAccounts, home);
693
+ }
694
+ }
695
+ catch (error) {
696
+ remoteControlPoolsError = error.message;
697
+ log("error", `remote-control pool config is invalid: ${remoteControlPoolsError}`);
698
+ }
699
+ const availablePoolSecrets = new Set();
700
+ const unverifiablePoolSecrets = new Set();
701
+ const invalidPoolServiceSessions = new Set();
702
+ const availablePoolFrontends = new Set();
703
+ const unverifiablePoolFrontends = new Set();
704
+ for (const pool of remoteControlPools.pools) {
705
+ const serviceSession = poolProbe(`service-session:${remoteControlPools.serviceSessionId ?? ""}`, () => {
706
+ try {
707
+ if (!remoteControlPools.serviceSessionId)
708
+ return "unavailable";
709
+ assertPersonalServiceSessionCapability(hqRoot ?? home, remoteControlPools.serviceSessionId);
710
+ return "available";
711
+ }
712
+ catch {
713
+ return "unavailable";
714
+ }
715
+ });
716
+ if (serviceSession === "unavailable")
717
+ invalidPoolServiceSessions.add(pool.accountId);
718
+ const secret = serviceSession === "available" ? poolProbe(`secret:${pool.secretName}`, async () => {
719
+ const result = await poolProbeProcess(opts.hqBinary, ["secrets", "--personal", "exists", pool.secretName, "--quiet"], {
720
+ cwd: hqRoot ?? home,
721
+ env: remoteControlPoolSecretsEnv({
722
+ ...env,
723
+ ...(hqRoot ? { HQ_ROOT: hqRoot } : {}),
724
+ HQ_NO_UPDATE_CHECK: "1",
725
+ }, remoteControlPools.serviceSessionId),
726
+ timeout: 15_000,
727
+ });
728
+ if (result.status === 0)
729
+ return "available";
730
+ return result.status === 1 ? "missing" : "unavailable";
731
+ }) : "unavailable";
732
+ if (secret === "available")
733
+ availablePoolSecrets.add(pool.secretName);
734
+ if (secret === "unavailable" && serviceSession === "available")
735
+ unverifiablePoolSecrets.add(pool.secretName);
736
+ let caValid = false;
737
+ try {
738
+ const caStat = fs.statSync(remoteControlPools.frontendCaPath);
739
+ caValid = caStat.isFile() && fs.accessSync(remoteControlPools.frontendCaPath, fs.constants.R_OK) === undefined;
740
+ }
741
+ catch {
742
+ caValid = false;
743
+ }
744
+ const frontend = poolProbe(`frontend:${pool.accountId}:${pool.frontendAddress}`, async () => {
745
+ if (!caValid)
746
+ return "unavailable";
747
+ const result = await poolProbeProcess("curl", [
748
+ "--silent", "--show-error", "--output", "/dev/null", "--write-out", "%{http_code}",
749
+ "--connect-timeout", "1", "--max-time", "2", "--head",
750
+ "--cacert", remoteControlPools.frontendCaPath,
751
+ "--resolve", `api.anthropic.com:443:${pool.frontendAddress}`,
752
+ "https://api.anthropic.com/v1/messages",
753
+ ], { timeout: 3_000 });
754
+ const code = Number.parseInt(result.stdout.trim(), 10);
755
+ if (result.status === 0 && code >= 100 && code <= 599)
756
+ return "available";
757
+ return result.error ? "unavailable" : "missing";
758
+ });
759
+ if (frontend === "available")
760
+ availablePoolFrontends.add(pool.accountId);
761
+ if (frontend === "unavailable")
762
+ unverifiablePoolFrontends.add(pool.accountId);
763
+ }
646
764
  getStagedHqChildBootstrap();
647
765
  const staged = getStagedSyncBundle();
648
766
  // Before release, preserve today's selection. Later plans can only use the
@@ -746,6 +864,14 @@ export async function runDaemon(opts) {
746
864
  remoteControlName: runnerSessionName(readIfExists(CLAUDE_RUNNER_SCRIPT) ?? ""),
747
865
  tmuxPath: findOnPath("tmux", env),
748
866
  hostName,
867
+ claudeConfigDir: env.CLAUDE_CONFIG_DIR?.trim() || path.join(home, ".claude"),
868
+ remoteControlPools,
869
+ remoteControlPoolsError,
870
+ availablePoolSecrets,
871
+ unverifiablePoolSecrets,
872
+ invalidPoolServiceSessions,
873
+ availablePoolFrontends,
874
+ unverifiablePoolFrontends,
749
875
  });
750
876
  if (startupPlanOpen) {
751
877
  const replaceablePackageRoots = cliPackageRoot ? [cliPackageRoot] : [];
@@ -3,9 +3,10 @@
3
3
  *
4
4
  * `lastAttemptedSlug` is written before each pull so a killed repair resumes
5
5
  * after that company instead of starving later journals. `lastRunAttemptedCheckIds`
6
- * is written only when a repair pass finishes, so `--client-health-report`
7
- * can omit company journals this pass did not try — the SessionStart hook
8
- * otherwise files those leftover rows as unrepaired.
6
+ * is written only when a repair pass finishes, so doctor JSON
7
+ * (`--json` and `--client-health-report`) can omit company journals this
8
+ * pass did not try — SessionStart hooks, including older ones that only
9
+ * call `hq doctor --json`, otherwise file those leftover rows as unrepaired.
9
10
  */
10
11
  import type { CheckResult } from "../types.js";
11
12
  export declare const REFRESH_SYNC_PROGRESS_RELATIVE_PATH: string;
@@ -27,6 +28,6 @@ export declare function writeRefreshSyncLastRun(hqRoot: string, input: {
27
28
  lastRunAtMs: number;
28
29
  }): void;
29
30
  export declare function freshAttemptedCompanyJournalCheckIds(hqRoot: string, nowMs: number): ReadonlySet<string> | null;
30
- /** True when a client-health report should hide this leftover company journal. */
31
+ /** True when doctor JSON should hide this leftover company journal. */
31
32
  export declare function isUnreachedCompanyJournalForClientHealth(result: CheckResult, attemptedCheckIds: ReadonlySet<string> | null): boolean;
32
33
  //# sourceMappingURL=refresh-sync-progress.d.ts.map
@@ -3,9 +3,10 @@
3
3
  *
4
4
  * `lastAttemptedSlug` is written before each pull so a killed repair resumes
5
5
  * after that company instead of starving later journals. `lastRunAttemptedCheckIds`
6
- * is written only when a repair pass finishes, so `--client-health-report`
7
- * can omit company journals this pass did not try — the SessionStart hook
8
- * otherwise files those leftover rows as unrepaired.
6
+ * is written only when a repair pass finishes, so doctor JSON
7
+ * (`--json` and `--client-health-report`) can omit company journals this
8
+ * pass did not try — SessionStart hooks, including older ones that only
9
+ * call `hq doctor --json`, otherwise file those leftover rows as unrepaired.
9
10
  */
10
11
  import * as fs from "node:fs";
11
12
  import * as path from "node:path";
@@ -93,7 +94,7 @@ export function freshAttemptedCompanyJournalCheckIds(hqRoot, nowMs) {
93
94
  }
94
95
  return new Set(progress.lastRunAttemptedCheckIds);
95
96
  }
96
- /** True when a client-health report should hide this leftover company journal. */
97
+ /** True when doctor JSON should hide this leftover company journal. */
97
98
  export function isUnreachedCompanyJournalForClientHealth(result, attemptedCheckIds) {
98
99
  if (!attemptedCheckIds)
99
100
  return false;
@@ -109,11 +109,11 @@ export interface BuildDoctorJsonInput {
109
109
  * Ordinary hq-core upgrade WARNs and leftover `sync.journal.personal` WARNs
110
110
  * are omitted from every JSON document (`--json` and `--client-health-report`),
111
111
  * including `summary` tallies: `--fix` cannot apply a release, and the leftover
112
- * personal shard is not a live sync target. Both current hooks and older ones
113
- * that only call `hq doctor --json` treated those leftover WARNs as unrepaired
114
- * failures. Human `hq doctor` still prints them. Company journals this `--fix`
115
- * pass did not try to refresh are omitted only from `--client-health-report`:
116
- * the repair cap leaves them for a later session.
112
+ * personal shard is not a live sync target. Company journals this `--fix`
113
+ * pass did not try to refresh are omitted the same way: the repair cap leaves
114
+ * them for a later session, and older hooks that only call `hq doctor --json`
115
+ * treated those leftover WARNs as unrepaired failures. Human `hq doctor`
116
+ * still prints them.
117
117
  */
118
118
  clientHealthReport?: boolean;
119
119
  /** Clock for the leftover-journal freshness window. Default: `Date.now`. */
@@ -62,17 +62,14 @@ function summarizeJsonResults(results) {
62
62
  }
63
63
  /** Assemble the machine-readable document from a completed run. */
64
64
  export function buildDoctorJson(input) {
65
- const attemptedRefreshSyncCheckIds = input.clientHealthReport === true
66
- ? freshAttemptedCompanyJournalCheckIds(input.hqRoot, (input.now ?? (() => new Date()))().getTime())
67
- : null;
65
+ const attemptedRefreshSyncCheckIds = freshAttemptedCompanyJournalCheckIds(input.hqRoot, (input.now ?? (() => new Date()))().getTime());
68
66
  const results = [];
69
67
  for (const run of input.families) {
70
68
  for (const result of run.results) {
71
69
  if (omitFromDoctorJson(result)) {
72
70
  continue;
73
71
  }
74
- if (input.clientHealthReport === true &&
75
- isUnreachedCompanyJournalForClientHealth(result, attemptedRefreshSyncCheckIds)) {
72
+ if (isUnreachedCompanyJournalForClientHealth(result, attemptedRefreshSyncCheckIds)) {
76
73
  continue;
77
74
  }
78
75
  results.push(toJsonResult(run.family.id, result, input.clientHealthReasonCodesEnabledCheckIds, input.clientHealthCheckStatusEnabledCheckIds));
@@ -0,0 +1,43 @@
1
+ /**
2
+ * The `setup:` block of a content pack's package.yaml (sample-pack-in-app-setup
3
+ * US-001, build contract section 1).
4
+ *
5
+ * A pack that declares one can be set up from the HQ app chat: the pack's own
6
+ * bot (`setup.bot.name`, also a worker under `contributes.workers`) asks the
7
+ * questions in order with tap-to-answer suggestions, then runs `setup.run`
8
+ * with the answers. `hq packages install` validates the block and fails naming
9
+ * the field; a pack without the block is untouched.
10
+ *
11
+ * Validation (contract):
12
+ * - `setup.bot.name` matches ^[a-z0-9-]{2,32}$ and appears in contributes.workers
13
+ * - question ids unique, ^[a-z_]+$; options is a list of strings (may be empty)
14
+ * - `run.script` exists in the pack; every `run.args` key is a question id;
15
+ * every `then` entry's first token exists in the pack
16
+ */
17
+ import type { PackManifest, PackSetup } from "../../types.js";
18
+ export declare const SETUP_BOT_NAME_RE: RegExp;
19
+ export declare const SETUP_QUESTION_ID_RE: RegExp;
20
+ /** A validation failure; `field` is the dotted path of the offending field. */
21
+ export declare class PackSetupError extends Error {
22
+ readonly field: string;
23
+ constructor(field: string, detail: string);
24
+ }
25
+ export interface SetupBlockContext {
26
+ /** Pack root on disk (the payload at install, `core/packages/<name>` later). */
27
+ packDir: string;
28
+ /** `contributes.workers` of the same manifest. */
29
+ workers: readonly string[];
30
+ /** Filesystem seam for tests; defaults to node:fs. */
31
+ existsSync?: (p: string) => boolean;
32
+ }
33
+ /**
34
+ * Validate a raw `setup:` value. Returns the typed block, or undefined when the
35
+ * manifest has no block (a legacy pack). Throws PackSetupError naming the field.
36
+ */
37
+ export declare function parseSetupBlock(raw: unknown, ctx: SetupBlockContext): PackSetup | undefined;
38
+ /**
39
+ * The validated setup block of an installed pack, or undefined when the pack
40
+ * has none or the block is invalid (`hq packages list` prints `setup: no`).
41
+ */
42
+ export declare function readInstalledPackSetup(manifest: Pick<PackManifest, "setup" | "contributes"> | null | undefined, packDir: string, existsSync?: (p: string) => boolean): PackSetup | undefined;
43
+ //# sourceMappingURL=setup-block.d.ts.map