@staix/agent-hub 0.7.1 → 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/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 CLAUDE.md / AGENTS.md marker blocks
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 manage the pinned local MLX runtime
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) => `wrote ${p}`).join("\n") : "already up to date");
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
+ }
@@ -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
- const readback = await rpc(project, { t: "status" }, sourceProtocol);
52
- status = readback.status;
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
- /** Wait until a hold has fenced condensation, steering and queue drains already in flight. */
198
- async fenceRecovery(): Promise<void> {
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
- while (this.draining.size || this.steering || this.condensing) await Bun.sleep(0);
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: 16_000, maxTokens: 2048 },
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
- const mlx = { ...DEFAULT_CONFIG.mlx, ...file.mlx };
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
- const log = (line: string) => appendFileSync(logFile, `${new Date().toISOString()} ${line}\n`);
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
- const file = join(opts.stateDir, "status.json"); // clients parse this on every connect: replace it atomically
520
- writeFileSync(`${file}.${instanceId}.tmp`, `${JSON.stringify(status(), null, 2)}\n`);
521
- renameSync(`${file}.${instanceId}.tmp`, file);
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
- const current = JSON.parse(readFileSync(join(opts.stateDir, "status.json"), "utf8"));
1242
- if (current.instanceId === instanceId) for (const f of ["hub.pid", "status.json", "control-token"]) rmSync(join(opts.stateDir, f), { force: true });
1243
- } catch { /* another owner or no published state: never remove it */ }
1244
- onStop?.();
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);
@@ -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();
@@ -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
- owner.query("DELETE FROM owner WHERE slot = 1 AND instance_id = ? AND pid = ?").run(instanceId, process.pid);
98
- owner.close(); owner = undefined;
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;