@hivefloor/cli 0.1.4 → 0.1.8
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/LICENSE +6 -20
- package/README.md +31 -9
- package/dist/chunk-2ERPDCOT.js +38 -0
- package/dist/chunk-4G362W6G.js +19 -0
- package/dist/chunk-5OJUJCE3.js +817 -0
- package/dist/chunk-5XXYQQG6.js +274 -0
- package/dist/chunk-AGIYVFDQ.js +10984 -0
- package/dist/{chunk-2BSA7LFD.js → chunk-BLLPDPTL.js} +7 -3
- package/dist/{chunk-R5KDI4WI.js → chunk-FPJNGKT5.js} +9 -3
- package/dist/chunk-HW3KRNCO.js +201 -0
- package/dist/{chunk-ZC2DAKB5.js → chunk-HWBUESB4.js} +1500 -316
- package/dist/{chunk-DNG5CRT3.js → chunk-K2IRTZH5.js} +8 -7
- package/dist/chunk-KQMPCV57.js +122 -0
- package/dist/{chunk-UJ4BQFWV.js → chunk-L6EFW2SJ.js} +53 -31
- package/dist/{chunk-T7TG6WA4.js → chunk-LX5A25O6.js} +4 -2
- package/dist/{chunk-W7DROKC7.js → chunk-MYJCU5JL.js} +89 -13
- package/dist/chunk-NMUH5NXN.js +52 -0
- package/dist/chunk-NRJONTFT.js +67 -0
- package/dist/chunk-QBBKGORP.js +327 -0
- package/dist/chunk-QGULSOQ2.js +142 -0
- package/dist/{chunk-PDXYOREI.js → chunk-QLMLB7RB.js} +110 -31
- package/dist/{chunk-TKMIV7BG.js → chunk-QPAFN47B.js} +3 -1
- package/dist/chunk-SBOEAOHD.js +48 -0
- package/dist/{chunk-SD65CIIY.js → chunk-TIR4CV3P.js} +117 -33
- package/dist/{chunk-QHYZBHXO.js → chunk-X2KH6AR4.js} +195 -251
- package/dist/chunk-XAXKZ572.js +106 -0
- package/dist/{chunk-VNCQFFQO.js → chunk-XPGL7VYE.js} +114 -1
- package/dist/cli-BFOJWJNG.js +279 -0
- package/dist/connect-ZJ4JXBAJ.js +27 -0
- package/dist/control-6GHTZQJV.js +25 -0
- package/dist/{disconnect-MKRHK4CX.js → disconnect-3T25NTKX.js} +55 -34
- package/dist/hook-TPSHBCWU.js +575 -0
- package/dist/index.js +9 -6
- package/dist/{link-HXBMMSG3.js → link-EAS5F5PC.js} +34 -18
- package/dist/{machine-connect-BYHZRRRB.js → machine-connect-KEI4BRWF.js} +30 -24
- package/dist/mcp-LVIYR3EN.js +1882 -0
- package/dist/orch-KVRL2YA2.js +137 -0
- package/dist/{run-336BSU72.js → run-JL2OZWK3.js} +1933 -29
- package/dist/{status-KBBSAADD.js → status-QXOOJGGN.js} +29 -14
- package/dist/token-BFWWQIDA.js +44 -0
- package/dist/voice-QXHFXRWG.js +264 -0
- package/dist/{where-KR7VWSEC.js → where-CMUORQIO.js} +20 -8
- package/package.json +6 -7
- package/dist/chunk-2IMJECYD.js +0 -71
- package/dist/chunk-7KF7IQJN.js +0 -30
- package/dist/chunk-GT3HFFT4.js +0 -132
- package/dist/chunk-JCLWCMEM.js +0 -8
- package/dist/chunk-K3NQKI34.js +0 -10
- package/dist/chunk-O2TC3WSS.js +0 -303
- package/dist/chunk-PY74LT2W.js +0 -41
- package/dist/cli-P3DYE5IR.js +0 -103
- package/dist/connect-ACNM5DQG.js +0 -23
- package/dist/hook-KR2HGVAR.js +0 -301
- package/dist/mcp-N2TMD4GR.js +0 -121
- package/dist/token-JDRLEV5D.js +0 -32
|
@@ -1,54 +1,116 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import { createRequire as __hfCreateRequire } from 'node:module';
|
|
3
|
+
const require = __hfCreateRequire(import.meta.url);
|
|
2
4
|
import {
|
|
5
|
+
FLEET_ENV_KEY,
|
|
6
|
+
FLEET_ENV_SOCKET,
|
|
7
|
+
FLEET_SERVER_NAME,
|
|
8
|
+
FleetServer,
|
|
9
|
+
PERMIT_ENV_KEY,
|
|
10
|
+
PERMIT_ENV_SOCKET,
|
|
11
|
+
PERMIT_TOOL,
|
|
12
|
+
PermitServer,
|
|
13
|
+
fleetAllowedTools,
|
|
14
|
+
newFleetKey,
|
|
15
|
+
newPermitKey
|
|
16
|
+
} from "./chunk-AGIYVFDQ.js";
|
|
17
|
+
import {
|
|
18
|
+
claudeDecision,
|
|
19
|
+
claudeMessage,
|
|
20
|
+
findPane,
|
|
21
|
+
markSessionsEnded,
|
|
3
22
|
nudgesOf,
|
|
23
|
+
readSessions,
|
|
24
|
+
realProc,
|
|
4
25
|
releaseSpool,
|
|
26
|
+
sameProcess,
|
|
5
27
|
stashNudges,
|
|
28
|
+
stopSession,
|
|
29
|
+
sweepInbox,
|
|
6
30
|
takeSpool
|
|
7
|
-
} from "./chunk-
|
|
31
|
+
} from "./chunk-5OJUJCE3.js";
|
|
32
|
+
import {
|
|
33
|
+
ensurePrivateDir
|
|
34
|
+
} from "./chunk-SBOEAOHD.js";
|
|
8
35
|
import {
|
|
9
36
|
buildProfiles,
|
|
10
37
|
cliVersionOs,
|
|
11
38
|
makeEnvReader,
|
|
12
39
|
scanProcesses
|
|
13
|
-
} from "./chunk-
|
|
40
|
+
} from "./chunk-MYJCU5JL.js";
|
|
14
41
|
import {
|
|
15
42
|
VERSION
|
|
16
|
-
} from "./chunk-
|
|
43
|
+
} from "./chunk-K2IRTZH5.js";
|
|
44
|
+
import {
|
|
45
|
+
VERBS,
|
|
46
|
+
activeVerbs,
|
|
47
|
+
controlOn,
|
|
48
|
+
orchestratorFullOn,
|
|
49
|
+
orchestratorOn,
|
|
50
|
+
readControl
|
|
51
|
+
} from "./chunk-KQMPCV57.js";
|
|
17
52
|
import {
|
|
18
53
|
claudeDirsFromRc,
|
|
54
|
+
claudeLabel,
|
|
19
55
|
profileKeyOf,
|
|
20
56
|
profileKeys,
|
|
21
57
|
uniqueProfileKey
|
|
22
|
-
} from "./chunk-
|
|
58
|
+
} from "./chunk-L6EFW2SJ.js";
|
|
23
59
|
import {
|
|
24
60
|
readMachine,
|
|
25
61
|
writeMachine,
|
|
26
62
|
writeServiceState
|
|
27
|
-
} from "./chunk-
|
|
63
|
+
} from "./chunk-LX5A25O6.js";
|
|
28
64
|
import {
|
|
29
65
|
apiRequest
|
|
30
|
-
} from "./chunk-
|
|
66
|
+
} from "./chunk-NRJONTFT.js";
|
|
31
67
|
import {
|
|
68
|
+
resolveClaudeBin,
|
|
69
|
+
runFile
|
|
70
|
+
} from "./chunk-XAXKZ572.js";
|
|
71
|
+
import {
|
|
72
|
+
atomicWrite,
|
|
73
|
+
cliEntry,
|
|
32
74
|
configDir,
|
|
75
|
+
controlLogFile,
|
|
33
76
|
home,
|
|
34
77
|
isDir,
|
|
35
78
|
isRecord,
|
|
36
79
|
machineSpoolFile,
|
|
37
80
|
serviceLogFile
|
|
38
|
-
} from "./chunk-
|
|
81
|
+
} from "./chunk-XPGL7VYE.js";
|
|
82
|
+
import {
|
|
83
|
+
runCmd
|
|
84
|
+
} from "./chunk-QGULSOQ2.js";
|
|
39
85
|
import {
|
|
86
|
+
ENDED_SESSIONS_MAX,
|
|
87
|
+
ENDED_SESSION_ID_RE,
|
|
40
88
|
HOOK_BATCH_MAX,
|
|
41
89
|
MACHINE_HEARTBEAT_SECONDS,
|
|
42
90
|
MACHINE_MIN_INTERVAL_SECONDS,
|
|
91
|
+
ORCH_LIMITS,
|
|
92
|
+
ORCH_PERMISSION_MODES,
|
|
93
|
+
PEEK_LINES_MAX,
|
|
94
|
+
PEEK_OUTPUT_MAX,
|
|
95
|
+
RESUME_ID,
|
|
96
|
+
cleanOrchText,
|
|
97
|
+
estimateClaudeCostUsd,
|
|
98
|
+
isResumeTool,
|
|
99
|
+
machineCommandsResponse,
|
|
43
100
|
machineHeartbeatResponseSchema,
|
|
101
|
+
orchCommandSchema,
|
|
102
|
+
orchCommandsResponseSchema,
|
|
103
|
+
orchEventIn,
|
|
44
104
|
parseUsageKey,
|
|
45
|
-
|
|
105
|
+
redact,
|
|
106
|
+
resumeArgv,
|
|
107
|
+
stripControlChars,
|
|
46
108
|
usageReadingSchema
|
|
47
|
-
} from "./chunk-
|
|
48
|
-
import "./chunk-
|
|
109
|
+
} from "./chunk-HWBUESB4.js";
|
|
110
|
+
import "./chunk-2ERPDCOT.js";
|
|
49
111
|
|
|
50
112
|
// src/service/run.ts
|
|
51
|
-
import
|
|
113
|
+
import fs12 from "fs";
|
|
52
114
|
|
|
53
115
|
// src/service/collectors.ts
|
|
54
116
|
import fs from "fs";
|
|
@@ -285,7 +347,16 @@ function parseClaudeLine(state, line) {
|
|
|
285
347
|
state.tokensIn += (input ?? 0) + (cacheCreate ?? 0) + (cacheRead ?? 0);
|
|
286
348
|
state.tokensOut += output ?? 0;
|
|
287
349
|
state.hasUsage = true;
|
|
288
|
-
|
|
350
|
+
let cost = asPositiveCost(raw.costUSD ?? raw.cost_usd ?? msg.costUSD ?? msg.cost_usd ?? usage.cost_usd ?? usage.costUSD);
|
|
351
|
+
if (cost === void 0) {
|
|
352
|
+
const est = estimateClaudeCostUsd(typeof msg.model === "string" ? msg.model : void 0, {
|
|
353
|
+
input: input ?? 0,
|
|
354
|
+
output: output ?? 0,
|
|
355
|
+
cacheRead: cacheRead ?? 0,
|
|
356
|
+
cacheWrite: cacheCreate ?? 0
|
|
357
|
+
});
|
|
358
|
+
if (est !== null && est > 0) cost = est;
|
|
359
|
+
}
|
|
289
360
|
if (cost !== void 0) {
|
|
290
361
|
state.costUsd += cost;
|
|
291
362
|
state.hasCost = true;
|
|
@@ -497,9 +568,9 @@ function parseDf(text, only) {
|
|
|
497
568
|
for (const line of text.split("\n").slice(1)) {
|
|
498
569
|
const m = /^(\S+)\s+(\d+)\s+(\d+)\s+(\d+)\s+\S+\s+(\/.*)$/.exec(line.trim());
|
|
499
570
|
if (!m) continue;
|
|
500
|
-
const [,
|
|
501
|
-
if (seen.has(
|
|
502
|
-
seen.add(
|
|
571
|
+
const [, fs13, total, used, , mount] = m;
|
|
572
|
+
if (seen.has(fs13) || only && !only(fs13, mount)) continue;
|
|
573
|
+
seen.add(fs13);
|
|
503
574
|
out.push({ mount: mount.slice(0, 80), used_gb: round1(Number(used) / MB), total_gb: round1(Number(total) / MB) });
|
|
504
575
|
}
|
|
505
576
|
return out.slice(0, 8);
|
|
@@ -536,7 +607,7 @@ async function collectMachineStats(deps) {
|
|
|
536
607
|
disks = df ? parseDf(df).map((d) => ({ ...d, mount: "/" })) : [];
|
|
537
608
|
} else {
|
|
538
609
|
const df = deps.exec("df", ["-kPl"]);
|
|
539
|
-
disks = df ? parseDf(df, (
|
|
610
|
+
disks = df ? parseDf(df, (fs13, mount) => fs13.startsWith("/dev/") && !fs13.startsWith("/dev/loop") && !/^\/(boot|snap|var\/lib\/docker)/.test(mount)) : [];
|
|
540
611
|
}
|
|
541
612
|
const stats = { cpu_pct, ram_used_mb, ram_total_mb, disks };
|
|
542
613
|
if (gpu_pct !== void 0) stats.gpu_pct = gpu_pct;
|
|
@@ -898,20 +969,39 @@ var readClaude = async (deps) => {
|
|
|
898
969
|
await readEnvTokens(deps, seenTokens, out);
|
|
899
970
|
return out;
|
|
900
971
|
};
|
|
972
|
+
async function readClaudeLogin(deps, dir) {
|
|
973
|
+
const d = normalizeDir(dir);
|
|
974
|
+
let raw = deps.readFile(joinPath(d, ".credentials.json"));
|
|
975
|
+
if (raw === null && deps.platform === "darwin" && deps.exec) {
|
|
976
|
+
raw = deps.exec("security", ["find-generic-password", "-s", keychainService(d, deps.home), "-w"]);
|
|
977
|
+
}
|
|
978
|
+
const creds = parseClaudeCreds(raw, deps.now());
|
|
979
|
+
if (creds.kind !== "ok") return void 0;
|
|
980
|
+
try {
|
|
981
|
+
const parsed = await readTokenUsage(deps, creds.token, false);
|
|
982
|
+
return {
|
|
983
|
+
...parsed.accountId ? { accountKey: accountKey("claude", parsed.accountId) } : {},
|
|
984
|
+
...parsed.plan ?? creds.plan ? { plan: parsed.plan ?? creds.plan } : {},
|
|
985
|
+
windows: parsed.windows.map((w) => ({ window: w.window, percent: w.percent, ...w.resets_at ? { resets_at: w.resets_at } : {} }))
|
|
986
|
+
};
|
|
987
|
+
} catch {
|
|
988
|
+
return void 0;
|
|
989
|
+
}
|
|
990
|
+
}
|
|
901
991
|
var CLAUDE_HEADERS = (token) => ({
|
|
902
992
|
Authorization: `Bearer ${token}`,
|
|
903
993
|
"anthropic-beta": "oauth-2025-04-20",
|
|
904
994
|
"anthropic-version": "2023-06-01",
|
|
905
995
|
Accept: "application/json"
|
|
906
996
|
});
|
|
907
|
-
async function readTokenUsage(deps, token) {
|
|
997
|
+
async function readTokenUsage(deps, token, probe = true) {
|
|
908
998
|
const headers = CLAUDE_HEADERS(token);
|
|
909
999
|
let parsed;
|
|
910
1000
|
try {
|
|
911
1001
|
parsed = parseClaudeUsage(await requestJson(deps, USAGE_URL, { headers }));
|
|
912
1002
|
if (parsed.windows.length === 0) throw new ReaderError("bad_response", issueMessage("bad_response"));
|
|
913
1003
|
} catch (e) {
|
|
914
|
-
if (!(e instanceof ReaderError) || e.code !== "rejected" && e.code !== "rate_limited") throw e;
|
|
1004
|
+
if (!probe || !(e instanceof ReaderError) || e.code !== "rejected" && e.code !== "rate_limited") throw e;
|
|
915
1005
|
return probeRateLimits(deps, token);
|
|
916
1006
|
}
|
|
917
1007
|
try {
|
|
@@ -1722,9 +1812,9 @@ function exec(cmd, args) {
|
|
|
1722
1812
|
return null;
|
|
1723
1813
|
}
|
|
1724
1814
|
}
|
|
1725
|
-
function readFile(
|
|
1815
|
+
function readFile(path11) {
|
|
1726
1816
|
try {
|
|
1727
|
-
return readFileSync(
|
|
1817
|
+
return readFileSync(path11, "utf8");
|
|
1728
1818
|
} catch {
|
|
1729
1819
|
return null;
|
|
1730
1820
|
}
|
|
@@ -1736,9 +1826,9 @@ function nodeDeps() {
|
|
|
1736
1826
|
return { status: res.status, text: () => res.text(), headers: Object.fromEntries([...res.headers].map(([k, v]) => [k.toLowerCase(), v])) };
|
|
1737
1827
|
},
|
|
1738
1828
|
readFile,
|
|
1739
|
-
readDir(
|
|
1829
|
+
readDir(path11) {
|
|
1740
1830
|
try {
|
|
1741
|
-
return readdirSync(
|
|
1831
|
+
return readdirSync(path11);
|
|
1742
1832
|
} catch {
|
|
1743
1833
|
return [];
|
|
1744
1834
|
}
|
|
@@ -1792,6 +1882,1800 @@ async function collectUsagePush(ctx) {
|
|
|
1792
1882
|
return buildUsagePush({ ...nodeDeps(), claudeTokens: tokens }, nodeStatsDeps());
|
|
1793
1883
|
}
|
|
1794
1884
|
|
|
1885
|
+
// src/service/control/ended.ts
|
|
1886
|
+
var ENDED_QUIET_MS = 15e3;
|
|
1887
|
+
async function pickEndedSessions(map, proc, now) {
|
|
1888
|
+
const cands = Object.entries(map).filter(([id, e]) => !e.ended && e.pid !== void 0 && e.start !== void 0 && now - e.at > ENDED_QUIET_MS && ENDED_SESSION_ID_RE.test(id)).sort((a, b) => a[1].at - b[1].at);
|
|
1889
|
+
const out = [];
|
|
1890
|
+
for (const [id, e] of cands) {
|
|
1891
|
+
if (out.length >= ENDED_SESSIONS_MAX) break;
|
|
1892
|
+
if (!await sameProcess(proc, e.pid, e.start)) out.push({ id, pid: e.pid, start: e.start, at: e.at });
|
|
1893
|
+
}
|
|
1894
|
+
return out;
|
|
1895
|
+
}
|
|
1896
|
+
|
|
1897
|
+
// src/service/control/executor.ts
|
|
1898
|
+
import fs6 from "fs";
|
|
1899
|
+
import path5 from "path";
|
|
1900
|
+
|
|
1901
|
+
// src/service/control/log.ts
|
|
1902
|
+
import fs2 from "fs";
|
|
1903
|
+
import path2 from "path";
|
|
1904
|
+
var CONTROL_LOG_MAX_BYTES = 1024 * 1024;
|
|
1905
|
+
var clean = (s, max) => s.replace(/[^\p{L}\p{N}._:@/-]+/gu, "_").slice(0, max) || "-";
|
|
1906
|
+
function logControl(line, file = controlLogFile()) {
|
|
1907
|
+
try {
|
|
1908
|
+
ensurePrivateDir(path2.dirname(file));
|
|
1909
|
+
let size = 0;
|
|
1910
|
+
try {
|
|
1911
|
+
size = fs2.statSync(file).size;
|
|
1912
|
+
} catch {
|
|
1913
|
+
}
|
|
1914
|
+
if (size > CONTROL_LOG_MAX_BYTES) fs2.renameSync(file, `${file}.1`);
|
|
1915
|
+
const text = `${new Date(line.at).toISOString()} cmd=${clean(line.id, 40)} kind=${clean(line.kind, 12)} session=${clean(line.session, 80)} by=${clean(line.by, 60)} result=${clean(line.result, 60)}
|
|
1916
|
+
`;
|
|
1917
|
+
fs2.appendFileSync(file, text, { mode: 384 });
|
|
1918
|
+
if (process.platform !== "win32") fs2.chmodSync(file, 384);
|
|
1919
|
+
} catch {
|
|
1920
|
+
}
|
|
1921
|
+
}
|
|
1922
|
+
|
|
1923
|
+
// src/service/control/local.ts
|
|
1924
|
+
import fs3 from "fs";
|
|
1925
|
+
import path3 from "path";
|
|
1926
|
+
var ADAPTER_TIMEOUT_MS = 2e4;
|
|
1927
|
+
var adapterFor = (adapters, tool, verb) => adapters.find((a) => a.tools.includes(tool) && a.verbs.includes(verb));
|
|
1928
|
+
function execTrouble(exec2) {
|
|
1929
|
+
if (!path3.isAbsolute(exec2)) return "exec is not an absolute path";
|
|
1930
|
+
let real;
|
|
1931
|
+
try {
|
|
1932
|
+
real = fs3.realpathSync(exec2);
|
|
1933
|
+
} catch {
|
|
1934
|
+
return "exec does not exist";
|
|
1935
|
+
}
|
|
1936
|
+
let st;
|
|
1937
|
+
try {
|
|
1938
|
+
st = fs3.statSync(real);
|
|
1939
|
+
} catch {
|
|
1940
|
+
return "exec does not exist";
|
|
1941
|
+
}
|
|
1942
|
+
if (!st.isFile()) return "exec is not a regular file";
|
|
1943
|
+
if (process.platform === "win32") return void 0;
|
|
1944
|
+
if (typeof process.getuid === "function" && st.uid !== process.getuid()) return "exec is not owned by the user";
|
|
1945
|
+
if ((st.mode & 18) !== 0) return "exec is writable by others";
|
|
1946
|
+
return void 0;
|
|
1947
|
+
}
|
|
1948
|
+
var cleanEnv = () => ({ PATH: process.env.PATH ?? "/usr/bin:/bin", ...process.env.HOME ? { HOME: process.env.HOME } : {} });
|
|
1949
|
+
async function runAdapter(a, cmd, run = runFile) {
|
|
1950
|
+
const trouble = execTrouble(a.exec);
|
|
1951
|
+
if (trouble) return { status: "failed", code: "adapter_error", detail: trouble };
|
|
1952
|
+
const r = await run(a.exec, [cmd.kind, cmd.session.external_id], { input: JSON.stringify(cmd.args), timeoutMs: ADAPTER_TIMEOUT_MS, env: cleanEnv() });
|
|
1953
|
+
if (r.code === 0) return { status: "done", via: "adapter" };
|
|
1954
|
+
const why = r.code === null ? r.spawnError ? "could not start" : "timed out" : r.stderr.trim();
|
|
1955
|
+
return { status: "failed", code: "adapter_error", detail: (why || `exit ${r.code}`).slice(0, 200) };
|
|
1956
|
+
}
|
|
1957
|
+
|
|
1958
|
+
// src/service/control/peek.ts
|
|
1959
|
+
import fs4 from "fs";
|
|
1960
|
+
var T = 4e3;
|
|
1961
|
+
var TRANSCRIPT_TAIL_BYTES = 256 * 1024;
|
|
1962
|
+
function peekLines(args) {
|
|
1963
|
+
const n = args["lines"];
|
|
1964
|
+
return typeof n === "number" && Number.isInteger(n) ? Math.min(PEEK_LINES_MAX, Math.max(1, n)) : 30;
|
|
1965
|
+
}
|
|
1966
|
+
var HOME_DIR = /(?<![\w./~-])(?:\/Users|\/home)\/[^/\s:'"`)]+(?=[/\s:'"`)]|$)/gm;
|
|
1967
|
+
var ANSI = /\u001b(?:\[[0-?]*[ -/]*[@-~]|\][^\u0007\u001b]*(?:\u0007|\u001b\\)|[@-Z\\-_])/g;
|
|
1968
|
+
function cleanPeek(raw, lines) {
|
|
1969
|
+
const plain = raw.replace(ANSI, "").replace(/\r\n?/g, "\n").split("\n").map((l) => stripControlChars(l).replace(/\s+$/, "")).join("\n").replace(/\n+$/, "");
|
|
1970
|
+
const text = redact(plain).text.replace(HOME_DIR, "~");
|
|
1971
|
+
const kept = text.split("\n").slice(-lines);
|
|
1972
|
+
while (kept.length > 1 && Buffer.byteLength(kept.join("\n")) > PEEK_OUTPUT_MAX) kept.shift();
|
|
1973
|
+
let out = kept.join("\n");
|
|
1974
|
+
if (Buffer.byteLength(out) > PEEK_OUTPUT_MAX) out = fitTail(out);
|
|
1975
|
+
return out;
|
|
1976
|
+
}
|
|
1977
|
+
function fitTail(s) {
|
|
1978
|
+
const cps = [...s];
|
|
1979
|
+
let bytes = 0;
|
|
1980
|
+
let i = cps.length;
|
|
1981
|
+
while (i > 0 && bytes + Buffer.byteLength(cps[i - 1]) <= PEEK_OUTPUT_MAX) bytes += Buffer.byteLength(cps[--i]);
|
|
1982
|
+
return cps.slice(i).join("");
|
|
1983
|
+
}
|
|
1984
|
+
function textOf(content) {
|
|
1985
|
+
if (typeof content === "string") return content;
|
|
1986
|
+
if (!Array.isArray(content)) return "";
|
|
1987
|
+
return content.map((p) => p && typeof p === "object" && p.type === "text" && typeof p.text === "string" ? p.text : "").filter(Boolean).join("\n");
|
|
1988
|
+
}
|
|
1989
|
+
function renderTranscript(jsonl) {
|
|
1990
|
+
const out = [];
|
|
1991
|
+
for (const line of jsonl.split("\n")) {
|
|
1992
|
+
if (!line.startsWith("{")) continue;
|
|
1993
|
+
let j;
|
|
1994
|
+
try {
|
|
1995
|
+
j = JSON.parse(line);
|
|
1996
|
+
} catch {
|
|
1997
|
+
continue;
|
|
1998
|
+
}
|
|
1999
|
+
if (!j || typeof j !== "object") continue;
|
|
2000
|
+
const o = j;
|
|
2001
|
+
if (o.type !== "user" && o.type !== "assistant") continue;
|
|
2002
|
+
const t = textOf(o.message?.content).trim();
|
|
2003
|
+
if (!t) continue;
|
|
2004
|
+
out.push(o.type === "user" ? t.split("\n").map((l) => `> ${l}`).join("\n") : t);
|
|
2005
|
+
}
|
|
2006
|
+
return out.join("\n");
|
|
2007
|
+
}
|
|
2008
|
+
function readTranscriptTail(file, maxBytes = TRANSCRIPT_TAIL_BYTES) {
|
|
2009
|
+
let fd;
|
|
2010
|
+
try {
|
|
2011
|
+
const st = fs4.lstatSync(file);
|
|
2012
|
+
if (!st.isFile() || st.isSymbolicLink()) return void 0;
|
|
2013
|
+
if (typeof process.getuid === "function" && st.uid !== process.getuid()) return void 0;
|
|
2014
|
+
const len = Math.min(st.size, maxBytes);
|
|
2015
|
+
const buf = Buffer.alloc(len);
|
|
2016
|
+
fd = fs4.openSync(file, fs4.constants.O_RDONLY | (fs4.constants.O_NOFOLLOW ?? 0));
|
|
2017
|
+
fs4.readSync(fd, buf, 0, len, st.size - len);
|
|
2018
|
+
return buf.toString("utf8");
|
|
2019
|
+
} catch {
|
|
2020
|
+
return void 0;
|
|
2021
|
+
} finally {
|
|
2022
|
+
if (fd !== void 0) fs4.closeSync(fd);
|
|
2023
|
+
}
|
|
2024
|
+
}
|
|
2025
|
+
async function peekSession(cmd, entry, env) {
|
|
2026
|
+
const lines = peekLines(cmd.args);
|
|
2027
|
+
const pane = entry.pid !== void 0 ? await findPane(env.run, env.proc, entry.pid) : void 0;
|
|
2028
|
+
if (pane) {
|
|
2029
|
+
const r = await env.run("tmux", ["capture-pane", "-p", "-J", "-S", `-${lines}`, "-t", pane], { timeoutMs: T });
|
|
2030
|
+
if (r.code === 0) return { status: "done", via: "tmux", output: cleanPeek(r.stdout, lines) };
|
|
2031
|
+
}
|
|
2032
|
+
if (entry.transcript) {
|
|
2033
|
+
const raw = (env.readTail ?? readTranscriptTail)(entry.transcript);
|
|
2034
|
+
if (raw !== void 0) return { status: "done", output: cleanPeek(renderTranscript(raw), lines), detail: "transcript" };
|
|
2035
|
+
}
|
|
2036
|
+
return { status: "failed", code: "no_pane" };
|
|
2037
|
+
}
|
|
2038
|
+
|
|
2039
|
+
// src/service/control/resume.ts
|
|
2040
|
+
import fs5 from "fs";
|
|
2041
|
+
import path4 from "path";
|
|
2042
|
+
var RESUME_SETTLE_MS = 1500;
|
|
2043
|
+
var failed = (code, detail) => ({ status: "failed", code, ...detail ? { detail } : {} });
|
|
2044
|
+
function findProgram(name, pathEnv = process.env.PATH) {
|
|
2045
|
+
if (!/^[a-z][a-z0-9-]*$/.test(name)) return void 0;
|
|
2046
|
+
for (const dir of (pathEnv ?? "").split(path4.delimiter)) {
|
|
2047
|
+
if (!dir || !path4.isAbsolute(dir)) continue;
|
|
2048
|
+
const file = path4.join(dir, name);
|
|
2049
|
+
try {
|
|
2050
|
+
const st = fs5.statSync(file);
|
|
2051
|
+
if (!st.isFile()) continue;
|
|
2052
|
+
fs5.accessSync(file, fs5.constants.X_OK);
|
|
2053
|
+
return file;
|
|
2054
|
+
} catch {
|
|
2055
|
+
}
|
|
2056
|
+
}
|
|
2057
|
+
return void 0;
|
|
2058
|
+
}
|
|
2059
|
+
function resumeDir(cwd) {
|
|
2060
|
+
if (typeof cwd !== "string" || cwd.length === 0 || cwd.length > 500 || cwd.includes("\0") || !path4.isAbsolute(cwd)) return void 0;
|
|
2061
|
+
try {
|
|
2062
|
+
const real = fs5.realpathSync(cwd);
|
|
2063
|
+
return fs5.statSync(real).isDirectory() ? real : void 0;
|
|
2064
|
+
} catch {
|
|
2065
|
+
return void 0;
|
|
2066
|
+
}
|
|
2067
|
+
}
|
|
2068
|
+
function resumeEnv(src = process.env) {
|
|
2069
|
+
const out = { TERM: "xterm-256color" };
|
|
2070
|
+
for (const k of ["PATH", "HOME", "USER", "LOGNAME", "SHELL", "LANG", "LC_ALL", "LC_CTYPE", "XDG_RUNTIME_DIR", "TMUX_TMPDIR"]) {
|
|
2071
|
+
const v = src[k];
|
|
2072
|
+
if (v !== void 0) out[k] = v;
|
|
2073
|
+
}
|
|
2074
|
+
return out;
|
|
2075
|
+
}
|
|
2076
|
+
var resumeSessionName = (tool, id) => `hf-${tool}-${id.toLowerCase()}`;
|
|
2077
|
+
function tmuxMajor(out) {
|
|
2078
|
+
const m = /(\d+)\.\d+/.exec(out);
|
|
2079
|
+
return m ? Number(m[1]) : void 0;
|
|
2080
|
+
}
|
|
2081
|
+
async function resumeSession(cmd, entry, d) {
|
|
2082
|
+
const id = cmd.session.external_id;
|
|
2083
|
+
if (!RESUME_ID.test(id)) return failed("bad_session_id");
|
|
2084
|
+
if (!entry) return failed("unknown_session", "this machine has no record of the session");
|
|
2085
|
+
const tool = entry.tool;
|
|
2086
|
+
if (!isResumeTool(tool)) return failed("unsupported", "no resume for this tool");
|
|
2087
|
+
if (entry.pid !== void 0 && await sameProcess(d.proc, entry.pid, entry.start)) return failed("already_running");
|
|
2088
|
+
const argv = resumeArgv(tool, id);
|
|
2089
|
+
if (!argv) return failed("bad_session_id");
|
|
2090
|
+
const dir = resumeDir(entry.cwd);
|
|
2091
|
+
if (!dir) return failed("no_folder", "the session folder is unknown or gone");
|
|
2092
|
+
const [prog, ...args] = argv;
|
|
2093
|
+
const bin = findProgram(prog, d.pathEnv);
|
|
2094
|
+
if (!bin) return failed("no_program", `${prog} is not on the service PATH`);
|
|
2095
|
+
const name = resumeSessionName(tool, id);
|
|
2096
|
+
const env = d.env ?? resumeEnv();
|
|
2097
|
+
const v = await d.run("tmux", ["-V"], { env, timeoutMs: 5e3 });
|
|
2098
|
+
if (v.spawnError) return failed("no_tmux", "tmux is not installed");
|
|
2099
|
+
const major = tmuxMajor(v.stdout);
|
|
2100
|
+
if (v.code !== 0 || major === void 0 || major < 3) return failed("old_tmux", "tmux 3.0 or newer is needed");
|
|
2101
|
+
const r = await d.run("tmux", ["new-session", "-d", "-s", name, "-c", dir, "--", bin, ...args], { env, timeoutMs: 1e4 });
|
|
2102
|
+
if (r.spawnError) return failed("no_tmux", "tmux is not installed");
|
|
2103
|
+
if (r.code !== 0) {
|
|
2104
|
+
if (/duplicate session/i.test(r.stderr)) return failed("already_running", `tmux session ${name} exists`);
|
|
2105
|
+
return failed("tmux_error", r.stderr.trim().slice(0, 200) || `exit ${r.code ?? "killed"}`);
|
|
2106
|
+
}
|
|
2107
|
+
await d.sleep(RESUME_SETTLE_MS);
|
|
2108
|
+
const alive = await d.run("tmux", ["has-session", "-t", `=${name}`], { env, timeoutMs: 5e3 });
|
|
2109
|
+
if (alive.code !== 0) return failed("exited", `${prog} exited at once`);
|
|
2110
|
+
return { status: "done", via: "tmux", detail: `tmux attach -t ${name}` };
|
|
2111
|
+
}
|
|
2112
|
+
|
|
2113
|
+
// src/service/control/executor.ts
|
|
2114
|
+
var POLL_WAIT_SEC = 25;
|
|
2115
|
+
var POLL_TIMEOUT_MS = 35e3;
|
|
2116
|
+
var OFF_SLEEP_MS = 15e3;
|
|
2117
|
+
var SEEN_TTL_MS = 36e5;
|
|
2118
|
+
var SeenIds = class {
|
|
2119
|
+
constructor(file) {
|
|
2120
|
+
this.file = file;
|
|
2121
|
+
if (!file) return;
|
|
2122
|
+
try {
|
|
2123
|
+
const j = JSON.parse(fs6.readFileSync(file, "utf8"));
|
|
2124
|
+
if (isRecord(j)) {
|
|
2125
|
+
for (const [k, v] of Object.entries(j)) if (typeof v === "number") this.ids.set(k, v);
|
|
2126
|
+
}
|
|
2127
|
+
} catch {
|
|
2128
|
+
}
|
|
2129
|
+
}
|
|
2130
|
+
file;
|
|
2131
|
+
ids = /* @__PURE__ */ new Map();
|
|
2132
|
+
/** True when the id was seen in the last hour; otherwise remembers it. */
|
|
2133
|
+
seenBefore(id, now) {
|
|
2134
|
+
for (const [k, t] of this.ids) if (now - t > SEEN_TTL_MS) this.ids.delete(k);
|
|
2135
|
+
if (this.ids.has(id)) return true;
|
|
2136
|
+
this.ids.set(id, now);
|
|
2137
|
+
if (this.file) {
|
|
2138
|
+
try {
|
|
2139
|
+
ensurePrivateDir(path5.dirname(this.file));
|
|
2140
|
+
atomicWrite(this.file, `${JSON.stringify(Object.fromEntries(this.ids))}
|
|
2141
|
+
`, 384);
|
|
2142
|
+
} catch {
|
|
2143
|
+
}
|
|
2144
|
+
}
|
|
2145
|
+
return false;
|
|
2146
|
+
}
|
|
2147
|
+
};
|
|
2148
|
+
async function resolveVersion(run, resolveBin) {
|
|
2149
|
+
const r = await run(await resolveBin() ?? "claude", ["--version"], { timeoutMs: 3e3 });
|
|
2150
|
+
return r.code === 0 ? r.stdout.trim() : void 0;
|
|
2151
|
+
}
|
|
2152
|
+
function adapterEnv(d) {
|
|
2153
|
+
const run = d.run ?? runFile;
|
|
2154
|
+
let cached;
|
|
2155
|
+
return {
|
|
2156
|
+
run,
|
|
2157
|
+
proc: d.proc ?? realProc(run),
|
|
2158
|
+
now: d.ctx.now,
|
|
2159
|
+
sleep: d.ctx.sleep,
|
|
2160
|
+
kill: d.kill ?? ((pid, sig) => void process.kill(pid, sig)),
|
|
2161
|
+
claudeVersion: d.claudeVersion ?? (() => cached ??= resolveVersion(run, d.claudeBin ?? resolveClaudeBin))
|
|
2162
|
+
};
|
|
2163
|
+
}
|
|
2164
|
+
var isVerb = (k) => VERBS.includes(k);
|
|
2165
|
+
var failed2 = (code, detail) => ({ status: "failed", code, ...detail ? { detail } : {} });
|
|
2166
|
+
async function handleCommand(cmd, receivedAt, d) {
|
|
2167
|
+
const now = d.ctx.now();
|
|
2168
|
+
const ctl = (d.readControl ?? readControl)();
|
|
2169
|
+
if (ctl.state !== "ok" || !ctl.config.enabled) return failed2("control_off");
|
|
2170
|
+
if (!isVerb(cmd.kind) || !ctl.config.verbs[cmd.kind]) return failed2("verb_off");
|
|
2171
|
+
if (ctl.config.from === "owner" && !cmd.requested_by.is_machine_owner) return failed2("not_from_owner");
|
|
2172
|
+
if (d.seen.seenBefore(cmd.id, now)) return null;
|
|
2173
|
+
if (cmd.expires_in_ms - (now - receivedAt) <= 0) return failed2("expired");
|
|
2174
|
+
const entry = readSessions(now)[cmd.session.external_id];
|
|
2175
|
+
const env = adapterEnv(d);
|
|
2176
|
+
if (cmd.kind === "resume") {
|
|
2177
|
+
const local2 = entry ? adapterFor(ctl.config.adapters, entry.tool, "resume") : void 0;
|
|
2178
|
+
if (local2 && entry && !(entry.pid !== void 0 && await sameProcess(env.proc, entry.pid, entry.start))) return await runAdapter(local2, cmd, env.run);
|
|
2179
|
+
return await resumeSession(cmd, entry, { run: env.run, proc: env.proc, sleep: (ms) => d.ctx.sleep(ms) });
|
|
2180
|
+
}
|
|
2181
|
+
if (!entry || !await sameProcess(env.proc, entry.pid, entry.start)) return failed2("unknown_session");
|
|
2182
|
+
const local = adapterFor(ctl.config.adapters, entry.tool, cmd.kind);
|
|
2183
|
+
if (local) return await runAdapter(local, cmd, env.run);
|
|
2184
|
+
const claude = entry.tool === "claude";
|
|
2185
|
+
let out;
|
|
2186
|
+
switch (cmd.kind) {
|
|
2187
|
+
case "message":
|
|
2188
|
+
out = claude ? await claudeMessage(cmd, cmd.id, entry, env) : failed2("unsupported", "no adapter for this tool");
|
|
2189
|
+
break;
|
|
2190
|
+
case "approve":
|
|
2191
|
+
case "deny":
|
|
2192
|
+
out = claude ? claudeDecision(cmd, entry, now) : failed2("unsupported", "no adapter for this tool");
|
|
2193
|
+
break;
|
|
2194
|
+
case "stop":
|
|
2195
|
+
out = await stopSession(cmd, entry, env);
|
|
2196
|
+
break;
|
|
2197
|
+
case "peek":
|
|
2198
|
+
out = await peekSession(cmd, entry, env);
|
|
2199
|
+
break;
|
|
2200
|
+
}
|
|
2201
|
+
return out;
|
|
2202
|
+
}
|
|
2203
|
+
var resultText = (r) => r === null ? "dropped_replay" : r.status === "done" ? `done${r.via ? `/${r.via}` : ""}${r.detail ? `/${r.detail}` : ""}` : `failed/${r.code ?? "unknown"}`;
|
|
2204
|
+
async function handleBatch(cmds, receivedAt, d, post) {
|
|
2205
|
+
for (const cmd of cmds) {
|
|
2206
|
+
let result;
|
|
2207
|
+
try {
|
|
2208
|
+
result = await handleCommand(cmd, receivedAt, d);
|
|
2209
|
+
} catch (e) {
|
|
2210
|
+
result = failed2("internal_error", e.message.slice(0, 200));
|
|
2211
|
+
}
|
|
2212
|
+
logControl({ at: d.ctx.now(), id: cmd.id, kind: cmd.kind, session: cmd.session.external_id, by: `${cmd.requested_by.user_id}:${cmd.requested_by.name}`, result: resultText(result) });
|
|
2213
|
+
if (result) await post(cmd.id, result);
|
|
2214
|
+
}
|
|
2215
|
+
}
|
|
2216
|
+
var seenFile = () => path5.join(configDir(), "control", "seen.json");
|
|
2217
|
+
async function pollOnce(d) {
|
|
2218
|
+
const m = readMachine();
|
|
2219
|
+
if (!m || m.revoked) return "revoked";
|
|
2220
|
+
const ctl = (d.readControl ?? readControl)();
|
|
2221
|
+
if (!controlOn(ctl)) return "off";
|
|
2222
|
+
const post = async (id, body) => {
|
|
2223
|
+
try {
|
|
2224
|
+
await apiRequest(d.ctx, m.host, `/api/v1/machine/commands/${encodeURIComponent(id)}/result`, { method: "POST", token: m.token, body, timeoutMs: 1e4 });
|
|
2225
|
+
} catch {
|
|
2226
|
+
}
|
|
2227
|
+
};
|
|
2228
|
+
for (const g of sweepInbox(d.ctx.now())) {
|
|
2229
|
+
logControl({ at: d.ctx.now(), id: g.entry.id, kind: "message", session: g.session, by: g.entry.from, result: "failed/not_taken" });
|
|
2230
|
+
await post(g.entry.id, failed2("not_taken"));
|
|
2231
|
+
}
|
|
2232
|
+
let r;
|
|
2233
|
+
try {
|
|
2234
|
+
r = await apiRequest(d.ctx, m.host, `/api/v1/machine/commands?wait=${POLL_WAIT_SEC}`, { token: m.token, timeoutMs: POLL_TIMEOUT_MS });
|
|
2235
|
+
} catch {
|
|
2236
|
+
return "error";
|
|
2237
|
+
}
|
|
2238
|
+
if (r.status === 401) return "revoked";
|
|
2239
|
+
if (r.status < 200 || r.status >= 300) return "error";
|
|
2240
|
+
const parsed = machineCommandsResponse.safeParse(r.json);
|
|
2241
|
+
if (!parsed.success) return "error";
|
|
2242
|
+
const receivedAt = d.ctx.now();
|
|
2243
|
+
if (parsed.data.commands.length === 0) return "idle";
|
|
2244
|
+
await handleBatch(parsed.data.commands, receivedAt, d, post);
|
|
2245
|
+
return "handled";
|
|
2246
|
+
}
|
|
2247
|
+
async function controlLoop(d, stopped, backoff) {
|
|
2248
|
+
let failures = 0;
|
|
2249
|
+
while (!stopped()) {
|
|
2250
|
+
let r = "error";
|
|
2251
|
+
try {
|
|
2252
|
+
r = await pollOnce(d);
|
|
2253
|
+
} catch {
|
|
2254
|
+
r = "error";
|
|
2255
|
+
}
|
|
2256
|
+
if (r === "revoked") {
|
|
2257
|
+
await d.ctx.sleep(OFF_SLEEP_MS);
|
|
2258
|
+
} else if (r === "off") {
|
|
2259
|
+
failures = 0;
|
|
2260
|
+
await d.ctx.sleep(OFF_SLEEP_MS);
|
|
2261
|
+
} else if (r === "error") {
|
|
2262
|
+
failures += 1;
|
|
2263
|
+
await d.ctx.sleep(backoff(failures));
|
|
2264
|
+
} else {
|
|
2265
|
+
failures = 0;
|
|
2266
|
+
}
|
|
2267
|
+
await new Promise((res) => setImmediate(res));
|
|
2268
|
+
}
|
|
2269
|
+
}
|
|
2270
|
+
|
|
2271
|
+
// src/service/orch/loop.ts
|
|
2272
|
+
import fs11 from "fs";
|
|
2273
|
+
import path10 from "path";
|
|
2274
|
+
|
|
2275
|
+
// src/service/orch/map.ts
|
|
2276
|
+
import crypto from "crypto";
|
|
2277
|
+
var ID_RE = /^[A-Za-z0-9][A-Za-z0-9._:@/-]{0,127}$/;
|
|
2278
|
+
function safeId(raw, fallback) {
|
|
2279
|
+
const s = typeof raw === "string" ? raw : "";
|
|
2280
|
+
if (ID_RE.test(s)) return s;
|
|
2281
|
+
const c = s.replace(/[^A-Za-z0-9._:@/-]/g, "_").replace(/^[^A-Za-z0-9]+/, "").slice(0, 128);
|
|
2282
|
+
return ID_RE.test(c) ? c : fallback;
|
|
2283
|
+
}
|
|
2284
|
+
function capText(raw, max) {
|
|
2285
|
+
const t = redact(raw).text;
|
|
2286
|
+
if (t.length <= max) return t;
|
|
2287
|
+
let cut = t.slice(0, max - 1);
|
|
2288
|
+
if (/[\uD800-\uDBFF]$/.test(cut)) cut = cut.slice(0, -1);
|
|
2289
|
+
return `${cut}\u2026`;
|
|
2290
|
+
}
|
|
2291
|
+
var isObj = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
2292
|
+
var str = (v) => typeof v === "string" ? v : "";
|
|
2293
|
+
var int = (v) => typeof v === "number" && Number.isFinite(v) && v >= 0 ? Math.round(v) : 0;
|
|
2294
|
+
function toolSummary(name, input, max = ORCH_LIMITS.toolSummary) {
|
|
2295
|
+
const i = isObj(input) ? input : {};
|
|
2296
|
+
let s;
|
|
2297
|
+
switch (name) {
|
|
2298
|
+
case "Bash":
|
|
2299
|
+
s = str(i.command) || "(no command)";
|
|
2300
|
+
if (str(i.description)) s = `${s} # ${str(i.description)}`;
|
|
2301
|
+
break;
|
|
2302
|
+
case "Read":
|
|
2303
|
+
case "Write":
|
|
2304
|
+
case "Edit":
|
|
2305
|
+
case "MultiEdit":
|
|
2306
|
+
case "NotebookEdit":
|
|
2307
|
+
s = str(i.file_path) || str(i.notebook_path) || "(no path)";
|
|
2308
|
+
if (name === "Write") s = `${s} (${str(i.content).length} chars)`;
|
|
2309
|
+
break;
|
|
2310
|
+
case "Glob":
|
|
2311
|
+
case "Grep":
|
|
2312
|
+
s = `${str(i.pattern)}${str(i.path) ? ` in ${str(i.path)}` : ""}`;
|
|
2313
|
+
break;
|
|
2314
|
+
case "WebFetch":
|
|
2315
|
+
s = str(i.url);
|
|
2316
|
+
break;
|
|
2317
|
+
case "WebSearch":
|
|
2318
|
+
s = str(i.query);
|
|
2319
|
+
break;
|
|
2320
|
+
default: {
|
|
2321
|
+
const parts = [];
|
|
2322
|
+
for (const [k, v] of Object.entries(i)) {
|
|
2323
|
+
if (typeof v === "string") parts.push(`${k}=${v.length > 200 ? `${v.slice(0, 200)}\u2026` : v}`);
|
|
2324
|
+
else if (typeof v === "number" || typeof v === "boolean") parts.push(`${k}=${String(v)}`);
|
|
2325
|
+
if (parts.length >= 8) break;
|
|
2326
|
+
}
|
|
2327
|
+
s = parts.join(" ");
|
|
2328
|
+
}
|
|
2329
|
+
}
|
|
2330
|
+
return capText(stripControlChars(s.replace(/\s*\n\s*/g, " ")), max);
|
|
2331
|
+
}
|
|
2332
|
+
function resultText2(content) {
|
|
2333
|
+
if (typeof content === "string") return content;
|
|
2334
|
+
if (Array.isArray(content)) return content.map((c) => isObj(c) && c.type === "text" ? str(c.text) : isObj(c) && c.type === "image" ? "[image]" : "").filter(Boolean).join("\n");
|
|
2335
|
+
return "";
|
|
2336
|
+
}
|
|
2337
|
+
var StreamMapper = class {
|
|
2338
|
+
parts = /* @__PURE__ */ new Map();
|
|
2339
|
+
/** The runtime's session id from `system/init` (for `--resume`). */
|
|
2340
|
+
externalId;
|
|
2341
|
+
msgId(raw) {
|
|
2342
|
+
const base = safeId(raw, "msg").slice(0, 110);
|
|
2343
|
+
const n = (this.parts.get(base) ?? 0) + 1;
|
|
2344
|
+
this.parts.set(base, n);
|
|
2345
|
+
if (this.parts.size > 500) this.parts.delete(this.parts.keys().next().value);
|
|
2346
|
+
return `${base}.${n}`;
|
|
2347
|
+
}
|
|
2348
|
+
/** Map one parsed stream-json line. Unknown types give nothing (newer Claude versions add some). */
|
|
2349
|
+
map(line) {
|
|
2350
|
+
if (!isObj(line)) return [];
|
|
2351
|
+
const out = [];
|
|
2352
|
+
switch (line.type) {
|
|
2353
|
+
case "system": {
|
|
2354
|
+
if (line.subtype === "init" && typeof line.session_id === "string") {
|
|
2355
|
+
const ext = safeId(line.session_id, "");
|
|
2356
|
+
if (ext) {
|
|
2357
|
+
this.externalId = ext;
|
|
2358
|
+
out.push({ type: "status", body: { state: "working", external_id: ext } });
|
|
2359
|
+
}
|
|
2360
|
+
}
|
|
2361
|
+
break;
|
|
2362
|
+
}
|
|
2363
|
+
case "assistant": {
|
|
2364
|
+
const msg = isObj(line.message) ? line.message : {};
|
|
2365
|
+
const content = Array.isArray(msg.content) ? msg.content : [];
|
|
2366
|
+
for (const block of content) {
|
|
2367
|
+
if (!isObj(block)) continue;
|
|
2368
|
+
if (block.type === "text" && str(block.text).trim() !== "") {
|
|
2369
|
+
out.push({ type: "message", body: { role: "assistant", text: capText(str(block.text), ORCH_LIMITS.messageText), msg_id: this.msgId(msg.id) } });
|
|
2370
|
+
} else if (block.type === "thinking" && str(block.thinking).trim() !== "") {
|
|
2371
|
+
out.push({ type: "thinking", body: { text: capText(str(block.thinking), ORCH_LIMITS.thinkingText) } });
|
|
2372
|
+
} else if (block.type === "tool_use") {
|
|
2373
|
+
const name = capText(stripControlChars(str(block.name)) || "tool", 100);
|
|
2374
|
+
out.push({ type: "tool_use", body: { tool_use_id: safeId(block.id, "tool"), name, input_summary: toolSummary(str(block.name), block.input) } });
|
|
2375
|
+
}
|
|
2376
|
+
}
|
|
2377
|
+
break;
|
|
2378
|
+
}
|
|
2379
|
+
case "user": {
|
|
2380
|
+
const msg = isObj(line.message) ? line.message : {};
|
|
2381
|
+
const content = Array.isArray(msg.content) ? msg.content : [];
|
|
2382
|
+
for (const block of content) {
|
|
2383
|
+
if (!isObj(block) || block.type !== "tool_result") continue;
|
|
2384
|
+
out.push({ type: "tool_result", body: { tool_use_id: safeId(block.tool_use_id, "tool"), ok: block.is_error !== true, summary: capText(stripControlChars(resultText2(block.content)), ORCH_LIMITS.toolSummary) } });
|
|
2385
|
+
}
|
|
2386
|
+
break;
|
|
2387
|
+
}
|
|
2388
|
+
case "result": {
|
|
2389
|
+
const u = isObj(line.usage) ? line.usage : {};
|
|
2390
|
+
const usage = { input_tokens: int(u.input_tokens), output_tokens: int(u.output_tokens), duration_ms: int(line.duration_ms) };
|
|
2391
|
+
if (typeof u.cache_read_input_tokens === "number") usage.cache_read = int(u.cache_read_input_tokens);
|
|
2392
|
+
if (typeof line.total_cost_usd === "number" && line.total_cost_usd >= 0) usage.cost_usd = line.total_cost_usd;
|
|
2393
|
+
out.push({ type: "usage", body: usage });
|
|
2394
|
+
if (line.is_error === true) {
|
|
2395
|
+
const code = capText(safeId(line.subtype, "error"), 60);
|
|
2396
|
+
const text = str(line.result) || (Array.isArray(line.errors) ? line.errors.map(str).join("; ") : "") || "The turn ended with an error.";
|
|
2397
|
+
out.push({ type: "error", body: { code, message: capText(stripControlChars(text), ORCH_LIMITS.errorMessage) } });
|
|
2398
|
+
}
|
|
2399
|
+
out.push({ type: "status", body: { state: "idle", ...this.externalId ? { external_id: this.externalId } : {} } });
|
|
2400
|
+
break;
|
|
2401
|
+
}
|
|
2402
|
+
default:
|
|
2403
|
+
break;
|
|
2404
|
+
}
|
|
2405
|
+
return out;
|
|
2406
|
+
}
|
|
2407
|
+
};
|
|
2408
|
+
var fileKey = (id) => crypto.createHash("sha256").update(id).digest("hex").slice(0, 32);
|
|
2409
|
+
|
|
2410
|
+
// src/service/orch/outbox.ts
|
|
2411
|
+
import fs7 from "fs";
|
|
2412
|
+
import path6 from "path";
|
|
2413
|
+
var BATCH_MAX_BYTES = 200 * 1024;
|
|
2414
|
+
var PENDING_MAX = 2e3;
|
|
2415
|
+
function relayable(ev) {
|
|
2416
|
+
if (ev.type === "message" && ev.body.role !== "assistant") return false;
|
|
2417
|
+
return orchEventIn.safeParse(ev).success;
|
|
2418
|
+
}
|
|
2419
|
+
var Outbox = class {
|
|
2420
|
+
constructor(file, io) {
|
|
2421
|
+
this.file = file;
|
|
2422
|
+
this.io = io;
|
|
2423
|
+
try {
|
|
2424
|
+
const j = JSON.parse(fs7.readFileSync(file, "utf8"));
|
|
2425
|
+
if (isRecord(j) && typeof j.last_src_seq === "number" && Number.isSafeInteger(j.last_src_seq) && j.last_src_seq >= 0) this.last = this.persisted = j.last_src_seq;
|
|
2426
|
+
} catch {
|
|
2427
|
+
}
|
|
2428
|
+
}
|
|
2429
|
+
file;
|
|
2430
|
+
io;
|
|
2431
|
+
last = 0;
|
|
2432
|
+
persisted = 0;
|
|
2433
|
+
pending = [];
|
|
2434
|
+
flushing;
|
|
2435
|
+
ready;
|
|
2436
|
+
failures = 0;
|
|
2437
|
+
/** The server no longer knows the session (404): nothing more is sent. */
|
|
2438
|
+
gone = false;
|
|
2439
|
+
revoked = false;
|
|
2440
|
+
dropped = 0;
|
|
2441
|
+
/** The counter, after asking the server once where it got to. */
|
|
2442
|
+
start() {
|
|
2443
|
+
this.ready ??= (async () => {
|
|
2444
|
+
for (let i = 0; i < 5 && !this.gone; i += 1) {
|
|
2445
|
+
const c = await this.io.cursor();
|
|
2446
|
+
if (c !== void 0) {
|
|
2447
|
+
if (c > this.last) this.last = c;
|
|
2448
|
+
return;
|
|
2449
|
+
}
|
|
2450
|
+
await this.io.sleep(Math.min(3e4, 1e3 * 2 ** i));
|
|
2451
|
+
}
|
|
2452
|
+
})();
|
|
2453
|
+
return this.ready;
|
|
2454
|
+
}
|
|
2455
|
+
/** Queue events; they go out on the next flush. Returns how many were taken. */
|
|
2456
|
+
push(drafts) {
|
|
2457
|
+
if (this.gone || drafts.length === 0) return 0;
|
|
2458
|
+
let taken = 0;
|
|
2459
|
+
for (const d of drafts) {
|
|
2460
|
+
const ev = { ...d, src_seq: 0 };
|
|
2461
|
+
if (!relayable({ ...ev, src_seq: 1 })) {
|
|
2462
|
+
this.dropped += 1;
|
|
2463
|
+
this.io.log?.(`dropped an event the server would refuse (${d.type})`);
|
|
2464
|
+
continue;
|
|
2465
|
+
}
|
|
2466
|
+
this.pending.push(ev);
|
|
2467
|
+
taken += 1;
|
|
2468
|
+
}
|
|
2469
|
+
if (this.pending.length > PENDING_MAX) {
|
|
2470
|
+
const cut = this.pending.length - PENDING_MAX;
|
|
2471
|
+
this.pending.splice(0, cut);
|
|
2472
|
+
this.dropped += cut;
|
|
2473
|
+
}
|
|
2474
|
+
return taken;
|
|
2475
|
+
}
|
|
2476
|
+
saveCounter() {
|
|
2477
|
+
if (this.last === this.persisted) return;
|
|
2478
|
+
ensurePrivateDir(path6.dirname(this.file));
|
|
2479
|
+
atomicWrite(this.file, `${JSON.stringify({ last_src_seq: this.last })}
|
|
2480
|
+
`, 384);
|
|
2481
|
+
this.persisted = this.last;
|
|
2482
|
+
}
|
|
2483
|
+
/** Send what is queued, in order, until it is all stored or the server can't take it now. One flush runs at a time. */
|
|
2484
|
+
flush() {
|
|
2485
|
+
this.flushing ??= this.flushAll().finally(() => {
|
|
2486
|
+
this.flushing = void 0;
|
|
2487
|
+
});
|
|
2488
|
+
return this.flushing;
|
|
2489
|
+
}
|
|
2490
|
+
get size() {
|
|
2491
|
+
return this.pending.length;
|
|
2492
|
+
}
|
|
2493
|
+
async flushAll() {
|
|
2494
|
+
await this.start();
|
|
2495
|
+
while (this.pending.length > 0 && !this.gone && !this.revoked) {
|
|
2496
|
+
const batch = [];
|
|
2497
|
+
let bytes = 20;
|
|
2498
|
+
for (const ev of this.pending) {
|
|
2499
|
+
if (batch.length >= ORCH_LIMITS.batch) break;
|
|
2500
|
+
if (ev.src_seq === 0) ev.src_seq = ++this.last;
|
|
2501
|
+
const size = Buffer.byteLength(JSON.stringify(ev)) + 1;
|
|
2502
|
+
if (batch.length > 0 && bytes + size > BATCH_MAX_BYTES) break;
|
|
2503
|
+
batch.push(ev);
|
|
2504
|
+
bytes += size;
|
|
2505
|
+
}
|
|
2506
|
+
try {
|
|
2507
|
+
this.saveCounter();
|
|
2508
|
+
} catch (e) {
|
|
2509
|
+
this.io.log?.(`could not save the event counter: ${e.message}`);
|
|
2510
|
+
}
|
|
2511
|
+
const r = await this.io.post(batch);
|
|
2512
|
+
if (r.kind === "ok") {
|
|
2513
|
+
this.failures = 0;
|
|
2514
|
+
if (r.acked > this.last) this.last = r.acked;
|
|
2515
|
+
const top = Math.max(r.acked, batch[batch.length - 1].src_seq);
|
|
2516
|
+
this.pending = this.pending.filter((e) => e.src_seq === 0 || e.src_seq > top);
|
|
2517
|
+
} else if (r.kind === "bad") {
|
|
2518
|
+
this.io.log?.(`the server refused a batch of ${batch.length} events (400); dropped`);
|
|
2519
|
+
this.dropped += batch.length;
|
|
2520
|
+
const top = batch[batch.length - 1].src_seq;
|
|
2521
|
+
this.pending = this.pending.filter((e) => e.src_seq === 0 || e.src_seq > top);
|
|
2522
|
+
} else if (r.kind === "gone") {
|
|
2523
|
+
this.gone = true;
|
|
2524
|
+
this.dropped += this.pending.length;
|
|
2525
|
+
this.pending = [];
|
|
2526
|
+
} else if (r.kind === "revoked") {
|
|
2527
|
+
this.revoked = true;
|
|
2528
|
+
} else {
|
|
2529
|
+
this.failures += 1;
|
|
2530
|
+
await this.io.sleep(Math.min(3e4, 500 * 2 ** Math.min(this.failures, 6)));
|
|
2531
|
+
if (this.failures >= 3) return;
|
|
2532
|
+
}
|
|
2533
|
+
}
|
|
2534
|
+
}
|
|
2535
|
+
};
|
|
2536
|
+
|
|
2537
|
+
// src/service/orch/session.ts
|
|
2538
|
+
import crypto2 from "crypto";
|
|
2539
|
+
import fs8 from "fs";
|
|
2540
|
+
import path7 from "path";
|
|
2541
|
+
import readline from "readline";
|
|
2542
|
+
var MCP_SERVER_NAME = "hivefloor_orch";
|
|
2543
|
+
var ALLOWED_UP_FRONT = ["TodoWrite", ...fleetAllowedTools()];
|
|
2544
|
+
var READ_TOOLS = /* @__PURE__ */ new Set(["Read", "LS"]);
|
|
2545
|
+
var ENV_KEEP = ["PATH", "HOME", "USER", "LOGNAME", "SHELL", "LANG", "LANGUAGE", "TERM", "TMPDIR", "TZ", "XDG_RUNTIME_DIR", "HTTPS_PROXY", "HTTP_PROXY", "NO_PROXY", "https_proxy", "http_proxy", "no_proxy", "NODE_EXTRA_CA_CERTS", "SSL_CERT_FILE", "SSL_CERT_DIR"];
|
|
2546
|
+
var ENV_DROP_FULL = ["CLAUDECODE", "CLAUDE_CODE_ENTRYPOINT", "CLAUDE_CONFIG_DIR"];
|
|
2547
|
+
var CAFFEINATE = "/usr/bin/caffeinate";
|
|
2548
|
+
var MAX_RESTARTS = 3;
|
|
2549
|
+
var INTERRUPT_GRACE_MS = 3e3;
|
|
2550
|
+
var STOP_GRACE_MS = 5e3;
|
|
2551
|
+
var FULL_OFF_AT_START = "Full power is off on this machine, so the session was not started.";
|
|
2552
|
+
var isObj2 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
2553
|
+
function insideDir(base, p) {
|
|
2554
|
+
if (typeof p !== "string" || !path7.isAbsolute(p)) return false;
|
|
2555
|
+
let real = path7.resolve(base, p);
|
|
2556
|
+
try {
|
|
2557
|
+
real = fs8.realpathSync(real);
|
|
2558
|
+
} catch {
|
|
2559
|
+
}
|
|
2560
|
+
let root = path7.resolve(base);
|
|
2561
|
+
try {
|
|
2562
|
+
root = fs8.realpathSync(root);
|
|
2563
|
+
} catch {
|
|
2564
|
+
}
|
|
2565
|
+
const rel = path7.relative(root, real);
|
|
2566
|
+
return rel === "" || !rel.startsWith("..") && !path7.isAbsolute(rel);
|
|
2567
|
+
}
|
|
2568
|
+
var isFull = (c) => c.power === "full";
|
|
2569
|
+
function permissionModeOf(c) {
|
|
2570
|
+
const m = c.permissionMode;
|
|
2571
|
+
return isFull(c) && m !== void 0 && ORCH_PERMISSION_MODES.includes(m) ? m : "default";
|
|
2572
|
+
}
|
|
2573
|
+
var asksInBrowser = (c) => permissionModeOf(c) !== "bypassPermissions";
|
|
2574
|
+
function briefOf(c) {
|
|
2575
|
+
if (typeof c.brief !== "string") return null;
|
|
2576
|
+
let t = cleanOrchText(c.brief).trim();
|
|
2577
|
+
const enc = new TextEncoder();
|
|
2578
|
+
if (enc.encode(t).length > ORCH_LIMITS.briefBytes) {
|
|
2579
|
+
const chars = Array.from(t);
|
|
2580
|
+
let bytes = 0;
|
|
2581
|
+
let n = 0;
|
|
2582
|
+
for (; n < chars.length; n++) {
|
|
2583
|
+
bytes += enc.encode(chars[n]).length;
|
|
2584
|
+
if (bytes > ORCH_LIMITS.briefBytes) break;
|
|
2585
|
+
}
|
|
2586
|
+
t = chars.slice(0, n).join("").trim();
|
|
2587
|
+
}
|
|
2588
|
+
return t === "" ? null : t;
|
|
2589
|
+
}
|
|
2590
|
+
function fullArgs(c, mcpConfigFile, resumeId) {
|
|
2591
|
+
const a = [
|
|
2592
|
+
"-p",
|
|
2593
|
+
"--input-format",
|
|
2594
|
+
"stream-json",
|
|
2595
|
+
"--output-format",
|
|
2596
|
+
"stream-json",
|
|
2597
|
+
"--verbose",
|
|
2598
|
+
`--model=${c.model}`,
|
|
2599
|
+
`--effort=${c.effort}`,
|
|
2600
|
+
"--permission-mode",
|
|
2601
|
+
permissionModeOf(c),
|
|
2602
|
+
"--mcp-config",
|
|
2603
|
+
mcpConfigFile,
|
|
2604
|
+
"--allowedTools",
|
|
2605
|
+
fleetAllowedTools().join(",")
|
|
2606
|
+
];
|
|
2607
|
+
if (asksInBrowser(c)) a.push("--permission-prompt-tool", `mcp__${MCP_SERVER_NAME}__${PERMIT_TOOL}`);
|
|
2608
|
+
if (c.chrome === true) a.push("--chrome");
|
|
2609
|
+
if (resumeId) a.push(`--resume=${resumeId}`);
|
|
2610
|
+
return a;
|
|
2611
|
+
}
|
|
2612
|
+
function claudeArgs(c, mcpConfigFile, resumeId) {
|
|
2613
|
+
if (isFull(c)) return fullArgs(c, mcpConfigFile, resumeId);
|
|
2614
|
+
const a = [
|
|
2615
|
+
"-p",
|
|
2616
|
+
"--input-format",
|
|
2617
|
+
"stream-json",
|
|
2618
|
+
"--output-format",
|
|
2619
|
+
"stream-json",
|
|
2620
|
+
"--verbose",
|
|
2621
|
+
`--model=${c.model}`,
|
|
2622
|
+
`--effort=${c.effort}`,
|
|
2623
|
+
"--permission-mode",
|
|
2624
|
+
"default",
|
|
2625
|
+
"--permission-prompt-tool",
|
|
2626
|
+
`mcp__${MCP_SERVER_NAME}__${PERMIT_TOOL}`,
|
|
2627
|
+
"--mcp-config",
|
|
2628
|
+
mcpConfigFile,
|
|
2629
|
+
"--strict-mcp-config",
|
|
2630
|
+
"--setting-sources=",
|
|
2631
|
+
"--allowedTools",
|
|
2632
|
+
ALLOWED_UP_FRONT.join(",")
|
|
2633
|
+
];
|
|
2634
|
+
if (resumeId) a.push(`--resume=${resumeId}`);
|
|
2635
|
+
return a;
|
|
2636
|
+
}
|
|
2637
|
+
var OrchSession = class {
|
|
2638
|
+
constructor(cfg, d) {
|
|
2639
|
+
this.cfg = cfg;
|
|
2640
|
+
this.d = d;
|
|
2641
|
+
this.mcpFile = path7.join(d.paths.run, `mcp-${fileKey(cfg.orchId)}.json`);
|
|
2642
|
+
}
|
|
2643
|
+
cfg;
|
|
2644
|
+
d;
|
|
2645
|
+
child;
|
|
2646
|
+
mapper = new StreamMapper();
|
|
2647
|
+
key = newPermitKey();
|
|
2648
|
+
fleetKey = newFleetKey();
|
|
2649
|
+
questions = /* @__PURE__ */ new Map();
|
|
2650
|
+
stateNow = "stopped";
|
|
2651
|
+
restarts = 0;
|
|
2652
|
+
expectedExit;
|
|
2653
|
+
interruptTimer;
|
|
2654
|
+
stderrTail = "";
|
|
2655
|
+
stoppedForGood = false;
|
|
2656
|
+
awake;
|
|
2657
|
+
mcpFile;
|
|
2658
|
+
get state() {
|
|
2659
|
+
return this.stateNow;
|
|
2660
|
+
}
|
|
2661
|
+
get externalId() {
|
|
2662
|
+
return this.mapper.externalId ?? this.cfg.resumeId ?? void 0;
|
|
2663
|
+
}
|
|
2664
|
+
get running() {
|
|
2665
|
+
return this.child !== void 0;
|
|
2666
|
+
}
|
|
2667
|
+
/** Stopped for good (by the user, a switch, or its own refusal to start): it will not spawn again. */
|
|
2668
|
+
get ended() {
|
|
2669
|
+
return this.stoppedForGood;
|
|
2670
|
+
}
|
|
2671
|
+
/** A check that throws (the file cannot be read) counts as "no". */
|
|
2672
|
+
fullStillAllowed() {
|
|
2673
|
+
try {
|
|
2674
|
+
return this.d.fullAllowed() === true;
|
|
2675
|
+
} catch {
|
|
2676
|
+
return false;
|
|
2677
|
+
}
|
|
2678
|
+
}
|
|
2679
|
+
emit(events) {
|
|
2680
|
+
for (const e of events) if (e.type === "status") this.stateNow = e.body.state;
|
|
2681
|
+
this.d.outbox.push(events);
|
|
2682
|
+
void this.d.outbox.flush();
|
|
2683
|
+
}
|
|
2684
|
+
status(state, detail) {
|
|
2685
|
+
const ext = this.externalId;
|
|
2686
|
+
this.emit([{ type: "status", body: { state, ...detail ? { detail: detail.slice(0, 300) } : {}, ...ext ? { external_id: ext } : {} } }]);
|
|
2687
|
+
}
|
|
2688
|
+
writeMcpConfig() {
|
|
2689
|
+
ensurePrivateDir(this.d.paths.run);
|
|
2690
|
+
const conf = {
|
|
2691
|
+
mcpServers: {
|
|
2692
|
+
// The permission tool is there only for a session that asks: one with bypass gets the fleet tools alone.
|
|
2693
|
+
...asksInBrowser(this.cfg) ? {
|
|
2694
|
+
[MCP_SERVER_NAME]: {
|
|
2695
|
+
type: "stdio",
|
|
2696
|
+
command: this.d.cli.node,
|
|
2697
|
+
args: [this.d.cli.entry, "mcp", "--permission-prompt"],
|
|
2698
|
+
env: { [PERMIT_ENV_SOCKET]: this.d.permit.socketPath, [PERMIT_ENV_KEY]: this.key }
|
|
2699
|
+
}
|
|
2700
|
+
} : {},
|
|
2701
|
+
[FLEET_SERVER_NAME]: {
|
|
2702
|
+
type: "stdio",
|
|
2703
|
+
command: this.d.cli.node,
|
|
2704
|
+
args: [this.d.cli.entry, "mcp", "--orchestrator"],
|
|
2705
|
+
env: { [FLEET_ENV_SOCKET]: this.d.fleet.socketPath, [FLEET_ENV_KEY]: this.fleetKey }
|
|
2706
|
+
}
|
|
2707
|
+
}
|
|
2708
|
+
};
|
|
2709
|
+
atomicWrite(this.mcpFile, `${JSON.stringify(conf)}
|
|
2710
|
+
`, 384);
|
|
2711
|
+
}
|
|
2712
|
+
fullEnv() {
|
|
2713
|
+
const env = { ...this.d.env };
|
|
2714
|
+
for (const k of ENV_DROP_FULL) delete env[k];
|
|
2715
|
+
if (this.cfg.configDir) env.CLAUDE_CONFIG_DIR = this.cfg.configDir;
|
|
2716
|
+
if (asksInBrowser(this.cfg)) env.MCP_TOOL_TIMEOUT = String((ORCH_LIMITS.questionTimeoutSec + 60) * 1e3);
|
|
2717
|
+
return env;
|
|
2718
|
+
}
|
|
2719
|
+
childEnv() {
|
|
2720
|
+
if (isFull(this.cfg)) return this.fullEnv();
|
|
2721
|
+
const env = {};
|
|
2722
|
+
for (const k of ENV_KEEP) if (this.d.env[k] !== void 0) env[k] = this.d.env[k];
|
|
2723
|
+
for (const k of Object.keys(this.d.env)) if (k.startsWith("LC_")) env[k] = this.d.env[k];
|
|
2724
|
+
if (this.cfg.configDir) env.CLAUDE_CONFIG_DIR = this.cfg.configDir;
|
|
2725
|
+
env.MCP_TOOL_TIMEOUT = String((ORCH_LIMITS.questionTimeoutSec + 60) * 1e3);
|
|
2726
|
+
return env;
|
|
2727
|
+
}
|
|
2728
|
+
/** Start (or start again with `--resume`). */
|
|
2729
|
+
start() {
|
|
2730
|
+
if (this.child || this.stoppedForGood) return;
|
|
2731
|
+
if (isFull(this.cfg) && !this.fullStillAllowed()) {
|
|
2732
|
+
this.stoppedForGood = true;
|
|
2733
|
+
this.emit([{ type: "error", body: { code: "full_not_allowed", message: FULL_OFF_AT_START } }]);
|
|
2734
|
+
this.finish();
|
|
2735
|
+
this.status("stopped", FULL_OFF_AT_START);
|
|
2736
|
+
return;
|
|
2737
|
+
}
|
|
2738
|
+
if (!isFull(this.cfg)) ensurePrivateDir(this.d.paths.work);
|
|
2739
|
+
this.writeMcpConfig();
|
|
2740
|
+
if (asksInBrowser(this.cfg)) this.d.permit.add(this.key, (req) => this.onPermit(req));
|
|
2741
|
+
this.d.fleet.add(this.fleetKey, this.d.fleetCall);
|
|
2742
|
+
this.status("starting");
|
|
2743
|
+
const resume = this.externalId ?? null;
|
|
2744
|
+
let child;
|
|
2745
|
+
try {
|
|
2746
|
+
child = this.d.spawn(this.d.claudeBin ?? "claude", claudeArgs(this.cfg, this.mcpFile, resume), { cwd: this.d.paths.work, env: this.childEnv() });
|
|
2747
|
+
} catch (e) {
|
|
2748
|
+
this.d.log(`orchestrator: could not start claude: ${e.message}`);
|
|
2749
|
+
this.onExit(null, null);
|
|
2750
|
+
return;
|
|
2751
|
+
}
|
|
2752
|
+
this.child = child;
|
|
2753
|
+
this.stderrTail = "";
|
|
2754
|
+
this.keepAwake(child);
|
|
2755
|
+
const brief = resume === null ? briefOf(this.cfg) : null;
|
|
2756
|
+
if (brief !== null) {
|
|
2757
|
+
child.stdin.write(`${JSON.stringify({ type: "user", message: { role: "user", content: [{ type: "text", text: brief }] } })}
|
|
2758
|
+
`);
|
|
2759
|
+
this.status("working");
|
|
2760
|
+
}
|
|
2761
|
+
const rl = readline.createInterface({ input: child.stdout, crlfDelay: Infinity });
|
|
2762
|
+
rl.on("line", (line) => this.onLine(line));
|
|
2763
|
+
child.stderr.setEncoding("utf8");
|
|
2764
|
+
child.stderr.on("data", (s) => {
|
|
2765
|
+
this.stderrTail = (this.stderrTail + s).slice(-2e3);
|
|
2766
|
+
});
|
|
2767
|
+
child.onExit((code, signal) => {
|
|
2768
|
+
rl.close();
|
|
2769
|
+
this.letSleep();
|
|
2770
|
+
if (this.child === child) {
|
|
2771
|
+
this.child = void 0;
|
|
2772
|
+
this.onExit(code, signal);
|
|
2773
|
+
}
|
|
2774
|
+
});
|
|
2775
|
+
}
|
|
2776
|
+
/** macOS only: `caffeinate -i -w <pid>` holds the idle-sleep assertion for exactly as long as our child lives. */
|
|
2777
|
+
keepAwake(child) {
|
|
2778
|
+
this.letSleep();
|
|
2779
|
+
if (this.cfg.keepAwake !== true || (this.d.platform ?? process.platform) !== "darwin") return;
|
|
2780
|
+
if (typeof child.pid !== "number" || !Number.isInteger(child.pid) || child.pid <= 0) return;
|
|
2781
|
+
try {
|
|
2782
|
+
ensurePrivateDir(this.d.paths.run);
|
|
2783
|
+
const c = this.d.spawn(CAFFEINATE, ["-i", "-w", String(child.pid)], { cwd: this.d.paths.run, env: {} });
|
|
2784
|
+
this.awake = c;
|
|
2785
|
+
c.stdout.resume();
|
|
2786
|
+
c.stderr.resume();
|
|
2787
|
+
c.onExit(() => {
|
|
2788
|
+
if (this.awake === c) this.awake = void 0;
|
|
2789
|
+
});
|
|
2790
|
+
} catch (e) {
|
|
2791
|
+
this.d.log(`orchestrator: keep-awake could not start: ${e.message}`);
|
|
2792
|
+
}
|
|
2793
|
+
}
|
|
2794
|
+
letSleep() {
|
|
2795
|
+
const c = this.awake;
|
|
2796
|
+
this.awake = void 0;
|
|
2797
|
+
c?.kill("SIGTERM");
|
|
2798
|
+
}
|
|
2799
|
+
onLine(line) {
|
|
2800
|
+
if (line.trim() === "") return;
|
|
2801
|
+
let j;
|
|
2802
|
+
try {
|
|
2803
|
+
j = JSON.parse(line);
|
|
2804
|
+
} catch {
|
|
2805
|
+
return;
|
|
2806
|
+
}
|
|
2807
|
+
if (isObj2(j) && j.type === "result") this.restarts = 0;
|
|
2808
|
+
if (isObj2(j) && j.type === "result" && this.interruptTimer) {
|
|
2809
|
+
this.interruptTimer.cancel();
|
|
2810
|
+
this.interruptTimer = void 0;
|
|
2811
|
+
}
|
|
2812
|
+
this.emit(this.mapper.map(j));
|
|
2813
|
+
}
|
|
2814
|
+
onExit(code, signal) {
|
|
2815
|
+
this.interruptTimer?.cancel();
|
|
2816
|
+
this.interruptTimer = void 0;
|
|
2817
|
+
this.denyAll("The session ended.");
|
|
2818
|
+
const why = this.expectedExit;
|
|
2819
|
+
this.expectedExit = void 0;
|
|
2820
|
+
if (why === "stop" || this.stoppedForGood) {
|
|
2821
|
+
this.finish();
|
|
2822
|
+
this.status("stopped");
|
|
2823
|
+
return;
|
|
2824
|
+
}
|
|
2825
|
+
if (why === "respawn") {
|
|
2826
|
+
this.start();
|
|
2827
|
+
return;
|
|
2828
|
+
}
|
|
2829
|
+
const tail = this.stderrTail.trim();
|
|
2830
|
+
this.d.log(`orchestrator: claude exited (code ${code ?? "-"}, signal ${signal ?? "-"})${tail ? `: ${capText(stripControlChars(tail.split("\n").slice(-3).join(" ")), 500)}` : ""}`);
|
|
2831
|
+
const kind = /usage limit|rate limit|429/i.test(tail) ? "usage_limit" : /login|log in|auth|credential|401/i.test(tail) ? "auth" : "exited";
|
|
2832
|
+
const say = { usage_limit: "Claude hit a usage limit", auth: "Claude is not signed in on this machine (or the login expired)", exited: "Claude exited" }[kind];
|
|
2833
|
+
this.emit([{ type: "error", body: { code: kind, message: `${say} (code ${code ?? "-"}, signal ${signal ?? "-"}).` } }]);
|
|
2834
|
+
if (this.restarts >= MAX_RESTARTS) {
|
|
2835
|
+
this.finish();
|
|
2836
|
+
this.status("crashed", `stopped after ${MAX_RESTARTS} restarts`);
|
|
2837
|
+
return;
|
|
2838
|
+
}
|
|
2839
|
+
this.restarts += 1;
|
|
2840
|
+
const wait = 2e3 * 2 ** (this.restarts - 1);
|
|
2841
|
+
this.status("starting", `restart ${this.restarts} of ${MAX_RESTARTS}`);
|
|
2842
|
+
this.d.setTimer(() => this.start(), wait);
|
|
2843
|
+
}
|
|
2844
|
+
finish() {
|
|
2845
|
+
this.d.permit.remove(this.key);
|
|
2846
|
+
this.d.fleet.remove(this.fleetKey);
|
|
2847
|
+
fs8.rmSync(this.mcpFile, { force: true });
|
|
2848
|
+
}
|
|
2849
|
+
/** A user message from HiveFloor, written as one stream-json line. Never a shell command. */
|
|
2850
|
+
send(text) {
|
|
2851
|
+
if (!this.child) return false;
|
|
2852
|
+
const clean2 = cleanOrchText(text).slice(0, ORCH_LIMITS.sendText);
|
|
2853
|
+
if (clean2.trim() === "") return false;
|
|
2854
|
+
this.child.stdin.write(`${JSON.stringify({ type: "user", message: { role: "user", content: [{ type: "text", text: clean2 }] } })}
|
|
2855
|
+
`);
|
|
2856
|
+
if (this.stateNow !== "working") this.status("working");
|
|
2857
|
+
return true;
|
|
2858
|
+
}
|
|
2859
|
+
/** End the turn: ask Claude first; if it is still working after a few seconds, SIGINT our own child and resume it. */
|
|
2860
|
+
interrupt() {
|
|
2861
|
+
const child = this.child;
|
|
2862
|
+
if (!child || this.interruptTimer) return;
|
|
2863
|
+
child.stdin.write(`${JSON.stringify({ type: "control_request", request_id: crypto2.randomBytes(8).toString("hex"), request: { subtype: "interrupt" } })}
|
|
2864
|
+
`);
|
|
2865
|
+
this.denyAll("Interrupted by the user.");
|
|
2866
|
+
this.interruptTimer = this.d.setTimer(() => {
|
|
2867
|
+
this.interruptTimer = void 0;
|
|
2868
|
+
if (this.child !== child || this.stateNow === "idle") return;
|
|
2869
|
+
this.expectedExit = "respawn";
|
|
2870
|
+
child.kill("SIGINT");
|
|
2871
|
+
}, INTERRUPT_GRACE_MS);
|
|
2872
|
+
}
|
|
2873
|
+
/** Stop for good (the user stopped it, or the orchestrator was switched off). */
|
|
2874
|
+
stop() {
|
|
2875
|
+
this.stoppedForGood = true;
|
|
2876
|
+
const child = this.child;
|
|
2877
|
+
if (!child) {
|
|
2878
|
+
this.finish();
|
|
2879
|
+
this.status("stopped");
|
|
2880
|
+
return;
|
|
2881
|
+
}
|
|
2882
|
+
this.expectedExit = "stop";
|
|
2883
|
+
this.denyAll("The session was stopped.");
|
|
2884
|
+
child.stdin.end();
|
|
2885
|
+
this.d.setTimer(() => {
|
|
2886
|
+
if (this.child === child) child.kill("SIGTERM");
|
|
2887
|
+
}, STOP_GRACE_MS);
|
|
2888
|
+
}
|
|
2889
|
+
/** The user's answer from the browser. An unknown or already answered id does nothing. */
|
|
2890
|
+
answer(questionId, choice) {
|
|
2891
|
+
const q = this.questions.get(questionId);
|
|
2892
|
+
if (!q) return false;
|
|
2893
|
+
this.questions.delete(questionId);
|
|
2894
|
+
q.timer.cancel();
|
|
2895
|
+
q.resolve(choice);
|
|
2896
|
+
return true;
|
|
2897
|
+
}
|
|
2898
|
+
denyAll(_why) {
|
|
2899
|
+
for (const [id, q] of this.questions) {
|
|
2900
|
+
this.questions.delete(id);
|
|
2901
|
+
q.timer.cancel();
|
|
2902
|
+
q.resolve(null);
|
|
2903
|
+
}
|
|
2904
|
+
}
|
|
2905
|
+
/** Called by the permit server for this session's key. */
|
|
2906
|
+
async onPermit(req) {
|
|
2907
|
+
const name = req.tool_name;
|
|
2908
|
+
const input = req.input;
|
|
2909
|
+
if (ALLOWED_UP_FRONT.includes(name)) return { behavior: "allow", updatedInput: input };
|
|
2910
|
+
if (READ_TOOLS.has(name) && insideDir(this.d.paths.work, name === "Read" ? input.file_path : input.path)) return { behavior: "allow", updatedInput: input };
|
|
2911
|
+
if (!this.child) return { behavior: "deny", message: "The session is not running." };
|
|
2912
|
+
const questionId = `q_${crypto2.randomBytes(12).toString("base64url")}`;
|
|
2913
|
+
let summary;
|
|
2914
|
+
let options;
|
|
2915
|
+
let kind;
|
|
2916
|
+
if (name === "AskUserQuestion") {
|
|
2917
|
+
kind = "ask";
|
|
2918
|
+
const qs = Array.isArray(input.questions) ? input.questions : [];
|
|
2919
|
+
const first = isObj2(qs[0]) ? qs[0] : {};
|
|
2920
|
+
summary = capText(stripControlChars(typeof first.question === "string" ? first.question : "A question from the orchestrator"), ORCH_LIMITS.questionSummary);
|
|
2921
|
+
const opts = Array.isArray(first.options) ? first.options : [];
|
|
2922
|
+
options = opts.map((o) => isObj2(o) && typeof o.label === "string" ? o.label : typeof o === "string" ? o : "").map((l) => capText(stripControlChars(l), ORCH_LIMITS.optionText)).filter((l) => l.trim() !== "").slice(0, ORCH_LIMITS.optionCount);
|
|
2923
|
+
} else {
|
|
2924
|
+
kind = "permission";
|
|
2925
|
+
summary = capText(`${name}: ${toolSummary(name, input, ORCH_LIMITS.questionSummary)}`, ORCH_LIMITS.questionSummary);
|
|
2926
|
+
options = ["allow", "deny"];
|
|
2927
|
+
}
|
|
2928
|
+
const choice = await new Promise((resolve) => {
|
|
2929
|
+
const timer = this.d.setTimer(() => {
|
|
2930
|
+
if (this.questions.delete(questionId)) resolve(null);
|
|
2931
|
+
}, ORCH_LIMITS.questionTimeoutSec * 1e3);
|
|
2932
|
+
this.questions.set(questionId, { kind, resolve, timer });
|
|
2933
|
+
this.emit([
|
|
2934
|
+
{ type: "question", body: { question_id: questionId, kind, tool: capText(stripControlChars(name), 100), summary, ...options.length > 0 ? { options } : {} } }
|
|
2935
|
+
]);
|
|
2936
|
+
this.status("waiting_input");
|
|
2937
|
+
});
|
|
2938
|
+
if (this.child && this.questions.size === 0) this.status("working");
|
|
2939
|
+
if (kind === "ask") {
|
|
2940
|
+
if (choice === null) return { behavior: "deny", message: "The user did not answer." };
|
|
2941
|
+
return { behavior: "deny", message: `The user answered through HiveFloor: ${choice.slice(0, ORCH_LIMITS.optionText)}` };
|
|
2942
|
+
}
|
|
2943
|
+
if (choice === "allow") return { behavior: "allow", updatedInput: input };
|
|
2944
|
+
return { behavior: "deny", message: choice === null ? "No answer from the user in time; denied." : "The user denied this." };
|
|
2945
|
+
}
|
|
2946
|
+
};
|
|
2947
|
+
|
|
2948
|
+
// src/service/orch/runtimes.ts
|
|
2949
|
+
import fs9 from "fs";
|
|
2950
|
+
import path8 from "path";
|
|
2951
|
+
var OK_TTL_MS = 5 * 6e4;
|
|
2952
|
+
var LIMITED_TTL_MS = 2 * 6e4;
|
|
2953
|
+
var FAIL_TTL_MS = 6e4;
|
|
2954
|
+
var STALE_MS = 30 * 6e4;
|
|
2955
|
+
var PROBE_TIMEOUT_MS = 6e3;
|
|
2956
|
+
function cachedLoginInfo(read, now = Date.now, timeoutMs = PROBE_TIMEOUT_MS) {
|
|
2957
|
+
const cache = /* @__PURE__ */ new Map();
|
|
2958
|
+
return async (dir) => {
|
|
2959
|
+
const c = cache.get(dir);
|
|
2960
|
+
const t = now();
|
|
2961
|
+
if (c?.good && t - c.at < (limitedOf(c.good.state) ? LIMITED_TTL_MS : OK_TTL_MS) && c.failedAt === void 0) return c.good.state;
|
|
2962
|
+
if (c?.failedAt !== void 0 && t - c.failedAt < FAIL_TTL_MS) return c.good && t - c.good.at < STALE_MS ? c.good.state : void 0;
|
|
2963
|
+
let state;
|
|
2964
|
+
let timer;
|
|
2965
|
+
try {
|
|
2966
|
+
state = await Promise.race([read(dir), new Promise((r) => {
|
|
2967
|
+
timer = setTimeout(() => r(void 0), timeoutMs);
|
|
2968
|
+
})]);
|
|
2969
|
+
} catch {
|
|
2970
|
+
state = void 0;
|
|
2971
|
+
} finally {
|
|
2972
|
+
if (timer) clearTimeout(timer);
|
|
2973
|
+
}
|
|
2974
|
+
if (state) {
|
|
2975
|
+
cache.set(dir, { at: t, good: { at: t, state } });
|
|
2976
|
+
return state;
|
|
2977
|
+
}
|
|
2978
|
+
cache.set(dir, { at: t, ...c?.good ? { good: c.good } : {}, failedAt: t });
|
|
2979
|
+
return c?.good && t - c.good.at < STALE_MS ? c.good.state : void 0;
|
|
2980
|
+
};
|
|
2981
|
+
}
|
|
2982
|
+
var sharedInfo;
|
|
2983
|
+
var realLoginInfo = (dir) => (sharedInfo ??= cachedLoginInfo((d) => readClaudeLogin(nodeDeps(), d)))(dir);
|
|
2984
|
+
var limitedOf = (s) => {
|
|
2985
|
+
const full = s.windows.filter((w) => w.percent >= 100);
|
|
2986
|
+
if (full.length === 0) return void 0;
|
|
2987
|
+
const times = full.map((w) => w.resets_at).filter((x) => !!x).sort();
|
|
2988
|
+
const last = times[times.length - 1];
|
|
2989
|
+
return last ? { resetsAt: last } : {};
|
|
2990
|
+
};
|
|
2991
|
+
function shortResets(iso, timeZone) {
|
|
2992
|
+
const d = new Date(iso);
|
|
2993
|
+
if (Number.isNaN(d.getTime())) return void 0;
|
|
2994
|
+
const parts = new Intl.DateTimeFormat("en-US", { timeZone, month: "short", day: "numeric", hour: "numeric", minute: "2-digit", hour12: true }).formatToParts(d);
|
|
2995
|
+
const g = (t) => parts.find((p) => p.type === t)?.value ?? "";
|
|
2996
|
+
const min = g("minute");
|
|
2997
|
+
return `${g("month")} ${g("day")} ${g("hour")}${min && min !== "00" ? `:${min}` : ""}${g("dayPeriod").toLowerCase()}`.slice(0, 40);
|
|
2998
|
+
}
|
|
2999
|
+
function usageOf(s, timeZone) {
|
|
3000
|
+
if (!s || s.windows.length === 0) return void 0;
|
|
3001
|
+
const lim = limitedOf(s);
|
|
3002
|
+
if (!lim) return { usage: "ok" };
|
|
3003
|
+
const resets = lim.resetsAt ? shortResets(lim.resetsAt, timeZone) : void 0;
|
|
3004
|
+
return { usage: "limited", ...resets ? { resets } : {} };
|
|
3005
|
+
}
|
|
3006
|
+
var signedIn = (dir) => {
|
|
3007
|
+
try {
|
|
3008
|
+
return fs9.statSync(dir).isDirectory() && (fs9.existsSync(path8.join(dir, ".credentials.json")) || process.platform === "darwin");
|
|
3009
|
+
} catch {
|
|
3010
|
+
return false;
|
|
3011
|
+
}
|
|
3012
|
+
};
|
|
3013
|
+
function claudeLogins(installedDirs, homeDir = home(), states = /* @__PURE__ */ new Map(), timeZone) {
|
|
3014
|
+
const std = path8.join(homeDir, ".claude");
|
|
3015
|
+
const dirs = [std, ...installedDirs.filter((d) => path8.resolve(d) !== path8.resolve(std))].filter((d, i, a) => a.indexOf(d) === i);
|
|
3016
|
+
const keys = profileKeys("claude", dirs, homeDir);
|
|
3017
|
+
const out = [];
|
|
3018
|
+
for (const d of dirs) {
|
|
3019
|
+
if (!signedIn(d)) continue;
|
|
3020
|
+
const id = keys.get(d);
|
|
3021
|
+
const st = states.get(d);
|
|
3022
|
+
const tag = st?.accountKey ? ` \xB7 #${st.accountKey.slice(0, 4)}` : "";
|
|
3023
|
+
out.push({ id, label: `${claudeLabel(d, id, st?.plan)}${tag}`.slice(0, 120), dir: d, ...usageOf(st, timeZone) });
|
|
3024
|
+
if (out.length >= 20) break;
|
|
3025
|
+
}
|
|
3026
|
+
return out;
|
|
3027
|
+
}
|
|
3028
|
+
async function claudeVersion(run, bin = "claude") {
|
|
3029
|
+
const r = await run(bin, ["--version"], { timeoutMs: 5e3 });
|
|
3030
|
+
if (r.code !== 0) return void 0;
|
|
3031
|
+
const v = r.stdout.trim().split(/\s+/)[0] ?? "";
|
|
3032
|
+
return /^[0-9][0-9A-Za-z.+-]{0,39}$/.test(v) ? v : void 0;
|
|
3033
|
+
}
|
|
3034
|
+
async function orchRuntimes(run, installedDirs, resolveBin = resolveClaudeBin, loginInfo, homeDir = home()) {
|
|
3035
|
+
const version = await claudeVersion(run, await resolveBin() ?? "claude");
|
|
3036
|
+
if (!version) return { runtimes: [], logins: [] };
|
|
3037
|
+
const states = /* @__PURE__ */ new Map();
|
|
3038
|
+
if (loginInfo) {
|
|
3039
|
+
const dirs = claudeLogins(installedDirs, homeDir).map((l) => l.dir);
|
|
3040
|
+
await Promise.all(dirs.map(async (d) => states.set(d, await loginInfo(d).catch(() => void 0))));
|
|
3041
|
+
}
|
|
3042
|
+
const logins = claudeLogins(installedDirs, homeDir, states);
|
|
3043
|
+
return {
|
|
3044
|
+
runtimes: [{ runtime: "claude", version, logins: logins.map(({ id, label, usage, resets }) => ({ id, label, ...usage ? { usage } : {}, ...resets ? { resets } : {} })) }],
|
|
3045
|
+
logins
|
|
3046
|
+
};
|
|
3047
|
+
}
|
|
3048
|
+
|
|
3049
|
+
// src/service/orch/spawn.ts
|
|
3050
|
+
import { spawn } from "child_process";
|
|
3051
|
+
var realSpawn = (file, args, opts) => {
|
|
3052
|
+
const child = spawn(file, args, { cwd: opts.cwd, env: opts.env, shell: false, windowsHide: true, stdio: ["pipe", "pipe", "pipe"] });
|
|
3053
|
+
let exited = false;
|
|
3054
|
+
let result = [null, null];
|
|
3055
|
+
const cbs = [];
|
|
3056
|
+
const done = (code, signal) => {
|
|
3057
|
+
if (exited) return;
|
|
3058
|
+
exited = true;
|
|
3059
|
+
result = [code, signal];
|
|
3060
|
+
for (const cb of cbs) cb(code, signal);
|
|
3061
|
+
};
|
|
3062
|
+
child.on("error", () => done(null, null));
|
|
3063
|
+
child.on("exit", (code, signal) => done(code, signal));
|
|
3064
|
+
child.stdin.on("error", () => void 0);
|
|
3065
|
+
return {
|
|
3066
|
+
pid: child.pid,
|
|
3067
|
+
stdin: child.stdin,
|
|
3068
|
+
stdout: child.stdout,
|
|
3069
|
+
stderr: child.stderr,
|
|
3070
|
+
// Only the child this handle started, and only while it runs: a pid is never signalled after its exit.
|
|
3071
|
+
kill: (signal) => {
|
|
3072
|
+
if (!exited) child.kill(signal);
|
|
3073
|
+
},
|
|
3074
|
+
onExit: (cb) => {
|
|
3075
|
+
if (exited) cb(...result);
|
|
3076
|
+
else cbs.push(cb);
|
|
3077
|
+
}
|
|
3078
|
+
};
|
|
3079
|
+
};
|
|
3080
|
+
|
|
3081
|
+
// src/service/orch/transcript.ts
|
|
3082
|
+
import fs10 from "fs";
|
|
3083
|
+
import path9 from "path";
|
|
3084
|
+
var TRANSCRIPT_ID_RE = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/;
|
|
3085
|
+
var CWD_SCAN_BYTES = 4 * 1024 * 1024;
|
|
3086
|
+
var PROJECT_NAME_EXACT_MAX = 200;
|
|
3087
|
+
var encodeProjectDir = (cwd) => cwd.replace(/[^A-Za-z0-9]/g, "-");
|
|
3088
|
+
function nameMatches(folder, cwd) {
|
|
3089
|
+
const enc = encodeProjectDir(cwd);
|
|
3090
|
+
if (enc === folder) return true;
|
|
3091
|
+
return enc.length > PROJECT_NAME_EXACT_MAX && folder.startsWith(enc.slice(0, PROJECT_NAME_EXACT_MAX));
|
|
3092
|
+
}
|
|
3093
|
+
function readTranscriptCwd(file, folder) {
|
|
3094
|
+
let fd;
|
|
3095
|
+
try {
|
|
3096
|
+
fd = fs10.openSync(file, fs10.constants.O_RDONLY | fs10.constants.O_NOFOLLOW);
|
|
3097
|
+
} catch {
|
|
3098
|
+
return void 0;
|
|
3099
|
+
}
|
|
3100
|
+
try {
|
|
3101
|
+
const size = fs10.fstatSync(fd).size;
|
|
3102
|
+
const buf = Buffer.alloc(Math.min(size, CWD_SCAN_BYTES));
|
|
3103
|
+
let got = 0;
|
|
3104
|
+
while (got < buf.length) {
|
|
3105
|
+
const n = fs10.readSync(fd, buf, got, buf.length - got, got);
|
|
3106
|
+
if (n <= 0) break;
|
|
3107
|
+
got += n;
|
|
3108
|
+
}
|
|
3109
|
+
const lines = buf.subarray(0, got).toString("utf8").split("\n");
|
|
3110
|
+
if (size > got) lines.pop();
|
|
3111
|
+
for (const line of lines) {
|
|
3112
|
+
if (!line.includes('"cwd"')) continue;
|
|
3113
|
+
let j;
|
|
3114
|
+
try {
|
|
3115
|
+
j = JSON.parse(line);
|
|
3116
|
+
} catch {
|
|
3117
|
+
continue;
|
|
3118
|
+
}
|
|
3119
|
+
if (typeof j !== "object" || j === null) continue;
|
|
3120
|
+
const cwd = j.cwd;
|
|
3121
|
+
if (typeof cwd === "string" && path9.isAbsolute(cwd) && !cwd.includes("\0") && nameMatches(folder, cwd)) return cwd;
|
|
3122
|
+
}
|
|
3123
|
+
return void 0;
|
|
3124
|
+
} catch {
|
|
3125
|
+
return void 0;
|
|
3126
|
+
} finally {
|
|
3127
|
+
fs10.closeSync(fd);
|
|
3128
|
+
}
|
|
3129
|
+
}
|
|
3130
|
+
var isRealDir = (p) => {
|
|
3131
|
+
try {
|
|
3132
|
+
return fs10.lstatSync(p).isDirectory();
|
|
3133
|
+
} catch {
|
|
3134
|
+
return false;
|
|
3135
|
+
}
|
|
3136
|
+
};
|
|
3137
|
+
var isRealFile = (p) => {
|
|
3138
|
+
try {
|
|
3139
|
+
return fs10.lstatSync(p).isFile();
|
|
3140
|
+
} catch {
|
|
3141
|
+
return false;
|
|
3142
|
+
}
|
|
3143
|
+
};
|
|
3144
|
+
function findTranscript(id, configDirs) {
|
|
3145
|
+
if (!TRANSCRIPT_ID_RE.test(id)) return void 0;
|
|
3146
|
+
let best;
|
|
3147
|
+
const seen = /* @__PURE__ */ new Set();
|
|
3148
|
+
for (const dir of configDirs) {
|
|
3149
|
+
const projects = path9.join(dir, "projects");
|
|
3150
|
+
if (seen.has(path9.resolve(projects))) continue;
|
|
3151
|
+
seen.add(path9.resolve(projects));
|
|
3152
|
+
let names;
|
|
3153
|
+
try {
|
|
3154
|
+
names = fs10.readdirSync(projects, { withFileTypes: true });
|
|
3155
|
+
} catch {
|
|
3156
|
+
continue;
|
|
3157
|
+
}
|
|
3158
|
+
for (const n of names) {
|
|
3159
|
+
if (!n.isDirectory()) continue;
|
|
3160
|
+
const file = path9.join(projects, n.name, `${id}.jsonl`);
|
|
3161
|
+
let st;
|
|
3162
|
+
try {
|
|
3163
|
+
st = fs10.lstatSync(file);
|
|
3164
|
+
} catch {
|
|
3165
|
+
continue;
|
|
3166
|
+
}
|
|
3167
|
+
if (!st.isFile()) continue;
|
|
3168
|
+
if (best && best.mtime >= st.mtimeMs) continue;
|
|
3169
|
+
best = { f: { id, configDir: dir, projectDir: path9.join(projects, n.name), file, cwd: void 0 }, mtime: st.mtimeMs };
|
|
3170
|
+
}
|
|
3171
|
+
}
|
|
3172
|
+
if (!best) return void 0;
|
|
3173
|
+
best.f.cwd = readTranscriptCwd(best.f.file, path9.basename(best.f.projectDir));
|
|
3174
|
+
return best.f;
|
|
3175
|
+
}
|
|
3176
|
+
function usableDir(dir) {
|
|
3177
|
+
if (dir === void 0 || !path9.isAbsolute(dir)) return false;
|
|
3178
|
+
try {
|
|
3179
|
+
return fs10.statSync(dir).isDirectory();
|
|
3180
|
+
} catch {
|
|
3181
|
+
return false;
|
|
3182
|
+
}
|
|
3183
|
+
}
|
|
3184
|
+
function ensureDir(dir) {
|
|
3185
|
+
fs10.mkdirSync(dir, { recursive: true, mode: 448 });
|
|
3186
|
+
return fs10.realpathSync(dir);
|
|
3187
|
+
}
|
|
3188
|
+
var stableDirFor = (homeDir, id) => path9.join(homeDir, "hivefloor", "orchestrators", id.slice(0, 8));
|
|
3189
|
+
var mainDirFor = (homeDir) => path9.join(homeDir, "hivefloor", "orchestrators", "main");
|
|
3190
|
+
var noLinks = (src) => {
|
|
3191
|
+
try {
|
|
3192
|
+
return !fs10.lstatSync(src).isSymbolicLink();
|
|
3193
|
+
} catch {
|
|
3194
|
+
return false;
|
|
3195
|
+
}
|
|
3196
|
+
};
|
|
3197
|
+
function placeTranscript(found, configDir2, cwd) {
|
|
3198
|
+
const sameCwd = found.cwd !== void 0 && path9.resolve(found.cwd) === path9.resolve(cwd);
|
|
3199
|
+
const folder = sameCwd ? path9.basename(found.projectDir) : encodeProjectDir(cwd);
|
|
3200
|
+
const destProject = path9.join(configDir2, "projects", folder);
|
|
3201
|
+
const dest = path9.join(destProject, `${found.id}.jsonl`);
|
|
3202
|
+
try {
|
|
3203
|
+
if (path9.resolve(dest) === path9.resolve(found.file)) return { ok: true, how: "in_place", file: dest };
|
|
3204
|
+
if (fs10.existsSync(dest)) return isRealFile(dest) ? { ok: true, how: "kept_copy", file: dest } : { ok: false, reason: "the place for the copy is taken by something that is not a file" };
|
|
3205
|
+
fs10.mkdirSync(destProject, { recursive: true, mode: 448 });
|
|
3206
|
+
fs10.copyFileSync(found.file, dest, fs10.constants.COPYFILE_EXCL);
|
|
3207
|
+
fs10.chmodSync(dest, 384);
|
|
3208
|
+
for (const extra of ["memory", found.id]) {
|
|
3209
|
+
const from = path9.join(found.projectDir, extra);
|
|
3210
|
+
const to = path9.join(destProject, extra);
|
|
3211
|
+
if (!isRealDir(from) || fs10.existsSync(to)) continue;
|
|
3212
|
+
try {
|
|
3213
|
+
fs10.cpSync(from, to, { recursive: true, force: false, errorOnExist: false, filter: noLinks });
|
|
3214
|
+
} catch {
|
|
3215
|
+
}
|
|
3216
|
+
}
|
|
3217
|
+
return { ok: true, how: "copied", file: dest };
|
|
3218
|
+
} catch (e) {
|
|
3219
|
+
return { ok: false, reason: e.message };
|
|
3220
|
+
}
|
|
3221
|
+
}
|
|
3222
|
+
|
|
3223
|
+
// src/service/orch/loop.ts
|
|
3224
|
+
var ORCH_POLL_WAIT_SEC = 25;
|
|
3225
|
+
var ORCH_POLL_TIMEOUT_MS = 35e3;
|
|
3226
|
+
var ORCH_OFF_SLEEP_MS = 15e3;
|
|
3227
|
+
var ORCH_SWITCH_CHECK_MS = 2e3;
|
|
3228
|
+
var INFO_EVERY_MS = 5 * 6e4;
|
|
3229
|
+
var OUTBOX_MAX = 4;
|
|
3230
|
+
var ORCH_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._:@/-]{0,127}$/;
|
|
3231
|
+
var orchDir = () => path10.join(configDir(), "orchestrator");
|
|
3232
|
+
var startKey = (a) => JSON.stringify([a.runtime, a.model, a.effort, a.login_id ?? null, a.mode, a.external_id ?? null, a.power ?? "safe", a.permission_mode ?? "default", a.chrome === true, a.keep_awake === true, a.cwd ?? "auto"]);
|
|
3233
|
+
var FULL_OFF_MESSAGE = 'Full power is not turned on on this machine. Run "hivefloor orch enable --full" on it, then start again.';
|
|
3234
|
+
var realTimer = (fn, ms) => {
|
|
3235
|
+
const t = setTimeout(fn, ms);
|
|
3236
|
+
t.unref?.();
|
|
3237
|
+
return { cancel: () => clearTimeout(t) };
|
|
3238
|
+
};
|
|
3239
|
+
var Orchestrator = class {
|
|
3240
|
+
constructor(d) {
|
|
3241
|
+
this.d = d;
|
|
3242
|
+
this.dir = d.dir ?? orchDir();
|
|
3243
|
+
this.seen = new SeenIds(path10.join(this.dir, "state", "seen.json"));
|
|
3244
|
+
}
|
|
3245
|
+
d;
|
|
3246
|
+
sessions = /* @__PURE__ */ new Map();
|
|
3247
|
+
outboxes = /* @__PURE__ */ new Map();
|
|
3248
|
+
permit;
|
|
3249
|
+
fleet;
|
|
3250
|
+
seen;
|
|
3251
|
+
logins = [];
|
|
3252
|
+
lastInfo = "";
|
|
3253
|
+
lastInfoAt = -Infinity;
|
|
3254
|
+
/** Every orch/info send runs through this one chain: a full-power report from the watcher and a round's info can't overlap, so the later one (built from the later state) always lands last. */
|
|
3255
|
+
infoChain = Promise.resolve();
|
|
3256
|
+
enabled = false;
|
|
3257
|
+
fullAllowed = false;
|
|
3258
|
+
startKeys = /* @__PURE__ */ new Map();
|
|
3259
|
+
reconciled = false;
|
|
3260
|
+
dir;
|
|
3261
|
+
log(line) {
|
|
3262
|
+
this.d.log?.(line);
|
|
3263
|
+
}
|
|
3264
|
+
machine() {
|
|
3265
|
+
return (this.d.readMachine ?? readMachine)();
|
|
3266
|
+
}
|
|
3267
|
+
outbox(orchId, m) {
|
|
3268
|
+
let o = this.outboxes.get(orchId);
|
|
3269
|
+
if (o) return o;
|
|
3270
|
+
this.evictOutboxes();
|
|
3271
|
+
const idPath = encodeURIComponent(orchId);
|
|
3272
|
+
o = new Outbox(this.stateFile(orchId), {
|
|
3273
|
+
post: async (events) => {
|
|
3274
|
+
try {
|
|
3275
|
+
const r = await apiRequest(this.d.ctx, m.host, `/api/v1/machine/orch/${idPath}/events`, { method: "POST", token: m.token, body: { events }, timeoutMs: 15e3 });
|
|
3276
|
+
if (r.status === 200 && isRecord(r.json) && typeof r.json.acked_src_seq === "number") return { kind: "ok", acked: r.json.acked_src_seq };
|
|
3277
|
+
if (r.status === 400 || r.status === 413) return { kind: "bad" };
|
|
3278
|
+
if (r.status === 404) return { kind: "gone" };
|
|
3279
|
+
if (r.status === 401) return { kind: "revoked" };
|
|
3280
|
+
return { kind: "retry" };
|
|
3281
|
+
} catch {
|
|
3282
|
+
return { kind: "retry" };
|
|
3283
|
+
}
|
|
3284
|
+
},
|
|
3285
|
+
cursor: async () => {
|
|
3286
|
+
try {
|
|
3287
|
+
const r = await apiRequest(this.d.ctx, m.host, `/api/v1/machine/orch/${idPath}/cursor`, { token: m.token, timeoutMs: 1e4 });
|
|
3288
|
+
if (r.status === 200 && isRecord(r.json) && typeof r.json.acked_src_seq === "number") return r.json.acked_src_seq;
|
|
3289
|
+
} catch {
|
|
3290
|
+
}
|
|
3291
|
+
return void 0;
|
|
3292
|
+
},
|
|
3293
|
+
sleep: (ms) => this.d.ctx.sleep(ms),
|
|
3294
|
+
log: (l) => this.log(`orchestrator ${orchId}: ${l}`)
|
|
3295
|
+
});
|
|
3296
|
+
this.outboxes.set(orchId, o);
|
|
3297
|
+
return o;
|
|
3298
|
+
}
|
|
3299
|
+
stateFile(orchId) {
|
|
3300
|
+
return path10.join(this.dir, "state", `${fileKey(orchId)}.json`);
|
|
3301
|
+
}
|
|
3302
|
+
/** Keep at most OUTBOX_MAX outboxes: drop idle ones that no running session uses, oldest first. */
|
|
3303
|
+
evictOutboxes() {
|
|
3304
|
+
for (const [id, o] of this.outboxes) {
|
|
3305
|
+
if (this.outboxes.size < OUTBOX_MAX) return;
|
|
3306
|
+
if (o.size > 0 || this.sessions.has(id)) continue;
|
|
3307
|
+
this.outboxes.delete(id);
|
|
3308
|
+
fs11.rmSync(this.stateFile(id), { force: true });
|
|
3309
|
+
}
|
|
3310
|
+
}
|
|
3311
|
+
async ensurePermit() {
|
|
3312
|
+
if (this.permit) return this.permit;
|
|
3313
|
+
const p = new PermitServer(path10.join(this.dir, "run", "permit.sock"));
|
|
3314
|
+
await p.listen();
|
|
3315
|
+
this.permit = p;
|
|
3316
|
+
return p;
|
|
3317
|
+
}
|
|
3318
|
+
async ensureFleet() {
|
|
3319
|
+
if (this.fleet) return this.fleet;
|
|
3320
|
+
const f = new FleetServer(path10.join(this.dir, "run", "fleet.sock"));
|
|
3321
|
+
await f.listen();
|
|
3322
|
+
this.fleet = f;
|
|
3323
|
+
return f;
|
|
3324
|
+
}
|
|
3325
|
+
/** One fleet read for an orchestrator: relayed to the server with this machine's credentials (the child holds none). */
|
|
3326
|
+
fleetCall(orchId, m0) {
|
|
3327
|
+
const idPath = encodeURIComponent(orchId);
|
|
3328
|
+
return async (tool, args) => {
|
|
3329
|
+
const m = this.machine() ?? m0;
|
|
3330
|
+
try {
|
|
3331
|
+
const r = await apiRequest(this.d.ctx, m.host, `/api/v1/machine/orch/${idPath}/fleet`, { method: "POST", token: m.token, body: { tool, args }, timeoutMs: 2e4 });
|
|
3332
|
+
if (r.status === 200 && isRecord(r.json) && typeof r.json.text === "string") return { ok: true, text: r.json.text };
|
|
3333
|
+
if (r.status === 404) return { ok: false, error: "Not found (or not yours)." };
|
|
3334
|
+
if (r.status === 401) return { ok: false, error: "This machine is not signed in to HiveFloor." };
|
|
3335
|
+
if (r.status === 429) return { ok: false, error: "Too many fleet reads; wait a moment." };
|
|
3336
|
+
return { ok: false, error: "The HiveFloor server could not answer that." };
|
|
3337
|
+
} catch {
|
|
3338
|
+
return { ok: false, error: "The HiveFloor server is not reachable." };
|
|
3339
|
+
}
|
|
3340
|
+
};
|
|
3341
|
+
}
|
|
3342
|
+
/** Events for a session that is not running here (an error, then `stopped`), so the server's state follows. */
|
|
3343
|
+
report(orchId, m, events) {
|
|
3344
|
+
const o = this.outbox(orchId, m);
|
|
3345
|
+
o.push(events);
|
|
3346
|
+
void o.flush();
|
|
3347
|
+
}
|
|
3348
|
+
notRunning(orchId, m, code, message) {
|
|
3349
|
+
this.report(orchId, m, [
|
|
3350
|
+
{ type: "error", body: { code, message } },
|
|
3351
|
+
{ type: "status", body: { state: "stopped", detail: message.slice(0, 300) } }
|
|
3352
|
+
]);
|
|
3353
|
+
}
|
|
3354
|
+
control() {
|
|
3355
|
+
return (this.d.readControl ?? readControl)();
|
|
3356
|
+
}
|
|
3357
|
+
/** Every Claude config dir a transcript may be under: the standard one, then the signed-in logins. */
|
|
3358
|
+
configDirs() {
|
|
3359
|
+
return [path10.join(this.d.home ?? home(), ".claude"), ...this.logins.map((l) => l.dir)];
|
|
3360
|
+
}
|
|
3361
|
+
/**
|
|
3362
|
+
* Where a full-power session runs and what it resumes. A fresh one: `~/hivefloor/orchestrators/main`. One with an id: the folder
|
|
3363
|
+
* its transcript names, or the stable folder when asked (`cwd: 'stable'`) or when that folder is gone; the transcript is copied
|
|
3364
|
+
* there first (transcript.ts). Returns a refusal as `{ code, message }`.
|
|
3365
|
+
*/
|
|
3366
|
+
fullPlan(args, login, resume) {
|
|
3367
|
+
const homeDir = this.d.home ?? home();
|
|
3368
|
+
const std = path10.join(homeDir, ".claude");
|
|
3369
|
+
const configOf = (dir) => dir === void 0 || path10.resolve(dir) === path10.resolve(std) ? void 0 : dir;
|
|
3370
|
+
const attached = args.mode === "attached";
|
|
3371
|
+
try {
|
|
3372
|
+
if (resume === null) return { work: ensureDir(mainDirFor(homeDir)), configDir: configOf(login?.dir), resumeId: null };
|
|
3373
|
+
const found = TRANSCRIPT_ID_RE.test(resume) ? findTranscript(resume, this.configDirs()) : void 0;
|
|
3374
|
+
if (!found) {
|
|
3375
|
+
if (attached) return { code: "session_not_found", message: "No Claude Code session with that id was found on this machine." };
|
|
3376
|
+
return { work: ensureDir(mainDirFor(homeDir)), configDir: configOf(login?.dir), resumeId: null, note: "The earlier conversation was not found on this machine; this is a new one." };
|
|
3377
|
+
}
|
|
3378
|
+
const dir = args.login_id === null || !login ? found.configDir : login.dir;
|
|
3379
|
+
const stable = args.cwd === "stable" || !usableDir(found.cwd);
|
|
3380
|
+
const work = stable ? ensureDir(stableDirFor(homeDir, found.id)) : found.cwd;
|
|
3381
|
+
const placed = placeTranscript(found, dir, work);
|
|
3382
|
+
if (!placed.ok) return { code: "resume_failed", message: `The session could not be made ready to resume: ${placed.reason}`.slice(0, 900) };
|
|
3383
|
+
const note = !stable ? void 0 : placed.how !== "copied" ? `Resumed from the copy already in ${work}.` : args.cwd === "stable" ? `Copied to ${work} and resumed there.` : `Its folder is gone, so it was copied to ${work} and resumed there.`;
|
|
3384
|
+
return { work, configDir: configOf(dir), resumeId: found.id, ...note ? { note } : {} };
|
|
3385
|
+
} catch (e) {
|
|
3386
|
+
return { code: "resume_failed", message: `The session's folder could not be made ready: ${e.message}`.slice(0, 900) };
|
|
3387
|
+
}
|
|
3388
|
+
}
|
|
3389
|
+
async start(orchId, m, args) {
|
|
3390
|
+
const cur = this.sessions.get(orchId);
|
|
3391
|
+
const key = startKey(args);
|
|
3392
|
+
if (cur?.running && this.startKeys.get(orchId) === key && !args.brief) return;
|
|
3393
|
+
for (const [id, s2] of this.sessions) {
|
|
3394
|
+
s2.stop();
|
|
3395
|
+
this.sessions.delete(id);
|
|
3396
|
+
}
|
|
3397
|
+
this.startKeys.clear();
|
|
3398
|
+
const full = args.power === "full";
|
|
3399
|
+
if (args.runtime !== "claude") return this.notRunning(orchId, m, "unsupported", `This version of the HiveFloor service runs only Claude sessions.`);
|
|
3400
|
+
if (args.mode !== "spawned" && !full) return this.notRunning(orchId, m, "unsupported", "Resuming a session by its id needs full power on this machine.");
|
|
3401
|
+
if (full && !orchestratorFullOn(this.control())) return this.notRunning(orchId, m, "full_not_allowed", FULL_OFF_MESSAGE);
|
|
3402
|
+
if (this.logins.length === 0) await this.refreshInfo(m, true);
|
|
3403
|
+
const login = args.login_id === null || args.login_id === "default" ? this.logins.find((l) => l.id === "default") : this.logins.find((l) => l.id === args.login_id);
|
|
3404
|
+
if (!login && args.login_id !== null) return this.notRunning(orchId, m, "login_unknown", "That login is not signed in on this machine.");
|
|
3405
|
+
const asked = args.resume_id ?? args.external_id ?? null;
|
|
3406
|
+
let resumeId = asked ? safeId(asked, "") || null : null;
|
|
3407
|
+
if (args.mode === "attached" && resumeId === null) return this.notRunning(orchId, m, "session_not_found", "No session id was given to resume.");
|
|
3408
|
+
let work = path10.join(this.dir, "work");
|
|
3409
|
+
let configDir2 = login && login.id !== "default" ? login.dir : void 0;
|
|
3410
|
+
let note;
|
|
3411
|
+
if (full) {
|
|
3412
|
+
const plan = this.fullPlan(args, login, resumeId);
|
|
3413
|
+
if ("code" in plan) return this.notRunning(orchId, m, plan.code, plan.message);
|
|
3414
|
+
({ work, configDir: configDir2, resumeId } = plan);
|
|
3415
|
+
note = plan.note;
|
|
3416
|
+
}
|
|
3417
|
+
const cfg = {
|
|
3418
|
+
orchId,
|
|
3419
|
+
model: args.model,
|
|
3420
|
+
effort: args.effort,
|
|
3421
|
+
configDir: configDir2,
|
|
3422
|
+
resumeId,
|
|
3423
|
+
power: full ? "full" : "safe",
|
|
3424
|
+
permissionMode: full ? args.permission_mode ?? "default" : "default",
|
|
3425
|
+
chrome: full && args.chrome === true,
|
|
3426
|
+
keepAwake: args.keep_awake === true,
|
|
3427
|
+
brief: args.mode === "spawned" ? args.brief ?? null : null
|
|
3428
|
+
};
|
|
3429
|
+
let permit;
|
|
3430
|
+
try {
|
|
3431
|
+
permit = await this.ensurePermit();
|
|
3432
|
+
} catch (e) {
|
|
3433
|
+
return this.notRunning(orchId, m, "permit_failed", `The permission channel could not start: ${e.message}`.slice(0, 900));
|
|
3434
|
+
}
|
|
3435
|
+
let fleet;
|
|
3436
|
+
try {
|
|
3437
|
+
fleet = await this.ensureFleet();
|
|
3438
|
+
} catch (e) {
|
|
3439
|
+
return this.notRunning(orchId, m, "fleet_failed", `The fleet tools channel could not start: ${e.message}`.slice(0, 900));
|
|
3440
|
+
}
|
|
3441
|
+
const claudeBin = await (this.d.claudeBin ?? resolveClaudeBin)();
|
|
3442
|
+
const deps = {
|
|
3443
|
+
spawn: this.d.spawn ?? realSpawn,
|
|
3444
|
+
...claudeBin ? { claudeBin } : {},
|
|
3445
|
+
outbox: this.outbox(orchId, m),
|
|
3446
|
+
permit,
|
|
3447
|
+
fleet,
|
|
3448
|
+
fleetCall: this.fleetCall(orchId, m),
|
|
3449
|
+
fullAllowed: () => orchestratorFullOn(this.control()),
|
|
3450
|
+
paths: { work, run: path10.join(this.dir, "run") },
|
|
3451
|
+
cli: this.d.cli ?? { node: process.execPath, entry: cliEntry() },
|
|
3452
|
+
env: this.d.ctx.env,
|
|
3453
|
+
now: this.d.ctx.now,
|
|
3454
|
+
setTimer: this.d.setTimer ?? realTimer,
|
|
3455
|
+
log: (l) => this.log(l),
|
|
3456
|
+
...this.d.platform ? { platform: this.d.platform } : {}
|
|
3457
|
+
};
|
|
3458
|
+
const s = new OrchSession(cfg, deps);
|
|
3459
|
+
this.sessions.set(orchId, s);
|
|
3460
|
+
this.startKeys.set(orchId, key);
|
|
3461
|
+
if (note) this.report(orchId, m, [{ type: "status", body: { state: "starting", detail: note.slice(0, 300) } }]);
|
|
3462
|
+
s.start();
|
|
3463
|
+
if (s.ended) {
|
|
3464
|
+
this.sessions.delete(orchId);
|
|
3465
|
+
this.startKeys.delete(orchId);
|
|
3466
|
+
return this.log(`orchestrator ${orchId}: not started, full power is off on this machine`);
|
|
3467
|
+
}
|
|
3468
|
+
this.log(`orchestrator ${orchId}: started (${args.model}, ${args.effort}, ${cfg.power}${full ? `, ${cfg.permissionMode}` : ""}${resumeId ? ", resumed" : ""})`);
|
|
3469
|
+
}
|
|
3470
|
+
async handle(cmd, m) {
|
|
3471
|
+
if (!ORCH_ID_RE.test(cmd.orch_id)) return this.log(`orchestrator: ignored a ${cmd.kind} for a malformed session id`);
|
|
3472
|
+
if (this.seen.seenBefore(`orch:${cmd.id}`, this.d.ctx.now())) return;
|
|
3473
|
+
const s = this.sessions.get(cmd.orch_id);
|
|
3474
|
+
switch (cmd.kind) {
|
|
3475
|
+
case "orch_start":
|
|
3476
|
+
await this.start(cmd.orch_id, m, cmd.args);
|
|
3477
|
+
break;
|
|
3478
|
+
case "orch_send":
|
|
3479
|
+
if (!s?.running || !s.send(cmd.args.text)) this.notRunning(cmd.orch_id, m, "not_running", "The session is not running on this machine; start it again from Settings.");
|
|
3480
|
+
break;
|
|
3481
|
+
case "orch_answer":
|
|
3482
|
+
s?.answer(cmd.args.question_id, cmd.args.choice);
|
|
3483
|
+
break;
|
|
3484
|
+
case "orch_interrupt":
|
|
3485
|
+
s?.interrupt();
|
|
3486
|
+
break;
|
|
3487
|
+
case "orch_stop":
|
|
3488
|
+
if (s) {
|
|
3489
|
+
s.stop();
|
|
3490
|
+
this.sessions.delete(cmd.orch_id);
|
|
3491
|
+
this.startKeys.delete(cmd.orch_id);
|
|
3492
|
+
} else this.report(cmd.orch_id, m, [{ type: "status", body: { state: "stopped" } }]);
|
|
3493
|
+
break;
|
|
3494
|
+
}
|
|
3495
|
+
}
|
|
3496
|
+
stopAll() {
|
|
3497
|
+
for (const [id, s] of this.sessions) {
|
|
3498
|
+
s.stop();
|
|
3499
|
+
this.sessions.delete(id);
|
|
3500
|
+
}
|
|
3501
|
+
this.startKeys.clear();
|
|
3502
|
+
}
|
|
3503
|
+
/** Full power was switched off on this machine: a session that runs with it ends now. */
|
|
3504
|
+
stopFull(m) {
|
|
3505
|
+
for (const [id, s] of this.sessions) {
|
|
3506
|
+
if (s.cfg.power !== "full") continue;
|
|
3507
|
+
if (m && !s.ended) this.report(id, m, [{ type: "error", body: { code: "full_not_allowed", message: "Full power was turned off on this machine, so the session was stopped." } }]);
|
|
3508
|
+
s.stop();
|
|
3509
|
+
this.sessions.delete(id);
|
|
3510
|
+
this.startKeys.delete(id);
|
|
3511
|
+
}
|
|
3512
|
+
}
|
|
3513
|
+
/**
|
|
3514
|
+
* The local switches, read between rounds (every ORCH_SWITCH_CHECK_MS while the loop runs): a round can sit in a long-poll or
|
|
3515
|
+
* a backoff for a long time, and turning the orchestrator or full power off must not wait for it, or for the network.
|
|
3516
|
+
* It only stops sessions; the next round tells the server.
|
|
3517
|
+
*/
|
|
3518
|
+
enforceSwitch() {
|
|
3519
|
+
if (this.sessions.size === 0) return;
|
|
3520
|
+
const ctl = this.control();
|
|
3521
|
+
if (!orchestratorOn(ctl)) this.stopAll();
|
|
3522
|
+
else if (!orchestratorFullOn(ctl)) this.stopFull(this.machine());
|
|
3523
|
+
}
|
|
3524
|
+
/**
|
|
3525
|
+
* Has this machine's own switch (orchestrator or full power) changed since the last round reported it? The loop wakes from its
|
|
3526
|
+
* off-sleep on this, so `hivefloor orch enable` shows in the machine list within seconds, with no service restart.
|
|
3527
|
+
*/
|
|
3528
|
+
switchPending() {
|
|
3529
|
+
const ctl = this.control();
|
|
3530
|
+
return orchestratorOn(ctl) !== this.enabled || orchestratorFullOn(ctl) !== this.fullAllowed;
|
|
3531
|
+
}
|
|
3532
|
+
/**
|
|
3533
|
+
* Full power was switched while the orchestrator stays on (`hivefloor orch enable --full`, or `--no-full`): tell the server now, from
|
|
3534
|
+
* the file as it is now, instead of at the end of the long-poll round that is in flight. Turning the orchestrator itself on or off
|
|
3535
|
+
* is the round's job (it also reconciles); this only handles the full-power flag.
|
|
3536
|
+
*/
|
|
3537
|
+
async reportFullChange() {
|
|
3538
|
+
const m = this.machine();
|
|
3539
|
+
if (!m || m.revoked || !this.enabled) return;
|
|
3540
|
+
const ctl = this.control();
|
|
3541
|
+
if (!orchestratorOn(ctl)) return;
|
|
3542
|
+
const fullOn = orchestratorFullOn(ctl);
|
|
3543
|
+
if (fullOn === this.fullAllowed) return;
|
|
3544
|
+
this.fullAllowed = fullOn;
|
|
3545
|
+
if (!fullOn) this.stopFull(m);
|
|
3546
|
+
await this.refreshInfo(m, true);
|
|
3547
|
+
}
|
|
3548
|
+
refreshInfo(m, force = false) {
|
|
3549
|
+
const run = this.infoChain.then(() => this.sendInfo(m, force));
|
|
3550
|
+
this.infoChain = run.catch(() => void 0);
|
|
3551
|
+
return run;
|
|
3552
|
+
}
|
|
3553
|
+
async sendInfo(m, force) {
|
|
3554
|
+
const now = this.d.ctx.now();
|
|
3555
|
+
let runtimes = [];
|
|
3556
|
+
if (this.enabled) {
|
|
3557
|
+
const r = await orchRuntimes(this.d.run ?? runFile, m.installed.claude_dirs, this.d.claudeBin ?? resolveClaudeBin, this.d.loginInfo ?? realLoginInfo);
|
|
3558
|
+
runtimes = r.runtimes;
|
|
3559
|
+
this.logins = r.logins;
|
|
3560
|
+
}
|
|
3561
|
+
const body = { enabled: this.enabled, runtimes, ...this.enabled && this.fullAllowed ? { full_allowed: true } : {} };
|
|
3562
|
+
const key = JSON.stringify(body);
|
|
3563
|
+
if (!force && key === this.lastInfo && now - this.lastInfoAt < INFO_EVERY_MS) return;
|
|
3564
|
+
try {
|
|
3565
|
+
const r = await apiRequest(this.d.ctx, m.host, "/api/v1/machine/orch/info", { method: "POST", token: m.token, body, timeoutMs: 1e4 });
|
|
3566
|
+
if (r.status === 204 || r.status === 200) {
|
|
3567
|
+
this.lastInfo = key;
|
|
3568
|
+
this.lastInfoAt = now;
|
|
3569
|
+
}
|
|
3570
|
+
} catch {
|
|
3571
|
+
}
|
|
3572
|
+
}
|
|
3573
|
+
/** After a service restart (or switching on): run what the server says should be running. */
|
|
3574
|
+
async reconcile(m) {
|
|
3575
|
+
const r = await apiRequest(this.d.ctx, m.host, "/api/v1/machine/orch/session", { token: m.token, timeoutMs: 1e4 });
|
|
3576
|
+
if (r.status !== 200 || !isRecord(r.json)) throw new Error(`session: ${r.status}`);
|
|
3577
|
+
const s = r.json.session;
|
|
3578
|
+
if (!isRecord(s) || typeof s.id !== "string") {
|
|
3579
|
+
this.stopAll();
|
|
3580
|
+
} else if (!ORCH_ID_RE.test(s.id)) {
|
|
3581
|
+
this.stopAll();
|
|
3582
|
+
} else if (!this.sessions.get(s.id)?.running) {
|
|
3583
|
+
const args = { runtime: s.runtime, model: s.model, effort: s.effort, login_id: s.login_id ?? null, resume_id: s.resume_id ?? null, mode: s.mode, external_id: s.external_id ?? null };
|
|
3584
|
+
for (const k of ["power", "permission_mode", "chrome", "keep_awake", "cwd", "brief"]) if (s[k] !== void 0 && s[k] !== null) args[k] = s[k];
|
|
3585
|
+
const c = orchCommandSchema.safeParse({ id: "reconcile", orch_id: s.id, kind: "orch_start", args });
|
|
3586
|
+
if (!c.success || c.data.kind !== "orch_start") this.notRunning(s.id, m, "bad_session", "The session settings from the server were not valid; nothing was started.");
|
|
3587
|
+
else await this.start(s.id, m, c.data.args);
|
|
3588
|
+
}
|
|
3589
|
+
this.reconciled = true;
|
|
3590
|
+
}
|
|
3591
|
+
/** One round: switch, info, reconcile, one long-poll. 'off' and 'revoked' tell the caller to sleep. */
|
|
3592
|
+
async step() {
|
|
3593
|
+
const m = this.machine();
|
|
3594
|
+
if (!m || m.revoked) return "revoked";
|
|
3595
|
+
const ctl = this.control();
|
|
3596
|
+
const on = orchestratorOn(ctl);
|
|
3597
|
+
const fullOn = orchestratorFullOn(ctl);
|
|
3598
|
+
if (on !== this.enabled || fullOn !== this.fullAllowed) {
|
|
3599
|
+
if (on !== this.enabled) this.reconciled = false;
|
|
3600
|
+
this.enabled = on;
|
|
3601
|
+
this.fullAllowed = fullOn;
|
|
3602
|
+
if (!on) this.stopAll();
|
|
3603
|
+
else if (!fullOn) this.stopFull(m);
|
|
3604
|
+
await this.refreshInfo(m, true);
|
|
3605
|
+
} else {
|
|
3606
|
+
await this.refreshInfo(m);
|
|
3607
|
+
}
|
|
3608
|
+
if (!on) return "off";
|
|
3609
|
+
for (const o of this.outboxes.values()) if (o.size > 0) void o.flush();
|
|
3610
|
+
if (!this.reconciled) await this.reconcile(m);
|
|
3611
|
+
const r = await apiRequest(this.d.ctx, m.host, `/api/v1/machine/orch/commands?wait=${ORCH_POLL_WAIT_SEC}`, { token: m.token, timeoutMs: ORCH_POLL_TIMEOUT_MS });
|
|
3612
|
+
if (r.status === 401) return "revoked";
|
|
3613
|
+
if (r.status !== 200) return "error";
|
|
3614
|
+
const parsed = orchCommandsResponseSchema.safeParse(r.json);
|
|
3615
|
+
if (!parsed.success) return "error";
|
|
3616
|
+
const after = this.control();
|
|
3617
|
+
if (!orchestratorOn(after)) return "ok";
|
|
3618
|
+
if (!orchestratorFullOn(after)) this.stopFull(m);
|
|
3619
|
+
for (const cmd of parsed.data.commands) {
|
|
3620
|
+
try {
|
|
3621
|
+
await this.handle(cmd, m);
|
|
3622
|
+
} catch (e) {
|
|
3623
|
+
this.log(`orchestrator: ${cmd.kind} failed: ${e.message}`);
|
|
3624
|
+
}
|
|
3625
|
+
}
|
|
3626
|
+
return "ok";
|
|
3627
|
+
}
|
|
3628
|
+
async close() {
|
|
3629
|
+
this.stopAll();
|
|
3630
|
+
await this.permit?.close();
|
|
3631
|
+
this.permit = void 0;
|
|
3632
|
+
await this.fleet?.close();
|
|
3633
|
+
this.fleet = void 0;
|
|
3634
|
+
}
|
|
3635
|
+
};
|
|
3636
|
+
async function orchLoop(d, stopped, backoff) {
|
|
3637
|
+
if (process.platform === "win32") return;
|
|
3638
|
+
const o = new Orchestrator(d);
|
|
3639
|
+
let failures = 0;
|
|
3640
|
+
let wake;
|
|
3641
|
+
const watch = setInterval(() => {
|
|
3642
|
+
try {
|
|
3643
|
+
o.enforceSwitch();
|
|
3644
|
+
} catch {
|
|
3645
|
+
}
|
|
3646
|
+
try {
|
|
3647
|
+
if (!o.switchPending()) return;
|
|
3648
|
+
if (wake) wake();
|
|
3649
|
+
else void o.reportFullChange().catch(() => void 0);
|
|
3650
|
+
} catch {
|
|
3651
|
+
}
|
|
3652
|
+
}, d.switchCheckMs ?? ORCH_SWITCH_CHECK_MS);
|
|
3653
|
+
watch.unref();
|
|
3654
|
+
try {
|
|
3655
|
+
while (!stopped()) {
|
|
3656
|
+
let r;
|
|
3657
|
+
try {
|
|
3658
|
+
r = await o.step();
|
|
3659
|
+
} catch {
|
|
3660
|
+
r = "error";
|
|
3661
|
+
}
|
|
3662
|
+
if (r === "ok") failures = 0;
|
|
3663
|
+
else if (r === "error") {
|
|
3664
|
+
failures += 1;
|
|
3665
|
+
await d.ctx.sleep(backoff(failures));
|
|
3666
|
+
} else {
|
|
3667
|
+
await Promise.race([d.ctx.sleep(ORCH_OFF_SLEEP_MS), new Promise((resolve) => {
|
|
3668
|
+
if (r === "off") wake = resolve;
|
|
3669
|
+
})]);
|
|
3670
|
+
wake = void 0;
|
|
3671
|
+
}
|
|
3672
|
+
}
|
|
3673
|
+
} finally {
|
|
3674
|
+
clearInterval(watch);
|
|
3675
|
+
await o.close();
|
|
3676
|
+
}
|
|
3677
|
+
}
|
|
3678
|
+
|
|
1795
3679
|
// src/service/run.ts
|
|
1796
3680
|
var FLUSH_EVERY_MS = 2e3;
|
|
1797
3681
|
var FLUSH_BUDGET_MS = 5e3;
|
|
@@ -1810,22 +3694,22 @@ function log(st, now, state) {
|
|
|
1810
3694
|
if (st.logged === state) return;
|
|
1811
3695
|
st.logged = state;
|
|
1812
3696
|
try {
|
|
1813
|
-
|
|
3697
|
+
fs12.mkdirSync(configDir(), { recursive: true, mode: 448 });
|
|
1814
3698
|
const f = serviceLogFile();
|
|
1815
3699
|
let size = 0;
|
|
1816
3700
|
try {
|
|
1817
|
-
size =
|
|
3701
|
+
size = fs12.statSync(f).size;
|
|
1818
3702
|
} catch {
|
|
1819
3703
|
}
|
|
1820
|
-
if (size > LOG_MAX_BYTES)
|
|
1821
|
-
|
|
3704
|
+
if (size > LOG_MAX_BYTES) fs12.writeFileSync(f, "", { mode: 384 });
|
|
3705
|
+
fs12.appendFileSync(f, `${new Date(now).toISOString()} ${state}
|
|
1822
3706
|
`, { mode: 384 });
|
|
1823
3707
|
} catch {
|
|
1824
3708
|
}
|
|
1825
3709
|
}
|
|
1826
|
-
async function call(deps, m,
|
|
3710
|
+
async function call(deps, m, path11, body) {
|
|
1827
3711
|
try {
|
|
1828
|
-
const r = await apiRequest(deps.ctx, m.host, `/api/v1/machine${
|
|
3712
|
+
const r = await apiRequest(deps.ctx, m.host, `/api/v1/machine${path11}`, { method: "POST", token: m.token, body, timeoutMs: 1e4 });
|
|
1829
3713
|
if (r.status >= 200 && r.status < 300) return { outcome: "ok", json: r.json };
|
|
1830
3714
|
if (r.status === 401) return { outcome: "revoked" };
|
|
1831
3715
|
if (r.status === 429 || r.status >= 500) return { outcome: "retry" };
|
|
@@ -1869,9 +3753,21 @@ async function serviceStep(deps, st) {
|
|
|
1869
3753
|
if (now < st.quietUntil) return true;
|
|
1870
3754
|
if (now >= st.heartbeat) {
|
|
1871
3755
|
const stats = await collectStats(deps.collectors);
|
|
1872
|
-
|
|
3756
|
+
let ended = [];
|
|
3757
|
+
try {
|
|
3758
|
+
ended = await pickEndedSessions(readSessions(now), deps.proc ?? realProc(), now);
|
|
3759
|
+
} catch {
|
|
3760
|
+
}
|
|
3761
|
+
const r = await call(deps, m, "/heartbeat", {
|
|
3762
|
+
os: cliVersionOs(),
|
|
3763
|
+
cli_version: VERSION,
|
|
3764
|
+
...stats ? { stats } : {},
|
|
3765
|
+
control: activeVerbs(readControl()),
|
|
3766
|
+
...ended.length > 0 ? { ended_sessions: ended.map((e) => e.id) } : {}
|
|
3767
|
+
});
|
|
1873
3768
|
if (r.outcome === "revoked") return revoke();
|
|
1874
3769
|
if (r.outcome === "ok") {
|
|
3770
|
+
markSessionsEnded(ended, now);
|
|
1875
3771
|
const parsed = machineHeartbeatResponseSchema.safeParse(r.json);
|
|
1876
3772
|
const secs = parsed.success ? parsed.data.interval : MACHINE_HEARTBEAT_SECONDS;
|
|
1877
3773
|
st.interval = Math.max(MACHINE_MIN_INTERVAL_SECONDS, secs) * 1e3;
|
|
@@ -1961,6 +3857,13 @@ async function runService(deps) {
|
|
|
1961
3857
|
};
|
|
1962
3858
|
process.on("SIGTERM", stop);
|
|
1963
3859
|
process.on("SIGINT", stop);
|
|
3860
|
+
if (deps.control !== false) {
|
|
3861
|
+
void controlLoop({ ctx: deps.ctx, seen: new SeenIds(seenFile()), ...deps.control }, () => st.stop, (n) => backoffMs(n, deps.random ?? Math.random)).catch(() => void 0);
|
|
3862
|
+
}
|
|
3863
|
+
if (deps.orch !== false) {
|
|
3864
|
+
void orchLoop({ ctx: deps.ctx, log: (l) => deps.ctx.err(`${l}
|
|
3865
|
+
`), ...deps.orch }, () => st.stop, (n) => backoffMs(n, deps.random ?? Math.random)).catch(() => void 0);
|
|
3866
|
+
}
|
|
1964
3867
|
try {
|
|
1965
3868
|
while (!st.stop) {
|
|
1966
3869
|
const more = await serviceStep(deps, st);
|
|
@@ -1969,6 +3872,7 @@ async function runService(deps) {
|
|
|
1969
3872
|
}
|
|
1970
3873
|
return 0;
|
|
1971
3874
|
} finally {
|
|
3875
|
+
st.stop = true;
|
|
1972
3876
|
process.off("SIGTERM", stop);
|
|
1973
3877
|
process.off("SIGINT", stop);
|
|
1974
3878
|
}
|