@jmcombs/pi-steward 0.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +140 -0
  3. package/core/disconnected-source.ts +110 -0
  4. package/core/drift.ts +247 -0
  5. package/core/format.ts +317 -0
  6. package/core/host-metrics.ts +121 -0
  7. package/core/llama-config.ts +72 -0
  8. package/core/llama-connection.ts +215 -0
  9. package/core/llama-models.ts +261 -0
  10. package/core/llama-slots.ts +104 -0
  11. package/core/llama-source.ts +1523 -0
  12. package/core/log-parse.ts +440 -0
  13. package/core/model-color.ts +59 -0
  14. package/core/select.ts +2923 -0
  15. package/core/slot-activity.ts +658 -0
  16. package/core/source.ts +84 -0
  17. package/core/state.ts +609 -0
  18. package/core/status-widget.ts +222 -0
  19. package/core/temperature.ts +149 -0
  20. package/core/types.ts +431 -0
  21. package/index.ts +503 -0
  22. package/package.json +51 -0
  23. package/server/api.ts +216 -0
  24. package/server/assets.ts +198 -0
  25. package/server/config-wiring.ts +490 -0
  26. package/server/drift-probe.ts +150 -0
  27. package/server/host-collector.ts +272 -0
  28. package/server/index.ts +228 -0
  29. package/server/log-tailer.ts +432 -0
  30. package/server/service-control.ts +337 -0
  31. package/server/service-probe.ts +71 -0
  32. package/server/steward-config.ts +430 -0
  33. package/setup/init-prompt.ts +214 -0
  34. package/setup/steward-setup.d.mts +16 -0
  35. package/setup/steward-setup.mjs +1398 -0
  36. package/ui/components/console.ts +511 -0
  37. package/ui/components/gauges.ts +120 -0
  38. package/ui/components/metrics.ts +63 -0
  39. package/ui/components/models.ts +296 -0
  40. package/ui/components/service.ts +358 -0
  41. package/ui/components/slots.ts +114 -0
  42. package/ui/components/sparkline.ts +59 -0
  43. package/ui/components/toolbar.ts +211 -0
  44. package/ui/dom.ts +120 -0
  45. package/ui/favicon.svg +17 -0
  46. package/ui/index.html +34 -0
  47. package/ui/main.ts +678 -0
  48. package/ui/steward.css +2008 -0
package/index.ts ADDED
@@ -0,0 +1,503 @@
1
+ /**
2
+ * @jmcombs/pi-steward — Steward, the llama.cpp control panel for Pi.
3
+ *
4
+ * Steward is a single-page operator dashboard for the local `llama-server`
5
+ * that backs Pi's llama.cpp provider. It answers four questions at a glance —
6
+ * is the service up, which models are resident, is the box healthy, and what
7
+ * is the server doing right now — and lets the operator act on all of them
8
+ * without a terminal.
9
+ *
10
+ * This file is the extension's entry point. Pi loads it via jiti, so
11
+ * TypeScript works without a build step. `/steward_start` brings up a loopback
12
+ * server for the session, `/steward_dashboard` opens it in a browser (starting it
13
+ * if needed), and `/steward_stop` shuts it down — as does the end of the session.
14
+ * `/steward_initialize` connects the machine in the first place. `STEWARD_PORT` chooses the port; a port that
15
+ * is already taken costs an ephemeral one, not the dashboard.
16
+ *
17
+ * See:
18
+ * - CONTRIBUTING.md (project conventions)
19
+ * - TEMPLATE.md at the repo root (how this package was scaffolded)
20
+ * - https://pi.dev/docs/extensions
21
+ */
22
+
23
+ import { spawn } from "node:child_process";
24
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
25
+ import type { ConnectionContext } from "./core/llama-connection.js";
26
+ import type { StewardDataSource } from "./core/source.js";
27
+ import type { StewardServer } from "./server/index.js";
28
+ import { buildInitPrompt, setupScriptPath } from "./setup/init-prompt.js";
29
+
30
+ /**
31
+ * The environment variable that moves the dashboard off its default port,
32
+ * matching `scripts/dev.ts`. `0` asks the OS for any free port.
33
+ */
34
+ const PORT_VARIABLE = "STEWARD_PORT";
35
+
36
+ /** The dashboard is per-session: one server, started on first use. */
37
+ let server: StewardServer | null = null;
38
+ /**
39
+ * The source that server is polling.
40
+ *
41
+ * The widget reads this rather than building its own. It used to construct a
42
+ * whole `LlamaSource` — including a `createConfigWiring()` file watcher — on
43
+ * every refresh and close it again, which is expensive enough that it could not
44
+ * be put on a timer, and which is why the widget only updated at turn
45
+ * boundaries: unload a model in the dashboard and the bar kept its old count
46
+ * until you typed something. Sharing the running source costs nothing and is
47
+ * exactly as fresh as the dashboard itself.
48
+ */
49
+ let liveSource: StewardDataSource | null = null;
50
+ /**
51
+ * Starts and stops run one at a time on this chain. Two quick `/steward_start`
52
+ * invocations then share one server rather than binding twice, and a
53
+ * `/steward_stop` or a session shutdown that lands mid-start stops the server
54
+ * that start produced instead of missing it.
55
+ */
56
+ let queue: Promise<void> = Promise.resolve();
57
+
58
+ /** Compares two URLs ignoring a trailing slash or `/v1`, which mean the same server. */
59
+ function normalizeLoose(url: string): string {
60
+ return url.trim().replace(/\/+$/u, "").replace(/\/v1$/u, "");
61
+ }
62
+
63
+ function describe(error: unknown): string {
64
+ return error instanceof Error ? error.message : String(error);
65
+ }
66
+
67
+ function enqueue<T>(step: () => Promise<T>): Promise<T> {
68
+ const next = queue.then(step);
69
+ // One failed step must not poison the chain for the next command.
70
+ queue = next.then(
71
+ () => undefined,
72
+ () => undefined,
73
+ );
74
+ return next;
75
+ }
76
+
77
+ function isAddressInUse(error: unknown): boolean {
78
+ if (typeof error !== "object" || error === null || !("code" in error)) return false;
79
+ return (error as { code?: unknown }).code === "EADDRINUSE";
80
+ }
81
+
82
+ /**
83
+ * The port the operator asked for, or `null` when they did not. Throws on a
84
+ * value that is set but unusable, rather than quietly binding somewhere else.
85
+ */
86
+ function configuredPort(): number | null {
87
+ const raw = process.env[PORT_VARIABLE];
88
+ if (raw === undefined || raw.trim() === "") return null;
89
+ const port = Number.parseInt(raw.trim(), 10);
90
+ if (!Number.isInteger(port) || port < 0 || port > 65535) {
91
+ throw new Error(`${PORT_VARIABLE}=${raw} is not a port number between 0 and 65535`);
92
+ }
93
+ return port;
94
+ }
95
+
96
+ interface Dashboard {
97
+ url: string;
98
+ /** The preferred port, when it was taken and the server landed elsewhere. */
99
+ displaced: number | null;
100
+ }
101
+
102
+ interface Launched extends Dashboard {
103
+ instance: StewardServer;
104
+ /** The source the server is polling, so the widget can read the same one. */
105
+ source: StewardDataSource | undefined;
106
+ }
107
+
108
+ /** Builds one fresh source, or `undefined` to let the server use its mock. */
109
+ type SourceFactory = () => StewardDataSource;
110
+
111
+ /**
112
+ * A factory for the live source. Building it needs the connection, which we
113
+ * resolve once from the command's context — inside Pi that reads the operator's
114
+ * configured provider auth. Everything is imported lazily so the extension costs
115
+ * nothing until the dashboard is actually asked for.
116
+ *
117
+ * Unconditional on purpose. This used to be gated behind `STEWARD_SOURCE=llama`,
118
+ * which made the config wiring below unreachable unless the operator had opted
119
+ * in *before* the machine was configured — so `/steward_initialize` followed by
120
+ * `/steward_dashboard` in the same session showed a simulated dashboard, and only a fresh
121
+ * Pi session ever showed the machine. The gate defeated the exact feature the
122
+ * wiring exists to provide.
123
+ *
124
+ * A machine with nothing to read is not a reason to invent one: panels that
125
+ * cannot be filled report themselves disconnected.
126
+ */
127
+ async function sourceFactory(ctx: ConnectionContext): Promise<SourceFactory | undefined> {
128
+ const { resolveLlamaConnection } = await import("./core/llama-connection.js");
129
+ const { LlamaSource } = await import("./core/llama-source.js");
130
+ const { createDisconnectedSource } = await import("./core/disconnected-source.js");
131
+ const { createListenerProbe } = await import("./server/service-probe.js");
132
+ const { createConfigWiring } = await import("./server/config-wiring.js");
133
+ // Read the artifact before resolving the connection: its baseUrl decides
134
+ // which server Steward watches, and the resolver needs it up front.
135
+ const { readStewardConfig } = await import("./server/steward-config.js");
136
+ const recorded = readStewardConfig();
137
+ const connection = await resolveLlamaConnection(ctx, process.env, recorded?.baseUrl ?? null);
138
+ const probeService = createListenerProbe();
139
+
140
+ return () => {
141
+ // Everything `steward.json` decides — the host collector, the service
142
+ // control commands, the drift baseline, the log tail — comes from the
143
+ // wiring, which reads the artifact now and keeps reading it as it changes.
144
+ // That is what lets an operator run `/steward_initialize` with the dashboard
145
+ // already open: the panels wire themselves up on the next repaint instead of
146
+ // waiting for a new Pi session, and a config that is deleted takes its
147
+ // collector and its buttons with it.
148
+ //
149
+ // A fresh wiring per source, for the same reason the collector was always
150
+ // built per source: a start that fails to bind closes the source it was
151
+ // handed (killing its collector and stopping its watcher), so a retry must
152
+ // not reuse a spent one.
153
+ const wiring = createConfigWiring();
154
+ return new LlamaSource({
155
+ connection,
156
+ fallback: createDisconnectedSource(),
157
+ probeService,
158
+ ...wiring.parts,
159
+ rewire: wiring.rewire,
160
+ });
161
+ };
162
+ }
163
+
164
+ /**
165
+ * What Pi's llama.cpp provider config says **on disk**, or `null` when it cannot
166
+ * be read.
167
+ *
168
+ * Pi resolves that file once at startup and keeps the value in memory, so an
169
+ * edit made by `/steward_initialize` does not reach the running session — chat
170
+ * keeps dialling the old address until Pi restarts. Comparing disk against the
171
+ * live value is what lets the widget say "restart pi" instead of printing two
172
+ * ports and leaving the operator to guess.
173
+ *
174
+ * Best-effort and read-only: a missing file, a changed shape, or anything else
175
+ * simply yields `null` and the widget falls back to the vaguer wording.
176
+ */
177
+ async function providerUrlOnDisk(): Promise<string | null> {
178
+ try {
179
+ const { readFileSync } = await import("node:fs");
180
+ const { homedir } = await import("node:os");
181
+ const { join } = await import("node:path");
182
+ const raw = readFileSync(join(homedir(), ".pi", "agent", "auth.json"), "utf8");
183
+ const parsed: unknown = JSON.parse(raw);
184
+ if (typeof parsed !== "object" || parsed === null) return null;
185
+ const entry = (parsed as Record<string, unknown>)["llama.cpp"];
186
+ if (typeof entry !== "object" || entry === null) return null;
187
+ const env = (entry as Record<string, unknown>).env;
188
+ const url =
189
+ typeof env === "object" && env !== null
190
+ ? (env as Record<string, unknown>).LLAMA_BASE_URL
191
+ : undefined;
192
+ return typeof url === "string" && url.trim() !== "" ? url.trim() : null;
193
+ } catch {
194
+ return null;
195
+ }
196
+ }
197
+
198
+ async function launch(makeSource?: SourceFactory): Promise<Launched> {
199
+ // Imported lazily so loading the extension costs nothing until the dashboard
200
+ // is actually asked for.
201
+ const { createStewardServer, DEFAULT_PORT } = await import("./server/index.js");
202
+ const preferred = configuredPort() ?? DEFAULT_PORT;
203
+
204
+ // A fresh source per attempt: a start that fails to bind closes the source it
205
+ // was given, so the fallback attempt must not be handed a spent one.
206
+ const firstSource = makeSource?.();
207
+ const first = createStewardServer({ port: preferred, source: firstSource });
208
+ try {
209
+ return { instance: first, url: await first.start(), displaced: null, source: firstSource };
210
+ } catch (error) {
211
+ if (!isAddressInUse(error)) throw error;
212
+ }
213
+
214
+ // A particular port is a convenience, not a requirement: a second Pi session
215
+ // or a dev server left running holds it far more often than anything is
216
+ // actually wrong, and the operator gets a URL either way.
217
+ const fallbackSource = makeSource?.();
218
+ const fallback = createStewardServer({ port: 0, source: fallbackSource });
219
+ try {
220
+ return {
221
+ instance: fallback,
222
+ url: await fallback.start(),
223
+ displaced: preferred,
224
+ source: fallbackSource,
225
+ };
226
+ } catch (error) {
227
+ throw new Error(
228
+ `port ${preferred} is in use and no other port could be bound (${describe(error)})`,
229
+ );
230
+ }
231
+ }
232
+
233
+ function ensureServer(ctx: ConnectionContext): Promise<Dashboard> {
234
+ return enqueue(async () => {
235
+ const running = server;
236
+ if (running !== null && running.url !== null) return { url: running.url, displaced: null };
237
+
238
+ // Resolve the source inside the queued step, not before it: enqueueing must
239
+ // stay synchronous so a concurrent `/steward_stop` chains behind this start
240
+ // rather than racing ahead of it.
241
+ const launched = await launch(await sourceFactory(ctx));
242
+ server = launched.instance;
243
+ liveSource = launched.source ?? null;
244
+ return { url: launched.url, displaced: launched.displaced };
245
+ });
246
+ }
247
+
248
+ /** Resolves `true` when there was a server to stop, `false` when there was not. */
249
+ function stopServer(): Promise<boolean> {
250
+ return enqueue(async () => {
251
+ const instance = server;
252
+ server = null;
253
+ liveSource = null;
254
+ if (instance === null) return false;
255
+ await instance.stop();
256
+ return true;
257
+ });
258
+ }
259
+
260
+ /** Hands the URL to the platform's opener. Rejects if the opener cannot run. */
261
+ function openInBrowser(url: string): Promise<void> {
262
+ const command =
263
+ process.platform === "darwin" ? "open" : process.platform === "win32" ? "cmd" : "xdg-open";
264
+ // `start` is a shell builtin on Windows, and its first argument is the
265
+ // window title — hence the empty string before the URL.
266
+ const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
267
+
268
+ return new Promise<void>((resolve, reject) => {
269
+ const child = spawn(command, args, { stdio: "ignore", detached: true });
270
+ child.once("error", reject);
271
+ child.once("spawn", () => {
272
+ child.unref();
273
+ resolve();
274
+ });
275
+ });
276
+ }
277
+
278
+ export default function (pi: ExtensionAPI): void {
279
+ // Starting the server and opening a browser are separate commands: an
280
+ // operator on a headless box, or one who just wants the widget live, has no
281
+ // use for a browser, and someone whose tab is closed should not have to stop
282
+ // and restart the server to get it back.
283
+ pi.registerCommand("steward_start", {
284
+ description: "Start the Steward dashboard service.",
285
+ handler: async (_args, ctx) => {
286
+ let dashboard: Dashboard;
287
+ try {
288
+ dashboard = await ensureServer(ctx);
289
+ } catch (error) {
290
+ ctx.ui.notify(`Steward could not start: ${describe(error)}`, "error");
291
+ return;
292
+ }
293
+ const where =
294
+ dashboard.displaced === null
295
+ ? `Steward is serving at ${dashboard.url}`
296
+ : `Steward is serving at ${dashboard.url} — port ${dashboard.displaced} was already in use`;
297
+ ctx.ui.notify(`${where}. Open it with /steward_dashboard.`, "info");
298
+ void refreshWidget(ctx);
299
+ trackDashboard(ctx);
300
+ },
301
+ });
302
+
303
+ pi.registerCommand("steward_dashboard", {
304
+ description: "Open the Steward dashboard in your browser, starting it if needed.",
305
+ handler: async (_args, ctx) => {
306
+ let dashboard: Dashboard;
307
+ try {
308
+ dashboard = await ensureServer(ctx);
309
+ } catch (error) {
310
+ ctx.ui.notify(`Steward could not start: ${describe(error)}`, "error");
311
+ return;
312
+ }
313
+
314
+ const where =
315
+ dashboard.displaced === null
316
+ ? `Steward is serving at ${dashboard.url}`
317
+ : `Steward is serving at ${dashboard.url} — port ${dashboard.displaced} was already in use`;
318
+ // The URL goes out before the browser is touched, so a headless or
319
+ // locked-down environment still gets something it can act on.
320
+ ctx.ui.notify(where, "info");
321
+ try {
322
+ await openInBrowser(dashboard.url);
323
+ void refreshWidget(ctx);
324
+ trackDashboard(ctx);
325
+ } catch (error) {
326
+ ctx.ui.notify(`Steward could not open a browser (${describe(error)})`, "warning");
327
+ }
328
+ },
329
+ });
330
+
331
+ pi.registerCommand("steward_stop", {
332
+ description: "Stop the Steward dashboard server.",
333
+ handler: async (_args, ctx) => {
334
+ try {
335
+ // Asked inside the queue, not before it: a start still in flight owns
336
+ // a server this stop is responsible for, and the answer is only true
337
+ // once that start has settled.
338
+ const stopped = await stopServer();
339
+ ctx.ui.notify(stopped ? "Steward stopped." : "Steward is not running.", "info");
340
+ stopTracking();
341
+ void refreshWidget(ctx);
342
+ } catch (error) {
343
+ ctx.ui.notify(`Steward could not stop cleanly: ${describe(error)}`, "warning");
344
+ }
345
+ },
346
+ });
347
+
348
+ pi.registerCommand("steward_initialize", {
349
+ description:
350
+ "Connect this machine to Steward — review the local llama.cpp setup and propose the configuration it needs.",
351
+ handler: async (_args, ctx) => {
352
+ // Delivered as a message rather than a prompt template because the
353
+ // helper's absolute path is only knowable at runtime: the package can be
354
+ // installed anywhere, and templates substitute positional arguments only.
355
+ // `display: false` keeps the instructions out of the transcript — they are
356
+ // a brief for the model, not something the operator needs to read back.
357
+ if (typeof pi.sendMessage !== "function") {
358
+ ctx.ui.notify(
359
+ "This Pi host cannot deliver the setup brief (sendMessage is unavailable).",
360
+ "error",
361
+ );
362
+ return;
363
+ }
364
+ pi.sendMessage(
365
+ {
366
+ customType: "steward-initialize",
367
+ content: buildInitPrompt(setupScriptPath()),
368
+ display: false,
369
+ },
370
+ { triggerTurn: true },
371
+ );
372
+ },
373
+ });
374
+
375
+ /**
376
+ * Polls while the dashboard is up, and only while it is up. A timer that runs
377
+ * with no dashboard would probe a machine nobody is watching; one that never
378
+ * runs leaves the bar frozen at whatever was true when you last typed — unload
379
+ * a model and it kept saying 1/10 until the next turn.
380
+ */
381
+ const trackDashboard = (ctx: ExtensionContext): void => {
382
+ if (widgetTimer !== null) return;
383
+ widgetTimer = setInterval(() => {
384
+ if (server === null) {
385
+ stopTracking();
386
+ void refreshWidget(ctx);
387
+ return;
388
+ }
389
+ void refreshWidget(ctx);
390
+ }, WIDGET_POLL_MS);
391
+ widgetTimer.unref?.();
392
+ };
393
+
394
+ const stopTracking = (): void => {
395
+ if (widgetTimer === null) return;
396
+ clearInterval(widgetTimer);
397
+ widgetTimer = null;
398
+ };
399
+
400
+ // The footer chip. Refreshed at the two moments the operator's eyes are on it
401
+ // — session start, and the end of each turn — rather than on a timer: a timer
402
+ // probes a machine nobody is looking at and can repaint mid-stream. A reading
403
+ // that is a turn old is fine; one that costs a permanent poll is not.
404
+ //
405
+ // Guarded on `hasUI` and on the method itself: Pi runs headless, and oh-my-pi
406
+ // ships a subset shim, so the commands must keep working with no chip.
407
+ const STATUS_WIDGET_KEY = "steward-status";
408
+ /**
409
+ * How often the bar re-reads while the dashboard is up. Slower than the
410
+ * dashboard's own poll — a status bar does not need per-second resolution —
411
+ * and it costs one snapshot off the source the server already holds, so there
412
+ * is no second connection and no second collector.
413
+ */
414
+ const WIDGET_POLL_MS = 4000;
415
+ let widgetInFlight = false;
416
+ let lastWidgetLine: string | null = null;
417
+ let widgetTimer: ReturnType<typeof setInterval> | null = null;
418
+ const refreshWidget = async (ctx: ExtensionContext): Promise<void> => {
419
+ // Feature-detected, not gated on `hasUI`. `session_start` is emitted from
420
+ // `bindExtensions` during startup, when the TUI may not have come up yet and
421
+ // `hasUI` is still false — gating on it meant the chip did not appear until
422
+ // the first turn ended. Setting a status headless is harmless: nothing
423
+ // renders it.
424
+ if (typeof ctx.ui?.setWidget !== "function") return;
425
+ if (widgetInFlight) return;
426
+ widgetInFlight = true;
427
+ try {
428
+ const { formatStatusWidget, resolveGlyph } = await import("./core/status-widget.js");
429
+ const { readStewardConfig } = await import("./server/steward-config.js");
430
+ const glyph = resolveGlyph(process.env);
431
+
432
+ // Steward's own state costs nothing: the extension holds the server.
433
+ const portalUrl = server?.url ?? null;
434
+ const recorded = readStewardConfig();
435
+ const stewardBaseUrl = recorded?.baseUrl ?? null;
436
+ // Where Pi would send a chat. when that cannot be established —
437
+ // never the loopback default, which would render an unknown as a
438
+ // plausible-looking misconfiguration.
439
+ const { providerBaseUrlOrNull } = await import("./core/llama-connection.js");
440
+ const providerBaseUrl = await providerBaseUrlOrNull(ctx as unknown as ConnectionContext);
441
+
442
+ // Read the source the dashboard is already polling. Only when the
443
+ // dashboard is up: a stopped Steward has nothing to say about llama.cpp,
444
+ // and the source belongs to the server.
445
+ let snapshot = null;
446
+ if (portalUrl !== null && liveSource !== null) {
447
+ snapshot = await liveSource.snapshot();
448
+ }
449
+
450
+ // Does the file already say what Steward watches? If so the mismatch is a
451
+ // stale session, not a bad config, and the fix is a restart.
452
+ const onDisk = await providerUrlOnDisk();
453
+ const providerFileAgrees =
454
+ onDisk !== null &&
455
+ stewardBaseUrl !== null &&
456
+ normalizeLoose(onDisk) === normalizeLoose(stewardBaseUrl);
457
+
458
+ const line = formatStatusWidget(
459
+ {
460
+ portalUrl,
461
+ snapshot,
462
+ providerBaseUrl,
463
+ stewardBaseUrl,
464
+ providerFileAgrees,
465
+ },
466
+ glyph,
467
+ );
468
+ // Repaint only on change. The poll below runs every few seconds and the
469
+ // line is usually identical; redrawing it regardless would flicker the
470
+ // editor for nothing.
471
+ if (line !== lastWidgetLine) {
472
+ lastWidgetLine = line;
473
+ ctx.ui.setWidget(STATUS_WIDGET_KEY, [line], { placement: "aboveEditor" });
474
+ }
475
+ } catch {
476
+ // Informational only: never let it disturb the loop. The previous line
477
+ // stays on screen rather than being cleared to nothing.
478
+ } finally {
479
+ widgetInFlight = false;
480
+ }
481
+ };
482
+
483
+ // `on` is not part of every host's extension API — oh-my-pi ships a subset
484
+ // shim — so it is feature-detected rather than assumed. Without it the
485
+ // server simply outlives the session until the process exits.
486
+ if (typeof pi.on === "function") {
487
+ pi.on("session_start", async (_event, ctx) => {
488
+ await refreshWidget(ctx);
489
+ });
490
+ // Also on turn START: if the chip missed its first chance at session_start
491
+ // — a TUI that was not up yet, a probe that lost a race — this is the next
492
+ // moment the operator looks at the footer, and it costs one loopback read.
493
+ pi.on("turn_start", async (_event, ctx) => {
494
+ await refreshWidget(ctx);
495
+ });
496
+ pi.on("turn_end", async (_event, ctx) => {
497
+ await refreshWidget(ctx);
498
+ });
499
+ pi.on("session_shutdown", async () => {
500
+ await stopServer();
501
+ });
502
+ }
503
+ }
package/package.json ADDED
@@ -0,0 +1,51 @@
1
+ {
2
+ "name": "@jmcombs/pi-steward",
3
+ "version": "0.0.0",
4
+ "description": "Steward — the llama.cpp control panel for Pi. A local browser dashboard for service control, resident models, host health, and streamed logs.",
5
+ "homepage": "https://github.com/jmcombs/pi-extensions/tree/main/packages/steward",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "https://github.com/jmcombs/pi-extensions.git",
9
+ "directory": "packages/steward"
10
+ },
11
+ "bugs": {
12
+ "url": "https://github.com/jmcombs/pi-extensions/issues"
13
+ },
14
+ "license": "MIT",
15
+ "author": "Jeremy Combs",
16
+ "type": "module",
17
+ "main": "./index.ts",
18
+ "types": "./index.ts",
19
+ "files": [
20
+ "index.ts",
21
+ "core/",
22
+ "server/",
23
+ "ui/",
24
+ "setup/",
25
+ "README.md",
26
+ "LICENSE",
27
+ "!**/*.test.ts",
28
+ "!**/__fixtures__"
29
+ ],
30
+ "keywords": [
31
+ "pi-package",
32
+ "pi-extension",
33
+ "llama.cpp",
34
+ "llama-server",
35
+ "dashboard",
36
+ "local-models",
37
+ "observability"
38
+ ],
39
+ "pi": {
40
+ "extensions": [
41
+ "./index.ts"
42
+ ],
43
+ "image": "https://raw.githubusercontent.com/jmcombs/pi-extensions/main/assets/steward/preview.png"
44
+ },
45
+ "engines": {
46
+ "node": ">=22.13.0"
47
+ },
48
+ "peerDependencies": {
49
+ "@earendil-works/pi-coding-agent": "*"
50
+ }
51
+ }