# Show the local llama.cpp router's state in opencode with ocmon

> Install ocmon, a small Go tool that reads the llama.cpp router's log, and show model state, prompt progress with ETA and iGPU load in opencode's sidebar.

Published 2026-11-11. HTML version: https://guides.tsvinc.dev/ocmon-local-llm-status-for-opencode/


Install [`ocmon`](https://gitlab.com/myrants-oss/ocmon), run it in a terminal, and wire it into opencode as a sidebar panel and home-screen footer line that show what the local llama.cpp router is doing: loading, ready, sleeping or failed, prompt progress with an ETA, generation speed, and the iGPU's load, memory, power and temperatures.

> **Scope** — Verified on: Ryzen 7 PRO 6850U laptop (Radeon 680M), Fedora 44, Go 1.26.9, opencode 1.19.3, llama.cpp b11292 Vulkan build, ocmon commit f067f45 (2026-10-21). The logs of that evening were not kept: on 2026-11-05 every flag, default and output format below was checked against the source at f067f45, and the router-down outputs in steps 3, 4 and 9 were re-run on another AMD box (Ryzen 7 8845HS, Radeon 780M).
> **Version:** steps 1 and 5 pin f067f45. The repository's `main` moved on the next day (new `-list`, `-load`, `-unload`, `-stop` flags; an unreadable log no longer gives the `error` state; different load-failure text), so `@latest` installs a version this manual does not describe.
> **Not verified:** the public plugin with its `tui.json` options (steps 6–8) was not run in opencode; what ran on this box was an earlier copy of the same plugin with the binary path written into the file. No request ran against the public build: the router was down, so its busy-state output comes from replaying my router log against a stub `GET /models` (Field notes). The waybar module (step 9) was not run. Toasts were not captured.
> **Needs:** the router and the `oc` wrapper from [Serve local models to opencode with the llama.cpp router](/serve-local-models-to-opencode-llama-router/), with the router's output going to a log file; Go 1.24 or newer; an AMD GPU on the `amdgpu` driver for the hardware line (everything else works without it).
> **Time:** 20 min. **Risk:** low. ocmon only reads the log, sysfs and the router's `GET /models`; it calls `/slots` only while a request is already running, with `autoload=false`. It writes one file, `~/.cache/ocmon/gen.json`. The opencode change is one environment variable in your wrapper.

## Before you start

- The router writes llama.cpp's slot timing lines to its log. ocmon reads them for prompt progress and speeds; check the log has them (after at least one prompt has run):
  ```bash
  grep -m1 'prompt processing, n_tokens' <ROUTER_LOG>
  ```
  Expected: one `slot print_timing` line with `prompt processing, n_tokens = …, progress = …`; the Verify section shows one from my log. The `[<port>]` prefix on it is the port of the model instance that wrote the line. Lines without it come from the router itself. Without a log, ocmon shows model states only.
- Check that the `amdgpu` device exposes the files ocmon reads:
  ```console
  $ grep -l amdgpu /sys/class/drm/card*/device/uevent
  /sys/class/drm/card1/device/uevent
  $ ls /sys/class/drm/card1/device/gpu_busy_percent /sys/class/drm/card1/device/mem_info_gtt_used /sys/class/drm/card1/device/mem_info_vram_used
  /sys/class/drm/card1/device/gpu_busy_percent
  /sys/class/drm/card1/device/mem_info_gtt_used
  /sys/class/drm/card1/device/mem_info_vram_used
  ```
  ocmon picks the first `/sys/class/drm/card*/device` that has `gpu_busy_percent`; `-dev` overrides it. Power and temperatures come from the hwmon sensors named `amdgpu` and `k10temp`, found by name.
- Check Go (the module's `go.mod` says `go 1.24`): `go version` must print `go1.24` or newer.
- Placeholders used below:
  - `<ROOT>`: the prerequisite manual's working directory (its `oc` wrapper is `<ROOT>/serve/oc`).
  - `<ROUTER_LOG>`: absolute path of the router's log file (in the prerequisite manual, `oc` appends the router's stdout and stderr to it).
  - `<OCMON>`: absolute path of the installed binary, by default `~/go/bin/ocmon`, written out in full: the plugin starts it with Node's `execFile`, which does not expand `~`.
  - `<SRC>`: absolute path of a directory you create for the plugin file (step 5).
  - `<TUI_JSON>`: the `tui.json` used only by the wrapper. The prerequisite manual's `oc` already points `OPENCODE_TUI_CONFIG` at `$ROOT/serve/opencode-tui/tui.json`; use that path, or change the line in step 7.
- Backups: copy your `oc` wrapper before step 7 (`cp <ROOT>/serve/oc <ROOT>/serve/oc.bak`, where `<ROOT>` is the prerequisite manual's working directory). Nothing else is changed. Your global `~/.config/opencode/tui.json` is not touched.

## Steps

1. **Install ocmon at the tested version.** The source is public at [gitlab.com/myrants-oss/ocmon](https://gitlab.com/myrants-oss/ocmon) (MIT), and `go install` fetches it from the Go module proxy, so there is nothing to clone. It is one `main.go` with no dependencies outside the standard library. The version string pins commit f067f45 (Scope, **Version**):
   ```bash
   go install gitlab.com/myrants-oss/ocmon@v0.0.0-20261021182238-f067f458af51
   ```
   Without `GOBIN` the binary goes to `~/go/bin`. On 2026-10-21 `@latest` still resolved to this version; I set `GOBIN` to keep it out of my `PATH` for the test:
   ```console
   $ GOBIN="$PWD/bin" go install gitlab.com/myrants-oss/ocmon@latest
   $ go version -m bin/ocmon | head -3
   bin/ocmon: go1.26.9-X:nodwarf5
   	path	gitlab.com/myrants-oss/ocmon
   	mod	gitlab.com/myrants-oss/ocmon	v0.0.0-20261021182238-f067f458af51	h1:ruhYlOpc4JPqKYVhtGPKsxWarAhRMrV0c4OZqgbGh0A=
   ```
   Expected: `go install` prints nothing, or one `go: downloading …` line on the first fetch, and `go version -m` names the module with the `v0.0.0-20261021182238-f067f458af51` version.

2. **Check the flags.**
   ```console
   $ bin/ocmon -h
   Usage of bin/ocmon:
     -dev string
       	amdgpu sysfs device dir (env OCMON_DEV; default: first card with gpu_busy_percent) (default "/sys/class/drm/card1/device")
     -i duration
       	refresh interval (default 1s)
     -json
       	print one JSON object (class, line, side, detail) and exit
     -log string
       	router log file (env OCMON_LOG); without it only model states are shown
     -url string
       	router base URL (env OCMON_URL) (default "http://127.0.0.1:8080")
     -waybar
       	print one JSON line for a waybar custom module and exit
   ```
   Flags win over the `OCMON_*` variables. The `README.md` in the module directory (step 5) has the full table; the README on the repository page describes the later version. What each part reads:
   - `-url`: `GET <url>/models` on every refresh, and `GET <url>/slots?model=<name>&autoload=false` only while the log shows a running request (for the live generated-token count).
   - `-log`: the last 1 MiB of the log on every refresh.
   - `-dev`: `gpu_busy_percent`, `mem_info_gtt_used`, `mem_info_vram_used`. A missing file prints `?`. The busy figure is one reading per refresh, not an average.
   - `-waybar` prints `{"class","text","tooltip"}` with an empty `text` when the router is down; `-json` prints `{"class","line","side","detail"}`, where `side` holds the short lines for opencode's sidebar.

   Expected: the six flags above, with the `env` names in the help text.

3. **Run it once in JSON mode.**
   ```bash
   <OCMON> -json -log <ROUTER_LOG>
   ```
   With no router on port 8080 (re-run 2026-11-05 on the 780M box):
   ```console
   $ bin/ocmon -json
   {"class":"down","detail":["GPU 27%  GTT 1772 MiB  VRAM 475 MiB  40.1 W  CPU 75°C  GPU 56°C","router: Get \"http://127.0.0.1:8080/models\": dial tcp 127.0.0.1:8080: connect: connection refused"],"line":"router down","side":null}
   ```
   Expected: one JSON line whose `class` is one of the states below. `error` with `router up, log unreadable` means the `-log` path is wrong (Failure modes, row 2).

   | `class` | When | `line` printed |
   |---|---|---|
   | `down` | `GET /models` fails | `router down` |
   | `error` | log unreadable, or the log shows a load error and no `model loaded` for the current instance | `router up, log unreadable` / `<model> FAILED to load (see router.log)` |
   | `idle` | every model is `unloaded` | `idle, no model loaded` |
   | `loading` | `/models` says `loading` | `ornith-35b loading…` |
   | `sleeping` | `/models` says `sleeping` | `ornith-35b sleeping (memory freed)` |
   | `loaded` | loaded, but no `-log`, or the newest instance in the log is another model | `ornith-35b loaded` |
   | `prompt` | request running, prompt not finished | `ornith-35b reading prompt ██░░░░░░░░░░░░░░░░░░  11%  2048/18618 tok  145 t/s · ~114 s left` |
   | `gen` | request running, prompt done | `<model> generating  <n> tok  <rate> t/s` (`…` instead of the rate on the first sample) |
   | `ready` | loaded, no request running | `ornith-35b ready  last: prompt 21 tok @ 11 t/s, gen 263 tok @ 17.7 t/s, ctx 18353` |

   The `line` values with a model name come from a replay of my router log through this binary (Field notes, 21:24); that output was not kept, and each value matches the format string in the source. `idle`, `gen` and the load-failure text are from the source and were not observed. On a load failure the sidebar's second line is the fixed text `see serve/router.log`, whatever your log path is.

   The `prompt` total is `n_tokens / progress` from the newest progress line, and the ETA is the remaining tokens divided by the running rate. llama.cpp prints `progress` with two decimals, so both are estimates (Field notes: 114 s shown, about 139 s real).

4. **Run the full-screen view.**
   ```bash
   <OCMON> -log <ROUTER_LOG>
   ```
   One frame with no router on port 8080 (re-run 2026-11-05 on the 780M box):
   ```console
   ocmon  11:05:12   (ctrl-c to quit)

     router down

     GPU 27%  GTT 1772 MiB  VRAM 475 MiB  46.9 W  CPU 75°C  GPU 56°C
     router: Get "http://127.0.0.1:8080/models": dial tcp 127.0.0.1:8080: connect: connection refused
   ```
   Expected: the screen redraws every `-i` (1 s). The line under the clock is the same `line` as in JSON mode; below it are the hardware line and either the router error or the model list (`models: <id>:<status> …`).

   > [!GOTCHA] Ctrl-C leaves the cursor hidden
   > ocmon hides the cursor and restores it in a deferred call, which does not run when Ctrl-C or SIGTERM kills the process. Run `tput cnorm` afterwards to get the cursor back.

5. **Get the plugin file.** The opencode plugin is `opencode-plugin/llm-status.tsx` in the module. Commit f067f45 is no longer on the repository's branch, so take it from the same module version as the binary:
   ```bash
   go mod download -json gitlab.com/myrants-oss/ocmon@v0.0.0-20261021182238-f067f458af51
   ```
   It prints a JSON object whose `"Dir"` is the module's directory in Go's module cache (read-only). Copy the plugin out of it, with `<DIR>` = that `Dir` value:
   ```bash
   mkdir -p <SRC>/opencode-plugin
   cp <DIR>/opencode-plugin/llm-status.tsx <SRC>/opencode-plugin/
   ```
   Expected: `<SRC>/opencode-plugin/llm-status.tsx` exists, from the same version as the binary.

   What it does: runs `<bin> -json <args…>` every `intervalMs` (skipping a tick while the previous call still runs, 3 s timeout), fills two opencode slots, `sidebar_content` (session view) and `home_footer` (home screen), and hides both when ocmon fails or reports `down`. It raises a toast on three transitions: loading to ready, any state to sleeping, any state to error. opencode loads the `.tsx` file as it is; there is no build step.

6. **Write `<TUI_JSON>`** with the plugin's absolute path and its options. [UNVERIFIED: this options form was not run in opencode on this box.]
   ```json
   {
     "$schema": "https://opencode.ai/tui.json",
     "plugin": [
       ["<SRC>/opencode-plugin/llm-status.tsx",
        { "bin": "<OCMON>", "args": ["-log", "<ROUTER_LOG>"], "intervalMs": 1000 }]
     ]
   }
   ```
   `bin` defaults to `ocmon` from opencode's `PATH`; an absolute path avoids depending on it. `args` go after `-json`; add `"-url", "…"` if the router is not on `127.0.0.1:8080`. `intervalMs` below 250 is ignored (1000 is used).

7. **Pass that file to opencode from your wrapper only.** The `opencode` call in `oc` must carry `OPENCODE_TUI_CONFIG=<TUI_JSON>`. The prerequisite manual's `oc` already has the line; if your wrapper does not, add it. The end of my `oc`:
   ```bash
   OPENCODE_DISABLE_CLAUDE_CODE=$claude_off \
   OPENCODE_TUI_CONFIG="$ROOT/serve/opencode-tui/tui.json" \
   OPENCODE_CONFIG="$ROOT/serve/opencode-local.json" opencode "$@"
   ```
   opencode's docs say `OPENCODE_TUI_CONFIG` points to a custom TUI config file. [UNVERIFIED: that opencode 1.19.3 merges it with the global `tui.json`, so global TUI settings still apply, was not checked against its source.] A plain `opencode` (cloud sessions) does not get the variable and shows no panel.

8. **Start `oc` and send a prompt.** [UNVERIFIED: with the public plugin and options form; see Scope.] Expected: the home screen shows `local LLM: <line>` at the bottom, and the session sidebar shows a `Local LLM` section (see Verify).

9. **Optional: add a waybar module.** [UNVERIFIED: not run on this box; there is no ocmon module in my waybar config.] Built from the waybar 0.15.0 `waybar-custom(5)` man page. Add `"custom/ocmon"` to one of the `modules-*` lists and define it:
   ```json
   "custom/ocmon": {
     "exec": "<OCMON> -waybar -log <ROUTER_LOG>",
     "return-type": "json",
     "interval": 1,
     "hide-empty-text": true
   }
   ```
   `class` becomes a CSS class, so `#custom-ocmon.prompt`, `#custom-ocmon.error` and the other class names from step 3 can be styled in `style.css`. `-waybar` output with no router on port 8080 (re-run 2026-11-05 on the 780M box):
   ```console
   $ bin/ocmon -waybar
   {"class":"down","text":"","tooltip":"GPU 27%  GTT 1772 MiB  VRAM 475 MiB  46.4 W  CPU 75°C  GPU 56°C\nrouter: Get \"http://127.0.0.1:8080/models\": dial tcp 127.0.0.1:8080: connect: connection refused"}
   ```

## Verify

While the model reads a prompt, the sidebar shows the section with progress and ETA. This capture is from my earlier copy of the plugin; the public build prints the same text for the same log line (Field notes, 21:24):

![opencode sidebar section titled Local LLM: the headline "ornith-35b · reading prompt" in the warning colour, a progress bar at 11 %, "2048/18618 tok", "145 t/s · ~114 s left", "GPU 2% · 22.5 W · 69/60 °C" and "mem GTT 13.0G · VRAM 3.7G".](images/sidebar-reading-prompt.png)

The numbers come from this router log line, the first progress line of an 18.8k-token prompt:

```text
[33621] 10.04.886.745 I slot print_timing: id  0 | task 716 | prompt processing, n_tokens =   2048, progress = 0.11, t =  14.12 s / 145.07 tokens per second
```

2048 / 0.11 = 18618 tokens total, and (18618 − 2048) / 145.07 = 114 s left, as shown.

## Undo / rollback

1. Restore the wrapper: `cp <ROOT>/serve/oc.bak <ROOT>/serve/oc` (or delete the `OPENCODE_TUI_CONFIG=…` line). opencode stops loading the plugin on its next start.
2. Delete `<TUI_JSON>`, `<SRC>` and `<OCMON>`.
3. Delete `~/.cache/ocmon/` (`$XDG_CACHE_HOME/ocmon/` if you set that variable). It holds `gen.json`, the generation-rate sample kept between one-shot runs. `go install` and `go mod download` also left the module source in Go's module cache (`go clean -modcache` clears all of it, for every module).
4. If you added the waybar module, remove `"custom/ocmon"` from the config and reload waybar.

[UNVERIFIED: rollback not exercised; per the source, `gen.json` and its `.tmp` are the only files ocmon writes.]

## Failure modes

| Symptom (exact message) | Cause | Fix |
|---|---|---|
| `"line":"router down"`, detail `dial tcp 127.0.0.1:8080: connect: connection refused`; the opencode panel and footer are hidden | Router not running, or listening elsewhere | Start it with `oc`, or pass `-url` |
| `"class":"error"`, `"line":"router up, log unreadable"`, detail `open <path>: no such file or directory` | `-log` (or `OCMON_LOG`) names a file that does not exist | Fix the path, also in the plugin's `args` |
| `ornith-35b loaded` while a prompt is running, no progress | No `-log` given, so only `/models` is read | Pass `-log <ROUTER_LOG>` |
| `GPU ?%  GTT ? MiB  VRAM ? MiB` while watts and temperatures are shown | `-dev` points at a card directory that does not exist (here `card0` on a box with only `card1`) | Drop `-dev`, or pass the right directory |
| Cursor gone after quitting the full-screen view | Ctrl-C kills ocmon before it restores the cursor | `tput cnorm` |

## Field notes

- **2026-10-21 21:23–21:34, Ryzen 7 PRO 6850U laptop (Radeon 680M), Fedora 44, Go 1.26.9, ocmon f067f45:** installed from the public module with `go install`; `go test ./...` in a clone passed. The router was down all evening (another experiment held its unit name and port 8080 refused), so every live output that evening was the `down` state. To check the text of each state I replayed my router log through the installed binary: a small stub answered `GET /models`, and the log was cut at a chosen line. Cut at the first progress line of task 716, it printed `2048/18618 tok  145 t/s · ~114 s left`, the numbers in the sidebar screenshot. Without `-log` the same state read `ornith-35b loaded`; `OCMON_LOG` worked like `-log`; a missing log gave `router up, log unreadable`; `-dev /sys/class/drm/card0/device` gave `?` for busy, GTT and VRAM. 20 `-json` calls took 4.8 ms median (4.0–5.4) with the router down and 10.6 ms (9.5–12.0) in the replay, which read a 106 KB cut of the log; not timed against a running router. Under `timeout -s INT` (Ctrl-C) the full-screen view wrote the hide-cursor sequence once and the show-cursor sequence zero times.
- **2026-10-21, same box, llama.cpp b11292 (the router's `llama-server`, file unchanged since 2026-09-30), opencode 1.19.3, sidebar screenshot (task 716):** the ETA said ~114 s at 11 %. The log's own timer went from 14.12 s at that line to 153.38 s at the last progress line, so about 139 s were left: the prompt was 18,857 tokens, not 18,618, and the rate fell from 145 to 123 t/s as the context grew.
- **2026-10-21, same log:** every prompt longer than 2048 tokens reports progress in steps of 2048 tokens (2048, 4096, 6144, …; the log has six such prompts; the one on lfm2.5-8b starts at 4096), then in smaller steps for the last part (task 716: …16384, 16543, 16793, 18341, 18841, 18853). On the five ornith-35b prompts, at about 145 t/s, that is one update every 14–18 s, so the bar moves in steps.
- **2026-10-21 21:02, same box, opencode 1.19.3:** first run of the plugin in an `oc` session, from my private copy (binary path written into the file, no options). The home screen footer showed `local LLM: ornith-35b ready  last: prompt 11 t/s, gen 17.7 t/s (263 tok), ctx 18353`, an earlier format of the `ready` line; the current one adds the token counts.
- **2026-11-05, Ryzen 7 8845HS box (Radeon 780M), Fedora 44, Go 1.26.0, ocmon f067f45:** the pinned `go install` and `go mod download -json` both worked from the Go module proxy, and `go test ./...` passed on that version. With nothing listening on 127.0.0.1:8080, `-h`, `-json`, `-waybar` and one frame of the full-screen view printed the blocks in steps 2, 3, 4 and 9. Under `timeout -s INT` the full-screen view again wrote the hide-cursor sequence once and the show-cursor sequence zero times. `@latest` now resolves to a later commit of 2026-10-22 (Scope, **Version**).
- **2026-10-21, Ryzen 7 PRO 6850U laptop:** without the panel, opencode showed my message with a `QUEUED` badge and no other progress indicator:

  ![An opencode message line ending with a blue QUEUED badge and no other progress indicator.](images/opencode-queued.png)

## Notes

- Why the panel does not keep the model awake: llama.cpp's server README lists `GET /health`, `/props`, `/models` and `/metrics` as exempt from the idle timer, and `/slots` is called only while a request is running, with `autoload=false`, so it cannot start a model.
- ocmon follows only the most recently spawned model instance in the last 1 MiB of the log. If the model was started before that window, the state reads `loaded` without prompt or generation details until the next spawn.
- ocmon depends on llama.cpp's log wording (`prompt processing, n_tokens = …`, `prompt eval time = …`, `release: … stop processing`). A release that changes those lines makes the progress and `last:` fields disappear without an error.
- `home_footer` is registered by opencode 1.19.3 with `mode:"single_winner"` (seen in the binary on 2026-10-21) [UNVERIFIED: not re-checked against the 1.19.3 source]; another plugin that fills the same slot may take it. Not tested.
- Unverified alternative: listing the plugin in the global `~/.config/opencode/tui.json` loads it in every opencode session; it hides itself while the router is down.

## Sources

- [ocmon](https://gitlab.com/myrants-oss/ocmon): source, README (flags, environment variables, plugin options), MIT licence. This manual follows module version `v0.0.0-20261021182238-f067f458af51` (commit f067f45) from the [Go module proxy](https://proxy.golang.org/gitlab.com/myrants-oss/ocmon/@v/list); the repository's `main` is a later version.
- [llama.cpp server README, "Sleeping on Idle"](https://github.com/ggml-org/llama.cpp/blob/master/tools/server/README.md): the endpoints that do not reset the idle timer.
- [opencode config docs](https://opencode.ai/docs/config/): TUI settings live in `tui.json`; "Use `OPENCODE_TUI_CONFIG` to point to a custom TUI config file."
- [opencode TUI docs](https://opencode.ai/docs/tui/): `tui.json` options and the same environment variable.
- [opencode `tui.json` schema](https://opencode.ai/tui.json): `plugin` is a list of strings or `[string, options]` pairs.
- [udondan/opencode-tui-plugin-workflows](https://github.com/udondan/opencode-tui-plugin-workflows): a small published TUI sidebar plugin; its README notes that TUI plugins go in `tui.json`, not `opencode.json`. I used it as the pattern for the plugin.
- [waybar `waybar-custom(5)`](https://github.com/Alexays/Waybar/blob/master/man/waybar-custom.5.scd): `return-type: json`, `interval`, `hide-empty-text`, class-based styling. Claims here are from the 0.15.0 man page installed on Fedora 44.

