@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.
- package/LICENSE +21 -0
- package/README.md +140 -0
- package/core/disconnected-source.ts +110 -0
- package/core/drift.ts +247 -0
- package/core/format.ts +317 -0
- package/core/host-metrics.ts +121 -0
- package/core/llama-config.ts +72 -0
- package/core/llama-connection.ts +215 -0
- package/core/llama-models.ts +261 -0
- package/core/llama-slots.ts +104 -0
- package/core/llama-source.ts +1523 -0
- package/core/log-parse.ts +440 -0
- package/core/model-color.ts +59 -0
- package/core/select.ts +2923 -0
- package/core/slot-activity.ts +658 -0
- package/core/source.ts +84 -0
- package/core/state.ts +609 -0
- package/core/status-widget.ts +222 -0
- package/core/temperature.ts +149 -0
- package/core/types.ts +431 -0
- package/index.ts +503 -0
- package/package.json +51 -0
- package/server/api.ts +216 -0
- package/server/assets.ts +198 -0
- package/server/config-wiring.ts +490 -0
- package/server/drift-probe.ts +150 -0
- package/server/host-collector.ts +272 -0
- package/server/index.ts +228 -0
- package/server/log-tailer.ts +432 -0
- package/server/service-control.ts +337 -0
- package/server/service-probe.ts +71 -0
- package/server/steward-config.ts +430 -0
- package/setup/init-prompt.ts +214 -0
- package/setup/steward-setup.d.mts +16 -0
- package/setup/steward-setup.mjs +1398 -0
- package/ui/components/console.ts +511 -0
- package/ui/components/gauges.ts +120 -0
- package/ui/components/metrics.ts +63 -0
- package/ui/components/models.ts +296 -0
- package/ui/components/service.ts +358 -0
- package/ui/components/slots.ts +114 -0
- package/ui/components/sparkline.ts +59 -0
- package/ui/components/toolbar.ts +211 -0
- package/ui/dom.ts +120 -0
- package/ui/favicon.svg +17 -0
- package/ui/index.html +34 -0
- package/ui/main.ts +678 -0
- 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
|
+
}
|