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:
```js
// 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
}
```
```ts
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_item`s 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.