Community编程与开发github.com

bitekas/web-hierarchy-server

将 AI 代理会话的完整委派层级结构实时展示为自动刷新的网页,方便无法查看终端的人类监控。

web-hierarchy-server 是什么?

该技能为 AI 代理(如 Claude Code)的会话生成一个实时更新的 Web 页面,展示整个代理委派树(spawn tree)。它通过读取代理框架已有的会话转录文件,构建一棵包含根会话及所有子代理的图表(包括类型、任务描述、模型、开始/结束时间、存活状态及最后日志行),并在 `GET /` 返回独立的 HTML 文档,同时通过 `GET /state` 提供 JSON 快照供页面每 10 秒轮询更新。页面支持“全部/仅活跃”切换,日志按需加载(`GET /agent/<id>`)以减少传输量。此技能支持 Linux、macOS 系统上的长期运行(可作为 systemd 服务或 LaunchAgent),并提供静态构建、身份/头像映射等功能,适合需要持续监控多代理协作场景的开发者和运维人员。

兼容平台✓Claude Code~Codex CLI~Cursor
npx skills add https://github.com/bitekas/agent-fleet/tree/HEAD/.claude/skills/web-hierarchy-server

Installed? Explore more 编程与开发 skills: steipete/bluebubbles, steipete/eightctl, steipete/blucli · View all 6 →

在你喜欢的 AI 中提问

打开一个已预加载此 Agent Skill 的新对话。

文档

Web 层级结构服务器

人类用户通常在其他设备上工作,无法查看子代理的终端。本技能将整个生成树转化为一个实时页面,用户只需打开一次即可持续观察:顶部是可点击的图表,下方是所选代理的完整日志。

此技能的安装上下文目录——由 fleet where web-hierarchy-server 打印的路径——包含此安装的两个文件:CONTEXT.md 标记了代理名称、其端口和 URL,config.json 保存了脚本读取的所有值。旁边的 config.example.json 说明了每个键的作用。脚本通过询问同一命令找到 config.json,或者读取 WEB_HIERARCHY_CONFIG 环境变量所指的文件;其中的相对路径会相对于其所在目录解析。

通过路径运行检查和脚本:

"${CLAUDE_SKILL_DIR}/installation/check"     # 检查某个配置目录是否已有服务器运行,以及是否最新
node "${CLAUDE_SKILL_DIR}/serve.mjs" --help  # 显示配置、转录根目录和将要使用的端口

核心规则:每个配置目录只能有一个服务器

一个配置目录(CLAUDE_CONFIG_DIR)对应一个代理的转录根目录,并且恰好只有一个服务器为之服务。同一端口上的第二个服务器是本规则要防止的失败情形:无论哪个进程赢得端口绑定,页面的服务都会正常,另一个进程则会因 EADDRINUSE 而退出,从用户角度页面看起来依然正常。启动之前,需确认该配置目录尚无服务器运行(installation/check 可回答此问题,且不会启动任何东西)。

持久化的形式是服务单元(installation/web-hierarchy-server.service.example):随机器启动、Restart=always,在代理开始委派工作之前就已运行,并能在重启后存活。安装服务需要 root 权限,这是需要人类操作的步骤。在 macOS 上,它是一个位于用户 GUI 域中的 LaunchAgent(installation/web-hierarchy-server.plist.example),无需 sudo 即可安装。如果没有人运行过服务,服务器会以分离进程的形式启动一次(Linux 上使用 setsid nohup ... &,macOS 上使用 nohup ... &,因为 macOS 没有 setsid),这种方式可以工作但重启后不会存活——这正是 installation/check 用文字说明而非报告绿色状态的情况。

对 serve.mjs 或 format.mjs 的编辑默认是延迟生效的。 Node 在进程启动时只加载一次这些文件,因此代码更改只有在下次重启时才会作用于正在运行的页面。这是预期行为,不是重启用户正在观看的服务器的理由,而且这也正是“我的修复没生效”的报告通常是因为旧代码仍在运行中的原因。

树是“服务”的;日志是“获取”的

这是整个系统的形态,是一个大小规则,而非风格问题。

会话的树大约每个代理 1 KB,无论该代理做了什么。其日志是无界的,渲染日志还会连带引入其中的图片。将每个代理的日志放入每次快照,会使一个 228 个代理的会话的 GET / 响应达到 358MB,其中 110MB 是 Base64 图片数据,并且每 10 秒轮询时都要重新发送。这种情况下,没有页面损坏,但页面根本无法打开。

因此,负载被拆分,每个路由只携带一种内容:

路由携带内容成本
GET /整棵树、根会话的日志以及该日志中内联为 data URI 的图片一份完整的文档,无需服务器即可打开
GET /state整棵树但不含日志(?logs=<id>,<id> 可指定例外);?view=active 将图表缩小到活跃视图每 10 秒轮询支付的代价,每次心跳,永远如此
GET /agent/<id>仅该代理的渲染日志一次获取,当用户点击某个块时
GET /agent/<id>?since=<n>仅从索引 n 开始的事件对于安静的代理返回空数组
GET /img/<hash>按内容哈希的一张图片,标记为 immutable每个用户每张图片一次,之后缓存

在更改任何内容之前,有两件事值得了解:

  • since 索引的是树自身的 count。 树中的一行携带该代理的真实事件计数,即使其日志未被渲染;客户端将这个数字作为 since 传回。两者都来自同一个转录解析,也必须如此:如果在其他地方单独推导其中一个,客户端会静默地跳过或重复事件,最终只能看到一条似是而非的日志。
  • GET /img/<hash> 是内容寻址的,因此响应会被永久缓存。 这就是为什么未知哈希会返回 404 而永远不会返回空 200——空 200 会导致该用户的图片永久损坏,且无法通过刷新修复。

如果这种拆分被破坏,不会有任何地方变红。 把每个日志都放进 GET /state,或者把图片内联到每个快照中,树仍然正确、标签仍然正确、页面仍然渲染,但大小会达到数百 MB。大小是真正要保护的属性;serve.test.mjs 中的大小检查项(第 6-9 行)是唯一能发现大小回退的地方,并且每个检查项都是一个成对的存在——此处没有、彼处有——因为仅凭“X 不存在”可能因为各种错误原因而成立。

保存的页面包含什么,不包含什么

GET / 是一个独立的 HTML 文档:内联 CSS 和 JS,无外部资源,并且包含一个无需服务器即可渲染的内置快照。内置的内容是整棵树和根会话的日志,以及该日志中的图片作为 data URI。

不内置的是:其他所有代理的日志。 这些日志不在文档中,而是在用户选择时通过 /agent/<id> 获取。因此,保存到磁盘的页面(或服务器已关闭的页面)会显示完整的图表和根会话的日志,但点击子代理块时不会显示任何内容。这些日志中的图片也是如此:它们通过 /img/<hash> 渲染,但保存的文件后面没有服务器。

如需将每一条日志冻结在一个文件中,请使用 build.mjs——该静态构建会将每个代理的日志和每张图片内联,因为纪念品无需再获取任何东西。这是唯一一个向 snapshot() 请求所有日志(logsFor: null)的调用者。

如何追踪会话

服务器是长期运行的;Claude 会话则不是:结束会话、更新或重启服务都会产生新的会话 ID。因此,服务器在每次快照时重新选择最近活动的会话,而不是在启动时固定一个,并在没有会话时提供“正在等待会话...”页面——这是启动时的正常状态。“最近活动”包括根会话的转录,而不仅仅是子代理文件:一个刚刚开始但尚未委派任务的会话会被立即跟踪,并以仅包含根节点的树的形式渲染;生成树则从其第一次委派开始增长。新会话开始时无需重启任何东西;页面会检测到 ID 变化并重建其日志。

--session=<id> 可固定一个会话(用于调试的冻结视图)。

配置目录下的每个项目别名都会被扫描,而非仅一个。会话的转录会存放在其当前工作目录的项目别名下,因此在 Git 工作目录中工作的会话会写入该工作目录的别名;固定一个别名的会话会使所有工作目录的会话不可见,但页面仍然正常渲染。

页面显示的是哪个代理

根块会以配置目录所属的 fleet 成员名称来标记,该名称通过 config.json 的 fleet.by_config_dir 解析。键是派生的,而非原始目录名,派生规则值得一读:

format.mjs 的 fleetKey() 获取配置目录的目录名,除非该目录名是字面量 .claude,此时它会改用父目录的目录名。如果 fleet 为每个代理分配了一个家目录,每个配置目录都位于 <home>/<agent>/.claude,那么按目录名键化会导致所有代理冲突在一个条目上,每个代理的页面都会以该条目命名的代理来标记。

这种失败是静默的:页面完全渲染但显示错误的名称,而同时打开两个页面的用户无法区分它们,也不会知道它们无法区分。字面量 .claude 条目会作为后备被尊重,因此以 .claude 为键的单代理安装会保持其标签;但它无法区分两个代理,因此不应在存在多个代理的情况下使用。同样的派生键会从 config.json 的 servers 中选择默认端口,原因相同:按目录名匹配会将所有代理映射到第一个条目的端口。

文件

  • serve.mjs — 实时服务器:提供 GET /、GET /state、GET /agent/<id> 和 GET /img/<hash>(参见上面的路由表)。主要工具。 --port=N、--host=<addr>、--cap=KB、--session=<id>,每个参数默认值来自 config.json;--help 打印用法、解析后的配置路径、转录根目录和默认端口,但不绑定任何东西。--port=0 使用临时端口,横幅会打印实际获取到的端口。
  • format.mjs — 共享格式化器:转录解析、生成树布局、每个事件的 HTML、主题 CSS,以及读取本地值的唯一位置(cfg() 读取 fleet where 指定的 config.json,或当设置了 WEB_HIERARCHY_CONFIG 时读取该变量指定的文件)。
  • build.mjs — 可选的静态快照(node build.mjs <out.html>):将当前状态烘焙到一个自包含的文件中,作为冻结、可共享的记录。用于纪念品,而非实时视图。
  • identities.json + avatars/ — 显示名称列表,除非 identities.file 指定了其他文件。
  • serve.test.mjs + fixtures/ — 测试:在临时端口上启动一个真实服务器,针对合成转录树,断言树的正确性,驱动所有路由(不含日志的树、单个代理的日志且没有其他代理的、?since 只返回新内容、按字节匹配的图片哈希),然后删除一个子代理的转录,并要求树和每个代理的路由都将其移除。其头部说明了每个检查项存在的原因、每个大小检查项成对的一半,以及为什么阴性对照是承担负载的部分。第 11 项要求 GET /state?view=active 丢弃默认视图中显示的已停止块。
  • view.test.mjs — 独立的“仅活跃”规则测试(filterView() 针对合成树):存活的块、它们上方的块以及根节点,同时丢弃已停止的兄弟和子节点;“全部”模式返回所有行不变。
  • format.test.mjs — 配置如何被查找和读取:当 WEB_HIERARCHY_CONFIG 未设置时通过 fleet where(通过 FLEET_BIN 选择模拟 fleet)、变量优先、失败的 fleet 传递而非猜测、相对路径、默认列表以及 packages.dir_pattern。
  • installation/ — 服务单元模板、安装过程和检查脚本。

页面显示的内容

页面采用暗色色盲友好(dark-daltonized)主题,与 Claude Code 控制台一致——调色板直接取自 Claude Code 的捆绑包(黑色背景、白色等宽文字、工具提示为成功蓝 #3399ff、错误为 #ff6666、带 "… +N lines" 展开器的暗淡结果行、思考标记、任务和用户消息以控制台输入样式显示)。

  • 生成树图表。 根会话位于顶部;所有无父节点的代理都挂在它下面。蓝色 = 存活(时间范围旁有旋转的动画;在 prefers-reduced-motion 下静止),灰色 = 已停止。点击一个块可显示该代理的完整日志。
  • 图表上方的“全部/仅活跃”切换开关。 “全部”为默认值,显示所有块。“仅活跃”显示蓝色块、每个蓝色块上方的所有块以及根节点:活跃标志是给块上色的依据,因此切换开关与颜色永远不会矛盾。上方的块保留是因为等待长时间运行的子任务时,父代理不会向自己的转录写入任何内容,因此看起来已停止,而子任务仍在运行;如果没有这些块,子任务就会从虚无中挂下来。当没有活跃块时,视图仅显示根节点。开关仅影响图表:时间戳仍然统计整个会话(同时显示“显示 N 个”),来自链接或 #a-<id> 指向隐藏代理的链接仍然可以打开其日志。选择会在浏览器的 localStorage 中按用户记住;如果存储被阻止,切换开关在该次访问中仍然有效。format.mjs 的 filterView() 实现了此规则;view.test.mjs 对其进行了验证。
  • 每个块上的任务描述。 真实来源:发起 Agent 调用时的 description,框架会将其复制到 agent-<id>.meta.json 中。如果缺失,则回退到代理任务提示的第一行。描述会换行,块高度会自动调整以容纳。
  • 每个块的最后一行日志,作为悬停工具提示。 在数百个代理的情况下,这是区分两个具有相同类型和描述的块的唯一方式;对于存活的代理,它显示了当前正在执行的操作。它存在于树中,因为它是日志内容中足够廉价的一部分:纯文本、截断、不含 HTML——一旦渲染标记,就会把图片和展开器带入每个快照。
  • 每个块上的模型信息。 parseTranscript 会从转录的助手回复回合中读取模型(去掉 claude- 前缀);如果代理尚未回复,则使用其 .meta.json 中的粗略别名。如果两者都没有,则不显示任何行。
  • 带头像的命名代理。 每个块显示一个名称、其头像、代理类型、描述、模型和时间范围,因此五个同类型的并发代理可以一目了然地区分。
    • 名称仅用于显示:从 identities.file 中的列表分配,作为 (agentType, 该类型内生成顺序) 的纯函数,每次刷新时根据转录重新计算。不存储任何内容,代理本身也不知道自己的名称——框架拥有转录,而此格式化器是只读的观察者。
    • 生成顺序,而非代理 ID 的哈希:在有 N 个名称和 N 个代理的情况下,哈希会因为鸽巢原理而冲突;生成顺序则不会。
    • names 的顺序是承重结构——只能追加。 重新排序或在中间插入会静默地重命名所有过去会话的图表。
    • avatar = 角色的剪影;portraits = 为某个名称提供的可选定制头像。 头像标识...

相关技能