@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
@@ -0,0 +1,430 @@
1
+ /**
2
+ * Reads and validates the `steward.json` handshake artifact.
3
+ *
4
+ * `steward.json` is written by the `/steward_initialize` skill (a later phase),
5
+ * not by hand in the common case, and it drives Steward to run local commands —
6
+ * the host-metrics collector and the service-control commands — so it is a
7
+ * code-execution surface.
8
+ * This module is the gate in front of that. It reads the artifact from
9
+ * `STEWARD_CONFIG` (if set) else `~/.config/steward/steward.json`, and returns a
10
+ * typed config or `null` — it NEVER throws, because an absent, malformed, or
11
+ * untrusted config is a state to degrade on, not an error to crash the dashboard
12
+ * with.
13
+ *
14
+ * Security (plan §M7): before the config is trusted at all, its file must be
15
+ * owned by the current user and must not be world-writable — otherwise another
16
+ * user could drop a command in and have Steward run it. A separate per-command
17
+ * consent gate ({@link hostCollectorConsented}, {@link controlConsented}) then
18
+ * requires that exact command's hash to be present in the config's `consent`
19
+ * map, so a rewritten command re-prompts rather than riding an old blanket
20
+ * "yes".
21
+ *
22
+ * This module is Node-only (the server half): it touches the filesystem, the
23
+ * process uid, and `crypto`. It is never shipped to the browser.
24
+ */
25
+
26
+ import { createHash } from "node:crypto";
27
+ import { readFileSync, statSync } from "node:fs";
28
+ import { homedir } from "node:os";
29
+ import { join } from "node:path";
30
+ import type { ConsentDrift } from "../core/drift.js";
31
+ import type { MemoryTopology, ServiceAction } from "../core/types.js";
32
+
33
+ /** The environment variable that overrides the default config location. */
34
+ const CONFIG_ENV = "STEWARD_CONFIG";
35
+
36
+ /** The collector command + its cadence, as recorded in `steward.json`. */
37
+ export interface HostCollectorConfig {
38
+ /** Argv of the long-lived collector: `command[0]` is the program, rest args. */
39
+ command: string[];
40
+ /** The collector's declared emit cadence, ms — the base for the staleness clock. */
41
+ intervalMs: number;
42
+ }
43
+
44
+ /**
45
+ * The service-control commands, as recorded in `steward.json`. All three are
46
+ * required together: a machine that can only be restarted is expressed by
47
+ * consenting to the restart command alone, not by declaring a partial block —
48
+ * that keeps "what this machine can do" (the argv) apart from "what the
49
+ * operator approved" (the consent map).
50
+ */
51
+ export interface ServiceControlConfig {
52
+ start: string[];
53
+ stop: string[];
54
+ restart: string[];
55
+ }
56
+
57
+ /**
58
+ * What `/steward_initialize` observed about how `llama-server` is launched on
59
+ * this machine — the baseline the live process is re-checked against on every
60
+ * snapshot (see `server/drift-probe.ts`).
61
+ *
62
+ * It is a RECORD, not an instruction: Steward never launches anything from it
63
+ * and it carries no consent, which is why it needs no hash. `mechanism` and
64
+ * `label` are descriptive only (`launchd`, `gui/501/com.llamacpp.router`) and
65
+ * exist so a later phase can point the operator at the right file to fix.
66
+ */
67
+ export interface LlamaLaunchConfig {
68
+ /** The argv the server was observed running with, `argv[0]` first. */
69
+ launchArgv: string[];
70
+ /** How it is launched (`launchd`, `systemd`, …), or `null` when unrecorded. */
71
+ mechanism: string | null;
72
+ /** The job/unit label, or `null` when unrecorded. */
73
+ label: string | null;
74
+ }
75
+
76
+ /**
77
+ * Where this machine's `llama-server` writes its combined stdout/stderr, as
78
+ * recorded by `/steward_initialize`.
79
+ *
80
+ * It is a path Steward READS, never a command it runs, so — unlike the collector
81
+ * and the control commands — it carries no consent hash: there is nothing here to
82
+ * approve. The file's ownership gate still applies, because it is the same file
83
+ * as everything else in this artifact.
84
+ */
85
+ export interface LogFileConfig {
86
+ /** Absolute path to the log file to follow. */
87
+ path: string;
88
+ }
89
+
90
+ /**
91
+ * The slice of `steward.json` this phase reads. Unknown keys are ignored so the
92
+ * artifact can carry fields later phases own without this reader rejecting them.
93
+ */
94
+ export interface StewardConfig {
95
+ /** Static machine memory layout — picks the HOST gauge SET, not a reading. */
96
+ memoryTopology: MemoryTopology;
97
+ /**
98
+ * Where `llama-server` listens, as the operator recorded it, or `null` when
99
+ * the artifact declares none.
100
+ *
101
+ * This **wins** over Pi's llama.cpp provider auth. It used to be written by
102
+ * setup and then ignored by this reader entirely, so a machine whose server
103
+ * was recorded on one port was polled on whatever port Pi's provider happened
104
+ * to name — the dashboard reported "not reachable" and every control looked
105
+ * broken while the server was healthy. Steward's own artifact is the operator
106
+ * telling Steward what to watch; the provider describes what Pi chats with,
107
+ * and the two are allowed to differ.
108
+ */
109
+ baseUrl: string | null;
110
+ hostCollector: HostCollectorConfig;
111
+ /**
112
+ * The recorded launch argv, or `null` when the artifact does not carry one
113
+ * (or carries an ill-formed one). Optional on purpose: drift re-validation is
114
+ * then simply unavailable — the dashboard says nothing about launch flags
115
+ * rather than refusing the whole config over a block nothing else depends on.
116
+ */
117
+ llama: LlamaLaunchConfig | null;
118
+ /**
119
+ * Start/stop/restart commands, or `null` when the artifact declares none (or
120
+ * declares them ill-formed). Control is optional: a machine may have metrics
121
+ * configured and no control, which is a dashboard state — a setup
122
+ * affordance — not a reason to reject the whole config.
123
+ */
124
+ control: ServiceControlConfig | null;
125
+ /**
126
+ * The log file to follow, or `null` when the artifact records none (or records
127
+ * an ill-formed one). Optional like the blocks above: a machine with no log
128
+ * path recorded still gets every other panel, and Steward falls back to
129
+ * `STEWARD_LOG_FILE` and the platform convention before giving up.
130
+ */
131
+ log: LogFileConfig | null;
132
+ /** sha256(command) → `true` for each command the operator has consented to run. */
133
+ consent: Record<string, true>;
134
+ }
135
+
136
+ /** The control actions, in the order the dashboard renders them. */
137
+ const CONTROL_ACTIONS: readonly ServiceAction[] = ["start", "stop", "restart"];
138
+
139
+ export interface ReadStewardConfigOptions {
140
+ /**
141
+ * Overrides the resolved config path (tests point this at a temp file). When
142
+ * omitted, `STEWARD_CONFIG` then `~/.config/steward/steward.json` is used.
143
+ */
144
+ path?: string;
145
+ /** The current uid, for the ownership check. Injected in tests; defaults to `process.getuid`. */
146
+ uid?: number | null;
147
+ /** Sink for the security warnings. Injected in tests; defaults to `console.warn`. */
148
+ warn?: (message: string) => void;
149
+ }
150
+
151
+ /** The path the config is read from, honouring `STEWARD_CONFIG`. */
152
+ export function stewardConfigPath(): string {
153
+ const override = process.env[CONFIG_ENV];
154
+ if (override !== undefined && override.trim() !== "") return override;
155
+ return join(homedir(), ".config", "steward", "steward.json");
156
+ }
157
+
158
+ /** The current uid, or `null` on a platform without one (Windows). */
159
+ function currentUid(): number | null {
160
+ return typeof process.getuid === "function" ? process.getuid() : null;
161
+ }
162
+
163
+ /**
164
+ * The canonical hash of a collector command, for the consent map: the sha256 of
165
+ * the argv joined on single spaces. The space join keeps the hash reproducible
166
+ * by hand (`printf '%s' 'macmon pipe -s 0 -i 1000' | shasum -a 256`) for a
167
+ * maintainer writing `steward.json` to test live; the `/steward_initialize`
168
+ * skill computes the same hash when it records consent. It trades a strict argv
169
+ * canonicalisation for that reproducibility — the gate's job is operator
170
+ * awareness of what runs, and consent still re-prompts whenever the command
171
+ * string changes.
172
+ */
173
+ export function hashCommand(command: string[]): string {
174
+ return createHash("sha256").update(command.join(" ")).digest("hex");
175
+ }
176
+
177
+ /**
178
+ * True when the config's collector command carries a matching entry in the
179
+ * consent map. The collector must NOT be spawned otherwise: consent is bound to
180
+ * the exact command, so a rewritten or repo-dropped command falls through here
181
+ * and re-prompts (via the skill) rather than running under a stale approval.
182
+ */
183
+ export function hostCollectorConsented(config: StewardConfig): boolean {
184
+ return config.consent[hashCommand(config.hostCollector.command)] === true;
185
+ }
186
+
187
+ /**
188
+ * True when the config declares a command for `action` AND that exact command
189
+ * carries a matching entry in the consent map. The same gate the collector
190
+ * passes through, applied per action: consenting to `restart` does not consent
191
+ * to `stop`, and rewriting a declared command drops it back out of the
192
+ * dashboard until the operator approves the new one.
193
+ */
194
+ export function controlConsented(config: StewardConfig, action: ServiceAction): boolean {
195
+ if (config.control === null) return false;
196
+ return config.consent[hashCommand(config.control[action])] === true;
197
+ }
198
+
199
+ /**
200
+ * The control commands the operator has consented to, keyed by action — the
201
+ * exact set the executor is built from. An action that is declared but not
202
+ * consented is simply absent, so it is never offered and never runs.
203
+ */
204
+ export function consentedControls(config: StewardConfig): Partial<Record<ServiceAction, string[]>> {
205
+ const commands: Partial<Record<ServiceAction, string[]>> = {};
206
+ if (config.control === null) return commands;
207
+ for (const action of CONTROL_ACTIONS) {
208
+ if (controlConsented(config, action)) commands[action] = [...config.control[action]];
209
+ }
210
+ return commands;
211
+ }
212
+
213
+ /**
214
+ * The commands this config declares but has NOT approved — the second producer
215
+ * of the dashboard's drift notice.
216
+ *
217
+ * A declared, unapproved command is Steward's security gate doing its job: the
218
+ * collector is not spawned, the button is not offered. But silence makes the
219
+ * resulting inert panel look identical to one that was never set up, and an
220
+ * operator who edited a command in `steward.json` (invalidating its hash) has no
221
+ * way to learn that is why their gauges went dark. This turns that into
222
+ * something the UI can say out loud.
223
+ */
224
+ export function consentDrift(config: StewardConfig): ConsentDrift {
225
+ return {
226
+ hostCollector: !hostCollectorConsented(config),
227
+ controls:
228
+ config.control === null
229
+ ? []
230
+ : CONTROL_ACTIONS.filter((action) => !controlConsented(config, action)),
231
+ };
232
+ }
233
+
234
+ /** True for a non-null, non-array object. */
235
+ function isRecord(value: unknown): value is Record<string, unknown> {
236
+ return typeof value === "object" && value !== null && !Array.isArray(value);
237
+ }
238
+
239
+ /** Validates the collector block, or `null` when it is missing/ill-formed. */
240
+ function parseHostCollector(value: unknown): HostCollectorConfig | null {
241
+ if (!isRecord(value)) return null;
242
+ const { command, intervalMs } = value;
243
+ if (
244
+ !Array.isArray(command) ||
245
+ command.length === 0 ||
246
+ !command.every((part) => typeof part === "string")
247
+ ) {
248
+ return null;
249
+ }
250
+ if (typeof intervalMs !== "number" || !Number.isFinite(intervalMs) || intervalMs <= 0) {
251
+ return null;
252
+ }
253
+ return { command: [...(command as string[])], intervalMs };
254
+ }
255
+
256
+ /** A non-empty argv of strings, or `null`. */
257
+ function parseCommand(value: unknown): string[] | null {
258
+ if (!Array.isArray(value) || value.length === 0) return null;
259
+ if (!value.every((part) => typeof part === "string")) return null;
260
+ return [...(value as string[])];
261
+ }
262
+
263
+ /** An optional descriptive string, trimmed, or `null` when absent/empty. */
264
+ function parseLabel(value: unknown): string | null {
265
+ if (typeof value !== "string") return null;
266
+ const trimmed = value.trim();
267
+ return trimmed === "" ? null : trimmed;
268
+ }
269
+
270
+ /**
271
+ * Validates the optional `llama` block. Only `launchArgv` is load-bearing — it
272
+ * is the baseline the drift check diffs against — so a block without a usable
273
+ * one yields `null` (drift checking unavailable) rather than a half-recorded
274
+ * baseline that could report a mismatch against nothing.
275
+ */
276
+ function parseLlama(value: unknown): LlamaLaunchConfig | null {
277
+ if (!isRecord(value)) return null;
278
+ const launchArgv = parseCommand(value.launchArgv);
279
+ if (launchArgv === null) return null;
280
+ return {
281
+ launchArgv,
282
+ mechanism: parseLabel(value.mechanism),
283
+ label: parseLabel(value.label),
284
+ };
285
+ }
286
+
287
+ /**
288
+ * Validates the optional `control` block. All three commands are required
289
+ * within it, so a half-written block yields `null` (control unconfigured)
290
+ * rather than a set of actions the operator never fully declared.
291
+ */
292
+ function parseControl(value: unknown): ServiceControlConfig | null {
293
+ if (!isRecord(value)) return null;
294
+ const start = parseCommand(value.start);
295
+ const stop = parseCommand(value.stop);
296
+ const restart = parseCommand(value.restart);
297
+ if (start === null || stop === null || restart === null) return null;
298
+ return { start, stop, restart };
299
+ }
300
+
301
+ /**
302
+ * Validates the optional `log` block. Only a non-empty string path is usable;
303
+ * anything else yields `null` and the discovery precedence takes over, rather
304
+ * than handing the tailer a path it cannot watch.
305
+ */
306
+ function parseLog(value: unknown): LogFileConfig | null {
307
+ if (!isRecord(value)) return null;
308
+ const path = parseLabel(value.path);
309
+ return path === null ? null : { path };
310
+ }
311
+
312
+ /** Validates the consent map, keeping only `true` entries. */
313
+ function parseConsent(value: unknown): Record<string, true> {
314
+ if (!isRecord(value)) return {};
315
+ const consent: Record<string, true> = {};
316
+ for (const [key, granted] of Object.entries(value)) {
317
+ if (granted === true) consent[key] = true;
318
+ }
319
+ return consent;
320
+ }
321
+
322
+ /**
323
+ * Reads the config artifact, returning a validated {@link StewardConfig} or
324
+ * `null`. Returns `null` — quietly for a plain absence, with a warning for a
325
+ * refusal — when the file does not exist, cannot be read, is not owned by the
326
+ * current user, is world-writable, is not valid JSON, or fails schema
327
+ * validation. Never throws.
328
+ */
329
+ export function readStewardConfig(options: ReadStewardConfigOptions = {}): StewardConfig | null {
330
+ const path = options.path ?? stewardConfigPath();
331
+ const uid = options.uid !== undefined ? options.uid : currentUid();
332
+ const warn = options.warn ?? ((message: string) => console.warn(message));
333
+
334
+ let stat: ReturnType<typeof statSync>;
335
+ try {
336
+ stat = statSync(path);
337
+ } catch {
338
+ // Absent (or unreadable) is the normal cold-start case, not a warning.
339
+ return null;
340
+ }
341
+
342
+ // Ownership: a config owned by another user could have been planted; refuse
343
+ // it. Skipped only where the platform has no uid (Windows).
344
+ if (uid !== null && stat.uid !== uid) {
345
+ warn(`[steward] ignoring ${path}: not owned by the current user`);
346
+ return null;
347
+ }
348
+ // World-writable means anyone on the box can rewrite the command Steward runs.
349
+ if ((stat.mode & 0o002) !== 0) {
350
+ warn(`[steward] ignoring ${path}: it is world-writable`);
351
+ return null;
352
+ }
353
+
354
+ let raw: string;
355
+ try {
356
+ raw = readFileSync(path, "utf8");
357
+ } catch {
358
+ return null;
359
+ }
360
+
361
+ let parsed: unknown;
362
+ try {
363
+ parsed = JSON.parse(raw);
364
+ } catch {
365
+ warn(`[steward] ignoring ${path}: it is not valid JSON`);
366
+ return null;
367
+ }
368
+ if (!isRecord(parsed)) {
369
+ warn(`[steward] ignoring ${path}: it is not a JSON object`);
370
+ return null;
371
+ }
372
+
373
+ const memoryTopology = parsed.memoryTopology;
374
+ if (memoryTopology !== "unified" && memoryTopology !== "discrete") {
375
+ warn(`[steward] ignoring ${path}: memoryTopology must be "unified" or "discrete"`);
376
+ return null;
377
+ }
378
+
379
+ const hostCollector = parseHostCollector(parsed.hostCollector);
380
+ if (hostCollector === null) {
381
+ warn(`[steward] ignoring ${path}: hostCollector.command / intervalMs is missing or invalid`);
382
+ return null;
383
+ }
384
+
385
+ // Control is optional, and a present-but-ill-formed block is worth saying out
386
+ // loud: the operator meant to configure it and the dashboard will show the
387
+ // setup affordance instead, which is otherwise indistinguishable from having
388
+ // never configured it at all.
389
+ const control = parseControl(parsed.control);
390
+ if (parsed.control !== undefined && control === null) {
391
+ warn(`[steward] ${path}: ignoring control — it needs a start, stop and restart command`);
392
+ }
393
+
394
+ // The launch record is optional too, and losing it costs only the drift
395
+ // check — but silently, so a block that is present and unusable says so:
396
+ // otherwise the dashboard would look exactly like a compliant machine.
397
+ const llama = parseLlama(parsed.llama);
398
+ if (parsed.llama !== undefined && llama === null) {
399
+ warn(`[steward] ${path}: ignoring llama — it needs a non-empty launchArgv array of strings`);
400
+ }
401
+
402
+ // The log path is optional as well, and losing it costs only the console —
403
+ // but again, say so: a present-but-unusable block would otherwise look exactly
404
+ // like a machine that never recorded one.
405
+ const log = parseLog(parsed.log);
406
+ if (parsed.log !== undefined && log === null) {
407
+ warn(`[steward] ${path}: ignoring log — it needs a non-empty path string`);
408
+ }
409
+
410
+ // Optional like the blocks above, and said out loud when present but unusable:
411
+ // silently falling back to the provider's URL is exactly the failure this
412
+ // field exists to prevent.
413
+ const baseUrl =
414
+ typeof parsed.baseUrl === "string" && parsed.baseUrl.trim() !== ""
415
+ ? parsed.baseUrl.trim()
416
+ : null;
417
+ if (parsed.baseUrl !== undefined && baseUrl === null) {
418
+ warn(`[steward] ${path}: ignoring baseUrl — it needs a non-empty URL string`);
419
+ }
420
+
421
+ return {
422
+ memoryTopology,
423
+ baseUrl,
424
+ hostCollector,
425
+ llama,
426
+ control,
427
+ log,
428
+ consent: parseConsent(parsed.consent),
429
+ };
430
+ }
@@ -0,0 +1,214 @@
1
+ /**
2
+ * The `/steward_initialize` instructions.
3
+ *
4
+ * This used to be a skill — a `SKILL.md` with four reference files, ~770 lines
5
+ * of procedure and mechanism. It was replaced because the procedure turned out
6
+ * to be scaffolding a capable model supplies on its own: across live runs the
7
+ * model consistently detected, proposed and gated consent correctly without
8
+ * being told the shape of those steps, and the per-mechanism launchd/systemd
9
+ * recipes were describing a domain it already knows better than the prose did.
10
+ *
11
+ * What survives is the part a model cannot derive: the failures that leave a
12
+ * machine looking healthy. Each item under "What will lie to you" is a case
13
+ * where trying the obvious thing produces no error — an empty log on a running
14
+ * server, a 400 that means two different things, a consent hash mismatch that
15
+ * silently blanks the dashboard. A model self-corrects from a stack trace; it
16
+ * cannot self-correct from silence, so those are written down and the rest is
17
+ * not.
18
+ *
19
+ * It is delivered as a command rather than a prompt template because the helper
20
+ * script's absolute path is only known at runtime — the package can be
21
+ * installed anywhere — and templates substitute positional arguments only.
22
+ */
23
+
24
+ import { join } from "node:path";
25
+
26
+ /** Absolute path of the helper, resolved against this module's own location. */
27
+ export function setupScriptPath(): string {
28
+ return join(import.meta.dirname, "steward-setup.mjs");
29
+ }
30
+
31
+ /**
32
+ * The instructions, with `scriptPath` written into the two commands that need
33
+ * it. Exported separately from the path so it can be tested without a real
34
+ * install layout.
35
+ */
36
+ export function buildInitPrompt(scriptPath: string): string {
37
+ return `You are an expert at managing operating systems and services. Set this machine up so that
38
+ **Steward** — a Pi plugin that runs a local dashboard for monitoring \`llama.cpp\` — can do its job.
39
+
40
+ Find out what is true, tell me briefly, propose a plan, get my approval, carry it out, prove it
41
+ worked. Detection is read-only. Nothing is applied without my approval.
42
+
43
+ ## How to report
44
+
45
+ **Be brief.** No tables of evidence, no capability scorecards, no recap of the commands you ran.
46
+ I want to read the answer, not the investigation. Two short sections:
47
+
48
+ **Current state** — a handful of lines. What is running, what already works, what does not.
49
+ **Plan** — a numbered list, one line per change, each with the exact command or diff. Mark any
50
+ step that restarts the service or drops loaded models as **disruptive**.
51
+
52
+ Then ask exactly one question: **approve the whole plan, or go one change at a time?**
53
+
54
+ - If I approve the whole plan, carry it out end to end without stopping to re-ask. The plan told me
55
+ what was disruptive, so approving it is my consent for those steps too. Report once at the end.
56
+ - If I ask to go one at a time, do that instead — one change, then stop.
57
+ - Either way: if reality turns out differently from the plan mid-flight — a command fails, a file is
58
+ not what you expected, a step would now do something the plan did not describe — stop and tell me.
59
+ Approval covers the plan you showed me, not a different one.
60
+
61
+ ## First, learn the machine
62
+
63
+ Nothing below names an operating system, a service manager, or a sensor tool, because you are
64
+ better at recognising those than any list I could write. Establish them yourself before you propose
65
+ anything:
66
+
67
+ - **The OS and the hardware** — enough to know whether the GPU has its own memory or shares the
68
+ system's.
69
+ - **How this machine supervises long-running services**, and how \`llama-server\` is started under it
70
+ today — the file or unit that defines it, the label it runs as, how it is stopped and reloaded. If
71
+ nothing supervises it, say so: that is the create path, and you write the definition.
72
+ - **What can measure this host** — whichever tool is already installed for GPU, CPU and temperature.
73
+ - **Where Pi already expects \`llama.cpp\`** — its provider's base URL. This is a *given*, not
74
+ something to negotiate: Pi is configured, other things may depend on it, and it holds that value
75
+ in memory for the life of the session. Read it before you plan anything.
76
+
77
+ Say what you found in one line. Then translate every requirement below into that environment's own
78
+ terms. The requirements are the contract; the mechanism is yours to choose.
79
+
80
+ ## What Steward needs to work
81
+
82
+ It reads exactly one file, \`$STEWARD_CONFIG\` if set and non-empty, otherwise
83
+ \`~/.config/steward/steward.json\`, and trusts nothing else about this machine. For each capability
84
+ below, establish whether this machine already delivers it, and propose the smallest change if not.
85
+
86
+ 1. **A model catalogue, and load/unload.** \`llama-server\` must run in **router mode** — serving a
87
+ models directory and/or a preset file, with no \`-m\`/\`--model\`/\`-hf\`. Pi's llama.cpp provider
88
+ throws outright on a single-model server.
89
+ 2. **Throughput and request counters.** These come from llama.cpp's Prometheus \`/metrics\`, which is
90
+ **off by default**.
91
+ 3. **Per-slot context fill and the busy count.** From \`/slots\`, on unless disabled.
92
+ 4. **A log console.** One file containing the server's **complete** output — see the traps below.
93
+ 5. **The base URL Pi already expects.** Not a URL of your choosing — the one you found in
94
+ discovery. If \`llama-server\` is not answering there, **move the server to Pi's address**: change
95
+ the port in its service definition, reload, done. Do not move Pi to the server.
96
+
97
+ That direction matters. Editing Pi's provider config means editing a file Pi read once at
98
+ startup and now holds in memory, so the change does not take effect in the running session, may
99
+ live in more than one file, and can break other things pointing at the same provider. Moving the
100
+ server needs no Pi change at all, so nothing has to be reloaded and nothing can be half-applied.
101
+
102
+ If the server cannot move — the port is taken, or something else depends on it — say so and ask.
103
+ Do not finish with the two diverged: chat then fails with "llama.cpp unavailable" while Steward
104
+ reports a perfectly healthy router, which is worse than both being broken because nothing looks
105
+ wrong.
106
+ 6. **Host metrics.** Steward spawns one long-lived command you nominate and reads **NDJSON on its
107
+ stdout**, one object per line forever: \`{"schema":"steward.hostmetrics/1","ts":<epoch ms>,…}\`
108
+ with \`gpuUtil\`, \`cpuUtil\` as fractions 0..1, \`gpuTempC\`, \`cpuTempC\`, \`ramUsedGB\`, \`ramTotalGB\`.
109
+ Every field is \`number|null\`; \`null\` means "this machine cannot measure it" and is never a zero.
110
+ **Use the tool this machine already has before writing your own.** Whatever it is, it will
111
+ report figures a hand-rolled collector cannot reach — temperature in particular usually needs
112
+ privileged access or a vendor tool. Assembling one from general-purpose utilities works and
113
+ silently gives up those readings, leaving permanent no-reading gauges on a machine that could
114
+ have filled them. Check first; say which tool you found, or that you found none and what that
115
+ costs.
116
+ 7. **Start / stop / restart**, recorded as argv arrays. There is no shell: no pipes, no \`&&\`, no
117
+ \`~\`, no \`$UID\` — write absolute paths and real numbers.
118
+
119
+ ## Restarting the server can wedge Pi's connection
120
+
121
+ Pi holds an open connection to \`llama.cpp\`. Restart the server underneath it and the next message
122
+ can hang or fail with "Connection error" while the server answers \`curl\` perfectly. There is no
123
+ reload for this.
124
+
125
+ So if — and only if — you restarted the server, **end by saying that chat may need a nudge**, as the
126
+ last line of your report:
127
+
128
+ > The server was restarted. If chat does not respond, switch models once, or restart Pi.
129
+
130
+ If you did not restart it, do not say this: a reconnect notice on a run that changed nothing reads
131
+ as though something went wrong.
132
+
133
+ ## What will lie to you
134
+
135
+ These are the failure modes you cannot detect by trying them, because each one leaves a machine
136
+ that looks healthy. Everything else here you can verify yourself; these you cannot.
137
+
138
+ - **\`--log-file\` corrupts the log in router mode.** It is a real, documented flag, which is why it
139
+ looks right. The router copies it into every child, each opens it truncate-not-append, and they
140
+ write at independent offsets. Never propose it.
141
+ - **stdout alone gives you an empty log.** llama.cpp writes every levelled line — including every
142
+ error — to **stderr**, and only the forwarded child lines to **stdout**. Redirecting stdout only
143
+ yields a running server with a silent, often 0-byte log. Both streams must reach **one** file, however this
144
+ machine's service manager expresses that. Confirm it against the running process's file
145
+ descriptors, not only the launch record — the descriptors are the same question on every platform,
146
+ and they are the answer that cannot be stale.
147
+ - **\`/metrics\`, \`/slots\` and \`/props\` return 400 both when the flag is missing and when no model is
148
+ loaded.** They cannot tell you whether the server is configured correctly. Determine compliance
149
+ from the **launch arguments**, never from a request.
150
+ - **A hand-written consent hash fails silently.** Steward runs only commands whose sha256 it has
151
+ recorded; a mismatch produces no error, just dark gauges and missing buttons. Always derive it
152
+ with the helper below — never write that map yourself.
153
+ - **Consent covers the argv, not the contents of whatever it points at.** Record the collector as a
154
+ self-contained command — \`["sh","-c","…pipeline…"]\` — never as a path to a script you wrote. A
155
+ recorded script path hashes only the path, so its contents can then change without invalidating
156
+ the consent, which is the entire point of the gate. A script file looks tidier and is a valid
157
+ argv array, which is exactly why this one is easy to get wrong.
158
+ - **Restarting a service does not reload its definition.** Every service manager keeps the loaded
159
+ job separate from the file that describes it, and the restart verb usually acts on the loaded
160
+ copy: the process comes back with a new pid and exit 0, running the *old* definition, and nothing
161
+ in the output says so. Measured on one machine whose service file had gained \`--metrics\` and a
162
+ second redirect while the running process had neither and its log stayed at 0 bytes.
163
+ Find this machine's reload path and use it. Then **prove the change reached the process** — read
164
+ the live argv and the live file descriptors, not the file you edited. A restart that reports
165
+ success is not evidence.
166
+ - **A stop that deregisters the service breaks Start.** Some managers have two kinds of stop: one
167
+ halts the process and leaves the job known, the other removes it entirely. Steward's Stop must be
168
+ the first kind, or Start has nothing left to start. Record the halting form, not the removing
169
+ one — and if reloading a definition requires the removing form, that is a separate step you
170
+ perform yourself, not the command you record.
171
+ - **On unified-memory hardware there is no VRAM figure to report.** Where the GPU shares system
172
+ memory there is no separate pool and no readable ceiling, so record \`memoryTopology: "unified"\`
173
+ with RAM only. Any VRAM number there is invented. A discrete GPU is the other case, not the
174
+ default — decide from the hardware you found, not from the operating system.
175
+ - **\`STEWARD_LOG_FILE\`, if set, overrides the log path you record.** Say which file the console will
176
+ actually follow. And prefer a durable location for the log over a temporary one: systems clear
177
+ their scratch directories on their own schedule, and a server stopped over a long weekend can come
178
+ back to find its history gone.
179
+
180
+ ## Writing the config
181
+
182
+ Do not hand-write the artifact. Build a proposal — the same JSON without a \`consent\` map — and:
183
+
184
+ \`\`\`
185
+ node ${scriptPath} plan --input ./proposal.json
186
+ node ${scriptPath} apply --input ./proposal.json
187
+ \`\`\`
188
+
189
+ \`plan\` validates, derives the consent hashes and shows the diff without writing. \`apply\` backs up,
190
+ writes atomically at mode 0600, and prints the revert command. Run \`help\` for the rest, including
191
+ \`probe-collector\`, which runs a candidate collector and proves it really streams — do that before
192
+ recording one.
193
+
194
+ Proposal shape: \`memoryTopology\`, \`baseUrl\`, \`hostCollector{command,intervalMs}\`, \`log{path}\`,
195
+ \`control{start,stop,restart}\`, \`llama{launchArgv,mechanism,label}\`.
196
+
197
+ ## Ground rules
198
+
199
+ - **Name the disruption in the plan, not after it.** A restart drops every resident model and any
200
+ in-flight request — say so on the line that proposes it, so approving the plan is informed.
201
+ - **Back up before editing, and tell me the exact revert command.** An exact command is one you
202
+ have checked will run on this machine in its current state — not a choice between two forms you
203
+ are unsure about. If you do not know which applies, find out before you offer it: a revert that
204
+ errors when I reach for it is worse than saying you don't know.
205
+ If a path is a symlink into a dotfiles repo, say so: the edit lands in version control.
206
+ - **A missing setup is not damage.** If nothing is running and no service is defined, that is the
207
+ create path — build one and ask me for the directory and port. Never restore a deleted or
208
+ modified configuration from git, a backup, or shell history; that was someone's decision.
209
+ - **Current state is what is on disk and running now.** Anything you learn from history, backups or
210
+ scratch files is inferred — label it, and never record it as this machine's configuration.
211
+ - **Say what you could not establish** rather than guessing, and finish by telling me plainly what
212
+ is live and what is still missing.
213
+ `;
214
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Types for the one helper the parity test imports directly.
3
+ *
4
+ * The script itself stays plain `.mjs` with only `node:` imports, so it runs on
5
+ * the oldest Node this package supports — older than default TypeScript
6
+ * stripping. That is why the drift comparison is duplicated there rather than
7
+ * imported from `core/drift.ts`, and why the parity test exists.
8
+ */
9
+
10
+ export type RecordedArgvDiff =
11
+ | { status: "clean"; added: string[]; removed: string[]; program: string | null }
12
+ | { status: "drifted"; added: string[]; removed: string[]; program: string | null }
13
+ | { status: "unknown"; reason: string };
14
+
15
+ /** Compares a recorded argv against a live `ps` line, as `core/drift.ts` does. */
16
+ export function diffRecordedArgv(recorded: readonly string[], observed: string): RecordedArgvDiff;