proper: a tmux workspace for my agents
These days I rarely have one thing running. There is a Claude Code session fixing a spec in one project, another one poking at a mobile app, a dev server somewhere and a shell I forgot about. Each of them lives in its own terminal tab and I spend a silly amount of time looking for the right one, or worse, not noticing that one of them has been waiting for me for ten minutes.
I tried a couple of tools built for exactly this and even built a kanban board that drove the sessions for me. They were fine, but I kept coming back to plain tmux, which I already use for everything. What I actually wanted was tmux plus a list on the left that tells me what is running where, and a sound when an agent needs me. So I built that. It's called proper and it's written in Rust.
What it does
proper runs its own tmux server with a single session. Every agent gets a window with two panes: a sidebar on the left listing my projects and the agents running in each, and the agent itself on the right. Both are plain tmux panes, so scrollback, mouse, colours and resizing come for free. The sidebar is a small Rust program that asks tmux which panes exist and draws the list.
It looks roughly like this:
┌──────────────────────────┬──────────────────────────────┐
│ ▾ outmuscleme │ > fix the flaky activity │
│ ▶ ● claude claude │ spec │
│ ◌ reviewer claude │ │
│ ! migration claude │ ⠋ Running rspec… │
│ ● zsh vim │ │
│ │ │
│ │ │
│ ? help q detach │ │
└──────────────────────────┴──────────────────────────────┘Using it
proper add ~/Projects/outmuscleme # register a project
proper # start the server and attach
proper new outmuscleme # claude in outmuscleme
proper new outmuscleme -- bin/rails s # or any command
proper ls # what is running
proper kill outmuscleme/claudeInside, j and k move through the list, Enter jumps to an agent and a mouse click does the same. n starts claude in the project under the cursor, s opens a shell there. x kills an agent (it asks first) and r restarts one that crashed. M-Tab moves focus between the sidebar and the agent.
Leaving does not stop anything
This was the one rule. Pressing q in the sidebar simply detaches the tmux client. The server keeps running and so do the agents. Running proper again attaches to the same session and everything is where I left it. If my terminal dies or I close the window by accident, nothing happens to the agents. To really shut things down there is tmux -L proper kill-server.
It dings when Claude needs me
Claude Code has hooks, small commands it runs on events like "the user sent a prompt", "I finished my turn" or "I need a permission". proper has a status subcommand made for them:
"hooks": {
"UserPromptSubmit": [{ "hooks": [{ "type": "command", "command": "proper status working" }] }],
"Stop": [{ "hooks": [{ "type": "command", "command": "proper status waiting" }] }],
"Notification": [{ "matcher": "permission_prompt|elicitation_dialog|agent_needs_input",
"hooks": [{ "type": "command", "command": "proper status attention" }] }]
}Each call marks the agent's pane with a tmux user option, so every sidebar shows ● while Claude works, ◌ when it finished and waits for a prompt and ! when it wants an answer or a permission. The last two also play a sound. Outside a proper pane the command does nothing, so the hooks are harmless in any other Claude session. I went for Glass.aiff, the macOS one, and it can be changed or muted with an environment variable.
How it is put together
I wanted as little of my own code between me and tmux as possible, so proper does not emulate a terminal and does not use tmux's control mode. It shells out to tmux and parses the output of list-panes with a tab separated format string. Each pane carries a few user options (@proper_role, @proper_project, @proper_agent, @proper_status), which is how proper tells its own panes from anything else. Besides a small projects.toml with names and paths, tmux is the only state.
The sidebars never talk to each other. A handful of tmux hooks (window changed, pane died, option set) signal a wait-for channel, every sidebar has a thread blocked on it, and they all re-read the pane list the moment something happens. Before that they polled once a second and switching windows felt laggy, because the sidebar you landed on was still showing the old state.
A few things I learned along the way:
One window per agent beats shuffling panes around. tmux destroys a window when its last pane goes and
swap-panequietly falls back to the marked pane when you forget-s. Windows are the unit tmux already understands, so switching, zooming and killing are single commands.remain-on-exit failedis the right setting. A clean exit closes the window like a tab, a crash keeps the dead pane around so I can read why (the output moves to scrollback, tmux only shows "Pane is dead" on screen) and pressr.A pane killed by a signal has an empty
pane_dead_status, the signal name is inpane_dead_signalinstead. Found that one with a test thatkill -9'd the sidebar.Inside a tmux pane,
TMUXpoints at the server the pane lives on. I typed my tmuxinator alias in a proper shell and it built the whole project layout on proper's server and switched my client to it. proper now starts agents with an emptyTMUX, so tmux inside them talks to my normal server like it would from a plain terminal.The tests start real tmux servers on throwaway sockets. No mocks, and the parser fixture is the actual output of
list-panes. One of them sources the generated tmux config on a real server, after I shipped two hook names that do not exist in tmux 3.4.
What's next
Git worktrees per agent, so two agents can work on the same repo without stepping on each other
Picking up projects from a folder instead of registering them one by one
Summary
proper is a tmux server with one session, each agent is a window with a sidebar
The sidebar is a tiny Rust program that reads tmux state when tmux says something changed
Claude Code hooks mark the agent as working, waiting or needing an answer, and the last two make a sound
qdetaches, agents keep running,properbrings you backtmux is the state, plus a TOML file with project paths
The code is on GitHub: github.com/tagrudev/proper