@hivefloor/cli 0.1.5 → 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 +29 -8
- package/dist/chunk-2ERPDCOT.js +38 -0
- package/dist/chunk-4G362W6G.js +19 -0
- package/dist/chunk-5OJUJCE3.js +817 -0
- package/dist/{chunk-U6RRUAC2.js → chunk-5XXYQQG6.js} +53 -8
- package/dist/chunk-AGIYVFDQ.js +10984 -0
- package/dist/{chunk-RYGL6XU4.js → chunk-BLLPDPTL.js} +4 -2
- package/dist/{chunk-3N5JYP5O.js → chunk-FPJNGKT5.js} +5 -3
- package/dist/chunk-HW3KRNCO.js +201 -0
- package/dist/{chunk-4DRHR7LR.js → chunk-HWBUESB4.js} +1158 -196
- package/dist/{chunk-C627OKTW.js → chunk-K2IRTZH5.js} +7 -6
- package/dist/chunk-KQMPCV57.js +122 -0
- package/dist/{chunk-J36OIJBM.js → chunk-L6EFW2SJ.js} +14 -10
- package/dist/{chunk-IJRHCDIG.js → chunk-LX5A25O6.js} +4 -2
- package/dist/{chunk-MZGGJ6ZS.js → chunk-MYJCU5JL.js} +4 -2
- package/dist/{chunk-H6NYMF2D.js → chunk-NMUH5NXN.js} +6 -4
- package/dist/{chunk-VX7SCIF2.js → chunk-NRJONTFT.js} +3 -1
- package/dist/chunk-QBBKGORP.js +327 -0
- package/dist/{chunk-SOXRITAW.js → chunk-QGULSOQ2.js} +3 -0
- package/dist/{chunk-6MDJB3BV.js → chunk-QLMLB7RB.js} +17 -9
- package/dist/{chunk-TKMIV7BG.js → chunk-QPAFN47B.js} +3 -1
- package/dist/chunk-SBOEAOHD.js +48 -0
- package/dist/{chunk-IWIDKNWC.js → chunk-TIR4CV3P.js} +21 -17
- package/dist/{chunk-6XVIEX75.js → chunk-X2KH6AR4.js} +25 -303
- package/dist/chunk-XAXKZ572.js +106 -0
- package/dist/{chunk-TPMFQUAW.js → chunk-XPGL7VYE.js} +12 -0
- package/dist/{cli-JCBZICR3.js → cli-BFOJWJNG.js} +56 -20
- package/dist/connect-ZJ4JXBAJ.js +27 -0
- package/dist/control-6GHTZQJV.js +25 -0
- package/dist/{disconnect-WQMDCUXT.js → disconnect-3T25NTKX.js} +24 -17
- package/dist/{hook-AYJNRGIY.js → hook-TPSHBCWU.js} +277 -17
- package/dist/index.js +9 -6
- package/dist/{link-ORT4LID6.js → link-EAS5F5PC.js} +14 -11
- package/dist/{machine-connect-N52CKQEW.js → machine-connect-KEI4BRWF.js} +23 -17
- package/dist/mcp-LVIYR3EN.js +1882 -0
- package/dist/orch-KVRL2YA2.js +137 -0
- package/dist/{run-ZAZ45MKQ.js → run-JL2OZWK3.js} +1920 -28
- package/dist/{status-GOCFNO7K.js → status-QXOOJGGN.js} +14 -11
- package/dist/{token-EMM4AJAU.js → token-BFWWQIDA.js} +10 -7
- package/dist/{voice-F5MIS3IZ.js → voice-QXHFXRWG.js} +5 -3
- package/dist/{where-XFXRTP7E.js → where-CMUORQIO.js} +11 -8
- package/package.json +5 -6
- package/dist/chunk-JCLWCMEM.js +0 -8
- package/dist/chunk-K3NQKI34.js +0 -10
- package/dist/chunk-X6B6R32A.js +0 -303
- package/dist/connect-2HLVPEOL.js +0 -23
- package/dist/mcp-2SF6Y73M.js +0 -118
|
@@ -1,57 +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";
|
|
39
82
|
import {
|
|
40
83
|
runCmd
|
|
41
|
-
} from "./chunk-
|
|
84
|
+
} from "./chunk-QGULSOQ2.js";
|
|
42
85
|
import {
|
|
86
|
+
ENDED_SESSIONS_MAX,
|
|
87
|
+
ENDED_SESSION_ID_RE,
|
|
43
88
|
HOOK_BATCH_MAX,
|
|
44
89
|
MACHINE_HEARTBEAT_SECONDS,
|
|
45
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,
|
|
46
97
|
estimateClaudeCostUsd,
|
|
98
|
+
isResumeTool,
|
|
99
|
+
machineCommandsResponse,
|
|
47
100
|
machineHeartbeatResponseSchema,
|
|
101
|
+
orchCommandSchema,
|
|
102
|
+
orchCommandsResponseSchema,
|
|
103
|
+
orchEventIn,
|
|
48
104
|
parseUsageKey,
|
|
105
|
+
redact,
|
|
106
|
+
resumeArgv,
|
|
107
|
+
stripControlChars,
|
|
49
108
|
usageReadingSchema
|
|
50
|
-
} from "./chunk-
|
|
51
|
-
import "./chunk-
|
|
109
|
+
} from "./chunk-HWBUESB4.js";
|
|
110
|
+
import "./chunk-2ERPDCOT.js";
|
|
52
111
|
|
|
53
112
|
// src/service/run.ts
|
|
54
|
-
import
|
|
113
|
+
import fs12 from "fs";
|
|
55
114
|
|
|
56
115
|
// src/service/collectors.ts
|
|
57
116
|
import fs from "fs";
|
|
@@ -509,9 +568,9 @@ function parseDf(text, only) {
|
|
|
509
568
|
for (const line of text.split("\n").slice(1)) {
|
|
510
569
|
const m = /^(\S+)\s+(\d+)\s+(\d+)\s+(\d+)\s+\S+\s+(\/.*)$/.exec(line.trim());
|
|
511
570
|
if (!m) continue;
|
|
512
|
-
const [,
|
|
513
|
-
if (seen.has(
|
|
514
|
-
seen.add(
|
|
571
|
+
const [, fs13, total, used, , mount] = m;
|
|
572
|
+
if (seen.has(fs13) || only && !only(fs13, mount)) continue;
|
|
573
|
+
seen.add(fs13);
|
|
515
574
|
out.push({ mount: mount.slice(0, 80), used_gb: round1(Number(used) / MB), total_gb: round1(Number(total) / MB) });
|
|
516
575
|
}
|
|
517
576
|
return out.slice(0, 8);
|
|
@@ -548,7 +607,7 @@ async function collectMachineStats(deps) {
|
|
|
548
607
|
disks = df ? parseDf(df).map((d) => ({ ...d, mount: "/" })) : [];
|
|
549
608
|
} else {
|
|
550
609
|
const df = deps.exec("df", ["-kPl"]);
|
|
551
|
-
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)) : [];
|
|
552
611
|
}
|
|
553
612
|
const stats = { cpu_pct, ram_used_mb, ram_total_mb, disks };
|
|
554
613
|
if (gpu_pct !== void 0) stats.gpu_pct = gpu_pct;
|
|
@@ -910,20 +969,39 @@ var readClaude = async (deps) => {
|
|
|
910
969
|
await readEnvTokens(deps, seenTokens, out);
|
|
911
970
|
return out;
|
|
912
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
|
+
}
|
|
913
991
|
var CLAUDE_HEADERS = (token) => ({
|
|
914
992
|
Authorization: `Bearer ${token}`,
|
|
915
993
|
"anthropic-beta": "oauth-2025-04-20",
|
|
916
994
|
"anthropic-version": "2023-06-01",
|
|
917
995
|
Accept: "application/json"
|
|
918
996
|
});
|
|
919
|
-
async function readTokenUsage(deps, token) {
|
|
997
|
+
async function readTokenUsage(deps, token, probe = true) {
|
|
920
998
|
const headers = CLAUDE_HEADERS(token);
|
|
921
999
|
let parsed;
|
|
922
1000
|
try {
|
|
923
1001
|
parsed = parseClaudeUsage(await requestJson(deps, USAGE_URL, { headers }));
|
|
924
1002
|
if (parsed.windows.length === 0) throw new ReaderError("bad_response", issueMessage("bad_response"));
|
|
925
1003
|
} catch (e) {
|
|
926
|
-
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;
|
|
927
1005
|
return probeRateLimits(deps, token);
|
|
928
1006
|
}
|
|
929
1007
|
try {
|
|
@@ -1734,9 +1812,9 @@ function exec(cmd, args) {
|
|
|
1734
1812
|
return null;
|
|
1735
1813
|
}
|
|
1736
1814
|
}
|
|
1737
|
-
function readFile(
|
|
1815
|
+
function readFile(path11) {
|
|
1738
1816
|
try {
|
|
1739
|
-
return readFileSync(
|
|
1817
|
+
return readFileSync(path11, "utf8");
|
|
1740
1818
|
} catch {
|
|
1741
1819
|
return null;
|
|
1742
1820
|
}
|
|
@@ -1748,9 +1826,9 @@ function nodeDeps() {
|
|
|
1748
1826
|
return { status: res.status, text: () => res.text(), headers: Object.fromEntries([...res.headers].map(([k, v]) => [k.toLowerCase(), v])) };
|
|
1749
1827
|
},
|
|
1750
1828
|
readFile,
|
|
1751
|
-
readDir(
|
|
1829
|
+
readDir(path11) {
|
|
1752
1830
|
try {
|
|
1753
|
-
return readdirSync(
|
|
1831
|
+
return readdirSync(path11);
|
|
1754
1832
|
} catch {
|
|
1755
1833
|
return [];
|
|
1756
1834
|
}
|
|
@@ -1804,6 +1882,1800 @@ async function collectUsagePush(ctx) {
|
|
|
1804
1882
|
return buildUsagePush({ ...nodeDeps(), claudeTokens: tokens }, nodeStatsDeps());
|
|
1805
1883
|
}
|
|
1806
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
|
+
|
|
1807
3679
|
// src/service/run.ts
|
|
1808
3680
|
var FLUSH_EVERY_MS = 2e3;
|
|
1809
3681
|
var FLUSH_BUDGET_MS = 5e3;
|
|
@@ -1822,22 +3694,22 @@ function log(st, now, state) {
|
|
|
1822
3694
|
if (st.logged === state) return;
|
|
1823
3695
|
st.logged = state;
|
|
1824
3696
|
try {
|
|
1825
|
-
|
|
3697
|
+
fs12.mkdirSync(configDir(), { recursive: true, mode: 448 });
|
|
1826
3698
|
const f = serviceLogFile();
|
|
1827
3699
|
let size = 0;
|
|
1828
3700
|
try {
|
|
1829
|
-
size =
|
|
3701
|
+
size = fs12.statSync(f).size;
|
|
1830
3702
|
} catch {
|
|
1831
3703
|
}
|
|
1832
|
-
if (size > LOG_MAX_BYTES)
|
|
1833
|
-
|
|
3704
|
+
if (size > LOG_MAX_BYTES) fs12.writeFileSync(f, "", { mode: 384 });
|
|
3705
|
+
fs12.appendFileSync(f, `${new Date(now).toISOString()} ${state}
|
|
1834
3706
|
`, { mode: 384 });
|
|
1835
3707
|
} catch {
|
|
1836
3708
|
}
|
|
1837
3709
|
}
|
|
1838
|
-
async function call(deps, m,
|
|
3710
|
+
async function call(deps, m, path11, body) {
|
|
1839
3711
|
try {
|
|
1840
|
-
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 });
|
|
1841
3713
|
if (r.status >= 200 && r.status < 300) return { outcome: "ok", json: r.json };
|
|
1842
3714
|
if (r.status === 401) return { outcome: "revoked" };
|
|
1843
3715
|
if (r.status === 429 || r.status >= 500) return { outcome: "retry" };
|
|
@@ -1881,9 +3753,21 @@ async function serviceStep(deps, st) {
|
|
|
1881
3753
|
if (now < st.quietUntil) return true;
|
|
1882
3754
|
if (now >= st.heartbeat) {
|
|
1883
3755
|
const stats = await collectStats(deps.collectors);
|
|
1884
|
-
|
|
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
|
+
});
|
|
1885
3768
|
if (r.outcome === "revoked") return revoke();
|
|
1886
3769
|
if (r.outcome === "ok") {
|
|
3770
|
+
markSessionsEnded(ended, now);
|
|
1887
3771
|
const parsed = machineHeartbeatResponseSchema.safeParse(r.json);
|
|
1888
3772
|
const secs = parsed.success ? parsed.data.interval : MACHINE_HEARTBEAT_SECONDS;
|
|
1889
3773
|
st.interval = Math.max(MACHINE_MIN_INTERVAL_SECONDS, secs) * 1e3;
|
|
@@ -1973,6 +3857,13 @@ async function runService(deps) {
|
|
|
1973
3857
|
};
|
|
1974
3858
|
process.on("SIGTERM", stop);
|
|
1975
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
|
+
}
|
|
1976
3867
|
try {
|
|
1977
3868
|
while (!st.stop) {
|
|
1978
3869
|
const more = await serviceStep(deps, st);
|
|
@@ -1981,6 +3872,7 @@ async function runService(deps) {
|
|
|
1981
3872
|
}
|
|
1982
3873
|
return 0;
|
|
1983
3874
|
} finally {
|
|
3875
|
+
st.stop = true;
|
|
1984
3876
|
process.off("SIGTERM", stop);
|
|
1985
3877
|
process.off("SIGINT", stop);
|
|
1986
3878
|
}
|