@staix/agent-hub 0.5.0 → 0.6.0

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.
@@ -15,6 +15,8 @@ export interface ClassPolicy {
15
15
  escalate_to?: PeerId[];
16
16
  /** false: `local` is never owner or reviewer for this class */
17
17
  local_allowed?: boolean;
18
+ /** Pi backend for Pi-owned work. */
19
+ pi_backend?: "dgx" | "mlx";
18
20
  }
19
21
 
20
22
  export interface Routing {
@@ -24,10 +26,12 @@ export interface Routing {
24
26
  classes: Partial<Record<TaskClass, ClassPolicy>>;
25
27
  signals: { pii_patterns: string[]; long_context_tokens: number };
26
28
  constraints: { pii: "local_only" | "off"; long_context: "skip_local" | "off"; budget_paused: "skip_peer" | "off" };
29
+ pi: { dgx_max_context_tokens: number; mlx_max_context_tokens: number };
27
30
  }
28
31
 
29
32
  const TEMPLATE = join(import.meta.dir, "..", "..", "templates", "routing.toml");
30
33
  export const LOCAL: PeerId = "local";
34
+ export const PI: PeerId = "pi";
31
35
 
32
36
  /** `.agenthub/routing.toml`, or the shipped default when the project has none. Throws on a file that does not parse or lacks a fixed model. */
33
37
  export function loadRouting(cwd: string): Routing {
@@ -41,13 +45,20 @@ export function loadRouting(cwd: string): Routing {
41
45
  if (!raw.local?.fixed_model) throw new Error("routing.toml: [local] fixed_model is required (the path that works without Switchyard)");
42
46
  const signals = { pii_patterns: [], long_context_tokens: 120_000, ...raw.signals };
43
47
  for (const p of signals.pii_patterns) new RegExp(p); // a bad pattern fails here, at load, not in the middle of an assignment
48
+ const classes = raw.classes ?? {};
49
+ for (const [name, policy] of Object.entries(classes)) {
50
+ if (policy?.pi_backend !== undefined && policy.pi_backend !== "dgx" && policy.pi_backend !== "mlx") throw new Error(`routing.toml: [classes.${name}] pi_backend must be "dgx" or "mlx"`);
51
+ }
52
+ const pi = { dgx_max_context_tokens: 262_144, mlx_max_context_tokens: 16_000, ...(raw as any).pi };
53
+ if (!(Number.isSafeInteger(pi.dgx_max_context_tokens) && pi.dgx_max_context_tokens > 0) || !(Number.isSafeInteger(pi.mlx_max_context_tokens) && pi.mlx_max_context_tokens > 0)) throw new Error("routing.toml: [pi] context limits must be positive integers");
44
54
  return {
45
55
  local: raw.local,
46
56
  targets: raw.targets ?? {},
47
57
  routes: raw.routes ?? {},
48
- classes: raw.classes ?? {},
58
+ classes,
49
59
  signals,
50
60
  constraints: { pii: "local_only", long_context: "skip_local", budget_paused: "skip_peer", ...raw.constraints },
61
+ pi,
51
62
  };
52
63
  }
53
64
 
@@ -102,6 +113,7 @@ export interface Assignment {
102
113
  /** what `local` should ask for on this task: a Switchyard route id and the model to fall back to */
103
114
  route?: string;
104
115
  fixedModel?: string;
116
+ piBackend?: "dgx" | "mlx";
105
117
  trace: string[];
106
118
  }
107
119
 
@@ -126,6 +138,12 @@ export function assign(
126
138
  if (states[peer] === "paused" && routing.constraints.budget_paused === "skip_peer") return "paused";
127
139
  if (pii && peer !== LOCAL) return "pii: on-prem peers only";
128
140
  if (peer === LOCAL && policy?.local_allowed === false) return "local_allowed = false for this class";
141
+ if (peer === PI && policy?.local_allowed === false) return "local_allowed = false also excludes pi for this class";
142
+ if (peer === PI && task.signals.includes("long_context")) {
143
+ const backend = policy?.pi_backend ?? "dgx";
144
+ const limit = backend === "mlx" ? routing.pi.mlx_max_context_tokens : routing.pi.dgx_max_context_tokens;
145
+ if (routing.signals.long_context_tokens > limit) return `pi ${backend} capability limit ${limit} tokens`;
146
+ }
129
147
  if (peer === LOCAL && role === "owner" && task.signals.includes("long_context") && routing.constraints.long_context === "skip_local") return "long_context: skip local";
130
148
  return undefined;
131
149
  };
@@ -137,7 +155,9 @@ export function assign(
137
155
  trace.push(` ${role} candidate ${peer}: ${why ? `skipped, ${why}` : states[peer]}`);
138
156
  if (!why) ok.push(peer);
139
157
  }
140
- return ok.find((p) => states[p] === "idle") ?? ok[0]; // idle before busy, otherwise preference order
158
+ const localTier = ok.filter((p) => p === LOCAL || p === PI);
159
+ if (localTier.length) return localTier.find((p) => states[p] === "idle") ?? localTier[0]; // local/Pi stays ahead of an idle cloud peer
160
+ return ok.find((p) => states[p] === "idle") ?? ok[0];
141
161
  };
142
162
 
143
163
  // Never the task's current owner by default: a decline or an escalation has to reach the next peer in the list.
@@ -155,6 +175,8 @@ export function assign(
155
175
  }
156
176
  const route = policy?.route ?? routing.local.route;
157
177
  const fixedModel = policy?.fixed_model ?? routing.local.fixed_model;
178
+ const piBackend = policy?.pi_backend;
158
179
  if (owner === LOCAL) trace.push(`route: ${route ?? "(none)"}, fixed_model ${fixedModel}`);
159
- return { ...(owner ? { owner } : {}), ...(pii ? { reviewer: "user" } : reviewer ? { reviewer } : {}), ...(route ? { route } : {}), fixedModel, trace };
180
+ if (owner === PI) trace.push(`pi decision: backend ${piBackend ?? "dgx"}${task.signals.includes("long_context") ? `, context limit ${piBackend === "mlx" ? routing.pi.mlx_max_context_tokens : routing.pi.dgx_max_context_tokens}` : ""}`);
181
+ return { ...(owner ? { owner } : {}), ...(pii ? { reviewer: "user" } : reviewer ? { reviewer } : {}), ...(route ? { route } : {}), fixedModel, ...(piBackend ? { piBackend } : {}), trace };
160
182
  }
package/src/hub/tasks.ts CHANGED
@@ -3,7 +3,7 @@ import type { MemoryClient } from "../memory/client.ts";
3
3
  import { CLASSES, type Board, type Task, type TaskClass, type TaskRefs } from "./board.ts";
4
4
  import type { Bus } from "./bus.ts";
5
5
  import { HUB, newEnvelope, USER, type Envelope, type PeerId, type PeerState } from "./envelope.ts";
6
- import { assign, detectSignals, LOCAL, type Assignment, type Routing } from "./routing.ts";
6
+ import { assign, detectSignals, LOCAL, PI, type Assignment, type Routing } from "./routing.ts";
7
7
 
8
8
  export interface TasksDeps {
9
9
  board: Board;
@@ -119,7 +119,7 @@ export class Tasks {
119
119
  const pii = this.isPii(task);
120
120
  const brief = pii ? undefined : await this.d.briefs?.forTask(task.owner!, task).catch(() => undefined);
121
121
  const rejected = task.history.filter((h) => h.event === "changes_requested").map((h) => `- ${h.by}: ${h.note ?? ""}`);
122
- const facts = [`class ${task.class}`, task.refs.paths?.length ? `paths ${task.refs.paths.join(", ")}` : "", task.refs.branch ? `branch ${task.refs.branch}` : "", a.reviewer ? `reviewer ${a.reviewer}` : "no reviewer"].filter(Boolean).join("; ");
122
+ const facts = [`class ${task.class}`, a.owner === PI ? `backend pi/${a.piBackend ?? "dgx"}` : "", task.refs.paths?.length ? `paths ${task.refs.paths.join(", ")}` : "", task.refs.branch ? `branch ${task.refs.branch}` : "", a.reviewer ? `reviewer ${a.reviewer}` : "no reviewer"].filter(Boolean).join("; ");
123
123
  const body = [
124
124
  `Task #${task.id} [${task.class}] ${task.title}`,
125
125
  task.detail,
@@ -216,7 +216,7 @@ export class Tasks {
216
216
  const list = this.d.routing().classes[task.class]?.escalate_to ?? [];
217
217
  if (task.state === "changes_requested") task = this.d.board.update(task.id, HUB, "reopened", { state: "in_progress" });
218
218
  const from = task.owner;
219
- const next = await this.assignOwner(task, by, { candidates: list, event: "escalated", note: `${why}; from ${from ?? "none"}` });
219
+ const next = await this.assignOwner(task, by, { candidates: list, event: "escalated", note: `${why}; from ${from ?? "none"}`, context: why });
220
220
  if (next.owner && next.owner !== from) {
221
221
  this.d.notify(`task ${this.publicTitle(next)} escalated from ${from} to ${next.owner} (${why})`);
222
222
  this.note(next, by, "decision", `Task #${next.id} escalated from ${from} to ${next.owner}: ${why}`);
@@ -239,7 +239,8 @@ export class Tasks {
239
239
  const routing = this.d.routing();
240
240
  for (const task of this.d.board.list()) {
241
241
  if (task.owner === peer && OPEN.includes(task.state)) {
242
- const candidates = [LOCAL, ...(routing.classes[task.class]?.peers ?? []).filter((p) => p !== LOCAL)];
242
+ const pii = this.isPii(task);
243
+ const candidates = [pii ? LOCAL : PI, pii ? undefined : LOCAL, ...(routing.classes[task.class]?.peers ?? []).filter((p) => p !== LOCAL && p !== PI)].filter((p): p is PeerId => !!p);
243
244
  const back = task.state === "in_progress" ? this.d.board.update(task.id, HUB, "released", { state: "proposed" }, `budget pause of ${peer}`) : task;
244
245
  const next = await this.assignOwner(back, HUB, { candidates, exclude: [peer], event: "reassigned", note: `budget pause of ${peer}`, clearOnFail: true, ...(context ? { context } : {}) });
245
246
  moved.push({ id: task.id, title: this.publicTitle(task), to: next.owner, role: "owner" });
@@ -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
+ }