結論
- tmux のキーバインド・オプション・
@変数・環境変数はサーバのメモリにある。.tmux.confはサーバ起動時に 1 回流すコマンド列で、行を消してsource-fileしても登録済みのものは消えない - ペイン・popup・
run-shell・if-shellのプロセスは、操作中のシェルではなくサーバの環境を継承する。後から足した PATH は届かず、if-shell 'command -v ...'は偽になってブロックを警告なしに捨てる - popup とコマンド付きのペインのシェルは非ログイン・非対話で、zsh なら
.zshenvしか読まない - 存在しない
-cのパス、解決できないdisplay-message -t、.や:を含むセッション名は、どれもその場ではエラーにならない (終了コード 0)。ずれは後で別の場所に出る - 確かめるときは
-L <名前> -f /dev/nullで隔離したサーバを使う。-Lだけだと~/.tmux.confを読む
以下は tmux 3.7c / zsh 5.9.2 で実測した。コード例はすべて -L demo の使い捨てサーバに対して打つので、普段のサーバには触れない。
前提: tmux コマンドはサーバへの依頼でしかない

tmux はクライアント・サーバ型になっている。端末で打つ tmux ... はクライアントで、ソケット越しにサーバへコマンドを送って終わる。
キーバインド・オプション・セッション・環境変数はサーバのメモリにあり、サーバが終わるまで残る。
.tmux.confを読むのはサーバの起動時だけ。新しいセッションを作っても読み直さない。source-fileは書かれたコマンドをもう 1 度流すだけ- ペイン・popup・
run-shell・if-shellのプロセスを起動するのはサーバ。操作中のシェルの子ではない
以下の罠は、どれもこの 2 点のどちらかから来る。
設定の行を消しても、キーバインドは消えない

source-file はファイルに書かれたコマンドを実行する。行をコメントアウトすると、その行を実行しなくなるだけで、登録済みのキーバインドを消す命令にはならない。
オプションも同じで、set -g の行を消しても既定値には戻らない。
cd "$(mktemp -d)"
printf '%s\n' 'bind -n M-y display-message probe' 'set -g status-left "[probe]"' > t.conf
tmux -L demo -f t.conf new-session -d
sed -i 's/^/# /' t.conf # 2 行ともコメントアウト
tmux -L demo source-file t.conf
tmux -L demo list-keys -T root | grep M-y # bind-key -T root M-y display-message probe
tmux -L demo show -gv status-left # [probe]
tmux -L demo kill-server消すなら unbind -n M-y や set -gu status-left を明示的に実行するか、kill-server でサーバを作り直す。
サーバオプション (set -s) も source-file で反映される点は set -g と同じで、別扱いは無い。
PITFALL: 消えたかはファイルではなく tmux list-keys と tmux show -g で確かめる。set -g mouse on のような別の設定で同じ操作ができていると、古いバインドが残っていても気付かない。
@ 変数はサーバの寿命だけ生きる

set -g @name value のユーザオプションもサーバのメモリにある。source-file では消えず、kill-server で消える。
この寿命は「サーバ 1 回につき 1 度だけ実行したい処理」の条件と一致するので、そのまま実行済みフラグに使える。
if-shell -F '#{!=:#{@bootstrapped},1}' {
set -g @bootstrapped 1
run-shell -b 'do-something-once'
}.tmux.conf は source-file のたびに頭から流れるので、素で書いた run-shell は再読込のたびに走る。
起動して source-file を 2 回打つと、素の run-shell は 3 回、フラグ付きは 1 回走った。kill-server して起動し直すと、フラグ付きもまた 1 回走る。
PITFALL: run-shell -b で起こしたプロセスのうち、シェルの子として動くものは kill-server で終わらない。一緒に終わるのは tmux が起動した sh -c までで、その子は孤児として残る (この環境では PPID が 1 になった)。
tmux -L demo -f /dev/null new-session -d
tmux -L demo run-shell -b 'sleep 321; true' # sleep は sh -c の子として動く
tmux -L demo kill-server
ps -o pid,ppid,args -C sleep | grep 321 # 残っているrun-shell -b 'sleep 322' のように単純コマンド 1 つなら一緒に終わった。この環境の /bin/sh は bash で、sh -c がそのコマンド自身に置き換わるため。
daemon を起こす設定を試したあとは、kill-server とプロセスの後始末の両方が要る。
設定の読み込み中は、まだクライアントがいない

サーバは .tmux.conf を読み終えてから、起動したクライアントを attach させる。
設定から switch-client のようにクライアントが要るコマンドを呼ぶと、相手がいない。
run-shell(-bなし) は設定の読み込みを止めて終わりを待つので、中のtmux switch-clientは必ずno current clientで失敗するrun-shell -bは attach と競争になる。後ろに何も無ければ attach が先に終わって成功した (5 回中 5 回)。後ろに時間のかかる行 (run-shell 'sleep 1') を置くとno current clientで失敗した
対策は、attach をポーリングで待ってから動くスクリプトに分けること。
#!/bin/bash
# wait-switch.sh: クライアントの attach を待ってから移動する
for _ in $(seq 50); do
[[ -n "$(tmux list-clients -F '#{client_name}' 2>/dev/null)" ]] && break
sleep 0.1
done
tmux switch-client -t othernew-session -d -s other
run-shell -b '/path/to/wait-switch.sh'PITFALL: 症状は「起動すると意図しないセッションにいる」になる。セッションが消えたのではなく、移動できていない。
-L が分けるのはソケットだけ
tmux -L <名前> は別のサーバを立てるが、変わるのはソケットのパスだけ。設定ファイルは同じ ~/.tmux.conf を読み、子プロセスの環境も変わらない。
設定の run-shell が daemon を起こしたりセッションを復元したりすれば、検証用のサーバからでも普段のデータディレクトリへ書き込む。
設定を読ませないなら -f /dev/null を足す。設定は読ませたうえで副作用を普段のサーバに限るなら、設定の側でソケット名を見る。
if-shell のシェルコマンドは実行前に format が展開されるので、#{b:socket_path} でサーバ自身のソケット名を判定できる。
if-shell '[ "#{b:socket_path}" = default ]' {
run-shell -b 'start-my-daemon'
}PATH はサーバを起動したときのまま

run-shell と if-shell のシェルはサーバの環境で動く。サーバの環境は、最初にサーバを起動したクライアントから 1 度だけ取り込まれる。
その後で対話シェルに足した PATH (~/go/bin など) は届かない。
cd "$(mktemp -d)"
mkdir bin && printf '#!/bin/sh\necho mytool\n' > bin/mytool && chmod +x bin/mytool
cat > t.conf <<'EOF'
if-shell 'command -v mytool >/dev/null 2>&1' {
bind-key C-f display-message found
}
EOF
PATH=/usr/bin:/bin tmux -L demo -f t.conf new-session -d # 短い PATH でサーバを起動
export PATH="$PWD/bin:$PATH" # 対話シェルにだけ足す
command -v mytool # 見つかる
tmux -L demo run-shell 'command -v mytool || echo NOTFOUND' # NOTFOUND
tmux -L demo list-keys -T prefix | grep C-f # 何も出ない
tmux -L demo kill-serverif-shell が偽でもエラーは出ない。ブロックが丸ごと無かったことになり、「設定を書いたのに何も起きない」としか見えない。
条件の手前でサーバの PATH を補えば通る。今の値は tmux show-environment -g PATH で見る。
run-shell 'tmux set-environment -g PATH "$HOME/go/bin:$PATH"'
if-shell 'command -v mytool >/dev/null 2>&1' {
bind-key C-f display-popup -E 'mytool picker'
}キーバインドから起動した new-window と display-popup も、PATH はサーバのものだった。
例外は、端末で tmux new-window のように打った場合 (attach していないクライアントからの実行) で、新しいペインの PATH だけは打ったシェルの PATH になる。PATH 以外の変数は引き継がない。
popup とコマンド付きのペインは .zshrc を読まない
tmux が起動するシェルの種類は、起動の仕方で変わる。zsh の各設定ファイルに印を書かせて、キーバインドから起動して測った (new-window は端末から打っても同じ結果だった)。
| 起動の仕方 | ログイン | 対話 | 読んだファイル |
|---|---|---|---|
new-window (コマンドなし) | yes | yes | .zshenv .zprofile .zshrc .zlogin |
new-window 'cmd' | no | no | .zshenv |
display-popup -E 'cmd' | no | no | .zshenv |
display-popup -E (コマンドなし) | no | yes | .zshenv .zshrc |
比較: zsh -lc 'cmd' | yes | no | .zshenv .zprofile .zlogin |
zsh は .zshenv を毎回、.zprofile と .zlogin をログイン時、.zshrc を対話時に読む。
PATH を .zshrc で組み立てていると、popup で起動したコマンドにはその分が無く、手で打てば動くコマンドが command not found になる。
zd=$(mktemp -d)
for f in .zshenv .zprofile .zshrc .zlogin; do echo "echo $f >> $zd/log" > "$zd/$f"; done
ZDOTDIR=$zd tmux -L demo -f /dev/null new-session -d 'sleep 60'
tmux -L demo set -g default-shell "$(command -v zsh)"
: > "$zd/log"; tmux -L demo new-window -d 'true'; sleep 0.5; cat "$zd/log" # .zshenv
: > "$zd/log"; tmux -L demo new-window -d; sleep 0.5; cat "$zd/log" # 4 つとも
tmux -L demo kill-server全部の起動で効かせたい PATH は .zshenv に置く。popup だけなら zsh -ic 'cmd' で包むと .zshrc を読む。
SSH_CONNECTION は attach したときにしか変わらない

update-environment に挙がった変数 (既定で SSH_CONNECTION SSH_AUTH_SOCK DISPLAY など) は、クライアントが attach した瞬間に、そのクライアントの値でセッション環境を上書きする。
セッション環境は全クライアントで共有なので、次の順に動くとずれる。
- ローカルの端末で attach したまま
- SSH から同じセッションに attach し、抜ける
- ローカルの端末でそのまま作業を続ける
3 の時点でもセッション環境には SSH 側の値が残り、以後に開いたペインはそれを受け取る。ローカルで attach し直すと消える。
tmux show-environment SSH_CONNECTION
# 1 の時点: -SSH_CONNECTION (先頭の - は「この変数を消す」の印)
# 3 の時点: SSH_CONNECTION=192.0.2.1 50000 192.0.2.2 221 つのペインを複数のクライアントが同時に見られるので、「このペインは SSH 越しか」に答えは 1 つに決まらない。
今操作しているクライアントで判定するなら、セッション環境ではなくクライアントのプロセスの環境を読む。Linux なら /proc から読める。
pid=$(tmux display-message -p '#{client_pid}')
tr '\0' '\n' < "/proc/$pid/environ" | grep '^SSH_CONNECTION='存在しない -c のパスは $HOME になる

new-session / new-window / split-window の -c に存在しないディレクトリを渡しても、終了コードは 0 で、ペインは $HOME で開く。呼び出し元のカレントディレクトリにもならない。
cd /tmp
tmux -L demo -f /dev/null new-session -d -s s -c /nonexistent/path; echo "rc=$?" # rc=0
tmux -L demo list-panes -s -t '=s' -F '#{pane_current_path} #{pane_start_path}' # /home/you /nonexistent/path
tmux -L demo kill-server指定したパスは #{pane_start_path} に残る。
PITFALL: 作業ディレクトリを定期的に保存するセッション復元ツールと組み合わせると、元に戻らない形で壊れる。
保存したディレクトリが消える => 復元で $HOME に開く => 次の保存で $HOME が記録される、と進むと元のパスは失われ、以後は「パスが存在しない」では検出できない。復元の前に判定する。
display-message -p -t は解決できない対象を空で返す
display-message の -t は、対象が見つからなくてもエラーにならない。format は空文字になり、終了コードは 0 になる。
セッション名に完全一致の = を付けた -t '=probe' は pane の指定として解決されず、この形になる。末尾に : を付ければ解決する。
tmux -L demo -f /dev/null new-session -d -s probe
tmux -L demo display-message -p -t '=probe' '#{session_name}|#{session_windows}' # |
tmux -L demo display-message -p -t '=probe:' '#{session_name}|#{session_windows}' # probe|1
tmux -L demo display-message -p -t 'nosuch' '#{session_name}'; echo "rc=$?" # (空行) rc=0
tmux -L demo kill-server「セッションを kill してよいか」のようなガードをここで組むと、全部が空文字になって判定が崩れる。セッションの属性は、列挙系のコマンドに -F を渡して読む方が確実。
tmux list-sessions -f '#{==:#{session_name},probe}' -F '#{session_attached}'
tmux list-panes -s -t '=probe' -F '#{pane_current_command}' # 1 行 = 1 ペインPITFALL: tmux コマンドを偽物に差し替えたユニットテストでは、この挙動は出ない。実物の tmux に当てて確かめる。
display-popup -E は標準出力を捨てる

popup のコマンドはサーバが起動する別の端末で動く。標準出力は popup の画面に出て、閉じると消える。呼び出し元のシェルは $(...) で受け取れない。
# tmux の中のシェルで打つ
out=$(tmux display-popup -E 'echo hello'); echo "[$out] rc=$?" # [] rc=0「パスを出力して、呼び出し元が cd する」スクリプトを popup に載せ替えると、ここで止まる。
popup から呼び出し元のペインへ作用させるには send-keys を使う。popup の中では $TMUX_PANE が空で、display-message -p '#{pane_id}' が呼び出し元のペインを返す。
pane=$(tmux display-message -p '#{pane_id}')
tmux send-keys -t "$pane" "cd $(printf '%q' "$dst")" EnterPITFALL: パスに空白や引用符が入ると、send-keys で送った文字列がそのままシェルに解釈される。printf '%q' でクォートしてから送る。
-F の \t はタブにならない
format はバックスラッシュのエスケープを解釈しない。-F '#{a}\t#{b}' は \t の 2 文字をそのまま出す。
tmux -L demo -f /dev/null new-session -d -s s
tmux -L demo list-sessions -F '#{session_name}\t#{session_windows}' # s\t1区切りに頼らず、空白を含みうる値を最後に回して、read の最後の変数に残り全体を吸わせる。
tmux -L demo rename-window -t '=s:0' 'my window'
tmux -L demo list-windows -t '=s' -F '#{window_index} #{window_panes} #{window_name}' |
while read -r idx panes name; do echo "$idx|$panes|$name"; done # 0|1|my window
tmux -L demo kill-server. と : を含むセッション名は、作れるが名前で指せない

new-session -s 'a.b' は成功し、名前もそのまま残る。ところが -t の文法では : が session と window を、. が window と pane を区切るので、後から名前で指せない。
tmux -L demo -f /dev/null new-session -d -s keep
tmux -L demo new-session -d -s 'a.b'; echo "rc=$?" # rc=0
tmux -L demo has-session -t 'a.b' # can't find pane: b
tmux -L demo has-session -t 'a.b:'; echo "rc=$?" # rc=0
tmux -L demo kill-server| 名前 | 作成 | -t 名前 | -t 名前: |
|---|---|---|---|
a.b | 成功 | can’t find pane: b | 指せる |
.dot | 成功 | can’t find pane: dot | 指せる |
c:d | 成功 | can’t find window: d | can’t find window: d: |
release/1.0 | 成功 | 指せる | 指せる |
c:d は c:d =c:d c:d: =c:d: のどれでも指せなかった。list-sessions -F '#{session_id}' で取れる $1 のような ID なら指せる。
ディレクトリ名やブランチ名からセッション名を作るなら、作る前に置き換える。
name=${name//[^A-Za-z0-9_-]/-} # 英数字・_・- 以外を - に
name=${name#-} # 先頭の - を落とすまとめ
| 罠 | 症状 | 対策 |
|---|---|---|
設定の行を消して source-file | バインドとオプションが残る | unbind / set -u を打つか kill-server |
@ 変数 | source-file で消えない | 実行済みフラグに使う |
run-shell -b の孫プロセス | kill-server 後も残る | プロセスも後始末する |
設定から switch-client | no current client | attach をポーリングで待つ |
-L <名前> | ~/.tmux.conf を読む | -f /dev/null か #{b:socket_path} で分岐 |
| サーバの PATH | if-shell がエラーなしに偽 | set-environment -g PATH で補う |
| popup / コマンド付きペイン | .zshrc を読まない | PATH は .zshenv に置く |
update-environment | SSH の値がローカルに残る | #{client_pid} の環境を読む |
-c <存在しないパス> | $HOME で開き rc=0 | 復元前に存在を確かめる |
display-message -p -t '=名前' | 空文字で rc=0 | =名前: か list-sessions -F |
display-popup -E | 標準出力が消える | send-keys で呼び出し元へ送る |
-F の \t | \t の 2 文字が出る | 最後の項目に空白を吸わせる |
. : 入りのセッション名 | 作れるが指せない | 作る前に置き換える |