Claude Code の「はい / いいえ」を物理ボタンで返す装置を作った
TL;DR
- Claude Code の確認ダイアログ(
Bash 実行していい?、選択肢など)を 物理ボタンで承諾/拒否 できる据え置きデバイスを作った - ESP32-C3 + 2.8" LCD + WS2812B テープ LED で、複数の Claude Code セッションを 1 台で捌ける(FIFO キュー + Mac LaunchAgent デーモン)
- Bash コマンドの意味を Ollama でその場で日本語要約 して LCD に表示。コードは GitHub にあるので、再現用の仕様書一式も置いた
なぜ作ったか
Claude Code をバックグラウンドで動かす運用が増えるにつれ、3 つの不満が出てきた。
-
承諾を求められると、PC を覗き込みに行く以外の手段がない
通知音は鳴るが、結局 Claude のウィンドウに切り替えてキーを押すしかない。手元の作業を中断して 1 アクションを返すのは思った以上にコストが大きい。 -
複数セッションを並行で動かすとフォーカスの取り合いが発生する
3 セッション同時に走らせると、確認ダイアログが立て続けに出てきて、どれがどのプロジェクトの何の確認なのかも追えなくなる。 -
Bashの許可を求められても、コマンド文字列だけだと意味が掴みにくい
git rebase --onto X Y..Zみたいなコマンド、毎回頭で展開して安全か考えるのは疲れる。
そこで、机の上に置く物理ボタン装置 を 1 台作った。
ボタンの色 LED が点滅して「いまどのプロジェクトが何を聞いている」を伝え、ボタン 1 個で承諾/拒否を返す。コマンドの意味は LCD 下段に Ollama で要約された日本語が出る。
完成形のシステム構成
┌──────────────────────────────────────────────────────────────────┐
│ │
│ Claude Code A ──hook──┐ │
│ Claude Code B ──hook──┼─→ hook_handler ─socket→ daemon │
│ Claude Code C ──hook──┘ (PreToolUse) (FIFO キュー) │
│ │ │
│ thinking_hook ─socket──→ ↓ │
│ (UserPromptSubmit/ │
│ PostToolUse/Stop) │ │
│ │ │
│ Mac Book Pro (常時起動 LaunchAgent) │ │
│ │ │
└───────────────────────────────────────────────USB-C──┼──────────┘
↓
┌────────────────┐
│ ESP32-C3 │
│ + 2.8" LCD │
│ + WS2812B×15 │
│ + ボタン×3 │
└────────────────┘
-
PC 側: 中継デーモン
claude_button_daemonが常駐し、ESP32 のシリアルポートを独占。複数 Claude Code セッションからの hook を Unix socket で受けて FIFO で直列処理する - 物理側: ESP32-C3-WROOM-02-N4 に 2.8 インチ ILI9341 LCD と WS2812B テープ LED 15 個、キースイッチ 3 個を接続
詳細は末尾の仕様書リンクへ。
設計の工夫
1. 思考中表示で「いま何のセッションが何やってる」を可視化
複数セッションを並行運用するとき、ボタンを押す前に 「いまどのプロジェクトが何の作業中か」 を一目で把握したい。
これを「カニアニメ + ラベル + 種別」の表示で解決した。
┌──────────────────────────────┐
│ 思考中 3 │ ← ヘッダー(実数)
├──────────────────────────────┤
│ ≧[゚▽゚]≦ accept_button#1 │
│ コーディング │
│ │
│ ≧[゚Д゚]≦ myrepo#2 │
│ 調査 │
│ │
│ ≧[゚~゚]≦ another_repo#1 │
│ コマンド │
└──────────────────────────────┘
- カニ顔文字 6 種・色 6 種を ラベルのハッシュで安定割当 → 同じセッションは常に同じ顔(識別性が出る)
- 種別は
UserPromptSubmit/PostToolUseの hook で daemon に通知し、PostToolUseのtool_nameからcoding/file_read/bash/web等を推定(Stopで削除) - カニは横に左右バウンス(30fps)し、生きていることが視覚的に伝わる
- 全セッションがアイドルになったらバックライト OFF で省電力
実装の細かい工夫として 1 行まるごとスプライト方式 を採用している(320×44 の LGFX_Sprite を使い回し、毎フレーム再構築 → 文字消失なし・ちらつきなし)。
2. Bash の意味を Ollama でその場で日本語要約
Bash(git rebase --onto X Y..Z) みたいなコマンド、許可するか判断する前に意味を確認したい。
command_explainer.py で 3 段ハイブリッド で要約を作っている。
1. ルール辞書(git/npm/docker/rm/sudo/...) ← 大半はここで確定
├─ 破壊的判定(rm -rf / sudo / >上書き / kill -9 等)→ ⚠ プレフィックス
└─ 既知コマンドなら確定
2. LRU メモリキャッシュ(2,048 件)
3. ディスクキャッシュ(data/cmd_cache.json)
4. Ollama HTTP API(qwen3.5:2b、M2 Pro で 約0.5s)
├─ system prompt で日本語短文を強制
└─ タイムアウト 3 秒、未起動時はサイレントフォールバック
ポイント:
- ⚠ 警告判定はルール層のみ に閉じ込めた。LLM が「このコマンドは安全です」と過信して警告マークを外す事故を避ける
- LCD 上では
⚠(U+26A0)はフォントに収録されてないため、文字列先頭で検出 → 除去 → 三角形アイコンを LovyanGFX のプリミティブで描画 して豆腐化を回避 - Ollama を入れなくても本体は動く(
command_hintが消えるだけ)
3. 中継デーモンで複数セッションを直列処理
ESP32 の USB シリアルは排他オープンしか許されないので、複数 Claude Code が直接シリアルを叩くと最初の 1 つしか繋がらない。
そこで Mac 側に常駐デーモン claude_button_daemon を置いて:
- Unix socket
/tmp/claude_button.sockで hook からのリクエストを受付 -
queue.Queueで FIFO 直列化 - ワーカー 1 本で ESP32 と通信
- セッションラベルは
プロジェクト名#連番で daemon が採番(LCD 上端に表示)
LCD 上端の帯に [accept_button#1] 残2件 のように残件数まで出すので、「あと何個ボタンを押せば終わるか」が見える。
4. 長押しで permissions.allow に永続登録
承諾ボタンを 1500ms 長押し すると、Claude Code の「常に許可」と同じことを物理ボタンでやれる。
| 操作 | action | 結果 |
|---|---|---|
| 短押し | accept |
今回のみ許可 |
| 長押し(≥1500ms) | accept_always |
今回許可 + {cwd}/.claude/settings.local.json の permissions.allow に追記 |
| 拒否(長押し中のキャンセル含む) | reject |
拒否 |
長押し閾値到達で LCD のフッター右側が紫の「常に許可」に切り替わる + LED が紫フラッシュでフィードバック。
リリースまでに拒否を押せばキャンセルされる。
rm -rf / sudo / git push -f 等の 危険コマンドは登録スキップ(今回のみ許可になり、reason に「登録スキップ: 危険コマンド判定」が入る)。
5. ESP32 再起動からの自動再接続 + LaunchAgent 常駐
実運用で困るのが「ESP32 が固まる/USB が抜ける/Mac がスリープ復帰する」みたいな普通の事象。
これらに耐えるよう、daemon に自動再接続ロジックを入れて、launchd 管理下に常駐させた。
実装で詰まったのは、pyserial の is_open は ESP32 が再起動しても True を返し続ける こと。/dev/cu.usbmodem* のファイル存在を別途確認することで切断検知している:
def is_connected(self) -> bool:
if self._serial is None or not self._serial.is_open:
return False
port_path = getattr(self._serial, "port", None)
if port_path and not os.path.exists(port_path):
return False
return True
LaunchAgent は KeepAlive=true + ThrottleInterval=10 でクラッシュ時最短 10 秒で再起動。
再接続成功時は 直前の thinking_overview を強制再送 して LCD の表示ズレを補償する。
ハマったところ
-
ESP32-C3 の LEDC は ch 0〜5 のみ
ESP32-S3 のコードを流用するとpwm_channel = 7のまま動く可能性があり、その場合 LCD バックライトが GPIO の idle 状態(HIGH)で固定されて「常時点灯っぽいが PWM が効かない」状態になる。pwm_channel = 0に直して解消。 -
pyserial.is_openは ESP32 再起動後も True
ポートファイル/dev/cu.usbmodem*の存在をos.path.exists()で別途チェックして切断検知している(上述)。 -
USB CDC RX バッファ既定 256B → 7 択以上で JSON 欠落
選択肢 7 個以上の JSON プロンプトが入りきらず、以降の受信が復旧不能になる現象。Serial.setRxBufferSize(2048)で 8 倍に拡張、加えて 2000 バイト超で自動 reset するセーフティを入れた。 -
hook の
exit 0 + 空出力は自動許可(allow)扱い
ESP32 未接続時に「何もしない」と空出力を返すと、ツールが無条件実行されて危険。必ず"ask"を明示的に返す 設計にしている。
おわりに
- ハードウェア組立を含めてClaude Codeと相談しながらできた。作成中ハードを使いながら作れたのもポイント
- LEDの代わりにステッピングモーターでClaude Codeマスコットを動かす案もあったが、二次利用ガイドラインが不明だったので自重。使っていいならピコピコさせたい
- このアプローチ、実は Claude Code 以外の AI エージェント にもそのまま転用可能(hook の入出力フォーマットさえ揃えれば PreToolUse 風のものは大体つけられる)とのこと。社内 LLM やエージェントフレームワークでも同じ装置が使えるはず
物理ボタンを 1 個机に置くだけで、Claude Code との付き合い方が想像以上に楽になった。ぜひ作ってみてください。
付録(再現用)
README.md
# 仕様書バンドル
以下のファイル群は、Qiita 記事『Claude Code の「はい / いいえ」を物理ボタンで返す装置を作った』の付属資料です。
**「これと同じものを作りたい」と思った人が、Claude Code(あるいは類似のコーディングエージェント)に投げて再現するための設計メモ集** として書いています。
## 想定する使い方
1. このフォルダごと空のリポジトリに置く(例: `docs/specs/` 配下にコピー)
2. Claude Code を起動して「`docs/specs/` を読んで、同じものを作りたい。まず実装方針を提案して」と頼む
3. Claude Code が適切に質問・提案してくる過程で、自分の好みに合わせて細部を決めていく
**完全な設計書ではありません**。プロジェクト全体の骨格と、自分が踏んだ落とし穴だけ書いてあります。
細かい API・データ構造・ファイル分割などは Claude Code と相談しながら決めれば十分動くものができるはずです。
## ファイル構成
| ファイル | 内容 |
|---|---|
| [article.md](article.md) | 本記事。プロジェクト全体の動機と工夫を 5〜7 分で読める形でまとめたもの |
| [specs_hardware.md](specs_hardware.md) | ハードウェア構成(部品・ピンアサイン・電源設計の勘どころ) |
| [specs_firmware.md](specs_firmware.md) | ESP32 ファームウェアの構成と表示の工夫 |
| [specs_software.md](specs_software.md) | PC 側(Mac)ソフトウェアの全体構成 |
| [specs_daemon.md](specs_daemon.md) | 中継デーモンの設計判断 |
| [specs_claude_settings.md](specs_claude_settings.md) | Claude Code 連携(hook 設定)の要点 |
| [usage.md](usage.md) | 完成後の使い方とハマりやすい点 |
## 読む順番
最初に本記事(article.md) で全体像を掴み、興味のあるレイヤから個別 spec へ。実装に着手するときは [usage.md](usage.md) のセットアップ章をたたき台にすると進めやすいです。
specs_claude_settings.md
# Claude Code 連携仕様
`.claude/settings.json` に登録する hook と、ボタン応答 → `permissionDecision` の対応関係。
細かい JSON 書式は [Claude Code 公式ドキュメント](https://docs.claude.com/en/docs/claude-code/hooks) を参照しつつ Claude Code に書かせれば良いので、ここでは方針だけ。
---
## 登録する hook(4 種類)
| Hook | 役割 | 性質 | 推奨 timeout |
|------|------|------|------|
| `PreToolUse` | ツール実行前の承諾確認 | **ブロッキング**(物理ボタン待ち) | 120 秒 |
| `UserPromptSubmit` | ユーザー入力直後 → 思考開始通知 | fire-and-forget | 5 秒 |
| `PostToolUse` | tool 実行後 → 思考再開通知 | fire-and-forget | 5 秒 |
| `Stop` | 応答完了 → 思考終了通知 | fire-and-forget | 5 秒 |
`matcher` は空文字列で全ツールにマッチさせます。`command` は **必ず絶対パス**(hook 実行時の cwd は呼び出し元プロジェクトになるため)。
---
## ボタン応答 → permissionDecision のマッピング
| ボタン操作 | action | permissionDecision | 補足 |
|-----------|--------|-------------------|------|
| 承諾 短押し | `accept` | `allow` | reason: 「物理ボタンで承諾」 |
| 拒否 短押し | `reject` | `deny` | reason: 「物理ボタンで拒否」 |
| 承諾 長押し(≥1500ms) | `accept_always` | `allow` | + `permissions.allow` に pattern 追記 |
| 選択肢確定 | `select` | `deny` + reason | reason に選んだラベルを含める |
| 「PC 画面で確認する」選択 | `fallback` | `ask` | LCD を pc_fallback 表示にして PC 側ダイアログへ |
| ESP32 未接続 / タイムアウト | — | `ask` | reason で原因を通知 |
---
## accept_always(長押し → 永続許可)の考え方
承諾を 1500ms 以上長押しすると、Claude Code の「常に許可」と同等の処理を物理ボタンで実行します。書き込み先は **`{cwd}/.claude/settings.local.json`** の `permissions.allow` 配列。
パターンの作り方は素直に:
| ツール | 追加パターン |
|------|------------|
| `Bash` | `Bash(<command 完全一致>)` |
| `Edit` / `Write` / `MultiEdit` / `Read` | `<ToolName>(<絶対パス>)` |
| `Glob` / `Grep` | ツール名のみ(全許可) |
| その他 | スキップ(reason で通知) |
書き込みは tempfile → `os.replace` で **atomic に**。ファイルが無ければ新規作成。
### 危険コマンドはスキップする
`Bash` のとき、`rm -rf` / `--force` / `--hard` / `sudo` / `>` 上書き / `kill -9` / `chmod -R 777` / `dd of=/dev/*` 等が含まれていたら **`permissions.allow` には登録しない**(今回のみ許可になり、reason に「危険コマンド判定で登録スキップ」を付ける)。長押しを誤発動して恒久許可してしまう事故を防ぐためです。
---
## 思考種別(kind)の推定
`PostToolUse` hook で `tool_name` を見て思考種別を推定し、LCD で色とラベルを割り当てます:
| tool_name | kind | LCD ラベル |
|---|---|---|
| `Read` / `Glob` / `Grep` / `LS` | `file_read` | 調査 |
| `Edit` / `Write` / `MultiEdit` / `NotebookEdit` | `coding` | コーディング |
| `Bash` | `bash` | コマンド |
| `WebFetch` / `WebSearch` | `web` | Web 調査 |
| `ExitPlanMode` / `TodoWrite` | `planning` | プラン |
| `Task` | `subagent` | サブ調査 |
| その他 | `considering` | 考慮中 |
`UserPromptSubmit` 由来は常に `considering` で登録します。
---
## ハマりポイント
### `exit 0 + 空出力` は **自動許可** と同義
これが最も怖い。ESP32 未接続時に「何もしない」と空出力を返すと、ツールが無条件実行されます。**必ず `permissionDecision: "ask"` を出力する** こと。
### stdout は応答 JSON 専用
hook が stdout に余計な行を出すと Claude Code が JSON パースで死にます。**ログは必ず stderr へ**。
### settings.json は絶対パスで書く
`python3 src/hook_handler.py` のような相対パスはダメ。hook 実行時の cwd は呼び出し元プロジェクトのディレクトリなので、相対パスで書くと「自分自身」が見つからず即死します。
### グローバル設定にすると全プロジェクトで動く
`.claude/settings.json` をプロジェクトごとに置く代わりに `~/.claude/settings.json` に書けば、全プロジェクトで物理ボタンが効きます。長押し時の `permissions.allow` 追記は **そのプロジェクトの cwd 配下** に書かれるので、自然に分離されます。
### 思考中 hook の timeout を長く取りすぎない
fire-and-forget でも timeout は 5 秒程度が推奨。長すぎると Claude Code が hook を待つ可能性があり、応答が遅延します。
specs_daemon.md
# 中継デーモン仕様
PC 側の常駐プロセス。ESP32 のシリアル接続を独占管理し、複数の Claude Code セッションからの hook を直列化します。
---
## 設計判断のサマリ
| 論点 | 採用 | 理由 |
|---|---|---|
| IPC | Unix domain socket(`/tmp/claude_button.sock` など) | TCP より単純、認証も簡素、Mac/Linux 両対応 |
| キュー | `queue.Queue` + ワーカー 1 本 | ESP32 シリアルは排他なので並列化しても無意味、FIFO 直列で十分 |
| 起動 | hook が socket 不在を検知したら自動 spawn + LaunchAgent で常駐 | 開発時は spawn、運用時は LaunchAgent と二段構え |
| プロセス管理 | PID ファイル(`/tmp/claude_button.pid`)で多重起動防止 | 単一プロセスを保証 |
| ログ | append ファイル + stderr | LaunchAgent からも見えるように |
「ファイルベースのソケット + 1 ワーカー + キュー」だけで複数セッション対応が成立するので、複雑な分散ロジックは不要です。
---
## セッション識別
`session_id` は Claude Code から渡される UUID 風の文字列で、ユーザーには分かりにくい。LCD 上端の帯では **「ディレクトリ名#連番」** 形式で表示します:
| session_id | cwd | label |
|---|---|---|
| `8e2b...` | `~/projects/myrepo` | `myrepo#1` |
| `4f9c...` | `~/projects/myrepo` | `myrepo#2` |
デーモンプロセスが続いている間は安定するので、複数の Claude Code を立て続けに起動しても LCD 上で見分けが付きます。
---
## 思考中レジストリ(タイムアウト 3 段)
`UserPromptSubmit` / `PostToolUse` / `Stop` の 3 hook から「いま思考中のセッション一覧」を集約して LCD に送ります。問題は **`Stop` hook が応答完了時にしか発火しない** こと。Claude が長考中のセッションは何分も hook が来ないので、ナイーブにタイムアウトすると LCD から消えてしまいます。
そこで 3 段構成に:
| 定数 | 役割 |
|---|---|
| ソフトタイムアウト(例: 60 秒) | hook 更新が途絶えたら削除候補 |
| 転写ファイル生存判定(例: mtime が 60 秒以内なら延命) | Claude のセッションログ `.jsonl` の mtime が新しければ「まだ生きてる」と判断して延命 |
| ハードタイムアウト(例: 600 秒) | 永久ゴースト防止のための強制削除 |
`UserPromptSubmit` / `PostToolUse` hook の stdin に `transcript_path` が含まれているので、それを daemon に転送して mtime を見れば実装できます。
---
## ESP32 自動再接続
USB の抜き差しや ESP32 のリセットに耐える必要があります。
### 切断検知
定期チェック(5 秒周期)+ 受信時の IO エラー検知の二段で。**`pyserial.is_open` は信用できない**([specs_software.md](specs_software.md) ハマりポイント参照)ので、`/dev/cu.usbmodem*` の存在を `os.path.exists` で別途確認します。
### 復旧後の副作用
再接続成功時に **直前の `thinking_overview` を強制再送** すること。ESP32 側はリセットで初期状態に戻っているので、daemon が状態を持ち直して LCD に反映してあげる必要があります。「最後に送ったメッセージを保持しておいて、再接続時に再送する」パターンを各種 status に対して入れると堅くなります。
---
## macOS LaunchAgent による常駐化
### plist の要点
```xml
<key>RunAtLoad</key> <true/> <!-- ログイン時自動起動 -->
<key>KeepAlive</key> <true/> <!-- 異常終了時も再起動 -->
<key>ThrottleInterval</key> <integer>10</integer> <!-- 最短 10 秒で再起動 -->
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key> <string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>
<key>LANG</key> <string>ja_JP.UTF-8</string>
</dict>
```
`PATH` を明示するのは、Ollama のような Homebrew 管理の外部プロセスを LaunchAgent から起動できるようにするため(GUI セッションの `PATH` は素のままだと貧弱)。
### インストール / 再起動の運用スクリプト
`launchctl bootstrap` で plist を有効化し、`launchctl kickstart -k` で再起動するシェルスクリプトを 3 本用意すると運用が楽です:
- `install_launch_agent.sh`: テンプレートのプレースホルダ(リポジトリパス、Python パス)を置換 → 配置 → bootstrap
- `uninstall_launch_agent.sh`: bootout で永続停止
- `restart_daemon.sh`: kickstart -k で再起動(管理外なら `--stop` → spawn)
中身は単純な置換 + `launchctl` 呼び出しです。
---
## 設定ファイル
`data/settings.json` を初回起動時に自動生成し、Ollama 連携などのオプションを保持します:
```json
{
"command_hint": {
"enabled": true,
"use_ollama": true,
"ollama_model": "qwen3.5:2b",
"ollama_url": "http://localhost:11434",
"timeout_sec": 3.0
}
}
```
ユーザーがモデルを差し替えやすいようにこのレベルのキーを露出させておくと親切。
---
## ハマりポイント
### `--stop` を打っても LaunchAgent が即復活する
`KeepAlive=true` の正常動作です。永続停止したいときは `launchctl bootout`(uninstall スクリプト)を使うこと。逆に「再起動」したいだけなら `kickstart -k` で十分。
### 思考中の sweeper を入れ忘れると LCD にゴーストが残る
hook 経由で登録された思考中エントリを誰も消さないと、Claude プロセスが死んでも LCD に残り続けます。10 秒周期で sweep するスレッドを 1 本立てるだけでよいので、最初から入れておくこと。
### Unix socket のパーミッション
`/tmp/claude_button.sock` は同一ユーザーから見えれば良いので、デフォルトのパーミッションで十分。マルチユーザー環境にする予定があるならユーザーホーム配下のソケットに移したほうが安全です。
### LaunchAgent の `PATH` を狭くしすぎない
Ollama を使う場合、`/opt/homebrew/bin` を `PATH` に入れ忘れると LLM 呼び出しが失敗します。逆にここを忘れていることに気付きにくいので、最初に書いておくこと。
### 多重起動防止
同名プロセスが残ったまま新しいデーモンを spawn すると socket バインドで失敗します。**PID ファイル** で `kill -0` チェックして既存プロセスが生きているなら即 exit、という単純なロックを入れておくと事故が減ります。
specs_firmware.md
# ファームウェア仕様
ESP32 上で「PC からプロンプトを受信 → LCD/LED で表示 → ボタン応答を返す」だけのシンプルな状態機械です。
描画と LED アニメ部分にいくつかコツがあります。
---
## 技術スタックの選び方
| 用途 | 採用 | 理由 |
|---|---|---|
| ビルド | PlatformIO | Arduino IDE より依存管理が楽、CI もしやすい |
| LCD 描画 | **LovyanGFX** | TFT_eSPI でも書けるが、日本語フォント (`lgfxJapanGothic_*`) と `LGFX_Sprite` を使うため |
| LED 制御 | FastLED v3 | WS2812B 制御の定番、ノンブロッキング駆動が書きやすい |
| JSON | ArduinoJson v7 | 受信プロンプトのパース・応答シリアライズ |
ESP32-C3 は内蔵 USB Serial/JTAG を使うので、PlatformIO の `build_flags` に `-DARDUINO_USB_CDC_ON_BOOT=1 -DARDUINO_USB_MODE=1` を両方つけて `Serial` を HWCDC に割り当てます。
---
## 状態機械
3 状態のシンプル構成:
```
prompt 受信
IDLE ─────────────────────────→ PROMPTING
↑ │
│ ボタン押下 │
│ ┌─── RESPONDED ←────────────────┘
│ │ (1 秒の表示保持)
└────┘
```
- `RESPONDED` は結果を 1 秒見せるだけの一時状態。次のプロンプトが来たら即座に上書きして `PROMPTING` に戻る(FIFO の連続処理を妨げないため)
- `status: cancel` を受信したら、`currentPromptId` 一致時のみ `PROMPTING → IDLE` に戻して LED 消灯
---
## 表示パターン
LCD で扱うべき主なモード:
| 状態 | 表示 |
|---|---|
| 全アイドル(思考中なし) | バックライト OFF(黒画面) |
| 思考中表示 | カニ顔文字アニメ + セッションラベル + 種別ラベル(最大 4 行) |
| 通常プロンプト | ヘッダー(ツール名)+ 本文 + 3 分割カラーフッター(左=未使用 / 中=Reject / 右=Accept) |
| 選択肢リスト | 質問文 + 選択肢リスト + ▲▼ スクロール表示。末尾に「PC 画面で確認する」フォールバック項目を必ず付ける |
| 結果表示(1 秒) | 「承諾」「拒否」「常に許可」「選択: 〇〇」「PC で確認」のいずれか |
| PC 画面フォールバック | 「PC 画面で操作してください」+ 青系呼吸 LED |
| 起動直後 | 「接続中...」+ 往復するカニ顔文字(USB CDC 接続待ち) |
3 分割フッターは **物理ボタンの並びと色を一致** させると体験が直感的になります。長押し閾値到達で右側を紫「常に許可」に差し替えるとフィードバックとして良いです。
---
## 実装の工夫(ハマりポイント込み)
### USB CDC の RX バッファを必ず拡張する
ESP32-C3 の HWCDC は **既定で 256 バイト** しかなく、選択肢を 7 つ以上含む JSON プロンプトが入りきらず欠落します。さらに以降の受信が復旧不能になります。
```cpp
Serial.setRxBufferSize(2048); // setup() の最初で
Serial.begin(115200);
```
加えて、改行が来ない異常入力時にバッファが肥大化したら自動 reset するセーフティを入れておくと安心。
### 思考中アニメは 1 行スプライト方式が必須
複数セッションのカニ顔文字を毎フレーム再描画すると、**文字が消えたりちらついたりします**。1 行ぶん(例: 320×44 ピクセル)のオフスクリーン `LGFX_Sprite` を使い回し、毎フレーム「カニ + ラベル + 種別」を一括再構築 → スプライト全体を画面に転送、という流れにすると消失なし・ちらつきなしで動きます。
### `⚠`(U+26A0)はフォントに無いので豆腐になる
日本語ゴシックフォントには警告マークが収録されていません。`command_hint` 文字列の先頭で検出 → 除去 → **三角形をプリミティブで描画** することで豆腐化を回避します。
### ボタンは長押し検出を入れる
1500ms 長押しで「常に許可(accept_always)」を送る仕様にすると、permissions の永続登録を物理ボタンだけで操作できます。実装は「押下開始時刻を記録 → リリース時に経過時間で短押し/長押しを判定」「閾値到達の瞬間に 1 回だけフィードバック描画」の素直なロジックでよいです。
### バックライトは PWM チャネル 0 を使う
ハードウェア仕様にも書きましたが、ESP32-C3 の LEDC は **0〜5 チャネルのみ**。S3 用コードから流用する際の典型的な事故です。
### 起動演出は入れたほうがいい
無音・無画面で USB CDC 接続を待つと「死んでるのか動いてるのか」分からないので、`while (!Serial)` のあいだカニを左右に動かす演出を入れています。10 秒待っても接続しなかったらガイド文を出すと親切。
### 状態遷移図を ChatGPT/Claude に最初に書かせる
3 状態 × 受信メッセージ × ボタン入力の組み合わせは意外と漏れます。実装前に状態表を書き出して、`STATE_RESPONDED` 中に `prompt` が来たら? `cancel` が来たら? などを潰しておくと後でハマりません。
specs_hardware.md
# ハードウェア仕様
机に置く据え置き型デバイスとして、USB-C 1 本で PC に常時接続する構成。
正確な配線図や部品配置は、ここに書いてある制約と勘どころを踏まえて自分の好みで詰めてください。
---
## 構成要素
| 部品 | 数量 | 役割 |
|---|---|---|
| ESP32-C3-WROOM-02-N4 + DIP 変換基板 | 1 | メインコントローラ。**裸モジュールでなく DIP 変換済みのほうが扱いやすい** |
| 2.8" SPI TFT(ILI9341、例: MSP2807) | 1 | 240×320。日本語表示に十分な広さ |
| WS2812B テープ LED | 15 個 | ボタン周り 5 個 × 3 ボタン。視覚フィードバック用 |
| タクトスイッチ | 3 | 承諾 / 拒否 / 選択肢ナビゲーション |
| 電源スイッチ(1A SPST) | 1 | 5V ライン途中。リセットも兼ねる |
| AMS1117-3.3 | 1 | 5V → 3.3V LDO |
| その他 | — | USB-C コネクタ、抵抗(10kΩ EN プルアップ、330Ω WS2812B 直列)、コンデンサ類 |
ESP32 と LCD は 3.3V、WS2812B は **5V 直結**。輝度を上げると LDO では電流不足になりやすい。
---
## ピンアサインの考え方
ESP32-C3 で使える GPIO は限られているので(`0〜10, 18, 19, 20, 21`、うち 18/19 は USB 占有)、配置の自由度はあまりありません。本実装では次の方針で割り当て:
- **SPI は IOMUX 直結ピン(GPIO6=SCK / GPIO7=MOSI / GPIO10=CS)を使う** → 40MHz 動作可、GPIO Matrix 経由だと約 26MHz で頭打ち
- **ボタンは内部プルアップで GPIO1 / GPIO3 / GPIO4 に集める**(隣接配置で配線が短くなる)
- **WS2812B のデータは GPIO0** で USB-C 近傍に配置 → 5V ラインを最短化
- **LCD の RESET / DC は UART0 のデフォルト端子(GPIO20 / GPIO21)を流用**(内蔵 USB Serial/JTAG 使用時は UART0 が空く)
- **LCD バックライトは GPIO5 を PWM で駆動** → 全アイドル時に消灯して省電力
詳細な配線は Claude Code と詰めて構いませんが、上記の役割分担さえ守れば動きます。
---
## 電源設計の勘どころ
USB-C の 5V を 1 本受けて、AMS1117-3.3 で 3.3V を作る単純構成。WiFi/BLE を使わないので、3.3V 側のバルク容量は控えめ、**5V 側を厚めに** するのが効きます(WS2812B の高速大電流変動が 5V 側で起きるため)。
定番として:
- WS2812B 初段近傍に **470μF 電解 + 0.1μF セラ**(突入電流とケーブル寄生 L 補償)
- ESP32 の VDD ピン直近に **0.1μF セラ最短配線**
- LDO 入出力に 10μF セラ
「電源スイッチを RESET 兼用にする」「BOOT は GPIO9-GND の 2pin ピンソケットをジャンパキャップで短絡」といった割り切りで、タクトスイッチを減らせます。
---
## ハマりポイント
### ESP32-C3 の LEDC は チャネル 0〜5 のみ
ESP32-S3 のサンプルコードを流用すると `pwm_channel = 7` のままになりがちです。C3 では 6 と 7 は存在しないので **初期化失敗** → GPIO の idle 状態(HIGH)固定になり、「LCD バックライトが消えない」事象が起きます。`pwm_channel = 0` を使ってください。
### GPIO2 / GPIO8 / GPIO9 はストラッピングピン
起動時の状態が boot mode に影響するので、外付けで HIGH/LOW に固定する回路を組まないこと。GPIO9 は BOOT 用に内部プルアップに任せて開放、必要なときだけジャンパキャップで GND 短絡。
### WS2812B 起動時のチェーン遠方グリッチ
ESP32 起動直後(FastLED 初期化完了前)に GPIO0 のアナログ機能ブロック過渡応答でデータ線にノイズが乗り、テープの **#32 や #80 付近** が誤ラッチします。本実装は 15 個で切るので実害なしですが、長尺で使うときは `setup()` 冒頭で `digitalWrite(0, LOW)` + 短い delay を入れてから FastLED を初期化すると抑制できます。
### 5V 給電は USB ケーブルから直接取る
ESP32 の 3V3 出力から WS2812B に給電すると、輝度を上げたときにハングします。WS2812B は **必ず USB の 5V ラインから直接** 取り、ESP32 と GND を共通接続。
### LCD のバックライト LED ピンを VCC 直結にしない
楽だからと VCC 直結にすると常時点灯になり、「全アイドル時に消灯」の省電力動作ができなくなります。GPIO 経由で PWM 制御する設計にしておくと、後から制御の余地があります。
specs_software.md
# PC 側ソフトウェア仕様
PC(Mac)側は **常駐デーモン + hook スクリプト** の 2 階建てで構成します。
言語は何でも良いですが、本実装は Python 3.10+ 標準ライブラリと `pyserial` のみで作っています。
---
## なぜ 2 階建てか
ESP32 の USB シリアルは **排他オープンしか許されません**。複数の Claude Code セッションが直接シリアルを開こうとすると、後発が `Resource busy` で失敗します。
そこで **デーモンが ESP32 接続を独占** し、各 hook は IPC 経由でデーモンにリクエストを送る方式にします。デーモン側で FIFO キューに積んで直列処理することで、複数セッション同時運用が成立します。詳細は [specs_daemon.md](specs_daemon.md) へ。
```
[Claude Code A] ──hook──┐
[Claude Code B] ──hook──┼─→ hook スクリプト ─IPC→ daemon ─serial→ ESP32
[Claude Code C] ──hook──┘ (FIFO)
```
---
## モジュールの分担(参考)
「全部 1 ファイルでも書けるが、責務で分けたほうが Claude Code とのやりとりが楽」というレベルの分割です。
| 役割 | 内容 |
|---|---|
| **PreToolUse hook** | ブロッキング。stdin の hook JSON を受けて daemon へ依頼 → 応答を `permissionDecision` に変換して stdout |
| **思考中 hook**(UserPromptSubmit / PostToolUse / Stop) | fire-and-forget。daemon に通知して即 exit。Claude Code 本体のフローを邪魔しない |
| **IPC クライアント** | Unix domain socket 接続。socket が無ければ daemon を spawn する |
| **シリアルブリッジ** | ESP32 との JSON 送受信。ポート自動検出(`/dev/cu.usbmodem*`)、接続管理 |
| **メッセージ整形** | `tool_name` / `tool_input` から LCD 表示用の日本語文字列を作る。ファイルパス短縮、ワークスペース外検知 |
| **コマンド要約** | Bash の意味を 1 行日本語に。**ルール辞書 → キャッシュ → Ollama** のハイブリッド |
| **permissions 更新** | 長押し(accept_always)時に `.claude/settings.local.json` の `permissions.allow` に atomic 追記 |
---
## コマンド要約(Ollama ハイブリッド)の考え方
Bash コマンドの日本語要約をどう実装するかは記事の主役の一つです。
LLM 一発で済ませると遅いし金もかかるので、3 段で攻めます:
```
1. ルール辞書(git / npm / docker / rm / sudo / ...) ← 大半はここで確定
└─ 破壊的判定(rm -rf / --force / sudo / > 上書き / kill -9 等)→ ⚠ プレフィックス
2. LRU メモリキャッシュ(数千件、スレッドセーフ)
3. ディスクキャッシュ(JSON ファイル、daemon 再起動を跨いで永続)
4. Ollama HTTP API(モデルは小さめ、例: qwen 2B 系で 0.5 秒程度)
└─ タイムアウト 3 秒、未起動時はサイレントに None 返却
```
**警告判定(⚠)はルール層のみ** に閉じ込めること。LLM が「このコマンドは安全」と過信して警告を外す事故を避けるためです。
Ollama を入れなくても本体は動くようにしておくと、再現する人のハードル下がります。
---
## ファイルパス整形の小ネタ
Read / Edit / Write 系ツールの `file_path` を LCD で見やすく短縮します:
- cwd 配下: cwd 相対 + 末尾 3 階層 + `…` で省略(例: `…/src/utils/foo.py`)
- ワークスペース外: フルパスを赤橙色で表示し、`path_alert: true` を ESP32 に送る
- 絶対パス: `~/` 置換してから上記処理
ワークスペース外パスへのアクセスは **「いつもと違う!」のシグナル** として強調表示すると、誤操作を減らせます。
---
## ハマりポイント
### hook の `exit 0 + 空出力` は自動許可
Claude Code の hook 仕様で、stdout が空のまま正常終了すると **「許可」と同義** に扱われます。ESP32 未接続時に「何もしない」と空出力すると、ツールが無条件実行されて危険です。**必ず `permissionDecision: "ask"` を明示的に出力する** こと。
### `pyserial` の `is_open` は ESP32 再起動後も True を返す
ESP32 がリセットされると `/dev/cu.usbmodem*` が一瞬消えますが、`pyserial` の `is_open` プロパティはそれを検知できません。「接続あり」と誤判定して書き込むと PIPE エラーで詰まります。
```python
def is_connected(self):
if self._serial is None or not self._serial.is_open:
return False
port_path = getattr(self._serial, "port", None)
if port_path and not os.path.exists(port_path):
return False
return True
```
ポートファイルの存在を `os.path.exists` で別途確認するのが確実です。
### hook の stdout を汚染しない
hook が stdout に何か余分な行を出すと Claude Code が JSON パースに失敗します。**ログは必ず stderr へ**。`logging.basicConfig(stream=sys.stderr)` を最初に呼ぶ習慣で。
### hook を fire-and-forget する場合は本当に即 exit
`UserPromptSubmit` / `PostToolUse` / `Stop` のような表示更新用 hook は、daemon へ通知したら **応答を待たずに即 exit** すること。daemon の応答を待つと Claude Code 本体のフローがブロックされて体験が悪化します。
### Ollama のタイムアウトは短めに
3 秒以内に応答が返らないコマンドは要約せず諦める設計にしておくこと。長考されると LCD 表示が遅延して逆にストレスになります。
usage.md
# 使い方と運用メモ
完成後の使い勝手と、運用で踏みやすい落とし穴のまとめ。再現するときの全体像をつかむ用途で読んでください。
---
## セットアップの全体像
| 工程 | 所要 | 参照 |
|---|---|---|
| ハードウェア組立 | 半日〜1 日 | [specs_hardware.md](specs_hardware.md) |
| ファームウェア書き込み | 30 分 | [specs_firmware.md](specs_firmware.md) |
| PC 側ソフトウェア準備 | 10〜30 分 | [specs_software.md](specs_software.md) / [specs_daemon.md](specs_daemon.md) |
| Ollama 準備(任意) | 10 分 | — |
| デーモン常駐化(LaunchAgent) | 1 分 | [specs_daemon.md](specs_daemon.md) |
| Claude Code 連携設定 | 1 分 | [specs_claude_settings.md](specs_claude_settings.md) |
| 動作確認 | 5 分 | 本ページ §動作確認 |
各工程の細部は仕様書側に書いてあります。順序通りやればハードウェア組立を含めても **週末で完成する** くらいのボリュームです。
---
## ボタン操作早見表
### 通常モード(accept / reject 2 択)
| ボタン | 短押し | 長押し(≥1500ms) |
|------|------|------------------|
| 承諾 | accept(今回のみ許可) | **accept_always**(permissions.allow に永続登録) |
| 拒否 | reject | reject |
| 選択 | 不使用 | 不使用 |
長押し閾値到達で LCD のフッター右側が紫「常に許可」に切り替わってフィードバックが出ます。リリース前に拒否ボタンを押せば長押しはキャンセルされます。
### 選択肢モード(3 択以上 / AskUserQuestion)
| ボタン | 動作 |
|------|------|
| 承諾 | 現在の選択肢で確定 |
| 拒否 | キャンセル |
| 選択 | 次の選択肢へ(末尾→先頭ループ) |
選択肢リストの末尾には常に「PC 画面で確認する」が追加されるので、長すぎる選択肢に対するエスケープハッチになります。
### PC 画面フォールバック中
LCD に「PC 画面で操作してください」が出ている状態。**任意のボタン 1 押しで解除** され、応答は PC 画面側で行います。
---
## LCD 表示パターンの早見表
| 状態 | 表示 | バックライト |
|---|---|---|
| 全アイドル(思考中なし) | 黒画面 | OFF(省電力) |
| 思考中(≥1 セッション) | カニアニメ + ラベル + 種別ラベル | ON(黒背景) |
| 通常プロンプト | クリーム白背景 + ヘッダー + 本文 + 3 分割カラーフッター | ON |
| 選択肢リスト | 質問文 + 選択肢 + ▲▼ スクロール | ON |
| 結果表示(1 秒) | 「承諾」「拒否」「常に許可」「選択: ○○」「PC で確認」 | ON |
| 起動直後 | 「接続中...」 + 往復するカニ顔文字 | ON |
---
## 動作確認の手順
1. デーモンが起動していることを確認: `pgrep -af claude_button_daemon`
2. ESP32 のソケットファイルが見える: `ls -la /tmp/claude_button.sock`
3. Claude Code で適当なコマンドを実行(例: `ls` を頼む)
4. LCD にプロンプトと LED 回転アニメが出る
5. 承諾ボタンを押す → コマンドが実行される
途中で詰まったら `tail -f /tmp/claude_button_daemon.log` でログを見るのが一番早いです。
---
## 運用で踏みやすい落とし穴
### `--stop` してもデーモンが復活する
`KeepAlive=true` の正常動作です。永続停止は `launchctl bootout` を使ってください。
### LCD の思考中表示が消えない
デーモンを再起動すると空 overview が再送されてクリアされます。日常的にはほぼ気にしなくて良いですが、開発中に状態がズレたら覚えておくと早いです。
### Ollama の日本語要約(command_hint)が出ない
Ollama 未起動・モデル未取得のとき、`command_hint` だけが消えて他はそのまま動きます(サイレントフォールバック)。出したいときは `brew services start ollama` と `ollama pull <モデル名>` を確認。
### 長押しで `permissions.allow` に登録されない
`rm -rf` / `sudo` / `git push -f` などの危険コマンドは登録スキップされます。これは「事故防止のため意図した挙動」なので、reason に「登録スキップ: 危険コマンド判定」が出ていれば正常です。
### LCD が常時消灯のまま反応しない
全アイドル状態でバックライト OFF にする設計なので、これ自体は正常。Claude Code で何か実行すれば点灯します。点灯しない場合は ESP32 と PC のシリアル接続を疑ってください。
### USB ケーブル / ハブの相性
WS2812B 全点灯時の電流変動でハブが落ちることがあります。**Mac 本体の USB-C 直挿しが安定** します。
### ESP32 のリセットでデーモンが詰まる
自動再接続が入っているので 5〜10 秒で復旧します。それ以上待っても復旧しないようなら、`launchctl kickstart -k gui/$(id -u)/<LaunchAgent ラベル>` でデーモンを再起動。


