3
1

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の全セッションを1画面で管理するローカルダッシュボードをNext.jsで実装する

3
Last updated at Posted at 2026-10-11

この記事でやること

Claude Code を複数のプロジェクトで並行して使っていると、「どのフォルダで何が動いていて、どれが確認待ちで止まっているか」が分からなくなります。

そこで、次の3つができるローカル専用のダッシュボードを Next.js で作りました。

  • Claude Code を使ったプロジェクトを自動で一覧化し、セッションの状態(作業中/返事待ち/完了/待機)を表示する
  • プロジェクトを選んで指示を書くと、そのフォルダでバックグラウンドの Claude Code を起動する
  • 会話ログから「自分の指示・Claude の返答・使ったツール」だけを抜き出して読む

データベースは使いません。Claude Code の CLI と、~/.claude 以下の会話ログを読むだけで組んでいます。この記事では、そのサーバー側の実装を中心に解説します。

環境

項目 バージョン
Claude Code 2.1.296
Next.js 16.4.0(App Router)
React 19.3.0
zod 4.6.5
OS macOS

claude agents --json の出力形式や ~/.claude 以下のファイル構成は、公開されたAPIとして保証されたものではありません。CLI のバージョンが上がると変わる可能性があるので、読み取り部分は zod で検証して、形が変わったら落ちるようにしています。

全体構成

ファイル 役割
lib/server/claude-agents.ts claude agents --json --all を実行してセッション一覧を取得
lib/server/projects.ts ~/.claude/projects/*/ の会話ログからプロジェクト一覧を作る
lib/server/claude-launch.ts claude --bg でバックグラウンドセッションを起動
lib/server/transcript.ts 会話ログ(JSONL)の末尾を読んで表示用に整形
lib/server/guard.ts ローカルからのリクエストだけを通す
app/api/*/route.ts 上記を束ねる Route Handler

画面側は /api/projects を5秒おきにポーリングして描画しているだけなので、ここでは省略します。

1. セッション一覧を取得して状態を揃える

claude agents --json --all を実行すると、バックグラウンドセッション(完了済みを含む)とターミナルで動いているセッションの一覧が JSON で返ってきます。

実際に使ってみると、バックグラウンドのセッションは id と state、ターミナルのセッションは id が無く status(busy など) という形で返ってきました。そこで zod の transform で同じ形に揃えます。

lib/server/claude-agents.ts
import "server-only";
import { execFile } from "node:child_process";
import { homedir } from "node:os";
import path from "node:path";
import { z } from "zod";

export const CLAUDE_BIN = process.env.CLAUDE_BIN ?? path.join(homedir(), ".local", "bin", "claude");

// バックグラウンドは id/state、ターミナルは id 無し・status で返るので揃える
const agentSchema = z
  .object({
    id: z.string().optional(),
    sessionId: z.string(),
    cwd: z.string(),
    startedAt: z.number(),
    state: z.string().optional(),
    status: z.string().optional(),
    name: z.string().optional(),
  })
  .transform(({ id, sessionId, state, status, ...rest }) => ({
    ...rest,
    sessionId,
    id: id ?? sessionId.slice(0, 8),
    state: state ?? status ?? "unknown",
  }));

export type Agent = z.output<typeof agentSchema>;

export function listAgents(): Promise<Agent[]> {
  return new Promise((resolve, reject) => {
    execFile(CLAUDE_BIN, ["agents", "--json", "--all"], { timeout: 10_000 }, (error, stdout) => {
      if (error) return reject(new Error(`claude agents の取得に失敗: ${error.message}`));
      try {
        resolve(z.array(agentSchema).parse(JSON.parse(stdout)));
      } catch (parseError) {
        reject(new Error(`claude agents の出力を読めません: ${String(parseError)}`));
      }
    });
  });
}

画面に出す状態は4つに絞ります。細かい状態を全部出すより、「人間が動く必要があるか」で分けるほうがダッシュボードとして使いやすいからです。

export type ProjectStatus = "working" | "waiting" | "done" | "idle";

export function toStatus(state: string): ProjectStatus {
  if (state === "working" || state === "busy") return "working";
  if (state === "blocked") return "waiting"; // 確認待ちで止まっている
  if (state === "done") return "done";
  return "idle";
}

1つのプロジェクトで複数セッションが動いているときは、一番手がかかる状態をカードに出します。

const STATUS_PRIORITY: readonly ProjectStatus[] = ["working", "waiting", "done", "idle"];

const status = STATUS_PRIORITY.find((st) => own.some((s) => s.status === st)) ?? "idle";

2. 会話ログからプロジェクト一覧を作る

Claude Code は会話ログを ~/.claude/projects/<フォルダパスを変換した名前>/<sessionId>.jsonl に保存しています。ディレクトリ名からは元のパスを確実に復元できないので、JSONL の行に入っている cwd を読んで実際のフォルダを特定します。

ログファイルは大きくなるので、先頭256KBだけを読みます。

lib/server/projects.ts
const HEAD_BYTES = 256 * 1024;

async function readCwd(file: string): Promise<string | null> {
  const handle = await open(file, "r");
  try {
    const { buffer, bytesRead } = await handle.read(Buffer.alloc(HEAD_BYTES), 0, HEAD_BYTES, 0);
    for (const line of buffer.subarray(0, bytesRead).toString("utf8").split("\n")) {
      try {
        const cwd = (JSON.parse(line) as { cwd?: unknown }).cwd;
        if (typeof cwd === "string" && cwd) return cwd;
      } catch {
        // 途中で切れた行・壊れた行は読み飛ばす
      }
    }
    return null;
  } finally {
    await handle.close();
  }
}

あとは各ディレクトリの最新ログの更新日時を見て、直近30日に使ったものだけを残します。ホームディレクトリ直下や worktree、/tmp で起動したセッションは対象外にしています。同じフォルダが別のディレクトリ名で重複した場合は、新しい方を残します。

一覧の生成は毎回ディスクを舐めるので、30秒だけメモリにキャッシュしています。

3. 信頼済みフォルダの判定(ハマりどころ)

claude --bg は、Claude Code で「信頼済み」にしていないフォルダでは起動できません。信頼済みかどうかは ~/.claude.json の projects[パス].hasTrustDialogAccepted に入っています。

async function loadTrustedPaths(): Promise<Set<string>> {
  try {
    const config = JSON.parse(await readFile(path.join(homedir(), ".claude.json"), "utf8")) as {
      projects?: Record<string, { hasTrustDialogAccepted?: boolean }>;
    };
    return new Set(
      Object.entries(config.projects ?? {})
        .filter(([, v]) => v.hasTrustDialogAccepted === true)
        .map(([k]) => k),
    );
  } catch (error) {
    console.error("~/.claude.json を読めません:", error);
    return new Set();
  }
}

ここでハマったのが、親フォルダが信頼済みでも子フォルダは信頼済み扱いにならないことです。~/Projects を信頼済みにしていても、~/Projects/dashboard では --bg が弾かれました。なので判定は完全一致だけにして、親はたどりません。

未信頼のフォルダは、ターミナルでそのフォルダに移動して一度 claude を起動し、確認に「はい」と答えれば使えるようになります。画面ではカードに「未信頼」と表示し、指示を送れないようにしています。

4. バックグラウンドセッションを起動する

指示を受けたら、対象フォルダで claude --bg を起動します。

lib/server/claude-launch.ts
import "server-only";
import { spawn } from "node:child_process";
import { CLAUDE_BIN } from "./claude-agents";

const LAUNCH_TIMEOUT_MS = 60_000;

export function launchBackground(cwd: string, prompt: string): Promise<{ agentId: string | null; output: string }> {
  return new Promise((resolve, reject) => {
    // 指示が「-」で始まってもオプション扱いされないよう -- で区切る
    const child = spawn(CLAUDE_BIN, ["--bg", "--permission-mode", "auto", "--", prompt], {
      cwd,
      env: process.env,
      stdio: ["ignore", "pipe", "pipe"],
    });
    let stdout = "";
    let stderr = "";
    const timer = setTimeout(() => {
      child.kill();
      reject(new Error("claude の起動が60秒以内に終わりませんでした"));
    }, LAUNCH_TIMEOUT_MS);

    child.stdout.on("data", (chunk: Buffer) => (stdout += chunk.toString()));
    child.stderr.on("data", (chunk: Buffer) => (stderr += chunk.toString()));
    child.on("error", (error) => {
      clearTimeout(timer);
      reject(new Error(`claude を起動できません: ${error.message}`));
    });
    child.on("close", (code) => {
      clearTimeout(timer);
      const output = `${stdout}${stderr}`.trim();
      if (code !== 0) return reject(new Error(output || `claude が終了コード ${code} で終了しました`));
      // 出力に含まれる8桁のセッションIDを拾う
      resolve({ agentId: output.match(/\b[0-9a-f]{8}\b/)?.[0] ?? null, output });
    });
  });
}

ポイントは3つです。

  • spawn に引数を配列で渡す:シェルを経由しないので、指示文に記号が入ってもコマンドとして解釈されない
  • -- で区切る:指示文が - で始まっていても CLI のオプションとして読まれない
  • タイムアウトを付ける:起動が返ってこないときに API が永遠に待たないようにする

許可モードを auto にしているのは、確認ダイアログで止まると「投げて放置」ができないためです。その代わり、記事の公開・送信・削除のような取り返しのつかない操作は、各プロジェクトの CLAUDE.md のルールで「人間がやる」と決めています。権限をダッシュボード側で細かく制御するのではなく、プロジェクト側のルールに持たせる分担です。ルールとサブエージェントの書き方は「Claude Codeのルール・エージェント設計術」で詳しく書いています。

5. 会話ログを末尾だけ読んで整形する

会話ビューでは、JSONL の末尾1MBだけを読み、表示に必要な行だけを取り出します。

lib/server/transcript.ts
const TAIL_BYTES = 1024 * 1024;

async function readTail(file: string): Promise<{ lines: string[]; truncated: boolean }> {
  const handle = await open(file, "r");
  try {
    const { size } = await handle.stat();
    const start = Math.max(0, size - TAIL_BYTES);
    const { buffer, bytesRead } = await handle.read(Buffer.alloc(size - start), 0, size - start, start);
    const lines = buffer.subarray(0, bytesRead).toString("utf8").split("\n");
    // 途中から読んだときは先頭の欠けた行を捨てる
    return { lines: start > 0 ? lines.slice(1) : lines, truncated: start > 0 };
  } finally {
    await handle.close();
  }
}

1行ずつ JSON.parse して、次のルールで振り分けます。

行の種類 扱い
type: "user" の文字列/text パート 自分の指示として表示
type: "user" のツール結果(配列) 表示しない(量が多すぎる)
type: "assistant" の text パート Claude の返答として表示
type: "assistant" の tool_use パート ツール名+file_path や command などの要点だけ表示
isSidechain / isMeta の行 表示しない(サブエージェント内部・メタ情報)
const TOOL_INPUT_KEYS = ["file_path", "command", "pattern", "url", "description", "query"] as const;

function describeTool(part: ContentPart): string {
  const input = part.input ?? {};
  const detail = TOOL_INPUT_KEYS.map((k) => input[k]).find((v) => typeof v === "string");
  return detail ? `${part.name}  ${String(detail).slice(0, 160)}` : (part.name ?? "tool");
}

ツールの入力まで全部出すと読めなくなるので、「何に対して何をしたか」が分かる1項目だけに絞っています。

6. ローカルからのリクエストだけを通す

ブラウザから AI に作業を投げられる API なので、ここは慎重に作ります。サーバーは next start -H 127.0.0.1 で起動してループバックだけで待ち受けたうえで、API 側でも Host と Origin を確認します。

lib/server/guard.ts
import "server-only";

// DNSリバインディング・他サイトからのPOST対策
const LOCAL_HOSTNAMES = new Set(["127.0.0.1", "localhost"]);

function hostnameOf(value: string | null, withScheme: boolean): string | null {
  if (!value) return null;
  try {
    return new URL(withScheme ? value : `http://${value}`).hostname;
  } catch {
    return null;
  }
}

export function isLocalRequest(request: Request, { requireOrigin }: { requireOrigin: boolean }): boolean {
  const host = hostnameOf(request.headers.get("host"), false);
  if (!host || !LOCAL_HOSTNAMES.has(host)) return false;

  const origin = request.headers.get("origin");
  if (!origin) return !requireOrigin;
  const originHost = hostnameOf(origin, true);
  return originHost !== null && LOCAL_HOSTNAMES.has(originHost);
}
  • 127.0.0.1 で待ち受けていても、悪意のあるサイトが自分のドメインを 127.0.0.1 に向け直す(DNSリバインディング)と、ブラウザ経由で API を叩かれる可能性があります。Host ヘッダーの確認はこれへの対策です
  • 指示を送る POST では Origin を必須にして、他のサイトのフォームから送られたリクエストを弾きます

さらに、指示 API では起動先のフォルダを画面から受け取りません。画面から受け取るのはプロジェクトの識別子(slug)だけで、パスはサーバー側の一覧から引きます。

app/api/instruct/route.ts
const bodySchema = z.object({
  slug: z.string().min(1).max(300),
  text: z.string().trim().min(1).max(10_000),
});

export async function POST(request: Request) {
  if (!isLocalRequest(request, { requireOrigin: true })) {
    return reply({ ok: false, error: "このPCのブラウザからのみ送信できます" }, 403);
  }
  if (!request.headers.get("content-type")?.includes("application/json")) {
    return reply({ ok: false, error: "JSONで送ってください" }, 415);
  }

  const parsed = bodySchema.safeParse(await request.json().catch(() => null));
  if (!parsed.success) return reply({ ok: false, error: "指示の内容が不正です" }, 400);

  const project = await findProject(parsed.data.slug);
  if (!project) return reply({ ok: false, error: "指示先のプロジェクトが見つかりません" }, 404);
  if (!project.trusted) return reply({ ok: false, error: "このフォルダはまだ信頼済みではありません" }, 409);

  try {
    const { agentId, output } = await launchBackground(project.path, parsed.data.text);
    return reply({ ok: true, agentId, output });
  } catch (error) {
    console.error("instruct failed:", error);
    return reply({ ok: false, error: error instanceof Error ? error.message : "起動に失敗しました" }, 500);
  }
}

Content-Type: application/json を必須にしているのは、HTML フォームから送れる形式(application/x-www-form-urlencoded など)を受け付けないためです。

会話ログの API も同じ考え方で、URL から受け取るのは8桁のセッションIDだけです。claude agents の一覧に存在する ID だけを受け付け、ログファイルの場所はサーバー側で決めます。

動作確認

next build 後に next start -H 127.0.0.1 で起動し、次のことを確認しました。

  • 直近30日に Claude Code を使ったプロジェクトがカードとして並ぶ
  • 指示を送るとバックグラウンドセッションが起動し、カードが「作業中」→「完了」に変わる
  • 未信頼のフォルダには指示を送れない(409)
  • Origin を別ドメインにした POST は 403 で弾かれる

常駐させたいので、macOS の launchd に next start を登録して、ログイン時に自動で立ち上がるようにしています。

まとめ

  • claude agents --json --all と ~/.claude/projects/*.jsonl を読むだけで、データベースなしにセッション管理画面が作れる
  • CLI の出力はセッションの種類で形が違うので、zod の transform で揃える
  • claude --bg はフォルダ単位で信頼済み判定される。親フォルダの信頼は引き継がれない
  • 起動は spawn +配列引数+ -- 区切りで、指示文をコマンドとして解釈させない
  • AIに指示を送れる API は、ループバック待ち受け+ Host/Origin 確認+起動先をサーバーで決める、の三重で守る

次は「終わったセッションへの続きの指示」と「確認待ちのセッションへの返答」を画面から送れるようにする予定です。

公式の agent view(claude agents)で足りる場合との使い分けや、人間とAIの権限の線引きは「Claude Codeで複数プロジェクトを管理する|AIと人間の共同管理ダッシュボード」で解説しています。


SEOスコアチェックツール: Direbase(ディレベース) — RINIAディレクターツール。
Web制作・SEO関連の技術情報サイト: CodeQuest.work

3
1
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
3
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?