Manual

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

Install 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.

Tested 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, 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):
    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:
    $ 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 (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):

    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:

    $ 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.

    $ 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.

    <OCMON> -json -log <ROUTER_LOG>
    

    With no router on port 8080 (re-run 2026-11-05 on the 780M box):

    $ 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).

    classWhenline printed
    downGET /models failsrouter down
    errorlog unreadable, or the log shows a load error and no model loaded for the current instancerouter up, log unreadable / <model> FAILED to load (see router.log)
    idleevery model is unloadedidle, no model loaded
    loading/models says loadingornith-35b loading…
    sleeping/models says sleepingornith-35b sleeping (memory freed)
    loadedloaded, but no -log, or the newest instance in the log is another modelornith-35b loaded
    promptrequest running, prompt not finishedornith-35b reading prompt ██░░░░░░░░░░░░░░░░░░ 11% 2048/18618 tok 145 t/s · ~114 s left
    genrequest running, prompt done<model> generating <n> tok <rate> t/s (… instead of the rate on the first sample)
    readyloaded, no request runningornith-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.

    <OCMON> -log <ROUTER_LOG>
    

    One frame with no router on port 8080 (re-run 2026-11-05 on the 780M box):

    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> …).

    GotchaCtrl-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:

    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:

    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.]

    {
      "$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:

    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:

    "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):

    $ 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".

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

[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)CauseFix
"line":"router down", detail dial tcp 127.0.0.1:8080: connect: connection refused; the opencode panel and footer are hiddenRouter not running, or listening elsewhereStart 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 existFix the path, also in the plugin's args
ornith-35b loaded while a prompt is running, no progressNo -log given, so only /models is readPass -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 viewCtrl-C kills ocmon before it restores the cursortput 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.

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.

Filed under: local-llm, opencode, llama.cpp, go, waybar, amd

Sources#

  • 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; the repository's main is a later version.
  • llama.cpp server README, "Sleeping on Idle": the endpoints that do not reset the idle timer.
  • opencode config docs: TUI settings live in tui.json; "Use OPENCODE_TUI_CONFIG to point to a custom TUI config file."
  • opencode TUI docs: tui.json options and the same environment variable.
  • opencode tui.json schema: plugin is a list of strings or [string, options] pairs.
  • 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): return-type: json, interval, hide-empty-text, class-based styling. Claims here are from the 0.15.0 man page installed on Fedora 44.