Windows でも動く、のタネ明かし
ccmux は単一バイナリで Windows / macOS / Linux どれでも動く。でも「シェルを動かす仕組み」は OS ごとに全然違う。どう隠してるか。
ccmux は、どの OS でも同じ 2MB のバイナリひとつ。Windows でも WSL なしで動きます。
でも実のところ、シェルと会話する仕組みは OS ごとにてんで違うんです。そのギャップを何で埋めているかという話。
「シェルに会話してもらう」とは
bash とか zsh は、相手が人間のターミナルじゃないと察すると挙動を変えます:
- プロンプト (
$) を出さなくなる - 色を省く
- 行ごとの入出力を諦めて、全部まとめてしまう
パイプ (ls | grep foo) にすると色が消えるの、経験ありませんか? あれは bash が「相手はターミナルじゃなくてパイプだな」と気づいて、出力の見た目を変えているから。
ccmux のようなツールがシェルを内部で動かす時、シェルにそのことを気づかせずに**「いや、ちゃんとターミナルで動いてるよ」と信じさせる**必要があります。そのための仕組みが PTY(pseudo-terminal、擬似端末)。
OS ごとの実態
PTY の実装は、OS ごとに別物です:
| OS | 実装 |
|---|---|
| Linux | openpty() + fork() + setsid() |
| macOS | 同じような Unix 系 API |
| Windows 10+ | ConPTY(CreatePseudoConsole) |
ConPTY は 2018 年の Windows 10 1809 で追加された、比較的新しい仕組み。それ以前は WinPTY というサードパーティ実装が必要で、この出現が「Windows でも tmux 的なものが作れる」時代を開きました。
portable-pty が全部飲み込んでくれる
Rust には portable-pty というクレートがあって、OS ごとの差を吸収してくれます。wezterm(Rust 製のターミナルエミュレータ)の作者が作ってるもので、完成度が高い。
同じ API で、裏では OS ごとに違うものが動きます:
use portable_pty::{native_pty_system, CommandBuilder, PtySize};
fn spawn_shell(rows: u16, cols: u16) -> anyhow::Result<...> { let pty = native_pty_system(); // ← OS を自動判定 let pair = pty.openpty(PtySize { rows, cols, pixel_width: 0, pixel_height: 0 })?;
let shell = detect_shell(); let mut cmd = CommandBuilder::new(&shell); cmd.env("TERM", "xterm-256color"); cmd.env("CCMUX", "1"); // ← 後で説明
let child = pair.slave.spawn_command(cmd)?; drop(pair.slave); // slave は子プロセスが持つ。親では閉じる。
let writer = pair.master.take_writer()?; let reader = pair.master.try_clone_reader()?;
// ...}#[cfg(windows)] も #[cfg(unix)] も、一度も書いていません。これがクロスプラットフォームの気持ちよさ。
どのシェルを起動するか
ccmux は OS ごとにシェルの優先順位を変えます。
Windows:
- Git Bash (
C:\Program Files\Git\bin\bash.exe) を最優先 - PATH 上の
bash - 最終手段で PowerShell
Windows で Git Bash を優先している理由は、Claude Code を含む多くの Unix 系 CLI が bash 前提に作られているから。PowerShell だと ls -la や grep がそのまま通らない場面が多くて不便です。
Unix系(macOS / Linux):
$SHELL環境変数(ユーザーの普段使いのシェル)- 無ければ
/bin/sh
シェルに「今どこにいるか」を聞く小ワザ
シェルで cd しても、ccmux からはそのイベントが見えません。シェルは黙って自分の内部状態を変えるだけ。
そこでプロンプトが出るたびに、シェル自身から現在のパスを放送してもらうように仕込みます:
let setup = " __ccmux_osc7() { \ printf '\\033]7;file://%s%s\\007' \"$HOSTNAME\" \"$PWD\"; \}; \PROMPT_COMMAND=\"__ccmux_osc7;${PROMPT_COMMAND}\"; \clear\n";writer.write_all(setup.as_bytes())?;\033]7;file://...\007 は OSC 7 という特殊なエスケープ。「今いるディレクトリはここ」とターミナルに伝える合言葉です。
ccmux はこれを vt100 パーサで拾って、ペインごとの cwd を最新に保ちます。ファイルツリーや新しいペインの起動ディレクトリが、**自動で「今の場所」**に追従するのはこのおかげ。
ちなみに、setup の先頭に スペース を入れているのは、bash の
HISTCONTROL=ignorespaceが効いてる環境で履歴を汚さないため。こういう細かい気遣いが効きます。
ネストした起動を拒否する
ccmux の中でさらに ccmux を起動したら、PTY の中で PTY が走ることになって入力が二重処理され、キー入力が壊れて動かなくなります。
これを防ぐため、子シェルに CCMUX=1 という環境変数を仕込んでおいて、ccmux 自体が起動時にチェックします:
if std::env::var("CCMUX").is_ok() { eprintln!("ccmux: already running inside a ccmux pane."); std::process::exit(1);}「なぜか動かない」系のバグを事前に潰せる、地味だけど価値ある一行。
クロスプラットフォームの PTY は普通に書こうとすると #[cfg(...)] が大量に出てきて辛いんですが、portable-pty に任せると一切出てこない。ccmux が「Windows でも動く」のは、小さいなりにこのクレートの恩恵が大きいです。