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.