Managing multiple opencode sessions in tmux

A tmux picker that shows what your AI agents are doing

Once you run more than one opencode session, keeping track of them gets hard. Your panes sit across many windows and sessions, each running its own opencode instance.

I built two small tools to fix this: a status server plugin that every opencode instance runs, and a tmux picker that shows all running sessions at a glance with their live status.

The problem

Opencode panes look identical in tmux. There is no indication of what a session is doing, whether it has processed your last message, or is stuck waiting for you to answer a question or approve a permission. When you work across several sessions in parallel, you spend more time checking up on them than actually reviewing output.

The status server plugin

Opencode knows everything about its sessions. The trick is getting that information out. I wrote a global plugin that spins up a tiny HTTP server inside every opencode instance. The plugin subscribes to the session event stream and keeps an in-memory store of titles and statuses.

// ~/.config/opencode/plugins/status-server.js
export const StatusServerPlugin = async ({ client, directory }) => {
  ...
  return { event, dispose }
}

Anything in ~/.config/opencode/plugins/ is auto-loaded, so there is no config or import to wire up. The plugin tracks three things per session:

  • the session title
  • whether it is busy or idle
  • whether it is waiting on a question or permission

The "waiting" state is the important one. Opencode surfaces it as question and permission events, and I flag the session so the picker can surface it immediately.

GET http://127.0.0.1:<random-port>/status

{
  "pid": 1234,
  "directory": "/home/you/project",
  "sessions": {
    "abc123": { "title": "Refactor auth middleware", "status": "waiting" },
    "def456": { "title": "Write unit tests", "status": "busy" }
  }
}

Each instance picks a random free port when it starts, which keeps multiple opencode processes from colliding. To find that port, the picker probes the process table.

Finding the port

The picker starts from pane_pid, which tmux reports as the command running in the pane - usually a shell. Opencode is that shell's child, so the script walks one level down:

get_opencode_child_pid() {
  local pane_pid="$1"
  local child
  child=$(pgrep -P "$pane_pid" -x opencode 2>/dev/null | head -1)
  if [[ -n "$child" ]]; then
    echo "$child"
    return 0
  fi
  if [[ -d "/proc/$pane_pid" ]]; then
    local comm
    comm=$(cat "/proc/$pane_pid/comm" 2>/dev/null)
    [[ "$comm" == "opencode" ]] && echo "$pane_pid"
  fi

Assumes a direct child

This walk assumes opencode runs as a direct child of the pane's command. If you launch it through a wrapper, the one-level -P lookup misses it.

When opencode replaces the shell with exec, the pane PID is the opencode process itself. Either way you end up with the right PID, and once you have it, ss -tlnp shows which port it is listening on:

ss -tlnp | grep "pid=1234,"
# LISTEN  0  1024  127.0.0.1:43210  0.0.0.0:*  users:(("opencode",pid=1234,fd=29))

With the port in hand, a quick curl against /status returns the live session state.

The tmux picker

The picker is a bash script bound to prefix + o. It does four things:

bind-key o run-shell "bash ~/.config/tmux/scripts/opencode-picker.sh"
  • finds every pane running opencode across all sessions using tmux list-panes -a filtered by the current command
  • resolves each pane to its opencode PID and status port
  • queries each status server and parses all responses at once
  • builds a label format string and opens tmux choose-tree with it

The result is a tree of your sessions that looks like this:

tmux choose-tree showing opencode session statuses

Selecting an entry switches you straight into that pane. No more tabbing through every window.

Batch parsing in Python

Querying a dozen panes means a dozen /status calls. Instead of spawning a parser per pane, the script pipes all raw JSON into one Python process that reads it line by line and emits one tab-separated row per pane:

TOTAL RUNNING WAITING TITLE PRIORITY LABEL
2    1       1       Refactor auth middleware  2  2 sessions (1 running; 1 waiting) — Refactor auth middleware

The title is picked from the most interesting session in that pane: waiting sessions first, then running ones, then anything else.

Labels in a format string

Tmux choose-tree -F takes a format string, but you cannot compute custom labels per pane inside it. The trick is generating a giant chain of nested conditionals that maps each pane ID to its label:

#{?#{==:#{s/%/p/:pane_id},p2}, 2 sessions (1 running; 1 waiting) — Refactor auth middleware,
  #{?#{==:#{s/%/p/:pane_id},p5}, 1 session (1 running) — Write unit tests, waiting-for-input,
    #{?...}
  }
}

Tmux walks down this chain for each row, comparing the row's ID against every known ID until one matches, then returns that label. The #{?…} tail is a fallback for panes with no opencode session.

The same approach works at the window and session level. Panes are compared on pane_id, windows on window_id, and sessions on session_id. The script aggregates counts up the tree, so a window with two opencode panes shows the total for both.

# three lookup chains, one per level
PANE_FMT="#{?#{==:#{s/%/p/:pane_id},p2}, 2 sessions (1 running; 1 waiting) — Refactor auth middleware,...}"
WIN_FMT="#{?#{==:#{s/%/w/:window_id},w1}, 3 panes (2 running; 1 waiting) — Refactor auth middleware,...}"
SES_FMT="#{?#{==:#{s/%/s/:session_id},s1}, 1 window (3 panes) — my-project,...}"

# outer dispatch picks the chain for each row's level
FMT="#{?#{pane_format},${PANE_FMT},#{?#{window_format},${WIN_FMT},${SES_FMT}}}"

tmux choose-tree -Z \
  -f '#{==:#{pane_current_command},opencode}' \
  -F "$FMT" \
  "switch-client -t '%%'"

Depends on the process name

Pane detection compares the pane's current command against opencode. If you rename the process, the -f filter and the -x match both need adjusting.

Tmux evaluates the format string once per row and sets exactly one of #{pane_format}, #{window_format}, or #{session_format} to 1, depending on the level being rendered. The outer conditional branches on those flags, so each row picks the label built for its level.

Commas break the label

The comma is tmux's argument separator in a format string, so a comma in a session title would break the label. The parser replaces commas with semicolons before embedding them.

What it feels like

Instead of hunting through panes, I hit prefix + o, see which sessions are waiting for me, and jump straight there. When I kick off a long refactor in one pane and keep coding in another, the picker tells me at a glance when it is done or when it hit a question I need to answer.

Because each instance runs its own status server on a random port with no shared config, it scales to any number of projects and tmux sessions.

Share

Comments (0)