@staix/agent-hub 0.7.0 → 0.7.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +16 -0
- package/README.md +20 -9
- package/docs/operations.md +100 -8
- package/docs/quickstart.md +2 -2
- package/docs/smoke.md +107 -1
- package/docs/specs/2026-09-19-agent-hub-design.md +23 -3
- package/docs/specs/2026-09-20-durable-delivery-design.md +2 -2
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +1 -1
- package/src/adapters/pi.ts +29 -4
- package/src/cli/init.ts +27 -5
- package/src/cli/main.ts +95 -5
- package/src/cli/models-setup.ts +39 -0
- package/src/cli/terminal-recovery.ts +9 -34
- package/src/cli/upgrade-runtime.ts +10 -2
- package/src/hub/bus.ts +18 -3
- package/src/hub/daemon.ts +59 -27
- package/src/hub/lifecycle.ts +15 -2
- package/src/hub/manager.ts +15 -3
- package/src/models/mlx.ts +24 -2
- package/src/models/ollama.ts +167 -0
- package/src/models/relay.ts +19 -4
- package/templates/AGENTS.block.md +8 -7
- package/templates/CLAUDE.block.md +0 -11
package/src/cli/main.ts
CHANGED
|
@@ -25,6 +25,7 @@ import { abortRecovery, createOperation, publicOperation, registeredProjects, ru
|
|
|
25
25
|
import { makeRecoveryDriver, makeUpgradePlan, preserveSource } from "./upgrade-runtime.ts";
|
|
26
26
|
import { recordTerminalLaunch } from "./terminal-recovery.ts";
|
|
27
27
|
import { ensureMlx, inspectMlx, stopMlx } from "../models/mlx.ts";
|
|
28
|
+
import { setupOllamaModel } from "./models-setup.ts";
|
|
28
29
|
|
|
29
30
|
import { backendLine, peerLine, type BackendRow, type PeerRow } from "./status-lines.ts";
|
|
30
31
|
|
|
@@ -37,7 +38,7 @@ const USAGE = `agent-hub ${VERSION}: Claude Code, Codex and Kimi as peers in one
|
|
|
37
38
|
ahub ui --all [--no-open] open the unified project dashboard
|
|
38
39
|
ahub ui --all --stop stop only the dashboard manager
|
|
39
40
|
ahub setup [--yes] install or update the Claude Code channel plugin from this package, then run doctor
|
|
40
|
-
ahub init write .agenthub/config.json and the
|
|
41
|
+
ahub init write .agenthub/config.json and the AGENTS.md marker block (drops a legacy CLAUDE.md block)
|
|
41
42
|
ahub up [--unattended] start the daemon for this directory
|
|
42
43
|
ahub upgrade --to <version> [--dry-run] [--yes] review and upgrade running projects
|
|
43
44
|
ahub restart [--dry-run] [--yes] recover this project's runtime
|
|
@@ -46,7 +47,7 @@ const USAGE = `agent-hub ${VERSION}: Claude Code, Codex and Kimi as peers in one
|
|
|
46
47
|
ahub codex [args...] start the Codex adapter and attach the TUI [--unattended]
|
|
47
48
|
ahub kimi [--model <alias>] start Kimi headless under ACP
|
|
48
49
|
ahub pi [--mode headless|tui] [--backend auto|dgx|mlx] [--session-id <id>] [--session-file <path>] start Pi
|
|
49
|
-
ahub models setup|status|start|stop
|
|
50
|
+
ahub models setup|status|start|stop prepare or inspect local Ollama MLX (legacy stop is explicit)
|
|
50
51
|
ahub local [--route <id> | --model <id>]
|
|
51
52
|
start the hub-native worker on the self-hosted models (routing.toml)
|
|
52
53
|
ahub say [@peer ...] <text> send as the console user (no @peer = broadcast); delivered at once,
|
|
@@ -70,7 +71,9 @@ const USAGE = `agent-hub ${VERSION}: Claude Code, Codex and Kimi as peers in one
|
|
|
70
71
|
ahub queue list [--peer <id>] [--json] inspect durable deliveries
|
|
71
72
|
ahub queue show <delivery-id> inspect one delivery and revision
|
|
72
73
|
ahub queue resolve <delivery-id> --action completed|retry|discard --reason <text>
|
|
73
|
-
ahub status | logs [-f] | doctor | kill
|
|
74
|
+
ahub status | logs [-f] | doctor | kill
|
|
75
|
+
ahub doctor --orphans [--kill] list registrations whose project root is gone; --kill stops their
|
|
76
|
+
daemons (SIGTERM, then SIGKILL) only after the process identity checks out`;
|
|
74
77
|
|
|
75
78
|
const argv = process.argv.slice(2);
|
|
76
79
|
let selector: string | undefined;
|
|
@@ -163,6 +166,87 @@ async function printProjects(json = false) {
|
|
|
163
166
|
if (!rows.length) console.log("No registered projects. Run ahub init in a project directory.");
|
|
164
167
|
}
|
|
165
168
|
|
|
169
|
+
/** Full command line of a live process, undefined once it is gone. */
|
|
170
|
+
function processCommandLine(pid: number): string | undefined {
|
|
171
|
+
const res = spawnSync("ps", ["-p", String(pid), "-o", "command="], { encoding: "utf8" });
|
|
172
|
+
if (res.status !== 0) return undefined;
|
|
173
|
+
return res.stdout.trim() || undefined;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* A bare PID is never enough to kill on: it must belong to this project's hub daemon.
|
|
178
|
+
* The daemon argv is `<bun> .../cli/main.ts --project <root> daemon [--unattended]`, built by
|
|
179
|
+
* startProject from the registry row. ps joins argv with spaces and drops quoting, so the
|
|
180
|
+
* token after `--project` must equal the root exactly: a substring check would let an orphan
|
|
181
|
+
* rooted at /tmp/foo match a healthy hub launched with --project /tmp/foo2, and a root whose
|
|
182
|
+
* path contains a space simply can never be verified this way and is refused (issue #56).
|
|
183
|
+
*/
|
|
184
|
+
function hubDaemonCommand(pid: number, root: string): string | undefined {
|
|
185
|
+
const command = processCommandLine(pid);
|
|
186
|
+
if (!command) return undefined;
|
|
187
|
+
const tokens = command.split(/\s+/);
|
|
188
|
+
const project = tokens.indexOf("--project");
|
|
189
|
+
if (project < 1 || tokens[project + 1] !== root || tokens[project + 2] !== "daemon") return undefined;
|
|
190
|
+
return command;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Live PID candidates for a registration whose project root is gone: the claim, the manifest, the legacy pid file. */
|
|
194
|
+
function orphanPids(project: Project): number[] {
|
|
195
|
+
const candidates = new Set<number>();
|
|
196
|
+
if (typeof project.pid === "number") candidates.add(project.pid);
|
|
197
|
+
const control = readControl(project.stateDir);
|
|
198
|
+
if (control && Number.isSafeInteger(control.pid)) candidates.add(control.pid!);
|
|
199
|
+
try {
|
|
200
|
+
const pid = Number(readFileSync(join(project.stateDir, "hub.pid"), "utf8").trim());
|
|
201
|
+
if (Number.isSafeInteger(pid) && pid > 0) candidates.add(pid);
|
|
202
|
+
} catch { /* no legacy pid file */ }
|
|
203
|
+
return [...candidates].filter((pid) => {
|
|
204
|
+
try { process.kill(pid, 0); return true; } catch { return false; }
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const processGone = (pid: number): boolean => !processCommandLine(pid);
|
|
209
|
+
|
|
210
|
+
async function killOrphan(pid: number, root: string): Promise<boolean> {
|
|
211
|
+
if (!hubDaemonCommand(pid, root)) {
|
|
212
|
+
console.log(` pid ${pid}: command line is not this project's hub daemon; refusing to kill`);
|
|
213
|
+
return false;
|
|
214
|
+
}
|
|
215
|
+
try { process.kill(pid, "SIGTERM"); } catch { return true; } // exited meanwhile
|
|
216
|
+
const graceful = Date.now() + 3_000;
|
|
217
|
+
while (Date.now() < graceful && !processGone(pid)) await Bun.sleep(100);
|
|
218
|
+
if (processGone(pid)) { console.log(` pid ${pid}: stopped with SIGTERM`); return true; }
|
|
219
|
+
// The PID may have been reused since SIGTERM; verify identity again before escalating.
|
|
220
|
+
if (!hubDaemonCommand(pid, root)) {
|
|
221
|
+
console.log(` pid ${pid}: identity changed after SIGTERM; refusing SIGKILL`);
|
|
222
|
+
return false;
|
|
223
|
+
}
|
|
224
|
+
try { process.kill(pid, "SIGKILL"); } catch { return true; }
|
|
225
|
+
const hard = Date.now() + 2_000;
|
|
226
|
+
while (Date.now() < hard && !processGone(pid)) await Bun.sleep(100);
|
|
227
|
+
if (!processGone(pid)) {
|
|
228
|
+
console.log(` pid ${pid}: still alive after SIGKILL`);
|
|
229
|
+
return false;
|
|
230
|
+
}
|
|
231
|
+
console.log(` pid ${pid}: killed with SIGKILL`);
|
|
232
|
+
return true;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
async function orphanDoctor(kill: boolean): Promise<void> {
|
|
236
|
+
const orphans = registeredProjects().filter((project) => !existsSync(project.root));
|
|
237
|
+
if (!orphans.length) return console.log("no orphaned hub registrations");
|
|
238
|
+
console.log("orphaned hub registrations (the project root is gone):");
|
|
239
|
+
let failed = 0;
|
|
240
|
+
for (const project of orphans) {
|
|
241
|
+
const pids = orphanPids(project);
|
|
242
|
+
console.log(` ${project.id} ${project.root}${pids.length ? ` live pid ${pids.join(", ")}` : " no live process"}`);
|
|
243
|
+
if (!kill) continue;
|
|
244
|
+
for (const pid of pids) if (!(await killOrphan(pid, project.root))) failed++;
|
|
245
|
+
}
|
|
246
|
+
if (!kill) console.log("kill live orphans with `ahub doctor --orphans --kill`, then forget each row with `ahub projects remove <id>`");
|
|
247
|
+
if (failed) fail(`${failed} orphaned hub process(es) could not be stopped`);
|
|
248
|
+
}
|
|
249
|
+
|
|
166
250
|
function fail(message: string): never {
|
|
167
251
|
console.error(`ahub: ${message}`);
|
|
168
252
|
process.exit(1);
|
|
@@ -338,7 +422,7 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
338
422
|
assertLifecycleAvailable();
|
|
339
423
|
const changed = init(cwd);
|
|
340
424
|
registeredProject();
|
|
341
|
-
console.log(changed.length ? changed.map((p) =>
|
|
425
|
+
console.log(changed.length ? changed.map((p) => `${existsSync(p) ? "wrote" : "removed"} ${p}`).join("\n") : "already up to date");
|
|
342
426
|
},
|
|
343
427
|
|
|
344
428
|
// Internal: the detached daemon process started by ahub up or the manager.
|
|
@@ -434,11 +518,16 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
434
518
|
const configured = loadConfig(cwd).mlx;
|
|
435
519
|
const runtimeDir = configured.runtimeDir ? resolve(cwd, configured.runtimeDir) : join(homedir(), ".agenthub", "runtimes", "mlx");
|
|
436
520
|
const modelPath = configured.modelPath ? resolve(cwd, configured.modelPath) : join(homedir(), ".agenthub", "models", "qwen3-8b-mlx");
|
|
437
|
-
const mlxOptions = { ...configured, runtimeDir, modelPath };
|
|
521
|
+
const mlxOptions = configured.provider === "ollama" ? configured : { ...configured, runtimeDir, modelPath };
|
|
438
522
|
if (action === "status") return console.log(JSON.stringify(await inspectMlx(mlxOptions), null, 2));
|
|
439
523
|
if (action === "start") { const handle = await ensureMlx(mlxOptions); return console.log(JSON.stringify(handle.status(), null, 2)); }
|
|
440
524
|
if (action === "stop") { await stopMlx(mlxOptions); return console.log("MLX stopped"); }
|
|
441
525
|
if (action !== "setup") fail("usage: ahub models setup|status|start|stop");
|
|
526
|
+
if (configured.provider === "ollama") {
|
|
527
|
+
await setupOllamaModel(configured);
|
|
528
|
+
const handle = await ensureMlx(configured);
|
|
529
|
+
return console.log(JSON.stringify(handle.status(), null, 2));
|
|
530
|
+
}
|
|
442
531
|
const python = join(runtimeDir, "bin", "python");
|
|
443
532
|
const run = (argv: string[]) => { const result = spawnSync(argv[0]!, argv.slice(1), { cwd, stdio: "inherit" }); if (result.status !== 0) fail(`models setup failed: ${argv.join(" ")}`); };
|
|
444
533
|
if (!existsSync(python)) run(["uv", "venv", "--python", "3.12", runtimeDir]);
|
|
@@ -634,6 +723,7 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
634
723
|
},
|
|
635
724
|
|
|
636
725
|
doctor: async () => {
|
|
726
|
+
if (args.includes("--orphans")) return orphanDoctor(args.includes("--kill"));
|
|
637
727
|
const version = (bin: string) => {
|
|
638
728
|
const res = spawnSync(bin, ["--version"], { encoding: "utf8" });
|
|
639
729
|
return res.status === 0 ? res.stdout.trim().split("\n")[0]! : undefined;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { MlxOptions } from "../models/mlx.ts";
|
|
2
|
+
import { validateOllamaOptions } from "../models/ollama.ts";
|
|
3
|
+
|
|
4
|
+
/** Explicit setup only: inference never downloads models or starts a service. */
|
|
5
|
+
export async function setupOllamaModel(options: MlxOptions): Promise<void> {
|
|
6
|
+
const config = validateOllamaOptions(options);
|
|
7
|
+
const source = options.sourceModel ?? "qwen3.5:4b-mlx";
|
|
8
|
+
if (!/^[a-zA-Z0-9][a-zA-Z0-9._:/-]*$/.test(source) || !source.endsWith("-mlx") || source.includes("cloud") || source.includes("..") || source.includes("://")) {
|
|
9
|
+
throw new Error("Ollama sourceModel must be an explicit local MLX model tag");
|
|
10
|
+
}
|
|
11
|
+
if (source === config.model) throw new Error("sourceModel and the dedicated AgentHub model must differ");
|
|
12
|
+
const base = `http://${config.host === "::1" ? "[::1]" : config.host}:${config.port}`;
|
|
13
|
+
const post = async (path: string, body: unknown) => {
|
|
14
|
+
const response = await fetch(`${base}${path}`, {
|
|
15
|
+
method: "POST", redirect: "error", headers: { "content-type": "application/json" },
|
|
16
|
+
body: JSON.stringify(body), signal: AbortSignal.timeout(30 * 60_000),
|
|
17
|
+
});
|
|
18
|
+
if (!response.ok) throw new Error(`Ollama ${path} failed: HTTP ${response.status}`);
|
|
19
|
+
const result = await response.json() as { error?: string };
|
|
20
|
+
if (result.error) throw new Error(`Ollama ${path} failed: ${result.error.slice(0, 200)}`);
|
|
21
|
+
return result;
|
|
22
|
+
};
|
|
23
|
+
await post("/api/pull", { model: source, stream: false });
|
|
24
|
+
const sourceMetadata = await post("/api/show", { model: source }) as { remote_host?: unknown; remote_model?: unknown };
|
|
25
|
+
if (sourceMetadata.remote_host != null || sourceMetadata.remote_model != null) {
|
|
26
|
+
throw new Error("Ollama source model resolves to a remote/cloud model");
|
|
27
|
+
}
|
|
28
|
+
// Idempotent setup must never silently overwrite an existing model's recipe.
|
|
29
|
+
const existing = await fetch(`${base}/api/show`, {
|
|
30
|
+
method: "POST", redirect: "error", headers: { "content-type": "application/json" },
|
|
31
|
+
body: JSON.stringify({ model: config.model }), signal: AbortSignal.timeout(10_000),
|
|
32
|
+
});
|
|
33
|
+
if (existing.ok) return; // The caller's inspect operation validates its context.
|
|
34
|
+
if (existing.status !== 404) throw new Error(`Ollama model inspection failed: HTTP ${existing.status}`);
|
|
35
|
+
await post("/api/create", {
|
|
36
|
+
model: config.model, from: source, stream: false,
|
|
37
|
+
parameters: { num_ctx: config.contextWindow, num_predict: config.maxTokens, temperature: 0.2 },
|
|
38
|
+
});
|
|
39
|
+
}
|
|
@@ -543,10 +543,13 @@ export async function closeTerminal(binding: TerminalBinding, timeoutMs = 600_00
|
|
|
543
543
|
try {
|
|
544
544
|
await showBinding(binding, config);
|
|
545
545
|
await run(config.runner, ["terminal", "close", "--terminal", binding.handle, "--json"]);
|
|
546
|
-
const
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
546
|
+
const deadline = Date.now() + 5000;
|
|
547
|
+
do {
|
|
548
|
+
const afterClose = listTerminals(await run(config.runner, ["terminal", "list", "--json"]));
|
|
549
|
+
if (!afterClose.some((terminal) => terminal.handle === binding.handle || terminal.incarnationId === binding.incarnationId)) return { closed: true, manualRequired: false, binding, blockers: [] };
|
|
550
|
+
await Bun.sleep(100);
|
|
551
|
+
} while (Date.now() < deadline);
|
|
552
|
+
return closeFailure(binding, [blocker("ambiguous-create", `close of terminal ${binding.handle} was not confirmed`, binding.peer, binding.handle)]);
|
|
550
553
|
} catch (error) {
|
|
551
554
|
return closeFailure(binding, [error instanceof OrcaCommandError ? error.blocker : blocker("command-error", String(error), binding.peer, binding.handle)]);
|
|
552
555
|
}
|
|
@@ -557,36 +560,8 @@ export async function createTerminal(binding: TerminalBinding, options?: Command
|
|
|
557
560
|
const config = normalizeOptions(options);
|
|
558
561
|
try {
|
|
559
562
|
const worktreeSelector = binding.worktreeId.startsWith("id:") ? binding.worktreeId : `id:${binding.worktreeId}`;
|
|
560
|
-
//
|
|
561
|
-
//
|
|
562
|
-
// session match is safe to adopt; any other occupant requires an operator
|
|
563
|
-
// decision rather than risking a duplicate native session.
|
|
564
|
-
const existing = listTerminals(await run(config.runner, ["terminal", "list", "--json"])).filter((terminal) =>
|
|
565
|
-
rootMatches(terminal.worktreePath ?? terminal.projectRoot, binding.projectRoot) &&
|
|
566
|
-
nestedString(terminal, ["worktreeId"]) === binding.worktreeId,
|
|
567
|
-
);
|
|
568
|
-
if (existing.length > 0) {
|
|
569
|
-
const matches: TerminalBinding[] = [];
|
|
570
|
-
for (const listed of existing) {
|
|
571
|
-
const handle = nestedString(listed, ["handle"]);
|
|
572
|
-
if (!handle) continue;
|
|
573
|
-
try {
|
|
574
|
-
const shown = terminalObject(await run(config.runner, ["terminal", "show", "--terminal", handle, "--json"]));
|
|
575
|
-
if (identityFrom(shown) === binding.peer && sessionFrom(shown) === binding.sessionId) {
|
|
576
|
-
const incarnationId = nestedString(shown, ["incarnationId"]);
|
|
577
|
-
const worktreeId = nestedString(shown, ["worktreeId"]);
|
|
578
|
-
if (incarnationId && worktreeId) matches.push({ ...binding, handle, incarnationId, worktreeId });
|
|
579
|
-
}
|
|
580
|
-
} catch { /* An unreadable candidate remains an ambiguity below. */ }
|
|
581
|
-
}
|
|
582
|
-
if (matches.length === 1) {
|
|
583
|
-
const recovered = matches[0]!;
|
|
584
|
-
const idle = await waitForIdle(recovered, timeoutMs, config);
|
|
585
|
-
if (!idle.satisfied) return { created: false, ready: false, manualRequired: true, blockers: idle.blockers };
|
|
586
|
-
return { created: true, ready: true, manualRequired: false, binding: recovered, newBinding: recovered, blockers: [] };
|
|
587
|
-
}
|
|
588
|
-
return { created: false, ready: false, manualRequired: true, blockers: [blocker("ambiguous-create", `an existing terminal in worktree ${binding.worktreeId} may be the result of an earlier create; refusing another create`, binding.peer)] };
|
|
589
|
-
}
|
|
563
|
+
// The coordinator journals a pending creation before this call and reconciles
|
|
564
|
+
// it on resume. Other agents in the worktree do not imply a prior create.
|
|
590
565
|
const value = await run(config.runner, ["terminal", "create", "--worktree", worktreeSelector, "--command", binding.launch.command, "--title", `${binding.peer} recovery`, "--json"]);
|
|
591
566
|
let created = terminalObject(value);
|
|
592
567
|
const createdHandle = nestedString(created, ["handle"]);
|
|
@@ -48,8 +48,16 @@ export async function inspectRecovery(project: Project): Promise<Inspection> {
|
|
|
48
48
|
};
|
|
49
49
|
let status = base.status;
|
|
50
50
|
if (!status && legacySupported) {
|
|
51
|
-
|
|
52
|
-
|
|
51
|
+
try {
|
|
52
|
+
const readback = await rpc(project, { t: "status" }, sourceProtocol);
|
|
53
|
+
status = readback.status;
|
|
54
|
+
} catch {
|
|
55
|
+
// server.stop() runs before state-file removal, so a stopping hub briefly refuses
|
|
56
|
+
// connections with its manifest still on disk. Never infer stopped from a connect
|
|
57
|
+
// failure: callers poll again and see "stopped" once the manifest is gone.
|
|
58
|
+
return { state: "unavailable", peers: [], blockers: ["runtime changed during recovery inspection"],
|
|
59
|
+
...(control?.instanceId ? { instanceId: control.instanceId } : {}), ...(control?.protocol ? { protocol: control.protocol } : {}) };
|
|
60
|
+
}
|
|
53
61
|
}
|
|
54
62
|
if (!status || !control) return { state: "unavailable", peers: [], blockers: ["authenticated source status unavailable"] };
|
|
55
63
|
let response: any;
|
package/src/hub/bus.ts
CHANGED
|
@@ -194,10 +194,25 @@ export class Bus {
|
|
|
194
194
|
return this.recoveryHeld;
|
|
195
195
|
}
|
|
196
196
|
|
|
197
|
-
/**
|
|
198
|
-
|
|
197
|
+
/**
|
|
198
|
+
* Wait until a hold has fenced condensation, steering and queue drains already in flight.
|
|
199
|
+
* A drain stuck on a peer that never settles must not spin a core forever (issue #56):
|
|
200
|
+
* the wait backs off and gives up, leaving queues, in-flight deliveries and a hold the
|
|
201
|
+
* caller did not set exactly as they were.
|
|
202
|
+
*/
|
|
203
|
+
async fenceRecovery(timeoutMs = 5_000): Promise<void> {
|
|
204
|
+
const heldBefore = this.recoveryHeld;
|
|
199
205
|
this.recoveryHeld = true;
|
|
200
|
-
|
|
206
|
+
const deadline = Date.now() + timeoutMs;
|
|
207
|
+
let delay = 1;
|
|
208
|
+
while (this.draining.size || this.steering || this.condensing) {
|
|
209
|
+
if (Date.now() >= deadline) {
|
|
210
|
+
this.recoveryHeld = heldBefore;
|
|
211
|
+
throw new Error(`recovery fence timed out after ${timeoutMs} ms with ${this.draining.size + this.steering + this.condensing} deliveries still in flight; retry the operation`);
|
|
212
|
+
}
|
|
213
|
+
await Bun.sleep(delay);
|
|
214
|
+
delay = Math.min(delay * 2, 50);
|
|
215
|
+
}
|
|
201
216
|
}
|
|
202
217
|
|
|
203
218
|
/** A bounded, JSON-safe representation of queued work and retry/dedupe state. */
|
package/src/hub/daemon.ts
CHANGED
|
@@ -50,7 +50,7 @@ export interface HubConfig {
|
|
|
50
50
|
inference: InferenceConfig;
|
|
51
51
|
omniroute: OmniRouteConfig;
|
|
52
52
|
pi: { enabled: boolean; auto_start: boolean; cmd: string[]; backend: "auto" | "dgx" | "mlx"; dgx_coding: string; dgx_fast: string; max_steps: number };
|
|
53
|
-
mlx: Pick<MlxOptions, "runtimeDir" | "modelPath" | "port" | "maxInputTokens" | "maxTokens">;
|
|
53
|
+
mlx: Pick<MlxOptions, "provider" | "host" | "runtimeDir" | "modelPath" | "port" | "model" | "sourceModel" | "contextWindow" | "maxInputTokens" | "maxTokens" | "maxConcurrency">;
|
|
54
54
|
local: { deny: string[]; bash_network: boolean; max_steps: number; read_allow: string[] };
|
|
55
55
|
}
|
|
56
56
|
export const DEFAULT_CONFIG: HubConfig = {
|
|
@@ -66,7 +66,7 @@ export const DEFAULT_CONFIG: HubConfig = {
|
|
|
66
66
|
inference: DEFAULT_INFERENCE,
|
|
67
67
|
omniroute: DEFAULT_OMNIROUTE,
|
|
68
68
|
pi: { enabled: false, auto_start: false, cmd: ["pi"], backend: "auto", dgx_coding: "coding", dgx_fast: "fast", max_steps: 30 },
|
|
69
|
-
mlx: { maxInputTokens:
|
|
69
|
+
mlx: { provider: "ollama", model: "agenthub-fast-mlx:4b-8k", sourceModel: "qwen3.5:4b-mlx", contextWindow: 8192, maxInputTokens: 6000, maxTokens: 2048, maxConcurrency: 1 },
|
|
70
70
|
local: { deny: [], bash_network: false, max_steps: 30, read_allow: [] },
|
|
71
71
|
};
|
|
72
72
|
|
|
@@ -79,7 +79,12 @@ const PEER_ID = /^[a-z][a-z0-9-]{0,31}$/;
|
|
|
79
79
|
export function loadConfig(cwd: string): HubConfig {
|
|
80
80
|
try {
|
|
81
81
|
const file = JSON.parse(readFileSync(join(cwd, ".agenthub", "config.json"), "utf8"));
|
|
82
|
-
|
|
82
|
+
if (file.mlx != null && (typeof file.mlx !== "object" || Array.isArray(file.mlx))) throw new Error("mlx configuration must be an object");
|
|
83
|
+
if (file.mlx?.provider !== undefined && !["ollama", "legacy"].includes(file.mlx.provider)) throw new Error("mlx.provider must be ollama or legacy");
|
|
84
|
+
if (file.mlx?.provider === undefined && (file.mlx?.modelPath || file.mlx?.runtimeDir || file.mlx?.bin || file.mlx?.port)) {
|
|
85
|
+
throw new Error("legacy MLX configuration requires explicit mlx.provider=legacy; migrate to provider=ollama to avoid Python serving");
|
|
86
|
+
}
|
|
87
|
+
const mlx = { ...(file.mlx?.provider === "legacy" ? { provider: "legacy" as const, maxInputTokens: 16_000, maxTokens: 2048 } : DEFAULT_CONFIG.mlx), ...file.mlx };
|
|
83
88
|
if (typeof mlx.runtimeDir === "string") mlx.runtimeDir = resolve(cwd, mlx.runtimeDir);
|
|
84
89
|
if (typeof mlx.modelPath === "string") mlx.modelPath = resolve(cwd, mlx.modelPath);
|
|
85
90
|
return {
|
|
@@ -94,8 +99,9 @@ export function loadConfig(cwd: string): HubConfig {
|
|
|
94
99
|
pi: { ...DEFAULT_CONFIG.pi, ...file.pi },
|
|
95
100
|
mlx,
|
|
96
101
|
};
|
|
97
|
-
} catch {
|
|
98
|
-
return DEFAULT_CONFIG;
|
|
102
|
+
} catch (error) {
|
|
103
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return DEFAULT_CONFIG;
|
|
104
|
+
throw error;
|
|
99
105
|
}
|
|
100
106
|
}
|
|
101
107
|
|
|
@@ -114,6 +120,10 @@ export interface DaemonOptions {
|
|
|
114
120
|
/** Auto-approve ACP permission requests with the agent's allow_once option. */
|
|
115
121
|
unattended?: boolean;
|
|
116
122
|
permissionTimeoutMs?: number;
|
|
123
|
+
/** Called once when shutdown begins, however it was triggered; the lifecycle uses it to arm a hard deadline. */
|
|
124
|
+
onShutdownStart?: () => void;
|
|
125
|
+
/** How often the daemon checks that its project root and state dir still exist. Tests shrink this. */
|
|
126
|
+
orphanWatchMs?: number;
|
|
117
127
|
}
|
|
118
128
|
|
|
119
129
|
interface Client {
|
|
@@ -172,7 +182,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
172
182
|
const config = opts.config ?? loadConfig(opts.cwd);
|
|
173
183
|
mkdirSync(opts.stateDir, { recursive: true });
|
|
174
184
|
const logFile = join(opts.stateDir, "hub.log");
|
|
175
|
-
|
|
185
|
+
// The state dir can vanish under a running hub (issue #56): a log line must never take a handler down with it.
|
|
186
|
+
const log = (line: string) => { try { appendFileSync(logFile, `${new Date().toISOString()} ${line}\n`); } catch { /* the state dir is gone; the watchdog is stopping the hub */ } };
|
|
176
187
|
|
|
177
188
|
// Any local web page can open a WebSocket to a loopback port, so the control link needs a secret.
|
|
178
189
|
// The file is written only after the port is bound: a second daemon that loses the bind must not clobber it.
|
|
@@ -516,9 +527,11 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
516
527
|
...(recoveryOperationId ? { recovery: { operationId: recoveryOperationId, phase: recoveryPhase, ready: recoveryReady() } } : {}),
|
|
517
528
|
});
|
|
518
529
|
const writeStatus = () => {
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
530
|
+
try {
|
|
531
|
+
const file = join(opts.stateDir, "status.json"); // clients parse this on every connect: replace it atomically
|
|
532
|
+
writeFileSync(`${file}.${instanceId}.tmp`, `${JSON.stringify(status(), null, 2)}\n`);
|
|
533
|
+
renameSync(`${file}.${instanceId}.tmp`, file);
|
|
534
|
+
} catch { /* the state dir is gone; the watchdog is stopping the hub (issue #56) */ }
|
|
522
535
|
};
|
|
523
536
|
|
|
524
537
|
// A queue change is not a bus event: without this, status.json reports the queue as it was before the last
|
|
@@ -702,7 +715,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
702
715
|
cwd: opts.cwd, stateDir: opts.stateDir, cmd: config.pi.cmd, mode, backend,
|
|
703
716
|
model: args.model,
|
|
704
717
|
sessionId: args.sessionId, sessionFile: args.sessionFile,
|
|
705
|
-
relay: { url: modelRelay.url, token: modelRelay.token, models: modelRelay.models.map((id) => ({ id, contextWindow: id.startsWith("mlx/") ? routing.pi.mlx_max_context_tokens : routing.pi.dgx_max_context_tokens, maxTokens: id.startsWith("mlx/") ? (config.mlx.maxTokens ?? 2048) : 8192 })) },
|
|
718
|
+
relay: { url: modelRelay.url, token: modelRelay.token, models: modelRelay.models.map((id) => ({ id, contextWindow: id.startsWith("mlx/") ? Math.min(routing.pi.mlx_max_context_tokens, config.mlx.provider === "ollama" ? (config.mlx.contextWindow ?? 8192) : routing.pi.mlx_max_context_tokens) : routing.pi.dgx_max_context_tokens, maxTokens: id.startsWith("mlx/") ? (config.mlx.maxTokens ?? 2048) : 8192 })) },
|
|
706
719
|
tools: [...TOOL_SCHEMAS.map((t) => t.function), ...TASK_TOOLS.map((t) => ({ name: t.name, description: t.description, parameters: t.inputSchema }))],
|
|
707
720
|
executeTool: async (name, raw, callId, sessionId) => {
|
|
708
721
|
if (stopping || (recoveryActive() && recoveryPhase !== "preparing")) return "error: recovery is holding tool effects";
|
|
@@ -1082,7 +1095,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1082
1095
|
}
|
|
1083
1096
|
case "recovery":
|
|
1084
1097
|
if (c.role !== "console") return void reply(recoveryError("recovery is a console command"));
|
|
1085
|
-
void recoveryOp(msg).then(reply, () => reply(recoveryError("recovery operation failed")));
|
|
1098
|
+
void recoveryOp(msg).then(reply, (error) => reply(recoveryError((error as Error)?.message ?? "recovery operation failed")));
|
|
1086
1099
|
return;
|
|
1087
1100
|
case "send": {
|
|
1088
1101
|
if (recoveryActive()) return void reply({ t: "sent", ok: false, error: "recovery is holding new deliveries" });
|
|
@@ -1214,6 +1227,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1214
1227
|
let shutdown: Promise<void> | undefined;
|
|
1215
1228
|
function stop(): Promise<void> {
|
|
1216
1229
|
if (shutdown) return shutdown;
|
|
1230
|
+
opts.onShutdownStart?.();
|
|
1217
1231
|
shutdown = stopOnce().catch((error) => { shutdown = undefined; throw error; });
|
|
1218
1232
|
return shutdown;
|
|
1219
1233
|
}
|
|
@@ -1225,26 +1239,44 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1225
1239
|
for (const i of intervals) clearInterval(i);
|
|
1226
1240
|
for (const done of checkpointWaits.values()) done(undefined);
|
|
1227
1241
|
for (const p of permissions.values()) p.done(undefined);
|
|
1228
|
-
// A peer can still be inside memory recall or its native handshake when kill arrives.
|
|
1229
|
-
// Drain those starts before taking the final owned-process snapshot.
|
|
1230
|
-
await Promise.allSettled([...starting.values()]);
|
|
1231
|
-
const exits = await Promise.allSettled([...bus.peers.values()].map((p) => p.stop()).concat(sidecar ? [sidecar.stop()] : []));
|
|
1232
|
-
const failed = exits.find((r) => r.status === "rejected");
|
|
1233
|
-
if (failed?.status === "rejected") throw failed.reason;
|
|
1234
|
-
await modelRelay?.close();
|
|
1235
|
-
await piReceipts?.close();
|
|
1236
|
-
budget.close();
|
|
1237
|
-
board.close();
|
|
1238
|
-
server.stop(true);
|
|
1239
|
-
bus.closeJournal();
|
|
1240
1242
|
try {
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1243
|
+
// A peer can still be inside memory recall or its native handshake when kill arrives.
|
|
1244
|
+
// Drain those starts before taking the final owned-process snapshot.
|
|
1245
|
+
await Promise.allSettled([...starting.values()]);
|
|
1246
|
+
const exits = await Promise.allSettled([...bus.peers.values()].map((p) => p.stop()).concat(sidecar ? [sidecar.stop()] : []));
|
|
1247
|
+
// A peer whose stop rejects must not strand the daemon (issue #56): its failure is
|
|
1248
|
+
// logged and shutdown finishes anyway, or the process ignores SIGTERM forever.
|
|
1249
|
+
for (const exit of exits) if (exit.status === "rejected") log(`peer stop failed during shutdown: ${(exit.reason as Error)?.message ?? exit.reason}`);
|
|
1250
|
+
await modelRelay?.close();
|
|
1251
|
+
await piReceipts?.close();
|
|
1252
|
+
budget.close();
|
|
1253
|
+
board.close();
|
|
1254
|
+
server.stop(true);
|
|
1255
|
+
try { bus.closeJournal(); } catch { /* the state dir is gone; the journal went with it */ }
|
|
1256
|
+
} finally {
|
|
1257
|
+
// State removal and the stopped notification run no matter what failed above,
|
|
1258
|
+
// or daemon.stopped never resolves and the daemon outlives its project.
|
|
1259
|
+
try {
|
|
1260
|
+
const current = JSON.parse(readFileSync(join(opts.stateDir, "status.json"), "utf8"));
|
|
1261
|
+
if (current.instanceId === instanceId) for (const f of ["hub.pid", "status.json", "control-token"]) rmSync(join(opts.stateDir, f), { force: true });
|
|
1262
|
+
} catch { /* another owner or no published state: never remove it */ }
|
|
1263
|
+
onStop?.();
|
|
1264
|
+
}
|
|
1245
1265
|
}
|
|
1246
1266
|
let onStop: (() => void) | undefined;
|
|
1247
1267
|
|
|
1268
|
+
// A hub whose project root or state dir vanished has nothing left to serve: the test suite
|
|
1269
|
+
// rmSync's the temp dir of a leaked daemon, and operators delete projects (issue #56).
|
|
1270
|
+
// The check is slow and unref'd, so it never keeps a daemon alive on its own.
|
|
1271
|
+
const orphanWatch = setInterval(() => {
|
|
1272
|
+
if (stopping) return;
|
|
1273
|
+
if (existsSync(opts.cwd) && existsSync(opts.stateDir)) return;
|
|
1274
|
+
log("project root or state directory is gone; shutting down");
|
|
1275
|
+
void stop().catch((error) => console.error(`shutdown incomplete: ${(error as Error).message}`));
|
|
1276
|
+
}, opts.orphanWatchMs ?? 10_000);
|
|
1277
|
+
orphanWatch.unref?.();
|
|
1278
|
+
intervals.push(orphanWatch);
|
|
1279
|
+
|
|
1248
1280
|
const tokenFile = join(opts.stateDir, "control-token");
|
|
1249
1281
|
writeFileSync(tokenFile, token, { mode: 0o600 });
|
|
1250
1282
|
chmodSync(tokenFile, 0o600);
|
package/src/hub/lifecycle.ts
CHANGED
|
@@ -91,6 +91,19 @@ export async function runProjectDaemon(project: Project, unattended = false): Pr
|
|
|
91
91
|
const instanceId = randomUUID();
|
|
92
92
|
let claimed = false;
|
|
93
93
|
let daemon: Awaited<ReturnType<typeof startDaemon>> | undefined;
|
|
94
|
+
// ponytail: shutdown is bounded as a whole, not per peer; a peer whose stop never settles
|
|
95
|
+
// escapes only through this process-level deadline (issue #56). 15 s leaves the claude-mem
|
|
96
|
+
// session-end work awaited in stop() its chance. Upgrade path: per-peer stop deadlines.
|
|
97
|
+
const SHUTDOWN_DEADLINE_MS = 15_000;
|
|
98
|
+
let forcedExit: ReturnType<typeof setTimeout> | undefined;
|
|
99
|
+
const onShutdownStart = () => {
|
|
100
|
+
if (forcedExit) return;
|
|
101
|
+
forcedExit = setTimeout(() => {
|
|
102
|
+
console.error(`hub shutdown did not finish within ${SHUTDOWN_DEADLINE_MS / 1000} s; exiting anyway`);
|
|
103
|
+
process.exit(1);
|
|
104
|
+
}, SHUTDOWN_DEADLINE_MS);
|
|
105
|
+
forcedExit.unref();
|
|
106
|
+
};
|
|
94
107
|
try {
|
|
95
108
|
const before = await inspectProject(registry.get(project.id) ?? project);
|
|
96
109
|
if (before.state === "running" || before.state === "starting") return;
|
|
@@ -103,7 +116,7 @@ export async function runProjectDaemon(project: Project, unattended = false): Pr
|
|
|
103
116
|
try {
|
|
104
117
|
daemon = await startDaemon({ cwd: project.root, stateDir: project.stateDir, projectId: project.id, instanceId,
|
|
105
118
|
controlPort: base + CONTROL, codexAppPort: base + CODEX_APP, codexProxyPort: base + CODEX_PROXY,
|
|
106
|
-
switchyardPort: base + SWITCHYARD, unattended });
|
|
119
|
+
switchyardPort: base + SWITCHYARD, unattended, onShutdownStart });
|
|
107
120
|
break;
|
|
108
121
|
} catch (error) {
|
|
109
122
|
if ((error as NodeJS.ErrnoException).code !== "EADDRINUSE") throw error;
|
|
@@ -116,7 +129,7 @@ export async function runProjectDaemon(project: Project, unattended = false): Pr
|
|
|
116
129
|
process.on("SIGINT", stop);
|
|
117
130
|
process.on("SIGTERM", stop);
|
|
118
131
|
try { await daemon.stopped; }
|
|
119
|
-
finally { process.off("SIGINT", stop); process.off("SIGTERM", stop); }
|
|
132
|
+
finally { if (forcedExit) clearTimeout(forcedExit); process.off("SIGINT", stop); process.off("SIGTERM", stop); }
|
|
120
133
|
} finally {
|
|
121
134
|
if (claimed) registry.release(project.id, instanceId);
|
|
122
135
|
registry.close();
|
package/src/hub/manager.ts
CHANGED
|
@@ -16,7 +16,7 @@ export type Lifecycle = {
|
|
|
16
16
|
startProject(project: Project, options?: { unattended?: boolean; env?: NodeJS.ProcessEnv }): Promise<any>;
|
|
17
17
|
stopProject(project: Project, expectedInstance?: string): Promise<void>;
|
|
18
18
|
};
|
|
19
|
-
export type ManagerOptions = { registry?: Registry; lifecycle?: Lifecycle; home?: string; cli?: string };
|
|
19
|
+
export type ManagerOptions = { registry?: Registry; lifecycle?: Lifecycle; home?: string; cli?: string; orphanWatchMs?: number };
|
|
20
20
|
type ManagerHandle = { stop(): Promise<void>; stopped: Promise<void> };
|
|
21
21
|
type Manifest = { port: number; protocol: number; instanceId: string; pid: number };
|
|
22
22
|
const active = new Map<string, { handle: ManagerHandle; issue(): string }>();
|
|
@@ -87,15 +87,20 @@ export async function startManager(options: ManagerOptions = {}): Promise<Manage
|
|
|
87
87
|
let server: ReturnType<typeof Bun.serve> | undefined;
|
|
88
88
|
let dashboard: ReturnType<typeof startDashboard> | undefined;
|
|
89
89
|
let stopping = false;
|
|
90
|
+
let orphanWatch: ReturnType<typeof setInterval> | undefined;
|
|
90
91
|
let resolveStopped!: () => void;
|
|
91
92
|
const stopped = new Promise<void>((resolve) => { resolveStopped = resolve; });
|
|
92
93
|
const cleanup = () => {
|
|
94
|
+
if (orphanWatch) clearInterval(orphanWatch);
|
|
93
95
|
dashboard?.stop(); server?.stop(true);
|
|
94
96
|
active.delete(home);
|
|
95
97
|
removeManifest(home, instanceId);
|
|
96
98
|
if (owner) {
|
|
97
|
-
|
|
98
|
-
|
|
99
|
+
// The state dir may already be gone (the orphan watchdog path, issue #56): a DELETE on an
|
|
100
|
+
// unlinked database fails with SQLITE_IOERR_VNODE on macOS, and the claim went with the file.
|
|
101
|
+
try { owner.query("DELETE FROM owner WHERE slot = 1 AND instance_id = ? AND pid = ?").run(instanceId, process.pid); } catch { /* claim file already gone */ }
|
|
102
|
+
try { owner.close(); } catch { /* already unlinked */ }
|
|
103
|
+
owner = undefined;
|
|
99
104
|
}
|
|
100
105
|
if (ownsRegistry) registry.close();
|
|
101
106
|
};
|
|
@@ -169,6 +174,13 @@ export async function startManager(options: ManagerOptions = {}): Promise<Manage
|
|
|
169
174
|
const temp = `${f.status}.${instanceId}.tmp`;
|
|
170
175
|
writeFileSync(temp, JSON.stringify({ port: server.port, protocol: PROTOCOL, instanceId, pid: process.pid }));
|
|
171
176
|
renameSync(temp, f.status);
|
|
177
|
+
// The manager outlives whoever opened it; a deleted AGENTHUB_HOME (a test suite's temp
|
|
178
|
+
// home, issue #56) must not leave it serving a registry that no longer exists.
|
|
179
|
+
orphanWatch = setInterval(() => {
|
|
180
|
+
if (stopping || existsSync(f.dir)) return;
|
|
181
|
+
void stop();
|
|
182
|
+
}, options.orphanWatchMs ?? 10_000);
|
|
183
|
+
orphanWatch.unref?.();
|
|
172
184
|
const handle = { stop, stopped };
|
|
173
185
|
active.set(home, { handle, issue: () => dashboard!.issue() });
|
|
174
186
|
return handle;
|
package/src/models/mlx.ts
CHANGED
|
@@ -9,8 +9,11 @@ import { Database } from "bun:sqlite";
|
|
|
9
9
|
export type MlxState = "disabled" | "starting" | "ready" | "stopped" | "foreign" | "error";
|
|
10
10
|
|
|
11
11
|
export interface MlxOptions {
|
|
12
|
+
provider?: "legacy" | "ollama";
|
|
12
13
|
runtimeDir?: string;
|
|
13
14
|
modelPath?: string;
|
|
15
|
+
model?: string;
|
|
16
|
+
sourceModel?: string;
|
|
14
17
|
bin?: string;
|
|
15
18
|
host?: string;
|
|
16
19
|
port?: number;
|
|
@@ -21,6 +24,7 @@ export interface MlxOptions {
|
|
|
21
24
|
spawn?: typeof spawn;
|
|
22
25
|
health?: (url: string, signal: AbortSignal) => Promise<boolean>;
|
|
23
26
|
processInfo?: (pid: number) => ProcessSignature | undefined;
|
|
27
|
+
contextWindow?: number;
|
|
24
28
|
}
|
|
25
29
|
|
|
26
30
|
export interface MlxStatus {
|
|
@@ -32,6 +36,12 @@ export interface MlxStatus {
|
|
|
32
36
|
maxConcurrency: number;
|
|
33
37
|
active: number;
|
|
34
38
|
lastError?: string;
|
|
39
|
+
provider?: "legacy" | "ollama";
|
|
40
|
+
modelAvailable?: boolean;
|
|
41
|
+
modelResident?: boolean;
|
|
42
|
+
expiresAt?: string | null;
|
|
43
|
+
contextWindow?: number;
|
|
44
|
+
maxTokens?: number;
|
|
35
45
|
}
|
|
36
46
|
|
|
37
47
|
export interface MlxHandle {
|
|
@@ -54,7 +64,7 @@ interface OwnerRecord {
|
|
|
54
64
|
processStart: string;
|
|
55
65
|
}
|
|
56
66
|
|
|
57
|
-
interface ProcessSignature {
|
|
67
|
+
export interface ProcessSignature {
|
|
58
68
|
command: string;
|
|
59
69
|
start: string;
|
|
60
70
|
}
|
|
@@ -227,7 +237,7 @@ function claimGenerationSlot(db: Database, slot: number, readInfo: (pid: number)
|
|
|
227
237
|
};
|
|
228
238
|
}
|
|
229
239
|
|
|
230
|
-
async function acquireGeneration(runtimeDir: string, maxConcurrency: number, signal?: AbortSignal, readInfo: (pid: number) => ProcessSignature | undefined = processInfo): Promise<() => void> {
|
|
240
|
+
export async function acquireGeneration(runtimeDir: string, maxConcurrency: number, signal?: AbortSignal, readInfo: (pid: number) => ProcessSignature | undefined = processInfo): Promise<() => void> {
|
|
231
241
|
if (!Number.isInteger(maxConcurrency) || maxConcurrency < 1) throw new Error("MLX maxConcurrency must be a positive integer");
|
|
232
242
|
if (signal?.aborted) throw new Error("MLX generation acquisition cancelled");
|
|
233
243
|
const deadline = Date.now() + 120_000;
|
|
@@ -304,6 +314,10 @@ function argsFor(options: Required<Pick<MlxOptions, "modelPath" | "host" | "port
|
|
|
304
314
|
}
|
|
305
315
|
|
|
306
316
|
export async function inspectMlx(options: MlxOptions = {}): Promise<MlxStatus> {
|
|
317
|
+
if (options.provider === "ollama") {
|
|
318
|
+
const { inspectOllama } = await import("./ollama.ts");
|
|
319
|
+
return inspectOllama(options);
|
|
320
|
+
}
|
|
307
321
|
const runtimeDir = options.runtimeDir ?? DEFAULT_RUNTIME_DIR;
|
|
308
322
|
const modelPath = options.modelPath ?? DEFAULT_MODEL_PATH;
|
|
309
323
|
const host = options.host ?? "127.0.0.1";
|
|
@@ -323,6 +337,10 @@ export async function inspectMlx(options: MlxOptions = {}): Promise<MlxStatus> {
|
|
|
323
337
|
}
|
|
324
338
|
|
|
325
339
|
export async function ensureMlx(options: MlxOptions = {}): Promise<MlxHandle> {
|
|
340
|
+
if (options.provider === "ollama") {
|
|
341
|
+
const { ensureOllama } = await import("./ollama.ts");
|
|
342
|
+
return ensureOllama(options);
|
|
343
|
+
}
|
|
326
344
|
const runtimeDir = options.runtimeDir ?? DEFAULT_RUNTIME_DIR;
|
|
327
345
|
const modelPath = options.modelPath ?? DEFAULT_MODEL_PATH;
|
|
328
346
|
const bin = options.bin ?? join(runtimeDir, "bin", "mlx_lm.server");
|
|
@@ -405,6 +423,10 @@ export async function ensureMlx(options: MlxOptions = {}): Promise<MlxHandle> {
|
|
|
405
423
|
}
|
|
406
424
|
|
|
407
425
|
export async function stopMlx(options: MlxOptions = {}): Promise<void> {
|
|
426
|
+
if (options.provider === "ollama") {
|
|
427
|
+
const { stopOllama } = await import("./ollama.ts");
|
|
428
|
+
return stopOllama(options);
|
|
429
|
+
}
|
|
408
430
|
const runtimeDir = options.runtimeDir ?? DEFAULT_RUNTIME_DIR;
|
|
409
431
|
const owner = readOwner(runtimeDir);
|
|
410
432
|
if (!owner) return;
|