@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.
- package/CHANGELOG.md +93 -0
- package/assets/bot-workers/setup/skills/add-a-pack.md +130 -0
- package/assets/bot-workers/setup/worker.yaml +3 -0
- package/dist/command-catalog.generated.d.ts +206 -0
- package/dist/command-catalog.generated.js +246 -0
- package/dist/commands/bot.d.ts +10 -0
- package/dist/commands/bot.js +20 -12
- package/dist/commands/daemon.js +385 -1
- package/dist/commands/integrations-connect.d.ts +6 -0
- package/dist/commands/integrations-connect.js +36 -0
- package/dist/commands/integrations-slack.d.ts +30 -0
- package/dist/commands/integrations-slack.js +81 -0
- package/dist/commands/integrations.js +2 -0
- package/dist/commands/pack-install.d.ts +20 -1
- package/dist/commands/pack-install.js +80 -5
- package/dist/commands/pack-start-bot.d.ts +24 -0
- package/dist/commands/pack-start-bot.js +76 -0
- package/dist/commands/packs.d.ts +9 -0
- package/dist/commands/packs.js +26 -3
- package/dist/commands/pkg-install.js +11 -2
- package/dist/commands/secrets.d.ts +1 -5
- package/dist/commands/secrets.js +2 -92
- package/dist/lib/bot/config.d.ts +7 -0
- package/dist/lib/bot/daemon.js +2 -1
- package/dist/lib/bot/index.d.ts +2 -0
- package/dist/lib/bot/index.js +2 -0
- package/dist/lib/bot/lean-settings.d.ts +16 -0
- package/dist/lib/bot/lean-settings.js +34 -0
- package/dist/lib/bot/pack-link.d.ts +66 -0
- package/dist/lib/bot/pack-link.js +153 -0
- package/dist/lib/bot/pack-setup-driver.d.ts +37 -0
- package/dist/lib/bot/pack-setup-driver.js +83 -0
- package/dist/lib/bot/pack-setup.d.ts +62 -0
- package/dist/lib/bot/pack-setup.js +103 -0
- package/dist/lib/bot/prompt.d.ts +8 -0
- package/dist/lib/bot/prompt.js +5 -0
- package/dist/lib/bot/run.d.ts +18 -2
- package/dist/lib/bot/run.js +163 -25
- package/dist/lib/bot/runtime/claude.d.ts +27 -2
- package/dist/lib/bot/runtime/claude.js +49 -4
- package/dist/lib/bot/runtime/types.d.ts +5 -0
- package/dist/lib/bot/runtime-failure.d.ts +18 -2
- package/dist/lib/bot/runtime-failure.js +21 -1
- package/dist/lib/bot/toolchain.d.ts +25 -0
- package/dist/lib/bot/toolchain.js +33 -0
- package/dist/lib/bot/worker-source.d.ts +1 -0
- package/dist/lib/bot/worker-source.js +7 -1
- package/dist/lib/daemon/catalog.d.ts +15 -0
- package/dist/lib/daemon/catalog.js +49 -0
- package/dist/lib/daemon/paths.d.ts +1 -0
- package/dist/lib/daemon/paths.js +1 -0
- package/dist/lib/daemon/remote-control-forwarder.d.ts +14 -0
- package/dist/lib/daemon/remote-control-forwarder.js +104 -0
- package/dist/lib/daemon/remote-control-pools.d.ts +44 -0
- package/dist/lib/daemon/remote-control-pools.js +245 -0
- package/dist/lib/daemon/remote-control.d.ts +4 -0
- package/dist/lib/daemon/remote-control.js +17 -2
- package/dist/lib/daemon/run.js +126 -0
- package/dist/lib/doctor/fix/refresh-sync-progress.d.ts +5 -4
- package/dist/lib/doctor/fix/refresh-sync-progress.js +5 -4
- package/dist/lib/doctor/json-output.d.ts +5 -5
- package/dist/lib/doctor/json-output.js +2 -5
- package/dist/lib/packages/setup-block.d.ts +43 -0
- package/dist/lib/packages/setup-block.js +236 -0
- package/dist/lib/packages/setup-prompt.d.ts +34 -0
- package/dist/lib/packages/setup-prompt.js +128 -0
- package/dist/lib/packages/setup-takeover.d.ts +50 -0
- package/dist/lib/packages/setup-takeover.js +75 -0
- package/dist/lib/packages/start-bot.d.ts +47 -0
- package/dist/lib/packages/start-bot.js +66 -0
- package/dist/lib/session-company-lock.d.ts +7 -0
- package/dist/lib/session-company-lock.js +97 -0
- package/dist/types.d.ts +82 -0
- package/package.json +1 -1
package/dist/commands/daemon.js
CHANGED
|
@@ -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
|