diff --git a/README.md b/README.md
index b3d1da4..321e5d6 100644
--- a/README.md
+++ b/README.md
@@ -43,6 +43,7 @@ If you build and operate your own agent in production, use a tracing platform. I
## Features
+- **Per-turn ledger** — In the session summary: one row per user turn with wall-clock time, tokens (input + output + cache) and cost, bars scaled to the session maximum, tool-call and error counts, click to jump. Answers "why did this take 40 minutes / cost $3" without reading the transcript.
- **Multi-platform** — Unified view across OpenClaw, Codex, Claude Code, Hermes, OMP, DeepSeek Harness and Gemini CLI sessions (dsh's multi-frame zstd session logs are decompressed transparently; Gemini CLI's `/rewind` checkpoints are folded so rewound history never renders twice)
- **Session browser** — Browse agents, filter/search sessions, view message history
- **Tool call inspection** — Expandable tool calls with arguments and results
diff --git a/frontend/src/lib/legacy-pure.ts b/frontend/src/lib/legacy-pure.ts
index 483644d..6133c31 100644
--- a/frontend/src/lib/legacy-pure.ts
+++ b/frontend/src/lib/legacy-pure.ts
@@ -12,6 +12,7 @@ export {
getTextContent,
clusterPrefillContent,
buildTraceTurns,
+ buildTurnLedger,
pickAutoPlatform,
} from './pure';
export { escapeHtml, renderMarkdownHtml, renderMarkdown } from './markdown';
diff --git a/frontend/src/lib/pure.ts b/frontend/src/lib/pure.ts
index de8a5e4..f89b969 100644
--- a/frontend/src/lib/pure.ts
+++ b/frontend/src/lib/pure.ts
@@ -217,3 +217,122 @@ export function buildTraceTurns(msgs: SessionMessage[], agentSpans: AgentSpan[]
for (const tn of turns) tn.spans.sort((a, b) => a.start - b.start);
return turns.filter((tn) => tn.spans.length > 0);
}
+
+// --- Per-turn ledger: where did the time, tokens and dollars go? ---
+// A turn = one user message plus everything the agent did until the next user
+// message. Usage is summed from assistant messages' `usage` (already normalised
+// per platform: input/output/cacheRead/cacheWrite tokens, cost as number or
+// {total}). Missing usage on a platform leaves the token/cost columns at zero
+// rather than hiding the row: the time column still tells the story.
+
+export interface TurnLedgerRow {
+ index: number;
+ text: string;
+ messageId: string | null;
+ start: number;
+ end: number;
+ durationMs: number;
+ toolCalls: number;
+ toolErrors: number;
+ inputTokens: number;
+ outputTokens: number;
+ cacheReadTokens: number;
+ cacheWriteTokens: number;
+ cost: number;
+}
+
+export interface TurnLedger {
+ rows: TurnLedgerRow[];
+ totals: { durationMs: number; toolCalls: number; tokens: number; cost: number };
+ hasUsage: boolean;
+ hasCost: boolean;
+}
+
+function usageCost(usage: SessionMessage['usage']): number {
+ const c = usage?.cost;
+ if (typeof c === 'number') return c;
+ return typeof c?.total === 'number' ? c.total : 0;
+}
+
+function usageNumber(usage: SessionMessage['usage'], ...keys: string[]): number {
+ if (!usage) return 0;
+ for (const k of keys) {
+ const v = usage[k];
+ if (typeof v === 'number') return v;
+ }
+ return 0;
+}
+
+export function buildTurnLedger(msgs: SessionMessage[]): TurnLedger {
+ const rows: TurnLedgerRow[] = [];
+ let cur: TurnLedgerRow | null = null;
+ let hasUsage = false;
+ let hasCost = false;
+
+ for (const m of msgs) {
+ const t = parseTimestampMs(m.timestamp);
+ if (m.role === 'user') {
+ const text = getTextContent(m.content || [])
+ .replace(/\s+/g, ' ')
+ .trim();
+ cur = {
+ index: rows.length + 1,
+ text: text.slice(0, 140) || '(user)',
+ messageId: m.id || null,
+ start: t ?? Number.NaN,
+ end: t ?? Number.NaN,
+ durationMs: 0,
+ toolCalls: 0,
+ toolErrors: 0,
+ inputTokens: 0,
+ outputTokens: 0,
+ cacheReadTokens: 0,
+ cacheWriteTokens: 0,
+ cost: 0,
+ };
+ rows.push(cur);
+ continue;
+ }
+ if (!cur) continue; // pre-user preamble (system prompts) belongs to no turn
+ if (t !== null) {
+ if (Number.isNaN(cur.start)) cur.start = t;
+ cur.end = Math.max(Number.isNaN(cur.end) ? t : cur.end, t);
+ }
+ if (m.role === 'toolCall') cur.toolCalls++;
+ if (m.role === 'toolResult' && m.isError) cur.toolErrors++;
+ for (const c of m.content || []) {
+ if (c.type === 'toolCall' || c.type === 'tool_use') cur.toolCalls++;
+ if (c.type === 'tool_result' && c.is_error) cur.toolErrors++;
+ }
+ if (m.usage) {
+ const inp = usageNumber(m.usage, 'input', 'inputTokens', 'input_tokens', 'prompt_tokens');
+ const out = usageNumber(m.usage, 'output', 'outputTokens', 'output_tokens', 'completion_tokens');
+ const cr = usageNumber(m.usage, 'cacheRead', 'cache_read_input_tokens', 'cacheReadTokens');
+ const cw = usageNumber(m.usage, 'cacheWrite', 'cache_creation_input_tokens', 'cacheWriteTokens');
+ const total = usageNumber(m.usage, 'totalTokens', 'total_tokens');
+ if (inp || out || cr || cw || total) hasUsage = true;
+ cur.inputTokens += inp || (out || cr || cw ? 0 : total);
+ cur.outputTokens += out;
+ cur.cacheReadTokens += cr;
+ cur.cacheWriteTokens += cw;
+ const cost = usageCost(m.usage);
+ if (cost) hasCost = true;
+ cur.cost += cost;
+ }
+ }
+
+ for (const r of rows) {
+ r.durationMs = Number.isNaN(r.start) || Number.isNaN(r.end) ? 0 : Math.max(0, r.end - r.start);
+ }
+ const totals = rows.reduce(
+ (acc, r) => {
+ acc.durationMs += r.durationMs;
+ acc.toolCalls += r.toolCalls;
+ acc.tokens += r.inputTokens + r.outputTokens + r.cacheReadTokens + r.cacheWriteTokens;
+ acc.cost += r.cost;
+ return acc;
+ },
+ { durationMs: 0, toolCalls: 0, tokens: 0, cost: 0 }
+ );
+ return { rows, totals, hasUsage, hasCost };
+}
diff --git a/frontend/src/views/sessions/SessionSummary.tsx b/frontend/src/views/sessions/SessionSummary.tsx
index 158f87d..cd5e8b1 100644
--- a/frontend/src/views/sessions/SessionSummary.tsx
+++ b/frontend/src/views/sessions/SessionSummary.tsx
@@ -10,6 +10,7 @@ import { formatCost, formatDurationCompact } from '@/lib/pure';
import { cn } from '@/lib/utils';
import { dirForPlatform, loadStoredFlag, saveStoredFlag, SUMMARY_COLLAPSED_KEY, useAppStore } from '@/store';
import { ChildAgentsSection } from '@/views/trace/ChildAgentsSection';
+import { TurnLedger } from './TurnLedger';
import type { ExportFormat } from './exports';
import { runExport } from './exports';
import type { MsgFilter, TimingAnalysis } from './lib';
@@ -350,6 +351,7 @@ export function SessionSummary({
+
code<>\n'));
});
+
+// --- buildTurnLedger: per-turn time / tokens / cost ---
+
+test('buildTurnLedger attributes time, tools, tokens and cost to the user turn that caused them', () => {
+ const msgs = [
+ { id: 's', role: 'system', timestamp: iso(-1000), content: [] }, // preamble: no turn
+ { id: 'u1', role: 'user', timestamp: iso(0), content: [{ type: 'text', text: 'first question' }] },
+ {
+ id: 'a1',
+ role: 'assistant',
+ timestamp: iso(5000),
+ usage: { input: 1000, output: 200, cacheRead: 5000, cost: 0.02 },
+ content: [
+ { type: 'text', text: 'x' },
+ { type: 'toolCall', id: 'c1', name: 'bash' },
+ ],
+ },
+ { id: 'tr', role: 'toolResult', timestamp: iso(8000), toolCallId: 'c1', isError: true, content: [] },
+ {
+ id: 'a2',
+ role: 'assistant',
+ timestamp: iso(9000),
+ usage: { input: 1200, output: 50, cost: { total: 0.01 } },
+ content: [],
+ },
+ { id: 'u2', role: 'user', timestamp: iso(20000), content: [{ type: 'text', text: 'second' }] },
+ { id: 'a3', role: 'assistant', timestamp: iso(21000), usage: { total_tokens: 300 }, content: [] },
+ ];
+ const l = pure.buildTurnLedger(msgs);
+ assert.equal(l.rows.length, 2);
+ const [t1, t2] = l.rows;
+ assert.equal(t1.text, 'first question');
+ assert.equal(t1.messageId, 'u1');
+ assert.equal(t1.durationMs, 9000); // user msg -> last agent msg of the turn
+ assert.equal(t1.toolCalls, 1); // content-part toolCall counted once
+ assert.equal(t1.toolErrors, 1);
+ assert.deepEqual([t1.inputTokens, t1.outputTokens, t1.cacheReadTokens, t1.cacheWriteTokens], [2200, 250, 5000, 0]);
+ assert.ok(Math.abs(t1.cost - 0.03) < 1e-9); // number and {total} cost shapes both summed
+ assert.equal(t2.durationMs, 1000);
+ assert.equal(t2.inputTokens, 300); // total_tokens-only platforms land in the input column
+ assert.equal(t2.cost, 0);
+ assert.deepEqual(l.totals, { durationMs: 10000, toolCalls: 1, tokens: 7750, cost: 0.03 });
+ assert.equal(l.hasUsage, true);
+ assert.equal(l.hasCost, true);
+});
+
+test('buildTurnLedger without usage still yields time rows and reports hasUsage=false', () => {
+ const l = pure.buildTurnLedger([
+ { id: 'u1', role: 'user', timestamp: iso(0), content: [{ type: 'text', text: 'q' }] },
+ { id: 'a1', role: 'assistant', timestamp: iso(3000), content: [] },
+ ]);
+ assert.equal(l.rows[0].durationMs, 3000);
+ assert.equal(l.hasUsage, false);
+ assert.equal(l.hasCost, false);
+});