Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
6b377e7
Single-source the JSON-RPC worker frame cap across every hop
Noah-Tervalon-Nvidia Sep 21, 2026
e99efb0
Serve a downloadable model catalog from the engine manager
Noah-Tervalon-Nvidia Sep 21, 2026
001b0e4
Serve the desktop model hub from the backend catalog
Noah-Tervalon-Nvidia Sep 21, 2026
c414dbf
Ship one inference-dispatcher, from cli-bin
Noah-Tervalon-Nvidia Sep 21, 2026
9a61f3d
Report a broken broker stream as a disconnect, not a bad frame
Noah-Tervalon-Nvidia Sep 22, 2026
e70fbd2
Add the shared frame primitives the views are built on
Noah-Tervalon-Nvidia Sep 22, 2026
5381e2f
Rebuild the terminal interface around nodes
Noah-Tervalon-Nvidia Sep 23, 2026
ec7a6c8
Stamp the release version the update notice compares against
Noah-Tervalon-Nvidia Sep 22, 2026
144e472
Edit an engine's launch arguments from the terminal interface
Noah-Tervalon-Nvidia Sep 22, 2026
32738dc
Reconcile the documentation with what the interface now does
Noah-Tervalon-Nvidia Sep 22, 2026
953a9ab
Let the staged-binary check know about the demo client
Noah-Tervalon-Nvidia Sep 22, 2026
eaedf55
Keep the interface readable on a light terminal
Noah-Tervalon-Nvidia Sep 22, 2026
7f5610d
Send the request identifier a settings commit requires
Noah-Tervalon-Nvidia Sep 22, 2026
67ca963
Detect the terminal background before Bubble Tea takes stdin
Noah-Tervalon-Nvidia Sep 22, 2026
fbb37e0
Keep this machine's settings snapshot current
Noah-Tervalon-Nvidia Sep 22, 2026
94d041d
Return to the node list when the Nodes tab is left
Noah-Tervalon-Nvidia Sep 22, 2026
33b5470
Take the settings verdict from the backend, not from the ports
Noah-Tervalon-Nvidia Sep 22, 2026
85ff266
Announce a pairing request once, and follow it as it changes
Noah-Tervalon-Nvidia Sep 23, 2026
b843b95
Say a node is outside the cluster rather than calling it silent
Noah-Tervalon-Nvidia Sep 23, 2026
a6bc294
Name the engines an unpaired node advertises
Noah-Tervalon-Nvidia Sep 23, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 49 additions & 37 deletions .cursor/rules/model-registry.mdc
Original file line number Diff line number Diff line change
@@ -1,30 +1,29 @@
---
description: Electron-main engine model hub (Ollama committed list + LM Studio catalog)
description: Backend-owned engine model catalogue (Ollama committed list + LM Studio catalog)
alwaysApply: true
---
<!--
SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
SPDX-License-Identifier: Apache-2.0
-->

# Model Registry — engine model hub
# Model Registry — engine model catalogue

- The backend has **no model-search RPC**, so the "Add Model" browse is
served by a standalone Electron-main module, **`src/electron/model-hub/`**
(main process, because the renderer cannot call `huggingface.co` under
CSP+CORS). There is **no generic Hugging Face browse and no shared GGUF
registry** — each enabled engine has exactly one curated source declared via
`EngineCaps.engineHub`.
- **Ollama** (`ollama-library.ts`): a **locked, committed list**, not a live
scrape. `src/electron/model-hub/ollama-models.json` (wire shape
`{ scrapedAt, source, count, models: OllamaTagsModel[] }`) is bundled into
the main process (`resolveJsonModule` + electron-vite inlining), so
`loadOllamaModels()` returns instantly with **no network access at runtime**.
Devs regenerate it with `npm run scrape:ollama-models`
(`scripts/scrape-ollama-models.ts`) when Ollama's catalog changes, then review
the diff and commit. The scraper is the **only** place Ollama scraping lives:
two phases — (A) parse the `https://ollama.com/library` index (anchored on
each card's `<a class="group w-full">` link, capability chips are
- The "Add Model" browse is served by **`nvpair-engine-manager`** over the
**`engine:catalog`** JSON-RPC method (`services/nvpair-engine-manager/catalog.go`).
It lives in the backend because both front ends need it: the desktop app's
Add Model modal and the terminal interface's model browser. There is **no
generic Hugging Face browse and no shared GGUF registry** — each enabled
engine has exactly one curated source.
- **Ollama**: a **locked, committed list**, not a live scrape.
`services/nvpair-engine-manager/catalog/ollama-models.json` (wire shape
`{ scrapedAt, source, count, models: [...] }`) is compiled in with `go:embed`,
so the catalogue is served with **no network access at runtime**. Devs
regenerate it with `npm run scrape:ollama-models`
(`desktop/scripts/scrape-ollama-models.ts`) when Ollama's catalog changes,
then review the diff and commit. The scraper is the **only** place Ollama
scraping lives: two phases — (A) parse the `https://ollama.com/library` index
(anchored on each card's `<a class="group w-full">` link, capability chips are
`text-indigo-600`, size chips `text-blue-600`, updated timestamp in the stats
span `title`), then (B) fetch each `https://ollama.com/library/<base>/tags`
page (concurrency 4, anchored on the self-contained mobile card
Expand All @@ -33,25 +32,38 @@ SPDX-License-Identifier: Apache-2.0
variants are filtered. Names are used as-is for `ollama pull`. **When Ollama
changes their markup, update the parser in the script and re-run it** — the
running app is unaffected.
- **LM Studio** (`lmstudio-catalog.ts`, `lmStudioCatalogCache`): still a **live
fetch**. GETs
- **LM Studio**: still a **live fetch**. GETs
`https://huggingface.co/api/models?author=lmstudio-community&sort=downloads&direction=-1&limit=500`
and normalizes each repo to a pull-ready id (e.g.
`lmstudio-community/Qwen3-8B-GGUF`, accepted by `lms get`). This is the
**only** remaining network call in the model hub, and it is main-process only
(6-hour TTL + in-flight guard so PAIR never fetches continuously).
- Both sources normalize to the shared `EngineHubModel`
(`src/shared/types/engine-api.ts`).
- `index.ts` exposes `getEngineHubModels(engineType)` (Ollama returns the
committed list synchronously; LM Studio awaits a cold cache's initial load;
returns `{ models }`) and `warmEngineHubs()` (warms **only** LM Studio —
Ollama needs none). The `engine:search-hub` handler in `empty-handlers.ts`
calls the former; the `overview:ready` handler in `window.ipc.ts` calls the
latter. Warm on renderer-ready, **not** on service connect: a catalog fetch
started before the window has painted competes with the renderer's own load,
and a hanging one leaves an unpainted window behind.
- The renderer (`src/ui/components/ModelHub/`) fetches an engine's full
catalog once (cached per-engine), filters by the search box **locally** on
each keystroke, and sorts client-side by Updated / Name / Size. Pulling goes
through the backend (`engine:pull-model` → `pull_model`); the hub only
produces the pull-ready id.
**only** network call in the catalogue: 6-hour TTL, a coalescing guard so
concurrent callers share one request, and a failure backoff so a dead upstream
is not re-dialled on every call.
- Both sources normalize to `CatalogModel`, which the desktop maps to the shared
`EngineHubModel` (`desktop/src/shared/types/engine-api.ts`).
- **The catalogue is filtered for the platform the models will install on**, not
the one serving it. MLX quantizations only install on Apple Silicon, so
`engine:catalog` takes an optional `platform` (defaulting to the server's
`GOOS`), marks Apple-only rows with `appleOnly`, and echoes the platform it
filtered for. A client driving a peer should say which peer; a client that
cannot determine it must tell the operator which platform the list applies to
rather than presenting it as universal.
- **Frame size matters here.** The Ollama reply is a single ~1.9 MiB JSON-RPC
line. Every hop on its path shares `jsonrpc.WorkerFrameBytes`, because an
over-long line is a terminal read error that silently closes the peer while
the child keeps running. A test in `catalog_test.go` fails if the marshalled
catalogue outgrows the frame; **filter or paginate rather than raising the cap
again**.
- The desktop relays through
`desktop/src/electron/service-bridge/model-catalog.ts`, which exposes
`getEngineHubModels(engineType)` and `warmEngineHubs()` (warms **only**
LM Studio — Ollama needs none). The `engine:search-hub` handler in
`empty-handlers.ts` calls the former; the `overview:ready` handler in
`window.ipc.ts` calls the latter. Warm on renderer-ready, **not** on service
connect: a catalog fetch started before the window has painted competes with
the renderer's own load, and a hanging one leaves an unpainted window behind.
- Both front ends fetch an engine's full catalog once (cached per-engine) and
filter **locally** as the user types. The desktop renderer
(`src/ui/components/ModelHub/`) sorts client-side by Updated / Name / Size.
Pulling goes through the engine manager (`engine:pull-model` → `pull_model`);
the catalogue only produces the pull-ready id.
66 changes: 45 additions & 21 deletions .cursor/rules/system-architecture.mdc
Original file line number Diff line number Diff line change
Expand Up @@ -46,10 +46,12 @@ Never launch one worker from both Electron and the broker. There is no
broker-absent fallback.

The Inference Demo's `inference-dispatcher` is the one non-broker executable
Electron launches. It is not a worker: its source lives in
`scripts/inference-dispatcher` at the monorepo root, it is absent from
`services/versions.json` and `modular-binaries.ts`, and it ships in its own
`tools/` resource directory. See "Inference Demo" below.
Electron launches, and the only child `nvpair-tui` spawns besides its own broker.
It is not a worker: its source lives in `scripts/inference-dispatcher` at the
monorepo root, and it is absent from `services/versions.json` and
`modular-binaries.ts`. It nonetheless ships inside `cli-bin` — beside the
binaries it is not one of — so both front ends resolve it the same way. See
"Inference Demo" below.

The connector remains `connecting` until broker `app:ready` and the required
Ollama proxy readiness arrive. A startup deadline routes failures to
Expand Down Expand Up @@ -175,20 +177,32 @@ pressure 1 for invalid, missing, or stale data, then ranks by
## Inference Demo

A fixed sixty-second burst of synthetic traffic sent through the local proxies
so job activity is visible on Overview.
so job activity is visible.

Both front ends run it, each owning its own schedule:

- Electron main (`src/electron/inference-demo.ts`,
`src/electron/inference-demo-schedule.ts`), surfaced on Settings > Service and
visible on Overview.
- `nvpair-tui` (`ui/demo.go`, `ui/demoschedule.go`), on the Jobs tab, driven by
the shell's one-second tick because every submission offset is a whole second.

The two schedules must stay identical — cohorts, stages, offsets, and the
ceiling. They are a contract between the front ends, not an implementation
detail of either; a change to one is a change to both. Neither drives the other.

- Electron main owns the schedule (`src/electron/inference-demo.ts`,
`src/electron/inference-demo-schedule.ts`).
- Each request spawns the bundled `inference-dispatcher`, which behaves as an
ordinary third-party HTTP client.
ordinary third-party HTTP client.
- Requests target a broker-reported proxy port, never an engine's own port. The
backend decides placement; PAIR must not describe the demo as distributing
work itself.
- Progress is broadcast on the `demo:state` Electron push channel
(`IpcPushChannelMap`), not the service push bus. State is node-local and is
not synchronized.
- Children run with `stdio: 'ignore'` and with `INFERENCE_DISPATCHER_*` stripped
from the environment. Never surface or log prompts or responses.
backend decides placement; PAIR must not describe the demo as distributing
work itself.
- State is node-local and is not synchronized. Electron broadcasts it on the
`demo:state` Electron push channel (`IpcPushChannelMap`); the terminal keeps it
in view state. Neither goes near the service push bus.
- Stopping cancels only unsent requests. Never cancel, await, or report on one
already submitted.
- Children run with stdio discarded and with `INFERENCE_DISPATCHER_*` stripped
from the environment. Never surface or log prompts or responses.

## Engines

Expand All @@ -203,12 +217,22 @@ operations.
state.
- Engine and proxy ports are persisted by their backend owners.
- Running adopted engines reject operations that require process ownership.
- Per-engine environment and CLI argument overrides are not part of the current
contract.

The model hub is Electron-main functionality under `src/electron/model-hub/`.
It provides curated Ollama and LM Studio catalogs; model operations still go
through the engine manager.
- Per-engine launch arguments and environment are editable through
`engine:get-settings`, `engine:preview-settings`, and `engine:apply-settings`,
which write the server port, the proxy port, and the launch text together
against a revision. Do not write any of the three by another route: a second
writer with no shared revision cannot tell that it lost. Validate through the
preview and commit the settings it returns, not the draft that was sent.
- Editability is the snapshot's `Editable` and `Reason`, not a rule restated in
a front end. The backend relays these to a peer, so a front end that refuses
remote editing on its own is wrong rather than cautious.

The model catalogue is owned by `nvpair-engine-manager` and served over
`engine:catalog`, so the desktop app and the terminal interface browse one
implementation. Electron relays it through
`src/electron/service-bridge/model-catalog.ts`. It provides curated Ollama and
LM Studio catalogs, filtered for the platform a model will install on; model
operations go through the engine manager.

## Pairing and security

Expand Down
15 changes: 13 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -165,11 +165,22 @@ jobs:
# LC_ALL=C makes the sort byte-wise, which is also what comm
# below assumes.
norm() { tr -d '\r' | sed 's/\.exe$//' | LC_ALL=C sort; }
expected=$(jq -r '.components | keys[]' versions.json | norm)
# inference-dispatcher is staged but is not a component, and
# both halves of that are deliberate. It is an ordinary HTTP
# client the Inference Demo spawns per request -- no JSON-RPC,
# nothing supervising it, no entry in versions.json -- but it
# ships in this directory because nvpair-tui resolves it
# beside its own executable, exactly as it resolves the
# broker. Named here rather than filtered by a pattern, so a
# second undeclared binary still fails.
expected=$(
{ jq -r '.components | keys[]' versions.json;
echo inference-dispatcher; } | norm
)
staged=$(ls -1 build/bin | norm)

if [ "$expected" = "$staged" ]; then
printf 'all %s declared components staged\n' \
printf 'all %s expected binaries staged\n' \
"$(printf '%s\n' "$expected" | wc -l | tr -d ' ')"
exit 0
fi
Expand Down
10 changes: 6 additions & 4 deletions desktop/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,10 @@ out
notes.txt
vault-notes.txt
coverage
# Compiled Go binaries: the services workers, nvpair-tui, and the Inference
# Demo's inference-dispatcher, all built by `npm run build:modular-binaries`.
cli-bin

# Compiled inference-dispatcher client (built from ../scripts/inference-dispatcher
# by `npm run build:tools`; the Go sources are tracked at the monorepo root).
tools

# Generated app-icon artifacts (build/runtime containers derived from the master
# artwork in resources/app-icon/ by `npm run generate:icons`, which runs
# automatically via prebuild:electron / prestart). Source of truth is app-icon/.
Expand Down Expand Up @@ -64,4 +62,8 @@ tsconfig.deadcode.node.json
tsconfig.deadcode.web.json
sea-config.json

# TypeScript incremental build caches. Machine-local and regenerated by any
# typecheck, so a tracked one produces a diff on every run.
*.tsbuildinfo

review.diff
31 changes: 24 additions & 7 deletions desktop/docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -254,17 +254,26 @@ engine's private port. This transport security is backend-owned; Electron only
reflects the advertised proxy port and reads a remote engine's real port from
`engine:remote-get-installed` facts.

The model hub is Electron-main functionality in `src/electron/model-hub/`:
The model catalogue is owned by `nvpair-engine-manager` and served over
`engine:catalog`. Electron relays it through
`src/electron/service-bridge/model-catalog.ts`; the terminal interface calls the
same method, so both front ends browse one implementation.

- Ollama models come from a locked, committed list
(`src/electron/model-hub/ollama-models.json`) bundled into the main process —
there is no runtime Ollama scraping. Devs regenerate the list with
(`services/nvpair-engine-manager/catalog/ollama-models.json`) compiled in with
`go:embed` — there is no runtime Ollama scraping. Devs regenerate the list with
`npm run scrape:ollama-models` (`scripts/scrape-ollama-models.ts`) and commit
it when Ollama's catalog changes;
- LM Studio models come from the curated `lmstudio-community` catalog, still
fetched live from Hugging Face and cached for six hours. The cache is warmed
fetched live from Hugging Face and cached for six hours, with concurrent
callers coalesced onto one request and a failure backoff. The cache is warmed
when the Overview renderer reports ready, not when the service connects, so a
slow or hanging catalog fetch cannot compete with the window's first paint;
- the request takes an optional `platform`, marks Apple-only (MLX) rows, and
echoes the platform it filtered for, so a client driving a peer is not offered
models that peer cannot install;
- the Ollama reply is a single multi-megabyte frame, so every hop on its path
shares `jsonrpc.WorkerFrameBytes`. See `docs/services-backend.md`;
- model pulls still run through `nvpair-engine-manager`.

## Inference Demo
Expand All @@ -273,14 +282,22 @@ The Inference Demo sends a fixed sixty-second burst of synthetic inference
traffic through the local proxies so job activity is visible on Overview. It is
the one place Electron launches a non-broker executable.

Both front ends offer it. The terminal interface runs the same schedule from its
Jobs tab (`services/nvpair-tui/ui/demoschedule.go`), against the same
dispatcher, so a headless machine can demonstrate routing too. The two schedules
are deliberately identical; neither drives the other, because demo state is
node-local.

- The schedule is built and owned by Electron main
(`src/electron/inference-demo.ts` and
`src/electron/inference-demo-schedule.ts`).
- Each scheduled request spawns the bundled `inference-dispatcher` client, a
standalone Go HTTP client that knows nothing about the broker, JSON-RPC, or
discovery. Its source is `scripts/inference-dispatcher` at the monorepo root
and it ships in `resources/tools`, outside the services `cli-bin` inventory.
See [Inference dispatcher](../../docs/inference-dispatcher.mdx).
discovery. Its source is `scripts/inference-dispatcher` at the monorepo root.
It ships inside `cli-bin` — it is still not a services component and has no
entry in `versions.json`, but sharing the directory is what lets `nvpair-tui`
find it beside its own executable in a packaged app as well as in a services
install. See [Inference dispatcher](../../docs/inference-dispatcher.mdx).
- Requests are addressed to a proxy port reported by the broker, never to an
engine's own port, so the backend places them exactly as it would place any
third-party client's traffic. PAIR makes no routing decision.
Expand Down
11 changes: 11 additions & 0 deletions desktop/docs/services-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@
| `errors:clear` | notification (we consume) | ✅ yes |
| `errors:report` | notification (we consume) | ✅ yes |
| `engine:action` | request (we call) | ✅ yes |
| `engine:catalog` | request (we call) | ✅ yes |
| `engine:configure-launch` | request (we call) | ⚠️ not called |
| `engine:configured-ports` | request (we call) | ⚠️ not called |
| `engine:describe` | request (we call) | ⚠️ not called |
Expand Down Expand Up @@ -231,11 +232,21 @@
| Method | Direction | In bridge? |
|---|---|---|
| `cluster:identity-changed` | request (we call) | ✅ yes |
| `cluster:invite-canceled` | request (we call) | ✅ yes |
| `cluster:invite-declined` | request (we call) | ✅ yes |
| `cluster:invite-expired` | request (we call) | ✅ yes |
| `cluster:invite-failed` | request (we call) | ✅ yes |
| `cluster:invite-received` | request (we call) | ✅ yes |
| `discovery:nodes-changed` | request (we call) | ✅ yes |
| `engine:install-progress` | request (we call) | ✅ yes |
| `engine:models-changed` | request (we call) | ✅ yes |
| `engine:pull-progress` | request (we call) | ✅ yes |
| `engine:remote-progress` | request (we call) | ✅ yes |
| `engine:settings-changed` | request (we call) | ✅ yes |
| `engine:settings-disconnected` | request (we call) | ✅ yes |
| `engine:state-changed` | request (we call) | ✅ yes |
| `error` | request (we call) | ✅ yes |
| `errors:update` | request (we call) | ✅ yes |
| `nodes:changed` | request (we call) | ✅ yes |
| `workloads:remove` | request (we call) | ✅ yes |
| `workloads:upsert` | request (we call) | ✅ yes |
Expand Down
9 changes: 6 additions & 3 deletions desktop/docs/services-backend.md
Original file line number Diff line number Diff line change
Expand Up @@ -253,9 +253,12 @@ result through the discovery snapshot and must not add a second, shorter
reachability verdict of its own — a failed `/v1/node-info` poll keeps the last
good metrics and never marks a node offline.

The renderer model hub is not a backend search service. Electron main obtains
curated Ollama and LM Studio catalogs, then sends pull-ready model IDs through
the engine manager.
The model catalogue is backend-owned. `nvpair-engine-manager` serves the curated
Ollama and LM Studio lists over `engine:catalog`, filtered for the platform a
model will install on; Electron relays the call and maps rows for the renderer,
which then sends pull-ready model IDs back through the engine manager. The
Ollama reply is a single multi-megabyte frame, so every hop on its path shares
`jsonrpc.WorkerFrameBytes`.

## Pairing and security

Expand Down
Loading
Loading