はじめに
本記事は Claude Code Hooks シリーズの第2回です。
前回の記事「Claude Code Hooks で開発ワークフローを自動化する ── 全14イベント徹底解説」では、Hooks の仕組みと14種類のイベントを網羅的に解説しました。stdin から JSON を受け取り、exit code で制御するという基本構造を把握した上で、次に出てくる疑問は「実際のプロダクトで Hooks をどう活用するか」でしょう。
そこで、Hooks を使ったリアルタイム監視ダッシュボードを設計・実装しました。本記事ではそのアーキテクチャと設計判断を掘り下げます。コードの全体は GitHub リポジトリに公開しています。
本記事はアーキテクチャの解説が中心です。コードの抜粋は設計意図を伝えるために掲載しており、完全なソースコードは GitHub を参照してください。
何を作ったか
Claude Code の操作をリアルタイムで可視化するダッシュボードです。ブラウザを開くだけで、いま Claude が何をしているかを俯瞰できます。
画面は6つのパネルで構成されています。
| パネル | 表示内容 |
|---|---|
| Stats | セッション数、イベント総数、エラー数 |
| Event Feed | 直近のイベントをリアルタイム表示 |
| Tasks | TaskCreate / TaskUpdate から抽出したタスク一覧 |
| Tool Usage | ツール別の使用回数を棒グラフで表示 |
| Changed Files | Edit / Write / MultiEdit で変更されたファイル |
| Questions | AskUserQuestion の質問と回答の履歴 |
プロジェクト(作業ディレクトリ)単位でフィルタリングでき、複数プロジェクトを横断して監視できます。
全体アーキテクチャ
まず、データがどのように流れるかを俯瞰します。
各コンポーネントの役割は明確に分離されています。
設計のポイントは、Hook スクリプトとサーバーがファイルシステムを介して疎結合になっている点です。Hook はファイルに書くだけ。サーバーはファイルを読むだけ。両者に直接の依存はありません。
設計判断 1: ファイルベースのイベントキュー
なぜ DB ではなく JSONL か
イベントの永続化に SQLite や Redis ではなく、単純な JSONL ファイルを選びました。理由は3つあります。
- 依存ゼロ -- Hook スクリプトは bash だけで動く必要がある
-
アトミック書き込み -- POSIX では 4KB 以下の
echo >>は不可分 -
デバッグ容易性 --
tail -fで内容を即座に確認できる
セッション単位のファイル分割
イベントはセッション ID ごとに独立したファイルに保存します。
~/.claude/dashboard-events/
├── abc123.jsonl # セッション abc123 のイベント
├── def456.jsonl # セッション def456 のイベント
└── ghi789.jsonl
この分割により、特定セッションの削除やアーカイブがファイル操作だけで完結します。
バイトオフセットによる差分読み取り
サーバー側の核心部分です。ファイル全体を毎回読み直すのではなく、前回読んだ位置をバイト単位で記憶します。
loadSessionFile(sessionId: string): DashboardEvent[] {
const filePath = path.join(this.stateDir, `${sessionId}.jsonl`);
if (!fs.existsSync(filePath)) return [];
let state = this.sessions.get(sessionId);
if (!state) {
state = { byteOffset: 0, events: [] };
this.sessions.set(sessionId, state);
}
const stat = fs.statSync(filePath);
if (stat.size <= state.byteOffset) return []; // 新規データなし
const fd = fs.openSync(filePath, 'r');
const buf = Buffer.alloc(stat.size - state.byteOffset);
fs.readSync(fd, buf, 0, buf.length, state.byteOffset);
fs.closeSync(fd);
state.byteOffset = stat.size; // 次回の開始位置を更新
const newEvents: DashboardEvent[] = [];
for (const line of buf.toString('utf-8').split('\n')) {
const trimmed = line.trim();
if (!trimmed) continue;
try {
const raw = JSON.parse(trimmed);
const ts = raw._ts || new Date().toISOString();
const { _ts, ...eventData } = raw;
newEvents.push({ event: eventData as HookEvent, timestamp: ts });
} catch { /* skip malformed */ }
}
state.events.push(...newEvents);
if (state.events.length > this.maxEventsPerSession) {
state.events.splice(0, state.events.length - this.maxEventsPerSession);
}
return newEvents;
}
この方式のメリットを図で示します。
ファイル全体の再パースを避けることで、イベントが蓄積してもパフォーマンスが劣化しません。
デバウンスによるバッチ処理
fs.watch は短時間に複数回発火することがあります。タイマーで束ねて処理します。
private startWatching(): void {
if (!fs.existsSync(this.stateDir))
fs.mkdirSync(this.stateDir, { recursive: true });
this.watcher = fs.watch(this.stateDir, (_eventType, filename) => {
if (!filename || !filename.endsWith('.jsonl')) return;
if (this.debounceTimer) clearTimeout(this.debounceTimer);
this.debounceTimer = setTimeout(() => {
const sessionId = filename.replace(/\.jsonl$/, '');
const newEvents = this.loadSessionFile(sessionId);
for (const entry of newEvents) this.broadcast(entry);
if (newEvents.length > 0) this.broadcastStatsUpdate();
}, this.debounceMs);
});
}
デフォルトの debounceMs は 100ms です。Claude Code が連続でツールを実行しても、サーバーの負荷が急増しない設計になっています。
設計判断 2: Server-Sent Events (SSE)
なぜ WebSocket ではなく SSE か
ダッシュボードの通信要件を整理します。
データの流れはサーバーからブラウザへの一方向のみです。ブラウザからサーバーへリアルタイムにデータを送る必要はありません。
| 比較項目 | WebSocket | SSE |
|---|---|---|
| 通信方向 | 双方向 | サーバー -> クライアント |
| プロトコル | 独自 | HTTP |
| 自動再接続 | 自前で実装 | ブラウザが自動で行う |
| プロキシ透過 | 問題が起きやすい | HTTP なので透過 |
| 実装量 | 多い | 少ない |
単方向で十分なユースケースに双方向プロトコルを持ち込む理由はありません。
init イベントで初期状態を一括送信
SSE 接続時に現在の全状態をスナップショットとして送信します。これにより、ブラウザはいつ接続しても最新の状態を即座に描画できます。
private handleSSE(
req: http.IncomingMessage,
res: http.ServerResponse
): void {
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
});
this.sseClients.add(res);
// 接続直後に全状態のスナップショットを送信
res.write(`event: init\ndata: ${JSON.stringify({
stats: this.computeStats(),
events: this.getRecentEvents(200),
projects: this.getProjects(),
tasks: this.extractAllTasks(),
questions: this.extractAllQuestions(),
changedFiles: this.extractAllChangedFiles(),
})}\n\n`);
// heartbeat で接続を維持
const ping = setInterval(() => {
if (!res.destroyed) res.write(': ping\n\n');
else clearInterval(ping);
}, this.heartbeatMs);
req.on('close', () => {
clearInterval(ping);
this.sseClients.delete(res);
});
}
SSE のイベント設計は3種類に絞っています。
- init -- 接続時に1回だけ送信する。6パネル分のデータを一括で渡す
- hook-event -- 新しいイベントが発生するたびに送信する
- stats-update -- hook-event と同時に統計を再計算して送信する
heartbeat で接続維持
30秒ごとに SSE コメント(: ping)を送信します。これはプロキシやロードバランサーによるアイドルタイムアウトを防ぐためです。SSE の仕様では : で始まる行はコメントとして扱われ、クライアントのイベントハンドラには届きません。
設計判断 3: プロジェクトグルーピング
cwd ベースのマルチプロジェクト対応
Claude Code のイベントには作業ディレクトリ(cwd)が含まれています。この情報を使い、セッションを横断してプロジェクト単位に集約します。
getProjects(): ProjectGroup[] {
const groups = new Map<
string,
{ sessions: Set<string>; events: DashboardEvent[] }
>();
for (const [sessionId, state] of this.sessions) {
for (const { event, timestamp } of state.events) {
const cwd = event.cwd || 'unknown';
let group = groups.get(cwd);
if (!group) {
group = { sessions: new Set(), events: [] };
groups.set(cwd, group);
}
group.sessions.add(sessionId);
group.events.push({ event, timestamp });
}
}
const projects: ProjectGroup[] = [];
for (const [cwd, group] of groups) {
const tools: Record<string, number> = {};
let errors = 0;
const changedFiles = new Set<string>();
for (const { event: ev } of group.events) {
if (ev.hook_event_name === 'PostToolUse' && ev.tool_name) {
tools[ev.tool_name] = (tools[ev.tool_name] || 0) + 1;
if (ev.tool_response?.error ||
ev.tool_response?.type === 'error') errors++;
if ((ev.tool_name === 'Edit' ||
ev.tool_name === 'Write' ||
ev.tool_name === 'MultiEdit') &&
ev.tool_input?.file_path) {
changedFiles.add(ev.tool_input.file_path);
}
}
}
projects.push({
cwd,
projectName: path.basename(cwd),
sessions: Array.from(group.sessions),
stats: {
totalEvents: group.events.length,
sessions: group.sessions.size,
tools,
errors,
},
events: group.events,
changedFiles: Array.from(changedFiles),
});
}
return projects.sort((a, b) => b.events.length - a.events.length);
}
集約のアルゴリズムは単純です。全セッションのイベントを走査し、cwd をキーにしてグループ化します。各グループ内でツール使用回数、エラー数、変更ファイルを集計し、ProjectGroup として返します。
path.basename(cwd) でディレクトリ名をプロジェクト名として使用しています。同名ディレクトリが異なるパスに存在する場合は、フルパスで区別されます。
設計判断 4: Hook スクリプトの堅牢性
jq 依存を避ける fallback
Hook スクリプトは Claude Code が実行するたびに呼ばれます。環境によっては jq がインストールされていない可能性があります。
#!/usr/bin/env bash
set -euo pipefail
STATE_DIR="${CLAUDE_DASHBOARD_DIR:-$HOME/.claude/dashboard-events}"
mkdir -p "$STATE_DIR"
INPUT="$(cat)"
# jq があれば使う。なければ grep + sed で代替する
if command -v jq >/dev/null 2>&1; then
SESSION_ID="$(echo "$INPUT" | jq -r '.session_id // empty')"
else
SESSION_ID="$(echo "$INPUT" | \
grep -o '"session_id"\s*:\s*"[^"]*"' | \
head -1 | \
sed 's/.*"\([^"]*\)"$/\1/')"
fi
if [ -z "$SESSION_ID" ]; then
exit 0
fi
TS="$(date -u +"%Y-%m-%dT%H:%M:%SZ")"
if command -v jq >/dev/null 2>&1; then
echo "$INPUT" | jq -c --arg ts "$TS" '. + {_ts: $ts}' \
>> "$STATE_DIR/${SESSION_ID}.jsonl"
else
LINE="$(echo "$INPUT" | tr -d '\n' | \
sed "s/}$/,\"_ts\":\"$TS\"}/")"
echo "$LINE" >> "$STATE_DIR/${SESSION_ID}.jsonl"
fi
フローを整理します。
設計上の注意点が3つあります。
-
set -euo pipefail-- エラーを見逃さない。未定義変数の参照も防ぐ -
session_idが空なら即座にexit 0-- 不正な入力で余計なファイルを作らない -
echo >> file-- 追記のみ。POSIX で 4KB 以下ならアトミック
grep + sed による JSON パースは完全ではありません。ネストした JSON や特殊文字を含む場合、誤った値を取得する可能性があります。可能な限り jq のインストールを推奨します。
設計判断 5: ゼロ依存サーバー
外部パッケージなしの設計
package.json を見てください。
{
"name": "claude-dashboard",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"start": "bun src/server.ts"
},
"devDependencies": {
"@types/node": "^25.3.2",
"bun-types": "^1.3.10",
"typescript": "^5.9.3"
}
}
dependencies が存在しません。devDependencies は型定義と TypeScript コンパイラのみです。
この判断には理由があります。
-
起動が速い --
node_modulesの解決が不要 - 脆弱性リスクが低い -- 依存グラフが存在しない
-
移植性が高い -- Bun がなくても
tsxやts-nodeで動く
HTTP サーバー、SSE、ファイル監視、JSON パースはすべて Node.js の標準ライブラリで実現できます。Express や Fastify を導入する必要はありません。
API 設計
ダッシュボードが提供する REST API の一覧です。
| エンドポイント | レスポンス型 | 用途 |
|---|---|---|
GET / |
text/html |
ダッシュボード画面の配信 |
GET /events |
text/event-stream |
SSE によるリアルタイムストリーム |
GET /api/stats |
application/json |
全体の統計情報 |
GET /api/events?limit=100 |
application/json |
直近のイベント一覧 |
GET /api/projects |
application/json |
プロジェクト単位の集約データ |
GET /api/tasks |
application/json |
タスクの一覧と状態 |
GET /api/questions |
application/json |
質問と回答の履歴 |
GET /api/files |
application/json |
変更されたファイルの一覧 |
設計の意図は次の通りです。
- SSE と REST を併用 -- SSE はリアルタイム配信に使い、REST は任意タイミングでの取得に使う
-
limit パラメータ --
/api/eventsのみ件数制限を受け付ける。大量のイベントを一度に返さない -
CORS ヘッダー --
Access-Control-Allow-Origin: *を全レスポンスに設定し、外部ツールからの利用を想定する
private handleRequest(
req: http.IncomingMessage,
res: http.ServerResponse
): void {
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS');
if (req.method === 'OPTIONS') {
res.writeHead(200); res.end(); return;
}
const parsed = new URL(
req.url || '/',
`http://${req.headers.host || 'localhost'}`
);
const p = parsed.pathname;
if (p === '/events') { this.handleSSE(req, res); }
else if (p === '/api/stats') { /* 統計を返す */ }
else if (p === '/api/events') { /* イベント一覧を返す */ }
else if (p === '/api/projects') { /* プロジェクト一覧を返す */ }
else if (p === '/api/tasks') { /* タスク一覧を返す */ }
else if (p === '/api/questions') { /* 質問履歴を返す */ }
else if (p === '/api/files') { /* 変更ファイルを返す */ }
else if (p === '/') { /* HTML を返す */ }
else { res.writeHead(404); res.end('Not Found'); }
}
ルーティングはフレームワークを使わず、URL オブジェクトの pathname で分岐しています。エンドポイントが8個なら if-else で十分です。
フロントエンドの状態管理
フレームワークなしのバニラ JS
フロントエンドも外部依存ゼロです。React も Vue も使いません。
状態はグローバルな STATE オブジェクトに集約しています。
const STATE = {
stats: {
totalEvents: 0,
sessions: 0,
tools: {},
errors: 0,
},
changedFiles: [],
projects: [],
activeProject: 'all',
allEvents: [],
knownCwds: new Set(),
tasks: new Map(),
questions: [],
};
STATE は単一のオブジェクトですが、各プロパティが担当するパネルと1対1で対応しています。
| STATE のプロパティ | 対応パネル |
|---|---|
stats |
Stats |
allEvents |
Event Feed |
tasks |
Tasks |
stats.tools |
Tool Usage |
changedFiles |
Changed Files |
questions |
Questions |
activeProject |
プロジェクトタブ(フィルタ) |
イベント駆動の差分更新
SSE から受け取ったイベントに応じて、必要な部分だけ DOM を更新します。
更新の粒度をまとめます。
- Event Feed -- 新しいイベント1件ごとに DOM ノードを1つ追加する。全体の再描画はしない
- Stats -- 数値の書き換えのみ
- Tasks / Questions -- イベント内容に応じて該当する項目だけを更新
-
Tool Usage -- 棒グラフは
stats-updateのたびに再描画する(要素数が少ないため問題ない)
プロジェクトフィルタの切り替え時のみ refreshView() で全パネルを再描画します。これはユーザー操作起因なので許容範囲です。
STATE.allEvents は最大500件に制限しています。古いイベントは先頭から削除され、メモリを圧迫しません。
まとめ
本記事で解説した設計判断を振り返ります。
| 設計判断 | 選択 | 理由 |
|---|---|---|
| イベント永続化 | JSONL ファイル | 依存ゼロ、デバッグ容易 |
| 差分読み取り | バイトオフセット | 蓄積しても性能劣化しない |
| リアルタイム配信 | SSE | 単方向で十分、自動再接続 |
| プロジェクト集約 | cwd ベース | セッション横断の自然な分類軸 |
| JSON パース | jq + grep fallback | 環境を選ばない堅牢性 |
| サーバー依存 | Node.js stdlib のみ | 脆弱性リスク低減、起動高速 |
| フロントエンド | バニラ JS | ビルド不要、即座にデプロイ |
全体を通して「不要な複雑さを持ち込まない」ことを徹底しています。ファイルシステムで十分なら DB を使わない。SSE で十分なら WebSocket を使わない。標準ライブラリで十分ならフレームワークを使わない。
このアーキテクチャは Claude Code のダッシュボード以外にも応用できます。
- CI/CD パイプラインのリアルタイム監視
- ローカル開発サーバーのイベント可視化
- CLI ツールの操作ログ収集と分析
ソースコードの全体は GitHub で公開しています。
シリーズ記事
本記事は Claude Code Hooks シリーズの第2回です。
| 回 | タイトル |
|---|---|
| 第1回 | Claude Code Hooks で開発ワークフローを自動化する ── 全14イベント徹底解説 |
| 第2回 | 本記事 |
| 第3回 | Claude Code Hooks 活用パターン集 |