12
14

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 実践編 ── 全操作を可視化するリアルタイムダッシュボードを作る

12
Last updated at Posted at 2026-02-27

はじめに

前回の記事「Claude Code Hooks で開発ワークフローを自動化する ── 全14イベント徹底解説」では、Hooks の仕組みと全14イベントを解説しました。

今回はその知識を活かして、実際に手を動かします。作るのは Claude Code の全操作をリアルタイムに可視化するダッシュボード です。

ツール呼び出し、タスク管理、ファイル変更、ユーザーへの質問。Claude Code が裏で何をしているか、すべてブラウザで一覧できます。

本記事はコードウォークスルーです。すべてのファイルを順に解説するので、読みながら手元で再現できます。

本記事は Claude Code Hooks ダッシュボードシリーズの 2本目 です。


完成イメージ

ダッシュボードはダークテーマの1画面構成です。6つのパネルで Claude Code の全操作を俯瞰できます。
2026-02-27 21.13.26.png

パネル 内容
Stats セッション数、イベント総数、エラー数
Event Feed 全イベントのリアルタイムログ
Tasks TaskCreate/TaskUpdate の進捗一覧
Tool Usage ツール別の呼び出し回数(棒グラフ)
Changed Files Edit/Write で変更されたファイル一覧
Questions AskUserQuestion の質問と回答

上部にはプロジェクトタブがあり、作業ディレクトリ別にフィルタリングできます。接続状態はヘッダー右上のドットで確認できます。


プロジェクト構成

claude-dashboard/
├── hook/
│   └── capture-event.sh    # Hook スクリプト(イベント収集)
├── src/
│   ├── server.ts           # エントリーポイント
│   ├── dashboard.ts        # Dashboard クラス(イベント収集・HTTP・SSE)
│   └── dashboard-html.ts   # フロントエンド UI(HTML/CSS/JS)
├── package.json
└── tsconfig.json

ファイルは全部で6つ。外部ライブラリへの依存はゼロです。Bun の標準機能だけで動きます。


アーキテクチャ概要

データの流れを図にします。

ポイントは3つあります。

  1. Hook スクリプトはファイルに書くだけ。サーバーとの直接通信は不要です。
  2. fs.watch による変更検知。ポーリングではなく OS のファイル監視を使います。
  3. SSE(Server-Sent Events)でプッシュ。WebSocket より軽量で、再接続も簡単です。

Step 1: Hook スクリプト

最初に作るのは、Claude Code からイベントを受け取るシェルスクリプトです。

hook/capture-event.sh
#!/usr/bin/env bash
# capture-event.sh — Append Claude Code hook events to per-session JSONL files.
#
# Usage:
#   echo '{"hook_event_name":"Stop","session_id":"abc",...}' | bash capture-event.sh
#
# Output directory: ~/.claude/dashboard-events/{session_id}.jsonl

set -euo pipefail

STATE_DIR="${CLAUDE_DASHBOARD_DIR:-$HOME/.claude/dashboard-events}"
mkdir -p "$STATE_DIR"

# Read full stdin
INPUT="$(cat)"

# Extract session_id (jq if available, otherwise grep fallback)
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

# Add _ts timestamp and append to session file (atomic for < 4KB on POSIX)
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
  # Fallback: inject _ts before closing brace
  LINE="$(echo "$INPUT" | tr -d '\n' | sed "s/}$/,\"_ts\":\"$TS\"}/")"
  echo "$LINE" >> "$STATE_DIR/${SESSION_ID}.jsonl"
fi

解説

保存先の決定

STATE_DIR="${CLAUDE_DASHBOARD_DIR:-$HOME/.claude/dashboard-events}"
mkdir -p "$STATE_DIR"

環境変数 CLAUDE_DASHBOARD_DIR があればそれを使います。なければ ~/.claude/dashboard-events/ がデフォルトです。mkdir -p で初回実行時にディレクトリを自動作成します。

stdin からの読み取り

Claude Code の Hook は、イベント情報を JSON 形式で stdin に渡します。cat で全体を読み取り、変数 INPUT に格納します。

session_id の抽出

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

jq があればそれを使い、なければ grep + sed でフォールバックします。session_id が空ならスクリプトは何もせず終了します。

タイムスタンプの付与と追記

TS="$(date -u +"%Y-%m-%dT%H:%M:%SZ")"
echo "$INPUT" | jq -c --arg ts "$TS" '. + {_ts: $ts}' >> "$STATE_DIR/${SESSION_ID}.jsonl"

UTC タイムスタンプを _ts フィールドとして追加します。出力先は {session_id}.jsonl で、セッションごとにファイルが分かれます。JSONL 形式(1行1JSON)なので、追記が安全です。

POSIX では 4KB 未満の write はアトミックです。1イベントの JSON は通常これに収まるため、複数プロセスからの同時追記でも壊れません。


Step 2: settings.json の設定

Hook スクリプトを Claude Code に登録します。~/.claude/settings.json に以下を追加してください。

~/.claude/settings.json
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "",
        "hooks": [
          {
            "type": "command",
            "command": "bash /path/to/claude-dashboard/hook/capture-event.sh"
          }
        ]
      }
    ],
    "PostToolUse": [
      {
        "matcher": "",
        "hooks": [
          {
            "type": "command",
            "command": "bash /path/to/claude-dashboard/hook/capture-event.sh"
          }
        ]
      }
    ],
    "Stop": [
      {
        "matcher": "",
        "hooks": [
          {
            "type": "command",
            "command": "bash /path/to/claude-dashboard/hook/capture-event.sh"
          }
        ]
      }
    ],
    "SessionStart": [
      {
        "matcher": "",
        "hooks": [
          {
            "type": "command",
            "command": "bash /path/to/claude-dashboard/hook/capture-event.sh"
          }
        ]
      }
    ],
    "SessionEnd": [
      {
        "matcher": "",
        "hooks": [
          {
            "type": "command",
            "command": "bash /path/to/claude-dashboard/hook/capture-event.sh"
          }
        ]
      }
    ]
  }
}

/path/to/claude-dashboard/ は実際のパスに置き換えてください。~ はシェル展開されないため、絶対パスで指定します。

登録するイベント

イベント 取得できる情報
SessionStart セッションの開始
PreToolUse ツール呼び出しの直前(入力パラメータ)
PostToolUse ツール呼び出しの直後(結果を含む)
Stop Claude の応答完了
SessionEnd セッションの終了

matcher が空文字列の場合、すべてのツールにマッチします。特定のツールだけ対象にしたい場合は "Edit" のようにツール名を指定できます。

前回記事の「全14イベント詳細解説」で各イベントの stdin に渡される JSON の構造を解説しています。


Step 3: 型定義と Dashboard クラス

サーバーサイドの中核は dashboard.ts です。イベント収集、統計計算、HTTP ルーティング、SSE 配信を1クラスにまとめています。

型定義

src/dashboard.ts
import * as http from 'http';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { getDashboardHTML } from './dashboard-html';

// Minimal types — no SDK dependency
interface HookEvent {
  hook_event_name: string;
  session_id: string;
  cwd: string;
  tool_name?: string;
  tool_input?: Record<string, any>;
  tool_response?: Record<string, any>;
  [key: string]: any;
}

export interface DashboardOptions {
  port: number;
  stateDir?: string;
  maxEventsPerSession?: number;
  debounceMs?: number;
  heartbeatMs?: number;
}

export interface DashboardEvent {
  event: HookEvent;
  timestamp: string;
}

export interface DashboardStats {
  totalEvents: number;
  sessions: number;
  tools: Record<string, number>;
  errors: number;
}

HookEvent は Claude Code が stdin に渡す JSON の型です。hook_event_namesession_id は常に存在します。tool_nametool_input はツール系イベントのみです。

DashboardOptions でサーバーの動作を調整できます。

プロパティ デフォルト 説明
port 3001 HTTP サーバーのポート
stateDir ~/.claude/dashboard-events JSONL の格納先
maxEventsPerSession 1000 セッション毎のイベント上限
debounceMs 100 ファイル監視のデバウンス間隔
heartbeatMs 30000 SSE の keepalive 間隔

続いて、タスク・質問・プロジェクト関連の型です。

src/dashboard.ts(続き)
export interface DashboardTask {
  id: string;
  subject: string;
  status: 'pending' | 'in_progress' | 'completed' | 'deleted';
  description?: string;
  sessionId: string;
  cwd: string;
  timestamp: string;
}

export interface DashboardQuestion {
  questionText: string;
  header?: string;
  options?: Array<{ label: string; description?: string }>;
  answer?: string;
  timestamp: string;
  cwd: string;
  sessionId: string;
}

export interface ChangedFileEntry {
  path: string;
  cwd: string;
}

export interface ProjectGroup {
  cwd: string;
  projectName: string;
  sessions: string[];
  stats: DashboardStats;
  events: DashboardEvent[];
  changedFiles: string[];
}

DashboardTask は Claude が TaskCreate / TaskUpdate ツールを使ったときに生成されます。DashboardQuestionAskUserQuestion ツールの履歴です。ProjectGroup は作業ディレクトリ(cwd)ごとにイベントをまとめた集計単位です。

コンストラクタとセッション管理

src/dashboard.ts(続き)
interface SessionFileState {
  byteOffset: number;
  events: DashboardEvent[];
}

export class Dashboard {
  private options: DashboardOptions;
  private stateDir: string;
  private maxEventsPerSession: number;
  private debounceMs: number;
  private heartbeatMs: number;
  private sessions: Map<string, SessionFileState> = new Map();
  private server?: http.Server;
  private sseClients: Set<http.ServerResponse> = new Set();
  private watcher?: fs.FSWatcher;
  private debounceTimer?: ReturnType<typeof setTimeout>;

  constructor(options: DashboardOptions) {
    this.options = options;
    this.stateDir = options.stateDir
      ?? path.join(os.homedir(), '.claude', 'dashboard-events');
    this.maxEventsPerSession = options.maxEventsPerSession ?? 1000;
    this.debounceMs = options.debounceMs ?? 100;
    this.heartbeatMs = options.heartbeatMs ?? 30000;
  }

SessionFileState が内部的なファイル読み取り状態です。byteOffset でファイルの「どこまで読んだか」を記録し、差分読み取りを実現します。

sessionsMap<string, SessionFileState> で、セッション ID をキーにしてイベントを保持します。sseClients は接続中のブラウザを管理する Set です。

JSONL ファイルの読み込み

src/dashboard.ts(続き)
  loadAllSessions(): void {
    if (!fs.existsSync(this.stateDir)) return;
    const files = fs.readdirSync(this.stateDir)
      .filter((f: string) => f.endsWith('.jsonl'));
    for (const file of files) {
      this.loadSessionFile(file.replace(/\.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;
  }

loadSessionFile のポイントは 差分読み取り です。

fs.openSyncfs.readSync でバイト単位の位置指定読み取りを行います。ファイル全体を毎回読み直さないため、セッションが長くなっても高速です。

maxEventsPerSession を超えた古いイベントは splice で切り捨てます。メモリの肥大化を防ぐためです。

統計の計算

src/dashboard.ts(続き)
  computeStats(): DashboardStats {
    let totalEvents = 0;
    const tools: Record<string, number> = {};
    let errors = 0;

    for (const [, state] of this.sessions) {
      totalEvents += state.events.length;
      for (const { event: ev } of state.events) {
        if (ev.hook_event_name === 'PostToolUse' && ev.tool_name) {
          tools[ev.tool_name] = (tools[ev.tool_name] || 0) + 1;
          if (ev.tool_response &&
              (ev.tool_response.error ||
               ev.tool_response.type === 'error')) {
            errors++;
          }
        }
      }
    }

    return { totalEvents, sessions: this.sessions.size, tools, errors };
  }

全セッションを走査し、PostToolUse イベントからツール利用回数を集計します。tool_response にエラーがあれば errors をカウントします。

プロジェクト別集計

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 &&
              (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(作業ディレクトリ)でグループ化します。同じセッションが複数ディレクトリで作業することもあるため、セッション単位ではなくイベント単位で分類します。

path.basename(cwd) でディレクトリ名だけを取り出し、タブの表示名にします。

タスクの抽出

src/dashboard.ts(続き)
  extractAllTasks(): Record<string, DashboardTask> {
    const tasks: Record<string, DashboardTask> = {};
    for (const [sessionId, state] of this.sessions) {
      for (const { event: ev, timestamp } of state.events) {
        if (ev.hook_event_name !== 'PostToolUse') continue;
        const tn = ev.tool_name;
        const input = ev.tool_input || {};
        const resp = ev.tool_response || {};

        if (tn === 'TaskCreate' || tn === 'TodoWrite') {
          const id = resp.task?.id || input.taskId
            || input.id || String(Object.keys(tasks).length + 1);
          const key = `${sessionId}:${id}`;
          tasks[key] = {
            id,
            subject: input.subject || input.title
              || resp.task?.subject || '(untitled)',
            status: 'pending',
            description: input.description,
            sessionId,
            cwd: ev.cwd || '',
            timestamp,
          };
        } else if (tn === 'TaskUpdate') {
          const id = input.taskId || input.id;
          if (!id) continue;
          const key = `${sessionId}:${id}`;
          if (tasks[key]) {
            if (input.status) tasks[key].status = input.status;
            if (input.subject) tasks[key].subject = input.subject;
            if (input.description)
              tasks[key].description = input.description;
            tasks[key].timestamp = timestamp;
          } else {
            tasks[key] = {
              id,
              subject: input.subject || '(untitled)',
              status: input.status || 'pending',
              description: input.description,
              sessionId,
              cwd: ev.cwd || '',
              timestamp,
            };
          }
        }
      }
    }
    return tasks;
  }

PostToolUse イベントのうち、tool_nameTaskCreateTodoWriteTaskUpdate のものを対象にします。キーは {sessionId}:{taskId} で、セッション間の ID 衝突を防ぎます。

TaskUpdate は既存タスクのステータスや件名を上書きします。該当タスクが見つからない場合は新規作成として扱います。

質問の抽出

src/dashboard.ts(続き)
  extractAllQuestions(): DashboardQuestion[] {
    const questions: DashboardQuestion[] = [];
    for (const [sessionId, state] of this.sessions) {
      for (const { event: ev, timestamp } of state.events) {
        if (ev.hook_event_name !== 'PostToolUse'
            || ev.tool_name !== 'AskUserQuestion') continue;
        const input = ev.tool_input || {};
        const resp = ev.tool_response || {};
        const qs = input.questions || [];
        const q = qs[0];
        if (!q) continue;
        const answer = resp.answer !== undefined
          ? (typeof resp.answer === 'object'
              ? JSON.stringify(resp.answer)
              : String(resp.answer))
          : (resp.result !== undefined
              ? String(resp.result) : undefined);
        questions.push({
          questionText: q.question || '',
          header: q.header,
          options: q.options,
          answer,
          timestamp,
          cwd: ev.cwd || '',
          sessionId,
        });
      }
    }
    return questions.slice(-50);
  }

AskUserQuestion ツールの tool_input.questions[0] から質問テキストを、tool_response.answer から回答を取り出します。直近50件に絞ることで、UI の表示量を適切に保ちます。

変更ファイルの抽出

src/dashboard.ts(続き)
  extractAllChangedFiles(): ChangedFileEntry[] {
    const seen = new Set<string>();
    const files: ChangedFileEntry[] = [];
    for (const [, state] of this.sessions) {
      for (const { event: ev } of state.events) {
        if (ev.hook_event_name !== 'PostToolUse') continue;
        if (ev.tool_name !== 'Edit'
            && ev.tool_name !== 'Write'
            && ev.tool_name !== 'MultiEdit') continue;
        const fp = ev.tool_input?.file_path;
        if (!fp) continue;
        const key = `${ev.cwd || ''}:${fp}`;
        if (seen.has(key)) continue;
        seen.add(key);
        files.push({ path: fp, cwd: ev.cwd || '' });
      }
    }
    return files;
  }

  getRecentEvents(limit?: number): DashboardEvent[] {
    const all: DashboardEvent[] = [];
    for (const [, state] of this.sessions) all.push(...state.events);
    all.sort((a, b) => a.timestamp.localeCompare(b.timestamp));
    return limit ? all.slice(-limit) : all;
  }

EditWriteMultiEdit ツールの file_path を重複排除しながら収集します。cwd:file_path をキーにすることで、異なるプロジェクトの同名ファイルを区別できます。

ファイル監視と SSE 配信

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);
    });
  }

  private broadcast(entry: DashboardEvent): void {
    const data = JSON.stringify(entry);
    for (const client of this.sseClients) {
      if (!client.destroyed)
        client.write(`event: hook-event\ndata: ${data}\n\n`);
    }
  }

  private broadcastStatsUpdate(): void {
    const data = JSON.stringify({
      stats: this.computeStats(),
      projects: this.getProjects(),
    });
    for (const client of this.sseClients) {
      if (!client.destroyed)
        client.write(`event: stats-update\ndata: ${data}\n\n`);
    }
  }

fs.watch は OS レベルのファイル監視です。.jsonl ファイルに変更があると、100ms のデバウンス後に差分を読み込みます。

デバウンスが重要な理由は、1回の Hook 実行でファイルシステムイベントが複数回発火する場合があるためです。100ms の遅延で重複を吸収します。

SSE 配信は2種類のイベントを送ります。

SSE イベント名 内容
hook-event 個別のイベント1件
stats-update 統計とプロジェクト一覧の更新

HTTP ルーティング

src/dashboard.ts(続き)
  async start(): Promise<void> {
    this.loadAllSessions();
    this.startWatching();
    if (this.options.port === 0) return;

    return new Promise((resolve) => {
      this.server = http.createServer(
        (req, res) => this.handleRequest(req, res)
      );
      this.server.listen(this.options.port, () => {
        console.log(
          `Dashboard running at http://localhost:${this.options.port}`
        );
        resolve();
      });
    });
  }

  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') {
      res.writeHead(200, { 'Content-Type': 'application/json' });
      res.end(JSON.stringify(this.computeStats()));
    } else if (p === '/api/events') {
      const l = parseInt(
        parsed.searchParams.get('limit') || '100', 10
      );
      res.writeHead(200, { 'Content-Type': 'application/json' });
      res.end(JSON.stringify(this.getRecentEvents(l)));
    } else if (p === '/api/files') {
      res.writeHead(200, { 'Content-Type': 'application/json' });
      res.end(JSON.stringify(this.extractAllChangedFiles()));
    } else if (p === '/api/projects') {
      res.writeHead(200, { 'Content-Type': 'application/json' });
      res.end(JSON.stringify(this.getProjects()));
    } else if (p === '/api/tasks') {
      res.writeHead(200, { 'Content-Type': 'application/json' });
      res.end(JSON.stringify(this.extractAllTasks()));
    } else if (p === '/api/questions') {
      res.writeHead(200, { 'Content-Type': 'application/json' });
      res.end(JSON.stringify(this.extractAllQuestions()));
    } else if (p === '/') {
      res.writeHead(200, { 'Content-Type': 'text/html' });
      res.end(getDashboardHTML());
    } else {
      res.writeHead(404); res.end('Not Found');
    }
  }

Express などのフレームワークは使いません。Node.js 標準の http.createServer だけでルーティングしています。

パス レスポンス
GET / ダッシュボード HTML
GET /events SSE ストリーム
GET /api/stats 統計 JSON
GET /api/events?limit=N 直近イベント JSON
GET /api/projects プロジェクト別集計 JSON
GET /api/tasks タスク一覧 JSON
GET /api/questions 質問一覧 JSON
GET /api/files 変更ファイル一覧 JSON

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`);

    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 接続時に init イベントで現在の全データを送ります。ブラウザはこれで初期描画し、以降は差分の hook-eventstats-update で更新します。

30秒間隔の ping コメントは、プロキシやロードバランサーによるアイドル切断を防ぎます。SSE のコメント行(: ping)はクライアント側では無視されます。

シャットダウン

src/dashboard.ts(続き)
  async stop(): Promise<void> {
    if (this.debounceTimer) clearTimeout(this.debounceTimer);
    if (this.watcher) {
      this.watcher.close();
      this.watcher = undefined;
    }
    for (const c of this.sseClients) {
      if (!c.destroyed) c.end();
    }
    this.sseClients.clear();
    return new Promise((resolve) => {
      if (!this.server) { resolve(); return; }
      this.server.close(() => resolve());
    });
  }
}

リソースを順番に解放します。ファイル監視を停止し、SSE 接続を閉じ、HTTP サーバーを終了します。


Step 4: フロントエンド UI

dashboard-html.ts はテンプレートリテラルで HTML/CSS/JS を返す関数です。SPA フレームワークは使わず、バニラ JavaScript で実装しています。

関数の外枠

src/dashboard-html.ts
export function getDashboardHTML(): string {
  return `<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Claude Code Dashboard</title>
  <style>
    /* CSS がここに入る */
  </style>
</head>
<body>
  <!-- HTML がここに入る -->
  <script>
    /* JavaScript がここに入る */
  </script>
</body>
</html>`;
}

HTML、CSS、JS がすべて1ファイルに収まります。ビルドツールが不要です。

CSS デザインシステム

:root {
  --gray-950: #030712; --gray-900: #111827;
  --gray-800: #1f2937; --gray-700: #374151;
  --gray-600: #4b5563; --gray-500: #6b7280;
  --gray-400: #9ca3af; --gray-300: #d1d5db;
  --gray-200: #e5e7eb; --gray-100: #f3f4f6;
  --white: #ffffff;
  --emerald-300: #6ee7b7; --emerald-400: #34d399;
  --emerald-500: #10b981;
  --sky-300: #7dd3fc; --sky-400: #38bdf8;
  --sky-500: #0ea5e9;
  --amber-300: #fcd34d; --amber-400: #fbbf24;
  --violet-400: #a78bfa; --rose-400: #fb7185;
  --red-400: #f87171;
  --yellow-500: #eab308; --blue-500: #3b82f6;
}

Tailwind CSS のカラーパレットを CSS 変数として定義しています。ダークテーマは --gray-950#030712)をベースに、--gray-900 をカードの背景にしています。

イベントの種類ごとに色を割り当てています。

イベント CSS 変数
SessionStart --emerald-400
SessionEnd ピンク --rose-400
PreToolUse 水色 --sky-300
PostToolUse --sky-400
Stop 黄色 --amber-400
UserPromptSubmit --violet-400

グリッドレイアウト

.grid {
  display: grid;
  grid-template-columns: 1fr 2fr 1fr;
  grid-template-rows: 1fr 1fr;
  gap: 1rem;
  padding: 1rem;
  flex: 1;
  min-height: 0;
}

3列 x 2行のグリッドです。中央列を 2fr にしてイベントフィードに広い領域を確保しています。

cardcard-scroll の2種類のカードがあります。card-scroll は内部にスクロール領域を持ちます。イベントフィード、タスク、変更ファイル、質問は量が増えるため card-scroll を使います。

HTML 構造

<div class="app">
  <header class="header">
    <h1>Claude Code Dashboard</h1>
    <div class="status">
      <span id="status-dot"
            class="status-dot connecting pulse"></span>
      <span id="status-text">Connecting...</span>
    </div>
  </header>

  <nav id="project-tabs" class="nav">
    <button class="project-tab active"
            data-project="all">All Projects</button>
  </nav>

  <main class="grid">
    <!-- 6つのパネルがここに配置される -->
  </main>
</div>

レイアウトは3層構造です。ヘッダー(タイトル + 接続状態)、ナビゲーション(プロジェクトタブ)、メイングリッド(6パネル)です。

JavaScript: 状態管理

const STATE = {
  stats: { totalEvents: 0, sessions: 0, tools: {}, errors: 0 },
  changedFiles: [],
  projects: [],
  activeProject: 'all',
  allEvents: [],
  knownCwds: new Set(),
  tasks: new Map(),
  questions: [],
};

STATE オブジェクトにすべての UI 状態を集約しています。React の useState や Vue の reactive に相当するものをプレーンなオブジェクトで実現しています。

activeProject はプロジェクトタブで選択中の cwd です。'all' なら全プロジェクトを表示します。

JavaScript: SSE 接続

function connect() {
  const es = new EventSource('/events');

  es.addEventListener('init', (e) => {
    const d = JSON.parse(e.data);
    STATE.globalStats = d.stats;
    STATE.changedFiles = d.changedFiles || [];
    STATE.allEvents = d.events || [];
    STATE.projects = d.projects || [];
    STATE.tasks = new Map();
    if (d.tasks) {
      for (const [key, task] of Object.entries(d.tasks))
        STATE.tasks.set(key, task);
    }
    STATE.questions = d.questions || [];
    updateStats(d.stats);
    updateFiles(d.changedFiles || []);
    updateProjectTabs(d.projects || []);
    renderTasks();
    renderQuestions();
    d.events.forEach(e => addEventRow(e));
    setConnected(true);
  });

  es.addEventListener('hook-event', (e) => {
    const entry = JSON.parse(e.data);
    STATE.allEvents.push(entry);
    while (STATE.allEvents.length > 500) STATE.allEvents.shift();
    const ev = entry.event;
    addProjectTab(ev.cwd);
    updateProjectTabCount(ev.cwd);
    addEventRow(entry);
    processEvent(entry);
    // 変更ファイルの検出
    if (ev.hook_event_name === 'PostToolUse' &&
        (ev.tool_name === 'Edit' ||
         ev.tool_name === 'Write' ||
         ev.tool_name === 'MultiEdit') &&
        ev.tool_input?.file_path) {
      const fp = ev.tool_input.file_path;
      if (!STATE.changedFiles.find(f =>
            (typeof f === 'string' ? f : f.path) === fp
            && (typeof f === 'string' || f.cwd === ev.cwd))) {
        STATE.changedFiles.push({ path: fp, cwd: ev.cwd || '' });
      }
    }
  });

  es.addEventListener('stats-update', (e) => {
    const d = JSON.parse(e.data);
    STATE.globalStats = d.stats;
    STATE.projects = d.projects || STATE.projects;
    updateProjectTabs(STATE.projects);
    if (STATE.activeProject === 'all') {
      updateStats(d.stats);
      updateFiles(STATE.changedFiles);
    } else {
      const p = STATE.projects.find(
        p => p.cwd === STATE.activeProject
      );
      if (p) updateStats(p.stats);
    }
  });

  es.onerror = () => {
    setConnected(false);
    es.close();
    setTimeout(connect, 3000);
  };
}

SSE の3つのイベントをそれぞれ処理します。

イベント 処理内容
init 全データで STATE を初期化し、UI を全描画
hook-event 1件のイベントを追加し、該当パネルを更新
stats-update 統計とプロジェクトタブを更新

onerror では3秒後に自動再接続します。ネットワーク切断やサーバー再起動があっても、ブラウザを開き直す必要はありません。

JavaScript: イベントフィードの描画

function addEventRow(entry) {
  const ev = entry.event;
  if (STATE.activeProject !== 'all'
      && ev.cwd !== STATE.activeProject) return;
  const feed = document.getElementById('event-feed');
  const detail = ev.tool_name ? ' ' + ev.tool_name : '';
  const tag = ev.cwd ? ev.cwd.split('/').pop() : '';
  const row = document.createElement('div');
  row.className = 'event-row ev-row';
  row.innerHTML =
    '<span class="ev-time">'
      + formatTime(entry.timestamp) + '</span>' +
    '<span class="ev-project">' + esc(tag) + '</span>' +
    '<span class="ev-name ev-'
      + (ev.hook_event_name || '') + '">'
      + esc(ev.hook_event_name) + '</span>' +
    '<span class="ev-detail">' + esc(detail) + '</span>';
  feed.appendChild(row);
  feed.scrollTop = feed.scrollHeight;
  while (feed.children.length > 200)
    feed.removeChild(feed.firstChild);
}

各行は4つの要素で構成されます。時刻、プロジェクト名、イベント名、詳細(ツール名)です。ev-SessionStart のような CSS クラスを付与することで、イベント種別ごとの色分けを実現しています。

要素が200件を超えると先頭から削除します。DOM ノードの無限増加を防ぐためです。

JavaScript: タスクの処理と描画

function processTaskEvent(ev) {
  if (ev.hook_event_name !== 'PostToolUse') return;
  const tn = ev.tool_name;
  const input = ev.tool_input || {};
  const resp = ev.tool_response || {};
  const sessionId = ev.session_id || '';

  if (tn === 'TaskCreate' || tn === 'TodoWrite') {
    const id = resp.task?.id || input.taskId
      || input.id || String(STATE.tasks.size + 1);
    const key = sessionId + ':' + id;
    STATE.tasks.set(key, {
      id,
      subject: input.subject || input.title
        || resp.task?.subject || '(untitled)',
      status: 'pending',
      description: input.description || '',
      cwd: ev.cwd || '',
      sessionId,
    });
    renderTasks();
  } else if (tn === 'TaskUpdate') {
    const id = input.taskId || input.id;
    if (!id) return;
    const key = sessionId + ':' + id;
    if (STATE.tasks.has(key)) {
      const t = STATE.tasks.get(key);
      if (input.status) t.status = input.status;
      if (input.subject) t.subject = input.subject;
      if (input.description) t.description = input.description;
    }
    renderTasks();
  }
}

サーバーサイドの extractAllTasks と同じロジックをブラウザ側にも持たせています。init 時にはサーバーが計算した結果を受け取り、以降の hook-event ではブラウザ側でインクリメンタルに更新します。

タスクのステータスは3色のバッジで表示されます。

ステータス 背景色 文字色
Pending #374151(灰色) #9ca3af
In Progress #422006(暗い黄色) #fbbf24
Done #052e16(暗い緑) #4ade80

JavaScript: プロジェクトフィルタリング

function updateProjectTabs(projects) {
  STATE.projects = projects;
  STATE.knownCwds = new Set(projects.map(p => p.cwd));
  const c = document.getElementById('project-tabs');
  c.innerHTML =
    '<button class="project-tab ' +
    (STATE.activeProject === 'all' ? 'active' : '') +
    '" data-project="all">All Projects</button>';
  for (const p of projects) {
    const btn = document.createElement('button');
    btn.className = 'project-tab ' +
      (STATE.activeProject === p.cwd ? 'active' : '');
    btn.setAttribute('data-project', p.cwd);
    btn.innerHTML =
      '<span>' + esc(p.projectName) + '</span>' +
      '<span class="tab-count">'
        + '(' + p.stats.totalEvents + ')</span>';
    c.appendChild(btn);
  }
  c.querySelectorAll('.project-tab').forEach(btn =>
    btn.addEventListener('click', () => {
      STATE.activeProject = btn.getAttribute('data-project');
      refreshView();
    })
  );
}

プロジェクトタブをクリックすると STATE.activeProject が変わり、refreshView() で全パネルが再描画されます。選択中のプロジェクトに属するイベントだけがフィルタリング表示されます。

タブにはイベント数がバッジとして表示されるので、どのプロジェクトが活発かひと目でわかります。

JavaScript: 接続状態の表示

function setConnected(on) {
  const dot = document.getElementById('status-dot');
  dot.className = 'status-dot ' +
    (on ? 'connected' : 'disconnected pulse');
  const t = document.getElementById('status-text');
  t.textContent = on ? 'Connected' : 'Disconnected';
  t.className = on ? 'connected-text' : 'disconnected-text';
}

接続中は緑のドット、切断時は赤のドットが点滅します。pulse アニメーションは CSS で定義しています。

完全なソースコード

以下は dashboard-html.ts の全体です。

src/dashboard-html.ts
export function getDashboardHTML(): string {
  return `<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Claude Code Dashboard</title>
  <style>
    :root {
      --gray-950: #030712; --gray-900: #111827; --gray-800: #1f2937; --gray-700: #374151;
      --gray-600: #4b5563; --gray-500: #6b7280; --gray-400: #9ca3af; --gray-300: #d1d5db;
      --gray-200: #e5e7eb; --gray-100: #f3f4f6; --white: #ffffff;
      --emerald-300: #6ee7b7; --emerald-400: #34d399; --emerald-500: #10b981;
      --sky-300: #7dd3fc; --sky-400: #38bdf8; --sky-500: #0ea5e9;
      --amber-300: #fcd34d; --amber-400: #fbbf24;
      --violet-400: #a78bfa; --rose-400: #fb7185; --red-400: #f87171;
      --yellow-500: #eab308; --blue-500: #3b82f6;
    }
    *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
    html, body { height: 100%; background: var(--gray-950); color: var(--gray-100); font-family: system-ui, -apple-system, sans-serif; }
    .bar { transition: width 0.3s ease; }
    @keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } }
    .pulse { animation: pulse 2s ease-in-out infinite; }
    .event-row { animation: fadeIn 0.2s ease-in; }
    @keyframes fadeIn { from { opacity: 0; transform: translateY(-4px); } to { opacity: 1; transform: translateY(0); } }

    .app { display: flex; flex-direction: column; height: 100vh; }
    .header { background: var(--gray-900); border-bottom: 1px solid var(--gray-800); padding: 0.75rem 1.5rem; display: flex; justify-content: space-between; align-items: center; flex-shrink: 0; }
    .header h1 { font-size: 1.125rem; font-weight: 600; color: var(--white); }
    .status { display: flex; align-items: center; gap: 0.5rem; font-size: 0.875rem; }
    .status-dot { width: 0.5rem; height: 0.5rem; border-radius: 9999px; }
    .status-dot.connecting { background: var(--yellow-500); }
    .status-dot.connected { background: var(--emerald-500); }
    .status-dot.disconnected { background: var(--red-400); }

    .nav { background: var(--gray-900); border-bottom: 1px solid var(--gray-800); padding: 0.5rem 1.5rem; display: flex; gap: 0.5rem; overflow-x: auto; flex-shrink: 0; }
    .project-tab { cursor: pointer; transition: all 0.15s ease; font-size: 0.75rem; padding: 0.375rem 0.75rem; border-radius: 0.25rem; border: 1px solid var(--gray-700); color: var(--gray-300); background: transparent; font-family: inherit; }
    .project-tab:hover { background: rgba(255,255,255,0.05); }
    .project-tab.active { background: rgba(59,130,246,0.15); border-color: var(--blue-500); }
    .tab-count { margin-left: 0.375rem; color: var(--gray-500); }

    .grid { display: grid; grid-template-columns: 1fr 2fr 1fr; grid-template-rows: 1fr 1fr; gap: 1rem; padding: 1rem; flex: 1; min-height: 0; }
    .card { background: var(--gray-900); border-radius: 0.5rem; border: 1px solid var(--gray-800); padding: 1rem; overflow-y: auto; }
    .card-scroll { background: var(--gray-900); border-radius: 0.5rem; border: 1px solid var(--gray-800); padding: 1rem; display: flex; flex-direction: column; overflow: hidden; }
    .card-scroll-body { flex: 1; overflow-y: auto; min-height: 0; }
    .card-title { font-size: 0.75rem; font-weight: 500; color: var(--gray-400); margin-bottom: 0.75rem; text-transform: uppercase; letter-spacing: 0.05em; flex-shrink: 0; }

    .stat-row { display: flex; justify-content: space-between; margin-bottom: 0.75rem; }
    .stat-label { color: var(--gray-400); }
    .stat-value { font-family: ui-monospace, monospace; color: var(--white); }
    .stat-value.error { color: var(--red-400); }

    .tool-row { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.5rem; }
    .tool-name { width: 4rem; font-size: 0.75rem; color: var(--gray-400); text-align: right; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
    .tool-bar-bg { flex: 1; background: var(--gray-800); border-radius: 9999px; height: 1rem; overflow: hidden; }
    .tool-bar { background: var(--sky-500); height: 100%; border-radius: 9999px; transition: width 0.3s ease; }
    .tool-count { width: 2rem; font-size: 0.75rem; color: var(--gray-400); text-align: right; }

    .ev-row { display: flex; gap: 0.75rem; padding: 0.125rem 0; font-family: ui-monospace, monospace; font-size: 0.75rem; }
    .ev-time { color: var(--gray-600); width: 4rem; flex-shrink: 0; }
    .ev-project { color: var(--gray-500); width: 5rem; flex-shrink: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
    .ev-name { width: 9rem; flex-shrink: 0; }
    .ev-detail { color: var(--gray-300); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }

    .ev-SessionStart { color: var(--emerald-400); } .ev-SessionEnd { color: var(--rose-400); }
    .ev-UserPromptSubmit { color: var(--violet-400); } .ev-PreToolUse { color: var(--sky-300); }
    .ev-PostToolUse { color: var(--sky-400); } .ev-Stop { color: var(--amber-400); }
    .ev-SubagentStop { color: var(--amber-300); } .ev-Notification { color: var(--gray-400); }
    .ev-PreCompact { color: var(--gray-500); }

    .task-item { display: flex; align-items: flex-start; gap: 0.5rem; padding: 0.25rem 0; border-bottom: 1px solid var(--gray-800); font-size: 0.75rem; }
    .badge { font-size: 10px; padding: 0.125rem 0.375rem; border-radius: 0.25rem; font-weight: 500; flex-shrink: 0; margin-top: 0.125rem; }
    .badge-pending { background: #374151; color: #9ca3af; }
    .badge-in_progress { background: #422006; color: #fbbf24; }
    .badge-completed { background: #052e16; color: #4ade80; }
    .task-content { min-width: 0; }
    .task-subject { color: var(--gray-200); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
    .task-desc { color: var(--gray-500); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; margin-top: 0.125rem; }

    .q-item { padding: 0.375rem 0; border-bottom: 1px solid var(--gray-800); font-size: 0.75rem; }
    .q-header-badge { font-size: 10px; padding: 0.125rem 0.375rem; border-radius: 0.25rem; font-weight: 500; color: var(--sky-400); background: rgba(56,189,248,0.1); }
    .q-text { color: var(--gray-300); }
    .q-opts { display: flex; flex-wrap: wrap; gap: 0.25rem; margin-top: 0.25rem; }
    .q-opt { font-size: 10px; padding: 0.125rem 0.375rem; border-radius: 0.25rem; background: var(--gray-800); color: var(--gray-400); }
    .q-answer { margin-top: 0.25rem; display: flex; align-items: center; gap: 0.375rem; }
    .q-answer-arrow { color: var(--emerald-500); }
    .q-answer-text { color: var(--emerald-300); }
    .q-awaiting { margin-top: 0.25rem; color: var(--gray-600); font-style: italic; }

    .file-item { color: var(--gray-300); padding: 0.125rem 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: ui-monospace, monospace; font-size: 0.75rem; }

    .empty { color: var(--gray-600); font-style: italic; }
    .connected-text { color: var(--emerald-400); }
    .disconnected-text { color: var(--red-400); }
  </style>
</head>
<body>
  <div class="app">
    <header class="header">
      <h1>Claude Code Dashboard</h1>
      <div class="status">
        <span id="status-dot" class="status-dot connecting pulse"></span>
        <span id="status-text" style="color:var(--gray-400)">Connecting...</span>
      </div>
    </header>

    <nav id="project-tabs" class="nav">
      <button class="project-tab active" data-project="all">All Projects</button>
    </nav>

    <main class="grid">
      <div class="card">
        <div class="card-title">Stats</div>
        <div class="stat-row"><span class="stat-label">Sessions</span><span id="stat-sessions" class="stat-value">0</span></div>
        <div class="stat-row"><span class="stat-label">Events</span><span id="stat-events" class="stat-value">0</span></div>
        <div class="stat-row"><span class="stat-label">Errors</span><span id="stat-errors" class="stat-value error">0</span></div>
      </div>

      <div class="card-scroll">
        <div class="card-title">Event Feed</div>
        <div id="event-feed" class="card-scroll-body"></div>
      </div>

      <div class="card-scroll">
        <div class="card-title">Tasks</div>
        <div id="todo-list" class="card-scroll-body"></div>
      </div>

      <div class="card">
        <div class="card-title">Tool Usage</div>
        <div id="tool-chart"></div>
      </div>

      <div class="card-scroll">
        <div class="card-title">Changed Files</div>
        <div id="file-list" class="card-scroll-body"></div>
      </div>

      <div class="card-scroll">
        <div class="card-title">Questions</div>
        <div id="question-list" class="card-scroll-body"></div>
      </div>
    </main>
  </div>

  <script>
    const STATE = {
      stats: { totalEvents: 0, sessions: 0, tools: {}, errors: 0 },
      changedFiles: [], projects: [], activeProject: 'all', allEvents: [], knownCwds: new Set(),
      tasks: new Map(), questions: [],
    };

    const STATUS_LABELS = { pending: 'Pending', in_progress: 'In Progress', completed: 'Done', deleted: 'Deleted' };

    function esc(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
    function formatTime(iso) { return new Date(iso).toLocaleTimeString('en-US', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' }); }
    function formatNumber(n) { if (n >= 1e6) return (n/1e6).toFixed(1)+'M'; if (n >= 1e3) return (n/1e3).toFixed(1)+'k'; return n.toString(); }

    function updateStats(s) {
      STATE.stats = s;
      document.getElementById('stat-sessions').textContent = s.sessions;
      document.getElementById('stat-events').textContent = s.totalEvents;
      document.getElementById('stat-errors').textContent = s.errors;
      updateToolChart(s.tools);
    }

    function updateToolChart(tools) {
      const c = document.getElementById('tool-chart');
      const entries = Object.entries(tools).sort((a,b) => b[1]-a[1]);
      const max = entries.length > 0 ? entries[0][1] : 1;
      c.innerHTML = entries.map(([name,count]) => {
        const pct = Math.round((count/max)*100);
        return '<div class="tool-row"><span class="tool-name">'+esc(name)+'</span><div class="tool-bar-bg"><div class="tool-bar" style="width:'+pct+'%"></div></div><span class="tool-count">'+count+'</span></div>';
      }).join('');
    }

    function addEventRow(entry) {
      const ev = entry.event;
      if (STATE.activeProject !== 'all' && ev.cwd !== STATE.activeProject) return;
      const feed = document.getElementById('event-feed');
      const detail = ev.tool_name ? ' '+ev.tool_name : '';
      const tag = ev.cwd ? ev.cwd.split('/').pop() : '';
      const row = document.createElement('div');
      row.className = 'event-row ev-row';
      row.innerHTML = '<span class="ev-time">'+formatTime(entry.timestamp)+'</span><span class="ev-project">'+esc(tag)+'</span><span class="ev-name ev-'+(ev.hook_event_name||'')+'">'+esc(ev.hook_event_name)+'</span><span class="ev-detail">'+esc(detail)+'</span>';
      feed.appendChild(row);
      feed.scrollTop = feed.scrollHeight;
      while (feed.children.length > 200) feed.removeChild(feed.firstChild);
    }

    function updateFiles(files) {
      STATE.changedFiles = files;
      const filtered = STATE.activeProject === 'all' ? files : files.filter(f => f.cwd === STATE.activeProject);
      document.getElementById('file-list').innerHTML = filtered.map(f => {
        const fp = typeof f === 'string' ? f : f.path;
        return '<div class="file-item">'+esc(fp)+'</div>';
      }).join('');
    }

    function processTaskEvent(ev) {
      if (ev.hook_event_name !== 'PostToolUse') return;
      const tn = ev.tool_name, input = ev.tool_input||{}, resp = ev.tool_response||{};
      const sessionId = ev.session_id || '';
      if (tn === 'TaskCreate' || tn === 'TodoWrite') {
        const id = resp.task?.id || input.taskId || input.id || String(STATE.tasks.size+1);
        const key = sessionId+':'+id;
        STATE.tasks.set(key, { id, subject: input.subject||input.title||resp.task?.subject||'(untitled)', status: 'pending', description: input.description||'', cwd: ev.cwd||'', sessionId });
        renderTasks();
      } else if (tn === 'TaskUpdate') {
        const id = input.taskId||input.id;
        if (!id) return;
        const key = sessionId+':'+id;
        if (STATE.tasks.has(key)) {
          const t = STATE.tasks.get(key);
          if (input.status) t.status=input.status;
          if (input.subject) t.subject=input.subject;
          if (input.description) t.description=input.description;
        } else {
          STATE.tasks.set(key, { id, subject: input.subject||'(untitled)', status: input.status||'pending', description: input.description||'', cwd: ev.cwd||'', sessionId });
        }
        renderTasks();
      }
    }

    function renderTasks() {
      const c = document.getElementById('todo-list');
      const filtered = [];
      for (const [,task] of STATE.tasks) { if (task.status==='deleted') continue; if (STATE.activeProject!=='all' && task.cwd!==STATE.activeProject) continue; filtered.push(task); }
      if (!filtered.length) { c.innerHTML = '<div class="empty">No tasks yet</div>'; return; }
      c.innerHTML = filtered.map(t => '<div class="task-item"><span class="badge badge-'+t.status+'">'+esc(STATUS_LABELS[t.status]||t.status)+'</span><div class="task-content"><div class="task-subject">'+esc(t.subject)+'</div>'+(t.description?'<div class="task-desc">'+esc(t.description)+'</div>':'')+'</div></div>').join('');
    }

    function processQuestionEvent(ev) {
      if (ev.hook_event_name !== 'PostToolUse' || ev.tool_name !== 'AskUserQuestion') return;
      const input = ev.tool_input||{}, resp = ev.tool_response||{};
      const qs = input.questions||[];
      const q = qs[0];
      if (!q) return;
      const answer = resp.answer !== undefined ? (typeof resp.answer === 'object' ? JSON.stringify(resp.answer) : String(resp.answer))
        : (resp.result !== undefined ? String(resp.result) : undefined);
      STATE.questions.push({ questionText: q.question||'', header: q.header||'', options: q.options, answer: answer||null, cwd: ev.cwd||'', sessionId: ev.session_id||'' });
      renderQuestions();
    }

    function renderQuestions() {
      const c = document.getElementById('question-list');
      const filtered = STATE.activeProject==='all' ? STATE.questions : STATE.questions.filter(q => q.cwd===STATE.activeProject);
      if (!filtered.length) { c.innerHTML = '<div class="empty">No questions yet</div>'; return; }
      c.innerHTML = [...filtered].reverse().slice(0,50).map(q => {
        const h = q.header ? '<span class="q-header-badge">'+esc(q.header)+'</span> ' : '';
        const opts = q.options && q.options.length ? '<div class="q-opts">'+q.options.map(o => '<span class="q-opt">'+esc(o.label)+'</span>').join('')+'</div>' : '';
        const a = q.answer ? '<div class="q-answer"><span class="q-answer-arrow">&#10132;</span><span class="q-answer-text">'+esc(q.answer)+'</span></div>' : '<div class="q-awaiting">Awaiting answer...</div>';
        return '<div class="q-item"><div>'+h+'<span class="q-text">'+esc(q.questionText)+'</span></div>'+opts+a+'</div>';
      }).join('');
    }

    function processEvent(entry) { processTaskEvent(entry.event); processQuestionEvent(entry.event); }

    function updateProjectTabs(projects) {
      STATE.projects = projects; STATE.knownCwds = new Set(projects.map(p=>p.cwd));
      const c = document.getElementById('project-tabs');
      c.innerHTML = '<button class="project-tab '+(STATE.activeProject==='all'?'active':'')+'" data-project="all">All Projects</button>';
      for (const p of projects) {
        const btn = document.createElement('button');
        btn.className = 'project-tab '+(STATE.activeProject===p.cwd?'active':'');
        btn.setAttribute('data-project', p.cwd);
        btn.innerHTML = '<span>'+esc(p.projectName)+'</span><span class="tab-count">('+p.stats.totalEvents+')</span>';
        c.appendChild(btn);
      }
      c.querySelectorAll('.project-tab').forEach(btn => btn.addEventListener('click', () => { STATE.activeProject = btn.getAttribute('data-project'); refreshView(); }));
    }

    function addProjectTab(cwd) {
      if (!cwd || STATE.knownCwds.has(cwd)) return;
      STATE.knownCwds.add(cwd);
      STATE.projects.push({ cwd, projectName: cwd.split('/').pop()||cwd, sessions:[], stats:{totalEvents:0,sessions:0,tools:{},errors:0}, events:[], changedFiles:[] });
      const btn = document.createElement('button');
      btn.className = 'project-tab';
      btn.setAttribute('data-project', cwd);
      btn.innerHTML = '<span>'+esc(cwd.split('/').pop()||cwd)+'</span><span class="tab-count">(1)</span>';
      btn.addEventListener('click', () => { STATE.activeProject = cwd; refreshView(); });
      document.getElementById('project-tabs').appendChild(btn);
    }

    function updateProjectTabCount(cwd) {
      const proj = STATE.projects.find(p=>p.cwd===cwd); if (!proj) return; proj.stats.totalEvents++;
      const btn = document.querySelector('.project-tab[data-project="'+CSS.escape(cwd)+'"]');
      if (btn) { const s = btn.querySelector('span:last-child'); if (s) s.textContent = '('+proj.stats.totalEvents+')'; }
    }

    function refreshView() {
      document.querySelectorAll('.project-tab').forEach(b => b.classList.toggle('active', b.getAttribute('data-project')===STATE.activeProject));
      if (STATE.activeProject==='all') { updateStats(STATE.globalStats||STATE.stats); updateFiles(STATE.changedFiles); }
      else { const p = STATE.projects.find(p=>p.cwd===STATE.activeProject); if (p) { updateStats(p.stats); updateFiles(STATE.changedFiles); } }
      const feed = document.getElementById('event-feed'); feed.innerHTML = '';
      (STATE.activeProject==='all' ? STATE.allEvents : STATE.allEvents.filter(e=>e.event.cwd===STATE.activeProject)).slice(-200).forEach(e=>addEventRow(e));
      renderTasks(); renderQuestions();
    }

    function setConnected(on) {
      const dot = document.getElementById('status-dot');
      dot.className = 'status-dot '+(on?'connected':'disconnected pulse');
      const t = document.getElementById('status-text');
      t.textContent = on?'Connected':'Disconnected';
      t.className = on?'connected-text':'disconnected-text';
    }

    function connect() {
      const es = new EventSource('/events');
      es.addEventListener('init', (e) => {
        const d = JSON.parse(e.data);
        STATE.globalStats=d.stats; STATE.changedFiles=d.changedFiles||[]; STATE.allEvents=d.events||[]; STATE.projects=d.projects||[];
        STATE.tasks = new Map();
        if (d.tasks) { for (const [key, task] of Object.entries(d.tasks)) STATE.tasks.set(key, task); }
        STATE.questions = d.questions || [];
        updateStats(d.stats); updateFiles(d.changedFiles||[]); updateProjectTabs(d.projects||[]);
        renderTasks(); renderQuestions();
        d.events.forEach(e=>addEventRow(e)); setConnected(true);
      });
      es.addEventListener('hook-event', (e) => {
        const entry = JSON.parse(e.data); STATE.allEvents.push(entry);
        while (STATE.allEvents.length>500) STATE.allEvents.shift();
        const ev = entry.event;
        addProjectTab(ev.cwd); updateProjectTabCount(ev.cwd); addEventRow(entry); processEvent(entry);
        if (ev.hook_event_name==='PostToolUse' && (ev.tool_name==='Edit'||ev.tool_name==='Write'||ev.tool_name==='MultiEdit') && ev.tool_input?.file_path) {
          const fp = ev.tool_input.file_path;
          if (!STATE.changedFiles.find(f => (typeof f==='string' ? f : f.path)===fp && (typeof f==='string' || f.cwd===ev.cwd))) {
            STATE.changedFiles.push({ path: fp, cwd: ev.cwd||'' });
          }
        }
      });
      es.addEventListener('stats-update', (e) => {
        const d = JSON.parse(e.data);
        STATE.globalStats = d.stats;
        STATE.projects = d.projects || STATE.projects;
        updateProjectTabs(STATE.projects);
        if (STATE.activeProject === 'all') { updateStats(d.stats); updateFiles(STATE.changedFiles); }
        else { const p = STATE.projects.find(p=>p.cwd===STATE.activeProject); if (p) updateStats(p.stats); }
      });
      es.onerror = () => { setConnected(false); es.close(); setTimeout(connect, 3000); };
    }
    renderTasks(); renderQuestions(); connect();
  </script>
</body>
</html>`;
}

Step 5: エントリーポイント

最後はサーバーの起動スクリプトです。

src/server.ts
#!/usr/bin/env bun
import { Dashboard } from './dashboard';

const port = parseInt(process.env.PORT || '3001', 10);
const dashboard = new Dashboard({ port });

await dashboard.start();
console.log('Watching ~/.claude/dashboard-events/ for new events...');

process.on('SIGINT', async () => {
  await dashboard.stop();
  process.exit(0);
});

環境変数 PORT でポートを変更できます。デフォルトは 3001 です。

SIGINT(Ctrl+C)で安全にシャットダウンします。ファイル監視の停止、SSE 接続の終了、HTTP サーバーの停止を順に行います。

package.json と tsconfig.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"
  }
}
tsconfig.json
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ES2022",
    "moduleResolution": "bundler",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "outDir": "dist"
  },
  "include": ["src"]
}

devDependencies のみで、ランタイム依存はありません。Bun が TypeScript を直接実行するため、ビルドステップも不要です。


動かしてみる

1. リポジトリのクローンと依存のインストール

git clone https://github.com/nogataka/claude-dashboard.git
cd claude-dashboard
bun install

2. Hook スクリプトの登録

~/.claude/settings.json に Step 2 の設定を追加します。/path/to/claude-dashboard/ はクローンした実際のパスに置き換えてください。

3. ダッシュボードの起動

bun start

以下のログが表示されれば成功です。

Dashboard running at http://localhost:3001
Watching ~/.claude/dashboard-events/ for new events...

4. ブラウザで確認

http://localhost:3001 を開きます。この時点ではイベントがないため、各パネルは空です。

5. Claude Code を使う

別のターミナルで Claude Code を起動して作業してください。ツールを呼び出すたびに、ダッシュボードにリアルタイムでイベントが流れてきます。

ポートを変更する場合は環境変数を使います。

PORT=8080 bun start

まとめ

本記事では、Claude Code の全操作を可視化するダッシュボードの実装を解説しました。

構成を振り返ります。

  • capture-event.sh: Hook イベントを JSONL ファイルに追記する
  • dashboard.ts: ファイル監視、差分読み取り、統計計算、SSE 配信を行う
  • dashboard-html.ts: 6パネルのダッシュボード UI を提供する
  • server.ts: エントリーポイント

外部ライブラリに依存しない約800行のコードで、リアルタイムモニタリングが実現できます。

リンク

シリーズ記事

本記事は Claude Code Hooks ダッシュボードシリーズの2本目です。

  1. Claude Code Hooks 全14イベント徹底解説 -- Hooks の基礎知識
  2. 本記事 -- コード全体ウォークスルー
  3. Hooks 活用パターン集 -- 応用的な Hook の実装例
12
14
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
12
14

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?