
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,日志文件位置完全由服务端决定。
一个完整示例
把上面的模块串起来,从零到能看到卡片的最小流程如下。
- 在项目里安装依赖,确认
claude可执行文件路径正确。如果不在默认位置,先导出环境变量:export CLAUDE_BIN=/你的路径/claude。 - 实现
lib/server/claude-agents.ts,用listAgents()拿到归一化后的会话数组,并用toStatus()把状态压成四种。 - 实现
lib/server/projects.ts,扫描~/.claude/projects/*/,用readCwd()从每个日志开头 256KB 里取出cwd,过滤掉主目录正下方、worktree、/tmp下的会话,只保留最近 30 天有更新的项目,并按文件夹去重保留较新的一份,结果缓存 30 秒。 - 实现
lib/server/guard.ts,在app/api/projects/route.ts里返回项目列表,页面每 5 秒轮询一次并渲染卡片。 - 实现
lib/server/claude-launch.ts与app/api/instruct/route.ts:校验本机来源、校验 JSON 请求体、按 slug 查出项目、确认已信任后,在对应文件夹执行claude --bg --permission-mode auto -- <指令>,把输出里的 8 位会话 ID 返回给页面。 - 实现
lib/server/transcript.ts与对应的日志接口:只读末尾 1MB,按前面表格的规则过滤行,工具调用只保留一个要点字段。 - 构建并启动:先执行
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 秒轮询一次接口。状态变化会有几秒延迟,属于预期行为。
文中出现的版本号、参数默认值与可用性信息都可能随版本变化,请以官网当前说明为准。