peerllm-host-cli 0.3.0 → 1.7.1

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 (49) hide show
  1. package/LICENSE +82 -13
  2. package/README.md +2 -16
  3. package/dist/cli/commands/dashboard.d.ts +1 -0
  4. package/dist/cli/commands/dashboard.d.ts.map +1 -1
  5. package/dist/cli/commands/dashboard.js +465 -209
  6. package/dist/cli/commands/dashboard.js.map +1 -1
  7. package/dist/cli/commands/models.js +8 -8
  8. package/dist/cli/commands/models.js.map +1 -1
  9. package/dist/cli/commands/start.d.ts.map +1 -1
  10. package/dist/cli/commands/start.js +5 -4
  11. package/dist/cli/commands/start.js.map +1 -1
  12. package/dist/cli/tui.d.ts +12 -0
  13. package/dist/cli/tui.d.ts.map +1 -1
  14. package/dist/cli/tui.js +49 -0
  15. package/dist/cli/tui.js.map +1 -1
  16. package/dist/core/catalog.d.ts +10 -7
  17. package/dist/core/catalog.d.ts.map +1 -1
  18. package/dist/core/catalog.js +10 -2
  19. package/dist/core/catalog.js.map +1 -1
  20. package/dist/core/host-stats.d.ts +54 -0
  21. package/dist/core/host-stats.d.ts.map +1 -0
  22. package/dist/core/host-stats.js +148 -0
  23. package/dist/core/host-stats.js.map +1 -0
  24. package/dist/core/model-download.d.ts.map +1 -1
  25. package/dist/core/model-download.js +9 -16
  26. package/dist/core/model-download.js.map +1 -1
  27. package/dist/core/npm-version.d.ts +23 -0
  28. package/dist/core/npm-version.d.ts.map +1 -0
  29. package/dist/core/npm-version.js +73 -0
  30. package/dist/core/npm-version.js.map +1 -0
  31. package/dist/core/orchestrator.d.ts +9 -0
  32. package/dist/core/orchestrator.d.ts.map +1 -1
  33. package/dist/core/orchestrator.js +25 -4
  34. package/dist/core/orchestrator.js.map +1 -1
  35. package/dist/core/token-rate.d.ts +14 -0
  36. package/dist/core/token-rate.d.ts.map +1 -0
  37. package/dist/core/token-rate.js +36 -0
  38. package/dist/core/token-rate.js.map +1 -0
  39. package/dist/daemon/methods.d.ts +4 -0
  40. package/dist/daemon/methods.d.ts.map +1 -1
  41. package/dist/daemon/methods.js +58 -6
  42. package/dist/daemon/methods.js.map +1 -1
  43. package/dist/daemon/run.d.ts.map +1 -1
  44. package/dist/daemon/run.js +10 -0
  45. package/dist/daemon/run.js.map +1 -1
  46. package/dist/shared/protocol.d.ts +41 -1
  47. package/dist/shared/protocol.d.ts.map +1 -1
  48. package/dist/shared/protocol.js +1 -1
  49. package/package.json +2 -2
@@ -1,14 +1,18 @@
1
1
  import { cpus, totalmem, freemem } from "node:os";
2
+ import { readdirSync, existsSync } from "node:fs";
2
3
  import { Command } from "commander";
4
+ import { graphics, fsSize } from "systeminformation";
3
5
  import { resolvePaths } from "../../core/paths.js";
4
6
  import { METHOD } from "../../shared/protocol.js";
5
7
  import { renderBanner, renderBrand } from "../banner.js";
6
8
  import { EXIT } from "../exit-codes.js";
7
9
  import { printError } from "../format.js";
8
10
  import { DaemonUnreachableError, IpcClient } from "../ipc-client.js";
9
- import { ANSI, bar, enterTui, moveTo, padRight, paint, sparkline, truncate, write, } from "../tui.js";
11
+ import { ANSI, bar, centerInWidth, enterTui, moveTo, padRight, padVisible, paint, sparkline, truncate, truncateVisible, visibleLength, write, } from "../tui.js";
10
12
  const MAX_CPU_SAMPLES = 60;
11
13
  const MAX_LOG_LINES = 6;
14
+ const GPU_REFRESH_EVERY = 5; // ticks between systeminformation GPU polls
15
+ const DISK_REFRESH_EVERY = 10; // ticks between disk + local-model-list polls
12
16
  function readCpuSnapshot() {
13
17
  let idle = 0;
14
18
  let total = 0;
@@ -43,121 +47,288 @@ function formatUptime(seconds) {
43
47
  const d = Math.floor(h / 24);
44
48
  return `${d}d ${h % 24}h`;
45
49
  }
46
- function row(label, value, labelWidth = 14) {
50
+ function formatMoney(amount) {
51
+ return amount.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 });
52
+ }
53
+ function row(label, value, labelWidth = 16) {
54
+ // padRight on plain label is safe — no ANSI codes in `label`.
47
55
  return paint("dim", padRight(label, labelWidth)) + value;
48
56
  }
49
- function panelTitle(title) {
50
- return paint(["cyan", "bold"], `┌─ ${title} `) + paint("dim", "─".repeat(Math.max(0, 50 - title.length)));
57
+ const FRAME_MIN = 60;
58
+ const FRAME_MAX = 100;
59
+ const FRAME_PAD = 4; // "│ " + " │"
60
+ function frameWidth(cols) {
61
+ return Math.max(FRAME_MIN, Math.min(FRAME_MAX, cols - 2));
51
62
  }
52
- function panelDivider(width) {
53
- return paint("dim", "─".repeat(width));
63
+ /** Render a fully-bordered panel: `┌─ Title (suffix) ─┐ … │ row │ … └────┘`. */
64
+ function renderPanel(title, suffix, lines, width) {
65
+ const inner = Math.max(2, width - 2); // visible chars between corners
66
+ const header = renderPanelHeader(title, suffix, inner);
67
+ const body = lines
68
+ .map((line) => {
69
+ const trimmed = truncateVisible(line, inner - 2);
70
+ return (paint("cyan", "│ ") +
71
+ padVisible(trimmed, inner - 2) +
72
+ paint("cyan", " │"));
73
+ })
74
+ .join("\n");
75
+ const footer = paint("cyan", "└" + "─".repeat(inner) + "┘");
76
+ return `${header}\n${body}\n${footer}`;
54
77
  }
55
- function render(state, cols) {
56
- // Clear + go home, then redraw.
57
- write(ANSI.home + ANSI.clear);
58
- write(renderBanner() + "\n");
59
- write(" " +
60
- renderBrand() +
61
- paint("dim", " · ") +
62
- paint("dim", "decentralized AI host · ") +
63
- paint(state.lastUpdated ? "gray" : "dim", state.lastUpdated ? `last update ${state.lastUpdated.toLocaleTimeString()}` : "loading…") +
64
- "\n\n");
65
- if (state.lastError) {
66
- write(" " + paint("yellow", `⚠ ${state.lastError}`) + "\n\n");
78
+ function renderPanelHeader(title, suffix, inner) {
79
+ // Target visible width: inner + 2 (matches footer "└" + inner dashes + "┘").
80
+ // Visible parts: "┌─ " (3) + title + " " (in segment) + optional "suffix " + dashes + "┐" (1)
81
+ const titleSegment = `${title} ` + (suffix ? `${suffix} ` : "");
82
+ const dashCount = Math.max(0, inner - 2 - visibleLength(titleSegment));
83
+ const left = paint("cyan", "┌─ ") + paint(["cyan", "bold"], `${title} `);
84
+ const suffixPart = suffix ? paint("yellow", `${suffix} `) : "";
85
+ return left + suffixPart + paint("cyan", "─".repeat(dashCount) + "┐");
86
+ }
87
+ function na(note) {
88
+ return paint("dim", `(${note})`);
89
+ }
90
+ function renderHeader(state, width) {
91
+ const version = state.status?.daemon.version ?? "";
92
+ const lastUpdate = state.lastUpdated
93
+ ? paint("gray", `last update ${state.lastUpdated.toLocaleTimeString()}`)
94
+ : paint("dim", "loading…");
95
+ const out = [];
96
+ // Center each row of the ASCII banner independently.
97
+ for (const row of renderBanner().split("\n")) {
98
+ out.push(centerInWidth(row, width));
67
99
  }
68
- // Daemon + Orchestrator panel
69
- write(panelTitle("Daemon") + "\n");
70
- if (state.status) {
71
- const s = state.status;
72
- write(" " +
73
- row("pid", String(s.daemon.pid)) +
100
+ out.push("");
101
+ out.push(centerInWidth(renderBrand() + paint("dim", " · decentralized AI host · ") + lastUpdate, width));
102
+ out.push(centerInWidth(paint("dim", "© 2026 Hassan Habib."), width));
103
+ out.push(centerInWidth(paint("dim", "Operated by BestBytes AI, LLC."), width));
104
+ out.push(centerInWidth(paint(["cyan", "bold"], "Terms & Conditions") +
105
+ paint("dim", " · ") +
106
+ paint("bold", version ? `v${version}` : "v—"), width));
107
+ return out.join("\n");
108
+ }
109
+ function buildDaemonPanel(state) {
110
+ if (!state.status)
111
+ return [paint("dim", "(awaiting status…)")];
112
+ const s = state.status;
113
+ const upgradeAvailable = s.daemon.latestCliVersion !== undefined &&
114
+ s.daemon.latestCliVersion !== s.daemon.version;
115
+ const versionCell = upgradeAvailable
116
+ ? paint("bold", s.daemon.version) +
117
+ paint("yellow", ` → ${s.daemon.latestCliVersion} on npm`)
118
+ : paint("bold", s.daemon.version);
119
+ const lines = [];
120
+ lines.push(row("pid", String(s.daemon.pid)) +
121
+ paint("dim", " ") +
122
+ row("uptime", paint(["green"], formatUptime(s.daemon.uptimeSeconds)), 10) +
123
+ paint("dim", " ") +
124
+ row("version", versionCell, 10));
125
+ lines.push(row("orchestrator", statusBadge(s.orchestrator.status)));
126
+ const authStr = s.auth.loggedIn
127
+ ? paint("green", "logged in") +
128
+ (s.auth.userId ? paint("dim", ` (user ${s.auth.userId.slice(0, 8)}…)`) : "")
129
+ : paint("yellow", "logged out");
130
+ lines.push(row("auth", authStr));
131
+ lines.push(row("agreement", s.auth.agreementAccepted ? paint("green", "accepted") : paint("yellow", "not accepted")));
132
+ return lines;
133
+ }
134
+ function buildHostPanel(state) {
135
+ if (!state.status)
136
+ return [paint("dim", "(awaiting status…)")];
137
+ const s = state.status;
138
+ const lines = [];
139
+ lines.push(row("name", s.config.hostName ?? paint("dim", "(unset)")));
140
+ lines.push(row("host id", paint("gray", s.config.hostId ? `${s.config.hostId.slice(0, 8)}…` : "(unset)")));
141
+ if (s.host) {
142
+ lines.push(row("status", s.host.isApproved
143
+ ? paint("green", "approved")
144
+ : paint("yellow", "pending approval")));
145
+ lines.push(row("uptime", paint("green", s.host.currentUptime)) +
74
146
  paint("dim", " ") +
75
- row("uptime", paint(["green"], formatUptime(s.daemon.uptimeSeconds)), 8) +
147
+ row("total up", paint("gray", s.host.totalUptime), 12));
148
+ lines.push(row("conversations", paint("cyan", s.host.conversations.toLocaleString())) +
76
149
  paint("dim", " ") +
77
- row("version", paint("bold", s.daemon.version), 9) +
78
- "\n");
79
- write(" " + row("orchestrator", statusBadge(s.orchestrator.status)) + "\n");
80
- const authStr = s.auth.loggedIn
81
- ? paint("green", "logged in") +
82
- (s.auth.userId ? paint("dim", ` (user ${s.auth.userId.slice(0, 8)}…)`) : "")
83
- : paint("yellow", "logged out");
84
- write(" " + row("auth", authStr) + "\n");
85
- const agreementStr = s.auth.agreementAccepted
86
- ? paint("green", "accepted")
87
- : paint("yellow", "not accepted");
88
- write(" " + row("agreement", agreementStr) + "\n");
89
- }
90
- else {
91
- write(" " + paint("dim", "(awaiting status…)") + "\n");
92
- }
93
- write("\n");
94
- // Host panel
95
- write(panelTitle("Host") + "\n");
96
- if (state.status) {
97
- const s = state.status;
98
- const hostName = s.config.hostName ?? paint("dim", "(unset)");
99
- const hostId = s.config.hostId ? `${s.config.hostId.slice(0, 8)}…` : paint("dim", "(unset)");
100
- write(" " + row("name", hostName) + "\n");
101
- write(" " + row("host id", paint("gray", hostId)) + "\n");
150
+ row("avg resp", `${s.host.averageResponseMs.toFixed(0)} ms`, 12));
102
151
  }
103
152
  else {
104
- write(" " + paint("dim", "(awaiting status…)") + "\n");
153
+ lines.push(row("status", na("backend stats loading…")));
105
154
  }
106
- write("\n");
107
- // Model panel
108
- write(panelTitle("Model") + "\n");
155
+ return lines;
156
+ }
157
+ function buildModelPanel(state) {
109
158
  if (state.models?.loaded) {
110
159
  const m = state.models.loaded;
111
- write(" " + row("loaded", paint(["bold", "green"], m.modelName)) + "\n");
112
- write(" " +
160
+ return [
161
+ row("loaded", paint(["bold", "green"], m.modelName)),
113
162
  row("gpu layers", String(m.gpuLayers)) +
114
- paint("dim", " ") +
115
- row("active conv.", paint("cyan", String(m.activeContexts)), 14) +
116
- "\n");
163
+ paint("dim", " ") +
164
+ row("active conv.", paint("cyan", String(m.activeContexts)), 16),
165
+ ];
117
166
  }
118
- else {
119
- write(" " +
120
- paint("dim", "(no model loaded — ") +
167
+ return [
168
+ paint("dim", "(no model loaded — ") +
121
169
  paint(["bold", "cyan"], "peerllm-host models download") +
122
- paint("dim", " to fetch one)") +
123
- "\n");
170
+ paint("dim", " to fetch one)"),
171
+ ];
172
+ }
173
+ function buildResourcesPanel(state, innerWidth) {
174
+ // Reserve space inside the frame: label (16) + padding (4) + percent/label tail (~14).
175
+ const barWidth = Math.max(20, Math.min(50, innerWidth - 36));
176
+ const lines = [];
177
+ lines.push(row("CPU", bar(state.cpuFraction, barWidth, "cyan")) +
178
+ paint("dim", ` ${(state.cpuFraction * 100).toFixed(0)}%`));
179
+ lines.push(row("RAM", bar(state.ramFraction, barWidth, "magenta")) +
180
+ paint("dim", ` ${(state.ramFraction * 100).toFixed(0)}%`));
181
+ if (state.gpu === undefined) {
182
+ lines.push(row("GPU", paint("dim", "detecting…")));
124
183
  }
125
- write("\n");
126
- // Resources panel
127
- const barWidth = Math.max(20, Math.min(40, cols - 40));
128
- write(panelTitle("Resources") + "\n");
129
- write(" " +
130
- row("CPU", bar(state.cpuFraction, barWidth, "cyan")) +
131
- paint("dim", ` ${(state.cpuFraction * 100).toFixed(0)}%`) +
132
- "\n");
133
- write(" " +
134
- row("RAM", bar(state.ramFraction, barWidth, "magenta")) +
135
- paint("dim", ` ${(state.ramFraction * 100).toFixed(0)}%`) +
136
- "\n");
137
- // Sparkline: CPU% over the last MAX_CPU_SAMPLES ticks
138
- const sparkWidth = Math.min(MAX_CPU_SAMPLES, Math.max(20, cols - 20));
139
- const padded = padSparkline(state.cpuSamples, sparkWidth);
140
- write(" " + row("trend (60s)", sparkline(padded, "cyan")) + "\n");
141
- write("\n");
142
- // Logs panel (tails recent log lines if any have arrived from the daemon)
143
- write(panelTitle("Recent activity") + "\n");
144
- if (state.logs.length === 0) {
145
- write(" " + paint("dim", "(no recent events — host idle)") + "\n");
184
+ else if (state.gpu === null) {
185
+ lines.push(row("GPU", na("no GPU detected")));
146
186
  }
147
187
  else {
148
- for (const l of state.logs.slice(-MAX_LOG_LINES)) {
149
- write(" " + formatLog(l, cols - 4) + "\n");
188
+ const g = state.gpu;
189
+ const hasUtil = !Number.isNaN(g.utilizationFraction) && g.utilizationFraction >= 0;
190
+ const gpuLabel = truncate(g.name, 22);
191
+ if (hasUtil) {
192
+ lines.push(row("GPU", bar(g.utilizationFraction, barWidth, "yellow")) +
193
+ paint("dim", ` ${(g.utilizationFraction * 100).toFixed(0)}% ${gpuLabel}`));
194
+ }
195
+ else {
196
+ lines.push(row("GPU", paint("dim", gpuLabel) + paint("gray", " (utilization unavailable)")));
197
+ }
198
+ if (g.vramTotalMB > 0) {
199
+ const vramFrac = Math.max(0, Math.min(1, g.vramUsedMB / g.vramTotalMB));
200
+ lines.push(row("VRAM", bar(vramFrac, barWidth, "yellow")) +
201
+ paint("dim", ` ${(g.vramUsedMB / 1024).toFixed(1)} / ${(g.vramTotalMB / 1024).toFixed(1)} GB`));
150
202
  }
151
203
  }
152
- write("\n");
153
- // Help bar
154
- write(panelDivider(Math.min(cols, 60)) + "\n");
155
- write(" " +
156
- keyHint("q", "quit") +
204
+ if (state.diskFraction !== undefined && state.diskTotalGB && state.diskTotalGB > 0) {
205
+ lines.push(row("Disk", bar(state.diskFraction, barWidth, "green")) +
206
+ paint("dim", ` ${(state.diskUsedGB ?? 0).toFixed(1)} / ${state.diskTotalGB.toFixed(1)} GB`));
207
+ }
208
+ else {
209
+ lines.push(row("Disk", na("disk info unavailable")));
210
+ }
211
+ const sparkWidth = Math.min(MAX_CPU_SAMPLES, Math.max(20, innerWidth - 22));
212
+ lines.push(row("trend (60s)", sparkline(padSparkline(state.cpuSamples, sparkWidth), "cyan")));
213
+ return lines;
214
+ }
215
+ function buildNetworkPanel(state) {
216
+ if (state.status?.network) {
217
+ const n = state.status.network;
218
+ return [
219
+ row("hosts", `${n.activeHosts.toLocaleString()} active`) +
220
+ paint("dim", " ") +
221
+ row("approved", paint("cyan", n.approvedHosts.toLocaleString()), 11) +
222
+ paint("dim", " ") +
223
+ row("total", paint("cyan", n.totalHosts.toLocaleString()), 8),
224
+ row("tokens served", n.totalServedTokens.toLocaleString()),
225
+ row("uptime", `${n.networkUptimePct.toFixed(1)}%`),
226
+ ];
227
+ }
228
+ if (state.status)
229
+ return [na("backend unreachable — retrying")];
230
+ return [na("connecting to daemon…")];
231
+ }
232
+ function buildTokensPanel(state) {
233
+ const t = state.status?.tokens;
234
+ if (!t)
235
+ return [na("awaiting daemon stats…")];
236
+ const fmtFlow = (label, f) => {
237
+ const rate = paint("cyan", `${f.tokensPerSecond.toFixed(1)} tok/s`);
238
+ const session = paint("dim", `session ${f.totalServed.toLocaleString()}`);
239
+ const lifetime = f.lifetime !== undefined
240
+ ? paint("dim", ` · lifetime ${f.lifetime.toLocaleString()}`)
241
+ : "";
242
+ return row(label, `${rate} ${session}${lifetime}`);
243
+ };
244
+ return [fmtFlow("in", t.in), fmtFlow("out", t.out)];
245
+ }
246
+ function buildEarningsPanel(state) {
247
+ if (state.status?.earnings) {
248
+ const e = state.status.earnings;
249
+ return [
250
+ row("pending payout", paint(["green", "bold"], `${formatMoney(e.pendingAmount)} ${e.currency}`)),
251
+ ];
252
+ }
253
+ if (state.status?.auth.loggedIn)
254
+ return [na("awaiting payout data…")];
255
+ return [na("login required to fetch earnings")];
256
+ }
257
+ function buildAvailableLlmsPanel(state, innerWidth) {
258
+ const lines = [];
259
+ const nameBudget = Math.max(20, innerWidth - 6); // leave room for " · " indent
260
+ const localModels = state.localModels;
261
+ if (localModels === undefined) {
262
+ lines.push(row("local", paint("dim", "scanning…")));
263
+ }
264
+ else if (localModels.length === 0) {
265
+ lines.push(row("local", paint("dim", "none — run ") + paint(["bold", "cyan"], "peerllm-host models download")));
266
+ }
267
+ else {
268
+ lines.push(row("local", paint("cyan", String(localModels.length))));
269
+ for (const name of localModels) {
270
+ lines.push(paint("dim", " · ") + paint("cyan", truncateVisible(name, nameBudget)));
271
+ }
272
+ }
273
+ if (state.models?.available !== undefined) {
274
+ lines.push(row("daemon list", paint("cyan", String(state.models.available.length))));
275
+ }
276
+ else {
277
+ lines.push(row("daemon list", na("daemon not reporting")));
278
+ }
279
+ const approved = state.models?.approved;
280
+ if (approved && approved.length > 0) {
281
+ lines.push(row("approved", paint(["green", "bold"], String(approved.length))));
282
+ for (const name of approved) {
283
+ lines.push(paint("dim", " · ") + paint(["green", "bold"], truncateVisible(name, nameBudget)));
284
+ }
285
+ }
286
+ else if (approved !== undefined) {
287
+ lines.push(row("approved", na("none — orchestrator hasn't registered yet")));
288
+ }
289
+ return lines;
290
+ }
291
+ function buildRecentActivityPanel(state, innerWidth) {
292
+ if (state.logs.length === 0)
293
+ return [paint("dim", "(no recent events — host idle)")];
294
+ return state.logs.slice(-MAX_LOG_LINES).map((l) => formatLog(l, innerWidth - 2));
295
+ }
296
+ function render(state, cols) {
297
+ write(ANSI.home + ANSI.clear);
298
+ const width = frameWidth(cols);
299
+ const inner = width - FRAME_PAD; // visible cells available for row content
300
+ write(renderHeader(state, width) + "\n\n");
301
+ if (state.lastError) {
302
+ write(" " + paint("yellow", `⚠ ${state.lastError}`) + "\n\n");
303
+ }
304
+ const panels = [
305
+ ["Daemon", undefined, buildDaemonPanel(state)],
306
+ ["Host", state.status?.host?.stale ? "(stale)" : undefined, buildHostPanel(state)],
307
+ ["Model", undefined, buildModelPanel(state)],
308
+ ["Resources", undefined, buildResourcesPanel(state, inner)],
309
+ [
310
+ "Network",
311
+ state.status?.network?.stale ? "(stale)" : undefined,
312
+ buildNetworkPanel(state),
313
+ ],
314
+ ["Tokens", undefined, buildTokensPanel(state)],
315
+ [
316
+ "Earnings",
317
+ state.status?.earnings?.stale ? "(stale)" : undefined,
318
+ buildEarningsPanel(state),
319
+ ],
320
+ ["Available LLMs", undefined, buildAvailableLlmsPanel(state, inner)],
321
+ ["Recent activity", undefined, buildRecentActivityPanel(state, inner)],
322
+ ];
323
+ for (const [title, suffix, lines] of panels) {
324
+ write(renderPanel(title, suffix, lines, width) + "\n");
325
+ }
326
+ // Help bar — also framed for visual consistency.
327
+ const help = keyHint("q", "quit") +
157
328
  keyHint("r", "refresh now") +
158
329
  keyHint("l", "clear logs") +
159
- keyHint("?", "help") +
160
- "\n");
330
+ keyHint("?", "help");
331
+ write(renderPanel("Keys", undefined, [help.trimEnd()], width) + "\n");
161
332
  }
162
333
  function padSparkline(values, width) {
163
334
  if (values.length >= width)
@@ -181,136 +352,221 @@ function formatLog(l, maxWidth) {
181
352
  const visiblePrefixLen = ts.length + 1 + 5;
182
353
  return prefix + " " + truncate(l.message, Math.max(0, maxWidth - visiblePrefixLen - 1));
183
354
  }
184
- export function makeDashboardCommand() {
185
- return new Command("dashboard")
186
- .description("Interactive TUI: watch live host status, model, and traffic")
187
- .option("--config-dir <path>", "override config directory")
188
- .option("--interval <ms>", "refresh interval in milliseconds (default 1000)", "1000")
189
- .action(async (opts) => {
190
- const intervalMs = Math.max(250, Number.parseInt(opts.interval ?? "1000", 10) || 1000);
191
- const paths = resolvePaths({ configDirOverride: opts.configDir });
192
- const client = new IpcClient(paths.ipcEndpoint);
193
- try {
194
- await client.connect();
355
+ export async function runDashboard(ipcEndpoint, modelsDir, intervalMs = 1000) {
356
+ const client = new IpcClient(ipcEndpoint);
357
+ try {
358
+ await client.connect();
359
+ }
360
+ catch (err) {
361
+ if (err instanceof DaemonUnreachableError) {
362
+ printError("daemon is not running", {
363
+ hint: "Start it with: peerllm-host start",
364
+ });
365
+ process.exit(EXIT.daemonNotRunning);
195
366
  }
196
- catch (err) {
197
- if (err instanceof DaemonUnreachableError) {
198
- printError("daemon is not running", {
199
- hint: "Start it with: peerllm-host start",
200
- });
201
- process.exit(EXIT.daemonNotRunning);
202
- }
203
- printError(err.message);
204
- process.exit(EXIT.runtimeError);
367
+ printError(err.message);
368
+ process.exit(EXIT.runtimeError);
369
+ }
370
+ const tui = enterTui();
371
+ const state = {
372
+ cpuSamples: [],
373
+ ramFraction: 0,
374
+ cpuFraction: 0,
375
+ logs: [],
376
+ };
377
+ let prevCpu = readCpuSnapshot();
378
+ // Init counters to (REFRESH_EVERY - 1) so the very first tick fires a refresh.
379
+ let gpuTick = GPU_REFRESH_EVERY - 1;
380
+ let diskTick = DISK_REFRESH_EVERY - 1;
381
+ client.onNotification((method, params) => {
382
+ const name = method.startsWith("event:") ? method.slice("event:".length) : method;
383
+ if (name === "log") {
384
+ const p = params;
385
+ if (p && typeof p.message === "string")
386
+ state.logs.push(p);
387
+ while (state.logs.length > MAX_LOG_LINES * 2)
388
+ state.logs.shift();
389
+ }
390
+ else if (name === "orchestratorStatus") {
391
+ void pollOnce();
205
392
  }
206
- const tui = enterTui();
207
- const state = {
208
- cpuSamples: [],
209
- ramFraction: 0,
210
- cpuFraction: 0,
211
- logs: [],
212
- };
213
- let prevCpu = readCpuSnapshot();
214
- // Subscribe to daemon events for live updates between polls.
215
- client.onNotification((method, params) => {
216
- const name = method.startsWith("event:") ? method.slice("event:".length) : method;
217
- if (name === "log") {
218
- const p = params;
219
- if (p && typeof p.message === "string")
220
- state.logs.push(p);
221
- while (state.logs.length > MAX_LOG_LINES * 2)
222
- state.logs.shift();
393
+ else if (name === "hostStats") {
394
+ const p = params;
395
+ if (typeof p.cpuPercent === "number") {
396
+ state.cpuFraction = Math.max(0, Math.min(1, p.cpuPercent / 100));
397
+ state.cpuSamples.push(state.cpuFraction);
398
+ while (state.cpuSamples.length > MAX_CPU_SAMPLES)
399
+ state.cpuSamples.shift();
223
400
  }
224
- else if (name === "orchestratorStatus") {
225
- // Force an immediate poll on connection-state changes.
226
- void pollOnce();
401
+ if (typeof p.ramUsedMB === "number" && typeof p.ramTotalMB === "number" && p.ramTotalMB > 0) {
402
+ state.ramFraction = Math.max(0, Math.min(1, p.ramUsedMB / p.ramTotalMB));
227
403
  }
228
- else if (name === "hostStats") {
229
- const p = params;
230
- if (typeof p.cpuPercent === "number") {
231
- state.cpuFraction = Math.max(0, Math.min(1, p.cpuPercent / 100));
232
- state.cpuSamples.push(state.cpuFraction);
233
- while (state.cpuSamples.length > MAX_CPU_SAMPLES)
234
- state.cpuSamples.shift();
235
- }
236
- if (typeof p.ramUsedMB === "number" && typeof p.ramTotalMB === "number" && p.ramTotalMB > 0) {
237
- state.ramFraction = Math.max(0, Math.min(1, p.ramUsedMB / p.ramTotalMB));
238
- }
404
+ if (p.gpu) {
405
+ const prev = state.gpu ?? undefined;
406
+ state.gpu = {
407
+ name: p.gpu.name ?? (prev && prev !== null ? prev.name : "GPU"),
408
+ utilizationFraction: typeof p.gpu.utilizationPercent === "number"
409
+ ? p.gpu.utilizationPercent / 100
410
+ : prev && prev !== null
411
+ ? prev.utilizationFraction
412
+ : Number.NaN,
413
+ vramUsedMB: p.gpu.vramUsedMB ?? (prev && prev !== null ? prev.vramUsedMB : 0),
414
+ vramTotalMB: p.gpu.vramTotalMB ?? (prev && prev !== null ? prev.vramTotalMB : 0),
415
+ };
239
416
  }
240
- });
417
+ }
418
+ });
419
+ try {
420
+ await client.call("events.subscribe", { filter: "*" });
421
+ }
422
+ catch {
423
+ // Non-fatal: polling still works without event subscription.
424
+ }
425
+ const pollOnce = async () => {
241
426
  try {
242
- await client.call("events.subscribe", { filter: "*" });
427
+ const [status, models] = await Promise.all([
428
+ client.call(METHOD.getStatus),
429
+ client.call(METHOD.modelsState),
430
+ ]);
431
+ state.status = status;
432
+ state.models = models;
433
+ state.lastError = undefined;
243
434
  }
244
- catch {
245
- // Non-fatal: events are a nice-to-have; polling still works.
435
+ catch (err) {
436
+ state.lastError = `IPC: ${err.message}`;
437
+ }
438
+ state.lastUpdated = new Date();
439
+ };
440
+ const sampleLocal = async () => {
441
+ // CPU (sync, every tick)
442
+ const next = readCpuSnapshot();
443
+ const totalDelta = next.total - prevCpu.total;
444
+ const idleDelta = next.idle - prevCpu.idle;
445
+ prevCpu = next;
446
+ if (totalDelta > 0) {
447
+ state.cpuFraction = Math.max(0, Math.min(1, 1 - idleDelta / totalDelta));
246
448
  }
247
- const pollOnce = async () => {
449
+ state.cpuSamples.push(state.cpuFraction);
450
+ while (state.cpuSamples.length > MAX_CPU_SAMPLES)
451
+ state.cpuSamples.shift();
452
+ // RAM (sync, every tick)
453
+ const total = totalmem();
454
+ const free = freemem();
455
+ if (total > 0)
456
+ state.ramFraction = Math.max(0, Math.min(1, 1 - free / total));
457
+ // GPU — throttled; uses systeminformation for cross-platform support.
458
+ // Daemon hostStats event takes precedence if it starts sending GPU data.
459
+ gpuTick++;
460
+ if (gpuTick >= GPU_REFRESH_EVERY) {
461
+ gpuTick = 0;
248
462
  try {
249
- const [status, models] = await Promise.all([
250
- client.call(METHOD.getStatus),
251
- client.call(METHOD.modelsState),
252
- ]);
253
- state.status = status;
254
- state.models = models;
255
- state.lastError = undefined;
256
- }
257
- catch (err) {
258
- state.lastError = `IPC: ${err.message}`;
463
+ const { controllers } = await graphics();
464
+ if (controllers.length === 0) {
465
+ state.gpu = null;
466
+ }
467
+ else {
468
+ const c = controllers[0];
469
+ const util = typeof c.utilizationGpu === "number" ? c.utilizationGpu / 100 : Number.NaN;
470
+ const vramTotal = (c.memoryTotal ?? c.vram) ?? 0;
471
+ const vramUsed = c.memoryUsed ?? 0;
472
+ state.gpu = {
473
+ name: c.model ?? "GPU",
474
+ utilizationFraction: util,
475
+ vramUsedMB: vramUsed,
476
+ vramTotalMB: vramTotal,
477
+ };
478
+ }
259
479
  }
260
- state.lastUpdated = new Date();
261
- };
262
- const sampleLocal = () => {
263
- const next = readCpuSnapshot();
264
- const totalDelta = next.total - prevCpu.total;
265
- const idleDelta = next.idle - prevCpu.idle;
266
- prevCpu = next;
267
- if (totalDelta > 0) {
268
- state.cpuFraction = Math.max(0, Math.min(1, 1 - idleDelta / totalDelta));
480
+ catch {
481
+ // Non-fatal: GPU info is best-effort.
269
482
  }
270
- state.cpuSamples.push(state.cpuFraction);
271
- while (state.cpuSamples.length > MAX_CPU_SAMPLES)
272
- state.cpuSamples.shift();
273
- const total = totalmem();
274
- const free = freemem();
275
- if (total > 0)
276
- state.ramFraction = Math.max(0, Math.min(1, 1 - free / total));
277
- };
278
- const tick = async () => {
279
- sampleLocal();
280
- await pollOnce();
281
- render(state, tui.cols);
282
- };
283
- // Initial paint, then steady cadence.
284
- await tick();
285
- const timer = setInterval(() => void tick(), intervalMs);
286
- const exit = (code) => {
287
- clearInterval(timer);
288
- client.close();
289
- tui.teardown();
290
- write(moveTo(1, 1));
291
- process.exit(code);
292
- };
293
- tui.onKey((key) => {
294
- if (key.name === "q" || (key.ctrl && key.name === "c")) {
295
- exit(EXIT.ok);
296
- return;
483
+ }
484
+ // Disk + local model list — throttled together.
485
+ diskTick++;
486
+ if (diskTick >= DISK_REFRESH_EVERY) {
487
+ diskTick = 0;
488
+ // Disk usage via systeminformation (cross-platform).
489
+ try {
490
+ const fsSizes = await fsSize();
491
+ if (fsSizes.length > 0) {
492
+ const mDir = modelsDir.toLowerCase().replace(/\\/g, "/");
493
+ let match = fsSizes[0];
494
+ let bestLen = 0;
495
+ for (const fs of fsSizes) {
496
+ const mount = (fs.mount ?? "").toLowerCase().replace(/\\/g, "/");
497
+ if (mDir.startsWith(mount) && mount.length > bestLen) {
498
+ bestLen = mount.length;
499
+ match = fs;
500
+ }
501
+ }
502
+ if (match.size > 0) {
503
+ state.diskFraction = Math.max(0, Math.min(1, match.use / 100));
504
+ state.diskTotalGB = match.size / 1024 ** 3;
505
+ state.diskUsedGB = match.used / 1024 ** 3;
506
+ }
507
+ }
297
508
  }
298
- if (key.name === "r") {
299
- void tick();
300
- return;
509
+ catch {
510
+ // Non-fatal.
301
511
  }
302
- if (key.name === "l") {
303
- state.logs = [];
304
- render(state, tui.cols);
305
- return;
512
+ // Local GGUF model listing (sync readdir).
513
+ try {
514
+ state.localModels = existsSync(modelsDir)
515
+ ? readdirSync(modelsDir)
516
+ .filter((f) => f.toLowerCase().endsWith(".gguf"))
517
+ .map((f) => f.replace(/\.gguf$/i, ""))
518
+ : [];
306
519
  }
307
- if (key.name === "?") {
308
- state.lastError = "keys: q=quit · r=refresh · l=clear logs · ?=this help";
309
- render(state, tui.cols);
520
+ catch {
521
+ // Non-fatal.
310
522
  }
311
- });
312
- // Park forever (timer + key handler drive the rest).
313
- await new Promise(() => { });
523
+ }
524
+ };
525
+ const tick = async () => {
526
+ await sampleLocal();
527
+ await pollOnce();
528
+ render(state, tui.cols);
529
+ };
530
+ await tick();
531
+ const timer = setInterval(() => void tick(), intervalMs);
532
+ const exit = (code) => {
533
+ clearInterval(timer);
534
+ client.close();
535
+ tui.teardown();
536
+ write(moveTo(1, 1));
537
+ process.exit(code);
538
+ };
539
+ tui.onKey((key) => {
540
+ if (key.name === "q" || (key.ctrl && key.name === "c")) {
541
+ exit(EXIT.ok);
542
+ return;
543
+ }
544
+ if (key.name === "r") {
545
+ void tick();
546
+ return;
547
+ }
548
+ if (key.name === "l") {
549
+ state.logs = [];
550
+ render(state, tui.cols);
551
+ return;
552
+ }
553
+ if (key.name === "?") {
554
+ state.lastError = "keys: q=quit · r=refresh · l=clear logs · ?=this help";
555
+ render(state, tui.cols);
556
+ }
557
+ });
558
+ // Park forever (timer + key handler drive the rest).
559
+ await new Promise(() => { });
560
+ }
561
+ export function makeDashboardCommand() {
562
+ return new Command("dashboard")
563
+ .description("Interactive TUI: watch live host status, model, and traffic")
564
+ .option("--config-dir <path>", "override config directory")
565
+ .option("--interval <ms>", "refresh interval in milliseconds (default 1000)", "1000")
566
+ .action(async (opts) => {
567
+ const intervalMs = Math.max(250, Number.parseInt(opts.interval ?? "1000", 10) || 1000);
568
+ const paths = resolvePaths({ configDirOverride: opts.configDir });
569
+ await runDashboard(paths.ipcEndpoint, paths.modelsDir, intervalMs);
314
570
  });
315
571
  }
316
572
  //# sourceMappingURL=dashboard.js.map