こんにちは、エンジニアのオオバです。
最近、僕はUnityで開発中の「勇者のどうぐ屋さん」を、Claude CodeとCodexの並列で進めています。
MacBook ProとMacBook Airの2台に、Unityプロジェクトのworktreeを3つずつ(Dev1〜Dev3)用意。
合計6枠です。
ひとつの枠に1つのAIを担当させて、僕は指示を出したり結果を見たりして回っていく。
並列にすると、手が空く時間がほとんど無くなります。
ところが6枠を回し始めてすぐ、困ったことが起きました。
「あれ、今どの枠が空いてたっけ?」
覚えていられないんですよね。
そして実際に、同じ枠にClaude CodeとCodexを両方アサインしてしまう事故が起きました。
同じworktreeで2つのAIが同時にファイルを書き換えたら、どちらの変更もぐちゃぐちゃになります。
そこで、各枠の使用状況をmacOSのメニューバーに常駐させるツール「dev-slots」を作りました。
メニューバーに6枠の状況を常駐させ、「ターンが進行中か」で作業中を判定すると、空き枠がひと目で分かります
完成形の表示は次のとおりです。
表示は ·X·│··· の形。
C がClaude、X がCodex、· が空き。
衝突しているときは赤い ! になります。
縦線の左が手元のMac、右がもう一台のMacです。
記号をクリックすると、各枠の詳細が開きます。
Macごとに「空き 2/3」のような空き枠の数。
枠ごとに、Claude・Codexそれぞれの状態(作業中 / 開いたまま停止)と、最終更新が何分前か、何の作業をしているかが並びます。
一番下には最終更新の時刻が出ていて、「今すぐ更新」(⌘ + R)で手動でも更新できます。
ポイントは判定の仕方。
最初は「プロセスが生きているか」で判定していて、まったく役に立ちませんでした。
最終的に「AIのターンが進行中かどうか」を見るようにして、ようやく実用になっています。
この記事では、作ったものの構成と、判定方法が変わっていった経緯、途中でハマったところを順番に紹介します。
やりたかったこと
やりたかったことはシンプルです。
6つのworktreeそれぞれについて、次のどれなのかを常に見えるようにしたい。
- 空いている
- Claude Codeが使っている
- Codexが使っている
- 両方が入っていて衝突している
見る場所はメニューバーにしました。
最初は、僕が普段使っているAI TASK BOARD(Webダッシュボード)に統合する案もありました。
ただ、Webダッシュボードはわざわざ開きに行く必要があります。
僕が欲しかったのは「画面の上にずっと出ている」状態でした。
なので、まずは各Macのローカルだけで完結するメニューバー常駐から始めています。
いきなり全部を作らず、小さく作って育てるベビーステップです。
作ったもの:Pythonのスキャナー+Swift1ファイルの常駐アプリ
dev-slotsは2つの部品でできています。
- スキャナー(
bin/dev-slots): Python 3.9。各worktreeをClaude Code / Codexのどちらが使っているかを判定する - メニューバーアプリ: Swiftの単一ファイル。Xcodeのプロジェクトは作らず、
swiftcだけでビルドする
役割分担として、判定と表示文言はすべてPython側に寄せました。
Swiftは受け取った文字列をメニューバーに出すだけです。
判定ロジックを直すたびにSwiftを再ビルドしなくて済むので、試行錯誤がかなり楽になりました。
ログイン時の自動起動は、LaunchAgent(com.ohba.dev-slots)に登録しています。
スキャナーは5秒ごとに起動しています。
毎回Pythonを立ち上げるコストが気になって、常駐プロセス化も考えました。
ただ、実測で1回0.1秒程度だったので見送りです。
困っていないところは触らないでおきます。
作ったあと、Claude Codeのsimplifyで4つの観点からレビューをかけました。
反映したのは次の4点です。
lsofの呼び出しを1回にまとめるpsの呼び出しを1回にまとめる- 表示文言をPython側に一本化する
- Claudeのトランスクリプトがどの枠に属するかを、ログの中の
cwdで判定する
そして動かしてみた初日。
実データで、Dev3にClaudeとCodexが両方入っている事故をさっそく検出しました。
作ってよかったと思った瞬間です。
AIが「どの枠を使っているか」を調べる方法
ここからは、ClaudeとCodexそれぞれについて「どの作業フォルダで動いているか」を取る方法です。
調べてみると、2つのツールで仕組みがまるで違いました。
Claudeデスクトップは1セッション1プロセス
Claudeデスクトップのセッションは、1セッションにつき1プロセス(claude --output-format stream-json)で動いています。
なので lsof -d cwd を使えば、各プロセスの作業フォルダが取れます。
ただし注意点があります。
ClaudeデスクトップのプロセスにはTTYがありません(ps で見ると ??)。
当初は「TTYがあるプロセスだけを対話セッションとして数える」つもりでした。
この方法ではClaudeデスクトップのセッションが全部漏れるので、使えません。
Codexアプリはスレッドごとのプロセスを持たない
Codexアプリのほうは、スレッドごとにプロセスが分かれていません。
代わりに使えるのが ~/.codex/state_5.sqlite です。
この中の threads テーブルに、スレッドごとの cwd、updated_at_ms、rollout_path が入っています。
スレッドと作業フォルダの対応は、このテーブルを正として扱うことにしました。
補助プロセスは数えない
ややこしいのが、本体以外のプロセスです。
codex app-server(node_repl の配下で、別のworktreeをcwdに持つことがある)codex exec(相談用に呼ぶ)claude -p(フックや補助の処理でも起動する)
これらを数えると、誰も作業していない枠が「使用中」になり、衝突を誤検出します。
なので判定の対象から外しました。
判定方法の変遷(ここが一番悩んだ)
ここが今回いちばん試行錯誤したところです。
「使用中」をどう定義するかで、3回変わりました。
1回目:プロセスが生きている / 最終更新から60分以内
最初の定義はこうです。
- Claude: プロセスが生きていれば使用中
- Codex: 60分以内に触ったスレッドがあれば使用中
Codexはスレッドを閉じたかどうかが取れません。
なので「最近触ったかどうか」で代用しました。
結果がこちら。
6枠中3枠が赤。
開きっぱなしにしているClaudeのセッションまで「使用中」と数えてしまっていたんです。
AIのセッションって、作業が終わっても閉じずに置いておくことが多いですよね。
この定義だと、ほぼ常に衝突扱いになります。
表示が赤だらけだと、本当の衝突が起きても気付けません。
これでは役に立たないので作り直しました。
2回目:最終更新から90秒以内なら作業中(不採用)
次に考えたのが「最終更新から90秒以内なら作業中」という定義です。
AIが作業している間はログがどんどん書き足されるので、更新が新しければ作業中とみなせるはず。
ただ、この案はすぐに穴が見つかりました。
Unityのコンパイルのような長いツール実行中は、その間ログが更新されません。
AIはしっかり作業しているのに「空き」と表示されてしまう。
その枠に別のAIを入れたら、まさに防ぎたかった事故そのものです。
なので不採用にしました。
3回目(最終形):ターンが進行中か
最終的にたどり着いたのが、「ターンが進行中かどうか」で判定する方法です。
AIに指示を出してから返事が終わるまでが1ターン。
ターンの途中なら作業中、ターンが終わっていれば停止中とみなします。
Claudeの場合は、~/.claude/projects/ 配下にあるトランスクリプト(*.jsonl)の末尾を見ます。
最後の会話メッセージの stop_reason で判定します。
end_turn: 停止tool_use: 作業中- 「[Request interrupted」で始まるメッセージ: 停止(ユーザーが中断した)
Codexの場合は、state_5.sqlite の threads から cwd と rollout_path を引きます。
rolloutファイルの中で、次のイベント(event_msg)のうち最後に出てきたものを見ます。
task_started: 作業中task_complete: 停止turn_aborted: 停止
この判定に変えたところ、衝突の表示はゼロ。
作業中の1枠だけがオレンジになりました。
冒頭のキャプチャが、まさにその状態です。
「プロセスがあるか」「最近更新されたか」で見ている間は、ずっと実態とズレていました。
AIが今まさに考えているかどうかは、AI自身のログに書いてあったんですね。
ハマりどころ
判定方法以外にも、いくつか引っかかったところがありました。
Codexのrolloutが数百MBある
Codexのrolloutファイルは、画像も含めて保存されるので数百MBになります。
しかもターン開始のイベントが、ファイル末尾から25MBも前にありました。
5秒ごとにこれをJSONとしてパースするのは重すぎます。
そこでJSONのパースはやめて、バイト列のまま後ろから探すことにしました。
bytes.rfind(b'"type":"task_started"')のように、イベント名の生のパターンで末尾から遡る- 読んだ位置をキャッシュしておき、次回は追記された分だけ読む
生のパターンで探して誤検出しないのか、気になりますよね。
会話の本文などJSON文字列の中に同じ文字列が出てきた場合、ダブルクォートは \" にエスケープされます。
なので "type":"task_started" という生のパターンは、イベント本体にしか一致しません。
~/.claude/projects のディレクトリ名は元に戻せない
~/.claude/projects には、作業フォルダのパスを変換した名前のディレクトリが並んでいます。
この変換は非可逆です。
たとえば douguyasan-src と douguyasan-src-unity7a は前方一致するので、ディレクトリ名だけではどちらの枠のものか決められません。
なので、どの枠に属するかはトランスクリプトの中に記録されている cwd で判定しています。
ノッチの裏にアイコンが隠れて消えた
2台分を並べるのに、最初は MBP 1C 2! 3! Air … のように文字で表示していました。
すると、ノッチ付きのMacBook Proでアイコンがノッチの裏に隠れて見えなくなりました。
メニューバーの右側にはアプリのアイコンが多く並ぶので、幅を取るとすぐにはみ出します。
「MBP」「Air」のラベルや枠番号はやめて、記号だけに短縮。
それが現在の ·X·│··· という表示です。
もう一台のMacをSSHで覗く
もう一台のMacの状況は、SSHで取ってきています。
スクリプト自身を標準入力で相手に送って実行する方式です。
ssh host /usr/bin/python3 - --local-only
この方式なら、相手のMacには何もインストールしなくて済みます。
接続はControlPersistで使い回しているので、毎回の接続コストもかかりません。
ただ、MacBook Air側には罠が3つありました。
- Remote Loginがオフだった。Tailscaleで届いていても、22番ポートが
Connection refusedになる - ユーザー名がMacBook Proと違った(
shunsukeohba) - worktreeの場所が
projects/products/配下で、MacBook Proと違った
さらに、手元に鍵が5本あったため、ssh-copy-id が Too many authentication failures で失敗しました。
-o PubkeyAuthentication=no を付けて実行すると通ります。
これからやりたいこと
今回は保留にしたものもあります。
- 使用中の枠に、別のツールからプロンプトを送ろうとしたらフックでブロックする
表示で気付けるようにはなりました。
次は、うっかりアサインしてしまっても仕組みで止められるようにしたいところです。
まとめ
AIを並列で回すと、作業そのものより「どの枠が空いているか」の管理で事故が起きます。
今回作ったdev-slotsのポイントをまとめます。
- 2台6枠の使用状況を、メニューバーに記号で常駐表示する
- 判定はPython、表示はSwift1ファイルに分ける
- 「使用中」は、AIのターンが進行中かどうかで判定する
- Claudeはトランスクリプト末尾の
stop_reason、Codexはrolloutの最後のタスクイベントを見る - 大きなログはパースせず、バイト列の末尾検索+読んだ位置のキャッシュで読む
AIが増えるほど、人間の頭で状況を覚えておくのは無理になっていきます。
だから僕は、見える場所に出しておくことにしました。
並列開発をしている方の参考になればうれしいです。

筆者のXをフォローしよう






