@staix/agent-hub 0.5.0 → 0.6.1

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.
@@ -0,0 +1,419 @@
1
+ import { spawn, spawnSync, type ChildProcess } from "node:child_process";
2
+ import { randomUUID } from "node:crypto";
3
+ import { closeSync, existsSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
4
+ import { homedir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { createServer, isIP } from "node:net";
7
+ import { Database } from "bun:sqlite";
8
+
9
+ export type MlxState = "disabled" | "starting" | "ready" | "stopped" | "foreign" | "error";
10
+
11
+ export interface MlxOptions {
12
+ runtimeDir?: string;
13
+ modelPath?: string;
14
+ bin?: string;
15
+ host?: string;
16
+ port?: number;
17
+ maxInputTokens?: number;
18
+ maxConcurrency?: number;
19
+ maxTokens?: number;
20
+ /** Test/runtime injection; the default runs the pinned executable directly. */
21
+ spawn?: typeof spawn;
22
+ health?: (url: string, signal: AbortSignal) => Promise<boolean>;
23
+ processInfo?: (pid: number) => ProcessSignature | undefined;
24
+ }
25
+
26
+ export interface MlxStatus {
27
+ state: MlxState;
28
+ url?: string;
29
+ model?: string;
30
+ pid?: number;
31
+ maxInputTokens: number;
32
+ maxConcurrency: number;
33
+ active: number;
34
+ lastError?: string;
35
+ }
36
+
37
+ export interface MlxHandle {
38
+ readonly status: () => MlxStatus;
39
+ readonly url: string;
40
+ readonly model: string;
41
+ readonly acquire: (signal?: AbortSignal) => Promise<() => void>;
42
+ readonly close: () => Promise<void>;
43
+ }
44
+
45
+ interface OwnerRecord {
46
+ schema: 1;
47
+ token: string;
48
+ pid: number;
49
+ bin: string;
50
+ modelPath: string;
51
+ host: string;
52
+ port: number;
53
+ startedAt: number;
54
+ processStart: string;
55
+ }
56
+
57
+ interface ProcessSignature {
58
+ command: string;
59
+ start: string;
60
+ }
61
+
62
+ const DEFAULT_RUNTIME_DIR = join(homedir(), ".agenthub", "runtimes", "mlx");
63
+ const DEFAULT_MODEL_PATH = join(homedir(), ".agenthub", "models", "qwen3-8b-mlx");
64
+ const OWNER_FILE = "owner.json";
65
+ const GENERATION_DB = "generation-slots.db";
66
+
67
+ const ownerPath = (runtimeDir: string) => join(runtimeDir, OWNER_FILE);
68
+ const generationDbPath = (runtimeDir: string) => join(runtimeDir, GENERATION_DB);
69
+
70
+ function assertLoopback(host: string): void {
71
+ const value = host.toLowerCase();
72
+ if (value !== "localhost" && value !== "127.0.0.1" && value !== "::1" && !(isIP(value) === 4 && value.startsWith("127."))) {
73
+ throw new Error("MLX host must be loopback");
74
+ }
75
+ }
76
+
77
+ function readOwner(runtimeDir: string): OwnerRecord | undefined {
78
+ if (!existsSync(ownerPath(runtimeDir))) return undefined;
79
+ try {
80
+ const value = JSON.parse(readFileSync(ownerPath(runtimeDir), "utf8")) as Partial<OwnerRecord>;
81
+ if (value.schema !== 1 || typeof value.token !== "string" || typeof value.pid !== "number" || !Number.isSafeInteger(value.pid) || value.pid < 1 ||
82
+ typeof value.bin !== "string" || typeof value.modelPath !== "string" || typeof value.host !== "string" ||
83
+ !Number.isInteger(value.port) || typeof value.startedAt !== "number" || typeof value.processStart !== "string") throw new Error("MLX owner record is malformed; inspect it before retrying");
84
+ return value as OwnerRecord;
85
+ } catch (error) { throw new Error(`MLX owner record is unreadable: ${(error as Error).message}`); }
86
+ }
87
+
88
+ function processInfo(pid: number): ProcessSignature | undefined {
89
+ try {
90
+ const result = spawnSync("ps", ["-p", String(pid), "-o", "lstart=", "-o", "command="], { encoding: "utf8" });
91
+ if (result.status !== 0) return undefined;
92
+ const line = String(result.stdout).trim();
93
+ const match = /^(\w{3}\s+\w{3}\s+\d+\s+\d+:\d+:\d+\s+\d{4})\s+(.*)$/.exec(line);
94
+ return match ? { start: match[1]!, command: match[2]! } : undefined;
95
+ } catch {
96
+ return undefined;
97
+ }
98
+ }
99
+
100
+ function ownedProcess(owner: OwnerRecord, readInfo = processInfo): boolean {
101
+ const current = readInfo(owner.pid);
102
+ return !!current && current.start === owner.processStart && current.command.includes(owner.bin) && current.command.includes(owner.modelPath);
103
+ }
104
+
105
+ function processAlive(pid: number): boolean | undefined {
106
+ try { process.kill(pid, 0); return true; }
107
+ catch (error) {
108
+ return (error as NodeJS.ErrnoException).code === "ESRCH" ? false : undefined;
109
+ }
110
+ }
111
+
112
+ function acquireStartLock(runtimeDir: string, readInfo: (pid: number) => ProcessSignature | undefined): () => void {
113
+ // A pre-SQLite lock cannot be reclaimed safely by this version. Leave it for
114
+ // explicit inspection instead of allowing two starters to race on the GPU.
115
+ if (existsSync(join(runtimeDir, "start.lock"))) throw new Error("legacy MLX startup lock exists; inspect it before retrying");
116
+ const db = generationDatabase(runtimeDir);
117
+ const self = readInfo(process.pid);
118
+ if (!self) { db.close(); throw new Error("MLX startup owner identity is unavailable"); }
119
+ const token = randomUUID();
120
+ try {
121
+ db.run(`CREATE TABLE IF NOT EXISTS startup_lock (
122
+ slot INTEGER PRIMARY KEY CHECK (slot = 1),
123
+ token TEXT NOT NULL,
124
+ pid INTEGER NOT NULL,
125
+ process_start TEXT NOT NULL
126
+ )`);
127
+ db.transaction(() => {
128
+ const row = db.query("SELECT slot, token, pid, process_start FROM startup_lock WHERE slot = 1").get() as Record<string, unknown> | null;
129
+ if (row) {
130
+ if (row.slot !== 1 || typeof row.token !== "string" || !row.token || !Number.isSafeInteger(row.pid) || (row.pid as number) < 1 ||
131
+ typeof row.process_start !== "string" || !row.process_start) throw new Error("MLX startup lock is malformed; inspect it before retrying");
132
+ const current = readInfo(row.pid as number);
133
+ if (current && current.start === row.process_start) throw new Error("another MLX startup owns the runtime lock");
134
+ if (!current && processAlive(row.pid as number) !== false) throw new Error("MLX startup lock owner cannot be authenticated");
135
+ db.query("DELETE FROM startup_lock WHERE slot = 1 AND token = ? AND pid = ? AND process_start = ?")
136
+ .run(row.token as string, row.pid as number, row.process_start as string);
137
+ }
138
+ db.query("INSERT INTO startup_lock (slot, token, pid, process_start) VALUES (1, ?, ?, ?)").run(token, process.pid, self.start);
139
+ }).immediate();
140
+ } catch (error) {
141
+ db.close();
142
+ throw error;
143
+ }
144
+ let released = false;
145
+ return () => {
146
+ if (released) return;
147
+ released = true;
148
+ try { db.query("DELETE FROM startup_lock WHERE slot = 1 AND token = ? AND pid = ? AND process_start = ?").run(token, process.pid, self.start); }
149
+ finally { db.close(); }
150
+ };
151
+ }
152
+
153
+ function writeOwner(runtimeDir: string, owner: OwnerRecord): void {
154
+ mkdirSync(runtimeDir, { recursive: true, mode: 0o700 });
155
+ const temporary = `${ownerPath(runtimeDir)}.${randomUUID()}.tmp`;
156
+ writeFileSync(temporary, `${JSON.stringify(owner)}\n`, { mode: 0o600, flag: "wx" });
157
+ renameSync(temporary, ownerPath(runtimeDir));
158
+ }
159
+
160
+ interface GenerationOwner {
161
+ slot: number;
162
+ token: string;
163
+ pid: number;
164
+ processStart: string;
165
+ }
166
+
167
+ class GenerationSlotBusy extends Error {}
168
+
169
+ function generationDatabase(runtimeDir: string): Database {
170
+ mkdirSync(runtimeDir, { recursive: true, mode: 0o700 });
171
+ const db = new Database(generationDbPath(runtimeDir), { create: true });
172
+ try {
173
+ db.run("PRAGMA busy_timeout = 100");
174
+ db.run(`CREATE TABLE IF NOT EXISTS generation_slots (
175
+ slot INTEGER PRIMARY KEY,
176
+ token TEXT NOT NULL,
177
+ pid INTEGER NOT NULL,
178
+ process_start TEXT NOT NULL
179
+ )`);
180
+ return db;
181
+ } catch (error) { db.close(); throw new Error(`MLX generation lock database is unreadable: ${(error as Error).message}`); }
182
+ }
183
+
184
+ function parseGenerationOwner(slot: number, row: Record<string, unknown>): GenerationOwner {
185
+ if (row.slot !== slot || typeof row.token !== "string" || !row.token || !Number.isSafeInteger(row.pid) || (row.pid as number) < 1 ||
186
+ typeof row.process_start !== "string" || !row.process_start) {
187
+ throw new Error("MLX generation owner is malformed; inspect it before retrying");
188
+ }
189
+ return { slot, token: row.token, pid: row.pid as number, processStart: row.process_start };
190
+ }
191
+
192
+ function claimGenerationSlot(db: Database, slot: number, readInfo: (pid: number) => ProcessSignature | undefined): (() => void) | undefined {
193
+ const token = randomUUID();
194
+ const self = readInfo(process.pid);
195
+ if (!self) throw new Error("MLX generation owner identity is unavailable");
196
+ try {
197
+ db.transaction(() => {
198
+ const row = db.query("SELECT slot, token, pid, process_start FROM generation_slots WHERE slot = ?").get(slot) as Record<string, unknown> | null;
199
+ if (row) {
200
+ const owner = parseGenerationOwner(slot, row);
201
+ const current = readInfo(owner.pid);
202
+ if (current && current.start === owner.processStart) throw new GenerationSlotBusy("generation slot is active");
203
+ if (!current && processAlive(owner.pid) !== false) throw new Error("MLX generation owner cannot be authenticated");
204
+ // A different start signature proves PID reuse; a missing process with
205
+ // ESRCH proves the old owner exited. The transaction makes reclamation
206
+ // and replacement one atomic state transition.
207
+ db.query("DELETE FROM generation_slots WHERE slot = ? AND token = ? AND pid = ? AND process_start = ?")
208
+ .run(owner.slot, owner.token, owner.pid, owner.processStart);
209
+ }
210
+ db.query("INSERT INTO generation_slots (slot, token, pid, process_start) VALUES (?, ?, ?, ?)")
211
+ .run(slot, token, process.pid, self.start);
212
+ }).immediate();
213
+ } catch (error) {
214
+ if (error instanceof GenerationSlotBusy) return undefined;
215
+ const message = String((error as Error).message).toLowerCase();
216
+ if (message.includes("database is locked") || message.includes("database is busy")) return undefined;
217
+ throw error;
218
+ }
219
+ let released = false;
220
+ return () => {
221
+ if (released) return;
222
+ released = true;
223
+ try {
224
+ db.query("DELETE FROM generation_slots WHERE slot = ? AND token = ? AND pid = ? AND process_start = ?")
225
+ .run(slot, token, process.pid, self.start);
226
+ } finally { db.close(); }
227
+ };
228
+ }
229
+
230
+ async function acquireGeneration(runtimeDir: string, maxConcurrency: number, signal?: AbortSignal, readInfo: (pid: number) => ProcessSignature | undefined = processInfo): Promise<() => void> {
231
+ if (!Number.isInteger(maxConcurrency) || maxConcurrency < 1) throw new Error("MLX maxConcurrency must be a positive integer");
232
+ if (signal?.aborted) throw new Error("MLX generation acquisition cancelled");
233
+ const deadline = Date.now() + 120_000;
234
+ const db = generationDatabase(runtimeDir);
235
+ try {
236
+ for (;;) {
237
+ for (let slot = 0; slot < maxConcurrency; slot++) {
238
+ const release = claimGenerationSlot(db, slot, readInfo);
239
+ if (release) return release;
240
+ }
241
+ if (signal?.aborted) throw new Error("MLX generation acquisition cancelled");
242
+ if (Date.now() >= deadline) throw new Error("MLX generation is busy");
243
+ await new Promise<void>((resolve, reject) => {
244
+ let settled = false;
245
+ const finish = (error?: Error) => {
246
+ if (settled) return;
247
+ settled = true;
248
+ clearTimeout(timer);
249
+ signal?.removeEventListener("abort", abort);
250
+ error ? reject(error) : resolve();
251
+ };
252
+ const timer = setTimeout(() => finish(), 50);
253
+ const abort = () => finish(new Error("MLX generation acquisition cancelled"));
254
+ signal?.addEventListener("abort", abort, { once: true });
255
+ });
256
+ }
257
+ } catch (error) {
258
+ db.close();
259
+ throw error;
260
+ }
261
+ }
262
+
263
+ async function waitForExit(owner: OwnerRecord, readInfo: (pid: number) => ProcessSignature | undefined): Promise<void> {
264
+ for (let i = 0; i < 60; i++) {
265
+ if (!readInfo(owner.pid) && processAlive(owner.pid) === false) return;
266
+ await Bun.sleep(50);
267
+ }
268
+ throw new Error("MLX process did not terminate after SIGTERM");
269
+ }
270
+
271
+ async function freePort(host: string, requested?: number): Promise<number> {
272
+ const server = createServer();
273
+ await new Promise<void>((resolve, reject) => {
274
+ server.once("error", reject);
275
+ server.listen(requested ?? 0, host, () => resolve());
276
+ });
277
+ const address = server.address();
278
+ const port = typeof address === "object" && address ? address.port : 0;
279
+ await new Promise<void>((resolve) => server.close(() => resolve()));
280
+ if (!port) throw new Error("MLX did not provide a usable loopback port");
281
+ return port;
282
+ }
283
+
284
+ async function defaultHealth(url: string, signal: AbortSignal): Promise<boolean> {
285
+ try {
286
+ const response = await fetch(`${url}/models`, { signal });
287
+ return response.ok;
288
+ } catch {
289
+ return false;
290
+ }
291
+ }
292
+
293
+ function argsFor(options: Required<Pick<MlxOptions, "modelPath" | "host" | "port">> & { maxConcurrency: number; maxTokens?: number }): string[] {
294
+ return [
295
+ "--model", options.modelPath,
296
+ "--host", options.host,
297
+ "--port", String(options.port),
298
+ "--allowed-origins", `http://${options.host}:${options.port}`,
299
+ "--decode-concurrency", String(options.maxConcurrency),
300
+ "--prompt-concurrency", String(options.maxConcurrency),
301
+ "--chat-template-args", JSON.stringify({ enable_thinking: false }),
302
+ ...(options.maxTokens ? ["--max-tokens", String(options.maxTokens)] : []),
303
+ ];
304
+ }
305
+
306
+ export async function inspectMlx(options: MlxOptions = {}): Promise<MlxStatus> {
307
+ const runtimeDir = options.runtimeDir ?? DEFAULT_RUNTIME_DIR;
308
+ const modelPath = options.modelPath ?? DEFAULT_MODEL_PATH;
309
+ const host = options.host ?? "127.0.0.1";
310
+ const maxInputTokens = options.maxInputTokens ?? 16_000;
311
+ const maxConcurrency = options.maxConcurrency ?? 1;
312
+ assertLoopback(host);
313
+ const owner = readOwner(runtimeDir);
314
+ if (!owner) return { state: "stopped", model: modelPath, maxInputTokens, maxConcurrency, active: 0 };
315
+ const current = (options.processInfo ?? processInfo)(owner.pid);
316
+ if (!ownedProcess(owner, options.processInfo ?? processInfo)) {
317
+ if (current || processAlive(owner.pid) !== false) return { state: "foreign", url: `http://${host}:${owner.port}/v1`, model: owner.modelPath, pid: owner.pid, maxInputTokens, maxConcurrency, active: 0, lastError: "runtime record does not match the live process" };
318
+ return { state: "stopped", model: owner.modelPath, maxInputTokens, maxConcurrency, active: 0 };
319
+ }
320
+ const url = `http://${owner.host}:${owner.port}/v1`;
321
+ const healthy = await (options.health ?? defaultHealth)(url, AbortSignal.timeout(1_000));
322
+ return { state: healthy ? "ready" : "starting", url, model: owner.modelPath, pid: owner.pid, maxInputTokens, maxConcurrency, active: 0 };
323
+ }
324
+
325
+ export async function ensureMlx(options: MlxOptions = {}): Promise<MlxHandle> {
326
+ const runtimeDir = options.runtimeDir ?? DEFAULT_RUNTIME_DIR;
327
+ const modelPath = options.modelPath ?? DEFAULT_MODEL_PATH;
328
+ const bin = options.bin ?? join(runtimeDir, "bin", "mlx_lm.server");
329
+ const host = options.host ?? "127.0.0.1";
330
+ const maxInputTokens = options.maxInputTokens ?? 16_000;
331
+ const maxConcurrency = options.maxConcurrency ?? 1;
332
+ const readInfo = options.processInfo ?? processInfo;
333
+ assertLoopback(host);
334
+ const health = options.health ?? defaultHealth;
335
+ const existing = await inspectMlx(options);
336
+ const existingOwner = readOwner(runtimeDir);
337
+ if (existingOwner && (existingOwner.modelPath !== modelPath || existingOwner.host !== host || (options.port !== undefined && existingOwner.port !== options.port) || existingOwner.bin !== bin)) {
338
+ throw new Error("MLX runtime is already owned with different host, port, binary or model settings");
339
+ }
340
+ const sharedHandle = (status: MlxStatus): MlxHandle => {
341
+ let active = 0;
342
+ return { url: status.url!, model: modelPath, status: () => ({ ...status, active }), acquire: async (signal) => {
343
+ const releaseSlot = await acquireGeneration(runtimeDir, maxConcurrency, signal, readInfo);
344
+ active++;
345
+ return () => { active = Math.max(0, active - 1); releaseSlot(); };
346
+ }, close: async () => {} };
347
+ };
348
+ if (existing.state === "ready" && existing.url && existing.model === modelPath) return sharedHandle(existing);
349
+ if (existing.state === "starting" && existing.url && existing.model === modelPath) {
350
+ for (let i = 0; i < 100; i++) {
351
+ if (await health(existing.url, AbortSignal.timeout(500))) return sharedHandle({ ...existing, state: "ready" });
352
+ await Bun.sleep(100);
353
+ }
354
+ throw new Error("existing MLX runtime did not become healthy within 10 seconds");
355
+ }
356
+ if (existing.state === "foreign") throw new Error("MLX endpoint is owned by an unknown process; refusing to kill or reuse it");
357
+ mkdirSync(runtimeDir, { recursive: true, mode: 0o700 });
358
+ const releaseStartLock = acquireStartLock(runtimeDir, readInfo);
359
+ let child: ChildProcess | undefined;
360
+ let startupError: Error | undefined;
361
+ try {
362
+ const port = await freePort(host, options.port);
363
+ const token = randomUUID();
364
+ const ownerBase: Omit<OwnerRecord, "pid" | "processStart"> = { schema: 1, token, bin, modelPath, host, port, startedAt: Date.now() };
365
+ const spawnProcess = options.spawn ?? spawn;
366
+ const logFd = openSync(join(runtimeDir, "mlx.log"), "a");
367
+ try {
368
+ child = spawnProcess(bin, argsFor({ modelPath, host, port, maxConcurrency, maxTokens: options.maxTokens }), {
369
+ cwd: runtimeDir,
370
+ env: { PATH: process.env.PATH ?? "", HOME: homedir() },
371
+ detached: true,
372
+ stdio: ["ignore", logFd, logFd],
373
+ });
374
+ child.once?.("error", (error) => { startupError = error; });
375
+ } finally { closeSync(logFd); }
376
+ if (!child.pid) throw new Error("MLX process did not provide a PID");
377
+ const signature = readInfo(child.pid);
378
+ if (!signature) throw new Error("could not verify MLX process ownership");
379
+ const owner: OwnerRecord = { ...ownerBase, pid: child.pid, processStart: signature.start };
380
+ writeOwner(runtimeDir, owner);
381
+ const url = `http://${host}:${port}/v1`;
382
+ for (let i = 0; i < 100; i++) {
383
+ if (startupError) throw startupError;
384
+ if (child.exitCode != null || child.signalCode != null) throw new Error("MLX exited before becoming healthy; inspect mlx.log");
385
+ if (await health(url, AbortSignal.timeout(500))) {
386
+ child.unref?.();
387
+ let active = 0;
388
+ // The server is machine-shared. Relay shutdown releases this process's handle;
389
+ // only the explicit stopMlx operation may stop the owned runtime.
390
+ const close = async () => {};
391
+ return { url, model: modelPath, status: () => ({ state: "ready", url, model: modelPath, pid: child?.pid, maxInputTokens, maxConcurrency, active }), acquire: async (signal) => {
392
+ const releaseSlot = await acquireGeneration(runtimeDir, maxConcurrency, signal, readInfo);
393
+ active++;
394
+ return () => { active = Math.max(0, active - 1); releaseSlot(); };
395
+ }, close };
396
+ }
397
+ await Bun.sleep(100);
398
+ }
399
+ throw new Error("MLX did not become healthy within 10 seconds");
400
+ } catch (error) {
401
+ try { child?.kill("SIGTERM"); } catch { /* best effort for our child only */ }
402
+ rmSync(ownerPath(runtimeDir), { force: true });
403
+ throw error;
404
+ } finally { releaseStartLock(); }
405
+ }
406
+
407
+ export async function stopMlx(options: MlxOptions = {}): Promise<void> {
408
+ const runtimeDir = options.runtimeDir ?? DEFAULT_RUNTIME_DIR;
409
+ const owner = readOwner(runtimeDir);
410
+ if (!owner) return;
411
+ if (!ownedProcess(owner, options.processInfo ?? processInfo)) {
412
+ if ((options.processInfo ?? processInfo)(owner.pid) || processAlive(owner.pid) !== false) throw new Error("MLX owner record does not match the live process; refusing to kill it");
413
+ rmSync(ownerPath(runtimeDir), { force: true });
414
+ return;
415
+ }
416
+ process.kill(owner.pid, "SIGTERM");
417
+ await waitForExit(owner, options.processInfo ?? processInfo);
418
+ rmSync(ownerPath(runtimeDir), { force: true });
419
+ }