← Learnings

You can't move a terminal, so move the session

I had four Claude Code sessions open in iTerm tabs, all of them mid-task, and then I needed to leave the desk. Every one was reachable from exactly one place: that laptop, that window, that chair.

So I built claude2tmux

One command reads the tabs of the iTerm2 window you ran it from, finds the Claude sessions inside them, and moves each one into tmux. Same working directory, same conversation. After that you can detach, shut the laptop, and pick all of them back up over SSH from another machine.

Running it bare changes nothing. It prints a table and stops. --go is the flag that actually performs the move.

zsh
$ claude2tmux

  iTerm2 window 1, 5 tab(s) -> tmux session 'claude': (1 other iTerm2 window not touched)

  MOVE   tab 1    ttys000  pid 39902  -> claude:api
           ~/code/api  add retry backoff to the webhook sender
  STAGE  tab 2    ttys001  pid 36339  -> claude:dotfiles
           ~/code/dotfiles  this session
  --     tab 3    ttys014  zsh    no Claude session
  MOVE   tab 4    ttys003  pid 30259  -> claude:web
           ~/code/web  login page flashes light before dark loads
  --     tab 5    ttys015  tmux   running tmux — its sessions are already in tmux

  MOVE  (2) SIGTERMed, then relaunched as claude --resume <id>.
        The iTerm2 tab stays open — only the Claude process is stopped.
  STAGE (1) window built, command typed but not entered — you quit
        the original and press Enter. Never killed for you.
FIG 1 · THE DEFAULT INVOCATION CHANGES NOTHING

Every tab gets a line, including the ones where the answer is nothing. That is deliberate: a tool that skips something and says so is fine, and a tool that drops something and prints nothing teaches you to trust a listing that is lying by omission.

MOVE gets killed and reopened in tmux. The tab itself stays open — only the Claude process inside it is stopped, so the tab drops back to a shell prompt with its scrollback intact. -- is a tab with nothing to move: a shell, an editor, or tmux.

STAGE is the session you are typing in right now. It cannot kill itself, because that would stop the migration halfway through. So it opens the tmux window and types the resume command there without pressing Enter. You quit that tab yourself, switch over, and press it. Anything whose session id cannot be proven is staged the same way, with a bare claude --resume that opens Claude Code's own picker.

Why it kills your session

Because it has to. On Linux you would reach for reptyr, which attaches to a running process, opens a new terminal, and swaps the file descriptors underneath the program without it noticing. macOS has no working equivalent: a process keeps the controlling terminal it was born with, for as long as it lives.

So the honest description of this tool is not "move my sessions into tmux". It is "kill my sessions and convincingly pretend I didn't."

The pretending works because Claude Code already writes everything down. Every message is appended to a transcript file as it happens, not at exit — one file per session, named by session id, under ~/.claude/projects/. That changes what the running process is worth. It is not the session. It is the current reader of a file that already exists on disk. Kill the reader, start a new one pointed at the same file, and the conversation comes back.

CLAUDE2TMUXBASH
# which directory the session is in — macOS makes you ask lsof
cwd=$(lsof -a -p "$pid" -d cwd -Fn | sed -n 's/^n//p' | head -1)

# kill the reader, start a new one on the same file
kill -TERM "$pid"
tmux new-window -d -t "=claude:" -n "$name" -c "$cwd"
tmux send-keys -t "=claude:$name" "claude --resume $sid" C-m
FIG 2 · THE ENTIRE PREMISE, IN FOUR LINES

Everything already written survives. A tool call that was in flight at the moment of the kill does not. That is the trade, and it is why the default invocation only prints a table. If it is not an acceptable trade for one particular session, --no-kill builds that session's tmux window and stages the resume command without touching the original.

One Claude session: the iTerm tab is killed, and its transcript file on disk is what carries the conversation into a new tmux window. ITERM2 TAB PID 39902 · TTYS000 TMUX WINDOW CLAUDE:API <SESSION-ID>.JSONL THE PART THAT SURVIVES SIGTERM APPENDED ALL ALONG CLAUDE --RESUME
FIG 3 · ONE SESSION MOVING. NOTHING GOES SIDEWAYS; IT GOES THROUGH THE DISK

How it knows which session is which

This is the part that has to be exactly right, because the failure mode is not an error message — it is resuming the wrong conversation, or killing a session that was never yours to touch. So nothing here is inferred. The chain is four hops, and every hop is a fact somebody else already wrote down.

tab → tty → the claude on that tty → its registry record → its id.

It starts by asking iTerm2, over AppleScript, for the tabs of the current window. Each one hands back its tty, which is the honest link between a tab you can see and a process you can signal.

CLAUDE2TMUXAPPLESCRIPT
tell application "iTerm2"
  repeat with t in tabs of current window
    repeat with s in sessions of t
      log (tty of s)      -- /dev/ttys000
    end repeat
  end repeat
end tell
FIG 4 · THE QUESTION, ASKED OF SOMETHING THAT KNOWS THE ANSWER

The session for a tab is then the claude process whose controlling terminal is exactly that tty, ignoring any process that has another claude above it in the parent chain — those are subagents running inside a session, not sessions of their own.

Scoping to real tabs is doing more work than it looks like. Two whole categories of mistake stop being possible, rather than being filtered out after the fact. A Claude desktop-app or VS Code session runs on a pty that is not an iTerm2 tab, so it can never be picked up and SIGTERMed out from under an app you were not even looking at. And a tab running tmux holds its Claude sessions on pane ttys rather than the tab's own tty, so anything already in tmux is invisible to the scan.

The last hop is the session id, and it is not something to work out from the outside — every running session already writes its own facts down, keyed by process id.

~/.claude/sessions/39902.jsonJSON
{ "pid": 39902,
  "sessionId": "9f3c1e2a-4b7d-4c2e-9a11-6f0e5d3c8b42",
  "cwd": "/Users/me/code/api",
  "entrypoint": "cli",
  "kind": "interactive",
  "procStart": "Mon Aug 17 21:00:23 2026" }
FIG 5 · THE ANSWER, WRITTEN DOWN, KEYED BY THE ONE THING I ALREADY HAD

sessionId is the process's own id, which matters more than it sounds: several sessions can share one working directory, so anything that reasons from directories will happily hand you your neighbour's conversation. entrypoint is cli, claude-desktop or claude-vscode, which is what the --system mode below uses to stay out of the app's business.

procStart is the subtle one. A registry file outlives a process that was hard-killed, and process ids get reused, so a stale record can describe a pid that now belongs to something else entirely. Comparing that timestamp against the process's real start time is what stops a long-dead session from being resurrected under a new pid. It is recorded in UTC while ps -o lstart= prints local time, which is a six-hour discrepancy on my machine and looks exactly like a bug until you convert one of them.

Two guarantees ride on top, both the same instinct as the table in FIG 1. A session is only offered for --resume if its transcript actually exists on disk, so nothing gets killed that cannot be brought back. And two live processes claiming one id are reported rather than quietly opened as two windows onto one conversation.

When there is no iTerm2 to ask

Run it from Terminal.app, Ghostty, an SSH session or a cron job and there are no tabs to enumerate. --system is that mode, and it is also what happens automatically when iTerm2 is not running: walk every Claude process on the machine instead, keeping the ones whose registry record says entrypoint: cli, that have a controlling terminal, and that are not already sitting on a tty tmux is driving.

What it will not do is quietly switch to that mode. If iTerm2 is running but reports nothing readable — one window whose session has ended is enough to make the AppleScript throw — the run stops and says so rather than falling back, because silently widening what a tool operates on is the same class of bug as killing the wrong session.

Get it

INSTALLSH
# Homebrew
brew tap daksh-gargas/tap
brew install claude2tmux

# or take the one file straight from the repo
curl -fsSL https://raw.githubusercontent.com/daksh-gargas/claude2tmux/main/claude2tmux \
  -o /usr/local/bin/claude2tmux && chmod +x /usr/local/bin/claude2tmux
FIG 6 · REQUIRES MACOS, TMUX, PYTHON3 · MIT

It is 721 lines of bash at github.com/daksh-gargas/claude2tmux, and it exists because a process cannot be handed a new terminal, but a transcript can be handed to a new process.

TOOL: CLAUDE2TMUX ↗