Claude Codeのセッション管理、自作という選択肢もあり——hooksを理解しながら作ってみた - エムスリーテックブログ

エムスリーテックブログ

エムスリー(m3)のエンジニア・開発メンバーによる技術ブログです

Claude Codeのセッション管理、自作という選択肢もあり——hooksを理解しながら作ってみた

【Unit1 ブログリレー2日目】

こんにちは、Unit1(製薬企業向けプラットフォームチーム)とデータ基盤チームを兼務しております石塚です! ブログリレー1日目は佐野さんによる「そのAIエージェント、いつから本番を任せますか?——人間と約350件並走させてわかったこと」でした。

Claude Codeのセッションを iTerm 上で立ち上げて作業していたら、気付けば10タブ以上のセッションが並び、「あのタスクを走らせたのはどのタブだっけ」「これは処理中?それとも私の回答待ち?」と、どのタブで何が起きているのか分からなくなる "セッション迷子" に陥っていました。並列でAIエージェントを走らせている方なら、同じような経験があるのではないでしょうか。

そこで、Claude Codeを相棒に「セッションをカンバン形式で俯瞰する仕組み」を自作することにしました。AIに実装を任せきりにするのではなく、コアとなる仕組みを1つずつ理解しながら組み上げる方針です。結果として、この開発自体がClaude Codeの内部挙動を学ぶ絶好の教材になり、今ではチームメンバーの手も借りながら、日々の開発に欠かせない道具になっています。

この記事では、このツールを支える2つの技術要素——Claude Code hookstty × AppleScript ——について、開発の中でぶつかった課題と解決の過程を紹介します。あわせて、社内ツールがチームに広がっていく様子から、エムスリーのエンジニア文化も少しだけお見せできればと思います。

なお、本記事の内容は執筆時点の環境(macOS Tahoe 26.5 / iTerm2 3.6.10 / Claude Code v2.1.220)で確認したものです。hooksの仕様はアップデートで変わる可能性があるため、最新の情報は公式ドキュメント*1を参照してください。

記事とは全く関係のない、家族旅行で行った海遊館のジンベイザメ

作ったもの: セッション監視ダッシュボード

まず全体像です。各Claude Codeセッションの状態を収集し、ブラウザ上のカンバンボードにリアルタイム表示するローカルWebアプリを作りました。

  • AI Working: AIがツール実行中・応答生成中
  • Waiting Input: ユーザーの入力待ち(後述のとおり3種類に分解)
  • Idle: 一定時間操作なし
  • Terminated: プロセス終了

会話履歴はダッシュボード上でチャット形式で読め、そのままプロンプトの送信もできるため、ツール上で会話を完結させることも可能です。カードをクリックすれば該当のiTermタブへフォーカスも移動します。「迷子になったセッションを一覧から見つけて、そのまま会話するか、ワンクリックでターミナルへ戻る」が基本の使い方です。

アーキテクチャは次のように、hooksでファイルを書き出し、それをサーバがファイル監視ライブラリのchokidarで検知してブラウザへ流すだけのシンプルな構成です。

[Claude Code Session] ──hook──> ~/.claude/<tool>/{sessionId}.json
                                        ↓ chokidarで監視
                                [Node.jsサーバ]
                                        ↓ WebSocketでプッシュ
                                [ブラウザUI (カンバンボード)]

Claude Code本体のコードには一切手を入れず、公式が用意している拡張ポイントから状態を読み取るだけの作りです。この「状態の読み取り」を支えるのが1つ目の技術要素、hooksです。

自作セッション管理ツールの雰囲気

技術要素1: hooks でセッションの状態を観測する

hooksとは

Claude Codeには、セッションの各段階でユーザー定義のシェルコマンドを起動する hooks という仕組みがあります。~/.claude/settings.json に登録すると、内部イベントに応じて指定コマンドが呼び出され、標準入力からJSON(session_id / hook_event_name / tool_name など)を受け取れます。

登録できるhookは数多く用意されており、例えば次のようなものがあります。

系統 hookの例
セッションの節目 SessionStart / SessionEnd
プロンプト送受信 UserPromptSubmit / Stop / SubagentStop
ツール実行 PreToolUse / PostToolUse
ユーザー確認 PermissionRequest / Notification
コンテキスト管理 PreCompact

開発を始める前の私は「セッション終了時に通知音を鳴らす」くらいのユースケースしか思いついていませんでした。しかし「状態遷移のトリガー」として見ると、hooksは一気に面白くなります。

表現したい状態遷移から逆算する

設計はhookの一覧からではなく、「表現したい状態遷移」から逆算しました。特にこだわったのは Waiting Input の3分解 です。単なる"待機中"ではなく、

  • 質問待ち(❓): AIがユーザーへの質問を出して止まっている
  • 許可待ち(🔐): ツール実行の許可ダイアログで止まっている
  • 応答終了(無印): AIの応答が終わり、次の指示を待っている

を区別できれば、「今すぐ反応すれば作業が進むセッション」を一覧から一瞬で選べます。10タブ並んだときのつらさはここで決まります。

対応関係は、セッションの状態を時系列で追うと分かりやすくなります。

セッションに起きること 発火するhook ダッシュボードの状態
セッション起動 SessionStart Waiting Input(初期状態)
ユーザーがプロンプト送信 UserPromptSubmit AI Working
AIがツールを実行 PreToolUse / PostToolUse AI Working(実行中のツール名を更新)
AIがユーザーへ質問 PreToolUse(tool_name=AskUserQuestion 等) Waiting Input ❓(質問待ち)
許可ダイアログが表示 PermissionRequest Waiting Input 🔐(許可待ち)
AIの応答が完了 Stop Waiting Input(次の指示待ち)
しばらく放置される 該当hookなし(サーバ側で時間判定) Idle
プロセスが終了する 該当hookなし(サーバ側でPID生存確認) Terminated

表の下2行に注目してください。実は「対応するhookが存在しない遷移」があり、ここが自作したからこそ気付けたポイントでした(詳細は次のセクションで説明します)。

ポイントは「質問待ち」の検出方法です。専用のイベントは存在しないため、PreToolUse に渡ってくる tool_nameAskUserQuestionEnterPlanMode だったら質問待ちへ遷移させる、という間接的な検出をしています。公式ドキュメントの仕様一覧を眺めているだけでは思いつかず、実際にhookのJSONをログに吐かせて観察して初めて「これで拾える」と気付いた部分です。

学び: hooksでは取れない遷移がある

作ってみて分かったのは、hooksだけでは状態機械が完成しない ことです。

時間経過はイベントにならない。 「一定時間放置されたらIdle」という遷移はどのhookからも発火しません。hookで毎回最終更新時刻を記録し、サーバ側のポーリングで「N分経過したらIdle」を判定する、"外側で時間を持つ"構成にしました。

プロセスの突然死も取れない。 SessionEnd hookはありますが、ターミナルごと閉じた場合やPC再起動では発火しないことがあります。hookが受け取る親プロセスID(Claude Code本体のPID)を毎回記録しておき、サーバ側で定期的に kill -0 <PID> を打って生存確認する方式にしました。SessionEndを信じない設計です。

/clear でセッションIDが変わる。 Claude Codeで /clear を実行すると、同じターミナル・同じPIDのまま session_id だけが新しくなります。素朴に実装すると、ダッシュボードに古いカードが残ったまま新しいカードが増えて「幽霊カード」が生まれます。最初は古いステータスファイルを削除する実装にしたのですが、「前のセッションをresumeしたい」ニーズと衝突したため、最終的に「同一PIDの旧セッションはterminatedへ書き換え、かつterminatedはPID重複排除の対象外にする」という2段構えに落ち着きました。

subagentのイベントも流れてくる。 Claude Codeが内部でサブエージェントを起動すると、そのイベントもhookに流れてきます。ある日ダッシュボードを見たら、身に覚えのないカードが何枚も湧いていました。入力JSONに agent_id が含まれていたらメインセッションではないと判断し、hookの冒頭で exit 0 して無視することで解決しています。

AGENT_ID=$(extract "agent_id")
[ -n "$AGENT_ID" ] && exit 0

技術要素2: tty × AppleScript でターミナルへ"戻る"

監視するだけでは、迷子は解決しない

状態が見えるようになったら、次は「目当てのセッションへの辿り着きやすさ」です。ダッシュボードには検索機能を持たせ、セッションに付けた名前やメモ、会話履歴の中身まで、すべてを検索対象にしました。「あの調査を頼んだセッションどこだっけ」というときも、キーワード1つで引き当てられます。前述のとおり、見つけたセッションとそのままツール上で会話を続けることもできます。

ただ、普段からiTermで作業していると「見つけたら、やはり使い慣れたターミナルで会話を続けたい」というのが正直な実感でした。そこで、カードをクリックしたら該当のiTermタブへフォーカスが飛ぶ 機能を付けました。ダッシュボードにすべてを閉じ込めるのではなく、探すのはダッシュボード・対話は慣れたターミナル、と行き来しやすくするのが狙いです。

仕組みは次の連携です。

hookが記録したPID
  → ps コマンドで該当プロセスの tty を特定
  → AppleScript で iTerm のウィンドウ・タブ・ペインを走査し、
    同じ tty を持つセッションを選択してフォーカス

Claude CodeのプロセスIDさえ分かれば、そのプロセスがぶら下がっているttyが分かり、ttyが分かればAppleScriptでターミナル側の該当タブを一意に特定できる、という発想です。macOSのAppleScriptは古くからある技術ですが、「AIエージェントの管理」という新しい文脈で活躍してくれました。

さらに同じ経路を逆向きに使うと、ダッシュボードからターミナルへテキストを送信 できます。ダッシュボード上のチャット画面から入力を送ると、実体はAppleScript経由でそのセッションのターミナルに文字が流れる、という「見た目はチャットアプリ、実体はCLI」の構造です。

学び: フォーカス制御とテキスト送信で得た細かな気づき

この機能はシンプルに見えて、実装してみて初めて分かる細かな気づきの連続でした。

縦分割ペインでフォーカスが外れる。 iTermは1タブ内に複数ペインを持てるため、タブ単位の特定だけでは分割時に隣のペインへフォーカスしてしまいます。ウィンドウ→タブ→ペインの3階層すべてでttyを照合するよう修正しました。

日本語入力のEnterが誤爆する。 ダッシュボードからのテキスト送信機能を付けたところ、日本語の変換確定のEnterで送信が発火する問題にぶつかりました。IMEのcomposition状態を判定して回避するのですが、実はこのバグ、メモ保存欄・メッセージ送信欄・スナップショット名入力欄と、入力欄を追加するたびに合計3回踏みました。日本語環境でWeb UIを自作する方は、送信系の入力欄すべてでIME対応を最初から入れることをおすすめします。

ターミナルごとの対応差。 iTerm2とTerminal.appはAppleScriptでタブ選択まで制御できますが、Ghosttyは執筆時点でアプリのアクティブ化までしか対応できませんでした。調べてみるとGhostty側にttyを公開するissueが立っており、開発が進んでいるようです。こうした「対応できない理由を上流まで掘りに行く」過程も自作の楽しさだと思います。

開発の風景: 社内ツールが育っていく

このツール、個人プロジェクトとして始まったのですが、社内に共有したところ思わぬ広がり方をしました。エムスリーのエンジニア文化が垣間見える出来事だったので少し紹介します。

  • リポジトリを公開したその日のうちに、チームメンバーが会話履歴のmarkdown表示機能をコミットしてくれた
  • 「使い始めました」と言った同僚が、同日にセットアップ自動化のPRを投げてきた
  • プロトタイプ段階から仕様書(SPEC.md)とテスト・CIを整備していたので、複数人開発への移行がスムーズだった
  • 専用のSlackチャンネルが立ち、機能追加のPRが日々流れるようになった。公開から5ヶ月ほどでPRの通し番号は140を超えた
  • 「名前が社内の別ツールと紛らわしい」という指摘からリネーム大会が開催され、AIにマスコットキャラ候補をいくつも提案させた末に今の名前とロゴが決まった

個人的に印象深かったのは、resume機能(終了したセッションを一覧から復活させる機能)を追加した翌朝、自分のPCの再起動で全セッションが吹き飛び、前日に作った機能に自分が救われたことです。ドッグフーディングの効用を身をもって体験しました。

道具に不満があったら自分たちで作る、作ったら共有する、共有されたら乗っかって育てる。この一連の流れが自然発生するのがエムスリーのエンジニア組織の好きなところです。

こうした「自作して理解する」文化はチーム内に限った話ではなく、直近のテックブログでも次のような記事が公開されています。あわせてどうぞ。

www.m3tech.blog

www.m3tech.blog

まとめ: 「自作する」も選択しやすい時代

Claude Codeのセッション管理は、いまや選択肢が豊富です。Claude Code公式のAgent View(執筆時点でresearch preview)をはじめ、Claude Desktop、cmux、herdrなど、完成度の高いツールが次々と登場しています。

これらの既存ツールに乗っかるのは、もちろん良い選択です。足りない機能があればissueやPRでOSSに貢献する道もありますし、forkしてカスタマイズする手もあります。そして今は、そこに並ぶかたちで「自作する」もぐっと選択しやすい時代になったと感じています。

自作の付加価値は2つあります。1つは 学びがそのまま残る こと。hooksの発火タイミングや限界、ttyとAppleScriptの連携といった、普段ブラックボックスのままの足回りを、手を動かしながら体で理解できました。もう1つは 欲しいものをすぐ形にできる楽しさ です。欲しい機能を思いついたその日に実装して、翌日から自分のワークフローが良くなる。この速度感は自作ならではでした。

AIに丸投げして動くものを量産するのではなく、仕組みを理解しながらAIで開発効率を上げる。そんな道具づくりの選択肢を、ぜひ持ってみてください。

We are Hiring!

エムスリーでは、道具から自分たちで作ってしまうエンジニアたちが、医療×テクノロジーの課題に日々取り組んでいます。こうした文化に興味を持っていただけた方は、ぜひカジュアル面談でお話ししましょう。

jobs.m3.com

*1:Claude Code hooksの公式リファレンス: Hooks reference