Claude Codeマルチエージェントのリアルタイム監視ダッシュボードを作った
目次
はじめに
前回の記事で、Claude Codeをtmuxベースのマルチエージェント開発チーム(11人編成)として運用する話を書いた。
構築してすぐ直面した問題がある。「今、誰が何をしているか分からない」。
tmuxのウィンドウを順番に覗いていくのは、11人×複数プロジェクトだと現実的ではない。ある日、あるプロジェクトのEngineerが許可待ちで30分止まっていたことに気づかず、その間TLもBLも「Engineer待ち」でIDLEだった。人間がボトルネックになっていたのに、それに気づく手段がなかった。
この経験から、173行のBashスクリプトでターミナルTUIのダッシュボードを作った。この記事では、設計判断と実装の詳細を書く。
なぜターミナルTUIか
最初はObsidianベースのダッシュボードを考えた。ファイルに状態を書き出して、ObsidianのDataviewで表示する案だ。
やめた理由は単純で、ファイルベースだと本質的にリアルタイムにならないから。スクリプト実行時点のスナップショットでしかなく、「今まさに止まっている」ことに気づけない。
ターミナルTUIにした理由:
- 1秒間隔の自動更新で、許可待ちのエージェントをすぐ発見できる
- tmuxの別ペインに常時表示できるので、作業中にチラ見できる
- 外部依存なし。bash + tmux標準コマンドだけで完結する
完成形
先に完成形を見せる。
Agent Status Dashboard
━━━ project-a (19m) ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
● pm Screen and document output tests
○ 10 idle
━━━ project-b (10h 40m) ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
◆ pm [許可待ち] Project retrospective
● tl Set up Biome linting with Husky pre-c...
● bl Review demo HTML specifications
● engineer Implement auth screen redesign...
○ 7 idle
2 sessions | 22 agents | 1 perm | 2 working | 9 done | 14 idle
各エージェントの状態を色分きアイコンで表示し、最下部にサマリーを出す。IDLEのエージェントは件数だけまとめて1行で表示する。数十体のエージェントを全部列挙するとターミナルに収まらないからだ。
ステータス判定:Claude Codeの状態をどう外から知るか
一番苦労したのがここ。Claude Codeには稼働状態を取得する公式APIがない。かなり泥臭いハックになった。
pane_titleとスピナー文字
Claude Codeは、tmuxのpane_titleにスピナー文字+タスク名をセットする。
⠐ Implement F1 foundation DB— ブレイユ文字が回転 → 処理中✳ Set up Biome linting— ✳で安定 → 完了✳ Claude Code— タスク名が "Claude Code" → タスク未割当
この3パターンを判定すればいい。問題は「ブレイユ文字かどうか」をどう判定するか。
UTF-8バイトパターンで判定する
ブレイユ文字(U+2800〜U+28FF)のUTF-8エンコーディングは、先頭バイトが e2、2バイト目が a0〜a3 の範囲になる。これを xxd -p でhex変換して比較する。
parse_title() {
local title="$1"
local task
task=$(echo "$title" | sed 's/^[^a-zA-Z0-9]*//;s/^[[:space:]]*//')
# タスク名が "Claude Code" or 空 → IDLE
if [ "$task" = "Claude Code" ] || [ -z "$task" ]; then
echo "IDLE|—"
return
fi
# 先頭3バイトをhexに変換
local hex
hex=$(printf '%s' "$title" | head -c 3 | xxd -p)
local byte2="${hex:2:2}"
# ブレイユ文字範囲(e2 a0-a3 xx)なら処理中
if [ "${hex:0:2}" = "e2" ] && [ "$byte2" \> "9f" ] && [ "$byte2" \< "a4" ]; then
echo "WORK|${task}"
else
echo "DONE|${task}"
fi
}macOSのgrepはPerl正規表現(\x{2800}等)に対応していないので、正規表現で直接Unicodeコードポイントを指定する方法は使えない。hexバイト比較が確実だった。
正直、Claude Codeのバージョンアップでスピナー表示が変わったら壊れる。脆い実装だとは分かっているが、他に方法がなかった。
許可待ち(PERM)の検出
ここが一番実用上重要な部分。
--dangerously-skip-permissionsを使わない運用だと、ツール実行時にユーザーの許可を求めるプロンプトが表示される。このとき、Claude Codeはスピナーを止めるため、状態としては DONE(✳)になる。
つまり、スピナーだけでは許可待ちかタスク完了か区別できない。
解決策として、tmux capture-paneでペインの末尾10行を取得し、許可プロンプトのキーワードを探す。
# WORK/DONEの場合、capture-paneで許可待ちを判定
if [ "$status" = "WORK" ] || [ "$status" = "DONE" ]; then
local tail
tail=$(tmux capture-pane -t "$target" -p 2>/dev/null | tail -10 || true)
if echo "$tail" | grep -qiE \
'Do you want to proceed|Allow|Deny|Yes$|No$|approve|Permission.*requires'; then
status="PERM"
fi
fi実際の許可プロンプトはこんな表示になる:
Permission rule Bash(rm *) requires confirmation for this command.
Do you want to proceed?
❯ 1. Yes
2. No
当初、capture-paneのチェックはWORK状態のエージェントにしか行っていなかった。しかし許可プロンプト表示中はスピナーが止まるためDONEと判定される。これに気づくまで、許可待ちのエージェントが「完了」として表示されていた。地味だが痛いバグだった。
4つのステータスまとめ
| 表示 | ステータス | 意味 | 判定方法 |
|---|---|---|---|
| 緑● | WORK | 処理中 | ブレイユスピナー検出 |
| 黄● | DONE | 完了・入力待ち | ✳スピナー + タスク名あり |
| 灰○ | IDLE | タスク未割当 | pane_title = "Claude Code" |
| 赤◆ | PERM | 許可待ち(要操作) | capture-paneでプロンプト検出 |
ちらつき問題と解決
素朴な実装の問題
最初は素朴に「画面クリア → 描画」でループしていた。
while true; do
clear
# 各エージェントの状態を出力...
sleep 1
doneclear(\033[2J)を実行すると、一瞬画面が真っ白になってから描画される。1秒ごとにチカチカして目が疲れる。
tmpファイルバッファ + カーソル上書き
解決策は、出力を一度tmpファイルに書き込み、カーソルを左上に戻してから一括出力すること。
BUF=$(mktemp)
trap 'printf "\033[?25h"; rm -f "$BUF"; exit 0' INT TERM EXIT
render() {
{
# 全出力をファイルに書く
printf "${BOLD}Agent Status Dashboard${RESET}\n"
echo ""
# ... 各セッション・エージェントの状態を出力 ...
} > "$BUF"
# カーソル左上 + 残余クリア → 一括出力
printf '\033[H\033[J'
/bin/cat "$BUF"
}ポイント:
\033[H(カーソルを左上に戻す)と\033[J(カーソル以降をクリア)の組み合わせ。全画面クリアではなく、前フレームの残余だけ消す- tmpファイル経由で出力を溜めてから一括表示。変数バッファ(
buf+=$(printf "...\n"))だとbashの$()が末尾改行を食うため使えない \033[?25lでカーソルを非表示にし、終了時に\033[?25hで戻す
セッション削除時の表示崩れ
もう1つの問題。stop-team.shでセッションを削除すると、表示対象が減って出力行数が短くなる。すると前フレームの下部が残像として残る。
\033[Jが「カーソル位置以降をクリア」するので、カーソルを先頭に戻してからクリアすれば前フレームの残像も消える。これで解決した。
エージェントIDのマッピング
tmuxのペインは window_name.pane_index(例: dev.0)で識別されるが、ダッシュボードには人間が読みやすい名前で表示したい。
agent_id() {
local win="$1" pane="$2"
case "${win}.${pane}" in
pm.0) echo "pm";;
lead.0) echo "tl";;
lead.1) echo "bl";;
lead.2) echo "ui_lead";;
ui.0) echo "ui_designer";;
ui.1) echo "ux_architect";;
dev.0) echo "engineer";;
dev.1) echo "engineer2";;
dev.2) echo "engineer3";;
dev.3) echo "tester";;
dev.4) echo "qa";;
*) echo "${win}.${pane}";;
esac
}team.yamlのロール定義と合わせている。未知のペインはそのままwindow.paneで表示するフォールバック付き。
実装全体の構造
173行のスクリプトの全体構造はシンプルで、以下の4ステップのループだ。
1. tmux list-sessions でセッション一覧取得(managerは除外)
2. 各セッションの tmux list-panes でペイン一覧取得
3. 各ペインの pane_title → parse_title() → ステータス判定
→ WORK/DONE なら capture-pane で PERM チェック
4. tmpファイルに書き込み → カーソル上書きで一括描画
5. sleep 1 → 1へ戻る
起動は bash agent-status.sh だけ。tmuxの別ウィンドウかペインで常時走らせておく。
運用してみて
許可待ち検出が一番効いた
以前は「なんか進捗ないな」→ 各ペインを巡回 →「あ、3人許可待ちだった」と気づくまでに15分かかっていた。今はダッシュボードの赤◆を見れば1秒で分かる。
11人が毎回許可を求めてくるので、ダッシュボードがなかったら人間がボタンを押す作業だけで午前中が終わる。冗談でなく。
アップタイム表示が地味に便利
各セッションの起動時刻から経過時間を計算して表示している。「10時間走りっぱなしだからそろそろコンテキストが埋まってるかも」という判断材料になる。
format_uptime() {
local created="$1"
local now
now=$(date +%s)
local diff=$((now - created))
local hours=$((diff / 3600))
local mins=$(( (diff % 3600) / 60 ))
if [ "$hours" -gt 0 ]; then
echo "${hours}h ${mins}m"
else
echo "${mins}m"
fi
}残っている課題
- Claude Codeの内部仕様依存。スピナー文字が変わったら壊れる。公式のステータスAPIが欲しい
- capture-paneの誤検出。ペインの末尾に"Allow"を含むログが残っていると誤ってPERM判定されることがある。出現頻度は低いが、ゼロではない
- macOSの
tmux capture-paneに-lフラグがない。行数を指定して取得できないため、全行取得してからtailで切っている。大きなペインだと若干遅い
まとめ
173行のBashスクリプトで、数十体のAIエージェントのリアルタイム監視ができるようになった。
技術的に面白かったのは、Claude Codeの状態判定にUTF-8のバイトパターンを使ったところ。「公式APIがない」制約の中で、pane_titleとcapture-paneという2つの情報源をかけ合わせて4状態を判別する。泥臭いが実用的なアプローチだと思う。
一番の収穫は「可視化すると運用の質が変わる」という当たり前の事実を再確認したこと。見えないものは管理できない。エージェントが何体いても、全体像が見えれば人間は適切に介入できる。
関連記事
- 技術
Claude Codeで11人編成のマルチエージェント開発チームを構築した話
Claude Codeで11人編成のマルチエージェント開発チームをtmuxベースで構築した記録。役割分離、通信プロトコル、Gate Systemによるフェーズ制御、監視ダッシュボード、コストまで実運用の知見を共有する。
ClaudeAIマルチエージェント - 技術
Webカメラだけで物理マウスを捨てる - NonMouseをM2 Mac + Python 3.14で再起動して自分用にチューニングした話
Webカメラと手のランドマーク検出でマウスを動かすOSS「NonMouse」を、M2 Mac + Python 3.14で動かすまでの依存再構築・Tasks API移行・TUI化と、押下トグル操作や加速カーブのチューニングを実装ベースで記録。
Python - 技術
React入門 - コンポーネント、JSX、Props、Stateの基本
Reactの基礎であるコンポーネント、JSX、Props、Stateを初心者向けに解説。宣言的UIの考え方、TypeScriptでの型定義、State更新の落とし穴、よくあるエラーと解決策、Todoアプリの実践例まで、つまずきポイントを押さえながら学べます。
ReactTypeScriptJavaScript
