nvim-tmux architecture

A bash shim that impersonates the tmux CLI surface Claude Code's Agent Teams calls, routing pane operations into real Neovim splits via the stock nvim --remote-expr RPC. Three files, ~1230 LOC.

Claude / caller Bash shim RPC bridge Lua (inside nvim) Neovim runtime

The big picture — 5 layers, top to bottom

1 · Caller external process
claude (Agent Teams)
Runs inside an nvim :terminal. Believes it's talking to real tmux. Probes tmux -V, then issues new-session, split-window, send-keys, break-pane… to lay out its swarm.
$NVIM env var
nvim auto-exports $NVIM (its RPC socket path) into every :terminal child. That's the back-channel the shim talks to its parent nvim over.
PATH resolves tmuxbin/tmux · argv ↓
2 · Bash shim bin/tmux · ~556 LOC, one file
main() + cmd_* handlers
Strips tmux global flags (-L/-S/-f…), short-circuits -V, routes the subcommand. Each of the 16 handlers parses its flags and makes one RPC.
  • cmd_split_window, cmd_send_keys, cmd_kill_pane, cmd_break_pane, cmd_join_pane, …
  • Shared helpers: arg (flag validation), emit_format (-P/-F output), die
state_* query wrappers
Thin shims over _lua_call for the bookkeeping queries: exit-code mapping for v:true/v:false (state_has_session) and newline-termination for lists (state_list_panes). state_leader_pane is a hardcoded constant — no nvim contact.
transport + RPC
Bottom of the same file — see layer 3.
spawns nvim --headless --server $NVIM --remote-expr "…"
3 · RPC bridge one call shape
nvim_expr
The single transport primitive: fork the nvim CLI against the parent's socket, evaluate one vim expression, capture the result. --headless + </dev/null keeps terminal escape probes out of captured stdout.
_lua_call METHOD args…
Quotes each arg as a vim string literal and evaluates v:lua.require'nvim-tmux'.METHOD(a,b,…) via nvim_expr. One RPC round-trip per tmux subcommand.
_lua_ensure_rtp
Lazily appends the repo to the live nvim's runtimepath on first call (via luaeval, path riding in the _A argument), so require('nvim-tmux') resolves even for non-plugin installs. Idempotent.
executes synchronously inside the running nvim ↓
4 · Lua module lua/nvim-tmux/init.lua · ~662 LOC, one module
§ stateflat maps
Single source of truth, in vim.g.nvim_tmux. Pure bookkeeping.
  • Queries: has_session, has_pane, get_nvim_field, list_panes, count_panes
  • Mutations: create_session, split_pane, set_nvim_binding
  • kill/break/join bookkeeping = locals, called by the actions below
  • read → mutate local → vim.g.nvim_tmux = t (nested writes don't persist)
§ actionstmux → nvim ops
One function per tmux subcommand; owns the window/buffer/job side.
  • split_window → win_gotoid + vsplit + bind
  • kill_pane → nvim_buf_delete / win_close
  • open_terminal → :terminal + TermClose autocmd
  • send_keys → token grammar → chansend
  • break_pane/join_pane → hide/restore, job kept alive
§ setup()packaging
Prepends bin/ to vim.env.PATH and sets NVIM_TMUX_NVIM_BIN = vim.v.progpath so :terminal children find the shim. Auto-run by plugin/nvim-tmux.lua (14-line stub) unless vim.g.nvim_tmux_disable.
vim.fn / vim.api / vim.cmd ↓
5 · Neovim runtime the actual editor
windows / buffers / terminal jobs
Real splits the user sees. vsplit, win_gotoid, :terminal, chansend, nvim_buf_delete — each teammate is a terminal buffer in its own window.
vim.g.nvim_tmux
The state dict lives here — scoped to this nvim instance, atomic per RPC call (single-threaded event loop), gone on quit. No disk, no jq, no lockfiles.
Why this shape? Each tmux invocation is a fresh short-lived process, so state must live somewhere persistent: the long-lived nvim process itself. Once state lives in nvim, the code that mutates it wants to live there too — bash shrinks to what it's good at (being the tmux-shaped argv parser at the process boundary), and the single event loop gives atomicity, per-instance scoping, and lifecycle cleanup for free.

Data flow — one example: tmux split-window -t 2:0.0 -h -P -F '#{pane_id}'

#WhereWhat happens
1bin/tmux main() strips globals, routes to cmd_split_window, which parses -t 2:0.0 -h -P -F '#{pane_id}' via the arg helper.
2bin/tmux _lua_call split_window "2:0.0" "h" "" → (first call only) _lua_ensure_rtpnvim_expr "v:lua.require'nvim-tmux'.split_window('2:0.0','h','')" forks the nvim CLI against $NVIM.
3init.lua § actions split_window resolves the parent's winid (state lookup, leader fallback), vim.fn.win_gotoid(), vim.cmd('vsplit'), grabs the new win_getid().
4init.lua § state split_pane('2:0.0') bumps windows["2:0"].next_pane and creates panes["2:0.1"]; set_nvim_binding('2:0.1', winid) records the window id. All inside one event-loop turn — atomic.
5nvim The split is visible; the expression result "2:0.1" returns over the socket.
6bin/tmux cmd_split_window captures 2:0.1 and, because -P -F, emit_format prints 2:0.1 — exactly what Claude expects from real tmux.

State shape — vim.g.nvim_tmux (flat: ids are the keys)

{
  next_session_idx: 3,
  leader_winid: 1000,    -- cached on first op
  sessions: {
    "swarm": { idx: 2, next_window: 1 }
  },
  windows: {
    "2:0": { name: "leader", next_pane: 2 }
  },
  panes: {
    "2:0.0": { nvim_winid: 1000, … },
    "2:0.1": {
      nvim_winid:   1003,  -- which split
      nvim_bufnr:   4,     -- which buffer
      nvim_chan_id: 2,     -- chansend target
      hidden: false,
      hidden_session: null
    }
  }
}
Ids encode the hierarchy
S:W.P — session-idx : window-idx . pane-idx, e.g. 2:0.1. Since the id already says where a pane lives, the store is flat: lookups are panes[pid], listings are prefix scans. The leader is always 1:0.0 (hardcoded constant). Indices are monotonic — never reused.
break-pane / join-pane
Hiding is in place: break-pane sets hidden=true and closes the window — the buffer + terminal job stay alive, and the record stays under its id (so it keeps resolving) while list-panes/count_panes skip it. join-pane re-splits, loads the preserved buffer, and moves the record to its new id.
Atomicity
Each _lua_call runs an entire operation to completion inside nvim's single-threaded event loop. Concurrent shim invocations can't interleave — no locks, no tmp+mv, no state files.

How it gets onto your PATH

As an nvim plugin
plugin/nvim-tmux.lua auto-runs setup() on startup → prepends bin/ to vim.env.PATH → every :terminal child (incl. Claude) resolves tmux to the shim. Zero system changes — your login shell never sees it.