@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.
- package/CHANGELOG.md +100 -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/logout.d.ts +6 -1
- package/dist/commands/logout.js +18 -2
- 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/fast-scaffold.d.ts +13 -0
- package/dist/fast-scaffold.js +136 -0
- package/dist/index.js +15 -1
- 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/dist/utils/cognito-config.d.ts +5 -0
- package/dist/utils/cognito-config.js +14 -0
- package/dist/utils/cognito-revoke.d.ts +33 -0
- package/dist/utils/cognito-revoke.js +42 -0
- 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
|
-
|
|
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) {
|
package/dist/lib/daemon/run.js
CHANGED
|
@@ -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
|
|
7
|
-
* can omit company journals this
|
|
8
|
-
*
|
|
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
|
|
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
|
|
7
|
-
* can omit company journals this
|
|
8
|
-
*
|
|
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
|
|
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.
|
|
113
|
-
*
|
|
114
|
-
*
|
|
115
|
-
*
|
|
116
|
-
*
|
|
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.
|
|
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 (
|
|
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
|