Files
llama-watch/docs/superpowers/plans/2026-08-14-llama-watch.md
T

73 KiB
Raw Blame History

llama-watch Stream Deck Plugin — Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Build a local-install Stream Deck plugin ("llama-watch") with two key actions — an in-flight request monitor per model and a live GPU metric graph — backed by a llama-swap instance at http://localhost:9292.

Architecture: A Node.js/TypeScript plugin running as a process launched by the Stream Deck app (SDK v2, @elgato/streamdeck@2.1.1). It maintains a persistent SSE connection to llama-swap /api/events (real-time in-flight + model status) and a 5s polling loop over /metrics (Prometheus text). Pure-logic modules (parser, tracker, poller, SSE parser, SVG renderer) are unit-tested with node:test + tsx. Key images are rendered as dynamic SVG strings passed to KeyAction.setImage — no canvas dependency.

Tech Stack: TypeScript, @elgato/streamdeck@^2.1.1, Rollup (@rollup/plugin-typescript, terser), @elgato/cli@^1.8.1 for validate/pack, tsx for tests, Node 24 runtime declared in the manifest (Stream Deck bundles it; the SDK requires ≥24). Icons generated from hand-written SVGs via macOS sips.

Global Constraints

  • Plugin UUID: com.bryce.llamawatch (reverse-DNS; lowercase alphanumeric, hyphens, periods only).
  • Action UUIDs: com.bryce.llamawatch.inflight, com.bryce.llamawatch.gpu.
  • Manifest: SDKVersion: 2, Nodejs.Version: "24", macOS only (OS: [{Platform: "mac", MinimumVersion: "13"}]), Software.MinimumVersion: "7.1". (Verified against @elgato/schemas: Nodejs "24" requires Software ≥ 7.1; the user's Stream Deck app is 7.4.2.)
  • Dependency versions pinned per the official hello-world sample: @elgato/streamdeck@^2.1.1, @elgato/cli@^1.8.1, @rollup/plugin-commonjs@^28.0.2, @rollup/plugin-node-resolve@^16.0.0, @rollup/plugin-terser@^0.4.4, @rollup/plugin-typescript@^12.1.2, @tsconfig/node20@^20.1.4, @types/node@22.13.0, rollup@^4.32.1, tslib@^2.8.1, typescript@^5.7.3, tsx@^4.19.2.
  • Poll interval for /metrics: fixed 5000 ms; ring buffer 60 samples (~5 min).
  • Metrics (from llamaswap_gpu_* Prometheus series): util_percent, memory_util_percent, temperature, power, fan.
  • Aggregates for GPU selector "all": util/memory/fan = average, temperature = max, power = sum.
  • Default base URL: http://localhost:9292. No hardcoded URLs beyond the default. Optional API key sent as Authorization: Bearer <key> when set.
  • Key press on either action opens <baseUrl>/ui in the default browser via streamDeck.system.openUrl.
  • No Marketplace submission in scope; assets are prepared marketplace-ready (plugin PNG 256/512, SVG action icons, category icon).
  • No code comments. Follow the no-comments rule strictly.
  • Existing conventions from the official sample: entry src/plugin.ts, output ${PLUGIN}.sdPlugin/bin/plugin.js, extension-less relative TS imports, ESM ("type": "module").

Task 1: Scaffold project structure

Files:

  • Create: package.json
  • Create: tsconfig.json
  • Create: rollup.config.mjs
  • Create: com.bryce.llamawatch.sdPlugin/manifest.json (placeholder manifest)
  • Create: src/plugin.ts (placeholder)
  • Create: src/actions/inflight-monitor.ts (placeholder), src/actions/gpu-graph.ts (placeholder)
  • Create: tests/.gitkeep
  • Create: .gitignore

Interfaces:

  • Consumes: nothing.

  • Produces: working npm run build (rollup outputs com.bryce.llamawatch.sdPlugin/bin/plugin.js), npm test runner available, placeholder plugin registers no actions yet.

  • Step 1: Create package.json

{
  "name": "llama-watch",
  "version": "1.0.0",
  "description": "Stream Deck plugin to monitor llama-swap requests and GPU metrics.",
  "private": true,
  "type": "module",
  "scripts": {
    "build": "rollup -c",
    "watch": "rollup -c -w --watch.onEnd=\"streamdeck restart com.bryce.llamawatch\"",
    "test": "tsx --test tests/"
  },
  "devDependencies": {
    "@elgato/cli": "^1.8.1",
    "@rollup/plugin-commonjs": "^28.0.2",
    "@rollup/plugin-node-resolve": "^16.0.0",
    "@rollup/plugin-terser": "^0.4.4",
    "@rollup/plugin-typescript": "^12.1.2",
    "@tsconfig/node20": "^20.1.4",
    "@types/node": "22.13.0",
    "rollup": "^4.32.1",
    "tslib": "^2.8.1",
    "tsx": "^4.19.2",
    "typescript": "^5.7.3"
  },
  "dependencies": {
    "@elgato/streamdeck": "^2.1.1"
  }
}
  • Step 2: Create tsconfig.json
{
  "extends": "@tsconfig/node20/tsconfig.json",
  "compilerOptions": {
    "customConditions": ["node"],
    "noImplicitOverride": true,
    "module": "ES2022",
    "moduleResolution": "Bundler"
  },
  "include": ["src/**/*.ts"],
  "exclude": ["node_modules"]
}
  • Step 3: Create rollup.config.mjs
import commonjs from "@rollup/plugin-commonjs";
import nodeResolve from "@rollup/plugin-node-resolve";
import terser from "@rollup/plugin-terser";
import typescript from "@rollup/plugin-typescript";
import path from "node:path";
import url from "node:url";

const isWatching = !!process.env.ROLLUP_WATCH;
const sdPlugin = "com.bryce.llamawatch.sdPlugin";

const config = {
  input: "src/plugin.ts",
  output: {
    file: `${sdPlugin}/bin/plugin.js`,
    sourcemap: isWatching,
    sourcemapPathTransform: (relativeSourcePath, sourcemapPath) => {
      return url.pathToFileURL(path.resolve(path.dirname(sourcemapPath), relativeSourcePath)).href;
    },
  },
  plugins: [
    {
      name: "watch-externals",
      buildStart: function () {
        this.addWatchFile(`${sdPlugin}/manifest.json`);
      },
    },
    typescript({ mapRoot: isWatching ? "./" : undefined }),
    nodeResolve({ browser: false, exportConditions: ["node"], preferBuiltins: true }),
    commonjs(),
    !isWatching && terser(),
    {
      name: "emit-module-package-file",
      generateBundle() {
        this.emitFile({ fileName: "package.json", source: `{ "type": "module" }`, type: "asset" });
      },
    },
  ],
};

export default config;
  • Step 4: Create .gitignore
node_modules/
*.streamDeckPlugin
.DS_Store
  • Step 5: Create placeholder com.bryce.llamawatch.sdPlugin/manifest.json
{
  "$schema": "https://schemas.elgato.com/streamdeck/plugins/manifest.json",
  "Name": "llama-watch",
  "Version": "1.0.0.0",
  "Author": "Bryce Zuccaro",
  "Actions": [],
  "Category": "llama-watch",
  "CodePath": "bin/plugin.js",
  "Description": "Monitor llama-swap: in-flight requests per model and live GPU metric graphs.",
  "Icon": "imgs/plugin/icon",
  "SDKVersion": 2,
  "Software": { "MinimumVersion": "7.1" },
  "OS": [{ "Platform": "mac", "MinimumVersion": "13" }],
  "Nodejs": { "Version": "24" },
  "UUID": "com.bryce.llamawatch"
}
  • Step 6: Create placeholder src/plugin.ts
import streamDeck from "@elgato/streamdeck";

streamDeck.connect();
  • Step 7: Create placeholder action filessrc/actions/inflight-monitor.ts and src/actions/gpu-graph.ts, each containing only:
export {};
  • Step 8: Create tests/.gitkeep (empty file)

  • Step 9: Install dependencies and verify the build and test runner

Run:

npm install
npm run build
npm test

Expected: npm run build produces com.bryce.llamawatch.sdPlugin/bin/plugin.js; npm test exits 0 (no test files yet).

  • Step 10: Commit
git add -A
git commit -m "chore: scaffold llama-watch Stream Deck plugin"

Task 2: util.ts — shared helpers and config

Files:

  • Create: src/lib/util.ts
  • Test: tests/util.test.ts

Interfaces:

  • Consumes: nothing.

  • Produces: DEFAULT_BASE_URL, CfgSettings, LlamaSwapConfig, cfgFromSettings(settings): LlamaSwapConfig, normalizeBaseUrl(s): string, shorten(name, max?): string, escapeXml(s): string.

  • Step 1: Write the failing testtests/util.test.ts

import assert from "node:assert/strict";
import { test } from "node:test";
import {
  cfgFromSettings,
  escapeXml,
  normalizeBaseUrl,
  shorten,
} from "../src/lib/util";

test("normalizeBaseUrl strips trailing slashes", () => {
  assert.equal(normalizeBaseUrl("http://localhost:9292/"), "http://localhost:9292");
  assert.equal(normalizeBaseUrl("http://localhost:9292"), "http://localhost:9292");
});

test("cfgFromSettings defaults baseUrl and omits empty apiKey", () => {
  const cfg = cfgFromSettings({});
  assert.equal(cfg.baseUrl, "http://localhost:9292");
  assert.equal(cfg.apiKey, undefined);
  assert.deepEqual(cfgFromSettings({ baseUrl: "http://x/", apiKey: "abc" }), {
    baseUrl: "http://x",
    apiKey: "abc",
  });
});

test("shorten keeps short names and truncates long ones", () => {
  assert.equal(shorten("Qwen3.8-27B"), "Qwen3.8-27B");
  assert.equal(shorten("DeepSeek-V4-Flash-0731"), "DeepSeek-…0731");
});

test("escapeXml escapes XML special characters", () => {
  assert.equal(escapeXml(`A&B <C> "D" 'E'`), "A&amp;B &lt;C&gt; &quot;D&quot; &apos;E&apos;");
});
  • Step 2: Run test to verify it fails

Run: npm test Expected: FAIL — module ../src/lib/util not found.

  • Step 3: Implement src/lib/util.ts
export const DEFAULT_BASE_URL = "http://localhost:9292";

export interface CfgSettings {
  baseUrl?: string;
  apiKey?: string;
}

export interface LlamaSwapConfig {
  baseUrl: string;
  apiKey?: string;
}

export function cfgFromSettings(settings: CfgSettings): LlamaSwapConfig {
  return {
    baseUrl: normalizeBaseUrl(settings.baseUrl ?? DEFAULT_BASE_URL),
    apiKey: settings.apiKey || undefined,
  };
}

export function normalizeBaseUrl(baseUrl: string): string {
  return baseUrl.trim().replace(/\/+$/, "");
}

export function shorten(name: string, max = 14): string {
  if (name.length <= max) return name;
  const head = Math.floor((max - 1) * 0.7);
  return `${name.slice(0, head)}${name.slice(name.length - (max - head - 1))}`;
}

export function escapeXml(text: string): string {
  return text
    .replace(/&/g, "&amp;")
    .replace(/</g, "&lt;")
    .replace(/>/g, "&gt;")
    .replace(/"/g, "&quot;")
    .replace(/'/g, "&apos;");
}
  • Step 4: Run test to verify it passes

Run: npm test Expected: PASS.

  • Step 5: Commit
git add -A
git commit -m "feat: add shared util helpers and config"

Task 3: metrics-parser.ts — Prometheus text parser

Files:

  • Create: src/lib/metrics-parser.ts
  • Create: tests/fixtures/metrics.txt
  • Test: tests/metrics-parser.test.ts

Interfaces:

  • Consumes: nothing.

  • Produces:

    • type GpuMetricKind = "util_percent" | "memory_util_percent" | "temperature" | "power" | "fan"
    • interface GpuSample { id: string; name: string; kind: GpuMetricKind; value: number }
    • parsePrometheus(text: string): { metric: string; labels: Record<string, string>; value: number }[]
    • parseGpuMetrics(text: string): GpuSample[]
    • gpuInfos(samples: GpuSample[]): { id: string; name: string }[] (deduped, sorted by numeric id)
  • Step 1: Create the fixturetests/fixtures/metrics.txt (trimmed capture from localhost:9292)

# HELP llamaswap_gpu_util_percent GPU utilization percent (0-100)
# TYPE llamaswap_gpu_util_percent gauge
llamaswap_gpu_util_percent{id="0",name="NVIDIA RTX PRO 6000 Blackwell Workstation Edition",uuid="GPU-b77149e6-c22d-599e-9728-c7d4cfee0eb4"} 100
llamaswap_gpu_util_percent{id="1",name="NVIDIA RTX PRO 6000 Blackwell Workstation Edition",uuid="GPU-a46fadfa-48d9-c9c4-8fde-9bd641142bb7"} 100
llamaswap_gpu_util_percent{id="2",name="NVIDIA GeForce RTX 5090",uuid="GPU-9a59ac37-12fb-5bc4-4767-4f4347898c0a"} 100
# HELP llamaswap_gpu_memory_util_percent GPU memory utilization percent (0-100)
# TYPE llamaswap_gpu_memory_util_percent gauge
llamaswap_gpu_memory_util_percent{id="0",name="NVIDIA RTX PRO 6000 Blackwell Workstation Edition",uuid="GPU-b77149e6-c22d-599e-9728-c7d4cfee0eb4"} 98.41347676402383
llamaswap_gpu_memory_util_percent{id="1",name="NVIDIA RTX PRO 6000 Blackwell Workstation Edition",uuid="GPU-a46fadfa-48d9-c9c4-8fde-9bd641142bb7"} 98.41245517790922
llamaswap_gpu_memory_util_percent{id="2",name="NVIDIA GeForce RTX 5090",uuid="GPU-9a59ac37-12fb-5bc4-4767-4f4347898c0a"} 97.02211181648113
# HELP llamaswap_gpu_temperature_celsius GPU temperature in Celsius
# TYPE llamaswap_gpu_temperature_celsius gauge
llamaswap_gpu_temperature_celsius{id="0",name="NVIDIA RTX PRO 6000 Blackwell Workstation Edition",uuid="GPU-b77149e6-c22d-599e-9728-c7d4cfee0eb4"} 51
llamaswap_gpu_temperature_celsius{id="1",name="NVIDIA RTX PRO 6000 Blackwell Workstation Edition",uuid="GPU-a46fadfa-48d9-c9c4-8fde-9bd641142bb7"} 55
llamaswap_gpu_temperature_celsius{id="2",name="NVIDIA GeForce RTX 5090",uuid="GPU-9a59ac37-12fb-5bc4-4767-4f4347898c0a"} 64
# HELP llamaswap_gpu_power_draw_watts GPU power draw in watts
# TYPE llamaswap_gpu_power_draw_watts gauge
llamaswap_gpu_power_draw_watts{id="0",name="NVIDIA RTX PRO 6000 Blackwell Workstation Edition",uuid="GPU-b77149e6-c22d-599e-9728-c7d4cfee0eb4"} 316.56
llamaswap_gpu_power_draw_watts{id="1",name="NVIDIA RTX PRO 6000 Blackwell Workstation Edition",uuid="GPU-a46fadfa-48d9-c9c4-8fde-9bd641142bb7"} 336.17
llamaswap_gpu_power_draw_watts{id="2",name="NVIDIA GeForce RTX 5090",uuid="GPU-9a59ac37-12fb-5bc4-4767-4f4347898c0a"} 377.28
# HELP llamaswap_gpu_fan_speed_percent GPU fan speed percent (0-100)
# TYPE llamaswap_gpu_fan_speed_percent gauge
llamaswap_gpu_fan_speed_percent{id="0",name="NVIDIA RTX PRO 6000 Blackwell Workstation Edition",uuid="GPU-b77149e6-c22d-599e-9728-c7d4cfee0eb4"} 51
llamaswap_gpu_fan_speed_percent{id="1",name="NVIDIA RTX PRO 6000 Blackwell Workstation Edition",uuid="GPU-a46fadfa-48d9-c9c4-8fde-9bd641142bb7"} 62
llamaswap_gpu_fan_speed_percent{id="2",name="NVIDIA GeForce RTX 5090",uuid="GPU-9a59ac37-12fb-5bc4-4767-4f4347898c0a"} 89
  • Step 2: Write the failing testtests/metrics-parser.test.ts
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { test } from "node:test";
import { gpuInfos, parseGpuMetrics, parsePrometheus } from "../src/lib/metrics-parser";

const FIXTURE = readFileSync(new URL("./fixtures/metrics.txt", import.meta.url), "utf8");

test("parsePrometheus parses a gauge with labels", () => {
  const samples = parsePrometheus(`# TYPE llamaswap_gpu_util_percent gauge\nllamaswap_gpu_util_percent{id="0",name="RTX 5090"} 42.5\n`);
  assert.equal(samples.length, 1);
  assert.equal(samples[0].metric, "llamaswap_gpu_util_percent");
  assert.deepEqual(samples[0].labels, { id: "0", name: "RTX 5090" });
  assert.equal(samples[0].value, 42.5);
});

test("parsePrometheus ignores comments and blank lines", () => {
  const samples = parsePrometheus("# HELP x y\n# TYPE x gauge\n\n");
  assert.equal(samples.length, 0);
});

test("parseGpuMetrics extracts all five kinds for three GPUs from the fixture", () => {
  const samples = parseGpuMetrics(FIXTURE);
  assert.equal(samples.length, 15);
  const kinds = new Set(samples.map((s) => s.kind));
  assert.deepEqual([...kinds].sort(), ["fan", "memory_util_percent", "power", "temperature", "util_percent"]);
  const power0 = samples.find((s) => s.kind === "power" && s.id === "0");
  assert.ok(power0);
  assert.equal(power0.value, 316.56);
});

test("gpuInfos dedupes, preserves names, sorts numerically by id", () => {
  const infos = gpuInfos(parseGpuMetrics(FIXTURE));
  assert.deepEqual(infos.map((i) => i.id), ["0", "1", "2"]);
  assert.equal(infos[2].name, "NVIDIA GeForce RTX 5090");
});
  • Step 3: Run test to verify it fails

Run: npm test Expected: FAIL — module not found.

  • Step 4: Implement src/lib/metrics-parser.ts
export interface PromSample {
  metric: string;
  labels: Record<string, string>;
  value: number;
}

const LINE_RE = /^([A-Za-z_:][A-Za-z0-9_:]*)(?:\{([^}]*)\})?\s+(-?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?)$/;

export function parsePrometheus(text: string): PromSample[] {
  const samples: PromSample[] = [];
  for (const rawLine of text.split("\n")) {
    const line = rawLine.trim();
    if (!line || line.startsWith("#")) continue;
    const m = LINE_RE.exec(line);
    if (!m) continue;
    const labels: Record<string, string> = {};
    if (m[2]) {
      for (const pair of m[2].split(",")) {
        const eq = pair.indexOf("=");
        if (eq === -1) continue;
        const value = pair.slice(eq + 1).trim();
        labels[pair.slice(0, eq).trim()] = value.replace(/^"(.*)"$/, "$1");
      }
    }
    samples.push({ metric: m[1], labels, value: parseFloat(m[3]) });
  }
  return samples;
}

export type GpuMetricKind = "util_percent" | "memory_util_percent" | "temperature" | "power" | "fan";

export const GPU_SERIES: Record<string, GpuMetricKind> = {
  llamaswap_gpu_util_percent: "util_percent",
  llamaswap_gpu_memory_util_percent: "memory_util_percent",
  llamaswap_gpu_temperature_celsius: "temperature",
  llamaswap_gpu_power_draw_watts: "power",
  llamaswap_gpu_fan_speed_percent: "fan",
};

export interface GpuSample {
  id: string;
  name: string;
  kind: GpuMetricKind;
  value: number;
}

export function parseGpuMetrics(text: string): GpuSample[] {
  const out: GpuSample[] = [];
  for (const s of parsePrometheus(text)) {
    const kind = GPU_SERIES[s.metric];
    if (!kind) continue;
    const id = s.labels["id"];
    if (id === undefined) continue;
    out.push({ id, name: s.labels["name"] ?? "", kind, value: s.value });
  }
  return out;
}

export function gpuInfos(samples: GpuSample[]): { id: string; name: string }[] {
  const byId = new Map<string, string>();
  for (const s of samples) {
    if (!byId.has(s.id)) byId.set(s.id, s.name);
  }
  return [...byId.entries()]
    .sort((a, b) => parseInt(a[0], 10) - parseInt(b[0], 10))
    .map(([id, name]) => ({ id, name }));
}
  • Step 5: Run test to verify it passes

Run: npm test Expected: PASS.

  • Step 6: Commit
git add -A
git commit -m "feat: add Prometheus metrics parser for llama-swap GPU series"

Task 4: llamaswap.ts — HTTP client

Files:

  • Create: src/lib/llamaswap.ts

Interfaces:

  • Consumes: LlamaSwapConfig from ./util.

  • Produces:

    • interface ModelInfo { id: string; status: string }
    • fetchMetrics(cfg: LlamaSwapConfig): Promise<string> — GET <baseUrl>/metrics, throws on non-2xx or timeout (5s).
    • fetchModels(cfg: LlamaSwapConfig): Promise<ModelInfo[]> — GET <baseUrl>/v1/models, maps data[] to {id, status: status.value}.
  • Step 1: Implement src/lib/llamaswap.ts (network module; verified via npm run build typecheck, and integration-tested in Task 12)

import { type LlamaSwapConfig } from "./util";

const TIMEOUT_MS = 5000;

export interface ModelInfo {
  id: string;
  status: string;
}

function headersFor(cfg: LlamaSwapConfig): Record<string, string> {
  const headers: Record<string, string> = {};
  if (cfg.apiKey) headers["Authorization"] = `Bearer ${cfg.apiKey}`;
  return headers;
}

export async function fetchMetrics(cfg: LlamaSwapConfig): Promise<string> {
  const res = await fetch(`${cfg.baseUrl}/metrics`, {
    headers: headersFor(cfg),
    signal: AbortSignal.timeout(TIMEOUT_MS),
  });
  if (!res.ok) throw new Error(`metrics HTTP ${res.status}`);
  return await res.text();
}

export async function fetchModels(cfg: LlamaSwapConfig): Promise<ModelInfo[]> {
  const res = await fetch(`${cfg.baseUrl}/v1/models`, {
    headers: headersFor(cfg),
    signal: AbortSignal.timeout(TIMEOUT_MS),
  });
  if (!res.ok) throw new Error(`models HTTP ${res.status}`);
  const body = (await res.json()) as { data?: { id: string; status?: { value?: string } }[] };
  return (body.data ?? []).map((m) => ({ id: m.id, status: m.status?.value ?? "unknown" }));
}
  • Step 2: Verify it typechecks in the bundle

Run: npm run build Expected: build succeeds (rollup/typescript compiles without error).

  • Step 3: Commit
git add -A
git commit -m "feat: add llama-swap HTTP client"

Task 5: metrics-poller.ts — polling, ring buffers, aggregates

Files:

  • Create: src/lib/metrics-poller.ts
  • Test: tests/metrics-poller.test.ts

Interfaces:

  • Consumes: fetchMetrics + LlamaSwapConfig (default fetchFn), parseGpuMetrics + GpuMetricKind from ./metrics-parser.

  • Produces:

    • class MetricsPoller { constructor(cfg: LlamaSwapConfig, intervalMs?: number, fetchFn?: (cfg: LlamaSwapConfig) => Promise<string>) }
    • start(): void, stop(): void, tick(): Promise<void> (public for tests)
    • on(listener: () => void): () => void (returns unsubscribe)
    • getValue(gpuId: string, kind: GpuMetricKind): number | undefined
    • getHistory(gpuId: string, kind: GpuMetricKind): number[] (copy of ring)
    • gpus(): { id: string; name: string }[]
    • isOffline(): boolean
  • Step 1: Write the failing testtests/metrics-poller.test.ts

import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { test } from "node:test";
import { type LlamaSwapConfig } from "../src/lib/util";
import { MetricsPoller } from "../src/lib/metrics-poller";
import { parseGpuMetrics } from "../src/lib/metrics-parser";

const FIXTURE = readFileSync(new URL("./fixtures/metrics.txt", import.meta.url), "utf8");
const FIXTURE2 = FIXTURE
  .replace(/llamaswap_gpu_util_percent\{id="0"[^}]*\} 100/, 'llamaswap_gpu_util_percent{id="0",name="RTX PRO 6000"} 30')
  .replace(/llamaswap_gpu_util_percent\{id="1"[^}]*\} 100/, 'llamaswap_gpu_util_percent{id="1",name="RTX PRO 6000"} 50')
  .replace(/llamaswap_gpu_util_percent\{id="2"[^}]*\} 100/, 'llamaswap_gpu_util_percent{id="2",name="RTX 5090"} 80');

function stubFetch(bodies: string[]) {
  let i = 0;
  return async () => bodies[Math.min(i++, bodies.length - 1)];
}

const CFG: LlamaSwapConfig = { baseUrl: "http://test" };

test("poller stores per-GPU history and computes aggregates", async () => {
  const poller = new MetricsPoller(CFG, 5000, stubFetch([FIXTURE, FIXTURE2]));
  await poller.tick();
  await poller.tick();

  assert.equal(poller.getValue("0", "util_percent"), 30);
  assert.deepEqual(poller.getHistory("0", "util_percent"), [100, 30]);

  const allUtil = poller.getValue("all", "util_percent");
  assert.ok(allUtil !== undefined);
  assert.ok(Math.abs(allUtil - (30 + 50 + 80) / 3) < 1e-9);

  const allTemp = poller.getValue("all", "temperature");
  assert.equal(allTemp, 64);

  const allPower = poller.getValue("all", "power");
  assert.ok(allPower !== undefined);
  assert.ok(Math.abs(allPower - (316.56 + 336.17 + 377.28)) < 1e-9);

  assert.deepEqual(poller.gpus().map((g) => g.id), ["0", "1", "2"]);
  assert.equal(poller.gpus()[2].name, "NVIDIA GeForce RTX 5090");
});

test("poller ring buffer caps at 60 samples", async () => {
  const poller = new MetricsPoller(CFG, 5000, stubFetch([FIXTURE]));
  for (let i = 0; i < 70; i++) await poller.tick();
  assert.equal(poller.getHistory("0", "fan").length, 60);
});

test("poller reports offline after a fetch failure and recovers", async () => {
  let fail = true;
  const fetchFn = async () => {
    if (fail) throw new Error("boom");
    return FIXTURE;
  };
  const poller = new MetricsPoller(CFG, 5000, fetchFn);
  await poller.tick();
  assert.equal(poller.isOffline(), true);
  fail = false;
  await poller.tick();
  assert.equal(poller.isOffline(), false);
  assert.ok(poller.getValue("0", "util_percent") !== undefined);
});
  • Step 2: Run test to verify it fails

Run: npm test Expected: FAIL — module not found.

  • Step 3: Implement src/lib/metrics-poller.ts
import { fetchMetrics } from "./llamaswap";
import { type LlamaSwapConfig } from "./util";
import { gpuInfos, parseGpuMetrics, type GpuMetricKind, type GpuSample } from "./metrics-parser";

const RING_SIZE = 60;

function avg(values: number[]): number {
  return values.reduce((a, b) => a + b, 0) / (values.length || 1);
}
function max(values: number[]): number {
  return values.reduce((a, b) => Math.max(a, b), -Infinity);
}
function sum(values: number[]): number {
  return values.reduce((a, b) => a + b, 0);
}

const AGGREGATE: Record<GpuMetricKind, (values: number[]) => number> = {
  util_percent: avg,
  memory_util_percent: avg,
  temperature: max,
  power: sum,
  fan: avg,
};

function key(gpuId: string, kind: GpuMetricKind): string {
  return `${gpuId}|${kind}`;
}

type FetchFn = (cfg: LlamaSwapConfig) => Promise<string>;

export class MetricsPoller {
  private buffers = new Map<string, number[]>();
  private gpuName = new Map<string, string>();
  private gpuIds: string[] = [];
  private listeners = new Set<() => void>();
  private timer?: ReturnType<typeof setInterval>;
  private lastError?: string;

  constructor(
    private cfg: LlamaSwapConfig,
    private intervalMs = 5000,
    private fetchFn: FetchFn = fetchMetrics,
  ) {}

  start(): void {
    void this.tick();
    this.timer = setInterval(() => void this.tick(), this.intervalMs);
  }

  stop(): void {
    if (this.timer) clearInterval(this.timer);
    this.timer = undefined;
  }

  on(listener: () => void): () => void {
    this.listeners.add(listener);
    return () => {
      this.listeners.delete(listener);
    };
  }

  async tick(): Promise<void> {
    try {
      const text = await this.fetchFn(this.cfg);
      this.apply(parseGpuMetrics(text));
      this.lastError = undefined;
    } catch (err) {
      this.lastError = err instanceof Error ? err.message : String(err);
    } finally {
      for (const listener of this.listeners) listener();
    }
  }

  private apply(samples: GpuSample[]): void {
    const byKind = new Map<GpuMetricKind, Map<string, number>>();
    for (const s of samples) {
      if (!byKind.has(s.kind)) byKind.set(s.kind, new Map());
      byKind.get(s.kind)!.set(s.id, s.value);
      if (!this.gpuName.has(s.id)) this.gpuName.set(s.id, s.name);
    }
    this.gpuIds = [...this.gpuName.keys()].sort((a, b) => parseInt(a, 10) - parseInt(b, 10));

    for (const [kind, values] of byKind) {
      for (const [id, value] of values) this.push(key(id, kind), value);
      const all = [...values.values()];
      if (all.length > 0) this.push(key("all", kind), AGGREGATE[kind](all));
    }
  }

  private push(k: string, value: number): void {
    let ring = this.buffers.get(k);
    if (!ring) {
      ring = [];
      this.buffers.set(k, ring);
    }
    ring.push(value);
    if (ring.length > RING_SIZE) ring.shift();
  }

  getValue(gpuId: string, kind: GpuMetricKind): number | undefined {
    const ring = this.buffers.get(key(gpuId, kind));
    return ring ? ring[ring.length - 1] : undefined;
  }

  getHistory(gpuId: string, kind: GpuMetricKind): number[] {
    const ring = this.buffers.get(key(gpuId, kind));
    return ring ? [...ring] : [];
  }

  gpus(): { id: string; name: string }[] {
    return this.gpuIds.map((id) => ({ id, name: this.gpuName.get(id) ?? "" }));
  }

  isOffline(): boolean {
    return this.lastError !== undefined;
  }
}
  • Step 4: Run test to verify it passes

Run: npm test Expected: PASS.

  • Step 5: Commit
git add -A
git commit -m "feat: add metrics poller with ring buffers and GPU aggregates"

Task 6: sse.ts + inflight-tracker.ts — SSE parsing and request tracking

Files:

  • Create: src/lib/sse.ts
  • Create: src/lib/inflight-tracker.ts
  • Test: tests/sse.test.ts
  • Test: tests/inflight-tracker.test.ts

Interfaces:

  • Consumes: nothing.

  • Produces:

    • sse.ts: interface SseMessage { event?: string; data: string }, parseSse(chunk: string): { messages: SseMessage[]; rest: string }.
    • inflight-tracker.ts:
      • interface InflightRequest { id?: string; model: string; req_path?: string; method?: string; timestamp?: string; elapsed_ms?: number }
      • interface ModelState { id: string; state: string }
      • type FeedEvent = { type: "inflight"; operation: "snapshot" | "add" | "remove"; requests: InflightRequest[] } | { type: "modelStatus"; models: ModelState[] }
      • type ModelRuntimeState = "stopped" | "loading" | "ready"
      • class InflightTracker { apply(event: FeedEvent): void; count(modelId: string): number; state(modelId: string): ModelRuntimeState | undefined }
  • Step 1: Write the failing tests

tests/sse.test.ts:

import assert from "node:assert/strict";
import { test } from "node:test";
import { parseSse } from "../src/lib/sse";

test("parseSse extracts event + data blocks separated by blank lines", () => {
  const { messages, rest } = parseSse('event:message\ndata:{"a":1}\n\nevent:message\ndata:hello\n\n');
  assert.equal(rest, "");
  assert.equal(messages.length, 2);
  assert.equal(messages[0].event, "message");
  assert.equal(messages[0].data, '{"a":1}');
  assert.equal(messages[1].data, "hello");
});

test("parseSse keeps a trailing partial event in rest", () => {
  const { messages, rest } = parseSse("event:message\ndata:par");
  assert.equal(messages.length, 0);
  assert.equal(rest, "event:message\ndata:par");
});

test("parseSse joins multi-line data fields with newline", () => {
  const { messages } = parseSse("data:line1\ndata:line2\n\n");
  assert.equal(messages.length, 1);
  assert.equal(messages[0].data, "line1\nline2");
});

tests/inflight-tracker.test.ts:

import assert from "node:assert/strict";
import { test } from "node:test";
import { InflightTracker, type FeedEvent } from "../src/lib/inflight-tracker";

test("snapshot rebuilds counts for all in-flight requests", () => {
  const tracker = new InflightTracker();
  tracker.apply({
    type: "inflight",
    operation: "snapshot",
    requests: [
      { model: "A", id: "1" },
      { model: "A", id: "2" },
      { model: "B", id: "3" },
    ],
  });
  assert.equal(tracker.count("A"), 2);
  assert.equal(tracker.count("B"), 1);
  assert.equal(tracker.count("C"), 0);
});

test("add and remove adjust per-model counts", () => {
  const tracker = new InflightTracker();
  tracker.apply({ type: "inflight", operation: "add", requests: [{ model: "A", id: "1" }] });
  tracker.apply({ type: "inflight", operation: "add", requests: [{ model: "A", id: "2" }] });
  assert.equal(tracker.count("A"), 2);
  tracker.apply({ type: "inflight", operation: "remove", requests: [{ model: "A", id: "1" }] });
  assert.equal(tracker.count("A"), 1);
  tracker.apply({ type: "inflight", operation: "remove", requests: [{ model: "A", id: "2" }] });
  assert.equal(tracker.count("A"), 0);
  tracker.apply({ type: "inflight", operation: "remove", requests: [{ model: "A", id: "9" }] });
  assert.equal(tracker.count("A"), 0);
});

test("modelStatus normalizes states", () => {
  const tracker = new InflightTracker();
  tracker.apply({
    type: "modelStatus",
    models: [
      { id: "A", state: "ready" },
      { id: "B", state: "loading" },
      { id: "C", state: "stopped" },
      { id: "D", state: "weird" },
    ],
  });
  assert.equal(tracker.state("A"), "ready");
  assert.equal(tracker.state("B"), "loading");
  assert.equal(tracker.state("C"), "stopped");
  assert.equal(tracker.state("D"), "stopped");
  assert.equal(tracker.state("missing"), undefined);
});
  • Step 2: Run tests to verify they fail

Run: npm test Expected: FAIL — modules not found.

  • Step 3: Implement src/lib/sse.ts
export interface SseMessage {
  event?: string;
  data: string;
}

export function parseSse(chunk: string): { messages: SseMessage[]; rest: string } {
  const messages: SseMessage[] = [];
  let rest = chunk;
  while (true) {
    const idx = rest.indexOf("\n\n");
    if (idx === -1) break;
    const block = rest.slice(0, idx);
    rest = rest.slice(idx + 2);
    const message = toMessage(block);
    if (message && message.data.length > 0) messages.push(message);
  }
  return { messages, rest };
}

function toMessage(block: string): SseMessage | null {
  let event: string | undefined;
  const dataLines: string[] = [];
  for (const line of block.split("\n")) {
    if (line.startsWith("event:")) event = line.slice(6).trim();
    else if (line.startsWith("data:")) dataLines.push(line.slice(5).trimStart());
  }
  if (dataLines.length === 0) return null;
  return { event, data: dataLines.join("\n") };
}
  • Step 4: Implement src/lib/inflight-tracker.ts
export interface InflightRequest {
  id?: string;
  model: string;
  req_path?: string;
  method?: string;
  timestamp?: string;
  elapsed_ms?: number;
}

export interface ModelState {
  id: string;
  state: string;
}

export type FeedEvent =
  | { type: "inflight"; operation: "snapshot" | "add" | "remove"; requests: InflightRequest[] }
  | { type: "modelStatus"; models: ModelState[] };

export type ModelRuntimeState = "stopped" | "loading" | "ready";

export class InflightTracker {
  private counts = new Map<string, number>();
  private states = new Map<string, ModelRuntimeState>();

  apply(event: FeedEvent): void {
    if (event.type === "inflight") {
      if (event.operation === "snapshot") {
        const counts = new Map<string, number>();
        for (const r of event.requests) counts.set(r.model, (counts.get(r.model) ?? 0) + 1);
        this.counts = counts;
      } else if (event.operation === "add") {
        for (const r of event.requests) this.counts.set(r.model, (this.counts.get(r.model) ?? 0) + 1);
      } else if (event.operation === "remove") {
        for (const r of event.requests) {
          const c = this.counts.get(r.model) ?? 0;
          if (c <= 1) this.counts.delete(r.model);
          else this.counts.set(r.model, c - 1);
        }
      }
    } else if (event.type === "modelStatus") {
      for (const m of event.models) this.states.set(m.id, normalizeState(m.state));
    }
  }

  count(modelId: string): number {
    return this.counts.get(modelId) ?? 0;
  }

  state(modelId: string): ModelRuntimeState | undefined {
    return this.states.get(modelId);
  }
}

function normalizeState(state: string): ModelRuntimeState {
  if (state === "ready") return "ready";
  if (state === "loading") return "loading";
  return "stopped";
}
  • Step 5: Run tests to verify they pass

Run: npm test Expected: PASS.

  • Step 6: Commit
git add -A
git commit -m "feat: add SSE parser and in-flight request tracker"

Task 7: event-feed.ts — SSE client and event decoding

Files:

  • Create: src/lib/event-feed.ts
  • Test: tests/event-feed.test.ts

Interfaces:

  • Consumes: LlamaSwapConfig from ./util, parseSse + SseMessage from ./sse, FeedEvent/InflightRequest/ModelState from ./inflight-tracker.

  • Produces:

    • decodeEvent(msg: SseMessage): FeedEvent | null (exported for tests)
    • class EventFeed { constructor(cfg: LlamaSwapConfig, onEvent: (ev: FeedEvent) => void); setStatusHandler(fn: (connected: boolean) => void): void; start(): void; stop(): void }
    • Reconnect with exponential backoff 1s → 30s; self-heals from snapshot.
  • Step 1: Write the failing testtests/event-feed.test.ts

import assert from "node:assert/strict";
import { test } from "node:test";
import { decodeEvent } from "../src/lib/event-feed";
import type { SseMessage } from "../src/lib/sse";

test("decodeEvent parses an inflight snapshot", () => {
  const msg: SseMessage = {
    event: "message",
    data: JSON.stringify({
      type: "inflight",
      data: JSON.stringify({
        operation: "snapshot",
        requests: [{ id: "14", model: "Qwen3.8-27B-NVFP4", req_path: "/v1/chat/completions", elapsed_ms: 165510 }],
      }),
    }),
  };
  const ev = decodeEvent(msg);
  assert.ok(ev);
  assert.equal(ev.type, "inflight");
  if (ev.type === "inflight") {
    assert.equal(ev.operation, "snapshot");
    assert.equal(ev.requests.length, 1);
    assert.equal(ev.requests[0].model, "Qwen3.8-27B-NVFP4");
    assert.equal(ev.requests[0].elapsed_ms, 165510);
  }
});

test("decodeEvent parses an inflight add with a singular request field", () => {
  const msg: SseMessage = {
    event: "message",
    data: JSON.stringify({
      type: "inflight",
      data: JSON.stringify({ operation: "add", request: { id: "9", model: "A" } }),
    }),
  };
  const ev = decodeEvent(msg);
  assert.ok(ev && ev.type === "inflight");
  if (ev && ev.type === "inflight") {
    assert.equal(ev.operation, "add");
    assert.equal(ev.requests[0].model, "A");
  }
});

test("decodeEvent parses modelStatus", () => {
  const msg: SseMessage = {
    event: "message",
    data: JSON.stringify({
      type: "modelStatus",
      data: JSON.stringify([
        { id: "DeepSeek-V4-Flash-0731", state: "ready" },
        { id: "Qwen3.8-27B-NVFP4", state: "stopped" },
      ]),
    }),
  };
  const ev = decodeEvent(msg);
  assert.ok(ev && ev.type === "modelStatus");
  if (ev && ev.type === "modelStatus") {
    assert.equal(ev.models.length, 2);
    assert.equal(ev.models[0].state, "ready");
  }
});

test("decodeEvent returns null for unrelated or malformed events", () => {
  assert.equal(decodeEvent({ event: "message", data: JSON.stringify({ type: "logData", data: "{}" }) }), null);
  assert.equal(decodeEvent({ event: "message", data: "not json" }), null);
});
  • Step 2: Run test to verify it fails

Run: npm test Expected: FAIL — module not found.

  • Step 3: Implement src/lib/event-feed.ts
import { type LlamaSwapConfig } from "./util";
import { parseSse, type SseMessage } from "./sse";
import { type FeedEvent, type InflightRequest, type ModelState } from "./inflight-tracker";

export function decodeEvent(msg: SseMessage): FeedEvent | null {
  try {
    const outer = JSON.parse(msg.data) as { type?: string; data?: string };
    if (!outer.data) return null;
    const inner = JSON.parse(outer.data) as Record<string, unknown>;

    if (outer.type === "inflight") {
      const requests: InflightRequest[] =
        (inner.requests as InflightRequest[]) ?? (inner.request ? [inner.request as InflightRequest] : []);
      return {
        type: "inflight",
        operation: inner.operation as "snapshot" | "add" | "remove",
        requests,
      };
    }

    if (outer.type === "modelStatus") {
      return { type: "modelStatus", models: inner as unknown as ModelState[] };
    }

    return null;
  } catch {
    return null;
  }
}

const MAX_RETRY_MS = 30000;
const INITIAL_RETRY_MS = 1000;

export class EventFeed {
  private controller?: AbortController;
  private closed = false;
  private retryMs = INITIAL_RETRY_MS;
  private statusHandler?: (connected: boolean) => void;

  constructor(
    private cfg: LlamaSwapConfig,
    private onEvent: (ev: FeedEvent) => void,
  ) {}

  setStatusHandler(fn: (connected: boolean) => void): void {
    this.statusHandler = fn;
  }

  start(): void {
    void this.connect();
  }

  stop(): void {
    this.closed = true;
    this.controller?.abort();
  }

  private async connect(): Promise<void> {
    while (!this.closed) {
      this.controller = new AbortController();
      try {
        const headers: Record<string, string> = { Accept: "text/event-stream" };
        if (this.cfg.apiKey) headers["Authorization"] = `Bearer ${this.cfg.apiKey}`;
        const res = await fetch(`${this.cfg.baseUrl}/api/events`, {
          headers,
          signal: this.controller.signal,
        });
        if (!res.ok || !res.body) throw new Error(`events HTTP ${res.status}`);
        this.statusHandler?.(true);
        this.retryMs = INITIAL_RETRY_MS;
        await this.readStream(res.body.getReader());
      } catch {
        if (this.closed) return;
      }
      this.statusHandler?.(false);
      if (this.closed) return;
      await sleep(this.retryMs);
      this.retryMs = Math.min(this.retryMs * 2, MAX_RETRY_MS);
    }
  }

  private async readStream(reader: ReadableStreamDefaultReader<Uint8Array>): Promise<void> {
    const decoder = new TextDecoder();
    let buffer = "";
    try {
      while (!this.closed) {
        const { done, value } = await reader.read();
        if (done) break;
        buffer += decoder.decode(value, { stream: true });
        const { messages, rest } = parseSse(buffer);
        buffer = rest;
        for (const msg of messages) {
          const ev = decodeEvent(msg);
          if (ev) this.onEvent(ev);
        }
      }
    } finally {
      reader.releaseLock();
    }
  }
}

function sleep(ms: number): Promise<void> {
  return new Promise((resolve) => setTimeout(resolve, ms));
}
  • Step 4: Run test to verify it passes

Run: npm test Expected: PASS.

  • Step 5: Commit
git add -A
git commit -m "feat: add SSE event feed for llama-swap api/events"

Task 8: render.ts — SVG key rendering

Files:

  • Create: src/lib/render.ts
  • Test: tests/render.test.ts

Interfaces:

  • Consumes: GpuMetricKind from ./metrics-parser, ModelRuntimeState from ./inflight-tracker, shorten/escapeXml from ./util.
  • Produces:
    • svgDataUrl(svg: string): string
    • renderInflight(opts: { modelName: string; state?: ModelRuntimeState; count: number; offline: boolean; pulse: boolean }): string
    • renderGpuGraph(opts: { gpuName: string; metric: GpuMetricKind; value: number | undefined; history: number[]; offline: boolean }): string

Rendering rules:

  • Inflight: offline → grey OFFLINE; no model (modelName === "unset") → dark ? / NO MODEL; unknown state → grey ; ready+0 → green IDLE; ready+≥1 → red ACTIVE (opacity toggled by pulse); loading → amber LOADING; else → grey OFF. Model name at the bottom.

  • GPU: dark chart area (x 3..69, y 20..55); % metrics scale 0..100, power scales to niceCeil(max*1.1); <polyline> line + gradient <path> fill under curve; value label top (severity-colored), METRIC · NAME bottom; offline → grey OFFLINE frame.

  • Step 1: Write the failing testtests/render.test.ts

import assert from "node:assert/strict";
import { test } from "node:test";
import { renderGpuGraph, renderInflight, svgDataUrl } from "../src/lib/render";

test("svgDataUrl wraps an SVG as a base64 data URL", () => {
  const url = svgDataUrl("<svg></svg>");
  assert.match(url, /^data:image\/svg\+xml;base64,/);
});

test("renderInflight: ready + count renders IDLE in green", () => {
  const svg = renderInflight({ modelName: "Qwen3.8-27B-NVFP4", state: "ready", count: 0, offline: false, pulse: false });
  assert.match(svg, /IDLE/);
  assert.match(svg, /#1e6b34/);
  assert.doesNotMatch(svg, /ACTIVE/);
});

test("renderInflight: ready + count>0 renders ACTIVE in red", () => {
  const svg = renderInflight({ modelName: "Qwen3.8-27B-NVFP4", state: "ready", count: 2, offline: false, pulse: false });
  assert.match(svg, /ACTIVE/);
  assert.match(svg, /#8b2626/);
});

test("renderInflight: pulse toggles opacity while active", () => {
  const a = renderInflight({ modelName: "A", state: "ready", count: 1, offline: false, pulse: false });
  const b = renderInflight({ modelName: "A", state: "ready", count: 1, offline: false, pulse: true });
  assert.notEqual(a, b);
  assert.match(b, /opacity="0.55"/);
});

test("renderInflight: offline and no-model states", () => {
  const offline = renderInflight({ modelName: "A", state: "ready", count: 0, offline: true, pulse: false });
  assert.match(offline, /OFFLINE/);
  const noModel = renderInflight({ modelName: "unset", state: undefined, count: 0, offline: false, pulse: false });
  assert.match(noModel, /NO MODEL/);
});

test("renderInflight: loading renders LOADING in amber", () => {
  const svg = renderInflight({ modelName: "A", state: "loading", count: 0, offline: false, pulse: false });
  assert.match(svg, /LOADING/);
  assert.match(svg, /#8a6d1d/);
});

test("renderGpuGraph: draws a polyline, value, and metric label", () => {
  const svg = renderGpuGraph({
    gpuName: "GPU 2 · RTX 5090",
    metric: "util_percent",
    value: 67,
    history: [0, 20, 40, 67],
    offline: false,
  });
  assert.match(svg, /<polyline/);
  assert.match(svg, /67%/);
  assert.match(svg, /UTIL/);
  assert.match(svg, /GPU 2/);
});

test("renderGpuGraph: power draws a watts value", () => {
  const svg = renderGpuGraph({
    gpuName: "ALL GPUS",
    metric: "power",
    value: 1030,
    history: [900, 1000, 1030],
    offline: false,
  });
  assert.match(svg, /1030W/);
  assert.match(svg, /PWR/);
  assert.match(svg, /ALL GPUS/);
});

test("renderGpuGraph: offline frame", () => {
  const svg = renderGpuGraph({ gpuName: "ALL GPUS", metric: "util_percent", value: undefined, history: [], offline: true });
  assert.match(svg, /OFFLINE/);
});

test("renderGpuGraph: single-point history avoids divide-by-zero", () => {
  const svg = renderGpuGraph({ gpuName: "ALL GPUS", metric: "util_percent", value: 50, history: [50], offline: false });
  assert.match(svg, /<svg/);
});
  • Step 2: Run test to verify it fails

Run: npm test Expected: FAIL — module not found.

  • Step 3: Implement src/lib/render.ts
import { type GpuMetricKind } from "./metrics-parser";
import { type ModelRuntimeState } from "./inflight-tracker";
import { escapeXml, shorten } from "./util";

export function svgDataUrl(svg: string): string {
  return `data:image/svg+xml;base64,${Buffer.from(svg).toString("base64")}`;
}

const METRIC_LABEL: Record<GpuMetricKind, string> = {
  util_percent: "UTIL",
  memory_util_percent: "VRAM",
  temperature: "TEMP",
  power: "PWR",
  fan: "FAN",
};

export interface InflightRenderOptions {
  modelName: string;
  state?: ModelRuntimeState;
  count: number;
  offline: boolean;
  pulse: boolean;
}

export function renderInflight(opts: InflightRenderOptions): string {
  const name = escapeXml(shorten(opts.modelName));

  if (opts.offline) {
    return frame("#3a3a3a", [
      centerText("!!", 34, 20, "bold", "#e0e0e0"),
      centerText("OFFLINE", 52, 9, "normal", "#bdbdbd"),
      centerText(name, 64, 7, "normal", "#ffffff"),
    ]);
  }

  if (opts.modelName === "unset") {
    return frame("#222222", [
      centerText("?", 36, 16, "bold", "#888888"),
      centerText("NO MODEL", 54, 8, "normal", "#666666"),
    ]);
  }

  if (!opts.state) {
    return frame("#222222", [centerText("…", 36, 16, "bold", "#999999"), centerText(name, 60, 7, "normal", "#999999")]);
  }

  const isActive = opts.state === "ready" && opts.count > 0;
  const bg = opts.state === "ready" ? (isActive ? "#8b2626" : "#1e6b34") : opts.state === "loading" ? "#8a6d1d" : "#3a3a3a";
  const label = opts.state === "ready" ? (isActive ? "ACTIVE" : "IDLE") : opts.state === "loading" ? "LOADING" : "OFF";
  const opacity = isActive && opts.pulse ? 0.55 : 1;

  return frame(bg, [
    centerText(label, 38, 18, "bold", "#ffffff", opacity),
    centerText(name, 64, 7, "normal", "#ffffff"),
  ]);
}

export interface GpuGraphRenderOptions {
  gpuName: string;
  metric: GpuMetricKind;
  value: number | undefined;
  history: number[];
  offline: boolean;
}

export function renderGpuGraph(opts: GpuGraphRenderOptions): string {
  const label = escapeXml(shorten(opts.gpuName));

  if (opts.offline) {
    return `<svg xmlns="http://www.w3.org/2000/svg" width="72" height="72" viewBox="0 0 72 72">
  <rect width="72" height="72" fill="#3a3a3a"/>
  <text x="36" y="30" text-anchor="middle" font-family="Arial,sans-serif" font-size="18" font-weight="bold" fill="#e0e0e0">!!</text>
  <text x="36" y="46" text-anchor="middle" font-family="Arial,sans-serif" font-size="9" fill="#bdbdbd">OFFLINE</text>
  <text x="36" y="62" text-anchor="middle" font-family="Arial,sans-serif" font-size="7" fill="#ffffff" opacity="0.9">${label}</text>
</svg>`;
  }

  const color = opts.value === undefined ? "#666666" : severityColor(opts.metric, opts.value);
  const valueText = opts.value === undefined ? "--" : `${Math.round(opts.value)}${unit(opts.metric)}`;
  const points = chartPoints(opts.history, opts.metric);

  const line =
    points.length > 1
      ? `<polyline points="${points.map((p) => `${p.x},${p.y}`).join(" ")}" fill="none" stroke="${color}" stroke-width="2" stroke-linejoin="round" stroke-linecap="round"/>`
      : "";

  const area =
    points.length > 1
      ? `<path d="M ${points.map((p) => `${p.x},${p.y}`).join(" L ")} L ${points[points.length - 1].x},55 L ${points[0].x},55 Z" fill="url(#grad)" opacity="0.35"/>`
      : "";

  return `<svg xmlns="http://www.w3.org/2000/svg" width="72" height="72" viewBox="0 0 72 72">
  <defs>
    <linearGradient id="grad" x1="0" y1="0" x2="0" y2="1">
      <stop offset="0%" stop-color="${color}" stop-opacity="0.9"/>
      <stop offset="100%" stop-color="${color}" stop-opacity="0"/>
    </linearGradient>
  </defs>
  <rect width="72" height="72" fill="#10131a"/>
  <rect x="1" y="18" width="70" height="40" fill="none" stroke="#242b38" stroke-width="1"/>
  <line x1="1" y1="55" x2="71" y2="55" stroke="#242b38" stroke-width="1"/>
  ${area}
  ${line}
  <text x="36" y="11" text-anchor="middle" font-family="Arial,sans-serif" font-size="11" font-weight="bold" fill="${color}">${valueText}</text>
  <text x="36" y="67" text-anchor="middle" font-family="Arial,sans-serif" font-size="7" fill="#8b93a5">${METRIC_LABEL[opts.metric]} · ${label}</text>
</svg>`;
}

function unit(metric: GpuMetricKind): string {
  return metric === "temperature" ? "°C" : metric === "power" ? "W" : "%";
}

function severityColor(metric: GpuMetricKind, value: number): string {
  if (metric === "temperature") return value >= 80 ? "#e0453a" : value >= 60 ? "#d9a02a" : "#3fae5a";
  if (metric === "power") return value >= 300 ? "#e0453a" : value >= 150 ? "#d9a02a" : "#3fae5a";
  return value >= 85 ? "#e0453a" : value >= 50 ? "#d9a02a" : "#3fae5a";
}

function chartPoints(history: number[], metric: GpuMetricKind): { x: number; y: number }[] {
  const n = history.length;
  if (n === 0) return [];
  const left = 3;
  const right = 69;
  const top = 20;
  const bottom = 55;
  const yMax = yScaleMax(metric, history);
  return history.map((v, i) => {
    const x = n === 1 ? (left + right) / 2 : left + ((right - left) * i) / (n - 1);
    const clamped = Math.max(0, Math.min(v, yMax));
    const y = bottom - ((clamped - 0) / (yMax - 0 || 1)) * (bottom - top);
    return { x: round1(x), y: round1(y) };
  });
}

function yScaleMax(metric: GpuMetricKind, history: number[]): number {
  if (metric !== "power") return 100;
  const maxValue = Math.max(...history);
  if (!isFinite(maxValue) || maxValue <= 0) return 100;
  return niceCeil(maxValue * 1.1);
}

function niceCeil(value: number): number {
  const magnitude = Math.pow(10, Math.floor(Math.log10(value)));
  for (const m of [1, 2, 2.5, 5, 10]) {
    if (m * magnitude >= value) return m * magnitude;
  }
  return value;
}

function round1(value: number): number {
  return Math.round(value * 10) / 10;
}

function centerText(text: string, y: number, size: number, weight: "normal" | "bold", fill: string, opacity?: number): string {
  return `<text x="36" y="${y}" text-anchor="middle" font-family="Arial,sans-serif" font-size="${size}" font-weight="${weight}" fill="${fill}"${opacity === undefined ? "" : ` opacity="${opacity}"`}>${text}</text>`;
}

function frame(bg: string, parts: string[]): string {
  return `<svg xmlns="http://www.w3.org/2000/svg" width="72" height="72" viewBox="0 0 72 72">
  <rect width="72" height="72" fill="${bg}"/>
  ${parts.join("\n  ")}
</svg>`;
}
  • Step 4: Run test to verify it passes

Run: npm test Expected: PASS.

  • Step 5: Commit
git add -A
git commit -m "feat: add SVG key renderers for inflight and GPU graph actions"

Task 9: runtime.ts + datasources.ts — shared state and PI data sourcing

Files:

  • Create: src/lib/runtime.ts
  • Create: src/lib/datasources.ts

Interfaces:

  • Consumes: EventFeed, InflightTracker, MetricsPoller, LlamaSwapConfig, fetchModels, fetchMetrics, gpuInfos, parseGpuMetrics, cfgFromSettings, CfgSettings.

  • Produces:

    • runtime: { tracker: InflightTracker; offline: boolean; ensureConnections(cfg): void; pollerInstance?: MetricsPoller; subscribe(listener): () => void }
    • registerDataSources(): void — wires streamDeck.ui.onSendToPlugin to answer models and gpus datasource requests.
  • Step 1: Implement src/lib/runtime.ts

import { EventFeed } from "./event-feed";
import { InflightTracker } from "./inflight-tracker";
import { MetricsPoller } from "./metrics-poller";
import { type LlamaSwapConfig } from "./util";

class Runtime {
  readonly tracker = new InflightTracker();
  offline = true;
  private feed?: EventFeed;
  private poller?: MetricsPoller;
  private listeners = new Set<() => void>();

  ensureConnections(cfg: LlamaSwapConfig): void {
    if (!this.feed) {
      this.feed = new EventFeed(cfg, (ev) => {
        this.tracker.apply(ev);
        this.emit();
      });
      this.feed.setStatusHandler((connected) => {
        this.offline = !connected;
        this.emit();
      });
      this.feed.start();
    }
    if (!this.poller) {
      this.poller = new MetricsPoller(cfg);
      this.poller.on(() => this.emit());
      this.poller.start();
    }
  }

  get pollerInstance(): MetricsPoller | undefined {
    return this.poller;
  }

  subscribe(listener: () => void): () => void {
    this.listeners.add(listener);
    return () => {
      this.listeners.delete(listener);
    };
  }

  private emit(): void {
    for (const listener of this.listeners) listener();
  }
}

export const runtime = new Runtime();
  • Step 2: Implement src/lib/datasources.ts
import streamDeck from "@elgato/streamdeck";
import { fetchMetrics, fetchModels } from "./llamaswap";
import { gpuInfos, parseGpuMetrics } from "./metrics-parser";
import { cfgFromSettings, type CfgSettings } from "./util";

interface DataSourceItem {
  label: string;
  value: string;
}

export function registerDataSources(): void {
  streamDeck.ui.onSendToPlugin(async (ev) => {
    const request = ev.payload as { event?: string } | undefined;
    const event = request?.event;
    if (!event) return;
    const settings = await ev.action.getSettings<CfgSettings>();

    if (event === "models") {
      let items: DataSourceItem[] = [];
      try {
        const models = await fetchModels(cfgFromSettings(settings));
        items = models.map((m) => ({ label: `${m.id} (${m.status})`, value: m.id }));
      } catch {
        items = [];
      }
      await streamDeck.ui.sendToPropertyInspector({ event, items });
      return;
    }

    if (event === "gpus") {
      let items: DataSourceItem[] = [];
      try {
        const text = await fetchMetrics(cfgFromSettings(settings));
        const gpus = gpuInfos(parseGpuMetrics(text));
        items = [
          { label: "All GPUs", value: "all" },
          ...gpus.map((g) => ({ label: `GPU ${g.id} · ${g.name}`, value: g.id })),
        ];
      } catch {
        items = [{ label: "All GPUs", value: "all" }];
      }
      await streamDeck.ui.sendToPropertyInspector({ event, items });
    }
  });
}
  • Step 3: Verify it typechecks

Run: npm run build Expected: build succeeds. (Note: import streamDeck default export + streamDeck.ui.onSendToPlugin — if the compiler reports a different method name, consult node_modules/@elgato/streamdeck/dist/plugin/ui.d.ts and adjust; the public API is onSendToPlugin / sendToPropertyInspector.)

  • Step 4: Commit
git add -A
git commit -m "feat: add shared runtime and PI datasource handlers"

Task 10: Actions — InflightMonitor and GpuGraph

Files:

  • Create: src/actions/inflight-monitor.ts
  • Create: src/actions/gpu-graph.ts

Interfaces:

  • Consumes: runtime, renderInflight/renderGpuGraph/svgDataUrl, cfgFromSettings/CfgSettings, GpuMetricKind, streamDeck (for system.openUrl).

  • Produces: two SingletonAction classes registered by UUIDs com.bryce.llamawatch.inflight and com.bryce.llamawatch.gpu.

  • Step 1: Implement src/actions/inflight-monitor.ts

import streamDeck, {
  action,
  type DidReceiveSettingsEvent,
  type KeyAction,
  type KeyDownEvent,
  SingletonAction,
  type WillAppearEvent,
} from "@elgato/streamdeck";
import { renderInflight, svgDataUrl } from "../lib/render";
import { runtime } from "../lib/runtime";
import { cfgFromSettings, type CfgSettings } from "../lib/util";

type InflightSettings = CfgSettings & {
  modelId?: string;
};

@action({ UUID: "com.bryce.llamawatch.inflight" })
export class InflightMonitor extends SingletonAction<InflightSettings> {
  private settings: InflightSettings = {};
  private action?: KeyAction<InflightSettings>;
  private unsubscribe?: () => void;
  private pulseTimer?: ReturnType<typeof setInterval>;
  private pulse = false;

  override onWillAppear(ev: WillAppearEvent<InflightSettings>): void {
    if (!ev.action.isKey()) return;
    this.settings = ev.payload.settings;
    this.action = ev.action;
    runtime.ensureConnections(cfgFromSettings(this.settings));
    this.unsubscribe = runtime.subscribe(() => this.render());
    this.render();
  }

  override onWillDisappear(): void {
    this.unsubscribe?.();
    this.unsubscribe = undefined;
    this.action = undefined;
    this.clearPulse();
  }

  override onDidReceiveSettings(ev: DidReceiveSettingsEvent<InflightSettings>): void {
    if (!ev.action.isKey()) return;
    this.settings = ev.payload.settings;
    this.action = ev.action;
    runtime.ensureConnections(cfgFromSettings(this.settings));
    this.render();
  }

  override onKeyDown(ev: KeyDownEvent<InflightSettings>): void {
    void streamDeck.system.openUrl(`${cfgFromSettings(ev.payload.settings).baseUrl}/ui`);
  }

  private render(): void {
    const action = this.action;
    if (!action) return;
    const modelId = this.settings.modelId ?? "";
    const state = runtime.tracker.state(modelId);
    const count = runtime.tracker.count(modelId);
    const offline = runtime.offline || modelId.length === 0;
    void action.setImage(
      svgDataUrl(
        renderInflight({
          modelName: modelId.length > 0 ? modelId : "unset",
          state,
          count,
          offline,
          pulse: this.pulse,
        }),
      ),
    );

    if (state === "ready" && count > 0 && !this.pulseTimer) {
      this.pulseTimer = setInterval(() => {
        this.pulse = !this.pulse;
        this.render();
      }, 500);
    } else if (!(state === "ready" && count > 0) && this.pulseTimer) {
      this.clearPulse();
    }
  }

  private clearPulse(): void {
    if (this.pulseTimer) clearInterval(this.pulseTimer);
    this.pulseTimer = undefined;
    this.pulse = false;
  }
}
  • Step 2: Implement src/actions/gpu-graph.ts
import streamDeck, {
  action,
  type DidReceiveSettingsEvent,
  type KeyAction,
  type KeyDownEvent,
  SingletonAction,
  type WillAppearEvent,
} from "@elgato/streamdeck";
import { type GpuMetricKind } from "../lib/metrics-parser";
import { renderGpuGraph, svgDataUrl } from "../lib/render";
import { runtime } from "../lib/runtime";
import { cfgFromSettings, type CfgSettings } from "../lib/util";

type GpuSettings = CfgSettings & {
  gpuId?: string;
  metric?: GpuMetricKind;
};

@action({ UUID: "com.bryce.llamawatch.gpu" })
export class GpuGraph extends SingletonAction<GpuSettings> {
  private settings: GpuSettings = {};
  private action?: KeyAction<GpuSettings>;
  private unsubscribe?: () => void;
  private lastSvg?: string;

  override onWillAppear(ev: WillAppearEvent<GpuSettings>): void {
    if (!ev.action.isKey()) return;
    this.settings = ev.payload.settings;
    this.action = ev.action;
    runtime.ensureConnections(cfgFromSettings(this.settings));
    this.unsubscribe = runtime.subscribe(() => this.render());
    this.render();
  }

  override onWillDisappear(): void {
    this.unsubscribe?.();
    this.unsubscribe = undefined;
    this.action = undefined;
  }

  override onDidReceiveSettings(ev: DidReceiveSettingsEvent<GpuSettings>): void {
    if (!ev.action.isKey()) return;
    this.settings = ev.payload.settings;
    this.action = ev.action;
    this.render();
  }

  override onKeyDown(ev: KeyDownEvent<GpuSettings>): void {
    void streamDeck.system.openUrl(`${cfgFromSettings(ev.payload.settings).baseUrl}/ui`);
  }

  private render(): void {
    const action = this.action;
    if (!action) return;
    const poller = runtime.pollerInstance;
    const gpuId = this.settings.gpuId ?? "all";
    const metric = this.settings.metric ?? "util_percent";
    const value = poller?.getValue(gpuId, metric);
    const history = poller?.getHistory(gpuId, metric) ?? [];
    const offline = !poller || poller.isOffline();
    const gpuName = gpuId === "all" ? "ALL GPUS" : this.gpuLabel(gpuId);
    const svg = renderGpuGraph({ gpuName, metric, value, history, offline });
    if (svg === this.lastSvg) return;
    this.lastSvg = svg;
    void action.setImage(svgDataUrl(svg));
  }

  private gpuLabel(gpuId: string): string {
    const gpu = runtime.pollerInstance?.gpus().find((g) => g.id === gpuId);
    return gpu && gpu.name ? `GPU ${gpuId} · ${gpu.name}` : `GPU ${gpuId}`;
  }
}
  • Step 3: Verify it typechecks

Run: npm run build Expected: build succeeds.

  • Step 4: Commit
git add -A
git commit -m "feat: implement inflight monitor and GPU graph actions"

Task 11: Entry point, manifest actions, property inspectors, assets

Files:

  • Modify: src/plugin.ts
  • Modify: com.bryce.llamawatch.sdPlugin/manifest.json
  • Create: com.bryce.llamawatch.sdPlugin/ui/inflight.html
  • Create: com.bryce.llamawatch.sdPlugin/ui/gpu.html
  • Create: com.bryce.llamawatch.sdPlugin/ui/sdpi-components.js (downloaded, v4)
  • Create: com.bryce.llamawatch.sdPlugin/imgs/plugin/icon.svg (256×256 source)
  • Create: com.bryce.llamawatch.sdPlugin/imgs/plugin/category-icon.svg
  • Create: com.bryce.llamawatch.sdPlugin/imgs/actions/inflight/icon.svg (monochrome)
  • Create: com.bryce.llamawatch.sdPlugin/imgs/actions/inflight/key.svg
  • Create: com.bryce.llamawatch.sdPlugin/imgs/actions/gpu/icon.svg (monochrome)
  • Create: com.bryce.llamawatch.sdPlugin/imgs/actions/gpu/key.svg

Interfaces:

  • Consumes: action classes and registerDataSources.

  • Produces: a fully manifest-defined plugin (both actions), compiled entry, PI pages wired to the models/gpus datasources, and generated PNG icons.

  • Step 1: Replace src/plugin.ts

import streamDeck from "@elgato/streamdeck";
import { GpuGraph } from "./actions/gpu-graph";
import { InflightMonitor } from "./actions/inflight-monitor";
import { registerDataSources } from "./lib/datasources";

streamDeck.actions.registerAction(new InflightMonitor());
streamDeck.actions.registerAction(new GpuGraph());
registerDataSources();

streamDeck.connect();
  • Step 2: Replace com.bryce.llamawatch.sdPlugin/manifest.json
{
  "$schema": "https://schemas.elgato.com/streamdeck/plugins/manifest.json",
  "Name": "llama-watch",
  "Version": "1.0.0.0",
  "Author": "Bryce Zuccaro",
  "Actions": [
    {
      "Name": "In-Flight Monitor",
      "UUID": "com.bryce.llamawatch.inflight",
      "Icon": "imgs/actions/inflight/icon",
      "Tooltip": "Shows whether a model has a request in flight",
      "PropertyInspectorPath": "ui/inflight.html",
      "Controllers": ["Keypad"],
      "States": [{ "Image": "imgs/actions/inflight/key", "ShowTitle": false }]
    },
    {
      "Name": "GPU Graph",
      "UUID": "com.bryce.llamawatch.gpu",
      "Icon": "imgs/actions/gpu/icon",
      "Tooltip": "Live GPU metric graph",
      "PropertyInspectorPath": "ui/gpu.html",
      "Controllers": ["Keypad"],
      "States": [{ "Image": "imgs/actions/gpu/key", "ShowTitle": false }]
    }
  ],
  "Category": "llama-watch",
  "CategoryIcon": "imgs/plugin/category-icon",
  "CodePath": "bin/plugin.js",
  "Description": "Monitor llama-swap: in-flight requests per model and live GPU metric graphs.",
  "Icon": "imgs/plugin/icon",
  "SDKVersion": 2,
  "Software": { "MinimumVersion": "7.1" },
  "OS": [{ "Platform": "mac", "MinimumVersion": "13" }],
  "Nodejs": { "Version": "24" },
  "UUID": "com.bryce.llamawatch"
}
  • Step 3: Download sdpi-components.js (v4) locally
curl -sL https://sdpi-components.dev/releases/v4/sdpi-components.js -o com.bryce.llamawatch.sdPlugin/ui/sdpi-components.js

Expected: file is non-empty and starts with a JS comment/IIFE.

  • Step 4: Create com.bryce.llamawatch.sdPlugin/ui/inflight.html
<!doctype html>
<html>
  <head lang="en">
    <meta charset="utf-8" />
    <script src="sdpi-components.js"></script>
  </head>
  <body>
    <sdpi-item label="Base URL">
      <sdpi-textfield setting="baseUrl" value="http://localhost:9292" placeholder="http://host:port" />
    </sdpi-item>
    <sdpi-item label="API Key">
      <sdpi-password setting="apiKey" placeholder="Optional" />
    </sdpi-item>
    <sdpi-item label="Model">
      <sdpi-select setting="modelId" datasource="models" loading="Loading models…" hot-reload placeholder="Select a model" />
    </sdpi-item>
  </body>
</html>
  • Step 5: Create com.bryce.llamawatch.sdPlugin/ui/gpu.html
<!doctype html>
<html>
  <head lang="en">
    <meta charset="utf-8" />
    <script src="sdpi-components.js"></script>
  </head>
  <body>
    <sdpi-item label="Base URL">
      <sdpi-textfield setting="baseUrl" value="http://localhost:9292" placeholder="http://host:port" />
    </sdpi-item>
    <sdpi-item label="API Key">
      <sdpi-password setting="apiKey" placeholder="Optional" />
    </sdpi-item>
    <sdpi-item label="GPU">
      <sdpi-select setting="gpuId" datasource="gpus" loading="Loading GPUs…" hot-reload default="all" placeholder="Select a GPU" />
    </sdpi-item>
    <sdpi-item label="Metric">
      <sdpi-select setting="metric" default="util_percent" placeholder="Select a metric">
        <option value="util_percent">Utilization %</option>
        <option value="memory_util_percent">VRAM %</option>
        <option value="temperature">Temperature</option>
        <option value="power">Power draw</option>
        <option value="fan">Fan speed</option>
      </sdpi-select>
    </sdpi-item>
  </body>
</html>
  • Step 6: Create the SVG assets

com.bryce.llamawatch.sdPlugin/imgs/plugin/icon.svg (256×256, graph glyph on dark rounded square):

<svg xmlns="http://www.w3.org/2000/svg" width="256" height="256" viewBox="0 0 256 256">
  <rect width="256" height="256" rx="48" fill="#0e1117"/>
  <polyline points="40,190 90,150 130,170 190,80 216,60" fill="none" stroke="#3fae5a" stroke-width="14" stroke-linecap="round" stroke-linejoin="round"/>
  <circle cx="216" cy="60" r="16" fill="#e0453a"/>
</svg>

com.bryce.llamawatch.sdPlugin/imgs/plugin/category-icon.svg (monochrome white, 56×56):

<svg xmlns="http://www.w3.org/2000/svg" width="56" height="56" viewBox="0 0 56 56">
  <polyline points="8,42 20,32 28,38 42,16 48,10" fill="none" stroke="#ffffff" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

com.bryce.llamawatch.sdPlugin/imgs/actions/inflight/icon.svg (monochrome white, activity pulse):

<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20">
  <polyline points="1,10 5,10 7,4 10,16 12,10 19,10" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

com.bryce.llamawatch.sdPlugin/imgs/actions/inflight/key.svg (72×72 default key):

<svg xmlns="http://www.w3.org/2000/svg" width="72" height="72" viewBox="0 0 72 72">
  <rect width="72" height="72" fill="#222222"/>
  <polyline points="10,50 22,50 28,30 36,60 42,50 62,50" fill="none" stroke="#ffffff" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

com.bryce.llamawatch.sdPlugin/imgs/actions/gpu/icon.svg (monochrome white, chip):

<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20">
  <rect x="4" y="4" width="12" height="12" rx="2" fill="none" stroke="#ffffff" stroke-width="2"/>
  <rect x="8" y="8" width="4" height="4" fill="#ffffff"/>
  <path d="M9 1v3M13 1v3M9 16v3M13 16v3M1 9h3M1 13h3M16 9h3M16 13h3" stroke="#ffffff" stroke-width="2" stroke-linecap="round"/>
</svg>

com.bryce.llamawatch.sdPlugin/imgs/actions/gpu/key.svg (72×72 default key):

<svg xmlns="http://www.w3.org/2000/svg" width="72" height="72" viewBox="0 0 72 72">
  <rect width="72" height="72" fill="#222222"/>
  <rect x="14" y="14" width="44" height="44" rx="6" fill="none" stroke="#ffffff" stroke-width="4"/>
  <rect x="30" y="30" width="12" height="12" fill="#ffffff"/>
  <path d="M32 2v8M40 2v8M32 62v8M40 62v8M2 32h8M2 40h8M62 32h8M62 40h8" stroke="#ffffff" stroke-width="4" stroke-linecap="round"/>
</svg>
  • Step 7: Generate the plugin PNG icon (256 + 512 @2x) with sips
cd com.bryce.llamawatch.sdPlugin/imgs/plugin
sips -s format png icon.svg --out icon.png
sips -z 512 512 icon.png --out icon@2x.png

Expected: icon.png is 256×256, icon@2x.png is 512×512.

  • Step 8: Build and verify

Run: npm run build Expected: build succeeds and com.bryce.llamawatch.sdPlugin/bin/plugin.js exists.

  • Step 9: Commit
git add -A
git commit -m "feat: wire entry point, manifest, property inspectors, and assets"

Task 12: Validate, install, and manually verify on the device

Files:

  • Create: README.md

Interfaces:

  • Consumes: the built plugin folder.

  • Produces: .streamDeckPlugin package, plugin installed and verified live on the Stream Deck XL, README.

  • Step 1: Validate the manifest

Run:

streamdeck validate com.bryce.llamawatch.sdPlugin

Expected: validation passes (fix any reported issues, e.g. missing icon paths).

  • Step 2: Enable developer mode (if needed) and install the plugin

Try the CLI link first:

streamdeck dev
streamdeck link com.bryce.llamawatch.sdPlugin

If link fails because the CLI cannot locate the app, copy the folder manually:

cp -R com.bryce.llamawatch.sdPlugin "$HOME/Library/Application Support/com.elgato.StreamDeck/Plugins/"

(The Stream Deck app at /Applications/Elgato Stream Deck.app auto-loads plugins from that directory.) Then verify with streamdeck list or by restarting the app.

  • Step 3: Verify the plugin loads

  • Restart the Stream Deck app (or streamdeck restart com.bryce.llamawatch).

  • Confirm the action list contains "In-Flight Monitor" and "GPU Graph" under the "llama-watch" category.

  • Check ~/Library/Logs/StreamDeck (or the plugin logs/ dir) for errors if the actions don't appear.

  • Step 4: Manual verification on the device (Stream Deck XL)

  • Place an In-Flight Monitor action and pick model DeepSeek-V4-Flash-0731; confirm it renders green IDLE. While an OpenWebUI/chat request is running against that model, confirm it turns red ACTIVE and pulses. Press the key → browser opens http://localhost:9292/ui.

  • Place a GPU Graph action; GPU dropdown should list "All GPUs" plus the three GPUs (RTX PRO 6000 ×2, RTX 5090). Select GPU 2 + metric Utilization %; confirm a graph line appears and updates every ~5s. Select metric Power draw; confirm watts value + auto-scaled chart. Press → opens /ui.

  • Stop the llama-swap instance (or use a bogus base URL on a spare key) and confirm buttons show OFFLINE; restore and confirm auto-recovery.

  • Step 5: Write README.md

# llama-watch

A Stream Deck plugin (macOS) that monitors a llama-swap instance.

## Actions

- **In-Flight Monitor** — per-model color state: `OFF` / `LOADING` / `IDLE` / `ACTIVE` (pulsing red). Powered by the real-time `/api/events` SSE feed.
- **GPU Graph** — live line chart of a GPU metric (utilization %, VRAM %, temperature, power draw, fan speed) for one GPU or all GPUs, sampled every 5 s.

Pressing either key opens `http://<base-url>/ui` in your browser.

## Install

Double-click the packaged `.streamDeckPlugin`, or run:

```bash
npm install
npm run build
streamdeck validate com.bryce.llamawatch.sdPlugin

Develop

npm run watch   # hot-reload while the Stream Deck app is running
npm test        # unit tests (node:test + tsx)

Configure

Per-key settings: base URL (default http://localhost:9292), optional API key, and the model / GPU / metric to watch. The model and GPU dropdowns are populated live from the instance.

Marketplace

Assets are prepared for eventual submission (imgs/plugin/icon.png 256/512, action icons, category icon). The plugin only stores its settings locally; it reads GPU metrics and request status from the user's own llama-swap server and sends nothing elsewhere.


- [ ] **Step 6: Package the distributable**

Run:
```bash
streamdeck pack com.bryce.llamawatch.sdPlugin

Expected: com.bryce.llamawatch.streamDeckPlugin created. Double-clicking it installs the plugin.

  • Step 7: Commit
git add -A
git commit -m "docs: add README; verify plugin install and rendering on device"

Self-Review

Spec coverage:

  • Feature 1 (in-flight per model) → Tasks 6, 7 (tracker + feed), Task 10 (action), Task 11 (PI model dropdown). ✔
  • Feature 2 (GPU graphs, all five metrics + aggregates) → Tasks 3, 5 (parser/poller/aggregates), Task 8 (renderer), Task 10 (action), Task 11 (PI GPU+metric dropdowns). ✔
  • 5s poll, 60-sample buffer → Task 5. ✔
  • 144×72 rendering / SVG, DPR 2 → SVG viewBox 72 + setImage; device is Stream Deck XL (DPR 2), SVG scales crisply. ✔
  • IDLE/ACTIVE color state, pulse → Task 8/10. ✔
  • Press opens /ui → Task 10 (streamDeck.system.openUrl). ✔
  • Error handling (offline, reconnect backoff, missed-poll skip, per-button isolation) → Task 5/7/9/10. ✔
  • Marketplace-ready assets → Task 11/12. ✔

Placeholder scan: No TBD/TODO; all code blocks complete; no "similar to Task N" references; every interface symbol is defined in a task.

Type consistency: GpuMetricKind, FeedEvent, ModelRuntimeState, LlamaSwapConfig, cfgFromSettings, runtime, renderInflight, renderGpuGraph names are identical across tasks. KeyAction<T> used consistently for stored action refs; streamDeck.system.openUrl and streamDeck.ui.onSendToPlugin/sendToPropertyInspector match @elgato/streamdeck@2.1.1 (verified against its .d.ts). No lint/typecheck config beyond the sample's tsconfig — npm run build is the type gate.