Spaces:
Running
Running
File size: 22,979 Bytes
63bdc81 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 | # 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.
|