macOS · zsh · tmux

Your Claude sessions, reachable from anywhere.

You start three Claude Code sessions in iTerm, get deep into all of them, and then realise none are reachable from anywhere but that one window. claude2tmux moves them into tmux — detach, close the laptop, pick them back up over SSH.

claude2tmux --list
$ claude2tmux

  Sessions that will move into tmux session 'claude':

  MOVE   ttys000  pid 39902  -> claude:D4G4
         ~/GitHub/D4G4  People don't realise how crappy their sound is…
  STAGE  ttys001  pid 36339  -> claude:dg
         ~  give me a flag which lists the sessions that will be moved
  MOVE   ttys002  pid 30842  -> claude:Wildr
         ~/GitHub/Wildr  Theme set to auto
  MOVE   ttys003  pid 30259  -> claude:First-Ink
         ~/GitHub/D4G4/First-Ink  lets make a new build
  MOVE   ttys005  pid 57289  -> claude:First-Ink-2
         ~/GitHub/D4G4/First-Ink  commit your work to main

  MOVE  (4) SIGTERMed, then relaunched as claude --resume <id>.
  STAGE (1) window built, command typed but not entered — you quit the
        original and press Enter. Never killed for you.

  Listing only. Run --go to perform it.

The catch, up front

It does not move the running process. A live process's controlling terminal cannot be reassigned on macOS — the Linux tool for that (reptyr) works by ptrace-ing the target and swapping its TTY, and there is no working macOS equivalent.

So claude2tmux migrates the session, not the process. Claude Code appends every message to ~/.claude/projects/<slug>/<id>.jsonl as it happens, so killing a session and running claude --resume <id> inside tmux restores the full conversation, working directory, and history.

What survives: everything already written to the transcript.
What is lost: a tool call that was in flight at the moment of the kill.

If that trade isn't acceptable for a given session, --no-kill builds its tmux window and stages the resume command without touching the original — you quit it yourself once it's idle.

Install

Homebrew

$ brew tap daksh-gargas/tap $ brew install claude2tmux

Direct from GitHub

One file, no dependencies beyond tmux and python3.

$ curl -fsSL https://raw.githubusercontent.com/daksh-gargas/claude2tmux/main/claude2tmux \ -o /usr/local/bin/claude2tmux && chmod +x /usr/local/bin/claude2tmux

Or grab the latest release tarball and install -m 755 claude2tmux /usr/local/bin/.

Optional: a c2t shortcut

$ claude2tmux --install-c2t && exec zsh

Writes a marked block to ~/.zshrc, so re-running updates it in place instead of appending a second copy. It backs the file up first, and refuses to install if you already define c2t elsewhere. --uninstall-c2t restores the file byte-for-byte.

Use

$ claude2tmux # list what would move — the default, changes nothing $ claude2tmux --go # do it

The listing states a disposition per session, so what actually moves is never something you have to infer.

DispositionMeaning
MOVE SIGTERMed, then relaunched as claude --resume <id> in a tmux window at the same cwd.
STAGE Window built, resume command typed but not entered. Never killed for you.
SKIP Already inside a tmux pane, or has no controlling terminal. Counted, not listed — there is nothing to do.

A session with no controlling TTY is not a terminal session at all — the VS Code or JetBrains extension host, a claude -p run, a hook. There is nothing to migrate, and SIGTERMing one would kill a session you cannot see in any tab, so they are excluded before anything else happens.

The session you run the command from is always STAGE, never MOVE — killing it would abort the migration mid-run. Quit that tab yourself, switch to its window, press Enter.

Flags

-l, --list list every session and its disposition; changes nothing --plain with --list: tab-separated for scripting --go perform the migration --no-kill with --go: build the windows, leave the originals running -s, --session N target tmux session name (default: claude) --install-c2t add a `c2t` shell function to ~/.zshrc --uninstall-c2t remove it again -h, --help usage

Bare claude2tmux is identical to --list, and --list overrides --go — asking to see the plan never executes it. An unrecognised flag exits 2 rather than being ignored, since a typo next to --go would otherwise be a bad surprise.

How a session is detected

A candidate has to survive four filters. Three are structural facts; the fourth is a heuristic, and it's worth knowing which is which.

1. argv[0] basename is exactly claude
Not a substring match on the command line. A running Claude session surrounds itself with shell wrappers whose command lines contain "claude" — snapshot sourcing, /tmp/claude-*-cwd redirects — and grepping would sweep all of them in. This uses shell parameter expansion rather than basename, because login shells are named -zsh and basename parses that as a flag.
2. No claude ancestor
A ppid walk, 25 levels. Subagents and teammates are always spawned under a parent Claude, so this leaves only top-level sessions. It also identifies "self": the nearest claude ancestor of $$.
3. Not on a tmux pane TTY
This cannot be inferred from ancestry — a pane's shell is parented to the tmux server, never to a Claude process — so it is checked against tmux list-panes -a -F '#{pane_tty}' directly. Without it, sessions already safely in tmux get killed and rebuilt for no reason.
4. A cwd and a session ID both resolve
cwd via lsof, since macOS has no /proc. Session ID from --resume <uuid> in argv when present — definitive, because resuming appends to the same transcript rather than forking a new one.

Filter 4's fallback is the soft spot. For a session started without --resume, the ID is recovered by matching the recorded cwd across transcripts and taking the most recently written. If you ran two sessions in one directory and killed one, the survivor could in principle be handed the dead one's ID. In practice the live session is always the most recently written — but it is inference, not a fact read off the process. --list shows you the resolved ID and the last user message of every session, so you can check before committing.