AB
AiBoss
チュートリアル

Claude Code

チュートリアル

用 Next.js 搭建 Claude Code 会话管理面板:读取 agents 输出与本地会话日志

同时跑多个 Claude Code 项目时,很难看清哪个文件夹在做什么、哪个会话卡在等待确认。这篇教程用 Next.js 搭一个只在本机运行的会话面板:读取 claude agents 的 JSON 输出、解析 ~/.claude 下的会话日志、在指定文件夹里启动后台会话,并只放行来自本机的请求。

同时在多个项目里使用 Claude Code 时,很容易失去对全局的掌握:哪个文件夹里正在跑任务、哪个会话停在等待确认、哪个已经结束,都要逐个终端窗口去翻。这篇教程用 Next.js 搭一个只在本机运行的会话面板,把三件事集中到一个页面上:自动列出用过 Claude Code 的项目并显示会话状态、选中项目后写一段指令就在该文件夹里启动后台会话、从会话日志里只抽出「自己的指令 / Claude 的回复 / 用到的工具」来阅读。整套实现不依赖数据库,只调用 Claude Code 的命令行工具并读取 ~/.claude 下的会话日志。如果你还没装 Claude Code,可以先看 Claude Code 的介绍页了解它是什么。

需要说明的是,claude agents --json 的输出格式和 ~/.claude 下的文件结构都不是对外承诺的公开 API,命令行工具版本升级后随时可能变化。因此下面所有读取日志、解析输出的地方都用 zod 做校验,一旦结构变了就直接报错,而不是悄悄给出错误结果。文中涉及版本号、参数默认值等易变信息时,请以官网当前说明为准。

准备工作

先确认本机具备以下条件,缺一项后面的步骤都跑不通。

  • Claude Code 命令行工具:已安装并能在终端里直接执行 claude。素材中的实现把可执行文件路径默认取为 ~/.local/bin/claude,同时允许用环境变量 CLAUDE_BIN 覆盖。如果你的安装位置不同,就设置这个环境变量。
  • Node.js 与包管理器:需要能运行 Next.js 的 Node 版本。素材使用的组合是 Next.js 16.4.0(App Router)、React 19.3.0、zod 4.6.5,操作系统为 macOS。这些版本只是参考,实际以你安装时的当前版本为准。
  • 至少在一个项目里用过 Claude Code:面板的项目列表来自 ~/.claude/projects/ 下的会话日志,没有历史记录就看不到任何卡片。
  • 目标文件夹已被信任:Claude Code 会记录哪些文件夹经过确认,未信任的文件夹无法启动后台会话,这一点在后面的「注意事项」里会详细说。

项目结构按职责拆成几个服务端模块,页面部分只负责轮询接口并渲染,这里不展开。

文件职责
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

操作步骤

第一步:取得会话列表并统一状态字段

执行 claude agents --json --all 会以 JSON 返回后台会话(含已完成)与终端中正在运行的会话。实际观察到的差异是:后台会话返回 id 和 state,终端会话没有 id,而是返回 status(例如 busy)。所以先用 zod 的 transform 把两种形状归一。

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

界面上只展示四种状态。与其把所有细分状态都列出来,不如按「人是否需要介入」来划分,面板才真正好用。

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

同一个项目里有多个会话在跑时,卡片上显示「最需要人处理」的那个状态:

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

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

第二步:从会话日志生成项目列表

Claude Code 把会话日志保存在 ~/.claude/projects/<把文件夹路径转换后的名字>/<sessionId>.jsonl。目录名无法可靠地还原出原始路径,所以要读 JSONL 每一行里的 cwd 字段来确定真实文件夹。日志文件会变得很大,因此只读开头 256KB。

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 秒。

第三步:判断文件夹是否已被信任

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,在确认提示里选择同意即可。界面上给这类卡片显示「未信任」,并禁止向它发送指令。

第四步:启动后台会话

收到指令后,在目标文件夹里启动 claude --bg。

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

这段代码有三个要点:

  • 用 spawn 以数组形式传参:不经过 shell,指令里带特殊符号也不会被当成命令解释。
  • 用 -- 分隔:指令即使以 - 开头,也不会被 CLI 当成选项读取。
  • 加超时:启动迟迟不返回时,接口不会无限等待。

权限模式设为 auto,是因为一旦卡在确认对话框上,「发出去就不管」的用法就无法成立。作为交换,发布文章、发送、删除这类不可逆操作,交给各项目 CLAUDE.md 里的规则约定「由人来执行」。也就是说,权限不在面板这一侧细粒度控制,而是把规则放在项目侧。

第五步:只读日志末尾并整理成可读内容

会话视图只读 JSONL 末尾 1MB,并挑出需要显示的行。

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

逐行 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");
}

把工具的完整输入都显示出来会没法阅读,所以只保留一项,让人看出「对什么做了什么」即可。

第六步:只放行来自本机的请求

这是一个能从浏览器把任务投给 AI 的接口,必须谨慎处理。服务端用 next start -H 127.0.0.1 启动,只监听回环地址,同时在接口侧校验 Host 与 Origin。

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 重绑定),从而借浏览器访问这个接口。校验 Host 头就是针对这种情况。发送指令的 POST 强制要求 Origin,用来挡掉从其他站点表单发出的请求。

此外,指令接口不接受来自页面的目标文件夹路径。页面只传项目标识符(slug),路径由服务端从自己的列表里查出来。

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: "只能从本机浏览器发送" }, 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)。会话日志接口同理,从 URL 里只接收 8 位会话 ID,并且只接受出现在 claude agents 列表里的 ID,日志文件位置完全由服务端决定。

一个完整示例

把上面的模块串起来,从零到能看到卡片的最小流程如下。

  1. 在项目里安装依赖,确认 claude 可执行文件路径正确。如果不在默认位置,先导出环境变量:export CLAUDE_BIN=/你的路径/claude。
  2. 实现 lib/server/claude-agents.ts,用 listAgents() 拿到归一化后的会话数组,并用 toStatus() 把状态压成四种。
  3. 实现 lib/server/projects.ts,扫描 ~/.claude/projects/*/,用 readCwd() 从每个日志开头 256KB 里取出 cwd,过滤掉主目录正下方、worktree、/tmp 下的会话,只保留最近 30 天有更新的项目,并按文件夹去重保留较新的一份,结果缓存 30 秒。
  4. 实现 lib/server/guard.ts,在 app/api/projects/route.ts 里返回项目列表,页面每 5 秒轮询一次并渲染卡片。
  5. 实现 lib/server/claude-launch.ts 与 app/api/instruct/route.ts:校验本机来源、校验 JSON 请求体、按 slug 查出项目、确认已信任后,在对应文件夹执行 claude --bg --permission-mode auto -- <指令>,把输出里的 8 位会话 ID 返回给页面。
  6. 实现 lib/server/transcript.ts 与对应的日志接口:只读末尾 1MB,按前面表格的规则过滤行,工具调用只保留一个要点字段。
  7. 构建并启动:先执行 next build,再用 next start -H 127.0.0.1 启动。

启动后可以逐项确认:

  • 最近 30 天用过 Claude Code 的项目以卡片形式排列出来。
  • 发送一条指令后,后台会话被启动,卡片状态从「作业中」变为「完成」。
  • 向未信任的文件夹发送指令会被拒绝,返回 409。
  • 把 Origin 换成其他域名再发 POST,会被 403 挡掉。

如果希望它常驻,可以把 next start 注册到 macOS 的 launchd,登录时自动拉起。

注意事项

输出格式不是稳定契约。claude agents --json 的字段和 ~/.claude 下的文件结构都没有作为公开 API 得到保证,命令行工具版本升级后可能变化。所有读取点都用 zod 校验,结构变了就报错,而不是继续用错误的数据渲染。

信任判定不继承父目录。claude --bg 只在被信任的文件夹里可用,而父文件夹被信任并不代表子文件夹被信任。判定必须做完全匹配,不要向上追溯。未信任的文件夹需要在终端里进入该目录启动一次 claude 并确认,之后才能从面板投递指令。

权限模式与不可逆操作。为了让「发出去就不管」成立,启动时用了 --permission-mode auto。代价是确认对话框不再拦住流程,因此发布、发送、删除这类不可逆操作要靠各项目 CLAUDE.md 里的规则约定由人执行,而不是指望面板侧做细粒度权限控制。

只读日志的片段。项目列表只读日志开头 256KB,会话视图只读末尾 1MB。从中间开始读时,开头被截断的那一行要丢掉,否则会解析失败。日志很大时,界面展示的只是片段而非完整历史。

接口暴露面。服务端只监听回环地址,接口同时校验 Host 与 Origin,POST 强制要求 Content-Type: application/json,并且不接受页面传来的目标路径,只接受服务端已知的 slug 与 8 位会话 ID。这几层缺一层,都可能让其他站点借浏览器把任务投进来。

缓存与时效。项目列表每次生成都要扫磁盘,因此只在内存里缓存 30 秒;页面每 5 秒轮询一次接口。状态变化会有几秒延迟,属于预期行为。

文中出现的版本号、参数默认值与可用性信息都可能随版本变化,请以官网当前说明为准。