@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
|
@@ -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;
|