@jmcombs/pi-steward 0.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +140 -0
  3. package/core/disconnected-source.ts +110 -0
  4. package/core/drift.ts +247 -0
  5. package/core/format.ts +317 -0
  6. package/core/host-metrics.ts +121 -0
  7. package/core/llama-config.ts +72 -0
  8. package/core/llama-connection.ts +215 -0
  9. package/core/llama-models.ts +261 -0
  10. package/core/llama-slots.ts +104 -0
  11. package/core/llama-source.ts +1523 -0
  12. package/core/log-parse.ts +440 -0
  13. package/core/model-color.ts +59 -0
  14. package/core/select.ts +2923 -0
  15. package/core/slot-activity.ts +658 -0
  16. package/core/source.ts +84 -0
  17. package/core/state.ts +609 -0
  18. package/core/status-widget.ts +222 -0
  19. package/core/temperature.ts +149 -0
  20. package/core/types.ts +431 -0
  21. package/index.ts +503 -0
  22. package/package.json +51 -0
  23. package/server/api.ts +216 -0
  24. package/server/assets.ts +198 -0
  25. package/server/config-wiring.ts +490 -0
  26. package/server/drift-probe.ts +150 -0
  27. package/server/host-collector.ts +272 -0
  28. package/server/index.ts +228 -0
  29. package/server/log-tailer.ts +432 -0
  30. package/server/service-control.ts +337 -0
  31. package/server/service-probe.ts +71 -0
  32. package/server/steward-config.ts +430 -0
  33. package/setup/init-prompt.ts +214 -0
  34. package/setup/steward-setup.d.mts +16 -0
  35. package/setup/steward-setup.mjs +1398 -0
  36. package/ui/components/console.ts +511 -0
  37. package/ui/components/gauges.ts +120 -0
  38. package/ui/components/metrics.ts +63 -0
  39. package/ui/components/models.ts +296 -0
  40. package/ui/components/service.ts +358 -0
  41. package/ui/components/slots.ts +114 -0
  42. package/ui/components/sparkline.ts +59 -0
  43. package/ui/components/toolbar.ts +211 -0
  44. package/ui/dom.ts +120 -0
  45. package/ui/favicon.svg +17 -0
  46. package/ui/index.html +34 -0
  47. package/ui/main.ts +678 -0
  48. package/ui/steward.css +2008 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Jeremy Combs
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,140 @@
1
+ <div align="center">
2
+ <img src="https://raw.githubusercontent.com/jmcombs/pi-extensions/main/assets/steward/preview.png" width="250" alt="@jmcombs/pi-steward">
3
+ <br>
4
+ <a href="https://www.npmjs.com/package/@jmcombs/pi-steward"><img src="https://img.shields.io/npm/v/@jmcombs/pi-steward.svg" alt="npm version"></a>
5
+ <a href="https://www.npmjs.com/package/@jmcombs/pi-steward"><img src="https://img.shields.io/npm/dm/@jmcombs/pi-steward.svg" alt="npm downloads"></a>
6
+ <a href="https://opensource.org/licenses/MIT"><img src="https://img.shields.io/badge/License-MIT-yellow.svg" alt="License: MIT"></a>
7
+ <a href="https://github.com/jmcombs/pi-extensions/stargazers"><img src="https://img.shields.io/github/stars/jmcombs/pi-extensions?style=social" alt="GitHub stars"></a>
8
+ <a href="https://github.com/jmcombs/pi-extensions/issues"><img src="https://img.shields.io/github/issues/jmcombs/pi-extensions" alt="Open issues"></a>
9
+ <a href="https://github.com/sponsors/jmcombs"><img src="https://img.shields.io/badge/Sponsor-30363D?style=flat&logo=GitHub-Sponsors&logoColor=EA4AAA" alt="Sponsor"></a>
10
+ </div>
11
+
12
+ # @jmcombs/pi-steward
13
+
14
+ > **Steward — the llama.cpp control panel for [Pi](https://pi.dev).** *How may we serve your models?*
15
+
16
+ A single-page operator dashboard for the local `llama-server` behind Pi's llama.cpp provider, plus a
17
+ status bar inside Pi itself. It answers four questions at a glance, and lets you act on all of them
18
+ without leaving your editor:
19
+
20
+ 1. **Is the service up**, and for how long?
21
+ 2. **Which models are resident**, and what is each costing you in memory?
22
+ 3. **Is the box healthy** — GPU, CPU, memory, temperature?
23
+ 4. **What is the server doing right now** — slots, throughput, requests, and its live log?
24
+
25
+ Start, stop and restart the service; load and unload models; watch the log — all from the dashboard.
26
+
27
+ <div align="center">
28
+ <img src="https://raw.githubusercontent.com/jmcombs/pi-extensions/main/assets/steward/dashboard.png" width="900" alt="The Steward dashboard">
29
+ </div>
30
+
31
+ ## Requirements
32
+
33
+ | | |
34
+ | --- | --- |
35
+ | **Node** | ≥ 22.13 |
36
+ | **[Pi](https://pi.dev)** | any recent version |
37
+ | **`llama-server`** | running in **router mode** — `--models-dir` and/or `--models-preset`, with no `-m`/`--model`/`-hf`. Pi's own llama.cpp provider requires this too. |
38
+ | **A [Nerd Font](https://www.nerdfonts.com)** | for the status bar's mark and separators. Set `STEWARD_GLYPH=""` to drop the mark if you would rather not install one. |
39
+
40
+ `/steward_initialize` checks everything else — metrics, slots, log capture, host sensors — and offers
41
+ to fix whatever is missing.
42
+
43
+ ## Install
44
+
45
+ ```bash
46
+ # Globally (recommended)
47
+ pi install npm:@jmcombs/pi-steward
48
+
49
+ # For a single session, without installing
50
+ pi -e ./packages/steward
51
+ ```
52
+
53
+ See the [Pi packages documentation](https://pi.dev/docs/packages) for git, local path,
54
+ project-scoped install, and filtering options.
55
+
56
+ ## Quick start
57
+
58
+ Start Pi, then:
59
+
60
+ ```
61
+ /steward_initialize 1. connect this machine — review, approve, done
62
+ /steward_start 2. start the dashboard service
63
+ /steward_dashboard 3. open it in your browser
64
+ ```
65
+
66
+ That is the whole path. Step 1 is once per machine; steps 2 and 3 are once per session — and
67
+ `/steward_dashboard` starts the service for you, so you can skip straight to it.
68
+
69
+ ## Commands
70
+
71
+ | Command | What it does |
72
+ | --- | --- |
73
+ | `/steward_initialize` | Connects this machine to Steward. Works out how `llama-server` runs here, proposes the changes it needs, and writes `steward.json` — **asking before every change**. Run once per machine, or again whenever the setup moves. |
74
+ | `/steward_start` | Starts the dashboard service on `127.0.0.1:8788` for this session. Prints the URL and opens nothing — useful on a headless box, or when you only want the status bar. |
75
+ | `/steward_dashboard` | Opens the dashboard in your browser, starting the service first if it is not already running. |
76
+ | `/steward_stop` | Stops the dashboard service. It also stops on its own when the Pi session ends. |
77
+
78
+ ## The status bar
79
+
80
+ Steward draws a line above the editor whenever it has something to say. It reports **Steward's own
81
+ state** first, with `llama.cpp` as detail.
82
+
83
+ <div align="center">
84
+ <img src="https://raw.githubusercontent.com/jmcombs/pi-extensions/main/assets/steward/status-states.svg" width="620" alt="Steward status bar states">
85
+ </div>
86
+
87
+ - **Everything up** — the dashboard is on `:8788`, `llama.cpp` answers on `:8080`, and 1 of its 10
88
+ models is resident. The count is models holding weights in memory, not models on disk.
89
+ - **llama.cpp down** — Steward is fine and says so in green; the server gets its own red block. The
90
+ dashboard still opens, and Start is there if you recorded one.
91
+ - **Not read yet** — Steward is up but has not completed a read. Deliberately its own colour: an
92
+ absent reading is not a healthy one.
93
+ - **Dashboard not running** — no `/steward_start` yet, or you stopped it. Nothing is said about
94
+ `llama.cpp`, because Steward is not watching it.
95
+ - **Pointed elsewhere** — Pi is dialling a different address than the server Steward watches, so
96
+ chat fails while everything else looks healthy. The bar names the fix.
97
+
98
+ Three colours, and only three: **green** started, **red** stopped, **orange** running but something
99
+ needs a person.
100
+
101
+ It refreshes while the dashboard is up, and reads the same data the dashboard does — no extra
102
+ connection to your server.
103
+
104
+
105
+ ## Configuration
106
+
107
+ Run `/steward_initialize` and let your clanker set me up. It reviews the local environment, captures
108
+ how `llama.cpp` is currently configured, works out what is needed for Pi, Steward and `llama.cpp` to
109
+ run together, then hands you a plan to approve. Nothing is applied before you say so.
110
+
111
+ It writes `~/.config/steward/steward.json` at mode `0600` — the collector, the log path, the base
112
+ URL, the service commands, and a consent hash for every command Steward may run. Steward ignores
113
+ that file if it is not owned by you or is world-writable, because it is a code-execution surface.
114
+
115
+
116
+ ### Environment variables
117
+
118
+ | Variable | Default | Purpose |
119
+ | --- | --- | --- |
120
+ | `STEWARD_CONFIG` | `~/.config/steward/steward.json` | Where the artifact lives. |
121
+ | `STEWARD_PORT` | `8788` | Dashboard port. `0` asks the OS for any free port; a port already taken costs an ephemeral one, not the dashboard. |
122
+ | `STEWARD_LOG_FILE` | — | Overrides the recorded `log.path` for the log console. |
123
+ | `STEWARD_GLYPH` | `󰢍` | The status bar's mark. `""` drops it; any other value replaces it. |
124
+
125
+
126
+ ## Development
127
+
128
+ ```bash
129
+ # From the repo root
130
+ npx vitest run packages/steward # tests
131
+ node scripts/typecheck.mjs # types
132
+ npx biome check packages/steward # lint and format
133
+
134
+ # Run the dashboard without Pi
135
+ npx tsx packages/steward/scripts/dev.ts
136
+ ```
137
+
138
+ ## License
139
+
140
+ MIT © [Jeremy Combs](https://github.com/jmcombs)
@@ -0,0 +1,110 @@
1
+ /**
2
+ * The source Steward uses when it has nothing to read.
3
+ *
4
+ * This replaces the simulated source the dashboard used to fall back on. That
5
+ * fallback was a liability: a machine with no `steward.json`, or one whose
6
+ * `llama-server` could not be reached, rendered a complete, plausible, moving
7
+ * dashboard made entirely of invented numbers — indistinguishable from the real
8
+ * thing. It was observed serving three "loaded" models on a machine whose router
9
+ * had ten models and none loaded.
10
+ *
11
+ * Nothing here invents a reading. Every gauge is `NaN` (which the UI renders as
12
+ * a no-reading track, never a zero), every list is empty, drift is `unknown`
13
+ * with a reason, and the service reports itself not running with no controls.
14
+ * The panels go blank and say so, which is the only honest thing a dashboard can
15
+ * do about a machine it cannot see.
16
+ *
17
+ * Keep this module free of Node and DOM APIs — see `./types.ts`.
18
+ */
19
+
20
+ import { unknownDrift } from "./drift.js";
21
+ import type { LogAttachment, StewardDataSource, Unsubscribe } from "./source.js";
22
+ import type { HostMetrics, LogLine, ModelAction, ServiceAction, Snapshot } from "./types.js";
23
+
24
+ /** Why the dashboard has nothing to show, in the operator's vocabulary. */
25
+ export const NOT_CONNECTED_REASON =
26
+ "Steward is not connected to this machine — run /steward_initialize";
27
+
28
+ /**
29
+ * Every gauge as a no-reading. `NaN` is deliberate and load-bearing: the UI
30
+ * distinguishes it from `0`, so an unmeasured host reads as blank rather than as
31
+ * an idle one.
32
+ */
33
+ function noReadings(): HostMetrics {
34
+ return {
35
+ vramUsedGB: Number.NaN,
36
+ vramTotalGB: Number.NaN,
37
+ ramUsedGB: Number.NaN,
38
+ ramTotalGB: Number.NaN,
39
+ gpuUtil: Number.NaN,
40
+ cpuUtil: Number.NaN,
41
+ gpuTempC: null,
42
+ cpuTempC: null,
43
+ };
44
+ }
45
+
46
+ /**
47
+ * A complete Snapshot that asserts nothing. `now` is the caller's clock so the
48
+ * dashboard's own staleness logic still works; everything else is absent.
49
+ */
50
+ export function disconnectedSnapshot(now: number): Snapshot {
51
+ return {
52
+ now,
53
+ service: {
54
+ running: false,
55
+ startedAt: null,
56
+ pid: null,
57
+ host: "",
58
+ port: 0,
59
+ build: "",
60
+ controls: [],
61
+ },
62
+ models: [],
63
+ slots: [],
64
+ metrics: noReadings(),
65
+ memoryTopology: "unified",
66
+ drift: unknownDrift(NOT_CONNECTED_REASON),
67
+ throughputTps: null,
68
+ throughputWindowSeconds: null,
69
+ requestsInFlight: null,
70
+ throughputHistory: [],
71
+ requestsQueued: null,
72
+ config: [],
73
+ };
74
+ }
75
+
76
+ /** A source with no machine behind it. Every read is empty; every write refuses. */
77
+ export function createDisconnectedSource(): StewardDataSource {
78
+ return {
79
+ name: "disconnected",
80
+
81
+ async snapshot(): Promise<Snapshot> {
82
+ return disconnectedSnapshot(Date.now());
83
+ },
84
+
85
+ recentLogs(): LogLine[] {
86
+ return [];
87
+ },
88
+
89
+ subscribeLogs(): Unsubscribe {
90
+ // Nothing will ever arrive, but the contract is a working unsubscribe.
91
+ return () => {};
92
+ },
93
+
94
+ attachLogs(): LogAttachment {
95
+ return { backlog: [], unsubscribe: () => {} };
96
+ },
97
+
98
+ // Refusing beats resolving: a caller that thinks a start succeeded here
99
+ // would poll forever for a service that was never asked to do anything.
100
+ async setService(action: ServiceAction): Promise<void> {
101
+ throw new Error(`cannot ${action} — ${NOT_CONNECTED_REASON}`);
102
+ },
103
+
104
+ async setModel(modelId: string, action: ModelAction): Promise<void> {
105
+ throw new Error(`cannot ${action} ${modelId} — ${NOT_CONNECTED_REASON}`);
106
+ },
107
+
108
+ close(): void {},
109
+ };
110
+ }
package/core/drift.ts ADDED
@@ -0,0 +1,247 @@
1
+ /**
2
+ * Config drift — the honesty check behind everything `steward.json` asserts.
3
+ *
4
+ * Steward's facts about a machine (which flags `llama-server` was launched
5
+ * with, which commands the operator approved) come from an artifact written
6
+ * once, by the `/steward_initialize` skill. Nothing stops the operator editing
7
+ * their launchd plist or systemd unit afterwards, and nothing announces it when
8
+ * they do. Without a re-check, a machine that lost `--metrics` shows a dark
9
+ * throughput tile AND — because "fully compliant renders nothing" — an implicit
10
+ * all-clear. This module is the type surface that makes the mismatch visible.
11
+ *
12
+ * Two independent producers feed one notice:
13
+ * - {@link LaunchDrift}: the live process argv diffed against what was
14
+ * recorded (the Node body that reads it lives in `server/drift-probe.ts`;
15
+ * the comparison itself is {@link diffLaunchArgv}, here, so it is testable
16
+ * without a real `llama-server`).
17
+ * - {@link ConsentDrift}: a collector or control command `steward.json`
18
+ * declares but whose exact command is NOT in the consent map. Today that
19
+ * silently does nothing — no collector, no button — and the operator has no
20
+ * way to tell an inert panel from an unconfigured one.
21
+ *
22
+ * The load-bearing rule for both: a check that could not be made is `unknown`,
23
+ * never a quiet "clean" and never a fabricated "drifted". A false alarm on a
24
+ * correctly configured machine costs exactly as much trust as a missed one.
25
+ *
26
+ * Keep this module free of Node and DOM APIs — see `./types.ts`.
27
+ */
28
+
29
+ import type { ServiceAction } from "./types.js";
30
+
31
+ /**
32
+ * The verdict of the launch-argv re-check.
33
+ *
34
+ * `unknown` is the honest degrade: no listening process, no `ps`, a permission
35
+ * error, a command line the process list truncated, or no recorded argv to
36
+ * compare against. It renders nothing, exactly as `clean` does — the difference
37
+ * is that `clean` is a statement and `unknown` is an absence of one.
38
+ */
39
+ export type LaunchDriftStatus = "clean" | "drifted" | "unknown";
40
+
41
+ /** The program path recorded at setup against the one running now. */
42
+ export interface ProgramChange {
43
+ recorded: string;
44
+ observed: string;
45
+ }
46
+
47
+ /**
48
+ * How the running process's argv compares to the argv `/steward_initialize`
49
+ * recorded. `added` and `removed` are whole flag GROUPS (`--port 8080`, not two
50
+ * loose tokens), so the notice can name what actually changed rather than
51
+ * printing two command lines and leaving the operator to spot the difference.
52
+ */
53
+ export interface LaunchDrift {
54
+ status: LaunchDriftStatus;
55
+ /** Groups the live process carries that were not recorded, e.g. `--no-slots`. */
56
+ added: string[];
57
+ /** Groups that were recorded and are gone now, e.g. `--metrics`. */
58
+ removed: string[];
59
+ /** Set when the binary itself changed; `null` when it did not. */
60
+ program: ProgramChange | null;
61
+ /** Why no verdict could be reached (`unknown` only); `null` otherwise. */
62
+ reason: string | null;
63
+ }
64
+
65
+ /**
66
+ * Commands `steward.json` declares but that carry no matching entry in its
67
+ * consent map. Steward refuses to run them — that is the security gate working
68
+ * — but silence makes the resulting empty panel indistinguishable from one that
69
+ * was never configured.
70
+ */
71
+ export interface ConsentDrift {
72
+ /** True when a collector is declared and its exact command is unapproved. */
73
+ hostCollector: boolean;
74
+ /** Declared-but-unapproved control actions, in start/stop/restart order. */
75
+ controls: ServiceAction[];
76
+ }
77
+
78
+ /** Both producers, as carried on a {@link import("./types.js").Snapshot}. */
79
+ export interface DriftState {
80
+ launch: LaunchDrift;
81
+ consent: ConsentDrift;
82
+ }
83
+
84
+ /**
85
+ * Re-validates the launch argv of one already-identified process. Node-side and
86
+ * platform-specific (it shells out to `ps`), so it is injected rather than
87
+ * imported by the source — this module stays free of Node APIs, mirroring
88
+ * {@link import("./llama-source.js").ServiceProbe}.
89
+ *
90
+ * It takes the pid the snapshot already resolved rather than a host and port,
91
+ * so the drift notice and the SERVICE block can never end up describing two
92
+ * different processes (and so a second `lsof` is not run per poll). A `null`
93
+ * pid — nothing listening, or no process probe configured — is a check that
94
+ * could not be made. It never rejects: every failure is an `unknown`
95
+ * {@link LaunchDrift}.
96
+ */
97
+ export type DriftProbe = (pid: number | null) => Promise<LaunchDrift>;
98
+
99
+ /** Nothing declared, nothing unapproved. */
100
+ export const NO_CONSENT_DRIFT: ConsentDrift = { hostCollector: false, controls: [] };
101
+
102
+ /** An "it could not be checked" verdict carrying the reason it could not. */
103
+ export function unknownLaunchDrift(reason: string): LaunchDrift {
104
+ return { status: "unknown", added: [], removed: [], program: null, reason };
105
+ }
106
+
107
+ /** The drift state of a source that does not re-validate anything. */
108
+ export function unknownDrift(reason: string): DriftState {
109
+ return { launch: unknownLaunchDrift(reason), consent: NO_CONSENT_DRIFT };
110
+ }
111
+
112
+ /**
113
+ * What `ps` prints when it has a process but no argv to show for it: a zombie,
114
+ * or a Linux `/proc` mounted with `hidepid`, comes back as `(llama-server)` or
115
+ * `[llama-server]`. It is a placeholder, not a command line — diffing against it
116
+ * would report every recorded flag removed AND a changed binary, the loudest
117
+ * possible false alarm on a machine that changed nothing.
118
+ */
119
+ const PLACEHOLDER_COMMAND = /^\(.*\)$|^\[.*\]$/u;
120
+
121
+ /**
122
+ * Splits an argv into flag groups: a token starting with `-` opens a group and
123
+ * every following non-flag token belongs to it, so `--port 8080` is one unit and
124
+ * `--metrics` is another. Tokens before the first flag are the program and its
125
+ * bare operands, returned separately — the binary changing is a different fact
126
+ * from a flag changing, and reads differently in the notice.
127
+ *
128
+ * `--flag=value` is normalised to `--flag value`: llama.cpp accepts a flag
129
+ * either way, so treating the two spellings as different would nag an operator
130
+ * whose plist merely writes them differently.
131
+ *
132
+ * Known limitation: a quoted VALUE that itself starts with a dash (`--alias
133
+ * "-Fast- Model"`) opens a group of its own, because a `ps` line has lost the
134
+ * quoting that would say otherwise. It costs only precision in the wording of a
135
+ * diff that is already being reported — the verdict is unaffected while nothing
136
+ * else changed, since both sides group the same way.
137
+ */
138
+ function groupArgv(tokens: readonly string[]): { program: string; groups: string[] } {
139
+ const groups: string[] = [];
140
+ const leading: string[] = [];
141
+ let current: string[] | null = null;
142
+
143
+ const flush = (): void => {
144
+ if (current !== null) groups.push(current.join(" "));
145
+ current = null;
146
+ };
147
+
148
+ for (const token of tokens) {
149
+ if (token.startsWith("-") && token !== "-" && token !== "--") {
150
+ flush();
151
+ const equals = token.indexOf("=");
152
+ // Only a flag's own `=` is split; a VALUE may legitimately contain one
153
+ // (`--override-kv tokenizer.ggml.add_bos_token=bool:false`) and is left
154
+ // exactly as written.
155
+ current = equals > 0 ? [token.slice(0, equals), token.slice(equals + 1)] : [token];
156
+ continue;
157
+ }
158
+ if (current === null) leading.push(token);
159
+ else current.push(token);
160
+ }
161
+ flush();
162
+
163
+ return { program: leading.join(" "), groups };
164
+ }
165
+
166
+ /** The groups in `a` that `b` does not have, counting duplicates. */
167
+ function missingFrom(a: readonly string[], b: readonly string[]): string[] {
168
+ const remaining = new Map<string, number>();
169
+ for (const group of b) remaining.set(group, (remaining.get(group) ?? 0) + 1);
170
+
171
+ const missing: string[] = [];
172
+ for (const group of a) {
173
+ const count = remaining.get(group) ?? 0;
174
+ if (count > 0) remaining.set(group, count - 1);
175
+ else missing.push(group);
176
+ }
177
+ return missing;
178
+ }
179
+
180
+ /** Whitespace-separated tokens, with the empties dropped. */
181
+ function tokenize(line: string): string[] {
182
+ return line.split(/\s+/u).filter((token) => token !== "");
183
+ }
184
+
185
+ /**
186
+ * Diffs the live command line against the argv recorded at setup.
187
+ *
188
+ * `observed` is one `ps` line: the argv joined on single spaces, which is
189
+ * exactly what `recorded.join(" ")` produces — so an equal join is a clean
190
+ * verdict no matter how the argv was quoted, and quoting can never fake a drift.
191
+ *
192
+ * What counts as drift is deliberately narrow, because a false alarm on a
193
+ * correctly configured machine is as damaging as a missed one:
194
+ * - ORDER is not drift. The groups are compared as multisets; a plist that
195
+ * lists the same flags in a different order changed nothing about how the
196
+ * server runs.
197
+ * - A line the process list CUT SHORT is not drift. A truncated line is a
198
+ * strict prefix of the recorded one that stops mid-token; that is reported
199
+ * `unknown`. A line that stops exactly at a token boundary is NOT treated as
200
+ * truncation — that is the single most likely real edit (deleting the last
201
+ * flag), and swallowing it would defeat the whole check.
202
+ * - Nothing recorded, nothing observed, or a `ps` PLACEHOLDER in place of a
203
+ * command line is `unknown`, never `clean`.
204
+ */
205
+ export function diffLaunchArgv(recorded: readonly string[], observed: string): LaunchDrift {
206
+ const expectedTokens = recorded.filter((token) => token !== "");
207
+ if (expectedTokens.length === 0) {
208
+ return unknownLaunchDrift("no launch command was recorded for this machine");
209
+ }
210
+
211
+ const line = observed.trim();
212
+ if (line === "" || PLACEHOLDER_COMMAND.test(line)) {
213
+ return unknownLaunchDrift("the process list reported no command line");
214
+ }
215
+
216
+ const expected = expectedTokens.join(" ");
217
+ if (line === expected) {
218
+ return { status: "clean", added: [], removed: [], program: null, reason: null };
219
+ }
220
+
221
+ // Cut mid-token: `ps` gave us less than the process actually holds, so there
222
+ // is no verdict to reach — saying "these flags were removed" here would
223
+ // invent drift out of a display width.
224
+ if (expected.startsWith(line) && expected.charAt(line.length) !== " ") {
225
+ return unknownLaunchDrift("the process list truncated the command line");
226
+ }
227
+
228
+ const before = groupArgv(expectedTokens);
229
+ const after = groupArgv(tokenize(line));
230
+ const added = missingFrom(after.groups, before.groups);
231
+ const removed = missingFrom(before.groups, after.groups);
232
+ const program = before.program === after.program ? null : after.program;
233
+
234
+ if (added.length === 0 && removed.length === 0 && program === null) {
235
+ // Same program, same groups: the two lines differ only in the order the
236
+ // flags were written, which changes nothing the server does.
237
+ return { status: "clean", added: [], removed: [], program: null, reason: null };
238
+ }
239
+
240
+ return {
241
+ status: "drifted",
242
+ added,
243
+ removed,
244
+ program: program === null ? null : { recorded: before.program, observed: program },
245
+ reason: null,
246
+ };
247
+ }