8
10

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Claude Code Hooks でリアルタイム監視基盤を設計する ── アーキテクチャ解説

8
Last updated at Posted at 2026-02-27

はじめに

本記事は 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つあります。

  1. 依存ゼロ -- Hook スクリプトは bash だけで動く必要がある
  2. アトミック書き込み -- POSIX では 4KB 以下の echo >> は不可分
  3. デバッグ容易性 -- tail -f で内容を即座に確認できる

セッション単位のファイル分割

イベントはセッション ID ごとに独立したファイルに保存します。

~/.claude/dashboard-events/
  ├── abc123.jsonl    # セッション abc123 のイベント
  ├── def456.jsonl    # セッション def456 のイベント
  └── ghi789.jsonl

この分割により、特定セッションの削除やアーカイブがファイル操作だけで完結します。

バイトオフセットによる差分読み取り

サーバー側の核心部分です。ファイル全体を毎回読み直すのではなく、前回読んだ位置をバイト単位で記憶します。

src/dashboard.ts
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 は短時間に複数回発火することがあります。タイマーで束ねて処理します。

src/dashboard.ts
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 接続時に現在の全状態をスナップショットとして送信します。これにより、ブラウザはいつ接続しても最新の状態を即座に描画できます。

src/dashboard.ts
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)が含まれています。この情報を使い、セッションを横断してプロジェクト単位に集約します。

src/dashboard.ts
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 がインストールされていない可能性があります。

hook/capture-event.sh
#!/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つあります。

  1. set -euo pipefail -- エラーを見逃さない。未定義変数の参照も防ぐ
  2. session_id が空なら即座に exit 0 -- 不正な入力で余計なファイルを作らない
  3. echo >> file -- 追記のみ。POSIX で 4KB 以下ならアトミック

grep + sed による JSON パースは完全ではありません。ネストした JSON や特殊文字を含む場合、誤った値を取得する可能性があります。可能な限り jq のインストールを推奨します。

設計判断 5: ゼロ依存サーバー

外部パッケージなしの設計

package.json を見てください。

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 がなくても tsxts-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: * を全レスポンスに設定し、外部ツールからの利用を想定する
src/dashboard.ts
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 オブジェクトに集約しています。

src/dashboard-html.ts
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 活用パターン集
8
10
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
8
10

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?