Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
12 changes: 12 additions & 0 deletions desktop/docs/services-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,11 @@
- ⚠️ nvpair-node-settings → settings/get-force-ports
- ⚠️ nvpair-node-settings → settings/set-cluster-auto-sync
- ⚠️ nvpair-node-settings → settings/set-force-ports
- ⚠️ nvpair-tui → pair:invite
- ⚠️ nvpair-tui → pair:invite-status
- ⚠️ nvpair-tui → pair:members
- ⚠️ nvpair-tui → pair:pending
- ⚠️ nvpair-tui → pair:respond
- ⚠️ nvpair-ui-broker → discovery:unsubscribe
- ⚠️ nvpair-ui-broker → engine:set-reserved-port
- ⚠️ nvpair-ui-broker → engine:unsubscribe
Expand Down Expand Up @@ -208,12 +213,19 @@
| Method | Direction | In bridge? |
|---|---|---|
| `cluster:identity-changed` | request (we call) | ✅ yes |
| `cluster:invite-canceled` | request (we call) | ✅ yes |
| `cluster:invite-expired` | request (we call) | ✅ yes |
| `cluster:invite-received` | request (we call) | ✅ yes |
| `engine:install-progress` | request (we call) | ✅ yes |
| `engine:pull-progress` | request (we call) | ✅ yes |
| `engine:state-changed` | request (we call) | ✅ yes |
| `error` | request (we call) | ✅ yes |
| `nodes:changed` | request (we call) | ✅ yes |
| `pair:invite` | request (we call) | ⚠️ not called |
| `pair:invite-status` | request (we call) | ⚠️ not called |
| `pair:members` | request (we call) | ⚠️ not called |
| `pair:pending` | request (we call) | ⚠️ not called |
| `pair:respond` | request (we call) | ⚠️ not called |
| `workloads:remove` | request (we call) | ✅ yes |
| `workloads:upsert` | request (we call) | ✅ yes |

Expand Down
122 changes: 122 additions & 0 deletions docs/terminal-interface.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,8 @@ cd <directory containing nvpair-tui>
| --- | --- |
| `--broker-path <path>` | Use a service binary that is not beside `nvpair-tui` |
| `--log-level <level>` | Verbosity of the terminal interface's own logging: `debug`, `info`, `warn`, or `error`. PAIR also reads this from `NVPAIR_LOG_LEVEL` |
| `--control-socket <path>` | Serve the pairing control socket somewhere other than its default per-user path |
| `--no-control-socket` | Serve no control socket, which disables [pairing from a script](#pair-from-a-script-or-over-ssh) |
| `--version` | Print the version and exit |

The interface's own log output goes to stderr, so it never corrupts the display.
Expand Down Expand Up @@ -185,6 +187,126 @@ Only pair when you trust both machines and the network. The PIN is a short-lived
bootstrap code, not a durable credential. Refer to the
[security policy](../SECURITY.md).

## Pair from a Script or Over SSH

Every pairing step above is also a command. While the terminal interface is
running, the same `nvpair-tui` binary invoked with a command talks to it, so you
can pair a machine from a script, or in one `ssh` line, without opening the
interface and pressing keys.

**The interface has to be running.** These commands drive it; they do not start
anything. Leave it running in tmux, as in
[Keep It Running After You Disconnect](#keep-it-running-after-you-disconnect).
If it is not running, every command exits `2` and says so. They also do not
reach the desktop application — that is a different program with its own
services, and the two must never run on one machine anyway.

```bash
nvpair-tui invite <address> [--port N] [--wait]
nvpair-tui pending
nvpair-tui accept --pin 123456 [--invite <id>] [--wait 2m]
nvpair-tui decline [--invite <id>]
nvpair-tui members
```

Add `--json` to any of them to get the raw result instead of a human line, for a
script to parse.

**Put the PIN in `NVPAIR_PIN`, not in `--pin`, in a script.** `--pin 402199` is
visible to every user on the machine for as long as the command runs, because
process arguments are public in `ps`, and it is written to your shell history.
An environment variable is neither.

```bash
NVPAIR_PIN=402199 nvpair-tui accept
```

`--pin` is for typing at an interactive prompt, where you have just read the
digits off another machine's screen and the pairing is over in seconds.

### The Desktop Invites, the Headless Box Accepts

This is the common case: someone at the desktop application starts the pairing,
and the machine that has to answer has no screen.

1. On the desktop machine, invite the box by address and note the six digits it
shows.
2. On the box, over SSH:

```bash
ssh gpu-box 'NVPAIR_PIN=402199 nvpair-tui accept'
```

It exits `0` once the machines are paired. Typing the digits like this is
fine for a one-off; see [Two Headless Boxes](#two-headless-boxes) for the
form to use in a script.

If you want to arm the accept before the desktop user has sent anything, give it
a window to wait in:

```bash
NVPAIR_PIN=402199 nvpair-tui accept --wait 2m
```

It waits up to two minutes for an invitation to arrive, then answers it.

### The Headless Box Invites, the Desktop Accepts

Run the invitation on the box and read out the PIN it prints:

```bash
$ nvpair-tui invite 192.168.1.40
PIN 402199 invite inv-9f3a1c to 192.168.1.40
Read the PIN to whoever is at that machine; they run: nvpair-tui accept --pin 402199
```

The desktop user types those six digits into the prompt the desktop application
shows. Add `--wait` and the command blocks until the pairing finishes, exiting
`0` when it succeeds.

### Two Headless Boxes

Neither machine has a screen, so the PIN goes from one command's output straight
into the other's environment, over the SSH connection's standard input rather
than through either machine's command line:

```bash
PIN=$(ssh box-a 'nvpair-tui invite box-b.example.net --json' | jq -r .pin)
echo "$PIN" | ssh box-b 'read -r NVPAIR_PIN; export NVPAIR_PIN; nvpair-tui accept --wait 2m'
ssh box-a 'nvpair-tui members'
```

The last line lists the cluster and both machines, which is the confirmation
that it worked.

The `read` is what keeps the PIN out of `ps` on both machines. Interpolating it
into the remote command — `ssh box-b "NVPAIR_PIN=$PIN ..."` — is easier to read
but puts the digits in the argument list of the local `ssh` and of the remote
shell, where anyone with an account on either machine can see them for as long
as the pairing takes.

### Exit Codes

| Code | Meaning |
| --- | --- |
| `0` | The pairing reached the state you asked for |
| `1` | The other machine refused: a wrong PIN, or a declined invitation |
| `2` | Anything else: no terminal interface running, a bad argument, an unreachable machine, an expired invitation |

### What You Can and Cannot Do This Way

`pending` lists only invitations that arrived while this terminal interface has
been running. If you restart it while an invitation is in flight, `pending`
comes back empty even though the other machine still thinks one is open. Send a
new invitation.

`--invite <id>` is only needed when more than one invitation is waiting at once.
With exactly one, the commands find it; with none or several, they say so and
name them.

Pairing is all these commands do. Everything else — engines, models, proxies,
settings — is still the interface's tabs.

## Prepare an Engine and a Model

From the **Engines** tab (6), select an engine with `j` / `k`, then:
Expand Down
2 changes: 1 addition & 1 deletion services/bom.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ As of the mDNS dedup, `grandcat/zeroconf`, `miekg/dns`, and `golang.org/x/net` a

| Library | Version | Used By | License | License URL |
|---------|---------|---------|---------|-------------|
| `github.com/Microsoft/go-winio` | v0.6.2 | ollama-proxy, lmstudio-proxy, nvpair-node-scanner, nvpair-manual-nodes, nvpair-node-settings, nvpair-cluster-manager, nvpair-ui-broker, nvpair-workload-manager, nvpair-errors, nvpair-engine-manager, nvpair-job-scheduler | MIT | [LICENSE](https://github.com/microsoft/go-winio/blob/main/LICENSE) |
| `github.com/Microsoft/go-winio` | v0.6.2 | ollama-proxy, lmstudio-proxy, nvpair-node-scanner, nvpair-manual-nodes, nvpair-node-settings, nvpair-cluster-manager, nvpair-ui-broker, nvpair-workload-manager, nvpair-errors, nvpair-engine-manager, nvpair-job-scheduler, nvpair-tui | MIT | [LICENSE](https://github.com/microsoft/go-winio/blob/main/LICENSE) |
| `github.com/charmbracelet/bubbles` | v1.0.0 | nvpair-tui | MIT | [LICENSE](https://github.com/charmbracelet/bubbles/blob/master/LICENSE) |
| `github.com/charmbracelet/bubbletea` | v1.3.10 | nvpair-tui | MIT | [LICENSE](https://github.com/charmbracelet/bubbletea/blob/master/LICENSE) |
| `github.com/charmbracelet/lipgloss` | v1.1.0 | nvpair-tui | MIT | [LICENSE](https://github.com/charmbracelet/lipgloss/blob/master/LICENSE) |
Expand Down
123 changes: 123 additions & 0 deletions services/nvpair-tui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ for the graphical UI, and it does not cover every operation the desktop does.
It spawns and owns its own `nvpair-ui-broker` child over stdio; the broker in turn
supervises the worker subprocesses, so `nvpair-tui` drives one host on its own.

While it runs it also serves a **local control socket**, so pairing can be
driven from a script instead of a keyboard — see
[Pairing without a keyboard](#pairing-without-a-keyboard).

This file is the component reference. For task-oriented usage instructions, see
[Using the PAIR terminal interface](../../docs/terminal-interface.mdx).

Expand Down Expand Up @@ -56,18 +60,129 @@ installed `bin/` layout). Override with `--broker-path`:
nvpair-tui # broker is a sibling binary
nvpair-tui --broker-path /opt/nvpair/bin/nvpair-ui-broker
nvpair-tui --log-level debug # own logging (to stderr)
nvpair-tui --control-socket /run/pair.sock # serve the endpoint somewhere else
nvpair-tui --no-control-socket # serve no endpoint at all
nvpair-tui --version
```

Logging goes to stderr (the broker's logs are shown inside the **Logs**
tab, not on the terminal), so it never corrupts the full-screen UI.

## Pairing without a keyboard

A running `nvpair-tui` listens on a per-user JSON-RPC control socket, and the
same binary invoked with a subcommand connects to it. That is how an operator
pairs a headless box over SSH, or from a script, without pressing keys in the
Cluster tab.

The subcommands reach a running **`nvpair-tui`**, not the desktop application's
broker. On a machine where only the desktop application is running, they report
that nothing is listening — which is correct: the two must never run at once.

### Subcommands

```sh
nvpair-tui invite <address> [--port N] [--wait] # prints the PIN on stdout
nvpair-tui pending # inbound invitations
nvpair-tui accept --pin 123456 [--invite <id>] # answer one
nvpair-tui accept --pin 123456 --wait 2m # wait for one, then answer it
nvpair-tui decline [--invite <id>]
nvpair-tui members # cluster id and members
```

Every subcommand takes `--json`, which prints the endpoint's raw result instead
of a human line, and `--control-socket <path>` to reach an instance started
with a non-default endpoint. `invite --wait --json` prints two JSON objects,
one per line: the invite as created, which carries the PIN, and then its final
state (an invite in a terminal state carries no PIN, so one document cannot
serve both).

`--invite` is only needed when more than one invitation is waiting; with
exactly one, the endpoint resolves it, and with none or several it says so.

**Pass the PIN in `NVPAIR_PIN`, not `--pin`, in a script.** An argument is
visible to every process on the machine in `ps` and is written to shell
history; the environment form is neither. `--pin` exists for typing at an
interactive prompt.

### Exit codes

| Code | Meaning |
| --- | --- |
| `0` | The pairing reached the state asked for: `paired` for `accept` and `invite --wait`, `declined` for `decline`. |
| `1` | The other side refused: an incorrect PIN, or a declined invitation. |
| `2` | Anything else: no running `nvpair-tui`, a bad argument, an unreachable peer, an expired or rejected invite, a transport failure. |

### The control socket

| | |
| --- | --- |
| **Path** | `$XDG_RUNTIME_DIR/nvpair/tui.sock` when the OS provides a runtime directory; otherwise `run/tui.sock` under the shared per-user data directory (`nvpair-shared/appdir`). On Windows, the named pipe `\\.\pipe\nvpair-tui-<sid>`. |
| **Protocol** | Newline-delimited JSON-RPC 2.0, the same as every other PAIR surface. Request/response only; the endpoint pushes nothing. |
| **Access** | The directory is `0700` and the socket `0600`, so the file permissions are the check. On Windows the pipe's default DACL grants the creating user. Anything that can open it can pair this machine, which is the authority the operator at the TUI already has. |
| **Clients** | Any number, served concurrently, each issuing any number of sequential requests. |
| **Overrides** | `--control-socket <path>`, or `--no-control-socket` to serve none. |

A socket file left behind by an instance that did not exit cleanly is removed
and reclaimed, but only after confirming nothing answers on it — a live socket
means another `nvpair-tui` is running, and this one refuses to steal it. If the
endpoint cannot be opened at all, `nvpair-tui` logs a warning and runs the
interactive UI anyway: the UI is its primary job.

On Unix the path has to fit the platform's `sun_path` limit (104 bytes on
macOS). Any path that would not fit — the default or one given with
`--control-socket` — is reported with that limit named, rather than being
silently truncated by the kernel into a socket nobody will dial.

### Methods

Each is relayed through the TUI's one broker connection, so the broker keeps a
single client. Where the answer is a cluster-manager `Invite`, the manager's own
JSON is relayed untouched.

| Method | Params | Result |
| --- | --- | --- |
| `ping` | — | `{version, brokerReady}` — `brokerReady` goes back to `false` if the broker dies, so a script is not sent on to an invite that can only time out |
| `pair:invite` | `{address, port?, nodeId?}` | the `Invite`, including its `pin` |
| `pair:invite-status` | `{inviteId}` | the current `Invite` |
| `pair:pending` | — | `{invites: [...]}` — inbound invitations not yet answered, each with the `pin` member removed and a `receivedAt` (this node's clock, epoch ms) added so a caller can age it |
| `pair:respond` | `{inviteId?, accept, pin?}` | the resulting `Invite` |
| `pair:members` | — | `{clusterId, clusterFriendlyName, nodeId, nodeUuid, name, members: [ClusterNode]}` |

`Invite` and `ClusterNode` are `nvpair-cluster-manager`'s own shapes; see its
[README](../nvpair-cluster-manager/README.md).

Errors relay the cluster manager's code and message verbatim, so `-32001`
(unknown invite) and `-32002` (invalid invite state) mean there what they mean
in the manager. Two codes originate here:

| Code | Meaning |
| --- | --- |
| `-32010` | `pair:respond` named no invite and none is waiting |
| `-32011` | `pair:respond` named no invite and several are waiting; `data.invites` lists them |

### What it does not do

**The pending set is session state.** `nvpair-cluster-manager` has no "list the
invitations you are holding" call, and `nodes:get-initial` reports a
`pending-inbound` peer without the invite id needed to answer it. So an
`nvpair-tui` restarted while an invitation was in flight reports nothing
pending even though the manager still holds a live invite; the inviting machine
has to send a new one. Nothing here reconstructs it.

**The PIN never reaches a log.** It goes to the terminal the operator asked for
and into the `pair:invite` result, and nowhere else: `pair:pending` strips it,
and no error message repeats one back.

## Architecture

```
nvpair-tui (this process)
├── supervisor.go spawn/own nvpair-ui-broker over stdio, graceful teardown
├── cli.go the subcommands: connect to a running instance's socket
├── rpc/ JSON-RPC 2.0 codec + id-matching client
├── pairing/ the one pairing implementation both drivers call
├── control/ the local control socket: path, listener, server, client
└── ui/ Bubble Tea root model + one file per tab
│ stdio (newline-delimited JSON-RPC 2.0)
Expand All @@ -77,6 +192,14 @@ nvpair-tui (this process)
The supervisor sends `shutdown` and closes the broker's stdin on exit; the
broker tears its own workers down, so quitting leaves no orphans.

`pairing.Service` is deliberately the only place pairing happens. The Cluster
tab and the control socket both call it, so they cannot disagree about which
invitation is waiting: an invite created from a script shows its PIN on the
tab's status line exactly as one created with `i`, and an accept made from a
script clears a PIN prompt the tab left open. Broker notifications are fanned
out in `main.go` — to the service first, then the UI — so the service sees an
invitation arrive whether or not the UI is keeping up.

## Build & test

Built by the repo's top-level `build.bat` / `build.sh` (stamped via
Expand Down
Loading