I was watching my server's swap creep past ninety percent and I couldn't tell which of the seven Claude Code sessions in tmux was the cause.
tmux ls gave me names — dev-1, dev-2, review, api-thing, three more I'd lost track of — and nothing else. No memory. No idle time. No reminder of which project each one belonged to. To find the offender I had to attach, eyeball the screen, detach, attach to the next. Twenty minutes for what should have been a glance.
muxc is the thin Go binary I wrote that afternoon so it would never take twenty minutes again. It walks tmux, walks /proc, walks ~/.claude/projects/, and prints one table per session with the memory, idle time, and project path I needed in the first place. No daemon, no socket, no background process.

That * on the last row marks an external session — one I didn't create through muxc new, but where muxc detected a Claude process running inside the tmux pane. I'll come back to that one; it was the first time I broke my own rules.

Run muxc with no subcommand and you get the same table inside a Bubble Tea TUI, auto-refreshing every two seconds, with cursor navigation and dedicated screens for new, kill, info, and doctor. If stdin or stdout isn't a real terminal — CI runners, piped input, dumb terminals — it falls back to a plain numbered REPL so scripts keep working.
What muxc refuses to do
The interesting design decisions in muxc are all refusals. From spec.md §2:
tmux is the source of truth for liveness. Never infer "is this running" from
state.json; always ask tmux.~/.claude/is read-only. No daemon, no socket, no background process. No Claude API calls and noclaude -porchestration. Zero network I/O.
That's the contract. Four refusals, each load-bearing.
No daemon. Every muxc invocation is a fresh one-shot run that reconstructs state from tmux + procfs + Claude's project directory, then exits. Lose the side-table at ~/.config/muxc/state.json and the binary still works on the next invocation — tmux still knows what's alive. There is no service to restart, no PID file to clean up, no socket to misconfigure.
No network I/O. strace -e trace=network on a muxc ls is empty. No telemetry, no auto-update check, no resolver call. The only subprocesses it spawns are tmux and (optionally) fzf for the attach picker. If your security team asks what muxc talks to over the wire, the answer is "nothing" and the answer is verifiable.
~/.claude/ is read-only. Claude Code owns those JSONL transcripts; muxc reads them to pull session names and tail recent messages, but it will never write or lock anything under that directory. Claude's data model stays Claude's, and updates to Claude can't break me.
No CGo. Single static binary, ~5.9 MB stripped. scp it to a server and it runs. No glibc version dance.
The hardest part of writing muxc has been resisting feature requests. Three people asked for a daemon. One asked for a web UI. One asked it to manage their non-Claude tmux sessions too. The answer to all of those is no, and the answer being no is the only reason muxc is small enough that you trust it next to your sessions.
Figuring out which process is "Claude"
The most interesting part of the binary is the part that answers a question tmux can't: of all the processes inside this pane, which one is the Claude I should bill the memory to?
The /proc walk
On every command that needs memory data, muxc does a single /proc scan at command entry. It reads /proc/<pid>/stat for every process on the box, builds a PPID→children map, and caches VmRSS from /proc/<pid>/status as a side effect. The whole walk costs about six milliseconds on a box with four hundred processes. The map is built once per invocation and re-used for every tmux session — there's no per-session re-scan, which spec.md §13.1 calls out specifically.
The heuristic
For each tmux pane, muxc walks the descendant tree from the pane PID and applies a small heuristic to pick out the Claude process:
// internal/proc/claude.go (paraphrased)
for _, pid := range candidates { // pane PID + every descendant
cmd, _ := readCmdline(pid)
base := filepath.Base(cmd.argv0)
if base == "claude" { return pid }
if base == "node" && argvContainsClaudePath(cmd.argv) { return pid }
exe, _ := os.Readlink("/proc/"+pid+"/exe")
if exe == cfg.Defaults.ClaudeBin { return pid }
}First match wins. If two candidates somehow tie, the lower PID wins. If nothing matches, claude_pid is reported as 0 and the row's memory column shows the shell-plus-tree total under a separate field — muxc never fails the whole command over one weird pane.
(I spent a week convinced I'd eventually have to shell out to ps for some edge case the heuristic missed. I haven't yet. Two months of personal use, zero false negatives.)
The pane PID counts too
The original spec §13.3 said "walk descendants of the pane PID". That was an implementation hint, not a hard contract, and I broke it about a week into running the tool. Some tmux sessions get launched with tmux new-session -d <claude-binary> directly, with no shell wrapper, and in those the pane PID is claude. One extra line — candidates := append([]int{panePID}, descendants...) — fixed it.
What the row's MEM number actually sums
Here's the shape of a real session's process tree on my box:
The MEM column reports 546 MB for that session — claude plus every descendant. Skipping the MCP and language-server children would understate the footprint by thirty to sixty percent on a real Claude workload, and it's the MCP children that have been the difference between "two sessions fit" and "swap is on fire".
RSS vs PSS
I picked RSS over PSS because RSS is one line read from /proc/<pid>/status. PSS is honest about shared pages — a node runtime shared across four MCP children won't be counted four times — but parsing smaps_rollup is slower and noisier. For v1 the question I wanted to answer was "which session is fat", and overcounting equally across sessions answers it fine. PSS may show up behind a --pss flag in v2; it isn't there yet.
Attaching without leaving a corpse
The first naive version of muxc attach did the obvious thing: exec.Command("tmux", "attach"), then Wait, then return. It worked. It also left muxc as a parent process for the entire duration of the tmux session — and when you eventually detached with Ctrl-b d, you landed back in muxc, not in your shell. To actually get out, you had to detach and then quit muxc. Two motions for what should have been one.
The fix is syscall.Exec. Instead of spawning tmux as a child, muxc replaces its own process image with tmux attach. The kernel rewrites the binary in place; the Go runtime is gone; only tmux is running afterwards. When you detach, you land directly in your shell because there was nothing else in the chain to land in.
There's one gotcha and it bit me on the first run. muxc tracks per-session attachment timestamps in state.json so ls can show "last attached two hours ago". The natural place to update the timestamp is after the attach call returns — except syscall.Exec never returns. The Go process no longer exists. Any "after" code silently never runs.
So the write has to happen before the exec:
// internal/cli/attach.go
if entry, ok := st.Sessions[fullName]; ok {
entry.LastAttachedAt = time.Now().UTC()
st.Upsert(fullName, entry)
_ = st.Write(cfg.Paths.StateFile) // best-effort; ignore write errors
}
return attacher.Attach(fullName, detach) // syscall.Exec under the hoodThe state write is intentionally best-effort. If it fails, the worst case is a stale last_attached_at; not worth aborting the attach. The whole thing reads like a one-line nothing detail, and that's exactly the kind of detail that separates a tool from a script.
Surfacing the sessions I didn't create
For the first month, muxc ls only showed sessions whose names started with the configured prefix (muxc- by default). The rule was clean. The code was simple. kill --all was safe by construction because there was nothing else to kill.
Then one afternoon I started a Claude session by hand for a quick code review (tmux new -s review-456 \; send-keys 'claude' Enter) and ran muxc ls, and muxc told me there were no sessions running. There were three. It just wasn't going to show me the one I'd just made because I'd skipped its blessed entry point. That was the moment I knew the rule was wrong.
The fix was careful: tmux sessions whose names don't match the prefix but whose process tree contains a detected Claude process now show up in ls, marked with a ★ prefix in the TUI and a * suffix in the CLI. The JSON output sets "is_external": true. But — and this is the asymmetry that matters — kill --all and kill --idle still ignore them. The blunt instruments stay safe. If you want to kill an external session you do it by name, or through tmux kill-session directly. An ExternalMode enum threads that distinction through every caller so it's impossible to get wrong by accident.
The full execution path of a muxc ls call, end to end:
Cold-path latency on a box with eight live Claude sessions: about forty milliseconds. That number isn't a brag — it's a budget. Anything slower than a key repeat would have killed the TUI's auto-refresh feel, and the auto-refresh feel is most of what makes the TUI worth shipping.
What I bet the binary on
Every dependency in a static binary is something you have to defend. Here's what I picked and why each one survived the trade-off review.
spf13/cobra— CLI framework. One file per subcommand ininternal/cli/, free shell completions, the boring choice that wins every time.charmbracelet/bubbletea+lipgloss+bubbles— the TUI.spec.md§3 explicitly rejected "TUI dashboard (ncurses)" as out of scope. Shipping the TUI was the only time I overruled my own spec, and it cost a megabyte of binary. Worth it: the auto-refreshing live table is what turned muxc from "tool I run when curious" into "tool I leave open in a pane".BurntSushi/toml— config parsing. Picked over Viper for the smaller surface area.olekukonko/tablewriter+dustin/go-humanize— boring formatting that would otherwise eat two hundred lines of code.fatih/color— colored output, withNO_COLORandMUXC_NO_COLORrespected.mattn/go-isatty— the TUI-vs-REPL switch hinges on this one library call.- Build chain: Go 1.24.2,
goreleaser,golangci-lint.
How it ships
CI runs on every pull request: gofmt -l . + go vet ./... + golangci-lint run + make test + goreleaser release --snapshot --clean --skip=publish to validate that the release pipeline cross-builds cleanly before anyone tags. The snapshot job uploads the archive set as a workflow artifact so I can spot-check a .deb before it goes live.
Releases trigger on v* tag pushes. GoReleaser produces four binaries (linux/amd64, linux/arm64, darwin/amd64, darwin/arm64), .deb and .rpm packages for both Linux architectures via nfpms, and a Homebrew formula auto-pushed to monzim/homebrew-tap via the brews block. SHA-256 checksums sit alongside. End-to-end the release workflow takes about forty-five seconds.
Install paths for v1.0.0:
| Platform | Method | Command |
|---|---|---|
| macOS / Linuxbrew | Homebrew tap | brew install monzim/tap/muxc |
| Debian / Ubuntu | .deb package | sudo dpkg -i muxc_1.0.0_linux_amd64.deb |
| Fedora / RHEL | .rpm package | sudo rpm -i muxc_1.0.0_linux_amd64.rpm |
| Any Linux | Tarball | curl -fLO https://github.com/monzim/muxc/releases/download/v1.0.0/muxc_1.0.0_linux_amd64.tar.gz && tar -xzf muxc_1.0.0_linux_amd64.tar.gz && sudo install -m 0755 muxc /usr/local/bin/muxc |
| Go developers | go install | go install github.com/monzim/muxc/cmd/muxc@latest |
| From source | Makefile | git clone https://github.com/monzim/muxc.git && cd muxc && make install |
What I already regret
I shipped v1.0.0 thinking the code was clean. The first time CI actually ran golangci-lint, it found eighteen pre-existing issues. Every one fixable in under five minutes. They'd accumulated invisibly for a year because the project's own CONTRIBUTING called the linter "optional but recommended". It should never have been optional.
There was a unit test, TestLatestSession_ReturnsLatest, that passed on every dev machine and failed on the first CI run. It asserted that the JSONL with the most recent mtime got picked. Dev machines had spread-out timestamps from prior work; a fresh git checkout shared one. Three lines of os.Chtimes in the test setup. They should have been there from day one.
The deviation document — the part of CLAUDE.md that explains why the TUI exists despite §3 rejecting it — should have lived in the README from v0. Two early contributors have already asked the same question, and the answer was discoverable only in commit messages. By the time you notice you need a "why we deviated" document, you needed it six months ago.
The Homebrew formula landed at muxc.rb in the tap repo root, not under Formula/muxc.rb. brew install works either way; brew bump-formula-pr doesn't. One-line fix in v1.0.1.
This morning
I tagged v1.0.0 yesterday. The binary I'd been running locally for months is now sitting on a Homebrew tap with a SHA-256 next to it and four packaging formats behind it.
This morning, muxc ls told me a session I'd attached to last Thursday was still alive, four days idle, pinning 380 MB. I killed it.
That was the whole reason this exists.

