Spaces:
Running
Trace panel β implementation spec
Status: built (2026-07-29) Β· Branch: worktree-session-sharing Β· PR: huggingface/agent-manager#8
A handoff document. Everything here was verified against this codebase and the live Hub on
2026-07-27/28. Read docs/session-sharing.md for the wider design; this file is only the
panel, and is written to be self-contained.
Β§11 records what actually shipped, including four places where the implementation departed from this spec on purpose. Sections 1β10 are the brief as written; where they disagree with Β§11, Β§11 is the code.
1. What to build
A read-only panel inside Agent Manager that renders one agent session as a readable conversation β turns, tool calls, thinking blocks, token counts β close in spirit to the Hugging Face agent trace viewer (Data Studio's session modal).
Two sources, same component:
- A local session of your own β the immediate value, and the only way to exercise all five readers against real data. Ship this first.
- An accepted incoming trace β a bundle downloaded from a Hub dataset, landing in
DATA_DIR/traces/<envelope-id>/. The receive half is not built yet.
The panel also hosts Fork and Handoff buttons (see Β§8 of the design doc), but those are not required for a first version.
2. Hard constraints
These are not style preferences; each one has already caused an incident in this repo.
- No LLM calls, anywhere. The whole feature is deterministic parsing. The operator has explicitly ruled out inline model calls in Agent Manager.
- Nothing heavy on the event loop. A single session here is 6.15 MB. This app has
wedged on synchronous work before β see
server/src/watchdog.js, a worker thread that exists solely to report main-thread stalls, with breadcrumbs (mark/tracked) around the known-heavy paths. Parse off the request path, or in a child process asserver/src/share.jsdoes. - Virtualize the message list from the start. The Hub's own session modal fails to render our 2.13 MB single-row dataset. Do not assume a session fits in the DOM.
- Do not extend the Overview's memoized parse.
traces.jsparses every session on a poll and memoizes by mtime; making those parsers also retain every turn would balloon memory across all sessions. Add a separate on-demand function for one session β put it intraces.jsso the line-shape knowledge stays in one file and drift is visible in review, but keep it out ofbuildTraces()/traceDigests(). - The filesystem is a FUSE bucket and it lies. Stale directory listings (a file written
seconds earlier reading as absent), exec bits stripped from
node_modules/.binand git hooks, and paths occasionally materialised as directories (.git/hooks/pre-commit,~/.gemini/projects.json, and historicallyopencode.jsonβ see the guard atrunner.js:415). Retry before concluding a file is missing.
3. Where it plugs in β already done
| Piece | State |
|---|---|
trace in the CLI catalog |
β
server/src/config.js:69 β bin: null, run: null, colour #7c8cf8 |
| Treated as a passive panel, never launched | β
server/src/runner.js:135 returns 'idle' for files and trace |
| Pane dispatch | β¬ web/src/App.tsx:423 β currently s.cli === 'files' ? <FilesPane/> : <TerminalPane/>; add a branch |
| Component | β¬ new web/src/components/TracePane.tsx |
| Reader endpoint | β¬ new, see Β§5 |
Model your component on web/src/components/FilesPane.tsx (181 lines) β the existing
passive, non-process pane. It shows the house style: plain fetch through web/src/api.ts,
no state library, CSS appended to web/src/styles.css with flat .kebab-case class names.
There is no modal/dialog framework; .welcome-backdrop + .welcome-card in styles.css is
the established overlay pattern (ShareDialog.tsx uses it).
4. Why the digest is NOT enough
The operator initially expected the existing digest would do. It will not, and here is the precise reason so nobody re-litigates it:
traces.js emptyDigest() (line 48) returns lastPromptText, lastAssistantText,
sinceTurns, sinceToolCalls, sinceTools (tool names and counts only), sinceFiles,
sinceTokens, running, and turnsLog. And turnsLog:
- is reset at the start of every request β
traces.js:60, commented "arrows only walk the current request's turns"; - is capped at
MAX_TURNS_LOG(24,traces.js:56); - holds only
{ answer, answerMd, ts }β assistant text, with no paired user prompt.
So the digest is a "what did this agent just do" summary that powers the Overview card. It carries no conversation history, no tool arguments, no tool results and no thinking blocks β exactly what a viewer needs. A full parse is required.
5. The reader
Suggested shape β one function, one session, on demand:
// server/src/traces.js (NOT part of buildTraces/traceDigests)
export async function readTrace(session, { offset = 0, limit = 200 } = {}) β {
harness, sessionId, model, cwd, firstTs, lastTs, total,
turns: [ Turn, β¦ ] // ordered oldestβnewest, sliced by offset/limit
}
type Turn = {
role: 'user' | 'assistant' | 'system' | 'tool';
text: string; // rendered as markdown for assistant/user
thinking?: string; // collapsed by default
ts?: number; // epoch ms
model?: string;
usage?: { in: number; out: number; cacheRead: number };
toolCalls?: { id: string; name: string; args: unknown }[];
toolResult?: { id: string; content: string; isError?: boolean };
};
Paginate at the API rather than sending 6 MB. Endpoint suggestion:
GET /api/trace/:sessionId?offset=&limit= for a live session, and
GET /api/trace/bundle/:envelopeId?offset=&limit= for an accepted one.
Locating the file is already solved β reuse findTrace(session, allSessions) exported
from server/src/share.js. It returns { src, sessionId? }, handles all five harnesses,
prefers the per-session pin where one exists (sessionUuid, codexSessionId,
opencodeSessionId), falls back to attribution by recorded working directory, and refuses to
guess when two sessions of the same harness share a folder. It also skips codex's
guardian/subagent rollouts. Do not reimplement this.
6. The five formats β verified line shapes
scripts/share-session.mjs already contains working readers for all five, written and
tested against real and faithful-synthetic data. Read it before writing anything: the
knowledge below is what it encodes, and reusing it avoids a second source of truth.
Claude Code β $CLAUDE_CONFIG_DIR/projects/<cwd-slug>/<uuid>.jsonl
One JSON object per line; the filename is the session id.
{type:'user', timestamp, cwd, gitBranch, message:{role:'user', content: string | [{type:'text',text}]}}{type:'assistant', timestamp, uuid, message:{id, model, role:'assistant', content:[{type:'text',text} | {type:'thinking',thinking} | {type:'tool_use',id,name,input}], usage:{input_tokens, cache_creation_input_tokens, cache_read_input_tokens, output_tokens}}}- Tool results come back as
type:'user'lines carryingtoolUseResultand acontent:[{type:'tool_result',tool_use_id,content}]. - Dedupe assistant lines by
message.idβ streaming writes the same message id more than once, and counting naively double-counts turns and tokens. - Skip
isMetaandsourceToolUseIDuser lines; they are not prompts. file-history-snapshot/file-history-deltalines embed whole file contents. The share pipeline drops them outright. A viewer should ignore them too.
Codex β $CODEX_HOME/sessions/YYYY/MM/DD/rollout-<ts>-<uuid>.jsonl
Everything is wrapped in payload.
{type:'session_meta', payload:{cwd, timestamp, thread_source?, source?}}β ifthread_source === 'subagent'orsource.subagent, this is an internal guardian rollout, not the user's conversation. Refuse it.{type:'response_item', payload:{type:'message', role, content:[{text}]}}β codex wraps environment/instruction blobs asrole:'user'items whose text starts with<; skip those or your prompt count is wrong.{type:'response_item', payload:{type:'function_call'|'custom_tool_call'|'local_shell_call'|'web_search_call', name, arguments}}βapply_patchcarries touched paths in its patch header:/\*\*\* (?:Update|Add|Delete) File: ([^\n"]+)/.{type:'event_msg', payload:{type:'token_count', info:{total_token_usage:{input_tokens, cached_input_tokens, output_tokens, total_tokens}}}}β cumulative per run, so keep the last one, and subtractcached_input_tokensfrominput_tokensto match Claude's "fresh input" convention.{type:'event_msg', payload:{type:'task_complete', last_agent_message}}β the authoritative final answer for a task.
OpenClaw β $OPENCLAW_HOME/.openclaw/agents/<agent>/sessions/<uuid>.jsonl
Already close to the Hub's STS shape:
{type:'message', timestamp, message:{role, content: string | [{type,text|name|input}], usage:{input,output,cacheRead}}}.
Content blocks whose type matches /tool/i are tool calls.
opencode β SQLite, ${XDG_DATA_HOME:-~/.local/share}/opencode/opencode.db
A session is a query, not a file.
session(id, directory, title, time_created, time_updated, tokens_input, tokens_output, tokens_cache_read)message(id, session_id, time_created, data)βdatais JSON withrolepart(id, message_id, session_id, time_created, data)βdatais JSON withtype: 'text' | 'tool' | 'step-finish', plustext,tool,state.input,state.output- Never copy or ship this database:
account.access_token,account.refresh_tokenand acredentialtable live in it. Select one conversation. - Live data is on local disk via a symlink with a durable copy synced to the bucket
(commit
1dfb753), precisely because a synchronous read of a FUSE-backed sqlite froze the whole server. Open read-only, and never on a hot path.
Hermes β SQLite, ~/.hermes/state.db
sessions(id, cwd, title, started_at, input_tokens, output_tokens, cache_read_tokens)messages(id, session_id, role, content, timestamp, tool_name, token_count, active)β flatcontent; a row withtool_nameset is a tool interaction.timestampis seconds (multiply by 1000). Same FUSE/symlink note as opencode.- Hermes has no per-session pin in Agent Manager, so it is attributed by
cwd. - Alternative worth knowing:
hermes sessions export --format traceemits Claude-Code JSONL specifically for the HF viewer, and--redactexists. We chose direct SQLite reads for one code path with opencode; either is defensible.
7. What the Hub's viewer does, for reference
Verified live. A dataset containing raw session .jsonl files gets auto-tagged
format:agent-traces, and the Hub aggregates one file into one row, deriving columns:
harness, session_id, prompt, messages, tools, metadata, sent_at,
num_user_messages, num_tool_calls, trace, file_path.
Clicking the row opens a Session modal showing: user/assistant turns with role badges and
timestamps, the model name as a chip, per-turn token counts (491β 520β (1,408 cached)), a
collapsible Thinking block with a one-line preview, collapsed N tool call (bash)
summaries, rendered markdown including tables, and a Collapsed/Expanded toggle.
It is embeddable and deep-linkable β β¦/embed/viewer/default/train?row=0 renders the modal
inside an iframe. We deliberately do not use it: it cannot render a gated repo (the
private path), and a brand-new dataset took ~50 minutes to become viewable. Both are
recorded in the design doc. It remains the visual reference, and it is what a public share
gets for free in a browser.
Live examples to look at:
thomwolf/am-session-sharing-design (public, renders),
thomwolf/am-session-sharing-design-gated (gated, shows the gate).
8. Suggested v1 scope
Ordered by value. The operator asked for full fidelity with everything collapsed by default.
- Ordered turns with role, timestamp, markdown-rendered text
- Thinking blocks β collapsed, one-line preview
- Tool calls β collapsed, showing name and a short argument summary; expand for full args
- Tool results β collapsed, truncated with an expand
- Per-turn token counts and the model chip
- Search / filter within the session
- Header: harness, model, window, totals β reuse the stats the share manifest already computes
Skip: statistics panels, prev/next row navigation, editing anything. The panel is read-only.
9. Traps that cost me time
constdoes not hoist. Twice I put helpers or accumulators after the loop that used them and got a temporal-dead-zone crash on the first line parsed. Declare state above.- Verify your success checks. I once printed "ok" from a grep that matched a comment, and
another time from
head's exit status rather than the compiler's. Both hid real failures. tsc/vitecannot execute fromnode_modules/.binon the FUSE mount (exec bit stripped). Run them asnode node_modules/typescript/bin/tsc β¦, or install into/tmp.web/andserver/have nonode_modulesin the repo β deps live at/app/*/node_modulesin the container. Symlink them for local runs.- Test the server on a spare port with
PORT=β¦ DATA_DIR=/tmp/β¦ node src/index.js, andPUBLIC_DIR=<worktree>/web/distto serve a locally built UI. Neverpkill -fa pattern that could match/app/server/src/index.jsβ that is the live Space at PID 1. - The app has no authentication at all; it only serves a usable backend while the Space is
private (
visibility.js,index.js:142). A freshDATA_DIRstarts with the Welcome overlay, which intercepts clicks βPOST /api/welcome/seento dismiss it in tests.
10. Open questions for whoever builds this
- Where does "open the trace" live? A button on the session row next to Share, an item
in the pane header, or an entry in the Overview card? A
tracesession needs creating and pointing at a source, and the session record has no field for that yet β suggesttraceSource: { kind: 'session' | 'bundle', ref: string }. - Does one panel follow a live session as it grows, or is it a frozen snapshot? Following means re-polling and appending; frozen is simpler and matches "trace".
- How much tool output to retain in memory once expanded β a single
Bashresult here can be hundreds of KB.
11. As built (2026-07-29)
Landed. Files: server/src/traces.js (reader appended at the bottom),
server/src/index.js (two routes), web/src/components/TracePane.tsx (new),
web/src/api.ts, web/src/types.ts, web/src/styles.css, plus the entry point in
App.tsx / Sidebar.tsx.
Four deliberate departures from Β§5βΒ§8
- Blocks, not a
Turnwith a singletoolResult. Β§5's shape can't represent the transcripts. Claude emits tool results on a separatetype:'user'line, and parallel calls finish out of order, so onetoolResultslot per turn silently drops all but one.readTrace()returns{ role, kind?, ts?, model?, usage?, blocks: [...] }with amakeStitcher()that files each result next to its own call bytool_use_idβ which is also what makes the collapsed3 tool calls (Bash, Read)row possible at all. kind: 'final'on the last assistant turn before each prompt. Corrected 2026-07-29: the original claim here β that codex'stask_completeis an authoritative answer separate from theresponse_itemtext, and that non-final text should render dimmed β was wrong on both counts.task_complete.last_agent_messageis byte-identical to the preceding assistant message in every task of the rollout checked, so treating it as separate rendered all 8 answers twice; and the Hub emphasises the final turn with an accent rule rather than dimming the others.markFinalTurns()now derives the final turn for every harness in one reverse pass, andtask_completeonly marks it.- Codex gets a stitcher too. Codex writes a call and its output as two separate
top-level items, so before this every call and every result was its own row and nothing
ever folded. Caught in testing:
0 paired, 1 standaloneβ1 paired, 0 standalone. - Environment blobs become
role:'system', not dropped. Claude's<system-reminder>, codex's<-prefixed user items, and codex's wholerole:'developer'stream are context, not prompts. Dimmed and collapsed behind their own tag name rather than hidden, because removing them makes the conversation read wrong β but one of them here is 27 KB, so expanded they bury the session before it starts. The Hub instead labels these "User" and expands them, or omits them entirely; this is a deliberate divergence.
The three open questions in Β§10, as answered
- Entry point: a Trace button on the session row, next to Share.
traceSourcewas kept verbatim, and is only needed for a pane pointed at something else β a plain agent session reads its own transcript, soGET /api/trace/<agent-session-id>works with no new record. Panes are reused per source rather than piling up. - Frozen per read, cheap to follow: the memo key includes
mtimeMs+size, so re-requesting after the file grows reparses andtotalclimbs. No incremental machinery. - Capped at parse time, 20 000 chars per block, with
morereported so the UI says "+412 KB not retained" instead of pretending.
trace is a passive pane, like files
PASSIVE_CLIS = ['files', 'trace'] in server/src/config.js, mirrored as isPassive() in
web/src/types.ts. It gates: the agent list in /api/meta, buildTraces(), the input route
(a trace pane refuses keystrokes), the Overview cards, archiving, the quickstart picker, the
group cart, and a group's agent count. Adding a third passive pane type now means one array.
Verified against real data
- 9.46 MB live Claude transcript (this session): 634 ms full parse, worst event-loop block 2 ms, 82 MB RSS; second call 3 ms off the memo. Counts reconciled against an independent pass over the raw file β 259 text blocks = 228 assistant + 28 prompts + 3 reminders, exactly; 466/467 tool results filed next to their call.
- Real opencode db (3.4 MB): 79 turns, 57 thinking blocks, 98/101 results paired. The two
guesses flagged in the drop were both confirmed against the live schema (
state.input/state.output,reasoning.text) β andsession.modelis a JSON blob, not a string. - A real STS bundle produced by
scripts/share-session.mjsand read back throughreadTraceBundle()β which is how an accepted trace will arrive. - codex / openclaw / hermes fixtures built from the shapes in
parseCodexandshare-session.mjs's converters. Hermes's seconds-not-milliseconds timestamps render as correct dates; codex guardian rollouts are refused withtrace-not-user-conversation. - Headless Chromium over CDP (no playwright in this image) against the built bundle, for
each harness: pane mounts, header reads
Claude Code | claude-opus-5 | 555 turns | 1,840,279β 398,440β (138,147,880 cached), 14 rows in the DOM for 555 turns, folds open, scrolling to 70 % pages in with no placeholders left, search reports what it searched, zero JS exceptions. - Refusals render as sentences, not failures: an unsupported CLI, a missing transcript, a guardian rollout, a missing bundle. Bad bundle refs are rejected at write and read.
Two bugs this testing caught that a curl check would not
- A bundle manifest stores
harnessas an object ({id,name,version}) while an STS session line stores a string. Both reachharnessLabel, which the header renders directly β React throws on an object child. Hencelabel(). - Claude sessions had no token total in the header: usage is per-message there, so the
session sum has to be accumulated (
usageSum), while codex and the db-backed harnesses keep their own authoritative total.
Not done
- Fork / Handoff are header slots only (design doc Β§8).
- Search is client-side over fetched turns and says so; server-side wants a
?q=returning matching offsets β a small addition topageOf(). - No
compactionblock is ever produced; Claude'stype:'summary'/systemlines andattachmentlines are ignored. The block type exists for when that's wired up. - The incoming half (accept/decline on an inbox PR) is out of scope as of 2026-07-29 β
see the scope note at the top of
docs/session-sharing.md. Abundlesource is populated by pasting a dataset URL into the sidebar's Trace button, which is the intended flow.
12. Comparison against the Hub's own viewer (2026-07-29)
Done on a real private share, thomwolf/codex-session-019fac81, by rendering the Hub's
Trace tab in headless Chromium and scraping it in document order, then taking an
independent census of the 393-line file so the arbiter was the data rather than either
renderer. Reproduce with Network.setExtraHTTPHeaders carrying a bearer token β the Hub
serves the blob page and its viewer with one.
Findings are in Β§11's list and in commit 08714a5. What is worth keeping here:
Both agree on totals, not on attribution. The Hub's first assistant row claims
16 tool calls (exec_command, apply_patch, write_stdin) and then exactly 1 tool call
for each of the next five turns. The file has 2, 2, 1, 3, 6, 7 between successive
assistant texts. Both sum to 21, so nothing is lost β but the per-turn attribution
differs, and the file backs ours. Its first row's token figure (354β, 31,488 cached)
is likewise the last model call of that task shown on the task's first row.
The Hub has no System or Developer row type. Measured: System: 0, Developer: 0
across the rendered window. It shows <recommended_plugins> as a User turn, expanded as
markdown, and omits codex's seven role:'developer' messages entirely.
Two streams, and the same content in both. This is the single most important thing
about the codex format and it is documented at normalizeCodex in traces.js:
response_item is what went to and from the model, event_msg is what the TUI showed.
agent_message duplicates the assistant response_items exactly; agent_reasoning
arrives first and repeats what a later reasoning item's summary entries contain, so
dedupe per ENTRY, not per joined string; and web searches exist ONLY as web_search_end.
Encrypted reasoning is normal. 49 of 56 reasoning items carried only
encrypted_content. Say so in the UI; do not leave the impression the model didn't think.