← All posts

My tmux Test SIGKILLed the Agent Running It

I tested a tmux message tool inside the live session that held my coding agent. One missing target killed the agent's own pane. The fix was a disposable tmux socket.

  • agents
  • tmux
  • testing
  • isolation
  • shell

Listen to this article

This edition uses a synthetic blended voice to read the full article.

I was testing a message-transport script when one tmux send-keys command landed in the Claude Code pane that was running the test. The agent disappeared with exit 137 — the number a shell reports when a process has been forcibly killed — and its half-finished test left junk windows scattered through my real working session.

Tmux is a terminal multiplexer: it keeps several terminal windows and panes alive inside one session. Mine held real agent roles, my own working windows, and the agent doing the test. So the plain-English version is simple: the agent tested the building’s door controls by locking itself inside the control room.

I had made the dangerous choice for a reason. The script moved messages between tmux panes, and I wanted a reality check against actual tmux instead of another fake. I still believe that part. A test that ends at a mock has not touched the boundary it claims to verify.

My wrong turn was assuming “real” had to mean “live.”

The missing target was enough

The test created throwaway windows in the default tmux session and sent keystrokes to them. Most commands named a target pane with -t. One test did not.

Without that target, tmux chose its current pane. In this case, “current” meant the agent’s own Claude Code pane. The test typed where the tester was running, disrupted the process, and the run ended in a forced kill. The same ambient-target trap had already taught me to ask the pane you’re in, not the one tmux is looking at. I had fixed identity lookup, then repeated the underlying mistake in a test harness.

The crash made cleanup unreliable too. Throwaway windows survived because the process that was supposed to remove them was gone. A test failure had become damage to the workspace around the test.

That is the useful distinction: realistic testing and shared-state testing are not the same thing.

Give the test its own tmux server

Tmux can address a named socket, the local connection point a client uses to reach a particular tmux server. The -L flag picks that socket. A test started with tmux -L a2atest gets a separate server, separate sessions, and separate windows from the default server holding the real work.

The smallest safe shape is this:

socket=a2atest
trap 'tmux -L "$socket" kill-server 2>/dev/null || true' EXIT

tmux -L "$socket" new-session -d -s test
tmux -L "$socket" new-window -t test -n receiver
tmux -L "$socket" send-keys -t test:receiver 'printf received' Enter

A shell trap is a cleanup instruction that runs when the script exits. Here it destroys the entire disposable tmux server in one command. If a test makes five bad windows, there is no list to reconstruct and no chance of selecting a real pane by mistake. The namespace — the isolated set of names the test can see — contains nothing worth preserving.

The committed test script already had its own session plus exit cleanup. Running that was safer than improvising new-window and send-keys commands in the live session. The ad-hoc version felt faster right up to the point where it killed the worker and created a manual cleanup job.

The Lifeboat Rule

I now call this the Lifeboat Rule: never test destructive controls inside the runtime keeping the tester alive.

Tmux made the failure obvious, but the rule is wider. If an agent is testing a process manager, terminal session, or shell that it depends on, “use the real thing” is incomplete advice. Use a real thing in a disposable namespace. The test should be able to destroy everything it can reach without destroying itself or somebody else’s work.

That is also why isolation checks have to prove the property you care about. With Git worktrees, the path you are standing in matters more than the shared Git metadata. With tmux, the socket you address matters more than the fact that the command says tmux.

If cleanup in a live session is ever unavoidable, I no longer delete by a guessed window name. I list the suspects, verify each one is a plain shell rather than a real Claude session, and remove them by pane identifier or descending window index so later numbers cannot shift under the command.

Better yet, I keep the test out of the live session and never need that cleanup at all.