Application Model¶
claude-code-log reads Claude Code transcript files (JSONL on disk) and
produces readable HTML, Markdown, and structured JSON views, with
optional caching, a TUI for navigation, and per-project aggregate
pages.
This document is the entry point for dev-docs/: a high-level view of
the parts, what each does, and where to read about them in detail. For
end-user documentation see the project README.md;
for contributor onboarding see CONTRIBUTING.md;
for user-facing operations docs see docs/.
1. Subsystems at a glance¶
| Subsystem | Owner module(s) | Deep-dive |
|---|---|---|
| CLI | cli.py |
inlined below (§ 2.1) |
| TUI | tui.py |
inlined below (§ 2.2) |
| Cache (SQLite) | cache.py + migrations/ |
inlined below (§ 2.3); user-facing in docs/restoring-archived-sessions.md |
| Migrations | migrations/ + migrations/runner.py |
inlined below (§ 2.4) |
| Parsing | parser.py, factories/ |
rendering-architecture.md § 3 |
| Message taxonomy | models.py |
messages.md |
| DAG (sessions, forks, agents) | dag.py |
dag.md |
| Sync sub-agents (#79) | converter.py, factories/agent_metadata_factory.py |
agents.md § 1 |
| Async task agents (#90) | converter.py, factories/task_notification_factory.py |
agents.md § 2 |
| Teammates (#91) | renderer.py, factories/teammate_factory.py, html/teammate_formatter.py |
teammates.md |
| Dynamic workflows (#174) | workflow.py, converter.py, renderer.py |
workflows.md |
| Rendering pipeline | renderer.py, html/, markdown/, json/ |
rendering-architecture.md |
Themes (classic, minimal) |
cli._resolve_theme, utils.DEFAULT_THEME, html/renderer.py (theme, generator stamp), html/minimal_theme.py, transcript.html ({% if minimal %} branches), html/templates/components/minimal/ |
option and staleness below (§ 2.1); the minimal theme's as-built reference starts at minimal-theme.md (map of its parts in § 0); user guide docs/themes.md |
| Branch lanes (minimal theme's DAG data) | lanes.py (model), html/minimal_theme.py (data-* attributes) |
agents.md § 6, dag.md § Branch lanes; attribute schema in minimal-theme.md § 12 |
| Minimal theme's DAG engine (branch modes, rail) | html/templates/components/minimal/minimal_dag.js, dag.css |
minimal-theme.md |
| Fold-bar / message hierarchy | html/templates/components/, JS in transcript.html |
message-hierarchy.md |
| CSS class taxonomy | html/templates/components/*.css |
css-classes.md |
| JSON export (#36) | json/ |
inlined below (§ 2.5) |
| Depth filter | renderer.py § Depth filtering, models.RenderingDepth |
inlined below (§ 2.6) |
| Image export | image_export.py |
inlined below (§ 2.7) |
| Performance profiling | renderer_timings.py |
inlined below (§ 2.8) |
| Markdown engine (wenmode) | markdown_plugins.py, html/utils.py, markdown/renderer.py |
inlined below (§ 2.17) |
| Intra-project render fan-out | render_pool.py (mechanism) + render_dispatch.py (policy) |
inlined below (§ 2.10) |
| Diagnosing hangs (SIGUSR1) | cli.py _install_stack_dump_signal |
inlined below (§ 2.11) |
| Watch mode / live page updates | watch.py, html/templates/components/live_update.js |
inlined below (§ 2.15); design in work/watch-mode.md; user-facing in docs/live-updates.md |
| Adding a new tool renderer | factories/tool_factory.py, html/tool_formatters.py |
implementing-a-tool-renderer.md (how-to) |
| Which tools have a specialized renderer or provider adapter | TOOL_INPUT_MODELS / TOOL_OUTPUT_PARSERS in factories/tool_factory.py, plus provider adapters |
tools-coverage.md (Claude and Codex status vs. upstream references) |
| Plugin system (third-party message transformers) | plugins.py, factories/priorities.py, Renderer._dispatch_format |
plugins.md |
A note on cross-cutting concerns: some behaviour spans several rows
of the table above and isn't owned by any single subsystem. Label
and preview composition (session header titles, branch labels,
fork-point box captions) is the most common one — it touches the
DAG layer (which decides what's a branch), the renderer's session
machinery (which assembles the label text), and the parsing layer
(which feeds the preview source). See the SessionHeaderMessage
entry in § 4 for the function-level surface.
2. Subsystems without their own deep-dive¶
The subsystems above with "inlined below" pointers don't have a dedicated dev-doc — the paragraph here is the canonical reference.
2.1 CLI¶
cli.py is the command-line entry point
(claude-code-log) built on Click. The default invocation processes
the entire ~/.claude/projects/ hierarchy; explicit paths target a
single transcript or directory. Major flags:
--tui— launch the interactive TUI (§ 2.2).--depth {session,user,assistant,agent,tool,hook}(defaulttool) — how deep into the message hierarchy to render (§ 2.6). Legacy--detail {full,high,low,minimal,user-only}is a deprecated alias.--from-date "yesterday",--to-date "today"— natural-language date filtering viadateparser.--open-browser— open the generatedindex.htmlafter rendering.--no-cache/--update-cache— bypass or force-refresh the SQLite cache (§ 2.3).--format {html,md,markdown,json}— switch output format (HTML is the default; Markdown is mainly used for sharing transcripts inline; JSON exports the processed tree for downstream tooling — see § 2.5).--compact— Markdown-only; suppresses repeated headings.--page-size N— paginate the combined-transcript HTML/Markdown output, packing whole sessions into pages of up to N messages each (sessions are never split across pages, so individual pages may overflow). Per-session HTML files are not paginated.--jobs N/-j N— worker processes for the all-projects conversion phase (default: CPU count;1disables parallelism). Also caps the per-project render fan-out, which is opt-in via$CLAUDE_CODE_LOG_RENDER_JOBS(§ 2.10).--theme {classic,minimal,default}— HTML theme (also onserveandwatch, and passed to the TUI for its HTML exports).defaultis the built-in default, resolved throughutils.DEFAULT_THEME(currentlyclassic); precedence is flag >$CLAUDE_CODE_LOG_THEME> built-in default, and an unknown environment value is a usage error (cli._resolve_theme). Only resolved names (classic/minimal) are ever rendered or stamped. HTML-only: Markdown/JSON warn on an explicit flag and render as before. The minimal page layers its own stylesheets and a sticky toolbar over the classic markup, with light/dark followingprefers-color-schemeor an in-page toggle — see rendering-architecture.md § 8, css-classes.md § Minimal Theme and minimal-theme.md (the theme's entry point); the user guide isdocs/themes.md.
CLI orchestration delegates to converter.py (which owns the
high-level "load + render + write" flow) and never touches renderer.py
directly. Output paths follow a stable convention so the cache and
re-renders can find existing files: combined_transcripts.html,
session-{id}.html, index.html, with --depth and --compact
adding suffixes per utils.variant_suffix.
--theme deliberately adds no suffix: a theme overwrites the same
files. What tells the themes apart is the generator comment on line 2
of every page, which the staleness checks already read:
<!-- Generated by claude-code-log v1.2.3 --> for classic (the same stamp
classic pages carried before themes existed) and
<!-- Generated by claude-code-log v1.2.3 theme=minimal --> otherwise
(html/renderer.py::html_generator_stamp). HtmlRenderer.is_outdated,
is_html_outdated(path, theme) and the cache's is_transcript_stale /
get_stale_sessions / is_page_stale all compare the stamp against the
theme a page rendered now would carry, so a page from the other theme
reports file_version_mismatch on every path — single-file, paginated,
streaming (§ 2.13), session-scoped (§ 2.12), render pool (§ 2.10), the
all-projects plan, watch/serve. Cache rows (html_cache,
html_pages) are shared between themes and need no theme column. One
promotion keeps archives single-themed: a --combined no run whose
combined output carries the other theme rewrites it once
(converter.combined_theme_mismatch), because its session pages link
back to it. The project index and the archive search page are rewritten
on every run and decide nothing from their stamp; they are rendered in
the run's theme on every path that writes them (the all-projects run and
so serve, its --watch ticks and watch --all-projects, and the
provider hierarchy; the TUI writes neither) and stamped like the other
pages (minimal-theme.md § 13).
For the all-projects invocation, process_projects_hierarchy runs in
three phases: plan (sequential, cheap — per-project staleness via
cache mtimes, also ensures DB schema/migrations exist), execute
(stale projects fan out over a ProcessPoolExecutor with the spawn
start method, largest-first; rendering is CPU-bound pure Python and
projects are independent, so this scales near-linearly with cores; when
the per-project render fan-out is enabled, any budget left over after one
worker per stale project is handed to it, § 2.10), and collect
(sequential — per-project index data is read back from the cache and the
cross-project index.html is written last). Workers
run silent; the parent prints one progress line per project as results
arrive. All workers share the WAL-mode SQLite cache DB — each writes
only its own project's rows, and WAL serialises the short write
transactions. A pool-level failure (e.g. a library caller without the
if __name__ == "__main__" guard spawn needs) degrades to inline
sequential processing with a warning rather than aborting.
2.2 TUI¶
tui.py is a Textual application that
browses the projects index, drills into individual sessions, and
exposes quick actions: render session to HTML, resume a session via
claude --resume, archive a session (move to cache-only), and so on.
Architecture is straightforward Textual: a few Screen subclasses,
a DataTable for the session list, key bindings dispatched through
Textual's BINDINGS mechanism. The TUI reads through cache.py
exclusively (never re-parses JSONL itself) — opening a 50-project
hierarchy takes milliseconds because cache hydration is incremental.
HTML exports honour --theme / $CLAUDE_CODE_LOG_THEME (§ 2.1):
run_session_browser(..., theme=) hands it to SessionBrowser as
html_theme (not theme, which is Textual's own UI theme), and
_ensure_session_file renders with it — regenerating a session page
left in the other theme, under the same session-{id}.html name.
The "archive" action is interesting: it moves a session's source JSONL
out of ~/.claude/projects/ while keeping the cache row intact. The
session then renders from cache only. See
docs/restoring-archived-sessions.md
for the user-facing behaviour and recovery flow.
2.3 Cache (SQLite)¶
cache.py maintains a SQLite database
at ~/.claude/projects/claude-code-log-cache.db (or
$CLAUDE_CODE_LOG_CACHE_PATH). Stored data:
- Per-session: id, summary, first/last timestamps, message count,
per-role token totals,
team_name(added in migration 005). - Per-message: a denormalised view used by archived-session
restoration (the cache holds enough to re-render even after the
source JSONL is deleted). Each row's
contentis the entry as zlib-compressed JSON, written atCONTENT_COMPRESSION_LEVEL(3, not zlib's default 6: levels only diverge on large payloads, so across a real 18,288-row archive it costs 2.6% more bytes and makes a cold conversion 11% faster — compressing rows is the largest item in a watch tick). Reading is level-agnostic, so rows written at any level still load. - Per-rendered-HTML: the HTML output itself, indexed by source file mtime + depth + compact flag (migrations 002–004) — so re-runs with unchanged inputs serve the cached HTML directly.
- Cross-session sidecar (migration 008): compact projections of a full
load's
SessionTree— per-session parent linkage, junction points, and cross-session dedup winners — persisted so the session-scoped incremental path (§ 2.12) can regenerate one stale session without loading the project. Rewritten wholesale on every full directory load, inside the same batch as the per-file writes, so it is exactly as fresh ascached_files.
Invalidation is mtime-based: when a JSONL's mtime is newer than its cache row, the session is reparsed. The schema-version row also invalidates the entire HTML cache when migrations bump the version, since rendered output may have changed even when source data hasn't.
Corruption is recovered by discarding, not repairing. A damaged
cache file is unusable and, left alone, permanently so: apply_migration
records only a migration that completed, so a CREATE INDEX that hits a
damaged page re-fails on every subsequent run. The writing
CacheManager.__init__ therefore catches SQLite's corruption errors
(is_corrupt_database_error — narrowly matched on message text, since
Python funnels "malformed", "not a database", "locked" and "disk full"
into the same exception classes), deletes the .db with its -wal/-shm
sidecars, and rebuilds. Everything in the cache is regenerable from the
JSONL source, so the cost is one slow run.
Two boundaries matter. A read_only manager — every spawned render
worker (§ 2.10) — never deletes: several run concurrently against one
file, and _lookup_project_id already degrades to "no cached data".
And a zero-byte file is not corruption; SQLite adopts one as a new
database, so an interrupted create heals without intervention.
Note the cause is usually outside this tool. The case this was built
for was a cache truncated to 2833 pages while its own header still
claimed 14189 — traced to the virtiofs mount it lived on, not to
anything the writer did. It had also been corrupt for some time
before it was noticed: migration 012's index build is the first
operation that full-scans messages, so earlier versions read around
the damage and reported success. Assume corruption can recur on such
filesystems, and that recovery, not prevention, is the tool's job.
Paginated output carries an extra invalidation axis. --page-size
assigns sessions to pages chronologically, and that assignment is
recomputed from scratch on every run, so a page's membership can
change while every session it already held is untouched: a new session
lands on the partly-filled last page, or sessions imported from another
machine sort into the middle by their original timestamps and shift
everything after them along. Per-session freshness alone can't see
this, so is_page_stale() also compares the run's computed session
list against the cached page_sessions rows and reports
sessions_changed. Without that comparison the affected pages report
up_to_date, and the new sessions land in the cache and in their own
session-*.html but never in any combined page (issue #308).
Connections run in WAL mode with synchronous=NORMAL (durable across
app crashes; only a power/OS crash can lose the last commit — fine for a
regenerable cache). Both pragmas come from
migrations.runner.apply_write_pragmas, which every writing
connection applies — the two that open outside CacheManager included:
the migration runner's own, which is what touches a brand-new database
first, and the FTS index builder's. As to the runner,
at the SQLite defaults it ran the whole migration chain at an fsync per
commit: 128 ms/db against 13 ms/db. Setting only journal_mode is not
the fix: it persists in the file, but synchronous is per-connection
and stays at FULL (50 ms/db). Those are 20 fresh databases per arm on
an idle disk; re-derive with scripts/bench_migration_pragmas.py
rather than trusting them, because the ratio is not a constant. Fsync
cost scales with device contention, so on a loaded box the same arms
measured 1382 ms against 46 ms — while a synchronous=OFF control
stayed at 10-12 ms/db across every load, which is what identifies the
cost as fsync rather than migration work.
Count the syscalls instead of timing them and the shape is exact, with
no dependence on what the disk was doing — strace -c -e trace=fsync
over one run_migrations, with the chain truncated to its lowest N
migrations:
| migrations | pre-fix | shipped |
|---|---|---|
| 12 | 228 | 7 |
| 13 | 236 | 7 |
| 14 | 244 | 7 |
Every migration added to the chain costs 8 more fsyncs at the defaults, and none with the pragmas — the runner's cost grows with the chain while the shipped one stays flat, so this bounds a cost that would otherwise rise with every schema change, rather than paying for itself once. Don't infer that slope from timings: what an fsync costs swings by more than 10x with contention, so per-migration wall-clock deltas measured on different days are incoherent (one such pair read +16 ms and the next −0.7 ms), while the counts above are stable.
The FTS index builder (cli.py::_build_search_index) is the same shape
one file over. It commits once per transcript file so an interrupted
backfill resumes, which at the defaults is an fsync per file — 33 / 43 /
63 / 103 fsyncs over 10 / 20 / 40 / 80 files, against a flat 16 once it
applies the pragmas. Small in absolute terms (~0.65 ms/file, roughly 1%
of a real build, where decompress and tokenise dominate), and included
because the slope is what the pragmas remove.
Which writer applies them is pinned rather than asserted:
TestConnectionCensus enumerates every sqlite3.connect in the package
by AST and fails until each is classified as a configured writer or a
reader. Prose could not hold that claim — the migration runner was
missed when these pragmas were first written, and the FTS builder was
missed again while fixing the runner.
A single-threaded run like
that one therefore badly understates what the pairing is worth during
a parallel test suite: the unit leg creates ~402 databases, and with the
runner's pragmas removed it takes ~130 s against ~51 s, three
interleaved pairs with no overlap between the arms. That is ~195 ms
saved per database — well above the ~110 ms an idle bench predicts,
because fsync does not parallelise. Sixteen -n auto workers do not get
sixteen devices; they queue at one, and at synchronous=FULL each
worker's commits lengthen every other worker's, so the contention the
pragmas remove is largely self-inflicted.
That measurement also carries a diagnostic worth more than the ratio. The slow arm was the stable one (±1.6%) while the fast arm swung ±16%, which inverts the usual expectation — because the slow arm is fsync-bound and therefore pinned by the device, indifferent to whatever else is on the box, while the fast arm is CPU-bound and exposed to it. Stability is not evidence of a quiet box; it can be evidence of a saturated resource. It is also why box load could not swamp the comparison: the noise lived entirely in the arm that got faster.
A mode=ro reader applies neither — it cannot switch journal modes and
needs neither pragma. The runner's connection is outside the lifecycle
described below and adds nothing to it: _migrated_db_paths memoises
the migration check, so it opens once per process and database rather
than per call. By default _get_connection() opens and closes a
connection per call, so no file handle lingers to block temp-dir cleanup
on Windows. A build issues ~190 such opens, which dominates cache-build
cost, so the converter wraps its hotspots (ensure_fresh_cache, the
per-file load loop, per-session generation) in CacheManager.batch():
one shared connection reused for the scope and closed on exit (including
on exception). batch() nesting is a no-op reuse, so the wraps compose.
batch() is per instance, and an instance is per project, so a loop over
projects still closes the last connection between every two of them. In
WAL mode that is the expensive close: the last connection out checkpoints
and deletes the -wal/-shm files, and the next connect +
PRAGMA journal_mode=WAL recreates them. process_projects_hierarchy
plans every project (11 short queries each) before converting any, which
on a 332-project archive was 3,658 open/close cycles — ~90 s of a 96 s
no-change pass on Windows against an 890 MB cache (29 ms per cycle with
nothing else open, 6.7 ms with one idle connection held). So the pass
holds a connection_lease(db_path) for its whole run: one connection
per thread and database, which _get_connection() and batch() both
yield instead of opening (a lease is an outermost batch that spans
instances). Closed on scope exit like a batch, so the Windows guarantee
holds at the pass boundary; per thread because sqlite3 connections
are thread-bound and serve answers requests on other threads while
the watch thread converts. A corrupt database drops the lease before
the rebuild deletes the file, and the rest of that pass runs
connection-per-call. Same pass with the lease: 5.3 s.
Freshness checks are batched too (issue #12): get_modified_files()
fetches every cached row for the project in one query (one connection
open, or zero extra inside a batch() scope) and rules out
subagent-sidecar fingerprints with one scandir per parent directory,
instead of a per-file query + is_dir() probes. The fingerprint covers
the agents' transcripts (agent-*.jsonl: count, newest mtime_ns, total
bytes) as well as their agent-*.meta.json sidecars, because a trunk's
cached rows carry those transcripts spliced in and a running agent grows
its file without touching the trunk. Rows written before transcripts
joined it are compared on the sidecar part alone, so the upgrade does not
re-parse every session at once.
In the flat layout an agent file's children sit beside it in the same
subagents/ dir, next to every unrelated sibling. So an agent file's
fingerprint counts only the sibling transcripts its parse spliced in —
the agent ids on its sidechain entries, which reach grandchildren through
the children's own spliced rows — and stores that scope after an @
(cache.SubagentsSnapshot, fingerprint_scope), so a check rebuilds the
same narrowing. Without it, one running agent's append invalidated every
sibling's row and a watch tick re-parsed the whole family (measured on a
synthetic 50-agent family: 50 agent parses per tick → 1). The snapshot
is taken before the parse and the scope applied after it, keeping the
as-of-the-parse rule; the sidecar part stays family-wide, since only a
re-parse can tell whose child a new spawn is. A dir is scanned once per
get_modified_files() however many of its agents it checks. This is what makes
TUI startup near-instant on multi-thousand-file archives.
For the operations / recovery side (archived sessions, manual
deletion, cleanupPeriodDays), see
docs/restoring-archived-sessions.md.
2.4 Migrations¶
claude_code_log/migrations/ is a
small migration system. Each migration is a NNN_description.sql file
applied in numeric order by migrations/runner.py. The schema-version
table tracks which migrations have run; cache.py invokes the runner
the first time a given cache DB is opened in a process (memoised per
DB path, re-checked if the file disappears), so a fresh checkout
running against an old cache DB transparently upgrades.
Current migrations:
001_initial_schema.sql— sessions table + per-message metadata.002_html_cache.sql— adds the rendered-HTML cache layer.003_html_pagination.sql/004_html_pagination_variant.sql— per-page HTML chunks for--page-size.005_session_team_name.sql— addsteam_nameto sessions for the teammates feature (PR #125).006_session_ai_title.sql/007_subagents_fingerprint.sql—ai_titleon sessions; subagent-sidecar fingerprint on cached files.008_session_sidecar.sql— cross-session sidecar tables (session_parents,junction_uuids,dedup_winners,sidecar_state) for session-scoped incremental rendering (§ 2.12).009_sessions_hidden.sql— addshiddento sessions, so warmup / empty sessions stay cached but out of the rendered set.010_sessions_residual_count.sql— addsresidual_countto sessions: the per-session entries a full load traverses butmessage_countdoesn't cover, which the incremental cache refresh needs to recomputeprojects.total_message_countby delta (§ 2.14). Deliberately NULLable — a NULL means "unknown basis", and the refresh declines rather than compute a delta from it.011_cached_file_size.sql— addssource_sizeto cached files, so freshness no longer misses an append that lands inside the mtime tolerance (§ 2.15). NULL on pre-011 rows falls back to mtime alone.012_message_lookup_indexes.sql— composite indexes for the refresh's per-tick lookups, each of which was walking every row in the project (17–22 ms a call on a 38,706-row archive, 0.2–2.5 ms after). Pure performance: no columns, nothing to backfill. Two things about it are worth knowing:(project_id, session_id, timestamp, file_id)carriestimestampfor a caller outside the refresh:load_session_entries(the TUI, and rendering an archived session) filters on session but orders by timestamp, and without that column the planner takes the timestamp index and walks the whole project to load one session. With it, the seek and the ordering come from one index and no sort is needed.- That same index is double-edged — it also lets the planner satisfy
a bare
session_id IS NOT NULLas a range scan over every session-bearing row and prefer that to seeking the uuids a query asked for, so three queries had to move that predicate out of SQL and into Python to keep it from making them slower.
Recreating-tables migrations toggle PRAGMA foreign_keys = OFF/ON
around the rebuild to avoid losing rows to cascade-deletes during the
swap.
2.5 JSON export¶
claude_code_log/json/ is a thin renderer
that mirrors HtmlRenderer / MarkdownRenderer: same
generate(...) / generate_session(...) / generate_projects_index(...)
surface, same --depth and --compact honoring. Output is a
structured JSON document — top-level version / title / detail /
compact / sessions / messages keys; each node carries
index / type / title / timestamp / session_id / content,
plus optional parent_uuid / agent_id / pair_first etc. when
present. Children are nested directly under their parent's
children array — it's the same tree the HTML/Markdown renderers
walk, serialized verbatim.
The renderer runs entries through generate_template_messages (the
same format-neutral pipeline § 3 describes), so JSON output inherits
all post-factory polishing for free: slash-command normalisation
(bare <command-name>X</command-name> → /X), command-args
hardening, teammate session-color enrichment, etc. There is no
JSON-specific cleanup pass — the rule of thumb is: if it shows up
right in HTML/Markdown, it shows up right in JSON. This is the
operative example of the factory-layer normalisation seam: raw
TranscriptEntry data is polished once at factory time into the
typed MessageContent models that all three renderers share, so
display polish lives in one place rather than being re-implemented
per output format.
A few JSON-specific touches:
_json_defaultunwraps Pydantic models embedded inMessageContentdataclasses (tool inputs/outputs are Pydantic;dataclasses.asdictdoesn't recurse into them, so without this hook they'd stringify via__repr__and lose structure). Also handlesEnumandPath.is_outdated(file_path)reads theversionfield from existing JSON output and compares against the current library version — same invalidation contract as the HTML cache so re-runs skip unchanged outputs. It guards onPath.is_file()(notexists()) so a non-regular destination like/dev/stdoutis treated as outdated rather than opened, which would deadlock the version sniff (issue #223). An explicit--outputbypasses the skip entirely (force_regenerate, issue #221) since the version marker can't tell which source produced a user-chosen file.combined_transcripts.jsonper project;session-{id}.jsonfor individual sessions. The naming respectsvariant_suffixfor detail/compact variants.
The projects-index JSON (all-projects-summary.json) is a parallel
top-level file — same shape as HTML's index.html but consumable by
external tools (dashboards, query scripts, jq pipelines).
2.6 Depth filter¶
The --depth flag (#159) lets users dial how deep into the message
hierarchy to render — session > user > assistant > agent > tool > hook
— naming the level by the node the output stops at. It maps onto
models.RenderingDepth (which keeps the older verbosity names internally)
via models.DEPTH_TO_DETAIL:
hook(=RenderingDepth.HOOK) — everything, incl. hooks + system notices.tool(=TOOL, default,DEFAULT_DEPTH) — detailed but cleaned: drops system/hook noise while keeping the full conversation and tool I/O.agent(=AGENT) — drops most tool I/O, keeps the conversation plus a curated set of "interaction signal" tools (WebSearch, WebFetch, Task, Agent — the ones that show what the agent did, not what it read). See_LOW_KEEP_TOOLSinrenderer.py.assistant(=ASSISTANT) — drops all tool I/O (user + assistant only).user(=USER) — drops everything except user messages and steering (for feeding downstream agents, e.g. a requirements doc).session(=SESSION, depth-only, no--detailspelling) — session structure only: session/branch headers + fork landmarks, every message body dropped. Handled by an explicit branch in_ghost_template_by_depth(thevisible_atpredicate keeps threshold-less built-ins likeUserTextMessagevisible at every level, so "drop even user messages" can't be expressed viadepth_visibility).
The legacy --detail full|high|low|minimal|user-only is kept as a
deprecated alias (removed in 2.0) and maps to the same RenderingDepths.
Filenames use a single canonical suffix per level — the --depth name
(.hook/.agent/.assistant/.user/.session), with the default tool/TOOL
suffix-less — regardless of which option selected it (so --detail low
and --depth agent share the .agent file). See utils.variant_suffix.
Recaps (AwaySummaryMessage) are a cross-cutting exception: they are a
high-level summary of activity, so they stay visible at every content
level (depth_visibility = USER), including user. The --no-recaps
flag suppresses them at all levels — giving --depth user --no-recaps
for a truly user-only view, or --depth assistant --no-recaps to drop the
recap/agent redundancy (#179).
Filtering happens in a single post-render pass on TemplateMessage:
_ghost_template_by_depth sets each non-visible slot in
RenderingContext.messages to None ("ghosting"), keyed by the content
class's depth_visibility predicate (plus the _LOW_KEEP_TOOLS
allowlist at low and sidechain dropping below HOOK). Indices stay
stable — surviving messages keep their message_index, so there is no
reindex; the rendered tree simply skips ghost slots. Earlier revisions
ran a second, pre-render _filter_by_depth pass on TranscriptEntry
plus a _reindex_filtered_context remap after every deletion; the
ghosting model collapsed both into this one axis.
Important interaction: _pair_skill_tool_uses also ghosts in place (the
slash-command body and the redundant "Launching skill" tool_result).
Because anchor-target references can be cached before a slot is ghosted —
a branch header's parent_message_index, session_first_message
entries, junction forward-links — each ghosting step sanitizes them
afterward: _pair_skill_tool_uses calls _drop_anchor_refs_into_ghosts
and _ghost_template_by_depth calls _repair_stale_anchor_refs, so no
#msg-d-{N} backlink dangles (see PR #131 fix). See
rendering-architecture.md § 5 for the full
pass order.
2.7 Image export¶
image_export.py is
format-agnostic: HTML and Markdown both call into it. Three modes
(matching the --image-export-mode CLI choices):
placeholder— drop the image and render a placeholder marker in its place.embedded— base64-encode the image directly into the output as a data URL.referenced— write the image to disk next to the output and embed asrc=reference.
Default is embedded for HTML (single self-contained file) and
referenced for Markdown (keeps the .md text small and lets
images live as separate PNGs alongside).
Referenced filenames are content-addressed — images/image_<digest>.<ext>
from a BLAKE2b digest of the decoded bytes — so a given image maps to
exactly one file no matter which render pass, run, or worker process
exports it, and re-exporting is idempotent (an existing file is already
the right bytes; writers go through a unique temp file + atomic
os.replace, so concurrent exporters of the same image replace
identical content). This replaced a per-render counter whose names
collided between the combined-page and per-session passes — each pass
restarted at image_0001 and assigned the same names to different
images, so the last pass to run overwrote the other's files. Content
addressing is also what lets referenced-mode renders use the fragment
store (§ 2.9) and the render fan-out (§ 2.10).
2.8 Performance profiling¶
renderer_timings.py
provides log_timing(label, t_start) context managers used throughout
renderer.py. Set CLAUDE_CODE_LOG_DEBUG_TIMING=1 to print per-phase
times to stderr — useful for spotting which phase regressed when a
large transcript suddenly takes seconds longer than before.
2.9 Render memo caches¶
A project conversion renders every message twice: once into its
combined-transcript page and once into its individual session-*.html.
Both go through HtmlRenderer.generate, which rebuilds the tree from the
same source entries, so the per-message formatting work is duplicated
wholesale. On a 118-file, 12k-message project that is 22,420
format_content calls covering 11,113 distinct messages.
render_cache.py memoizes the two
dominant leaves of that work — Pygments highlighting
(html/renderer_code.py::highlight_code_with_pygments) and wenmode
Markdown (html/utils.py::_render_markdown_memoized, behind
render_markdown / render_user_markdown / render_markdown_inline).
Measured effect on that project: 12.4s → 8.7s wall, with all 88 output
files byte-identical. Hit rates run ~69% (Pygments) and ~59% (Markdown);
Pygments exceeds the 50% that page-vs-session duplication alone implies
because the same file contents are commonly re-read across messages.
Two properties matter for correctness:
- Markdown is not a pure function of its text. The SHA-linkifier
plugin resolves commit hashes against the per-render repo cwd carried
by
git_remote._render_repo_cwd(read viacurrent_render_repo_cwd()), so identical text legitimately renders different links in different projects. That cwd is part of the Markdown key — without it a long-lived host (serve, the TUI) would serve one project's commit links inside another's page. Pygments has no such coupling and keys on its arguments alone. - Bounded by bytes, not entries. Rendered fragments are large, so
functools.lru_cache(maxsize=N)gives no control over footprint. The caches evict LRU against a byte budget and refuse any single entry above 1/8 of it, so one huge highlighted file cannot evict everything behind it.CLAUDE_CODE_LOG_RENDER_CACHE_MBsets the budget (default 192 MB per cache;0disables memoization and restores the previous always-recompute behaviour). Typical projects stay far under it — the measured project held 13.3 MB of Pygments and 2.7 MB of Markdown with zero evictions.
CLAUDE_CODE_LOG_DEBUG_TIMING=1 prints hit rate, entry count and bytes
per cache alongside the render timings.
The fragment store sits above the leaf memo and removes the
page-vs-session duplication itself rather than its expensive leaves:
fragment_store.py caches each
message's complete formatted fragment — the (title, html, timestamp)
triple _annotate_tree_for_render writes onto the tree — for the length
of one conversion, so the per-session pass reuses what the combined-page
pass formatted (measured: 64,968 lookups → 32,441 formats, a 50% hit
rate, on a 803MB/187-file archive). One store per convert_jsonl_to
call, created by _make_fragment_store (HTML only) and handed to the
combined-page and session-file loops; it dies with the conversion, so
nothing about it ever needs invalidating.
CLAUDE_CODE_LOG_FRAGMENT_STORE=0 disables it for bisecting.
Because the store is a RAM-for-CPU trade — measured serial peaks on the
803MB reference project: 1252MB store-off vs 1521MB store-on, the
+269MB being the fragment text plus per-entry overhead —
_make_fragment_store carries a memory valve: when available memory is
under _MIN_AVAILABLE_MEMORY_PER_TRANSCRIPT_BYTE (2.4×, the store-on
peak plus margin) times the project's transcript bytes, the conversion runs
store-less at its pre-store footprint instead of trading its last RAM
for CPU. An explicit CLAUDE_CODE_LOG_FRAGMENT_STORE=1 overrides the
valve; whenever the valve trips, the render pool's far higher memory
bar has already declined, so a store-less conversion is always serial.
A fragment is not a pure function of its source entry, and the store
carries three guards for the three ways the same message legitimately
renders differently in different trees (each found by hash-diffing real
projects — see work/render-format-once.md § 4.8): fragments embedding
per-tree #msg-d-{N} anchors are never stored (output scan); a
signature of the tree-derived render inputs (pair presence,
display_model, agent_depth, sidechannel/collapse flags) is part of
the store key; and get() verifies the requesting tree's content
digest matches the one stored at put time before serving.
fragment_store.content_digest is a canonical BLAKE2b walk over
exactly the compare-relevant state — same field coverage as dataclass
equality, with the per-tree message_index/fragment_key fields
excluded via compare=False, and any looseness of Python == that
the canonical form can't express (bool/int, dict order,
identity-repr objects) resolving to a conflict served fresh, never
a false hit. A digest is stored rather than the content object so the
store holds only strings and bytes — picklable, spillable, and unable
to pin object graphs — the property the planned fed-worker format
phase depends on. On the reference 803MB archive this is peak-RSS-
neutral (measured 1521MB either way: the stored contents alias
objects the master entry list keeps alive, and the store-on peak of
+269MB over store-off is the 186MB of fragment text plus per-entry
overhead), so the fragment text itself is what any future memory
bounding must address. Keys are (id(source_entry), part_ordinal) — entry
identity, stamped in the pass-2 render loop — because transcript uuids
collide across resumed/forked sessions. Renders with
image_export_mode="referenced" participate like any other: image
filenames are content-addressed (§ 2.7), so a stored fragment's
src= names the file the first formatting of that message already
wrote, and replaying the fragment skips only a write that has
happened.
2.10 Intra-project render fan-out¶
render_pool.py fans a single
project's output files — its combined pages and its per-session files —
out over worker processes, and
render_dispatch.py is the
policy layer that decides when it is used: build_render_pool (should
this conversion have a pool), worth_dispatching (is this batch worth
sending) and dispatch_render_units (run the batch, falling back
inline). The dependency runs one way — render_dispatch imports
render_pool, never the reverse — so a worker never loads the policy it
executes. This sits below the project-level pool in
§ 2.1 and exists because that one leaves cores idle in two shapes:
the all-projects wall clock is bounded by the largest single project
(measured: 5 real projects, 4 cores, 195s of work, 65.0s wall — exactly
the largest project's own time, at 2.18 cores average), and an
incremental run has only one or two stale projects to fan out at all.
Workers are fed, never self-sufficient: they do not load the
transcript at all. Each dispatched unit carries its own slice of the
parent's master message list (RenderUnit.entries — a page's sessions'
entries, or one session's trunk + integrated agent entries), the
master-list ordinal of each entry (entry_ordinals, so fragment-store
keys name the same positions in every process), and — for session units —
the session's slice of the parent's fragment store
(RenderUnit.fed_fragments). The one per-worker piece of state is a
slim SessionTree (dag.slim_session_tree, sent via the pool
initializer): the render path reads only the DAG lines, junction points
and workflow dicts, so the per-message nodes mapping — whose pickled
size is the whole transcript — stays behind (its lookups raise on a slim
tree, so a future render-path consumer of nodes fails loudly in a
worker instead of silently diverging). The parent keeps every staleness
check and cache write to itself (so the DB stays single-writer — a
worker's own CacheManager, kept only for the per-session combined-link
lookup, is constructed read_only=True: no migrations, no project-row
upsert, mode=ro connections), slices
session units with the same trunk predicate generate_session filters
by (so the worker's re-filter of the fed slice is idempotent), and the
pool starts lazily on first submit. RenderPool.submit declines a unit
with no entry slice to the inline path, and a pool is only created when
the conversion has a pre-built session tree — a worker-side DAG rebuild
from a slice alone could genuinely differ.
Which batches are worth dispatching is decided per batch by
render_dispatch.worth_dispatching, on the work a batch carries rather than
the number of units in it. A conversion dispatches two batches: its
stale pages (one unit per ~page_size messages, where nearly all the
render time is) and its stale session files (cheap, because the page
pass has already put their fragments in the store). Weighing them by
unit count got this exactly backwards — an 8-unit floor meant a project
with fewer than 8 pages rendered its expensive page batch inline and
fanned out only the cheap session batch, paying the pool's ~1s of
spawn + import for the batch with the least to gain. Weighed by entry
count against _MIN_ENTRIES_FOR_RENDER_POOL (4,000, about twice what
that startup costs at the ~2,000 entries/s the render phase sustains),
projects between ~4k and ~15k messages went from 0.86–1.05× to
1.27–2.27×, and projects below it went from 0.82–0.94× — a real loss —
back to 1.00×. Two refinements complete the rule: a lone unit never
dispatches (it would render in a worker while the parent waits), and
once the pool has started (RenderPool.started) any multi-unit batch
does, since the startup is then sunk — which is how the session batch
rides along behind the page batch that paid for it.
_MIN_MESSAGES_FOR_RENDER_POOL is the same number, and only a
short-circuit: every batch is a subset of the project's message list, so
a project below it could never form a batch that clears the gate.
Before the feed, each worker re-loaded the whole transcript from the warm cache in its initializer (~0.7s at 12k messages; ~12s of CPU per worker at 329MB of transcript), so per-worker cost scaled with project size rather than unit size — measured post-fragment-feed on a 16-core Mac, 16 workers still burned 286.1s of CPU to do 91.1s of serial work, almost all of it transcript reloads, and 16 workers came out slower than 8. Feeding moves each entry at most twice per conversion (its page unit, then its session unit) instead of once per worker. Fragments cross the boundary as before (plain strings + digests — § 2.9's store made them portable): a page worker returns its fragment store as a delta in its result, the parent absorbs it, and session units dispatch afterwards carrying their slices. Pages always drain before sessions dispatch, so the feed covers essentially every message; every fed fragment is digest-verified on use, so the worst a stale slice can do is cost the recompute it would have cost anyway.
Memory: the cap charges measured post-feeding footprints. The
conversion parent is the heavyweight — its master entry list (Pydantic
entries, TemplateMessage tree, SessionTree) plus the fragment store's
text and in-flight pickled slices measured ~4.4× the transcript's bytes
on disk (598MB on a 140MB/47k-message project, 1458MB on 329MB/97k; VmHWM
polling, 2026-08-26). Fed workers hold only base imports, their memo
caches and the in-flight unit's slice: the largest worker measured
~136MB + 0.59× transcript across the same runs.
render_pool.memory_capped_workers charges the parent 4.5× + base and
each worker 0.8× + base — a ~1.2–1.35× margin over the fit, because the
alternative failure mode is real: when workers each held a full copy, an
unguarded auto on a large archive exhausted RAM and drove the machine
into swap, pegging every core — far worse than rendering serially. (The
one under-charged pathology is a single session spanning most of the
transcript, whose unit slice re-inflates toward the parent's ~3× in its
worker; it is one outlier inside the headroom, and such projects yield
too few units to fan wide.) The cap reads available memory as cgroup
limit first (inside a container the host's totals are a lie), then
MemAvailable, then free physical pages, taking 60% of it as budget.
The all-projects parent applies the same cap with
concurrent_projects=resolved_jobs before handing out any budget, and
each worker re-checks against its own project before starting a pool.
Unknown memory allows at most 2 workers.
Both non-Linux platforms need their own probe, since each would otherwise
fall through to that unknown-memory branch and be capped at 2 workers
regardless of cores or RAM — a 16-core Mac measured the whole fan-out on
2 before this was fixed. macOS has neither MemAvailable nor
SC_AVPHYS_PAGES: _darwin_available_bytes shells out to vm_stat
(present on every macOS, no dependency) and counts free + inactive +
speculative + purgeable pages, since excluding the cheaply reclaimable
ones reports a busy Mac as having almost nothing free. Windows has no
/proc and no os.sysconf at all: _windows_available_bytes reads
ullAvailPhys from GlobalMemoryStatusEx via ctypes. Every probe
returns None rather than raising, so an unreadable platform stays
conservative instead of failing a conversion.
On by default at the CPU count, settled by the 16-core measurement
below. $CLAUDE_CODE_LOG_RENDER_JOBS overrides: 1 or off disables it,
auto is the default, an integer pins a count — see
render_pool.resolve_render_jobs. --jobs never enables or disables it;
it only caps it, so the two pool levels together can't oversubscribe. An
explicit convert_jsonl_to(render_jobs=N) overrides the environment; the
default None consults it. render_dispatch.build_render_pool declines regardless for
single-file mode, a missing cache manager, a missing pre-built session
tree (workers render fed slices against the conversion's tree),
projects below _MIN_MESSAGES_FOR_RENDER_POOL, and machines without
the memory the cap formula demands; a pool that is created still only
starts if some batch clears worth_dispatching above.
image_export_mode="referenced" used to decline too, when renders
allocated images/image_NNNN.png names from a per-call counter that
collided across passes and processes; referenced filenames are now
content-addressed (image_export.export_image digests the decoded
bytes and writes via temp-file + atomic replace), so any pass, run, or
worker exporting the same image converges on the same file and the
mode fans out like any other.
One ordering change made the pages parallelisable: the paginated writer used to reveal page N-1's "Next" link while generating page N, so page N's render edited page N-1's file. That fixup now runs once after all pages land — order-independent and idempotent.
Measured on 4 cores, 47k-message project (240 session files, 218 output units); the serial floor — cache check, transcript load, planning, all in the parent and none of it parallelised — is 4.7s:
| wall | total CPU | |
|---|---|---|
| neither optimisation | 44.8s | 43.5s |
| memo only (§ 2.9) | 27.6s | 26.2s |
| fan-out only (4 workers) | 22.5s | 57.0s |
| both | 20.0s | 48.8s |
Read those together, because the pair is easy to misread. The fan-out parallelises perfectly well on its own — 44.8s → 22.5s is 2.0× on 4 cores against a 4.7s serial floor. It looks weak on top of the memo (27.6s → 20.0s, 1.38×) only because the two optimisations attack the same work: the memo removes the page-vs-session duplication, and splitting units across processes gives each worker a cold cache and brings some of it back. Both together is still the fastest configuration, so they compose — just sub-additively.
A 16-core Mac over 8 real projects (1543MB of transcripts, largest 329MB)
shows what happens with cores to spare — and why the two --all-projects
scenarios diverge so sharply:
| scenario | config | wall | CPU | cores used |
|---|---|---|---|---|
| full rebuild, 8 projects | memo only | 100.8s | 226.0s | 2.2 of 16 |
| full rebuild, 8 projects | both | 79.3s (1.27x) | 324.8s | 4.1 of 16 |
| incremental, 1 stale | memo only | 93.2s | 90.8s | 1.0 of 16 |
| incremental, 1 stale | both | 34.6s (2.70x) | 367.9s | 10.6 of 16 |
The incremental row is what flipped the default: 2.70x on the everyday shape of a run, using 10.6 cores instead of 1.
The full-rebuild row is the unsolved case, and the numbers say exactly
why. Converting the largest project alone takes 93.2s; converting all
eight takes 100.8s. The other seven cost 7.6s of wall clock between them —
the run is, to within 8%, "how long does the biggest project take". The
static budget split then gives that project jobs // stale projects = 2
render workers while the seven small ones finish and 13 cores go idle. So
the fan-out helps it barely (1.27x) even though the same project alone
reaches 2.70x. Pool-bounding projects are now handled by hold-back:
_holdback_plans runs a greedy makespan comparison, largest first
(hold while pool(rest) + fanned(largest) beats pooling everything,
with fanned time from the conservative _fanned_speedup of the
workers a lone memory-capped conversion would get), keeps what it
holds out of the pool and converts each alone with the full render
budget afterwards. Costs compare cached message counts when every
plan has one (bytes alone would miss a dense giant), bytes otherwise.
On the reference archive it holds exactly the 97k giant — measured
65.4s → 58.5s (1.12x) on the 8-core VM under the original
single-dominant rule, whose decision the general rule reproduces
there, since the twin 47k projects keep each other's pool bounded —
and it additionally covers shapes the 2x-dominance bar missed, such
as a runner-up that bounds a pool of small projects without being 2x
any of them. What it deliberately never does is serialize level-sized
projects (a certain loss — they already saturate the pool) or hold
anything a machine can't actually fan. Fixing the truly general case —
several level-sized projects sharing one static split — still needs
the split to be dynamic, or a single flat pool over render units
rather than two nested levels.
Two consequences. First, core count matters a lot: 48.8s of CPU over 4 cores is 12.2s plus the 4.7s floor, so a 10-core machine should land near 9.5s rather than 20s. Second, the per-worker cost (then: a full transcript reload plus a cold memo) grows with worker count, so the speedup saturates rather than scaling indefinitely.
Those tables predate the fragment feed and the entry feed, which removed
most of that per-worker cost in two steps. Re-measured on an 8-core VM
over the same 8-project archive (1539MB): with fragments fed, the
incremental scenario reached 2.08x at +76% CPU over serial (the
pre-feed baseline burned +305%); with entries fed as well — no worker
transcript loads at all — it reached 2.87x at +3% CPU (62.6s →
21.8s wall, 62.5s CPU vs 60.5s serial), and the single-project sweep's
per-worker CPU overhead fell from ~4.4s to ~0.7s at 8 workers. With the
memory cap re-sized to the measured post-feeding footprints (above),
the same VM's incremental cap went 5 → 8 of 8 workers and the scenario
reached 3.24x at +6% CPU (61.8s → 19.1s wall, 63.3s CPU vs 59.5s
serial), byte-identical in every configuration. The full-rebuild
scenario improved to 1.12x via the hold-back (above, since
generalized from the single-dominant rule to the greedy makespan
comparison); what remains is its general case — several level-sized
projects, where the core split jobs // stale still grants 1
render worker/project — and the serial parent floor (load + parse +
plan). Both land with the remaining "format once, assemble many"
step: the flat pool over render units. Planned in
work/render-format-once.md.
2.11 Diagnosing hangs (SIGUSR1 stack dump)¶
When claude-code-log appears stuck (100% CPU, no output), a
single SIGUSR1 to the running process dumps the live Python
stack of every thread to stderr without killing it:
The handler is wired in cli.py::_install_stack_dump_signal() via
faulthandler.register(SIGUSR1, all_threads=True, chain=False) and
installed before any heavy work in the entry point. POSIX-only —
Windows lacks SIGUSR1, the install is a silent no-op there. Unlike
py-spy, this needs no root and no extra install, since the runtime
is already wired to dump itself on demand. Added by PR #135 to make
the DAG cyclic-children class of bug diagnosable in the field; useful
for any future hang.
2.12 Session-scoped incremental rendering¶
Converting a project used to load its entire transcript even when the
cache was fresh and a single session file needed regenerating — the
master list, DAG and trees exist before any staleness is consulted.
The session-scoped path (streaming stage 2 of
work/render-format-once.md) removes
that: when the Phase-1b gate in convert_jsonl_to finds the cache
fresh, the combined output current, and only session files stale,
converter._load_stale_session_transcripts loads only the files
holding those sessions' entries and renders them through the ordinary
_generate_individual_session_files, byte-identical to the full path.
Three pieces make the partial load faithful:
- The cross-session sidecar (migration 008,
cache.SessionSidecar) is persisted by every full unfiltered directory load, in the same batch as the per-file cache writes: per-session parent linkage (resume/fork attachment points), junction points with their ordered target lists, and the dedup winner for every uuid carried by more than one session (resume replay prefixes). All three are compact projections of theSessionTreethe load already built. A partial load enforces the winner map up front (losing copies drop before the DAG sees them), patches parent/junction facts whose other end isn't loaded, and adds empty ancestor stub lines so depth chains resolve. - Discovery by the cache's file map, not by filename stem
(
CacheManager.get_session_file_map): real archives contain files whose entries span two sessions (a continuation written into the previous session's file) and sessions with no file of their own. Co-resident fresh sessions load along with a stale one and are skipped by the per-session staleness check. An archived stale session (cached rows, source deleted) is skipped outright — the full path loads everything and still renders nothing for it. - A pagination-aware combined-freshness check
(
_combined_output_is_stale): the gate used to askis_transcript_stale("combined_transcripts.html"), a name a paginated project has no cache row for — so paginated projects never early-exited and full-loaded on every direct conversion. The helper replays the pagination pass's plan from cached session data alone (same session→page assignment, sameis_page_staleper page, same invalidation triggers). This also makes the plain "everything is current" early exit fire for paginated projects.
Equivalence is held to byte-identity by
test/test_session_scoped_render.py — real fixture projects with the
full loader monkeypatched to raise, plus a synthetic project pinning
resume-replay dedup, cross-session fork parents, junctions and
ancestor chains — and by hash runs over real archives (85 and 187
output files byte-identical on the two measured projects, with 64 and
129 session files regenerated through the partial path; sidecars of
300–742 dedup winners in play). Measured on the 8-core VM against the
803MB reference archive, warm cache: one stale session file went
4.6s → 0.6s (7.9x), and a fully-fresh direct conversion 4.6s → 0.4s
(the early exit finally firing for a paginated project).
CLAUDE_CODE_LOG_SESSION_SCOPED=0 disables the path for bisecting.
Every decline (no sidecar yet, date filters, --no-cache, an
inconsistent file map) falls back to the full load, whose behaviour is
unchanged — the sidecar is an optimization input, never a correctness
requirement.
2.13 Page-granular streaming conversion¶
The session-scoped path (§ 2.12) still full-loads whenever the
combined output is stale, so peak RAM stayed bounded below by the
largest single project — a machine under ~2x its largest project could
not convert it at all. The streaming path (stage 3 of
work/render-format-once.md) removes
that floor for paginated HTML conversions:
converter._stream_paginated_conversion plans the session→page
assignment purely from cached session data (the same
_assign_sessions_to_pages call, via the shared _plan_page helper,
so the plans cannot drift), then for each page needing work — a stale
page, or stale session files on it — loads only the files holding
that page's sessions through the § 2.12 partial-load machinery,
renders the page and its stale session files together against a
per-page fragment store, and drops it all before the next page. Peak
residency becomes max(one page's source files), not the project.
When it runs:
- Structurally: HTML directory conversions with a fresh-able cache,
no date filters, no
--force-style regeneration, combined output enabled, and pagination in play per the cached counts. The sidecar must exist; sinceensure_fresh_cachenow persists it too (same batch as the per-file cache writes), a run whose cache was just refreshed can stream instead of loading the project a second time. - By a memory valve, with a sparse fallback: in auto mode the path
always engages when available memory is under 2.4x the project's
transcript bytes — literally the same knee as the fragment-store valve
(§ 2.9): both read
_MIN_AVAILABLE_MEMORY_PER_TRANSCRIPT_BYTE, one constant so the two can't drift. The ladder is continuous: below ~2.4x the store had already declined, and streaming takes over the serial conversion; the fan-out's own (far higher) memory bar has long since declined by then, which is why streaming renders inline without losing anything. With more memory than that (or none measurable), the pass still runs — but in sparse mode: once it has planned its pages and counted the ones needing work (stale/missing page, or stale session files on it — pure cache and stat queries), it declines itself unless that count is at most a third of the plan (_STREAMING_MAX_SPARSE_FRACTION), falling through to the full load - fan-out. The reasoning is measured, not structural: streaming's
wall scales with the pages needing work while the full path pays the
whole-project load regardless, so sparse work (the daily-run shape)
streams faster even where the fan-out is available — but a dense
rebuild renders serially and loses to the fan-out on a roomy
machine. On the 8-core/16GB VM against a 137MB/26-page archive
(
scripts/bench_render.py): incremental (1 page + 3 sessions stale) streamed 2.0s/276MB peak RSS vs the fan-out full path's 3.3s/542MB, while a full rebuild streamed 11.1s vs the fan-out's 6.7s; the crossover sits near 36% of pages stale and moves only weakly with core count (the full load, not rendering, is the fixed cost), so 1/3 keeps a margin under it.CLAUDE_CODE_LOG_STREAMING=1forces the path wherever structurally eligible, bypassing both the valve and the sparse gate;=0disables it (the bisecting knob).
Two details are load-bearing for correctness:
- Strict file resolution: a page load requires every one of its
sessions to resolve to a complete, present source-file set
(
_resolve_session_source_files(strict=True)); any gap declines the whole pass to the full load rather than rendering a page with a session's remnant. - The co-resident-session restriction: a source file can span two
sessions that sit on different pages, so one page's load can carry
a partially-loaded session from another page. The per-page session
pass is therefore restricted to that page's own stale sessions
(
_generate_individual_session_files(restrict_to_sessions=...)) — without it, the partial session renders truncated and its cache row then reads current forever (pinned by mutation test intest/test_streaming_render.py::TestFileSpanningSessions).
Byte-identity is held by test/test_streaming_render.py (a real
fixture with the full loader monkeypatched to raise, a synthetic
project whose resume/fork couplings span the page split, and the
file-spanning trap above) and by hash runs over the two coupling-heavy
real archives — every scenario (warm parity, full rebuild,
incremental) byte-identical on both. Measured on the 8-core/16GB VM,
serial, warm cache (full path → streamed): 296MB project full rebuild
454MB → 305MB peak RSS; 803MB project full rebuild 1490MB → 591MB at
slightly lower wall (28.2s → 25.2s), incremental 1092MB → 587MB at
7.6s → 4.6s. The remaining ~590MB is the largest page's co-resident
files plus interpreter baseline — the floor scales with page size,
not archive size.
Declines fall through to the full load unchanged. The cache refresh itself no longer full-loads on changed sources — that is § 2.14.
2.14 Incremental cache refresh¶
The last full-residency path was ensure_fresh_cache itself: one
changed file re-walked every file through load_directory_transcripts
to recompute three things — per-session cache rows, project
aggregates, and the sidecar. Streaming stage 4
(converter._incremental_cache_refresh) recomputes all three from a
bounded closure of the modified files instead:
- Per-file parse: each modified file's old identity state
(sessions, uuids, parentUuids, requestIds, residual type counts) is
captured from the messages table, then
load_transcriptre-parses it (rewriting its rows exactly as always). Old uuids must be a subset of new — a shrunk/rewritten file means history changed and declines. - Closure (SQL projections, no entry loading): the modified
files' sessions, plus every session holding a copy of a modified
uuid (re-election partners), plus owners of external attachment
points (the dedup winner's session when duplicated), plus the
complete old target list of any junction a modified entry touches
or whose uuid is re-elected (so target order rebuilds natively),
plus files holding cross-file metadata (summaries by leafUuid,
ai-titles by session) for closure sessions. Sidecar-derived ids are
trunk-normalized — junction owners/targets can be branch-qualified
{trunk}@{uuid12}line ids that the file map doesn't know. - Partial load: the closure's files go through
_load_sessions_partialwith two refresh-mode deviations — old dedup-winner enforcement exempts modified uuids (their election re-runs natively on the full candidate set, which the closure guarantees is loaded), and the sidecar junction patch skips junctions marked native (the old row would erase a new fork/resume target). - Facts persist, restricted to closure sessions (all complete by strict file resolution — the co-resident partial-session trap applies to cache facts too): session rows upserted; junction rows rewritten for tree junctions owned by closure sessions (patched ones rewrite their old rows identically — harmless); parent rows and re-elected winners replaced; project aggregates move by delta — new minus old session-row contributions, plus the modified files' summary-row delta, with bookends extended from the loaded entries' own timestamp strings (the DB normalizes formats).
The two migrations the delta needs. total_message_count is
len(messages) of a full load — the traversed entry list — so
moving it by delta requires every traversed entry to be attributable
to a persisted session row:
- 009 (
sessions.hidden): warmup-only and empty/agent-only sessions used to be filtered before the write, leaving their contribution off the record. The writer now persists them from one unfilteredcompute_session_datapass, flaggedhidden = 1, and every read site meaning "sessions a human would render" filtershidden = 0— so the visible set is byte-identical to the old filtered computation. - 010 (
sessions.residual_count): entries a session owns thatcompute_session_dataskips (attachment,ai-title), counted from the traversed list bycompute_session_residuals. Counting them from cached message rows instead is wrong, because attachments are parsed into the cache but can be dropped by DAG traversal — a real-archive holdback caught exactly that (+16). A session owning only such entries gets a row of its own (hidden, since it has no first user message) so the arithmetic has somewhere to put them. The column is deliberately NULLable: a pre-010 row's contribution is unknown rather than zero, and the refresh declines on one.
Together they close the identity the delta relies on, verified across the full 78-project corpus:
total_message_count = Σ(message_count + residual_count) + #summary rows
Summary entries are the one class with no session attribution, and
they bypass traversal (appended wholesale), so counting their cached
rows is exact.
Decline ladder (each falls through to the unchanged full load):
missing sidecar/project data, deleted source files (archival is the
full path's business), shrunk files, a pre-009 cache (a closure
session with prior entries but no row) or a pre-010 one (a closure
session whose residual_count is NULL), attachments involved in
cross-session dedup, a requestId with independent surviving copies on
both sides of the closure boundary (D1 attribution would need global
traversal order — same-uuid replay spans are dedup-resolved and
safe), any strict-resolution gap, and a closure larger than
max(4 files, a third of the project).
The design identities were verified empirically before implementation
across the full 78-project real corpus (I1: total_message_count == Σ
unfiltered per-session counts + typed residual; I2: token totals == Σ
per-session tokens; I3: bookends == raw min/max; zero failures), and
the implementation is held to DB-state equivalence — session rows,
aggregates, and all three sidecar tables equal to a full refresh's,
plus rendered byte-identity — by
test/test_incremental_cache_refresh.py and by holdback runs on real
archives (hold back the newest files, warm the cache, restore them,
then compare the incremental refresh against the full one: matched on
the 803MB reference archive, 296MB repower, and 1003MB
platform-frontend-next). DB state is the bar rather than HTML because
the first bug this caught — a modified file's new summaries titling
sessions outside the closure — was invisible in the rendered bytes.
Measured on the 8-core/16GB VM, first conversion after three new
sessions appear in the 803MB reference archive (peak RSS / wall):
full refresh + full render 1131MB / 10.6s; full refresh + streamed
render 901MB / 8.1s; incremental refresh + streamed render 582MB /
6.7s. Once the render streams, the refresh's full load is the
peak — which is what this section removes, completing "no archive too
big for the machine". CLAUDE_CODE_LOG_INCREMENTAL_CACHE=0 is the
kill switch.
2.15 Watch mode and live page updates¶
Two commands keep output current while a session is still being written:
claude-code-log watch (a resident loop) and claude-code-log serve
--watch (the same loop on a thread beside the HTTP server). See
work/watch-mode.md for the design and the
measurements behind it.
The engine never renders. claude_code_log/watch.py polls
(size, mtime_ns) over **/*.jsonl and **/agent-*.meta.json under
the watched roots, debounces, and calls a callback; the callback runs the
ordinary conversion. The poll is one os.scandir walk taking size and
mtime from the directory entries, not a glob per pattern plus a stat
per hit: over a whole archive (1,896 files) the glob form cost 0.4–0.9 s
per poll, more than the poll interval, and the walk returns the same
dict 4–7× faster. Name matching follows the platform's case rule, as
Path.glob does (insensitive on Windows), so a session the converter
discovers is also one the watcher wakes for. The scan is a trigger, not a source of truth — a
false positive costs one no-op conversion, while the conversion already
knows precisely what is stale. Two file classes are excluded because both
land in the watched tree and would make the loop feed itself: dot-prefixed
atomic-write temp files, and generated output.
Debounce is a quiet period (--quiet-period, 300ms) with a max-latency
cap (--max-latency, 2s): a turn writes several entries in quick
succession, so without the quiet period most of a turn is spent rendering
states nobody sees; without the cap a long unbroken stream would never
surface. tick() (poll once) is split from run() (wait) and the clock
is injectable, so tests drive ticks by hand against a fake clock.
What makes a tick cheap is §2.12's session-scoped path, which is
reachable here only because ensure_fresh_cache_detailed reports how
it refreshed (CacheRefresh.NONE/INCREMENTAL/FULL). Phase 1b's
staleness test is per-session message counts, so it refuses a FULL
refresh — which carries no guarantee that a session's content didn't
change while its count stayed the same. An INCREMENTAL refresh does carry
it: §2.14's ladder only succeeds after proving each modified file's
cached rows are an exact prefix of its current rows, i.e. a pure append.
--combined therefore defaults to no for watch: with a combined
output present, the session's growth makes it stale and the conversion
falls to the streaming path instead.
Both commands take --theme (§ 2.1). A theme switch makes every page
stale on the first tick; because a --combined no tick would otherwise
leave the combined page in the old theme behind session pages that link
to it, convert_jsonl_to (and the all-projects plan) promote that one
tick to rewriting it — after which ticks are cheap again.
What the tick then spends its time on was, once §2.12 was reachable, no longer the render (12% of it) but the cache refresh. Profiled on the 803MB / 217-file reference archive appending to its largest session file (39.7MB, 207 entries), a 1.03s tick materialised the same entries three times: §2.14's refresh parsed the file from source (488ms, of which 307ms re-serialising and re-inserting every row), then the closure load rebuilt them from those rows (129ms), then the session-scoped render rebuilt them again (141ms). Two changes removed most of that:
- A per-conversion parsed-entry store (
entry_store.py, §2.16) serves the refresh's list to the other two consumers: closure load 255ms → 7ms. - The staleness sweep stopped scaling with the session count.
get_stale_sessionsran two SQLite queries per session throughis_transcript_stale, and each of those calledget_library_version(), which re-parsed installed package metadata every time — 173 calls and 35ms a tick. The version lookup is nowlru_cached (it cannot change inside a process) and the two tables are read once and joined in Python: 85ms → 4.5ms.
Tick: 1.03s → 0.717s. load_transcript's full re-parse and full row
rewrite of the modified file (483ms) was then the remaining bulk, and
§2.16's cross-tick resumption takes it to ~35ms: 0.257s steady state in a
resident watch. What remained after that was the refresh's own cache
queries, every one of which was scanning the whole project — fixed by
migration 012's indexes plus one query rewrite (get_file_states joined
cached_files and filtered on file_name, which no index could serve;
resolving names to file_id first made it 15.9ms → 0.0ms).
A steady-state watch tick on the 803MB reference archive is 0.145s, from 1.03s — with rendering, which was where this began, now a rounding error against it.
The served page updates itself via
html/templates/components/live_update.js, active only over http(s) —
a file:// page cannot fetch anything, not even its own URL, so the
poller notices and does nothing. It polls a HEAD of its own URL and
compares Last-Modified, Content-Length and X-Content-Revision.
Each covers the one before it: HTTP dates have one-second granularity, so
two updates inside the same second are otherwise invisible (the same trap
as the cache's mtime tolerance, which §2.3 solves the same way), and size
is blind to a rewrite that keeps it — a counter or a status word changing
width-for-width. X-Content-Revision is a digest of the served bytes,
added by server.py to every file response; it is deliberately not an
ETag, because the stock send_head skips its If-Modified-Since check
whenever a request carries an If-None-Match and never evaluates one, so
advertising an ETag would cost the 304s on multi-MB pages. Hashing is
cached per (path, mtime_ns, size) but only once the file's mtime is a
second old — a file being written right now is re-read, which is exactly
the case the header exists for. On Windows an open landing while
atomic_write_text swaps the file in can raise PermissionError, which
the stock handler answers with a 404; so server.py first opens the file
with the writer's own retry (utils.retry_on_sharing_violation) and holds
it while the stock handler serves it, because Windows will not replace a
file that is held open. On a change the page re-fetches and
either patches or swaps.
Patching applies when the new render's node-key sequence extends
the one on screen: the nodes whose own markup changed are replaced, new
ones are inserted, and everything else is left alone — keeping its
scroll, fold, <details> and localised timestamps because it is
literally the same DOM. Change detection is a per-node hash of the
node's own markup, taken from the freshly parsed document, never from
the live DOM: decoration rewrites the live tree (timestamp
localisation replaces innerHTML), so a hash taken from it never
matches one taken from server bytes. Nothing is emitted server-side for
this.
A node's own markup is its :scope > .message plus every
non-.message-node child of its .children — a fork point renders
inside .children so folding hides it with the subtree, and on a
fork-only slot it is the node's only content and carries its id.
Swapping (replace #transcript wholesale) is the fallback for
everything else: renumbered or reordered ids, deletions, a node with no
key, more than 40 changed nodes, and the first update of a session,
which is where the hashes are first taken. It is also what every update
did before patching existed. Measured across three real sessions
replayed through the renderer, 45 of 47 growth steps are pure
extensions; the other 2 are out-of-order arrivals that renumber the
positional msg-d-N ids and take the swap.
A swap destroys anything that decorated the old markup, so components
register with window.claudeLogOnRehydrate(fn) (defined at the top of
<body>, before every component include) and the poller calls
window.claudeLogRehydrate(root) — over the whole container after a
swap, and over only the elements it placed after a patch. Passing the
containing node instead is a live trap: the session header's fold bar
counts its descendants, so it is replaced on every append, and its node
is the entire page. Currently registered: timestamp localisation (scoped
to a subtree), the timeline rebuild, the filter and search refresh (one
microtask per update: the active type filter is re-applied and an active
search re-run quietly — nothing scrolls, unfolds or opens; registered
in transcript.html between the timeline and the engine so it runs after
the one and before the other, minimal-theme.md § 11) and, in the minimal
theme, the DAG engine's relayout (one microtask per update, before the
next paint — minimal-theme.md § 8). Delegated listeners and
everything bound to the toolbar or floating buttons survive untouched
and must not register. On the swap path only, fold state and
<details> are captured and restored by the poller, keyed data-uuid →
data-session-id → positional id — session headers and fork points
carry no uuid, and on a single-session page the header is the only
foldable node.
Measured: ~1s from append to visible. On a 2.4MB / 896-card page, a swap
touches 897 cards, re-localises 896 timestamps and blocks the main
thread 107ms; a patch of the same append touches 3 cards, 2
timestamps, 61ms. The remaining 61ms is fetch plus DOMParser of the
whole page, which only a server-shipped delta would remove. Idle polls
cost ~1ms.
Timestamp localisation drains against the idle deadline, not in
fixed batches. timezone_converter.js used 25 elements per
requestIdleCallback, which made the callback count the cost: a 4MB
page's 1,180 timestamps are 8ms of work but took 766ms of wall clock,
and a 27MB page's 5,010 took 3.3s — in document order, so a live
update's new cards were localised last and the fade-in played over a raw
ISO string. Draining while timeRemaining() allows (checked per 32
elements, {timeout: 200}, first slice on the current task under a 24ms
budget) takes those to 13ms and 35ms.
2.16 Parsed-entry store¶
entry_store.py. A conversion that refreshes the cache incrementally
(§2.14) parses each modified file from source, and then loads those same
entries back out of the rows it has just written — once for the closure
load and once for the session-scoped render (§2.12). Each rebuild is a
zlib.decompress + json.loads + Pydantic validation pass over the
whole file, so a one-line append to a 39.7MB session paid it twice at
~130ms each. The store holds the list the first pass produced and serves
it to the other two.
Four properties keep the invalidation surface at zero, and they are the reason it is a parameter rather than a memo:
- One store per conversion, threaded explicitly. Never a global, and
never hung off
CacheManager— the TUI keeps one of those across many conversions. - Only
_incremental_cache_refreshfills it, with the files it just parsed, andconvert_jsonl_todrops it after Phase 1b. A cold or full conversion therefore stores nothing, and the streaming path (§2.13) is deliberately never handed one: its bounded residency depends on dropping each page's entries before the next page loads, which a store spanning pages would defeat. What it holds is bounded by what changed, not by the archive. - Hits are verified against the file.
getre-stats and compares(size, mtime_ns)against the stamp captured before the parse. A stamp taken before the parse can only be older than the entries describe, so a file that grew mid-parse declines to the cache rather than serving a list its stamp misdescribes. The stamp also carries the file's sub-agent fingerprint (cache.subagents_fingerprint, which covers theagent-*.jsonltranscripts as well as the sidecars, scoped for an agent file as its cache row is —PendingStampsnapshots before the parse and finishes after it): a held trunk list includes its agents' spliced transcripts, and a running agent grows its file while the trunk sits still — a trunk-only stamp kept awatch/serve --watchstore serving that block one tick behind until the trunk next changed (minimal-theme.md § 8). - Handouts are deep copies, because the pipeline mutates entries in
place:
_integrate_agent_entriesappends#agent-{id}tosessionIdand is not idempotent, and dedup re-parents around dropped copies. Today each consumer gets freshly deserialised objects; serving the same objects twice would render…#agent-X#agent-X. The copy stays cheap because the bulk of an entry is immutable strings, whichdeepcopyshares rather than copies — 2.0ms and 0.83MB for the 207-entry, 39.7MB session, against 123ms to rebuild it from the cache.
Held to byte-identity with the store disabled, over repeated appends on a
fixture whose 170 sidechain entries exercise the mutation above
(test/test_entry_store.py), with the copy isolation pinned by a test
that fails with exactly the doubled suffix when the copy is removed.
CLAUDE_CODE_LOG_ENTRY_STORE=0 is the kill switch; a per-file memory
valve at put time declines to hold a file when available memory is
under 6x its bytes, and =1 overrides that valve.
Owned across ticks, a store does more. watch keeps one for the life
of the loop and passes it to every conversion (convert_jsonl_to takes
an optional store; a caller-owned one outlives the call, an internally
made one doesn't). That turns the store from a within-tick cache into a
resumption point, via a second, prefix-pinned mode:
- The parse resumes.
put_prefixrecords the byte offset the parse consumed plus a BLAKE2b digest of the bytes below it, and the next tick hashes that prefix and reads only the tail. The digest is a stronger check than the row-fingerprint prefix comparison it saves — identical bytes imply identical rows — and it costs 32 ms over 39.7 MB against 143 ms to re-parse them. A mismatch (rewound session, replayed history) drops the prefix and re-reads from the top. Byte reading replaces text reading only when a store is present; every other caller keeps the identical text path, and the held entries are the pre-post-processing parse products, since the whole-file passes (sidecar linking, prompt-hash linking, agent splicing — 0.1 ms together) must re-run over the concatenated list. The cut is on entries as well as bytes: a final line whose newline hasn't landed — a torn append, or a file simply stored without a trailing one — parses and is returned like any other, but neither its bytes nor its entry is held, so the next tick re-reads that line instead of handing its entry back a second time. Prefixes are charged against the same budget as whole-file entries and evicted alongside them, since awatchstore never goes out of scope. - The cache write appends.
CacheManager.extend_cached_entriesinserts only the new rows instead ofsave_cached_entries' delete-and- rewrite, which re-runsjson.dumps+zlib.compressover every entry (310 ms of a tick, for one added line).
The write needs a proof the read doesn't, and it is the subtle part:
A file being append-only does not make its rows append-only. A trunk's cached rows carry its subagents' transcripts, spliced in at their anchors, so a subagent still running — the normal case under
watch— grows a block in the middle of the row sequence while the trunk file only gained lines at the end.
So _appended_rows offers rows only when the list is provably just the
file's own parsed lines: resumed from a verified prefix, no agent
references, no sidecars, nothing spliced, and no length change from the
whole-file passes. That covers 136 of the reference archive's 185 trunk
files; the 26% that reference subagents take the unchanged full rewrite.
Beneath it, extend_cached_entries independently refuses when the table
no longer holds the row count the caller thinks it wrote — the guard
against another process having rewritten them. With the gates removed the
caller offers a wrong 96-entry slice and that check catches it, so the
tests assert on the offer rather than only on the write. That count and
the insert run inside one BEGIN IMMEDIATE, because Python's sqlite3
opens a transaction on the first write and not on a SELECT: without
the explicit lock a second writer sharing the cache could append in
between, and the check would let both appends land.
Both cache writers are stamped with the source file's (mtime, size) as
captured before the parse — the same discipline as the sidecar
fingerprint beside it, and as the entry store's own stamp. A session
appended to mid-read would otherwise be recorded at the size it reached
while the rows are only what was parsed, marking an incomplete cache
current; stamped with what was parsed, the growth invalidates instead.
Steady-state tick on the 803MB reference archive: 0.717s → 0.257s (cumulatively 1.03s → 0.26s). Equivalence is held at three levels — parse output against the text path (162 fixture files whole-file, 90 resumed), cache DB state against a full-rewrite run over 6 ticks with 3 files growing, and rendered HTML throughout. DB state is the bar for the same reason as §2.14: the first bug of this kind is invisible in the rendered bytes.
Resumption only helps a resident loop — a one-shot run, the TUI, and
every tick-one still parse whole. Persisting (prefix_len, prefix_hash)
in cached_files would extend it across processes; that migration was
considered and not needed for the case that motivated it.
2.17 Markdown engine¶
Markdown is rendered by wenmode
(mistune's successor by the same author; the switch is #323). Four
pipelines share one rule set, built by
markdown_plugins.transcript_rules():
wenmode's github preset (wenmode ≥ 0.15.1) with strikethrough
restricted to ~~two~~ through Strikethrough(allow_single_tilde=False)
(prose says "~2, ~6 min" all the time), bare-URL autolinks that never
link e-mail-shaped tokens (ruff@0.6.0, git@github.com:), wenmode's
GFM tag filter off (every raw-HTML node is escaped by the renderer, so
a half-escaped <script> would be worse than none), and the
definition-list plugin. Everything else is strict GFM as wenmode
implements it.
| pipeline | where | what is added |
|---|---|---|
| HTML, assistant/tool/web content | html/utils.py::_get_markdown_renderer |
_TranscriptHTMLRenderer(escape=True, soft_break="br") — every soft break as <br />, mistune's hard_wrap; handlers: Pygments on fenced code with a language, escaped block-level raw HTML wrapped in <p>; the SHA-link transform |
| HTML, user content | html/utils.py::_get_user_markdown_renderer |
the same pipeline as a distinct singleton (the render memo keys on which one rendered) |
| HTML, whole document | html/utils.py::_get_document_markdown_renderer |
wenmode's frontmatter plugin, loading with PyYAML; used by render_markdown_document only — see Front matter below |
| Markdown output | markdown/renderer.py::_protect_html_tags, markdown_plugins.linkify_shas_in_text |
no renderer at all — see below |
The escape contract is not a preference. Transcript content is
untrusted from every source (#245): the assistant echoes arbitrary
user, file and web input verbatim. Both HTML singletons render raw
HTML as entity-escaped text, and the renderer's URL policy is
mistune's denylist (javascript:, vbscript:, data: except images,
file:, …) rather than wenmode's allowlist — transcripts link to
cci: editor targets and vercel.json:20 file references that no
allowlist would anticipate, and none of those can execute script.
test/test_xss_browser.py and test/test_markdown_rendering.py pin
it.
Front matter is data, and only at the top of a whole body. Read as
plain Markdown, the closing --- of a YAML front-matter block is a
setext underline, so the block became one heading. A whole body — a
message, a recap, the full view of every render_*markdown_collapsible
body, a Markdown file's Read/Write content, a plan — goes through
render_markdown_document. When the text opens with a block
(frontmatter.is_frontmatter_start:
wenmode's rule, plus the line after the opening fence must hold
something, so --- then a blank line stays a horizontal rule), the
document pipeline parses it once, the plugin stores the loaded value in
root.data, and render_node renders the rest. A non-empty mapping
shows as render_params_table above the body; anything else (a YAML
error, a scalar, a list) as a YAML code block, so nothing is lost. Any
other text goes to the ordinary pipeline and memo entry unchanged.
Inline renders, the collapsed previews (cut from the body after the
front matter) and the Markdown inside a params value never parse front
matter, so a value that itself starts with --- neither recurses nor
turns into a table. Loading is yaml.SafeLoader with aliases refused
(they would expand exponentially when walked into a table); dates and
other non-JSON scalars become strings. A block over 64 KiB, nested
deeper than 32 levels, or deep enough to exhaust PyYAML's recursion is
shown raw instead: one message must never fail a project's render. The
Markdown output splits the block with split_frontmatter and renders it
through _render_params (behind _protect_html_tags) or a yaml fence,
but only for user and assistant text — narrower than the HTML side, since
most other bodies are code-fenced in .md anyway.
Extensions are transforms, not inline rules. SHA linkification
(#156) runs after parsing, as a RootTransform walking Text and
InlineCode nodes and skipping anything under a Link. Nothing about
rule ordering, combined-regex group numbering or an in_link state
flag survives from the mistune version; the transform links exactly
the SHA multiset the two mistune rules did on a 5936-body corpus.
The Markdown output splices; it does not re-render. Both
linkify_shas_in_text and _protect_html_tags parse with
positions=True and rewrite only the byte ranges of the nodes they
care about (a resolvable SHA; a raw-HTML node, or a < in plain text
that a lax viewer could take as a tag start). Everything else in the
text — escapes, fence characters, autolinks, snake_case — survives
byte for byte, which the mistune MarkdownRenderer round-trip did not
guarantee and which wenmode's own Markdown renderer would not either
(it backslash-escapes _, [, |, * in text: measured at 217 of
531 real bodies touched).
The wenmode pin is deliberate. wenmode>=0.15.1,<0.16: the
extensions subclass wenmode rules and renderers, so a minor bump is a
re-tested step, not a lockfile refresh. 0.15.1 fixed the five parser
bugs found during the migration and added the soft_break and
allow_single_tilde options, which replaced three local workarounds;
test/test_markdown_rendering.py pins each of those behaviours, and
work/wenmode-evaluation.md records the method for re-measuring a
bump against real transcripts.
3. Data lifecycle¶
┌──────────────────┐
│ JSONL file(s) │
│ (~/.claude/...) │
└────────┬─────────┘
│
parser.py + factories/
│
▼
┌───────────────────────┐
│ list[TranscriptEntry] │ (typed Pydantic models)
└───────────┬───────────┘
│
factories/ dispatch
│
▼
┌─────────────────────────┐
│ list[TemplateMessage] │ (each carrying a typed
│ with MessageContent │ MessageContent variant)
└─────────────┬───────────┘
│
renderer.py (generate_template_messages):
build DAG → pair → reorder → relocate
subagent blocks → build hierarchy →
cleanup sidechain dups → populate caches
│
▼
┌──────────────────────┐
│ Tree of TemplateMsg │
│ + RenderingContext │ (caches: teammate_colors,
│ + nav data │ task_subjects, etc.)
└──────────┬───────────┘
│
┌────────────┬─────────────┴─────────────┬────────────┐
▼ ▼ ▼ ▼
html/renderer.py markdown/renderer.py json/renderer.py
│ │ │
▼ ▼ ▼
index.html + *.md combined_transcripts.json
session-*.html (single file) session-*.json
all-projects-summary.json
│ │ │
└──────────────────┼──────────────────────┘
│
┌──────────┴────────────┐
▼ ▼
cache.py image_export.py
(SQLite) (HTML / Markdown only —
JSON serialises paths)
Cache reads/writes happen in parallel with the main pipeline:
cache.py is consulted before parsing (cache hit → skip parse), after
rendering (write the rendered HTML), and during TUI navigation (the
TUI never re-parses).
4. Cross-cutting glossary¶
Terms that appear across multiple subsystems — defined once here.
-
TranscriptEntry: typed Pydantic model for a single line in the source JSONL. Variants:
User,Assistant,Summary,System,Passthrough,QueueOperation. Seeparser.pyandmodels.py. -
MessageContent: render-time content variant produced by the factories from
TranscriptEntry. Many flavours (UserTextMessage,ToolUseMessage,TeammateMessage, …). OneTranscriptEntrymay yield multipleMessageContents (a single assistant turn with N tool_uses produces N+1 messages). See messages.md for the full taxonomy. -
TemplateMessage: the render-time wrapper around a
MessageContent. Carriesmessage_index, parent/child links, pair_first/pair_middle/pair_last, ancestry, and the renderer-format CSS classes. Defined inrenderer.py. -
RenderingContext: mutable cache attached to one render pass. Holds the message registry plus nested per-session caches (
teammate_colors,task_subjects,task_id_for_tool_use,session_first_message, etc.). Caches are session-scoped because combined-transcripts mode merges multiple sessions and per-session identifiers (teammate_id, task_id) aren't globally unique. -
session_id: the JSONL's
sessionIdfield. Often a UUID string. In some renderer paths a synthetic form is used: {trunk}#agent-{agentId}for sub-agent transcripts (so they form a separate DAG-line attached to their spawning trunk).-
{trunk}@{first_uuid_prefix}for branch sessions (rewinds / parallel-tool_use forks). See dag.md. -
render_session_id: the session id that should be used when walking
ctx.messagesto find content for rendering, accounting for synthetic rewrites. -
sidechain: a sub-agent's transcript entries are flagged
isSidechain: true. The DAG layer integrates them into the parent session's tree under the spawning Task/Agent tool_use anchor. See agents.md, dag.md. -
agent_id: identifier copied from a Task/Agent tool_result (either
toolUseResult.agentIdor parsed from the Markdown metadata tail). Used to stitch sub-agent JSONL files into the trunk DAG. See agents.md. -
workflow run: one execution of the
Workflowtool — a JS orchestrator fanning out into phase-grouped side-channel sub-agents, left on disk under<sid>/subagents/workflows/<runId>/. Parsed byworkflow.pyinto aWorkflowRunand spliced into the message tree at the Workflow tool_use site. See workflows.md. -
fork point / branch: when a session has multiple children with the same parent, the parent is the fork point and each child initiates a branch. Real forks come from
/exitrewinds; spurious forks (parallel tool_uses, structural-only siblings) are collapsed by_walk_session_with_forks. See dag.md. -
SessionHeaderMessage: the synthetic content type produced for every session boundary in the rendered output — the header that appears above each session's first real message. Two flavours: trunk headers for top-level sessions, and branch headers for fork branches (the "branch heading" you'll see referenced in bug reports). Both headers are constructed by
_build_trunk_header/_build_branch_header(inrenderer.py); the branch header's title is composed by_branch_labelin the shapeBranch • <uuid8> • <preview>, with the preview computed once by scanning the branch's DAG-line uuids for the first user entry with text (viaextract_text_contentinparser.py+create_session_previewinutils.py, which callssimplify_command_tagsto strip raw<command-name>XML soup down to/cmd). When troubleshooting branch-heading rendering, those are the functions to inspect. -
pair_first / pair_middle / pair_last: a pair of messages rendered as one logical unit (tool_use + tool_result, Slash + UserSlash, thinking + assistant).
pair_middleexists for triples — currently the slash-command(UserSlash → Slash → CommandOutput)shape. -
depth: see § 2.6.
-
detail-aware tools: the curated set of tools whose I/O survives
--depth agentbecause they convey what the agent did, not what it read (WebSearch,WebFetch,Task,Agent). -
passthrough: a
PassthroughTranscriptEntryis a non-conversation entry (hook callbacks, progress updates, last-prompt markers). The DAG layer keeps them in the structure but the renderer typically hides them.
5. Where to start reading¶
Common entry questions and their best first stop:
- "How does a JSONL line become an HTML row?" → rendering-architecture.md.
- "Why are forks rendered weirdly / what is a branch session?" → dag.md.
- "What message types exist and what do they look like?"
→ messages.md plus the samples in
messages/. - "I want to add support for a new Claude Code tool." → implementing-a-tool-renderer.md.
- "I want to write a third-party plugin (e.g. for an MCP tool we don't ship)." → plugins.md.
- "How does folding / collapsible content work?" → message-hierarchy.md.
- "What CSS classes does a message div get?" → css-classes.md.
- "How are sub-agent transcripts (sync, async, teammates) integrated?" → agents.md, then teammates.md for the teammates-specific machinery.
- "How does a dynamic-workflow run (phases, agents, orchestrator script) get rendered?" → workflows.md.
- "I want to extend the cache / change the schema." → § 2.3, § 2.4 here, then read the migration files in order.
- "How do I export to JSON for downstream tooling?"
→ § 2.5 here (and
--format jsonfrom § 2.1). - "claude-code-log is hung — how do I see what it's doing?"
→ § 2.11 (
SIGUSR1stack dump). - "What's planned but not implemented?"
→
work/— each.mdis an in-flight or proposed plan.