ちなみに

火曜日の空は僕を押しつぶした。

iPhoneから開発できるようにした

EchoというiOS向けのSSHクライアントが出たので、iPhoneからMacBookSSH接続して開発できるようにした。接続にはTailscaleを使っている。

EchoはGhosttyのターミナルエンジンを使っていて、TUIの表示がめちゃくちゃ綺麗。$2.99の買い切りでサブスクじゃないのも良い。

tmuxのセッションにそのままアタッチできるので、MacBookで作業していた続きをiPhoneから継続できる。Claude Codeの操作もふつうにできた。

セットアップはTailscaleを両デバイスに入れてMacBookのリモートログインを有効にするだけ。ただSSH鍵の設定がちょっと面倒で、Echo側では鍵の生成ができないのでmacOS側で生成した秘密鍵を持ち込む必要がある(しかもパスフレーズなしじゃないとダメ)。

ssh-keygen -t ed25519 -f ~/.ssh/echo_key -N ""

生成した秘密鍵の中身をEchoのSSH Keys設定からインポートすればいい。

MacBookの蓋を閉じて運用したい場合はスリープを無効にしておく。蓋を閉じるとTailscaleがオフラインになるので。

sudo pmset -c sleep 0
sudo pmset -c disablesleep 1

ところで現時点ではEchoで日本語入力ができない。日本語を打ちたいときはiOSの音声入力を使うとわりとなんとかなる。

ソファに寝転がりながらiPhoneで開発できるの、めちゃくちゃ便利。

zerobrewに移行しようとして苦労した

zerobrew はRust製のHomebrew代替。uv がpipを置き換えたのと同じノリでHomebrewを高速化しようというプロジェクトで、ウォームキャッシュなら7.6倍速いらしい。Homebrewも最近は速くなっていたけど、新しいものが気になって試してみた。結果的にめちゃくちゃ苦労した(まだexperimentalなのでこれからという感じ)。

まず最初の壁がprefixの長さ。zerobrewのデフォルトだと /opt/zerobrew/prefix にインストールされるんだけど、いきなりこう言われる。

error: store corruption: zerobrew prefix "/opt/zerobrew/prefix" (20 bytes)
is longer than "/opt/homebrew" (13 bytes).
On macOS, Mach-O binaries cannot be expanded in-place

macOSMach-Oバイナリはライブラリパスが固定長で埋め込まれている。Homebrewのbottleは /opt/homebrew(13バイト) でビルドされているから、prefixはそれ以下でないといけない。/opt/zerobrew(13バイト) にしても一部のIntel向けbottleは /usr/local(10バイト) なので失敗する。安全策で /opt/zb(7バイト) まで詰めたら、今度は別の問題が起きた。

null-byteパッチングバグ

/opt/zb でインストールしたらtmuxが設定ファイルを読めなくなった。tmux -vvvv のログだと /opt/zb というディレクトリをコンフィグとして読もうとしている。hex dumpで調べた。

0009fdd0: 7420 6861 7665 2025 7300 2f6f 7074 2f7a  t have %s./opt/z
0009fde0: 6200 0000 0000 002f 6574 632f 746d 7578  b....../etc/tmux
0009fdf0: 2e63 6f6e 663a 7e2f 2e74 6d75 782e 636f  .conf:~/.tmux.co
0009fe00: 6e66 3a24 5844 475f 434f 4e46 4947 5f48  nf:$XDG_CONFIG_H
0009fe10: 4f4d 452f 746d 7578 2f74 6d75 782e 636f  OME/tmux/tmux.co
0009fe20: 6e66 3a7e 2f2e 636f 6e66 6967 2f74 6d75  nf:~/.config/tmu
0009fe30: 782f 746d 7578 2e63 6f6e 6600           x/tmux.conf.

/opt/zb の直後に6バイトの \0 がある。元は /opt/homebrew/etc/tmux.conf:~/.tmux.conf:... という1本のC文字列だった。/opt/homebrew(13バイト) → /opt/zb(7バイト) の置換で余った6バイトをnull埋めした結果、文字列が分断されている。C文字列は \0 で終端されるから、tmuxには "/opt/zb" しか見えない。

zerobrewのパッチコードを見るとこう。

contents[i..i + new_bytes.len()].copy_from_slice(new_bytes);  // /opt/zb を書き込み
contents[i + new_bytes.len()..i + old_bytes.len()].fill(0);   // 残り6バイトを\0埋め

prefixだけで独立した文字列なら安全だけど、/opt/homebrew/etc/tmux.conf:... のようにprefixがパスの先頭部分である場合は壊れる。tmux以外でも同じパターンを持つバイナリは全部壊れているはず。

@@HOMEBREW_PREFIX@@が残る

null-byteの件でprefixは元のHomebrewと同じ長さにすべきだとわかったので /opt/zerobrew に戻した。Homebrewのformulaは zb migrate の過程で消してしまっていたので、180個以上のformulaをBrewfileにまとめて zb bundle で入れ直した。

ところがneovimが起動しない。調べたらHomebrewのbottleにビルド時のプレースホルダ@@HOMEBREW_PREFIX@@ が残っていた。brew install ではここが実際のprefixに置換されるんだけど、zerobrewはこの置換をちゃんと実装していない。/opt/zerobrew/Cellar/ 以下で252ファイル、Mach-Oだけで312箇所にプレースホルダーが残っていた。neovim, git, pythonが軒並み壊れた。

install_name_toolcodesign で手動置換するワークアラウンドスクリプトを書いて対応した。

# Mach-Oの依存パスを置換
install_name_tool \
  -change "@@HOMEBREW_PREFIX@@/opt/luv/lib/libluv.1.dylib" \
          "/opt/zerobrew/opt/luv/lib/libluv.1.dylib" \
  /opt/zerobrew/Cellar/neovim/0.11.6/bin/nvim

# 署名し直さないとmacOSにkillされる
codesign --force --sign - /opt/zerobrew/Cellar/neovim/0.11.6/bin/nvim

issue #209 で報告した。メンテナはパッチ方式の限界を認識していて "I have no interest in constantly patching an already subpar approach" とのこと。将来的に自前CDNかソースビルドへの移行を検討しているらしい。

ちなみにzb自体もHomebrewの liblzma に動的リンクされていて、Homebrewのformulaを消した瞬間にzbコマンドが死ぬ(issue #208)。自分がやろうとしていることの被害者になっている。

散々やり直した末に /opt/zerobrew + ワークアラウンドスクリプトでなんとか動いている。速度はめちゃくちゃ速い。ただ upgrade コマンドは未実装だし、パッケージを入れるたびにスクリプトを走らせるのはちょっとつらい。

興味がある人はHomebrewと併用するところから始めるのをおすすめします。

カラーテーマをkanagawaに変えた

長いこと Catppuccinを使っていたけど、飽きてきたのでkanagawaに乗り換えた。ターミナルからエディタまで全部変えた。

kanagawaの落ち着いた和風のトーンがめちゃくちゃ良い。Catppuccinとは全然方向性が違うけど、こっちのほうが今の気分に合っている。

ただ、デフォルトだと白字がちょっと黄色っぽすぎて調節した。リポジトリスクリーンショットを見るとそんなことないので何かミスっている可能性はある(まあ自分好みになったからいいか)。

zellijからtmuxに戻した

去年の3月から zellij をターミナルマルチプレクサとして使っていたけど、約11ヶ月で結局 tmux に戻した。

zellijはRust製で設定がKDLで書けて、UIもモダンでめちゃくちゃ良かった。locked modeをデフォルトにして Ctrl+g でモード切替するスタイルが気に入っていた。floating paneの完成度はtmuxより進んでいたし、Wasmベースのプラグインシステムにも将来性を感じていた。

ただ、細かく改善しようとするとtmuxのほうが圧倒的に小回りが利く。戻した理由は大きく2つある。

1つ目はzellijの開発ペースへの不安。v0.42.2からv0.43.0まで4ヶ月リリースがなかったし、v0.43.1(2025年8月)以降もまた止まっている。メインのターミナル環境を更新の不透明なツールに預けるのはちょっと怖かった。

2つ目は Claude Code との相性。Claude Codeはターミナルで動くAIエージェントなんだけど、tmuxのpane操作やsession管理と組み合わせたい場面がめちゃくちゃ多い。tmuxなら tmux list-panestmux send-keys で外部からセッションを操作できる。zellijはCLIからの制御がまだ弱かった(し、スクリプタビリティ全般がtmuxに比べると足りなかった)。

そしてこのClaude Codeとの相性というのは、単にtmuxを操る側だけの話ではない。tmux configを書く側もClaude Codeに任せられる。実際、今回のリライトはほとんどClaude Codeにやってもらった。「こういう動きにしたい」と伝えるだけでconfigとシェルスクリプトが出てくる。ゼロコンフィグで使いやすいツールという考え方は少し古くなったのかもしれない。細かく設定できるツールをAIで好みに合わせて仕上げる、というのが今のやり方になりつつある。

おすすめの設定

折角なので気に入っている設定を紹介しておく。

Alt+矢印でpane移動して、端まで行ったら隣のwindowへ飛ぶようにしている。zellijの MoveFocusOrTab と同じ動きで、これがないと戻れなかった。

bind -n M-Left run-shell 'cur="#{pane_id}"; tmux select-pane -L; [ "$cur" = "$(tmux display -p "##{pane_id}")" ] && tmux select-window -t :-; true'
bind -n M-Right run-shell 'cur="#{pane_id}"; tmux select-pane -R; [ "$cur" = "$(tmux display -p "##{pane_id}")" ] && tmux select-window -t :+; true'

Alt+c でClaude Codeが動いているpaneにトグルする。どのsessionにいても飛べる。

bind -n M-c run-shell '~/.config/tmux/toggle-claude-pane.sh "#{pane_id}"'

toggle-claude-pane.sh の中身はこんな感じ。今いるpaneがClaude Codeなら前のpaneに戻り、そうでなければ全sessionからClaude Codeのpaneを探して飛ぶ。

#!/bin/bash
cur="$1"
cur_pid=$(tmux display -t "$cur" -p '#{pane_pid}')
if ps -eo ppid,comm | awk -v p="$cur_pid" '$1 == p && $2 == "claude"' | grep -q .; then
  tmux last-window 2>/dev/null || tmux last-pane 2>/dev/null
  exit 0
fi
target=""
while read -r id pid; do
  [ "$id" = "$cur" ] && continue
  if ps -eo ppid,comm | awk -v p="$pid" '$1 == p && $2 == "claude"' | grep -q .; then
    target="$id"; break
  fi
done < <(tmux list-panes -a -F '#{pane_id} #{pane_pid}')
if [ -n "$target" ]; then
  cur_session=$(tmux display -p '#{session_name}')
  target_session=$(tmux display -t "$target" -p '#{session_name}')
  [ "$cur_session" != "$target_session" ] && tmux switch-client -t "$target_session"
  tmux select-window -t "$target"
  tmux select-pane -t "$target"
else
  tmux display "No Claude pane found"
fi

yazi をpopupで開いて、閉じたときにcwdを呼び出し元のpaneに反映する。

bind y display-popup -E -w 90% -h 90% -d "#{pane_current_path}" \
  "tmux new-session '~/.config/tmux/yazi-popup.sh #{pane_id}' \\; set status off \\; set detach-on-destroy on"

yazi-popup.sh はこれだけ。yaziが終了時に書き出すcwdを send-keys で元のpaneに送る。

#!/bin/sh
pane_id="$1"
tmp=$(mktemp -t yazi-cwd.XXXXXX)
yazi --cwd-file="$tmp"
if cwd=$(cat -- "$tmp") && [ -n "$cwd" ] && [ "$cwd" != "$PWD" ]; then
  tmux send-keys -t "$pane_id" "cd \"$cwd\"" Enter
fi
rm -f -- "$tmp"

pane分割時に自動で均等配置にするhook。手動で select-layout -E しなくてよくなる。

set-hook -g after-split-window "select-layout -E"
set-hook -g pane-exited "select-layout -E"

tmuxを使い込んでいるみなさんもぜひ自分のおすすめ設定を教えてください。

worktreeにcdするのをやめた

git worktreeの管理ツールが色々出てきている。git-wtはtmux統合やfzfベースのインタラクティブ選択が便利だし、他にもworktreeの管理をいい感じにするツールはいくつかある。

ただ、こういったツールの紹介を見ていると「worktreeへの移動をどう楽にするか」に注目が集まっている気がする。でも、worktreeにcdする必要ってそもそもあるんだろうか。

worktreeでやりたいことはコードを書くかAIに書かせるかのどちらかで、だったらworktreeの中でエディタやAIツールを直接起動すればいい。gtrはそれができる。

gtr editor my-feature    # エディタで開く
gtr ai my-feature         # AIツールを起動

ディレクトリの移動は発生しない。

僕のグローバル設定はこう。

git config --global gtr.ai.default claude
git config --global gtr.editor.default vscode
git config --global gtr.copy.includedirs .claude

copy.includedirsでworktree作成時に自動コピーするディレクトリを指定できる。.claudeを入れておくとClaude Codeのプロジェクト設定を引き継げるのでめちゃくちゃ便利。

fzfとの組み合わせ

折角なので、zshに定義しているfzf連携も紹介する。worktree一覧をfzfで表示して、キーバインドでエディタやAIを直接起動できるようにしている。

_git_worktree_format() {
  local root=$1
  local git_cmd=$2
  local line path branch mark is_root
  local base_branch=$("$git_cmd" symbolic-ref --quiet refs/remotes/origin/HEAD 2>/dev/null)
  base_branch=${base_branch#refs/remotes/origin/}
  base_branch=${base_branch:-main}

  while read -r line; do
    path=${line%% *}
    branch=${line##*\[}
    branch=${branch%\]*}
    if ! "$git_cmd" -C "$path" diff --quiet HEAD 2>/dev/null; then
      mark="*"
    elif [[ "$branch" != "$base_branch" ]] && [ -z "$("$git_cmd" -C "$path" log "origin/${base_branch}...HEAD" --oneline 2>/dev/null)" ]; then
      mark="x"
    else
      mark=""
    fi
    if [ "$path" = "$root" ]; then
      is_root="(root)"
    else
      is_root=""
    fi
    print "[${branch}]${mark}\t${path}\t${is_root}"
  done
}

_git_worktree_colorize() {
  sed -e $'s/\\[\\([^]]*\\)\\]\\*\\(.*\\)(root)/\e[1;36m[\\1]\e[1;31m*\e[0m\\2\e[90m(root)\e[0m/' \
      -e $'s/\\[\\([^]]*\\)\\]x\\(.*\\)(root)/\e[1;36m[\\1]\e[1;31mx\e[0m\\2\e[90m(root)\e[0m/' \
      -e $'s/\\[\\([^]]*\\)\\] \\(.*\\)(root)/\e[1;36m[\\1]\e[0m \\2\e[90m(root)\e[0m/' \
      -e $'s/\\[\\([^]]*\\)\\]\\*/\e[1;33m[\\1]\e[1;31m*\e[0m/' \
      -e $'s/\\[\\([^]]*\\)\\]x/\e[1;33m[\\1]\e[1;31mx\e[0m/' \
      -e $'s/\\[\\([^]]*\\)\\] /\e[1;33m[\\1]\e[0m /'
}

# alt-w で起動
# enter:cd | C-e:editor | C-a:AI -c | M-a:AI | C-d:delete
git_worktree_list() {
  local worktrees=$(git worktree list)
  local root=$(echo "$worktrees" | head -1 | awk '{print $1}')
  local git_cmd=$(command -v git)
  local result=$(
    echo "$worktrees" | _git_worktree_format "$root" "$git_cmd" | column -ts $'\t' | _git_worktree_colorize |
    fzf --ansi \
      --height 50% \
      --no-sort \
      --header 'enter:cd | C-e:editor | C-a:AI -c | M-a:AI | C-d:delete' \
      --expect ctrl-e,ctrl-a,alt-a \
      --bind 'ctrl-d:execute-silent(branch=$(echo {1} | tr -d "[]*"); git worktree remove {2}; git branch -D $branch)+abort' \
      --preview 'cd {2} && git log --oneline -10 --color=always'
  )

  local key="${result%%$'\n'*}"
  local selection="${result#*$'\n'}"
  local branch=$(echo "$selection" | awk '{print $1}' | sed 's/[][]//g; s/[*x]$//')
  local path=$(echo "$selection" | awk '{print $2}')

  case "$key" in
    ctrl-e)
      BUFFER="git gtr editor '$branch'"
      zle accept-line
      return
      ;;
    ctrl-a)
      BUFFER="git gtr ai '$branch' -- -c"
      zle accept-line
      return
      ;;
    alt-a)
      BUFFER="git gtr ai '$branch'"
      zle accept-line
      return
      ;;
    *)
      if [ -n "$path" ]; then
        BUFFER="cd ${path}"
        zle accept-line
        return
      fi
      ;;
  esac
  zle clear-screen
}
zle -N git_worktree_list
bindkey '^[w' git_worktree_list

同じキーバインドをPR一覧(alt-p)とブランチ一覧(alt-b)にも定義していて、どの入口から入っても「選ぶ→エディタ or AI」の流れになっている(cdはあくまでフォールバック)。

# alt-p: PR一覧 → enter:worktree | C-e:editor | C-a:AI
bindkey '^[p' fzf-pullreq

# alt-b: ブランチ一覧 → enter:switch | C-t:worktree | C-e:editor | C-a:AI
bindkey '^[b' fzf-branch

みなさんもworktree管理ツールを選ぶとき、「cdを楽にする」じゃなくて「cdしなくていい」ほうを試してみてください。

追記

いつの間にか git-wt にも editor を開くコマンドが追加されていた https://github.com/toritori0318/git-wt?tab=readme-ov-file#open-in-editor

2026年の目標と2025年のふりかえり

2026年の目標

怖がられないこと

  • 自分では普通にしているつもりでも、相手からすると怖いと感じることがあるらしい
  • 相手の立場に立った言動ができるようになりたい

2025年のふりかえり

blog.nishimu.land

全力で楽しむ

かなり楽しめた年だったと思う。

暮らし

  • 去年買った家での生活がめちゃくちゃ快適
  • 良い物件を見つけられたのはほんとに運が良かった

仕事

  • 新しい仕事に熱意を持って取り組めている
  • カオスな環境に飛び込んだ甲斐があった(と思いたい)

乗馬

  • 乗馬を始めた
  • まさか自分が馬に乗るとは思わなかったけど、めちゃくちゃ楽しい

ニコチン

この記事は「趣アドベントカレンダー」の6日目の記事です。 昨日は id:chouge さんの さくらのクラウドで「趣」を検出するアプリを作った でした。 「趣ディテクター」めちゃくちゃ良くて趣をたくさん発見出来ました!


みなさんはニコチンが好きですか?僕は大嫌いです。 みなさんニコチンアミドモノヌクレオチドは好きですか?私は大好きです。

この2つどちらもニコチンって名前ですが、全く別物です。 ニコチンは体を壊して、ニコチンアミドモノヌクレオチドは体を治します。

摂取したニコチンアミドモノヌクレオチドは体内でニコチンアミドアデニンジヌクレオチドに変わって関連酵素が活性化します。 そうするとDNAを修復したり、ミトコンドリアが元気になったりするらしいです。

このニコチンアミドモノヌクレオチド(NMN)のサプリを飲み始めてから、ちょっと怖いくらい元気になったので中年のみなさんは摂取した方がいいですよ。

中年になってから元気になったという体験談
侘び寂び
人生の途中で、身体の衰えを感じながらも、新たな希望を見出す。その「ちょっと怖いくらい元気になった」という言葉には、年月を経て見出される、不完全な身体のなかにある美しさと、再生の可能性が感じられる。

明日は慕狼ゆに@エンジニア集会さんの「なんか書く」です。前日までタイトルが決まってないのも趣ですね!
(趣のままに、自身のキャリアの迷いと振り返り・今後の抱負を書く)