@indigoai-us/hq-cli 5.345.104 → 5.345.105

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 (74) hide show
  1. package/CHANGELOG.md +93 -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/pack-install.d.ts +20 -1
  15. package/dist/commands/pack-install.js +80 -5
  16. package/dist/commands/pack-start-bot.d.ts +24 -0
  17. package/dist/commands/pack-start-bot.js +76 -0
  18. package/dist/commands/packs.d.ts +9 -0
  19. package/dist/commands/packs.js +26 -3
  20. package/dist/commands/pkg-install.js +11 -2
  21. package/dist/commands/secrets.d.ts +1 -5
  22. package/dist/commands/secrets.js +2 -92
  23. package/dist/lib/bot/config.d.ts +7 -0
  24. package/dist/lib/bot/daemon.js +2 -1
  25. package/dist/lib/bot/index.d.ts +2 -0
  26. package/dist/lib/bot/index.js +2 -0
  27. package/dist/lib/bot/lean-settings.d.ts +16 -0
  28. package/dist/lib/bot/lean-settings.js +34 -0
  29. package/dist/lib/bot/pack-link.d.ts +66 -0
  30. package/dist/lib/bot/pack-link.js +153 -0
  31. package/dist/lib/bot/pack-setup-driver.d.ts +37 -0
  32. package/dist/lib/bot/pack-setup-driver.js +83 -0
  33. package/dist/lib/bot/pack-setup.d.ts +62 -0
  34. package/dist/lib/bot/pack-setup.js +103 -0
  35. package/dist/lib/bot/prompt.d.ts +8 -0
  36. package/dist/lib/bot/prompt.js +5 -0
  37. package/dist/lib/bot/run.d.ts +18 -2
  38. package/dist/lib/bot/run.js +163 -25
  39. package/dist/lib/bot/runtime/claude.d.ts +27 -2
  40. package/dist/lib/bot/runtime/claude.js +49 -4
  41. package/dist/lib/bot/runtime/types.d.ts +5 -0
  42. package/dist/lib/bot/runtime-failure.d.ts +18 -2
  43. package/dist/lib/bot/runtime-failure.js +21 -1
  44. package/dist/lib/bot/toolchain.d.ts +25 -0
  45. package/dist/lib/bot/toolchain.js +33 -0
  46. package/dist/lib/bot/worker-source.d.ts +1 -0
  47. package/dist/lib/bot/worker-source.js +7 -1
  48. package/dist/lib/daemon/catalog.d.ts +15 -0
  49. package/dist/lib/daemon/catalog.js +49 -0
  50. package/dist/lib/daemon/paths.d.ts +1 -0
  51. package/dist/lib/daemon/paths.js +1 -0
  52. package/dist/lib/daemon/remote-control-forwarder.d.ts +14 -0
  53. package/dist/lib/daemon/remote-control-forwarder.js +104 -0
  54. package/dist/lib/daemon/remote-control-pools.d.ts +44 -0
  55. package/dist/lib/daemon/remote-control-pools.js +245 -0
  56. package/dist/lib/daemon/remote-control.d.ts +4 -0
  57. package/dist/lib/daemon/remote-control.js +17 -2
  58. package/dist/lib/daemon/run.js +126 -0
  59. package/dist/lib/doctor/fix/refresh-sync-progress.d.ts +5 -4
  60. package/dist/lib/doctor/fix/refresh-sync-progress.js +5 -4
  61. package/dist/lib/doctor/json-output.d.ts +5 -5
  62. package/dist/lib/doctor/json-output.js +2 -5
  63. package/dist/lib/packages/setup-block.d.ts +43 -0
  64. package/dist/lib/packages/setup-block.js +236 -0
  65. package/dist/lib/packages/setup-prompt.d.ts +34 -0
  66. package/dist/lib/packages/setup-prompt.js +128 -0
  67. package/dist/lib/packages/setup-takeover.d.ts +50 -0
  68. package/dist/lib/packages/setup-takeover.js +75 -0
  69. package/dist/lib/packages/start-bot.d.ts +47 -0
  70. package/dist/lib/packages/start-bot.js +66 -0
  71. package/dist/lib/session-company-lock.d.ts +7 -0
  72. package/dist/lib/session-company-lock.js +97 -0
  73. package/dist/types.d.ts +82 -0
  74. package/package.json +1 -1
@@ -16,6 +16,8 @@
16
16
  import * as fs from "node:fs";
17
17
  import * as os from "node:os";
18
18
  import * as path from "node:path";
19
+ import { spawn, spawnSync } from "node:child_process";
20
+ import { once } from "node:events";
19
21
  import { compare as semverCompare, major as semverMajor, valid as validSemver } from "semver";
20
22
  import { DEFAULT_SERVICES, SERVICE_DESCRIPTIONS, enabledServices, hqCloudUnits } from "../lib/daemon/catalog.js";
21
23
  import { applyServiceToggle, effectiveSyncOwner, readDaemonConfig, writeDaemonConfig } from "../lib/daemon/config.js";
@@ -26,7 +28,9 @@ import { describeLegacyUnit, findLegacyUnits } from "../lib/daemon/legacy.js";
26
28
  import { isDaemonProcess, runningDaemonPid } from "../lib/daemon/lock.js";
27
29
  import { bundledHqCloud, managedHqCloud, managedHqCloudRoot, readHqCloudCopy, readHqCloudUpdateState } from "../lib/daemon/hq-cloud.js";
28
30
  import { MACHINE_TYPE_MARKER_PATH, detectMachineType, homeFromPasswd, } from "../lib/daemon/machine-type.js";
29
- import { daemonPaths } from "../lib/daemon/paths.js";
31
+ import { daemonPaths, ensurePrivateDir } from "../lib/daemon/paths.js";
32
+ import { assertPersonalServiceSessionCapability, parseRemoteControlPoolsConfig, readRemoteControlPoolsConfig, writeRemoteControlPoolsConfig, isOwnedPoolForwarder, monitorOwnedPoolForwarder, assertDistinctRealMountPath, assertNoSymlinkPathComponents, poolHostsFile, remoteControlPoolSecretsEnv, remoteControlPoolArgs, } from "../lib/daemon/remote-control-pools.js";
33
+ import { createInferenceForwarder } from "../lib/daemon/remote-control-forwarder.js";
30
34
  import { processCommandLine } from "../lib/daemon/lock.js";
31
35
  import { writeControlRequest } from "../lib/daemon/requests.js";
32
36
  import { runDaemonCall } from "../lib/daemon/command-call.js";
@@ -700,6 +704,53 @@ async function runPinHqCloud(d, version, opts) {
700
704
  }
701
705
  d.out("Run `hq daemon trigger updater`; wait for the hq-cloud update or current-version message in `hq daemon logs updater`, then restart `hq daemon restart sync`, `hq daemon restart heartbeat`, and `hq daemon restart session-host`.");
702
706
  }
707
+ async function validateRemoteControlPools(d, file) {
708
+ let input;
709
+ try {
710
+ input = JSON.parse(d.readFile(path.resolve(file)));
711
+ }
712
+ catch (error) {
713
+ throw new DaemonCommandError(`could not read pool config: ${error.message}`);
714
+ }
715
+ const config = parseRemoteControlPoolsConfig(input, await knownClaudeAccountIds(d), d.home);
716
+ d.out(`Valid remote-control pool configuration (${config.pools.length} pool${config.pools.length === 1 ? "" : "s"}).`);
717
+ }
718
+ async function configureRemoteControlPools(d, file) {
719
+ const source = path.resolve(file);
720
+ let input;
721
+ try {
722
+ input = JSON.parse(d.readFile(source));
723
+ }
724
+ catch (error) {
725
+ throw new DaemonCommandError(`could not read pool config: ${error.message}`);
726
+ }
727
+ const config = parseRemoteControlPoolsConfig(input, await knownClaudeAccountIds(d), d.home);
728
+ const { paths } = currentHost(d);
729
+ writeRemoteControlPoolsConfig(paths.remoteControlPoolsPath, config, d.home);
730
+ d.out(`Saved ${config.pools.length} remote-control pool${config.pools.length === 1 ? "" : "s"} to ${paths.remoteControlPoolsPath}. Restart the daemon to apply.`);
731
+ }
732
+ async function knownClaudeAccountIds(d) {
733
+ const host = currentHost(d);
734
+ const root = readDaemonConfig(host.paths.configPath).hqRoot ?? d.resolveHqRoot(undefined);
735
+ if (!root)
736
+ throw new DaemonCommandError("Could not resolve the HQ folder needed to validate hq lanes Claude account IDs.");
737
+ const { loadLanesConfig } = await import("../lib/lanes/config.js");
738
+ return Object.keys(loadLanesConfig(root).providers.claude.accounts);
739
+ }
740
+ async function showRemoteControlPools(d, opts) {
741
+ const { paths } = currentHost(d);
742
+ const config = readRemoteControlPoolsConfig(paths.remoteControlPoolsPath, undefined, d.home);
743
+ if (opts.json) {
744
+ d.out(JSON.stringify(config, null, 2));
745
+ return;
746
+ }
747
+ d.out(config.pools.length === 0 ? "No remote-control pools are configured." : `Configured remote-control pools (${config.pools.length}):`);
748
+ if (config.serviceSessionId)
749
+ d.out(` personal service session: ${config.serviceSessionId}`);
750
+ for (const pool of config.pools) {
751
+ d.out(` ${pool.accountId}: capacity ${pool.capacity}, frontend https://${pool.frontendAddress}:${pool.frontendPort}, forwarder 127.0.0.1:${pool.forwarderPort}, mount ${pool.mountPath}, secret ${pool.secretName}`);
752
+ }
753
+ }
703
754
  async function runMigrate(d, opts) {
704
755
  const host = currentHost(d);
705
756
  const units = legacyUnitsFor(d, host);
@@ -778,9 +829,12 @@ async function runTmuxService(d, opts) {
778
829
  const stop = () => controller.abort();
779
830
  process.once("SIGTERM", stop);
780
831
  process.once("SIGINT", stop);
832
+ opts.signal?.addEventListener("abort", stop, { once: true });
781
833
  try {
782
834
  process.exitCode = await runRemoteControlHost({
783
835
  tmux,
836
+ ...(opts.tmuxSocket ? { tmuxSocket: opts.tmuxSocket } : {}),
837
+ ...(opts.sessionEnv ? { sessionEnv: opts.sessionEnv } : {}),
784
838
  session: opts.session,
785
839
  cwd: hqRoot,
786
840
  argv: opts.argv,
@@ -803,6 +857,7 @@ async function runTmuxService(d, opts) {
803
857
  });
804
858
  }
805
859
  finally {
860
+ opts.signal?.removeEventListener("abort", stop);
806
861
  process.off("SIGTERM", stop);
807
862
  process.off("SIGINT", stop);
808
863
  }
@@ -830,6 +885,299 @@ async function runServiceRemoteControl(d, opts) {
830
885
  watch: claude,
831
886
  });
832
887
  }
888
+ async function configuredRemoteControlPool(d, accountId) {
889
+ const host = currentHost(d);
890
+ const hqRoot = d.env.HQ_ROOT?.trim() || readDaemonConfig(host.paths.configPath).hqRoot;
891
+ if (!hqRoot)
892
+ throw new DaemonCommandError("Remote Control account pools need HQ_ROOT.");
893
+ const { loadLanesConfig } = await import("../lib/lanes/config.js");
894
+ const knownAccounts = Object.keys(loadLanesConfig(hqRoot).providers.claude.accounts);
895
+ const config = readRemoteControlPoolsConfig(host.paths.remoteControlPoolsPath, knownAccounts, d.home);
896
+ const pool = config.pools.find((candidate) => candidate.accountId === accountId);
897
+ if (!pool)
898
+ throw new DaemonCommandError(`Remote Control pool account "${accountId}" is not configured.`);
899
+ if (!config.serviceSessionId)
900
+ throw new DaemonCommandError("Remote Control pools require a configured personal service session ID.");
901
+ try {
902
+ assertPersonalServiceSessionCapability(hqRoot, config.serviceSessionId);
903
+ }
904
+ catch (error) {
905
+ throw new DaemonCommandError(`Remote Control pools cannot start: ${error.message}.`);
906
+ }
907
+ return { pool, paths: host.paths, hqRoot, frontendCaPath: config.frontendCaPath, serviceSessionId: config.serviceSessionId };
908
+ }
909
+ function forwardLines(stream, consume) {
910
+ let pending = "";
911
+ stream.on("data", (chunk) => {
912
+ pending += chunk.toString();
913
+ const lines = pending.split(/\r?\n/);
914
+ pending = lines.pop() ?? "";
915
+ for (const line of lines)
916
+ consume(line);
917
+ });
918
+ stream.on("end", () => { if (pending)
919
+ consume(pending); });
920
+ }
921
+ async function runServiceRemoteControlPool(d, accountId) {
922
+ const { pool, paths, hqRoot } = await configuredRemoteControlPool(d, accountId);
923
+ const expectedMountRoot = path.join(d.home, ".local", "state", "hq-remote-control");
924
+ if (path.dirname(pool.mountPath) !== expectedMountRoot || path.basename(pool.mountPath) !== pool.accountId) {
925
+ throw new DaemonCommandError(`Pool ${accountId} mountPath must be ${path.join(expectedMountRoot, pool.accountId)}.`);
926
+ }
927
+ assertNoSymlinkPathComponents(pool.mountPath);
928
+ ensurePrivateDir(pool.mountPath);
929
+ assertDistinctRealMountPath(pool.mountPath, hqRoot);
930
+ const overlayDir = path.join(paths.root, "remote-control-pools", "hosts");
931
+ ensurePrivateDir(overlayDir);
932
+ const overlayPath = path.join(overlayDir, `${pool.accountId}.hosts`);
933
+ fs.writeFileSync(overlayPath, poolHostsFile(fs.readFileSync("/etc/hosts", "utf8"), pool), { mode: 0o600 });
934
+ fs.chmodSync(overlayPath, 0o600);
935
+ const script = [
936
+ "set -eu",
937
+ "mount --make-rprivate /",
938
+ "mount --bind \"$1\" \"$2\"",
939
+ "mount --bind \"$3\" /etc/hosts",
940
+ "cd \"$2\"",
941
+ "export HQ_ROOT=\"$2\"",
942
+ "exec \"$4\" daemon service remote-control-pool-host --account-id \"$5\"",
943
+ ].join("\n");
944
+ const child = spawn("unshare", ["--map-root-user", "--mount", "--fork", "--kill-child=TERM", "sh", "-c", script,
945
+ "hq-remote-control-pool", hqRoot, pool.mountPath, overlayPath, d.hqBinary, pool.accountId,
946
+ ], { cwd: hqRoot, env: { ...d.env, HQ_ROOT: hqRoot }, stdio: ["ignore", "pipe", "pipe"] });
947
+ forwardLines(child.stdout, (line) => d.out(line));
948
+ forwardLines(child.stderr, (line) => d.err(line));
949
+ const stop = () => child.kill("SIGTERM");
950
+ process.once("SIGTERM", stop);
951
+ process.once("SIGINT", stop);
952
+ try {
953
+ const [code, signal] = await once(child, "exit");
954
+ if (code !== 0)
955
+ throw new DaemonCommandError(`Remote Control pool ${accountId} namespace exited ${code ?? signal ?? "unknown"}.`);
956
+ }
957
+ finally {
958
+ process.off("SIGTERM", stop);
959
+ process.off("SIGINT", stop);
960
+ }
961
+ }
962
+ function poolForwarderReadyFile(d, accountId) {
963
+ return path.join(currentHost(d).paths.root, "remote-control-pools", "forwarder", `${accountId}.json`);
964
+ }
965
+ function poolForwarderOwnsPort(readyFile, accountId) {
966
+ return isOwnedPoolForwarder(readyFile, accountId, (pid) => {
967
+ try {
968
+ process.kill(pid, 0);
969
+ return true;
970
+ }
971
+ catch {
972
+ return false;
973
+ }
974
+ }, (pid) => {
975
+ if (process.platform === "linux") {
976
+ try {
977
+ return fs.readFileSync(`/proc/${pid}/cmdline`, "utf8").replace(/\0/g, " ");
978
+ }
979
+ catch {
980
+ return null;
981
+ }
982
+ }
983
+ return processCommandLine(pid);
984
+ });
985
+ }
986
+ function waitForOwnedForwarder(readyFile, accountId, signal) {
987
+ return new Promise((resolve, reject) => {
988
+ const started = Date.now();
989
+ let finished = false;
990
+ let timer;
991
+ const done = (error) => {
992
+ if (finished)
993
+ return;
994
+ finished = true;
995
+ clearTimeout(timer);
996
+ signal.removeEventListener("abort", onAbort);
997
+ if (error)
998
+ reject(error);
999
+ else
1000
+ resolve();
1001
+ };
1002
+ const onAbort = () => done(new Error("pool startup was stopped"));
1003
+ const probe = () => {
1004
+ if (signal.aborted)
1005
+ return onAbort();
1006
+ if (poolForwarderOwnsPort(readyFile, accountId))
1007
+ return done();
1008
+ if (Date.now() - started >= 10_000)
1009
+ return done(new Error("owned pool inference forwarder did not become ready within 10 seconds"));
1010
+ timer = setTimeout(probe, 100);
1011
+ };
1012
+ signal.addEventListener("abort", onAbort, { once: true });
1013
+ probe();
1014
+ });
1015
+ }
1016
+ async function runServiceRemoteControlPoolHost(d, accountId) {
1017
+ const { pool, frontendCaPath } = await configuredRemoteControlPool(d, accountId);
1018
+ const claude = await hostTool(d, "claude", undefined);
1019
+ const readyFile = poolForwarderReadyFile(d, accountId);
1020
+ await waitForOwnedForwarder(readyFile, accountId, AbortSignal.timeout(10_500));
1021
+ const controller = new AbortController();
1022
+ const monitorForwarder = monitorOwnedPoolForwarder(() => poolForwarderOwnsPort(readyFile, accountId), controller.signal);
1023
+ const host = runTmuxService(d, {
1024
+ session: `remote-control-pool-${accountId}`,
1025
+ tmuxSocket: `hq-daemon-pool-${accountId}`,
1026
+ argv: [claude, ...remoteControlPoolArgs(pool)],
1027
+ label: `claude remote-control pool ${accountId}`,
1028
+ watch: claude,
1029
+ signal: controller.signal,
1030
+ sessionEnv: {
1031
+ HOME: d.home,
1032
+ CLAUDE_CONFIG_DIR: d.env.CLAUDE_CONFIG_DIR?.trim() || path.join(d.home, ".claude"),
1033
+ NODE_EXTRA_CA_CERTS: frontendCaPath,
1034
+ IS_SANDBOX: "1",
1035
+ CLAUDE_CODE_OAUTH_TOKEN: null,
1036
+ ANTHROPIC_AUTH_TOKEN: null,
1037
+ ANTHROPIC_API_KEY: null,
1038
+ ANTHROPIC_BASE_URL: null,
1039
+ },
1040
+ });
1041
+ try {
1042
+ d.out(`Remote Control pool ${accountId} is connected to its owned inference forwarder on 127.0.0.1:${pool.forwarderPort}.`);
1043
+ try {
1044
+ await Promise.race([host, monitorForwarder]);
1045
+ }
1046
+ catch (error) {
1047
+ if (error.message.includes("owned pool inference forwarder"))
1048
+ throw new DaemonCommandError(error.message);
1049
+ throw error;
1050
+ }
1051
+ }
1052
+ finally {
1053
+ controller.abort();
1054
+ await host;
1055
+ }
1056
+ }
1057
+ async function runServiceRemoteControlPoolForwarderHost(d, accountId) {
1058
+ const { pool, paths, hqRoot, serviceSessionId } = await configuredRemoteControlPool(d, accountId);
1059
+ const secretEnv = remoteControlPoolSecretsEnv({
1060
+ ...d.env,
1061
+ HQ_ROOT: d.env.HQ_ROOT?.trim() || hqRoot,
1062
+ HQ_NO_UPDATE_CHECK: "1",
1063
+ }, serviceSessionId);
1064
+ const scopeProbe = spawnSync(d.hqBinary, ["secrets", "--personal", "exists", pool.secretName, "--quiet"], {
1065
+ cwd: paths.root,
1066
+ env: secretEnv,
1067
+ encoding: "utf8",
1068
+ windowsHide: true,
1069
+ timeout: 15_000,
1070
+ });
1071
+ if (scopeProbe.status !== 0) {
1072
+ throw new DaemonCommandError(`Remote Control pool ${accountId} cannot start: configured service session "${serviceSessionId}" cannot access the configured HQ Secret.`);
1073
+ }
1074
+ const readyFile = poolForwarderReadyFile(d, accountId);
1075
+ ensurePrivateDir(path.dirname(readyFile));
1076
+ const child = spawn(d.hqBinary, [
1077
+ "secrets", "--personal", "exec", "--only", pool.secretName, "--",
1078
+ "/bin/sh", "-c", "unset HQ_SESSION_ID; exec \"$@\"", "hq-pool-forwarder",
1079
+ d.nodeBinary, process.argv[1] ?? d.hqBinary, "daemon", "service", "remote-control-pool-forwarder",
1080
+ "--account-id", accountId, "--port", String(pool.forwarderPort), "--secret-env", pool.secretName, "--ready-file", readyFile,
1081
+ ], { cwd: paths.root, env: secretEnv, stdio: "ignore", detached: true });
1082
+ const stopped = new AbortController();
1083
+ const childExit = once(child, "exit");
1084
+ child.once("exit", () => stopped.abort());
1085
+ const signalForwarderGroup = (signal) => {
1086
+ if (child.pid === undefined)
1087
+ return;
1088
+ try {
1089
+ process.kill(-child.pid, signal);
1090
+ }
1091
+ catch (error) {
1092
+ if (error.code !== "ESRCH")
1093
+ throw error;
1094
+ }
1095
+ };
1096
+ const stop = () => { stopped.abort(); signalForwarderGroup("SIGTERM"); };
1097
+ process.once("SIGTERM", stop);
1098
+ process.once("SIGINT", stop);
1099
+ try {
1100
+ try {
1101
+ await waitForOwnedForwarder(readyFile, accountId, stopped.signal);
1102
+ }
1103
+ catch (error) {
1104
+ if (child.exitCode !== null || child.signalCode !== null) {
1105
+ throw new DaemonCommandError(`Remote Control pool ${accountId} inference forwarder exited before readiness.`);
1106
+ }
1107
+ throw error;
1108
+ }
1109
+ const [code, signal] = await childExit;
1110
+ throw new DaemonCommandError(`Remote Control pool ${accountId} inference forwarder exited (${code ?? signal ?? "unknown"}).`);
1111
+ }
1112
+ finally {
1113
+ process.off("SIGTERM", stop);
1114
+ process.off("SIGINT", stop);
1115
+ if (child.exitCode === null && child.signalCode === null) {
1116
+ signalForwarderGroup("SIGTERM");
1117
+ const exited = once(child, "exit").then(() => true);
1118
+ const timeout = new Promise((resolve) => setTimeout(() => resolve(false), 5_000));
1119
+ if (!(await Promise.race([exited, timeout]))) {
1120
+ signalForwarderGroup("SIGKILL");
1121
+ await exited;
1122
+ }
1123
+ }
1124
+ }
1125
+ }
1126
+ async function runServiceRemoteControlPoolForwarder(d, opts) {
1127
+ const secret = d.env[opts.secretEnv];
1128
+ if (!secret)
1129
+ throw new DaemonCommandError("The configured HQ Secret was not injected into the pool inference forwarder.");
1130
+ const server = createInferenceForwarder({ port: Number(opts.port), secret: () => secret });
1131
+ let markerWritten = false;
1132
+ const writeReadyMarker = () => {
1133
+ ensurePrivateDir(path.dirname(opts.readyFile));
1134
+ const temp = `${opts.readyFile}.${process.pid}.tmp`;
1135
+ fs.writeFileSync(temp, `${JSON.stringify({ accountId: opts.accountId, pid: process.pid })}\n`, { mode: 0o600 });
1136
+ fs.renameSync(temp, opts.readyFile);
1137
+ fs.chmodSync(opts.readyFile, 0o600);
1138
+ markerWritten = true;
1139
+ };
1140
+ let stopping = false;
1141
+ const stop = () => {
1142
+ stopping = true;
1143
+ if (server.listening)
1144
+ server.close();
1145
+ };
1146
+ process.once("SIGTERM", stop);
1147
+ process.once("SIGINT", stop);
1148
+ try {
1149
+ await new Promise((resolve, reject) => {
1150
+ server.once("listening", () => {
1151
+ if (stopping) {
1152
+ server.close();
1153
+ reject(new DaemonCommandError("The pool inference forwarder was stopped during startup."));
1154
+ return;
1155
+ }
1156
+ const address = server.address();
1157
+ if (!address || typeof address === "string" || address.address !== "127.0.0.1") {
1158
+ reject(new DaemonCommandError("The pool inference forwarder did not bind loopback-only."));
1159
+ return;
1160
+ }
1161
+ writeReadyMarker();
1162
+ resolve();
1163
+ });
1164
+ server.once("error", reject);
1165
+ });
1166
+ await once(server, "close");
1167
+ }
1168
+ finally {
1169
+ if (markerWritten) {
1170
+ try {
1171
+ const marker = JSON.parse(fs.readFileSync(opts.readyFile, "utf8"));
1172
+ if (marker.pid === process.pid)
1173
+ fs.unlinkSync(opts.readyFile);
1174
+ }
1175
+ catch { /* stale or already removed marker is harmless */ }
1176
+ }
1177
+ process.off("SIGTERM", stop);
1178
+ process.off("SIGINT", stop);
1179
+ }
1180
+ }
833
1181
  async function runServiceCodexRemoteControl(d, opts) {
834
1182
  const { CODEX_TMUX_SESSION } = await import("../lib/daemon/remote-control.js");
835
1183
  const codex = await hostTool(d, "codex", opts.codex);
@@ -1063,6 +1411,19 @@ export function registerDaemonCommand(program, over = {}) {
1063
1411
  .description("List the older HQ background units the daemon would take over; --apply takes them over")
1064
1412
  .option("--apply", "Stop the older units and let the daemon run their services")
1065
1413
  .action(wrap((d, opts) => runMigrate(d, opts)));
1414
+ const pools = daemon
1415
+ .command("remote-control-pools")
1416
+ .description("Validate, configure, and inspect opt-in Claude Remote Control account pools");
1417
+ pools.command("validate <file>")
1418
+ .description("Validate a host-local desired-state JSON file without saving it")
1419
+ .action(wrap((d, file) => validateRemoteControlPools(d, file)));
1420
+ pools.command("configure <file>")
1421
+ .description("Save a validated host-local desired-state JSON file")
1422
+ .action(wrap((d, file) => configureRemoteControlPools(d, file)));
1423
+ pools.command("show")
1424
+ .description("Show configured pool metadata (never secret values)")
1425
+ .option("--json", "Print JSON")
1426
+ .action(wrap((d, opts) => showRemoteControlPools(d, opts)));
1066
1427
  const service = daemon
1067
1428
  .command("service", { hidden: true })
1068
1429
  .description("Entry points the daemon runs for services that have no command of their own");
@@ -1077,6 +1438,29 @@ export function registerDaemonCommand(program, over = {}) {
1077
1438
  .requiredOption("--name <name>", "remote-control session name")
1078
1439
  .option("--permission-mode <mode>", "claude --permission-mode")
1079
1440
  .action(wrap((d, opts) => runServiceRemoteControl(d, opts)));
1441
+ service
1442
+ .command("remote-control-pool")
1443
+ .description("Keep one configured Claude Remote Control account pool running in its private mount namespace")
1444
+ .requiredOption("--account-id <id>", "Claude account ID from hq lanes accounts")
1445
+ .action(wrap((d, opts) => runServiceRemoteControlPool(d, opts.accountId)));
1446
+ service
1447
+ .command("remote-control-pool-host")
1448
+ .description("Run one pool's forwarder and Claude host inside its private namespace")
1449
+ .requiredOption("--account-id <id>", "Claude account ID from hq lanes accounts")
1450
+ .action(wrap((d, opts) => runServiceRemoteControlPoolHost(d, opts.accountId)));
1451
+ service
1452
+ .command("remote-control-pool-forwarder-host")
1453
+ .description("Supervise one pool's personal HQ Secret injected inference forwarder")
1454
+ .requiredOption("--account-id <id>", "Claude account ID from hq lanes accounts")
1455
+ .action(wrap((d, opts) => runServiceRemoteControlPoolForwarderHost(d, opts.accountId)));
1456
+ service
1457
+ .command("remote-control-pool-forwarder")
1458
+ .description("Run the loopback-only /v1/messages inference forwarder")
1459
+ .requiredOption("--account-id <id>", "Claude account ID from hq lanes accounts")
1460
+ .requiredOption("--port <port>", "loopback listener port")
1461
+ .requiredOption("--secret-env <name>", "HQ Secrets injected environment variable name")
1462
+ .requiredOption("--ready-file <path>", "private daemon state file for listener ownership")
1463
+ .action(wrap((d, opts) => runServiceRemoteControlPoolForwarder(d, opts)));
1080
1464
  service
1081
1465
  .command("codex-remote-control")
1082
1466
  .description("Keep the box's Codex remote-control runner going in a tmux session, restarting it after a Codex update")
@@ -48,6 +48,12 @@ interface ConnectOptions {
48
48
  scopes?: string;
49
49
  /** Connect again even when this app is already connected. */
50
50
  force?: boolean;
51
+ /**
52
+ * Print the sign-in link and exit at once. The sign-in finishes on the HQ
53
+ * console, so nothing on this machine waits for it: a bot can send the link
54
+ * to a person and check `hq integrations list` afterwards.
55
+ */
56
+ linkOnly?: boolean;
51
57
  }
52
58
  /**
53
59
  * Validate `--auth` against the supported enum, rejecting an unknown value
@@ -548,6 +548,8 @@ async function connectViaOAuth(token, companyUid, target, opts) {
548
548
  ...target.ref,
549
549
  ...(requestedScopes ? { scopes: requestedScopes } : {}),
550
550
  };
551
+ if (opts.linkOnly)
552
+ return await linkOnlySignIn(token, companyUid, target, opts, startInput);
551
553
  // A user-registered app pins the console callback and skips the loopback
552
554
  // entirely — see `userClientSignIn` for why an ephemeral port cannot be the
553
555
  // redirect URI here.
@@ -876,6 +878,39 @@ async function resolveUserOAuthClient(opts, appLabel) {
876
878
  // proceed with the id alone rather than inventing a blank secret.
877
879
  return { clientId };
878
880
  }
881
+ /**
882
+ * `--link-only`: start the sign-in with hq-pro's console callback (no
883
+ * `redirectUri`, as `userClientSignIn` does), print the link and return. The
884
+ * console finishes the flow it did not start (hq-pro#2823, hq-console#783), so
885
+ * the caller confirms with `hq integrations list` once the person says done.
886
+ * The link goes to stdout (the whole line, unredacted: it is useless redacted)
887
+ * so a bot can read it; with --json it is `{ "app", "authorizationUrl" }`.
888
+ */
889
+ async function linkOnlySignIn(token, companyUid, target, opts, startInput) {
890
+ let started;
891
+ try {
892
+ started = await startOAuth(token, companyUid, startInput);
893
+ }
894
+ catch (err) {
895
+ if (!(err instanceof IntegrationsCliError) || !err.code)
896
+ throw err;
897
+ if (BRING_YOUR_OWN_CLIENT_CODES.has(err.code))
898
+ return await bringYourOwnClientHandoff(target, opts, err.message, err.redirectUri);
899
+ if (!CONSOLE_HANDOFF_CODES.has(err.code))
900
+ throw err;
901
+ return await consoleHandoff(target, { ...opts, browser: false });
902
+ }
903
+ const app = started.displayName || target.label;
904
+ if (opts.json) {
905
+ console.log(JSON.stringify({ app, authorizationUrl: started.authorizationUrl }));
906
+ }
907
+ else {
908
+ console.error(`Sign-in link for ${redactUrlsInText(app)} (it finishes on the HQ console):`);
909
+ console.log(started.authorizationUrl);
910
+ console.error(chalk.dim("When the browser says it connected, run `hq integrations list` to confirm."));
911
+ }
912
+ return null;
913
+ }
879
914
  /**
880
915
  * Sign in with an OAuth app the admin registered themselves.
881
916
  *
@@ -1361,6 +1396,7 @@ export function registerConnectCommands(integrations) {
1361
1396
  .option("--scopes <list>", "Permissions to request, space- or comma-separated (default: what the app needs)")
1362
1397
  .option("--no-browser", "Print the sign-in URL instead of opening a browser")
1363
1398
  .option("--timeout <seconds>", "How long to wait for a browser sign-in (default 300)")
1399
+ .option("--link-only", "Print the sign-in link and exit without waiting; the sign-in finishes on the HQ console")
1364
1400
  .option("--force", "Connect again even if this app is already connected")
1365
1401
  .option("--json", "Machine-readable output")
1366
1402
  .action(async (app, opts) => {
@@ -0,0 +1,30 @@
1
+ /**
2
+ * `hq integrations slack` — connect a person's own Slack account to HQ from
3
+ * the command line, without the console's Add Integration dialog.
4
+ *
5
+ * hq integrations slack connect [--company <slug>] [--open] [--json]
6
+ * hq integrations slack accounts [--json]
7
+ *
8
+ * `connect` asks hq-pro for Slack's own sign-in link
9
+ * (`POST /v1/slack/personal/connect`, the same call the console makes) and
10
+ * prints it, or opens it with `--open`. Slack sends the person back to
11
+ * hq-pro's callback, which stores the user token in their personal vault and
12
+ * shows a "Connected" page; nothing here waits for that. `accounts` lists the
13
+ * connected Slack accounts (token-free summaries), so a caller can confirm.
14
+ */
15
+ import { Command } from "commander";
16
+ export interface SlackPersonalAccountSummary {
17
+ accountId?: string;
18
+ teamId?: string;
19
+ teamName?: string;
20
+ teamDomain?: string;
21
+ slackUserId?: string;
22
+ companyUid?: string;
23
+ companyName?: string;
24
+ companySlug?: string;
25
+ capabilities?: string[];
26
+ }
27
+ export declare function startSlackPersonalConnect(token: string, companyUid: string): Promise<string>;
28
+ export declare function listSlackPersonalAccounts(token: string): Promise<SlackPersonalAccountSummary[]>;
29
+ export declare function registerSlackCommands(integrations: Command): void;
30
+ //# sourceMappingURL=integrations-slack.d.ts.map
@@ -0,0 +1,81 @@
1
+ import chalk from "chalk";
2
+ import open from "open";
3
+ import { ensureCognitoIdToken } from "../utils/cognito-session.js";
4
+ import { getCompanyUid, vaultApiFetch } from "../utils/vault-api.js";
5
+ import { raiseForResponse } from "./integrations-core.js";
6
+ export async function startSlackPersonalConnect(token, companyUid) {
7
+ const res = await vaultApiFetch({
8
+ token,
9
+ path: "/v1/slack/personal/connect",
10
+ method: "POST",
11
+ body: { companyUid },
12
+ });
13
+ if (!res.ok)
14
+ await raiseForResponse(res, "Failed to start the Slack sign-in");
15
+ const body = (await res.json());
16
+ if (!body.url)
17
+ throw new Error("HQ did not return a Slack sign-in link.");
18
+ return body.url;
19
+ }
20
+ export async function listSlackPersonalAccounts(token) {
21
+ const res = await vaultApiFetch({ token, path: "/v1/slack/personal/accounts" });
22
+ if (!res.ok)
23
+ await raiseForResponse(res, "Failed to list Slack accounts");
24
+ const body = (await res.json());
25
+ return body.accounts ?? [];
26
+ }
27
+ export function registerSlackCommands(integrations) {
28
+ const slack = integrations
29
+ .command("slack")
30
+ .description("Connect your own Slack account to HQ (stored in your personal vault)");
31
+ slack
32
+ .command("connect")
33
+ .description("Get Slack's sign-in link for your account; the sign-in finishes on HQ")
34
+ .option("--company <slug>", "Company the Slack workspace belongs to (defaults to your single active company)")
35
+ .option("--open", "Open the link in the browser instead of only printing it")
36
+ .option("--json", "Machine-readable output: { url, opened }")
37
+ .action(async (opts) => {
38
+ const token = await ensureCognitoIdToken();
39
+ const companyUid = await getCompanyUid(token, opts.company);
40
+ const url = await startSlackPersonalConnect(token, companyUid);
41
+ let opened = false;
42
+ if (opts.open) {
43
+ try {
44
+ await open(url);
45
+ opened = true;
46
+ }
47
+ catch {
48
+ opened = false;
49
+ }
50
+ }
51
+ if (opts.json) {
52
+ console.log(JSON.stringify({ url, opened }));
53
+ return;
54
+ }
55
+ console.error(opened ? "Opened Slack's sign-in page. If it did not appear, open:" : "Slack sign-in link:");
56
+ console.log(url);
57
+ console.error(chalk.dim("The link works for 10 minutes. Run `hq integrations slack accounts` to confirm once Slack says connected."));
58
+ });
59
+ slack
60
+ .command("accounts")
61
+ .description("List your connected Slack accounts")
62
+ .option("--json", "Machine-readable output")
63
+ .action(async (opts) => {
64
+ const token = await ensureCognitoIdToken();
65
+ const accounts = await listSlackPersonalAccounts(token);
66
+ if (opts.json) {
67
+ console.log(JSON.stringify({ accounts }));
68
+ return;
69
+ }
70
+ if (!accounts.length) {
71
+ console.log("No Slack accounts connected. Run `hq integrations slack connect`.");
72
+ return;
73
+ }
74
+ for (const a of accounts) {
75
+ const team = a.teamName || a.teamDomain || a.teamId || "Slack workspace";
76
+ const company = a.companyName || a.companySlug || a.companyUid || "";
77
+ console.log(`${team}${company ? ` → ${company}` : ""}`);
78
+ }
79
+ });
80
+ }
81
+ //# sourceMappingURL=integrations-slack.js.map
@@ -58,6 +58,7 @@ import { IntegrationsCliError, bareProvider, callGateway, fetchConnections, fetc
58
58
  import { registerConnectCommands } from "./integrations-connect.js";
59
59
  import { registerImportCommands } from "./integrations-import.js";
60
60
  import { registerManageCommands } from "./integrations-manage.js";
61
+ import { registerSlackCommands } from "./integrations-slack.js";
61
62
  import { fetchPendingApprovals } from "./integrations-api.js";
62
63
  export { IntegrationsCliError, callGateway, fetchConnections, fetchUsableApps, queuedOutcome, selectConnection, toolPrefixForProvider, unwrapGatewayResult, } from "./integrations-core.js";
63
64
  /**
@@ -469,5 +470,6 @@ export function registerIntegrationsCommand(program) {
469
470
  }
470
471
  registerConnectCommands(integrations);
471
472
  registerManageCommands(integrations);
473
+ registerSlackCommands(integrations);
472
474
  }
473
475
  //# sourceMappingURL=integrations.js.map