agent-manager-template / docs /trace-panel-spec.md
Thomas Wolf
Session sharing: publish a session's trace as a Hub dataset, and read one in a panel (#10)
63bdc81 unverified
|
Raw
History Blame Contribute Delete
23 kB

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:

  1. A local session of your own β€” the immediate value, and the only way to exercise all five readers against real data. Ship this first.
  2. 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 as server/src/share.js does.
  • 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.js parses 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 in traces.js so the line-shape knowledge stays in one file and drift is visible in review, but keep it out of buildTraces()/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/.bin and git hooks, and paths occasionally materialised as directories (.git/hooks/pre-commit, ~/.gemini/projects.json, and historically opencode.json β€” see the guard at runner.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 carrying toolUseResult and a content:[{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 isMeta and sourceToolUseID user lines; they are not prompts.
  • file-history-snapshot / file-history-delta lines 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?}} β€” if thread_source === 'subagent' or source.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 as role:'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_patch carries 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 subtract cached_input_tokens from input_tokens to 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) β€” data is JSON with role
  • part(id, message_id, session_id, time_created, data) β€” data is JSON with type: 'text' | 'tool' | 'step-finish', plus text, tool, state.input, state.output
  • Never copy or ship this database: account.access_token, account.refresh_token and a credential table 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) β€” flat content; a row with tool_name set is a tool interaction. timestamp is 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 trace emits Claude-Code JSONL specifically for the HF viewer, and --redact exists. 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.

  1. Ordered turns with role, timestamp, markdown-rendered text
  2. Thinking blocks β€” collapsed, one-line preview
  3. Tool calls β€” collapsed, showing name and a short argument summary; expand for full args
  4. Tool results β€” collapsed, truncated with an expand
  5. Per-turn token counts and the model chip
  6. Search / filter within the session
  7. 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

  • const does 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/vite cannot execute from node_modules/.bin on the FUSE mount (exec bit stripped). Run them as node node_modules/typescript/bin/tsc …, or install into /tmp.
  • web/ and server/ have no node_modules in the repo β€” deps live at /app/*/node_modules in the container. Symlink them for local runs.
  • Test the server on a spare port with PORT=… DATA_DIR=/tmp/… node src/index.js, and PUBLIC_DIR=<worktree>/web/dist to serve a locally built UI. Never pkill -f a 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 fresh DATA_DIR starts with the Welcome overlay, which intercepts clicks β€” POST /api/welcome/seen to dismiss it in tests.

10. Open questions for whoever builds this

  1. 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 trace session needs creating and pointing at a source, and the session record has no field for that yet β€” suggest traceSource: { kind: 'session' | 'bundle', ref: string }.
  2. 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".
  3. How much tool output to retain in memory once expanded β€” a single Bash result 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

  1. Blocks, not a Turn with a single toolResult. Β§5's shape can't represent the transcripts. Claude emits tool results on a separate type:'user' line, and parallel calls finish out of order, so one toolResult slot per turn silently drops all but one. readTrace() returns { role, kind?, ts?, model?, usage?, blocks: [...] } with a makeStitcher() that files each result next to its own call by tool_use_id β€” which is also what makes the collapsed 3 tool calls (Bash, Read) row possible at all.
  2. kind: 'final' on the last assistant turn before each prompt. Corrected 2026-07-29: the original claim here β€” that codex's task_complete is an authoritative answer separate from the response_item text, and that non-final text should render dimmed β€” was wrong on both counts. task_complete.last_agent_message is 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, and task_complete only marks it.
  3. 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.
  4. Environment blobs become role:'system', not dropped. Claude's <system-reminder>, codex's <-prefixed user items, and codex's whole role:'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

  1. Entry point: a Trace button on the session row, next to Share. traceSource was kept verbatim, and is only needed for a pane pointed at something else β€” a plain agent session reads its own transcript, so GET /api/trace/<agent-session-id> works with no new record. Panes are reused per source rather than piling up.
  2. Frozen per read, cheap to follow: the memo key includes mtimeMs+size, so re-requesting after the file grows reparses and total climbs. No incremental machinery.
  3. Capped at parse time, 20 000 chars per block, with more reported 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) β€” and session.model is a JSON blob, not a string.
  • A real STS bundle produced by scripts/share-session.mjs and read back through readTraceBundle() β€” which is how an accepted trace will arrive.
  • codex / openclaw / hermes fixtures built from the shapes in parseCodex and share-session.mjs's converters. Hermes's seconds-not-milliseconds timestamps render as correct dates; codex guardian rollouts are refused with trace-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 harness as an object ({id,name,version}) while an STS session line stores a string. Both reach harnessLabel, which the header renders directly β€” React throws on an object child. Hence label().
  • 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 to pageOf().
  • No compaction block is ever produced; Claude's type:'summary' / system lines and attachment lines 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. A bundle source 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.