@otto-code/brain 0.8.1 → 0.8.3

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.
@@ -23,10 +23,12 @@ import * as results from "../ops/results.js";
23
23
  import { createCpuSampler, sample as sampleSystem } from "../sysmon.js";
24
24
  import { createHostApi } from "./host-api.js";
25
25
  import { createRouter, Telemetry } from "./router.js";
26
+ import { BrainStatusPublisher } from "./status-events.js";
26
27
  import { Supervisor } from "./supervisor.js";
27
28
  import * as tailscale from "./tailscale.js";
28
29
  import { CertManager, resolveTlsOptions } from "./tls.js";
29
30
  import { removePidFile, writePidFile } from "./pid-lock.js";
31
+ import { createBrainRunLog } from "./run-log.js";
30
32
  /** The effective config with secrets masked, for the `/__host/config` read. */
31
33
  function redactConfig(config) {
32
34
  return {
@@ -93,14 +95,15 @@ function withAuth(inner, token) {
93
95
  };
94
96
  }
95
97
  export async function startService({ config, modelNeedle, env = process.env, onLog = () => { }, }) {
98
+ const runLog = createBrainRunLog(env);
99
+ const log = (line) => {
100
+ runLog.write(line);
101
+ onLog(line);
102
+ };
103
+ // The management API must be useful before any setup exists: the Brain page
104
+ // is where the owner downloads both a runtime and their first model. Keep the
105
+ // listener up without either; loadModel reports the missing prerequisite.
96
106
  const runtime = resolveRuntime(config, env);
97
- if (!runtime) {
98
- throw new CommandError({
99
- code: "NO_RUNTIME",
100
- message: "no llama.cpp runtime available",
101
- details: "run `otto brain runtime install` to download one, or install LM Studio",
102
- });
103
- }
104
107
  const paths = resolveBrainPaths(env);
105
108
  const tlsOptions = await resolveTlsOptions(config, paths);
106
109
  // `listen.host: "tailscale"` binds the tailnet interface only (invisible to the
@@ -138,10 +141,22 @@ export async function startService({ config, modelNeedle, env = process.env, onL
138
141
  // this, and every reader goes through a getter so nobody holds a stale array.
139
142
  let catalog = scanModels(config, env);
140
143
  const needle = modelNeedle ?? config.defaultModel ?? store.lastModelId ?? undefined;
141
- const model = needle ? pickModel(catalog, needle) : pickAutoModel(catalog);
142
- let profile = forModel(store, model, config.defaults);
144
+ let model = null;
145
+ if (catalog.length > 0) {
146
+ try {
147
+ model = needle ? pickModel(catalog, needle) : pickAutoModel(catalog);
148
+ }
149
+ catch (error) {
150
+ // A removed default or last-used model must not take the management
151
+ // service down. An explicit CLI selection remains an actionable error.
152
+ if (modelNeedle)
153
+ throw error;
154
+ log(`note: ${error instanceof Error ? error.message : "configured model is unavailable"}`);
155
+ }
156
+ }
157
+ let profile = model ? forModel(store, model, config.defaults) : null;
143
158
  const gpu = await queryGpu();
144
- if (gpu) {
159
+ if (gpu && model && profile) {
145
160
  const fit = vram.fitToBudget({
146
161
  model,
147
162
  profile,
@@ -149,28 +164,39 @@ export async function startService({ config, modelNeedle, env = process.env, onL
149
164
  totalVramBytes: gpu.totalBytes,
150
165
  });
151
166
  if (!fit.adjusted && !fit.budget.fits) {
152
- throw new CommandError({
153
- code: "DOES_NOT_FIT",
154
- message: `refusing to start: ${fit.reason}`,
155
- details: "use a smaller quant, or run `otto brain calibrate` for a measured budget",
156
- });
167
+ // Starting the host is what exposes the Library and model profile UI.
168
+ // An automatic startup candidate that cannot load must therefore leave
169
+ // the host alive and unloaded, not make the only recovery surface vanish.
170
+ log(`note: not loading ${model.displayName}: ${fit.reason ?? "does not fit in available VRAM"}`);
171
+ model = null;
172
+ profile = null;
173
+ }
174
+ else {
175
+ if (fit.adjusted && fit.reason)
176
+ log(`note: ${fit.reason}`);
177
+ profile = fit.profile;
157
178
  }
158
- if (fit.adjusted && fit.reason)
159
- onLog(`note: ${fit.reason}`);
160
- profile = fit.profile;
161
179
  }
162
180
  const telemetry = new Telemetry();
163
181
  const supervisor = new Supervisor({ runtime });
164
182
  supervisor.on("log", (line) => {
183
+ runLog.write(line);
165
184
  if (/error|failed|warn/i.test(line))
166
185
  onLog(line);
167
186
  });
187
+ supervisor.on("crashed", (error) => runLog.write(`FATAL ${error}`));
168
188
  // Serialize model switches: the router queues request-driven switches, but the
169
189
  // config path (POST /__host/config) calls loadModel directly. Chaining here
170
190
  // guarantees two switches (e.g. a config write racing a request-driven switch)
171
191
  // can never overlap two supervisor.start() calls, whichever caller triggers them.
172
192
  let modelSwitchChain = Promise.resolve();
173
193
  const loadModelUnsafe = async (target) => {
194
+ // A runtime can be installed from the Library tab after this service starts.
195
+ // Resolve it at load time so the user does not have to restart the brain.
196
+ supervisor.runtime = resolveRuntime(config, env);
197
+ if (!supervisor.runtime) {
198
+ throw new Error("no llama.cpp runtime available; install one from the Library tab");
199
+ }
174
200
  const gpuInfo = await queryGpu();
175
201
  let fitProfile = forModel(store, target, config.defaults);
176
202
  if (gpuInfo) {
@@ -232,6 +258,11 @@ export async function startService({ config, modelNeedle, env = process.env, onL
232
258
  // One CPU sampler for the lifetime of the service: it reports a busy fraction
233
259
  // between successive calls, so a fresh one per request would always return null.
234
260
  const cpuSampler = createCpuSampler();
261
+ // Constructed here, ahead of both consumers, because the router installs the
262
+ // snapshot builder into it and the management API serves it at
263
+ // /__host/events. One instance, so `capabilities.events` and the stream can
264
+ // never disagree about whether this brain publishes.
265
+ const statusEvents = new BrainStatusPublisher();
235
266
  const hostApi = createHostApi({
236
267
  supervisor,
237
268
  getCatalog: () => catalog,
@@ -258,11 +289,12 @@ export async function startService({ config, modelNeedle, env = process.env, onL
258
289
  getAllowWrite: allowWrite,
259
290
  getModelsDir: () => managedModelsDir(config, env),
260
291
  sampleResources: () => sampleSystem(cpuSampler, { host: supervisor.host, port: supervisor.internalPort }),
292
+ statusEvents,
261
293
  });
262
294
  const handler = withAuth(createRouter({
263
295
  supervisor,
264
296
  telemetry,
265
- logger: { warn: (m) => onLog(`WARN ${m}`) },
297
+ logger: { warn: (m) => log(`WARN ${m}`) },
266
298
  getCatalog: () => catalog,
267
299
  loadModel,
268
300
  version: resolveVersion(),
@@ -273,7 +305,10 @@ export async function startService({ config, modelNeedle, env = process.env, onL
273
305
  applyConfigPatch,
274
306
  getAllowConfigWrite: allowWrite,
275
307
  hostApi,
276
- getResources: () => sampleSystem(cpuSampler, { host: supervisor.host, port: supervisor.internalPort }),
308
+ // `buildCheapStatus` already samples `/slots`. Do not hit it a second
309
+ // time in parallel: the shared rate tracker needs one ordered timeline.
310
+ getResources: () => sampleSystem(cpuSampler),
311
+ statusEvents,
277
312
  }), authToken);
278
313
  // TLS terminates in-process when configured; otherwise plain HTTP. The cert
279
314
  // manager issues/generates the first keypair before we listen, and hot-swaps
@@ -300,9 +335,17 @@ export async function startService({ config, modelNeedle, env = process.env, onL
300
335
  server.listen(port, bindHost, resolve);
301
336
  });
302
337
  certManager?.start();
303
- await supervisor.start(model, profile);
304
- store.lastModelId = model.id;
305
- saveProfilesStore(store, paths);
338
+ if (model && profile && runtime) {
339
+ await supervisor.start(model, profile);
340
+ store.lastModelId = model.id;
341
+ saveProfilesStore(store, paths);
342
+ }
343
+ else if (!runtime) {
344
+ log("ready: no llama.cpp runtime installed; use the Library tab to download one");
345
+ }
346
+ else {
347
+ log("ready: no model installed; use the Library tab to download one");
348
+ }
306
349
  writePidFile({
307
350
  pid: process.pid,
308
351
  host: bindHost,
@@ -311,9 +354,15 @@ export async function startService({ config, modelNeedle, env = process.env, onL
311
354
  secure: Boolean(tlsOptions),
312
355
  displayHost,
313
356
  }, env);
357
+ log(`ready: ${supervisor.model?.displayName ?? "no model loaded"} on ${bindHost}:${port}; run log ${runLog.path}`);
314
358
  const stop = async () => {
315
359
  certManager?.stop();
360
+ log("Brain service stopping");
361
+ // Before server.close(), which waits on open connections: a subscribed
362
+ // daemon holds an SSE response open indefinitely by design.
363
+ statusEvents.close();
316
364
  await supervisor.stop();
365
+ server.closeIdleConnections?.();
317
366
  await new Promise((resolve) => server.close(() => resolve()));
318
367
  removePidFile(env);
319
368
  };
@@ -322,7 +371,7 @@ export async function startService({ config, modelNeedle, env = process.env, onL
322
371
  supervisor,
323
372
  host: bindHost,
324
373
  port,
325
- model,
374
+ model: supervisor.model,
326
375
  secure: Boolean(tlsOptions),
327
376
  displayHost,
328
377
  stop,
@@ -0,0 +1,88 @@
1
+ /**
2
+ * The brain's authoritative status event source.
3
+ *
4
+ * The brain owns its own state, so it publishes it rather than being polled for
5
+ * it. One daemon subscribes over SSE (`GET /__host/events`) and fans the result
6
+ * out to every connected Otto client, replacing a per-client status poll that
7
+ * could never show a transition sooner than its own interval.
8
+ *
9
+ * Three properties this has to keep:
10
+ *
11
+ * - **Complete snapshots, never deltas.** A reader that missed an event, or
12
+ * reconnected after one, must not have to reconstruct anything: the newest
13
+ * snapshot is the whole truth. That is what makes reconnect repair on both
14
+ * sides idempotent.
15
+ * - **Coalescing lives here, not in the reader.** A timer tick that finds
16
+ * nothing changed emits nothing, and a completion's traffic counters are not
17
+ * a state change. Otherwise "push" would just be a poll with extra steps, and
18
+ * a busy brain would broadcast to every client for every token.
19
+ * - **No work while nobody is listening.** The sampler only runs while a
20
+ * subscriber is attached, so a brain the daemon never subscribed to (an older
21
+ * daemon, or one that is not running) pays nothing.
22
+ *
23
+ * The snapshot itself is deliberately the *cheap* status: no `resources`, whose
24
+ * collection spawns `nvidia-smi`. That stays an opt-in pull for the Overview tab.
25
+ */
26
+ /** A complete cheap `/__host/status` body. Opaque here; the router assembles it. */
27
+ export type BrainStatusSnapshot = Record<string, unknown>;
28
+ export type BrainStatusListener = (snapshot: BrainStatusSnapshot) => void;
29
+ /**
30
+ * The fields whose change is worth waking every connected client for.
31
+ *
32
+ * Everything omitted here still rides in the payload; it just cannot *trigger*
33
+ * a payload on its own. The omissions are the churning ones:
34
+ *
35
+ * - `telemetry` totals and `recent` advance on every completion.
36
+ * - `logLineCount` advances on every llama-server log line.
37
+ * - `slots.contexts` is capacity detail that changes independently of work.
38
+ *
39
+ * `slots.threads` is intentionally included. It is sampled at a bounded 4 Hz,
40
+ * not notified per model token, so it gives the Overview live counts and rates
41
+ * without turning a fast model into a token-rate broadcast.
42
+ *
43
+ * `telemetry.warning` is kept: the reasoning-only advice is a state the UI
44
+ * shows, not a counter.
45
+ */
46
+ export declare function statusChangeKey(snapshot: BrainStatusSnapshot): string;
47
+ export interface BrainStatusPublisherOptions {
48
+ /** Resample cadence while subscribed. See DEFAULT_SAMPLE_INTERVAL_MS. */
49
+ sampleIntervalMs?: number;
50
+ }
51
+ /**
52
+ * Owns the current snapshot and decides when it changed.
53
+ *
54
+ * Constructed before the router (so `serve.ts` can hand the same instance to
55
+ * both the router and the management API) and inert until the router installs
56
+ * the snapshot source with {@link setSource}. `/__host/capabilities` advertises
57
+ * `events` only once it is {@link ready}, so a brain can never claim a stream it
58
+ * cannot actually serve.
59
+ */
60
+ export declare class BrainStatusPublisher {
61
+ #private;
62
+ constructor(options?: BrainStatusPublisherOptions);
63
+ /** Whether a snapshot source has been installed - i.e. events can be served. */
64
+ get ready(): boolean;
65
+ get listenerCount(): number;
66
+ /** Install the snapshot builder. Called once, by the router. */
67
+ setSource(source: () => Promise<BrainStatusSnapshot>): void;
68
+ /**
69
+ * Attach a listener. It receives the current snapshot immediately when one is
70
+ * known, and a fresh sample is taken right away so a subscriber that arrives
71
+ * during a transition is not handed a stale first frame.
72
+ */
73
+ subscribe(listener: BrainStatusListener, onClose?: () => void): () => void;
74
+ /**
75
+ * Something authoritative changed - resample now rather than at the next tick.
76
+ * Cheap to over-call: an unchanged snapshot emits nothing.
77
+ */
78
+ notify(): void;
79
+ /**
80
+ * Drop every listener and end the streams behind them.
81
+ *
82
+ * Ending them is not optional at shutdown: an open SSE response is an open
83
+ * connection, and `server.close()` waits for those, so a brain that only
84
+ * unsubscribed would hang on stop until its daemon happened to disconnect.
85
+ */
86
+ close(): void;
87
+ }
88
+ //# sourceMappingURL=status-events.d.ts.map
@@ -0,0 +1,256 @@
1
+ /**
2
+ * The brain's authoritative status event source.
3
+ *
4
+ * The brain owns its own state, so it publishes it rather than being polled for
5
+ * it. One daemon subscribes over SSE (`GET /__host/events`) and fans the result
6
+ * out to every connected Otto client, replacing a per-client status poll that
7
+ * could never show a transition sooner than its own interval.
8
+ *
9
+ * Three properties this has to keep:
10
+ *
11
+ * - **Complete snapshots, never deltas.** A reader that missed an event, or
12
+ * reconnected after one, must not have to reconstruct anything: the newest
13
+ * snapshot is the whole truth. That is what makes reconnect repair on both
14
+ * sides idempotent.
15
+ * - **Coalescing lives here, not in the reader.** A timer tick that finds
16
+ * nothing changed emits nothing, and a completion's traffic counters are not
17
+ * a state change. Otherwise "push" would just be a poll with extra steps, and
18
+ * a busy brain would broadcast to every client for every token.
19
+ * - **No work while nobody is listening.** The sampler only runs while a
20
+ * subscriber is attached, so a brain the daemon never subscribed to (an older
21
+ * daemon, or one that is not running) pays nothing.
22
+ *
23
+ * The snapshot itself is deliberately the *cheap* status: no `resources`, whose
24
+ * collection spawns `nvidia-smi`. That stays an opt-in pull for the Overview tab.
25
+ */
26
+ var __classPrivateFieldSet = (this && this.__classPrivateFieldSet) || function (receiver, state, value, kind, f) {
27
+ if (kind === "m") throw new TypeError("Private method is not writable");
28
+ if (kind === "a" && !f) throw new TypeError("Private accessor was defined without a setter");
29
+ if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot write private member to an object whose class did not declare it");
30
+ return (kind === "a" ? f.call(receiver, value) : f ? f.value = value : state.set(receiver, value)), value;
31
+ };
32
+ var __classPrivateFieldGet = (this && this.__classPrivateFieldGet) || function (receiver, state, kind, f) {
33
+ if (kind === "a" && !f) throw new TypeError("Private accessor was defined without a getter");
34
+ if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot read private member from an object whose class did not declare it");
35
+ return kind === "m" ? f : kind === "a" ? f.call(receiver) : f ? f.value : state.get(receiver);
36
+ };
37
+ var _BrainStatusPublisher_instances, _BrainStatusPublisher_listeners, _BrainStatusPublisher_closers, _BrainStatusPublisher_sampleIntervalMs, _BrainStatusPublisher_source, _BrainStatusPublisher_timer, _BrainStatusPublisher_last, _BrainStatusPublisher_lastKey, _BrainStatusPublisher_sampling, _BrainStatusPublisher_resample, _BrainStatusPublisher_closed, _BrainStatusPublisher_sample, _BrainStatusPublisher_startTimer, _BrainStatusPublisher_stopTimer;
38
+ /**
39
+ * How often the publisher resamples while at least one listener is attached.
40
+ *
41
+ * A sample is needed at all because two inputs have no event to hang off:
42
+ * `activity` is a file written by a *different* process (a `calibrate` or
43
+ * `pull` CLI run), and the slot phase split is a loopback read of
44
+ * llama-server's `/slots`. Everything else notifies directly. Sampling is not
45
+ * a heartbeat: an unchanged sample emits nothing.
46
+ */
47
+ const DEFAULT_SAMPLE_INTERVAL_MS = 250;
48
+ function isRecord(value) {
49
+ return typeof value === "object" && value !== null && !Array.isArray(value);
50
+ }
51
+ /**
52
+ * The fields whose change is worth waking every connected client for.
53
+ *
54
+ * Everything omitted here still rides in the payload; it just cannot *trigger*
55
+ * a payload on its own. The omissions are the churning ones:
56
+ *
57
+ * - `telemetry` totals and `recent` advance on every completion.
58
+ * - `logLineCount` advances on every llama-server log line.
59
+ * - `slots.contexts` is capacity detail that changes independently of work.
60
+ *
61
+ * `slots.threads` is intentionally included. It is sampled at a bounded 4 Hz,
62
+ * not notified per model token, so it gives the Overview live counts and rates
63
+ * without turning a fast model into a token-rate broadcast.
64
+ *
65
+ * `telemetry.warning` is kept: the reasoning-only advice is a state the UI
66
+ * shows, not a counter.
67
+ */
68
+ export function statusChangeKey(snapshot) {
69
+ const scheduler = isRecord(snapshot.scheduler) ? snapshot.scheduler : null;
70
+ const telemetry = isRecord(snapshot.telemetry) ? snapshot.telemetry : null;
71
+ const slots = isRecord(snapshot.slots) ? snapshot.slots : null;
72
+ return JSON.stringify({
73
+ version: snapshot.version ?? null,
74
+ apiVersion: snapshot.apiVersion ?? null,
75
+ state: snapshot.state ?? null,
76
+ model: snapshot.model ?? null,
77
+ modelId: snapshot.modelId ?? null,
78
+ pid: snapshot.pid ?? null,
79
+ vramBytes: snapshot.vramBytes ?? null,
80
+ loadSeconds: snapshot.loadSeconds ?? null,
81
+ startedAt: snapshot.startedAt ?? null,
82
+ lastError: snapshot.lastError ?? null,
83
+ upstream: snapshot.upstream ?? null,
84
+ runtime: snapshot.runtime ?? null,
85
+ capabilities: snapshot.capabilities ?? null,
86
+ activity: snapshot.activity ?? null,
87
+ reasoning: snapshot.reasoning ?? null,
88
+ inference: snapshot.inference ?? null,
89
+ queued: snapshot.queued ?? null,
90
+ schedulerWaiting: scheduler?.waiting ?? null,
91
+ schedulerLastTurn: scheduler?.lastTurn ?? null,
92
+ warning: telemetry?.warning ?? null,
93
+ slots: slots
94
+ ? {
95
+ total: slots.total ?? null,
96
+ busy: slots.busy ?? null,
97
+ idle: slots.idle ?? null,
98
+ prefill: slots.prefill ?? null,
99
+ decode: slots.decode ?? null,
100
+ threads: slots.threads ?? null,
101
+ }
102
+ : null,
103
+ });
104
+ }
105
+ /**
106
+ * Owns the current snapshot and decides when it changed.
107
+ *
108
+ * Constructed before the router (so `serve.ts` can hand the same instance to
109
+ * both the router and the management API) and inert until the router installs
110
+ * the snapshot source with {@link setSource}. `/__host/capabilities` advertises
111
+ * `events` only once it is {@link ready}, so a brain can never claim a stream it
112
+ * cannot actually serve.
113
+ */
114
+ export class BrainStatusPublisher {
115
+ constructor(options = {}) {
116
+ _BrainStatusPublisher_instances.add(this);
117
+ _BrainStatusPublisher_listeners.set(this, new Set());
118
+ /** Per-subscription teardown, so `close()` can end the responses it feeds. */
119
+ _BrainStatusPublisher_closers.set(this, new Set());
120
+ _BrainStatusPublisher_sampleIntervalMs.set(this, void 0);
121
+ _BrainStatusPublisher_source.set(this, null);
122
+ _BrainStatusPublisher_timer.set(this, null);
123
+ /** The last snapshot that was actually emitted, replayed to a late subscriber. */
124
+ _BrainStatusPublisher_last.set(this, null);
125
+ _BrainStatusPublisher_lastKey.set(this, null);
126
+ /** True while a sample is in flight, so overlapping triggers collapse into one. */
127
+ _BrainStatusPublisher_sampling.set(this, false);
128
+ /** A trigger that arrived mid-sample; runs exactly one more sample afterwards. */
129
+ _BrainStatusPublisher_resample.set(this, false);
130
+ _BrainStatusPublisher_closed.set(this, false);
131
+ __classPrivateFieldSet(this, _BrainStatusPublisher_sampleIntervalMs, options.sampleIntervalMs ?? DEFAULT_SAMPLE_INTERVAL_MS, "f");
132
+ }
133
+ /** Whether a snapshot source has been installed - i.e. events can be served. */
134
+ get ready() {
135
+ return __classPrivateFieldGet(this, _BrainStatusPublisher_source, "f") !== null && !__classPrivateFieldGet(this, _BrainStatusPublisher_closed, "f");
136
+ }
137
+ get listenerCount() {
138
+ return __classPrivateFieldGet(this, _BrainStatusPublisher_listeners, "f").size;
139
+ }
140
+ /** Install the snapshot builder. Called once, by the router. */
141
+ setSource(source) {
142
+ __classPrivateFieldSet(this, _BrainStatusPublisher_source, source, "f");
143
+ }
144
+ /**
145
+ * Attach a listener. It receives the current snapshot immediately when one is
146
+ * known, and a fresh sample is taken right away so a subscriber that arrives
147
+ * during a transition is not handed a stale first frame.
148
+ */
149
+ subscribe(listener, onClose) {
150
+ if (__classPrivateFieldGet(this, _BrainStatusPublisher_closed, "f")) {
151
+ onClose?.();
152
+ return () => { };
153
+ }
154
+ __classPrivateFieldGet(this, _BrainStatusPublisher_listeners, "f").add(listener);
155
+ if (onClose)
156
+ __classPrivateFieldGet(this, _BrainStatusPublisher_closers, "f").add(onClose);
157
+ if (__classPrivateFieldGet(this, _BrainStatusPublisher_last, "f"))
158
+ listener(__classPrivateFieldGet(this, _BrainStatusPublisher_last, "f"));
159
+ __classPrivateFieldGet(this, _BrainStatusPublisher_instances, "m", _BrainStatusPublisher_startTimer).call(this);
160
+ this.notify();
161
+ return () => {
162
+ __classPrivateFieldGet(this, _BrainStatusPublisher_listeners, "f").delete(listener);
163
+ if (onClose)
164
+ __classPrivateFieldGet(this, _BrainStatusPublisher_closers, "f").delete(onClose);
165
+ if (__classPrivateFieldGet(this, _BrainStatusPublisher_listeners, "f").size === 0)
166
+ __classPrivateFieldGet(this, _BrainStatusPublisher_instances, "m", _BrainStatusPublisher_stopTimer).call(this);
167
+ };
168
+ }
169
+ /**
170
+ * Something authoritative changed - resample now rather than at the next tick.
171
+ * Cheap to over-call: an unchanged snapshot emits nothing.
172
+ */
173
+ notify() {
174
+ if (__classPrivateFieldGet(this, _BrainStatusPublisher_closed, "f") || __classPrivateFieldGet(this, _BrainStatusPublisher_listeners, "f").size === 0)
175
+ return;
176
+ void __classPrivateFieldGet(this, _BrainStatusPublisher_instances, "m", _BrainStatusPublisher_sample).call(this);
177
+ }
178
+ /**
179
+ * Drop every listener and end the streams behind them.
180
+ *
181
+ * Ending them is not optional at shutdown: an open SSE response is an open
182
+ * connection, and `server.close()` waits for those, so a brain that only
183
+ * unsubscribed would hang on stop until its daemon happened to disconnect.
184
+ */
185
+ close() {
186
+ if (__classPrivateFieldGet(this, _BrainStatusPublisher_closed, "f"))
187
+ return;
188
+ __classPrivateFieldSet(this, _BrainStatusPublisher_closed, true, "f");
189
+ __classPrivateFieldGet(this, _BrainStatusPublisher_instances, "m", _BrainStatusPublisher_stopTimer).call(this);
190
+ __classPrivateFieldGet(this, _BrainStatusPublisher_listeners, "f").clear();
191
+ const closers = [...__classPrivateFieldGet(this, _BrainStatusPublisher_closers, "f")];
192
+ __classPrivateFieldGet(this, _BrainStatusPublisher_closers, "f").clear();
193
+ for (const onClose of closers) {
194
+ try {
195
+ onClose();
196
+ }
197
+ catch {
198
+ // Shutdown must not be blocked by one stream failing to end.
199
+ }
200
+ }
201
+ }
202
+ }
203
+ _BrainStatusPublisher_listeners = new WeakMap(), _BrainStatusPublisher_closers = new WeakMap(), _BrainStatusPublisher_sampleIntervalMs = new WeakMap(), _BrainStatusPublisher_source = new WeakMap(), _BrainStatusPublisher_timer = new WeakMap(), _BrainStatusPublisher_last = new WeakMap(), _BrainStatusPublisher_lastKey = new WeakMap(), _BrainStatusPublisher_sampling = new WeakMap(), _BrainStatusPublisher_resample = new WeakMap(), _BrainStatusPublisher_closed = new WeakMap(), _BrainStatusPublisher_instances = new WeakSet(), _BrainStatusPublisher_sample = async function _BrainStatusPublisher_sample() {
204
+ const source = __classPrivateFieldGet(this, _BrainStatusPublisher_source, "f");
205
+ if (!source || __classPrivateFieldGet(this, _BrainStatusPublisher_closed, "f"))
206
+ return;
207
+ if (__classPrivateFieldGet(this, _BrainStatusPublisher_sampling, "f")) {
208
+ __classPrivateFieldSet(this, _BrainStatusPublisher_resample, true, "f");
209
+ return;
210
+ }
211
+ __classPrivateFieldSet(this, _BrainStatusPublisher_sampling, true, "f");
212
+ try {
213
+ const snapshot = await source();
214
+ if (__classPrivateFieldGet(this, _BrainStatusPublisher_closed, "f"))
215
+ return;
216
+ const key = statusChangeKey(snapshot);
217
+ // Always keep the newest body for replay, even when nothing significant
218
+ // changed: a late subscriber should not get last minute's counters.
219
+ __classPrivateFieldSet(this, _BrainStatusPublisher_last, snapshot, "f");
220
+ if (key === __classPrivateFieldGet(this, _BrainStatusPublisher_lastKey, "f"))
221
+ return;
222
+ __classPrivateFieldSet(this, _BrainStatusPublisher_lastKey, key, "f");
223
+ // Set iteration tolerates a listener unsubscribing mid-emit, which the
224
+ // SSE teardown does when a daemon disconnects during a snapshot.
225
+ for (const listener of __classPrivateFieldGet(this, _BrainStatusPublisher_listeners, "f")) {
226
+ try {
227
+ listener(snapshot);
228
+ }
229
+ catch {
230
+ // A broken listener is that listener's problem; the others still get it.
231
+ }
232
+ }
233
+ }
234
+ catch {
235
+ // A failed sample is not a state change. The next tick tries again.
236
+ }
237
+ finally {
238
+ __classPrivateFieldSet(this, _BrainStatusPublisher_sampling, false, "f");
239
+ if (__classPrivateFieldGet(this, _BrainStatusPublisher_resample, "f")) {
240
+ __classPrivateFieldSet(this, _BrainStatusPublisher_resample, false, "f");
241
+ void __classPrivateFieldGet(this, _BrainStatusPublisher_instances, "m", _BrainStatusPublisher_sample).call(this);
242
+ }
243
+ }
244
+ }, _BrainStatusPublisher_startTimer = function _BrainStatusPublisher_startTimer() {
245
+ if (__classPrivateFieldGet(this, _BrainStatusPublisher_timer, "f") || __classPrivateFieldGet(this, _BrainStatusPublisher_closed, "f"))
246
+ return;
247
+ __classPrivateFieldSet(this, _BrainStatusPublisher_timer, setInterval(() => this.notify(), __classPrivateFieldGet(this, _BrainStatusPublisher_sampleIntervalMs, "f")), "f");
248
+ // Never hold the process open for status reporting alone.
249
+ __classPrivateFieldGet(this, _BrainStatusPublisher_timer, "f").unref?.();
250
+ }, _BrainStatusPublisher_stopTimer = function _BrainStatusPublisher_stopTimer() {
251
+ if (!__classPrivateFieldGet(this, _BrainStatusPublisher_timer, "f"))
252
+ return;
253
+ clearInterval(__classPrivateFieldGet(this, _BrainStatusPublisher_timer, "f"));
254
+ __classPrivateFieldSet(this, _BrainStatusPublisher_timer, null, "f");
255
+ };
256
+ //# sourceMappingURL=status-events.js.map
@@ -15,7 +15,7 @@ import type { Profile } from "../config/schema.js";
15
15
  export declare const DEFAULT_INTERNAL_PORT = 20800;
16
16
  export type SupervisorState = "stopped" | "starting" | "ready" | "failed" | "stopping";
17
17
  export interface SupervisorOptions {
18
- runtime: Runtime;
18
+ runtime: Runtime | null;
19
19
  internalPort?: number;
20
20
  host?: string;
21
21
  readyTimeoutMs?: number;
@@ -40,7 +40,7 @@ export interface SupervisorStatus {
40
40
  */
41
41
  export declare class Supervisor extends EventEmitter {
42
42
  #private;
43
- runtime: Runtime;
43
+ runtime: Runtime | null;
44
44
  internalPort: number;
45
45
  host: string;
46
46
  readyTimeoutMs: number;
@@ -54,6 +54,12 @@ export class Supervisor extends EventEmitter {
54
54
  /** Start (or restart) the server for a model + profile. */
55
55
  async start(model, profile) {
56
56
  await this.stop();
57
+ if (!this.runtime) {
58
+ this.lastError = "no llama.cpp runtime available";
59
+ __classPrivateFieldGet(this, _Supervisor_instances, "m", _Supervisor_setState).call(this, "failed", this.lastError);
60
+ throw new Error(this.lastError);
61
+ }
62
+ const runtime = this.runtime;
57
63
  this.model = model;
58
64
  this.profile = profile;
59
65
  this.lastError = null;
@@ -62,12 +68,12 @@ export class Supervisor extends EventEmitter {
62
68
  __classPrivateFieldGet(this, _Supervisor_instances, "m", _Supervisor_setState).call(this, "starting");
63
69
  const args = buildArgs({ ...profile, modelPath: model.modelPath, mmprojPath: model.mmprojPath }, { port: this.internalPort, host: this.host });
64
70
  this.args = args;
65
- this.command = formatCommand(this.runtime, args);
71
+ this.command = formatCommand(runtime, args);
66
72
  __classPrivateFieldGet(this, _Supervisor_instances, "m", _Supervisor_log).call(this, `launching: ${this.command}`);
67
73
  const started = Date.now();
68
- this.child = spawn(this.runtime.exe, args, {
69
- cwd: this.runtime.dir,
70
- env: buildEnv(this.runtime),
74
+ this.child = spawn(runtime.exe, args, {
75
+ cwd: runtime.dir,
76
+ env: buildEnv(runtime),
71
77
  windowsHide: true,
72
78
  stdio: ["ignore", "pipe", "pipe"],
73
79
  });
@@ -194,7 +200,7 @@ export class Supervisor extends EventEmitter {
194
200
  startedAt: this.startedAt ? this.startedAt.toISOString() : null,
195
201
  lastError: this.lastError,
196
202
  upstream: this.upstreamBase,
197
- runtime: `${this.runtime.label} v${this.runtime.version}`,
203
+ runtime: this.runtime ? `${this.runtime.label} v${this.runtime.version}` : "not installed",
198
204
  };
199
205
  }
200
206
  }
package/dist/sysmon.d.ts CHANGED
@@ -31,6 +31,14 @@ export interface SlotInfo {
31
31
  /** Slots emitting tokens. */
32
32
  decode: number;
33
33
  contexts: number[];
34
+ threads?: Array<{
35
+ slot: number;
36
+ phase: "prefill" | "decode";
37
+ promptTokens: number | null;
38
+ generatedTokens: number;
39
+ promptTokensPerSecond: number | null;
40
+ tokensPerSecond: number | null;
41
+ }>;
34
42
  }
35
43
  /** One combined reading for the status panel. */
36
44
  export interface SystemSample {
@@ -55,6 +63,11 @@ export declare function createCpuSampler(): CpuSampler;
55
63
  * the field spellings real llama.cpp builds emit, without a live server.
56
64
  */
57
65
  export declare function summariseSlots(rows: unknown[]): SlotInfo;
66
+ /** Measures throughput from successive `/slots` snapshots, without guessing. */
67
+ export declare class SlotActivityTracker {
68
+ #private;
69
+ sample(rows: unknown[], now?: number): SlotInfo;
70
+ }
58
71
  /** Slot occupancy from the running server. */
59
72
  declare function slots({ host, port }: {
60
73
  host: string;