jt(1) — tmux pane status for AI agents

linux · arm64 · x86_64 · wsl2

Name

jt — visual state for tmux sessions running AI agents. Pane borders change color when a session needs attention; a sidebar shows what each session is doing; jt nodes aggregates state across machines over Tailscale.

Synopsis

jt [status]
jt watch
jt popup
jt sidebar [on|off|toggle]
jt spawn   <name> <cmd>
jt kill    <name>
jt attach  <name>
jt nodes

Description

If you run several AI agents at once — Claude Code, Codex CLI, an autonomous task runner, a long-build — each one usually lives in its own tmux session (a persistent shell that survives logout). The problem: you can only look at one session at a time, so it's hard to tell which agents are working, which are idle, and which are done without flipping through them.

jt turns each session's tmux pane border into a status light: green while output is flowing, amber when it has been quiet for more than 20 seconds, gray when finished, red on error. It also gives you a sidebar with a live table of every session and a jt nodes command that shows the same view across machines.

Under the hood, two pieces: jtd, a small background process (a daemon) that polls tmux every two seconds, classifies each session, repaints the pane border, and writes a JSON state file; and jt, the CLI you actually use, which reads that file directly so jt status is instant. No third-party runtime dependencies. Linux, WSL2, ARM64. The main session's pane border is never touched.

Screen

jamditis@houseofjawn:~$ jt status

  houseofjawn  16:42:05

    main           active    52h    claude
    morning-wake   active    4m     claude
       Checking email...
       Found 1 transcript.
    checkin-wake   silent  ~  12m    bash
       No new transcripts.
    cleanup-wake   done      22s    bash
       CLAUDE_TASK_COMPLETE:0

jamditis@houseofjawn:~$ 
Real jt status output. The dot at morning-wake pulses while the agent is producing output; goes solid amber when it falls silent for more than 20 seconds.

States

Every two seconds, each session is classified into one of four states.

State lifecycle diagram for active, silent, done, and error transitions.
Session lifecycle rules used by the classifier every 2 seconds.

The done/error states are read from a marker line in /tmp/<prefix>_<unix_ts>.txt, where the timestamp matches the tmux session's creation time within 60 seconds. The daemon scans two prefixes by default — claude_scheduled (used by claude-scheduler) and codex_scheduled (used by Codex CLI scheduler wrappers). Set JT_OUTPUT_FILE_PREFIXES=claude_scheduled,codex_scheduled,my_runner to add your own. Sessions without a matching log only move between active and silent.

Glossary

Terms used elsewhere on this page, briefly defined.

tmux
terminal multiplexer; lets you keep many shells open inside one terminal window, with sessions that survive logout
session
a single tmux instance with one or more windows and panes; jt watches one per agent
pane
a region inside a tmux split; jt changes its border color to signal status
daemon
a long-running background process; here, jtd launched by your user's systemd
systemd user service
a unit run by Linux's service manager under your account, started on login and restarted on crash
Tailscale
a private mesh VPN that gives every machine a stable IP in the 100.64.0.0/10 range; jt nodes uses it to talk to other machines without exposing them to the public internet
$XDG_RUNTIME_DIR
a per-user, per-login temporary directory (e.g. /run/user/1000); the state file lives there because it is private to you and wiped on logout
marker
the literal line CLAUDE_TASK_COMPLETE:0 (or :1 for failure) appended to an agent's log when it finishes; the daemon reads it from /tmp/claude_scheduled_<unix_ts>.txt or /tmp/codex_scheduled_<unix_ts>.txt (or any prefix listed in JT_OUTPUT_FILE_PREFIXES) with a timestamp matching the session's creation time within 60 seconds; this is what flips a session to done or error

Install

Requires Python 3.11+, tmux 3.2+ (format strings in pane-border-style), and systemd. Tested on Linux ARM64 (Raspberry Pi), x86_64, and WSL2.

# clone, install Python entry points, register systemd user service
git clone https://github.com/jamditis/jawn-tmux.git ~/projects/jawn-tmux
cd ~/projects/jawn-tmux
./install.sh

# verify
systemctl --user status jtd
jt status

The installer is idempotent: re-running it is safe. It appends a source-file line to ~/.tmux.conf only if missing, installs the systemd unit to ~/.config/systemd/user/, and starts jtd.

Keybindings

Bindings come from tmux/jt.conf, sourced from your tmux config.

Ctrl+B a
open a status popup; q closes, k prompts to kill a session by name
Ctrl+B Shift+A
toggle a 36-column sidebar pane running jt watch

Commands

jt [status]
print a table of local sessions with status, elapsed time, and tail
jt watch
re-render every two seconds; used by the sidebar pane
jt popup
interactive overlay via tmux display-popup
jt sidebar [on|off|toggle]
toggle the sidebar pane
jt spawn <name> <cmd>
create a named tmux session running cmd
jt kill <name>
kill a tmux session
jt attach <name>
attach to a session
jt nodes
fetch state from configured nodes concurrently and render them together
jt doctor [--json]
run health checks against the daemon and environment; exit 1 on any FAIL, 0 otherwise
jt logs [-f] [-n N] [--since <time>]
tail the jtd systemd journal — wraps journalctl --user -u jtd with sensible defaults

All read-only commands consult the local state file directly — no subprocess per query — so jt status is roughly a JSON parse plus a format pass.

Architecture

Architecture diagram showing jtd daemon writing state and serving HTTP while jt CLI reads local state and remote nodes.
High-level runtime flow: one writer (jtd) and many readers (jt commands and remote node fetches).

Two processes. jtd writes; everything else reads. State lives in $XDG_RUNTIME_DIR/jt-state.json (with a /tmp fallback) and is written atomically with mode 0600.

jtd binds :6248 to 127.0.0.1 by default — the state JSON contains session names and the last few output lines, so the listener stays local until you opt in. To enable cross-node aggregation, set JT_BIND to your Tailscale IP (or 0.0.0.0 if the host is firewalled to a private interface) and restart the daemon.

State file

// written by jtd every 2 seconds, mode 0600
{
  "node": "houseofjawn",
  "updated_at": 1740000000,
  "sessions": {
    "morning-wake": {
      "status": "active",        // active | silent | done | error
      "command": "codex",
      "elapsed_secs": 720,
      "last_activity_secs": 8,
      "output_tail": ["Checking email...", "Found 1 transcript."],
      "output_file": "/tmp/codex_scheduled_1740000000.txt"
    }
  }
}

Cross-node setup

List your machines in ~/.config/jt/nodes.json:

[
  { "name": "houseofjawn", "ip": "100.64.0.11", "port": 6248 },
  { "name": "officejawn",  "ip": "100.64.0.12",  "port": 6248 }
]

On each node, drop in a unit override and restart the daemon so it re-binds:

# open an editor for ~/.config/systemd/user/jtd.service.d/override.conf
systemctl --user edit jtd

# in the editor:
[Service]
Environment=JT_BIND=<tailscale-ip>

# then reload + restart so the new JT_BIND takes effect
systemctl --user daemon-reload
systemctl --user restart jtd

Unreachable nodes do not block the others — they appear in the rendered output as such.

Files

$XDG_RUNTIME_DIR/jt-state.json
local state written by jtd (falls back to /tmp/jt-state.json)
~/.config/jt/nodes.json
cross-node configuration; missing file means no remote nodes
~/.config/systemd/user/jtd.service
systemd user unit installed by install.sh
tmux/jt.conf
keybindings, sourced from ~/.tmux.conf

Environment

JT_BIND
HTTP bind interface for jtd; default 127.0.0.1
JT_PORT
HTTP port; default 6248
JT_STATE_FILE
override state file path; default $XDG_RUNTIME_DIR/jt-state.json
JT_LOG_LEVEL
daemon log level; default INFO (also DEBUG, WARNING, ERROR)
JT_OUTPUT_FILE_PREFIXES
comma-separated marker filename prefixes the daemon scans in /tmp; default claude_scheduled,codex_scheduled
XDG_RUNTIME_DIR
directory used for the state file when JT_STATE_FILE is not set

Troubleshooting

When something looks wrong, start with two commands:

# one-shot health report; exit 1 if any check FAILs
jt doctor

# last 50 lines of the daemon journal
jt logs -n 50

# tail live
jt logs -f

jt doctor checks $XDG_RUNTIME_DIR, state file freshness, tmux version, /tmp readability, configured marker prefixes, port and bind resolution, daemon HTTP reachability, and the most recent recorded daemon error. Each check reports OK, WARN, or FAIL with a one-line reason; pass --json for the same data in machine-readable form.

Common gotcha: if jt status shows nothing while jt doctor reports the daemon HTTP responds but the state file is missing or stale, the daemon and CLI are disagreeing on $XDG_RUNTIME_DIR. Add this to your jtd.service unit and restart:

[Service]
Environment=XDG_RUNTIME_DIR=/run/user/%U

Daemon-side errors (one-off poll failures, tmux disappearing, etc.) are recorded in the state file as a last_error field and shown as a banner in jt status / jt watch for five minutes. Older errors stay visible to jt doctor for forensic context but stop blocking its exit code.

See also

cmux(1)
visual state for AI agent workflows on macOS+Ghostty — the inspiration for this project
tmux(1)
the underlying terminal multiplexer
jamditis/jawn-tmux
source, issues, releases
CONTRIBUTING.md
how to set up the dev environment and submit a PR