Web hierarchy server
The human an agent works for is often on another device and cannot see the subagents' terminal. This turns the whole spawn tree into one live page they open once and watch: a clickable graph up top, the selected agent's full log below.
This skill's install context - the directory
fleet where web-hierarchy-server prints - holds this install's two files:
CONTEXT.md names the agent, its port and its URL, and config.json holds
every value the scripts read. config.example.json beside this file documents
each key. The scripts find config.json by asking that same command, or read
the file WEB_HIERARCHY_CONFIG names when it is set; a relative path inside it
resolves against its own directory.
Run the checks and scripts by path:
"${CLAUDE_SKILL_DIR}/installation/check" # is a server up for this config dir, is it current
node "${CLAUDE_SKILL_DIR}/serve.mjs" --help # the config, transcript root and port it would use
The one standing rule: ONE server per config dir
A config dir (CLAUDE_CONFIG_DIR) is one agent's transcript root, and exactly
one server serves it. A second server on the same port is the failure this
rule exists to prevent: whichever process wins the bind serves the page, the
other dies with EADDRINUSE, and from the viewer's side the page looks fine
either way. Before starting one, establish that no server is already up for that
config dir (installation/check answers it, and never starts anything).
The durable form is a service unit (installation/web-hierarchy-server.service.example):
machine-start, Restart=always, up from boot before any work is delegated, and
surviving reboots. Installing one needs root, which is a human's step. On macOS
it is a LaunchAgent in the user's gui domain
(installation/web-hierarchy-server.plist.example), installed without sudo. Where
nobody has run it, the server runs as a detached process started once
(setsid nohup ... & on Linux, nohup ... & on macOS, which has no setsid, so it
outlives the session that launched it) - that form
works and does not survive a reboot, which is exactly what installation/check
says in words rather than reporting a green.
Edits to serve.mjs or format.mjs are LATENT by design. node loads them
once at process start, so a landed change reaches a running page only at the
next restart. That is expected; it is not a reason to bounce a server that a
viewer is watching, and it IS the reason a "my fix did not take" report is
usually a running process holding the old code.
The tree is SERVED; the logs are FETCHED
This is the shape of the whole thing, and it is a size rule, not a style.
A session's TREE costs about a kilobyte per agent whatever that agent did. Its
LOGS are unbounded, and rendering one drags its images in with it. Putting every
agent's log in every snapshot makes GET / for a 228-agent session 358MB,
110MB of it base64 image data, re-sent on every 10s poll. Nothing is broken, and
the page cannot be opened.
So the payload is split, and each route carries one kind of thing:
| Route | Carries | Costs |
|---|---|---|
GET / | the whole tree, the ROOT session's log, and that content's images inline as data URIs | one document, complete without a server |
GET /state | the whole tree and NO logs (?logs=<id>,<id> names exceptions); ?view=active narrows the GRAPH to the active view | what the 10s poll pays, every tick, for ever |
GET /agent/<id> | exactly that agent's rendered log | one fetch, when the viewer selects a block |
GET /agent/<id>?since=<n> | only events from index n | an empty array for a quiet agent |
GET /img/<hash> | one image by content hash, immutable | once per image per viewer, then cached |
Two consequences worth knowing before changing anything here:
sinceindexes the tree's owncount. A tree row carries the agent's real event count even though its log was not rendered, and that number is what a client hands back assince. Both come out of the same transcript parse, and they have to: derive one of them somewhere else and a client silently skips or repeats events, with nothing to see but a log that is subtly wrong.GET /img/<hash>is content-addressed, so an answer is cached for ever. That is why an unknown hash is a 404 and never an empty 200 - an empty 200 is a permanently broken image for that viewer, and no reload fixes it.
Nothing goes red if this split is lost. Put every log in GET /state, or
inline images into every snapshot, and the tree is right, the labels are right,
the page renders, and it is hundreds of megabytes. The SIZE is the property under protection; the size legs
in serve.test.mjs (6-9) are the only thing that can see it regress, and each is
a PAIR - absent here, present there - because "X is absent" is otherwise true for
all the wrong reasons.
What a SAVED page has, and what it does not
GET / is one standalone HTML document: inline CSS and JS, no external
assets, and a baked-in snapshot that renders with no server at all. What is baked
is the whole tree and the root session's log, with that content's images as
data URIs.
Not baked: every other agent's log. Those are not in the document; they
arrive from /agent/<id> on selection. So a page saved to disk, or one whose
server has died, shows the complete graph and the root session's log, and shows
nothing when a subagent block is clicked. The images inside those logs are the
same story: they render from /img/<hash> and there is no server behind a saved
file.
For a frozen record of EVERY log in one file, use build.mjs - the static
bake inlines every agent's log and every image, because a keepsake has
nothing to fetch from. That is the one caller that asks snapshot() for all the
logs (logsFor: null).
How it follows sessions
A server is long-lived; a Claude session is not: ending a session, an update and a unit restart all mint a new session id. So the server re-picks the most recently active session on every snapshot rather than resolving one at startup, and serves a "waiting for a session..." page while none exists yet - the normal state at boot. "Most recently active" includes the ROOT transcript, not just subagent files: a freshly started session that has not delegated yet is followed immediately and rendered as a root-only tree, and the spawn tree grows from its first delegation. Nothing needs restarting when a new session begins; the page detects the id change and rebuilds its logs.
--session=<id> pins one session instead (a frozen view for debugging).
Every project slug under the config dir is scanned, not one. A session's transcript lands under the project slug of its CWD, so a session working in a git worktree writes under the WORKTREE's slug; pinning one slug makes every worktree session invisible while the page still renders perfectly.
Which agent a page says it is showing
The root block is labelled with the fleet member the config dir belongs to,
resolved through config.json's fleet.by_config_dir. The key is derived, not
the raw basename, and the derivation is the part worth reading:
format.mjs'sfleetKey()takes the config dir's basename, EXCEPT when that basename is the literal.claude, where it takes the PARENT directory's basename instead. A fleet that gives each agent a home directory puts every config dir at<home>/<agent>/.claude, so keyed by basename all of them collide on one entry and every agent's page is labelled with whichever agent that entry names.
That failure is silent: the page renders completely and says the wrong name, and
a viewer with two pages open cannot tell them apart and does not know they
cannot. A literal .claude entry is honoured as a fallback, so a
single-agent install keyed .claude keeps its label; it cannot distinguish
two agents and should not be used where more than one exists. The same derived
key picks the default port from config.json's servers, for the same reason:
a basename match would hand every agent the first entry's port.
Files
serve.mjs- the live server:GET /,GET /state,GET /agent/<id>andGET /img/<hash>(see the route table above). The primary tool.--port=N,--host=<addr>,--cap=KB,--session=<id>, each defaulting fromconfig.json;--helpprints the usage, the resolved config path, the transcript root and the default port, and binds nothing.--port=0takes an ephemeral port and the banner prints the one it actually got.format.mjs- the shared formatter: transcript parsing, spawn-tree layout, per-event HTML, the theme CSS, and the one place local values are read (cfg()over theconfig.jsonthatfleet wherenames, or overWEB_HIERARCHY_CONFIGwhen that is set).build.mjs- optional STATIC snapshot (node build.mjs <out.html>): bakes the current state into one self-contained file for a frozen, shareable record. Use for a keepsake, not the live view.identities.json+avatars/- the display-name roster, used unlessidentities.filenames another one.serve.test.mjs+fixtures/- the test: it starts a real server on an ephemeral port against a synthetic transcript tree, asserts the tree, drives every route (tree without logs, one agent's log and nobody else's,?sinceshipping only what is new, an image served by hash byte for byte), and then DELETES one subagent's transcript and requires the tree AND the per-agent route to lose it. Its header states why each leg is there, which pair each size leg is half of, and why the negative control is the load-bearing part. Leg 11 requiresGET /state?view=activeto drop the stopped block that the default view draws.view.test.mjs- the "active only" rule on its own (filterView()over a synthetic tree): alive blocks, the blocks above them and the root, with the stopped siblings and children next to them dropped, and "all" returning every row unchanged.format.test.mjs- how the config is found and read: throughfleet wherewhenWEB_HIERARCHY_CONFIGis unset (a fake fleet selected byFLEET_BIN), the variable winning, a failing fleet relayed rather than guessed past, relative paths, the roster default, andpackages.dir_pattern.installation/- the unit template, the install procedure, andcheck.
What the page shows
It reads like the Claude Code console in the dark-colorblind
(dark-daltonized) theme - palette taken verbatim from the Claude Code bundle
(black background, white monospace text, tool bullets in success-blue #3399ff,
errors #ff6666, dim result lines with "... +N lines" expanders, thinking
markers, task and user messages framed like console input).
- Spawn-tree graph. The root session is the top node; every parentless agent
hangs off it. Blue = alive (with a rotating spinner arc beside the time range;
static under
prefers-reduced-motion), grey = stopped. Click a block to show that agent's full log below. - An "all / active only" switch above the graph. "all" is the default and
draws every block. "active only" draws the blue blocks, every block above
one of them, and the root: the alive flag is the one that colours a block,
so the switch never disagrees with the colours. The blocks above stay
because a parent waiting on a long child writes nothing to its own
transcript and reads as stopped while the child runs; without them the
child would hang from nothing. With nothing alive the view is the root
alone. The switch narrows the GRAPH only: the stamp still counts the whole
session (plus "N shown"), and a from-link or
#a-<id>to a hidden agent still opens its log. The choice is remembered per viewer in the browser'slocalStorage; where storage is blocked the switch still works for that visit.format.mjs'sfilterView()is the rule;view.test.mjspins it. - Per-agent task description on every block. Source of truth: the spawning
Agentcall'sdescription, which the harness copies intoagent-<id>.meta.json. Fallback when absent: the first line of the agent's task prompt. The description wraps and the block grows to fit it. - The last log line on every block, as a hover tooltip. At a few hundred agents it is the only thing that separates two blocks with the same type and the same description, and for an alive agent it is what it is doing right now. It rides in the TREE because it is the one piece of log content cheap enough to: plain text, clamped, never HTML - the moment it renders markup it drags images and expanders into every snapshot.
- Model on every block.
parseTranscriptreads the model off the transcript's assistant turns (theclaude-prefix stripped); when the agent has not answered yet, the coarse alias in its.meta.jsonstands in. A node with neither renders no line rather than a placeholder. - Named agents with avatars. Each block shows a name, its avatar, the
agentType, the description, the model and the time range, so five concurrent
agents of one type are distinguishable at a glance.
- The name is a display label only: assigned from the roster at
identities.fileas a pure function of (agentType, spawn order within that type), recomputed from the transcripts on every refresh. Nothing is stored anywhere, and the agents do not know their own names - the harness owns the transcripts and this formatter is a read-only observer. - Spawn order, not a hash of the agent id: with N names and N agents a hash collides by pigeonhole; spawn order cannot.
namesorder is load-bearing - append only. Reordering or inserting mid-list silently renames every past session's graph.avatar= the role's silhouette;portraits= optional bespoke art for a name. The avatar identifies the ROLE, the name identifies the AGENT.- An agentType absent from the roster renders unnamed, as does the root session, which is a person's and not an agent's.
- A roster that will not load is not a runtime error: every block renders
with no name and no avatar, which reads as a design choice rather than a
fault. It is logged to stderr, and
installation/checkresolves the path as one of its legs - that leg is the only thing standing between a missing roster and nobody noticing for weeks.
- The name is a display label only: assigned from the roster at
- from/to headers. The task prompt and every inter-agent message carry
from: <spawner> - to: <agent> - HH:MM:SS; the from-id is clickable and selects that agent. The root session's own prompts come from a person, so they carryconfig.json'slabels.root_ownerinstead. - One-line tool units.
Bash/Read/Writecalls pair with their results into a single collapsed line; click expands the full command and the result. - File changes as diffs.
Edit/Write/NotebookEditrender as the console's Edit display: file-path header, line-number gutters, context rows, and the colorblind diff colors. - Handover packages. When a message references artifact files, or a package
directory of the shape
packages.dir_patterndescribes, a foldable block lists the deliverables, each collapsed:.mdunfolds to its text, images unfold inline,.htmlunfolds to an inert escaped-source view and is never embedded live. A relative path in a message is resolved againstpackages.repo_root. - Images as click-to-open thumbnails, everywhere they appear, deduplicated
by content hash so a diagram quoted in dozens of messages costs one copy. An
image the document BAKED IN is a data URI and clicking it opens a Blob URL
(browsers block opening a
data:URI as a top-level navigation); every other image renders as<img data-img="<hash>">with no data at all and is fetched from/img/<hash>, which the browser then caches permanently because the URL is the content hash. - An alive agent's last line animates. Its leading marker cycles six
Claude-style spinner frames in claude-orange, like the console's progress
indicator, and downgrades to the static marker the moment a later event
appends or the agent stops. Those six glyphs, and the box-drawing, dash,
ellipsis and bullet characters inside the scripts' string literals, are
FUNCTIONAL output: they are what the page draws. An ASCII sweep that
normalises them changes what the program emits, which is a product change
wearing a punctuation sweep's clothes. Sweep by offset, never by line:
format.mjscarries a large CSS template literal whose lines begin with/*and*, so a line-based "is this a comment" test edits the emitted stylesheet while believing it edits source. The guard is asserting that the non-ASCII content of every string literal is byte-identical before and after.
The served page is ONE STANDALONE HTML DOCUMENT: inline CSS and JS, zero external assets, and the tree plus the root session's log baked in with their images as data URIs - see What a SAVED page has above for what that does and does not survive the server dying. Polling and the per-agent fetches are the live-update mechanism on top. Everything is gzipped when the client accepts it, which matters through a forwarded or tunnelled port.
Thinking is not recoverable. Subagent transcripts persist extended thinking as an encrypted signature with empty plaintext, so the page marks where each agent thought and cannot show the content. The rest of the protocol is complete.
Triage: the page is empty, stale, or will not load
- Is the server up?
curl -so /dev/null -w "%{http_code}" http://127.0.0.1:<port>/on the machine itself.installation/checkanswers the same question with more detail and starts nothing. - "Waiting for a session..." is not a fault when the config dir has no session transcript yet. Any session, delegating or not, is followed as soon as its root transcript appears.
- The page shows the wrong agent's work.
CLAUDE_CONFIG_DIRin the server's environment wins overtranscripts.config_dir; a server launched without it falls back to the config file. Nothing on the page says which was used, which is why that key is one ofcheck's legs. - An edit did not take. node holds the code from process start: restart the server (a human action on a unit; on a detached process, kill it and start one). Confirm afterwards, because a restart that raced a still-bound port leaves the OLD process serving.
- A forwarded port goes stale while still looking connected after the host reboots. Re-establish the forward rather than debugging the server.
- The graph is there and a block's log stays empty. The log is a separate
fetch (
/agent/<id>), so check that route directly:curl -s "http://127.0.0.1:<port>/agent/<id>" | head -c 200. A 404 means the server no longer sees that transcript - the usual cause is a session change under a page that has been open a while, and a reload fixes it. A SAVED page shows nothing here and that is by design: only the tree and the root log are in the document. - A thumbnail is blank in a log. Images come from
/img/<hash>; a 404 there means the hash was evicted from the server's bounded image store, and re-selecting that agent re-renders its log and re-inserts it. A blank thumbnail on a SAVED page is the same by-design gap as (6).