Skip to content

mrshu/muxboard

Repository files navigation

Muxboard: a Stream Deck+ dashboard for cmux AI coding agents

Monitor your cmux AI coding agents (Claude Code, Codex, Pi, OMP) from an Elgato Stream Deck+: which agents need attention show on the keys, and your CodexBar usage limits show on the LCD.

License: MIT Platform: macOS Stream Deck+ Node.js ≥ 20

Muxboard dashboard

Muxboard turns the 8 keys of an Elgato Stream Deck+ into a queue of cmux panes whose coding agents (Claude Code, Codex, Pi, OMP, or any other) have finished, failed, gotten blocked, or are waiting for your input. For cmux to see OMP sessions at all, install its hook bridge once with cmux hooks omp install (cmux ≥ 0.64.17). The LCD touch strip shows CodexBar usage: session and weekly quota with pace, plus spend and tokens per provider.

The newest attention item is key 1 (top-left); the queue fills left-to-right, top-to-bottom:

1 2 3 4      key 1 = newest attention item
5 6 7 8      key 8 = 8th newest

Press a key to bring cmux to the foreground and jump straight to that workspace/surface. Empty slots render muted. When cmux or CodexBar is unreachable, the display degrades gracefully and the rest keeps working:

Offline state

Install

macOS, with Node.js, the Elgato Stream Deck app, and cmux installed:

curl -fsSL https://raw.githubusercontent.com/mrshu/muxboard/main/scripts/setup.sh | bash

It downloads the latest packaged plugin from Releases, installs it and the 8-key + 4-dial profile, and checks that cmux automation mode is enabled (see Requirements). Then open the Stream Deck app and pick the Muxboard profile. To build from source instead, see Quick start.

How it works

Surface Shows Source
8 keys Attention queue: agent glyph, status, repo, age cmux list-notifications --json
LCD strip (4×200×100) One CodexBar provider per segment: session + weekly quota with pace, spend + tokens codexbar serve HTTP
4 dials Scroll, filter, quota view, refresh local state

Required cmux setting. cmux's control socket rejects processes outside a cmux session by default (socketControlMode: cmuxOnly), and the Stream Deck app launches plugins outside any session. Set cmux Settings → Automation → Socket Control Mode → Automation (then fully quit and relaunch cmux) so the plugin is accepted. Without it, the keys stay on the muted "cmux offline" state. See Requirements.

  • Keys are assigned to physical slots newest-first, with the panes that need you pinned ahead of those actively working. Agent, status, and age are fused from several cmux signals (no terminal scraping); see How a pane's state is derived.
  • Tapping a key runs cmux open-notification --id <uuid>, which focuses the workspace + surface and marks the row read (it does not dismiss it).
  • Long-pressing a key (hold ~0.6s) runs cmux dismiss-notification --id <uuid>, removing it from the queue ("seen it, nothing further") without switching to cmux. It fires while you're still holding (you get a ✓); releasing does nothing more. The key clears on the next poll.
  • The LCD shows one segment per CodexBar provider, auto-discovered from CodexBar rather than a hardcoded list. Each segment carries the provider name in CodexBar's brand color, the session and weekly gauges with their reset times, and a footer with today's spend and tokens, so all your providers are visible at once.
  • Each gauge also carries a calm pace marker, comparing how much quota you've used against how far through the window the clock is: a faded same-hue extension toward where you "should" be when you're under the clock (in reserve, banking headroom), or a coral cap past it when you're over (in deficit). By default the row number is percent remaining; rotating dial 3 flips it to the signed pace delta (+12% green = reserve, −8% coral = deficit).

Dials (Stream Deck+)

Dial Rotate Press
1 Scroll the queue (when > 8 items) Jump to newest item
2 Cycle filter: all → claude → codex → omp → pi Reset filter to all
3 Toggle the quota number: remaining% ↔ pace (reserve/deficit) Open CodexBar /usage
4 Rotate the LCD provider window (when > 4 providers) Force refresh both polls

Requirements

  • Node.js ≥ 20 (developed on v26).
  • cmux on your PATH (or set cmuxBin). Verified against cmux 0.64.16. To enable automation: cmux Settings → Automation → Socket Control Mode → Automation (or add "automation": { "socketControlMode": "automation" } to ~/.config/cmux/cmux.json), then fully quit and relaunch cmux. This is required for the keys to work; without it cmux rejects the plugin. Verify with cmux capabilities | grep access_mode (should read "automation").
  • cmux agent hooks, for accurate live working/waiting state. The state and age on each key come from cmux's agent event stream, which cmux emits from agent hooks (injected automatically by cmux's Claude wrapper, for other agents run cmux hooks setup). They are not required to launch, but without them the keys fall back to the notification feed plus process CPU and can read stale (a wrong "waiting" or a frozen age) until the agent session restarts. If a long-running session stops emitting them, restart it (or cmux hooks setup) to resume. Check the feed is live: cmux events --limit 5 should show recent agent.hook.* rows while an agent works. See How a pane's state is derived.
  • CodexBar for the LCD: run codexbar serve --port 17777. Muxboard defaults to 17777 (keeping CodexBar's own default 8080 free). Optional; the keys work without it.
  • Stream Deck+ hardware and the free Elgato Stream Deck desktop app to run the plugin on the device. The app is what launches the plugin process.

You can review the full visuals and verify all transforms without the hardware or the desktop app. See Headless preview below.

Quick start

npm install
npm test          # unit tests over the core transforms
npm run validate  # prints the 8-key layout + LCD summary, asserts acceptance
npm run preview   # renders out/dashboard.png (+ dashboard-offline.png)

Headless preview

npm run preview rasterizes the exact key + LCD SVGs from the test fixtures to out/*.png via @resvg/resvg-js, so you can see precisely what the device will show with no Stream Deck+ and no desktop app.

Run on the device

  1. Enable cmux automation mode (see Requirements) and relaunch cmux.
  2. Install the Elgato Stream Deck desktop app.
  3. Build + link the plugin and start CodexBar:
    npm run dev
  4. Install the device profile (places all 8 keys + 4 dials, no dragging):
    # quit the Stream Deck app first
    npm run install-profile
    # reopen the Stream Deck app, then pick the "Muxboard" profile from the
    # profile dropdown at the top of the window
    Keys read cmux directly; the LCD reads CodexBar.

Why a separate install step? Elgato's profile importer rejects programmatically-built .streamDeckProfile files ("content corrupted") on recent macOS builds, so the plugin can't auto-apply a bundled profile. install-profile sidesteps the importer by writing the profile straight into the app's profile store (ProfilesV3) in its native format, keyed to your connected Stream Deck+. See Architecture.

The cmux notification contract

Muxboard is driven entirely by cmux notifications: agents make a pane "need attention" by emitting one. cmux already does this for built-in agents; for custom agents, emit a notification (e.g. from an agent hook) shaped like the rows returned by cmux list-notifications --json:

{
  "id": "015D0B50-...",           // uuid; used as the focus/open key
  "title": "Claude Code",         // agent → claude | codex | omp | pi | unknown
  "subtitle": "",
  "body": "Claude is waiting for your input",   // status (see mapping)
  "is_read": true,
  "workspace_id": "6ECA42AE-...", // required
  "surface_id": "4F5A8945-...",   // focused on press
  "tab_title": "RCJ Scoreboard",  // shown as the repo/short name
  "created_at": "2026-06-20T11:59:46Z"  // sort key, newest-first
}

Agent is detected from the running process: Muxboard reads cmux's top --processes coding_agents (matched to the workspace by PID), so a codex CLI in a pane named fieldtheory-cli is still identified as codex. If the process can't be resolved, it falls back to matching the title/tab name (claude/codex/omp/pi), then the optional agentAliases override, else unknown.

Status is mapped from body, strongest signal first:

Status Body contains (any) Treatment
failed fail, failed, error, crashed, exception strongest (red border)
blocked permission, approve, blocked, denied, confirm strong (amber)
waiting waiting, awaiting, input, ready for, your turn strong (yellow)
finished done, finished, complete, completed normal (teal)
waiting waiting, input, and anything else (a notification means the pane wants you) strong (yellow)

Notes:

  • Rows missing id or workspace_id are dropped (never fatal).
  • Read notifications stay on the board, demoted. cmux flips is_read when you merely see a notification — not when you resolve it — and muxboard's own open-notification marks it read too, so a read row can still need you. Dropping read rows would hide genuine attention, so instead a read failed/blocked is demoted to waiting: the key stays but loses the urgent badge and the front-pin. Live Needs status re-flags genuine attention. Notifications are collapsed to one per workspace (newest wins), so each repo occupies a single key showing its current state.
  • An explicit "clear notifications" in cmux is honored live: the cmux events stream emits notification.clear_requested (carrying --tab=<workspace>), and any key that fired at or before that clear is dropped immediately, ahead of the next poll — even one that was still unread. A later prompt (a re-ask) survives, since its timestamp is past the clear.
  • To emit one yourself: cmux notify --title "Codex CLI" --body "Task failed: ..." (run inside the target workspace, or pass --workspace).

Orca support

Muxboard also surfaces Orca worktrees alongside cmux panes on the same keys. It polls orca worktree ps --json and derives the key from the worktree's primary agent state, not the worktree status — the latter is just a terminal-liveness flag (PTY alive → active) that does not roll up the agent lifecycle, so a finished or question-blocked agent still leaves the worktree active. By agent state: waiting/blocked (a permission prompt or an AskUserQuestion) shows as a needs-input key; working as a working key that sinks to the end; done as finished (or failed when interrupted), but only while the worktree is unread so an already-seen result doesn't linger. Each key carries a small badge — the Orca mark or a cmux monogram — so you can tell the two apart.

Orca is auto-detected: the poller starts only when an Orca runtime is reachable (orca status), so cmux-only users see no change. Set enableOrca: true|false in the plugin's global settings to force it on or off, and orcaBin/orcaPollMs to tune the binary path and cadence.

Pressing an Orca key brings Orca forward and jumps to the worktree's most recent terminal (orca terminal focus). Orca has no dismiss primitive, so a long-press focuses the worktree too (which clears its unread in Orca).

How a pane's state is derived

A key shows a status (working, waiting, permission, or failed) and an age. Neither comes from a single cmux field; cmux's notifications, title spinner, and agent state each tell a partial, often-stale story. Muxboard fuses several signals so a key reflects what is actually true, which in practice is frequently more accurate than any one cmux surface on its own. Each signal is best-effort and degrades to the next when unavailable.

  1. Queue membership and the reason come from cmux list-notifications. A notification puts a pane on a key; the reason (failed, blocked, waiting, finished) is mapped from structured fields, never by scraping the free-form body (see the table above). cmux flips is_read when you see a notification, not when you resolve it, so a read (is_read: true) permission/failure is demoted to waiting — the key stays visible but loses its urgent badge — rather than dropped, which would hide things that still need you. An explicit "clear notifications" in cmux is honored live via the event stream (notification.clear_requested), removing the key at once.

  2. Activity (working vs waiting) comes from the cmux events stream. Muxboard prefers cmux's own computed verdict (set_status: Running, Idle, Needs), the same state that drives cmux's UI. For workspaces cmux doesn't publish a status for, it derives state from raw agent hooks (UserPromptSubmit and PreToolUse → working; Stop and SessionEnd → idle; Notification and AskUserQuestion → needs). A working pane shows ● working and sinks below the panes still waiting on you, since it no longer needs you. The title spinner glyph is the fallback when the stream is unavailable.

  3. Age is the time since the current state began (the transition occurred_at), so a key reads "working for 2m" or "waiting since 09:31" rather than the age of a stale, lingering notification. It falls back to the notification created_at.

  4. A busy command counts as working, from cmux top. An agent can finish its turn and return to waiting while a command it launched keeps running, so a workspace whose process CPU is at or above busyCpuPercent is treated as working even after the agent yields, with a short hysteresis window so a bursty command doesn't flicker. An explicit "needs you" still wins over busy, so permission prompts stay visible.

Grid priority, front to back: failed, then permission, then needs-input (cmux's "Needs" status, shown as a prominent ◆ NEEDS YOU badge), then plain waiting, then actively-working last. The newest item is key 1. Actively-working panes are listed even without a notification; they land at the very end, so the panes that need you always stay up front, and pressing one focuses its workspace.

Known limitation: a Claude agent waiting on its own background subagent does that work in-process, where cmux reports no spinner, no set_status, and low CPU, so the pane reads waiting. The only ground truth is the agent's own terminal screen, which Muxboard deliberately does not scrape. That narrow case (an agent blocked on its own background task) is the one state no cmux signal exposes.

CodexBar contract

Muxboard polls codexbar serve (default http://127.0.0.1:17777). It queries each provider individually (/usage?provider=all returns nothing) and handles both payload shapes CodexBar emits:

  • Codex exposes primary/secondary windows at the top level.
  • Claude and others nest them under usage.

Each window provides usedPercent, resetsAt, windowMinutes, and a resetDescription; primary is the session (5h) and secondary the weekly (7d) window. The pace marker/number is derived locally from resetsAt + windowMinutes (elapsed-vs-used); windows with no time bounds (e.g. an "Unlimited" weekly) show no pace. Today's spend and token count come from /cost?provider=<p> (a daily series; amounts are treated as USD since CodexBar emits no currency code). A provider that returns an { error } object (e.g. an expired token) is shown as unavailable. Data older than 2× the poll interval is flagged STALE.

Configuration

Stored in the plugin's global settings; all fields have safe defaults (src/config.ts):

Field Default Notes
cmuxBin "cmux" Binary path or name (spawned directly)
codexbarBaseUrl "http://127.0.0.1:17777" codexbar serve --port 17777 base URL
codexbarProviders [] Optional allow-list/order; empty = auto-discover all
cmuxPollMs 1500 cmux poll interval
codexbarPollMs 45000 CodexBar poll interval
agentAliases {} Manual override (name substring → agent); process detection is primary
busyCpuPercent 40 Workspace CPU% (from cmux top) at/above which a running command counts as "working"

Architecture

  Stream Deck+ plugin ── spawns ──► cmux CLI ──► cmux socket (automation mode)
        └── TCP ──► codexbar serve (LCD usage)

src/
  plugin.ts          entry: connect, load config, start services
  runtime.ts         shared store/clients/services + macOS foregrounding
  config.ts          defaults + defensive resolveConfig()
  core/              dependency-free, unit-tested, no SDK import
    types.ts
    cmux/            client (CLI wrapper), normalize (agent/reason), sort,
                     eventStatus (live state from the event stream + CPU)
    codexbar/        client (HTTP), normalize (dual-shape + error + cost)
    render/          palette, format, keyRender (SVG), lcdRender (SVG)
    services/        store, cmux/codexbar poll loops, cmuxEvents (event stream)
  actions/           attentionKey (8 keys), dialStrip (4 dials): thin SDK glue
scripts/             preview / validate / gen-icons / install-profile / dev.sh
test/                fixtures + node:test suite
com.mrshu.muxboard.sdPlugin/   manifest, layouts, imgs, built bin

Why automation mode is required

cmux's control socket does an ancestry check: under the default socketControlMode: cmuxOnly it only accepts processes spawned inside a cmux session. The Stream Deck app launches plugins via launchd, outside any session, so a direct cmux call is rejected with "broken pipe". Setting socketControlMode: automation removes the ancestry check for local processes of the same user, which is what lets the plugin spawn cmux directly. This is the approach the gonzaloserrano/streamdeck-cmux plugin also uses. (Note: on some builds and macOS versions the mode reportedly doesn't take effect; see upstream issues #1864 / #3282, and verify with cmux capabilities | grep access_mode.)

The device profile

scripts/install-profile.mjs writes a Muxboard profile straight into the Stream Deck app's ProfilesV3 store (the app's own V3 format, keyed to the connected Stream Deck+'s device id), placing the Attention Slot action on all 8 keys and the Muxboard Dial on all 4 dials. Run it with the app closed (npm run install-profile); the app picks it up on next launch and you select it from the profile dropdown.

This deliberately bypasses the app's profile importer, which rejects programmatically-built .streamDeckProfile archives as "content corrupted" on recent macOS builds (confirmed across clean/stored zips and deterministic UUIDs). Elgato's only supported way to produce an importable profile is to build it in the app UI and Export it, so we skip import entirely and write the store format the app itself uses.

Rendering is SVG-first: Stream Deck's setImage accepts SVG data-URIs, so keys and LCD segments are plain strings, with no native canvas dependency and fully testable. Each action caches the last SVG per instance to debounce redundant draws (anti-flicker). Polls never overlap, and last-good data is retained on failure so a transient outage never blanks the display.

Testing

npm test        # 30 unit tests: normalization, slotting, dual-shape codexbar,
                # SVG structure, store dial machines, service offline retention
npm run validate
npm run typecheck

Troubleshooting

  • Plugin won't start or crash-loops on first install. The Stream Deck app runs Node plugins with its own managed Node.js runtime, downloaded on demand. If it's missing (NodeJS/manifest.json not found in ~/Library/Logs/ElgatoStreamDeck/StreamDeck.log), fully quit and relaunch the Stream Deck app so it fetches the runtime, then restart the plugin.
  • require is not defined / exit code 1. The bundle must be CommonJS with a .cjs extension (this repo's package.json is "type":"module"). npm run build already emits bin/plugin.cjs; the manifest's CodePath points at it.
  • Changed the manifest? Re-link. A plugin restart does not re-read the manifest. Run npx streamdeck link com.mrshu.muxboard.sdPlugin again (or restart the Stream Deck app) after editing it.
  • LCD shows "CodexBar off". Ensure codexbar serve --port 17777 is running and that codexbarBaseUrl matches the port.
  • Keys are blank or show "cmux offline". cmux is rejecting the plugin. Confirm cmux capabilities | grep access_mode reads "automation" (not cmuxOnly). If it still says cmuxOnly, the setting hasn't taken; set Socket Control Mode to Automation and fully quit and relaunch cmux (a reload is not enough). The plugin log (com.mrshu.muxboard.sdPlugin/logs/) will show broken pipe when rejected.
  • No Muxboard keys, or the profile is missing. Run npm run install-profile with the Stream Deck app closed, reopen it, and select the Muxboard profile from the dropdown. (The app's profile importer rejects bundled profiles as "content corrupted" on recent macOS builds, so the profile is written directly into the app's store instead.)
  • A key is stuck on a stale state (wrong "waiting", or an age that won't move even though the agent is active). cmux's agent hook feed has gone quiet for that session, so Muxboard has no live signal and falls back to the last notification. Confirm it: cmux events --limit 5 shows recent UI rows but no agent.hook.* while an agent works. See the FAQ entry below; the usual cause is a PATH issue where cmux's claude wrapper is shadowed.

FAQ

Claude panes show stale/wrong state (or don't appear), but codex works

This is almost always a PATH problem, and it's upstream of Muxboard: cmux's #5796. cmux injects Claude's hooks through a claude wrapper shim on PATH. If Claude Code's own ~/.local/bin/claude (created/updated by its auto-installer) sits earlier on PATH, it shadows the shim, so claude runs the real binary and no hooks fire. Codex is unaffected because its hooks are a file (~/.codex/hooks.json), not a PATH shim. Diagnose:

which claude        # if it's ~/.local/bin/claude (not a .../cmux-cli-shims/... path), the shim is shadowed
cmux events --limit 5   # codex emits agent.hook.* while working; Claude emits none

Fix: make cmux's shim win on PATH by re-prepending its shim dir after your PATH setup runs, then start your Claude sessions in a fresh cmux terminal (pre-existing sessions won't recover). Add to the end of your shell config:

# ~/.config/fish/config.fish
for d in $PATH
    if string match -q '*cmux-cli-shims*' -- $d
        set -gx PATH $d $PATH
        break
    end
end
# ~/.zshrc (or ~/.bashrc with the loop adapted)
for __d in ${(s/:/)PATH}; do
  if [[ "$__d" == *cmux-cli-shims* ]]; then export PATH="$__d:$PATH"; break; fi
done
unset __d

After a fresh session, which claude should resolve to a .../cmux-cli-shims/... path. Verify hooks with cmux events --limit 5: you should now see agent.hook.PreToolUse while the agent works.

A pane shows "working" but with a stale-looking age

Without the agent-hook stream, Muxboard can't know exactly when work started, so the age falls back to the last notification time. Once hooks fire (see above), the age reflects the live activity. A CPU-bound command (build/test) is also detected as working via cmux top; an agent merely waiting on remote inference has no local signal.

Why is an active agent not on a key?

Muxboard lists actively-working panes at the end of the queue, but only once cmux reports them as working (a live spinner / hook activity). A brand-new agent with no notification and no live "working" signal yet won't appear until it either needs you or cmux marks it working.

Privacy & non-goals

  • Localhost only. The sole network call is to CodexBar on 127.0.0.1.
  • No terminal scraping; only cmux's structured notification fields.
  • No destructive actions. Muxboard never dismisses cmux notifications, runs commands inside agents, or sends approve/deny input. It reads and focuses.
  • No cloud and no database beyond plugin settings and an in-memory cache.

MVP intentionally excludes: command execution, approve/deny buttons, non-Stream Deck+ devices.

License

MIT

About

Stream Deck+ plugin that surfaces cmux coding-agent panes needing attention (Claude Code, Codex, Pi) on the 8 keys, with CodexBar quota/limits on the LCD strip.

Topics

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors