Provider-agnostic cost & agent-tree explorer for AI coding sessions.
React 19 + Vite UI, backed by a Node 22+ server. The only outbound request is the price-catalogue refresh (see Pricing; AGENT_ATLAS_PRICING=offline disables it). No telemetry,
and binds 127.0.0.1 by default.
Reads the transcripts your agent harness already writes, builds a nested tree of
every agent that ran (main → subagents → their subagents, any depth),
prices every node from its own token usage, and rolls costs up:
total = own + Σ children.total.
main — (session) opus-5 high 19.4h 251c $323.17 (own $81.76 + sub $241.41)
├─ lib-researcher — Survey tracing options opus-5 xhigh 12m 41c $6.08 (own $3.80 + sub $2.28)
│ ├─ general-purpose — Research Langfuse… opus-5 xhigh 5m 19c $1.07
…
Install and build the browser client once:
npm install
npm run buildThen use the CLI:
node src/cli.mjs list [--days 7 | --all] [--json] # sessions, cost-so-far, LIVE badge
node src/cli.mjs tree <session-id|path> [--json|--html] # expandable tree (terminal/JSON/HTML)
node src/cli.mjs export <id> --out tree.html # standalone HTML, no server needed
node src/cli.mjs serve [--host 127.0.0.1] [--port 4747] [--interval 10] [--token X]Web view: / is a Vite-built React application and /session/<id> is its React detail route. The list uses a worker-backed session index, server-side filtering/sorting/aggregates, paged JSON and bounded virtual rows, so the initial UI payload and mounted DOM do not grow with the complete history. Detail data is fetched only after navigating to a session.
Compatibility endpoints remain available: /api/sessions and /api/tree/<id>. The React UI uses additive /api/ui/sessions and /api/ui/session/<id> endpoints. With --token X, HTML and API requests require ?t=X; fingerprinted static browser assets are intentionally public and contain no transcript data.
Transcripts are append-only JSONL, so ingestion is incremental and idempotent:
- Per file we keep a byte offset and parse only new, complete lines (a partial trailing line waits for the next pass).
- Aggregation dedups by
message.idkeeping the MAX-usage variant — the same rule the harness uses for its own streaming duplicates — so re-reading any range converges to the same numbers. A live session's cost is a monotone lower bound of its final cost. - There is no "session closed" marker on disk, and none is needed: a session is LIVE if its file changed in the last 2 minutes; its tree is simply the tree so far, and live pages auto-refresh.
Change detection is a stat scan every --interval seconds. Parsing/indexing runs in a worker thread, so the HTTP listener and health endpoint become available before the initial corpus index completes. The UI reports this transient indexing state rather than presenting partial totals as final.
src/providers/<name>.mjs implements: discover() → session refs,
buildTree(ref, cache) → tree, and refFromPath(absPath) → a ref or null
("not mine"). src/providers/index.mjs is the registry: it concatenates
discover() over the providers and dispatches buildTree on ref.provider.
A ref is {provider, id, project, path, mtimeMs, size}.
tree <path> / export <path> now only accept a path a provider recognises
(today: an existing *.jsonl transcript). Any other real path used to be parsed
as a transcript and rendered as a garbage tree; it is no session matches now.
Session ids are unchanged.
Implemented:
- claude (Claude Code): sessions in
~/.claude/projects/<proj>/<uuid>.jsonl(override root withAGENT_ATLAS_CLAUDE_ROOT); harness subagents from<uuid>/subagents/agent-*.jsonl+.meta.jsonsidecars (agentType,description,parentAgentId,spawnDepth); cost per node computed from per-message usage with the 5m/1h cache-tier split. - codex (Codex CLI): Codex's own stores under
~/.codex(override the root withAGENT_ATLAS_CODEX_ROOT, thenCODEX_HOME— these name the home, not a sessions directory, because the three inputs are siblings):state_5.sqlite(threads,thread_spawn_edges),thread_history_1.sqlite(thread_turns,thread_items) and the rollout JSONL undersessions/, which is the only place a per-response token breakdown exists. Read-only, vianode:sqlite. Cost is per API call, priced with that call's own turn model — a thread that switched models mid-session is not priced at one of them. No dollar figure exists anywhere in Codex's store, so every row is list-price × tokens flaggedcomputed. A thread that is the child of athread_spawn_edgesrow nests inside its parent's tree withvia: "spawn"(not"cli", which means "spotted in a Claude transcript", and not"harness"). A nested spawn child is not addressable on its own —tree <child-id>andGET /session/<child-id>do not resolve it, the same way a Claude harness subagent's own file is not a top-level session. Archived threads are still listed: atlas audits what is on disk. On a host that has Codex data, Node prints oneExperimentalWarning: SQLite is an experimental feature…line to stderr per process, the first time the store is opened.NODE_NO_WARNINGS=1silences it for--jsonpipelines. On a host that never ran Codex, nothing is read, no warning is printed, and nothing about Claude sessions changes.codex-auto-reviewis Codex's internal Guardian model for automatic permission checks, as described in Codex's model catalogue. Atlas labels it as internal, retains its recorded id, and does not substitute the parent agent's model or invent a price when the catalogues have none. If turn contexts are absent, Atlas preserves models declared in thread settings or SQLite metadata for display (modelSourcerecords that origin). Those declarations never price calls lacking their own turn model. If no model was saved, the UI says model not recorded; the model that authored an instruction template is not evidence of which model executed a session. The default minimum-cost filter keeps unavailable and partial estimates visible; it only hides fully priced cheap sessions.
Planned (design notes, not yet implemented):
- cursor: nothing to read — Cursor persists no billable usage locally and its headless CLI reports no cost (open feature request). Cursor-launched work has no local trace at all today.
Transcript detection needs no cooperation from whoever launched the agent,
which is why it is the only mechanism here: when a Bash
tool_use whose command mentions codex returns a tool_result carrying
codex's own session id: <uuid> line, agent-atlas emits one CLI:codex leaf
per distinct uuid per tree, under the agent whose transcript it was read from.
It is a stub — cost is always 0 / n/a — and the banner fields it carries (tokensUsed, sandbox,
workdir, approval, codexVersion, under reported) are harvested
only from a complete printed banner; a grep or log re-read yields the session id
and nothing else. Those reported.* key names are provisional, and may be
replaced if a real codex provider module ever reads Codex's own store. Two
known false negatives: a launch whose output the harness spilled to
<session-dir>/tool-results/<name>.txt (that sibling file is never opened),
and a launch run as a background shell and read back via BashOutput (that
tool call carries no command to anchor on). Neither leaves a trace in the tree.
Prices are not tables in the source. src/pricing/ resolves a model's token
prices at runtime, so a model that ships tomorrow is priced as soon as a
catalogue lists it.
- Sources, in order: your local overrides, then LiteLLM's
model_prices_and_context_window.json, then OpenRouter's/api/v1/models(only for models LiteLLM does not list yet). Input, output, cache read and cache write (5m and 1h) are priced separately where the source has them. - Cache policy: each source is re-read at most every 24 h (1 h back-off
after a failure), in the background for
serveand for at most 2 s per source inlist/tree/export. The result is cached at$XDG_CACHE_HOME/agent-atlas/prices.json(AGENT_ATLAS_PRICES_CACHE). A failed, slow or implausible (< 50 models) refresh changes nothing; the last good catalogue, else the snapshot bundled with the release (npm run prices:snapshot), keeps answering however old. Models an upstream delists keep their last price.AGENT_ATLAS_PRICING=offlinenever touches the network. - Overrides:
$XDG_CONFIG_HOME/agent-atlas/prices.json(AGENT_ATLAS_PRICES_OVERRIDE),{"model": {"in": 5, "out": 25, "cr": 0.5, "cw5": 6.25, "cw1h": 10}}in $/MTok; unlisted classes are unpublished. Wins over every source, re-read live. - Matching: provider prefixes, date suffixes,
@version,-v1:0,[1m]and dots/dashes are normalised (anthropic/claude-opus-5.5=claude-opus-5-5-20260921). No fuzzy or nearest-version fallback: a guess would be a silent wrong number. - Provenance: every node carries
cost.provenance(model, source, matched key, origincache/bundled/override, fetched-at, URL); the session view shows it in the "prices" tile. - Unknown is not zero: a model no source knows is
n/a(or a lower bound≥ $xnext to priced calls), is listed per session, and is never hidden by themin $filter. A token class the source does not publish (e.g. OpenAI cache writes) makes the resultpartial, not free. - Not modelled: long-context tiers (> 200k/272k), batch/priority tiers, and promotional windows. A session is priced at the current catalogue price, so a later price change re-prices history. These are estimated API list-price costs, not subscription charges or invoice totals. Reproducible historical pricing is tracked separately in #31.
- The harness sweeps transcripts after
cleanupPeriodDays(default 30). v1 is in-memory over what's on disk; durable retention would add a store later. - The initial worker index remains corpus-sized; it is deliberately asynchronous so it does not delay the HTTP server.
Published to GHCR by CI on every push to main (and semver tags):
docker run --rm -p 4747:4747 \
-v "$HOME:/data/home" -e AGENT_ATLAS_CLAUDE_ROOT=/data/home/.claude/projects \
-e AGENT_ATLAS_CODEX_ROOT=/data/home/.codex \
ghcr.io/daimonbot/agent-atlas:latestThe image runs as user node (uid 1000). Its default CODEX_HOME is
/data/home/.codex, matching the shared-home mount above; set CODEX_HOME or
AGENT_ATLAS_CODEX_ROOT for a different location (the Atlas override wins).
Pricing caches default to /tmp/agent-atlas-cache, so a read-only root
filesystem needs only a writable /tmp mount for that cache. Harness databases
are always opened read-only; SQLite may need its own writable sidecar files as
described below.
The mount is deliberately not :ro. Codex stores use SQLite WAL mode.
A read-only mount works when the required readable -wal and -shm files are
already maintained by the Codex writer; otherwise SQLite may need to create
the -shm sidecar and opening can fail. Every Atlas connection uses
readOnly: true, so Atlas never writes the databases themselves. Allow the
minimum required sidecar-file writes when configuring a new mount. For a
Claude-only installation, mount :ro and point AGENT_ATLAS_CODEX_ROOT at an
empty directory to disable Codex discovery.