Generated banner for Session Report
Generated logo for Session Report

Session Report

AnthropicOfficialPublished by Anthropic

  • Skill
  • Content generator
  • Agent tooling
  • Apache-2.0
  • Low risk
  • Has scripts

From the author

Generate an explorable HTML report of Claude Code session usage (tokens, cache, subagents, skills, expensive prompts) from ~/.claude/projects transcripts.
— the description by Anthropic

AutoGineer summary

Written by AutoGineer from the skill’s own files, not by the author

The Session Report skill provides a structured way to visualize and audit Claude Code usage patterns. By analyzing session transcripts, it produces a self-contained, interactive HTML report that highlights token consumption, cache efficiency, subagent activity, and expensive prompts. It is designed to help users identify anomalies, optimize agent performance, and understand resource allocation across different projects.

What it does

  • Analyzes Claude Code session transcripts to extract usage metrics.
  • Generates a self-contained HTML report with interactive tables and charts.
  • Identifies high-consumption projects, skills, and individual prompts.
  • Highlights performance anomalies like low cache-hit rates or excessive subagent spawning.
  • Provides actionable optimization suggestions based on session data.
  • Supports configurable time ranges for historical analysis.

When to use it

  • When you need to audit token usage and costs across multiple Claude Code projects.
  • When investigating why a specific project or skill is consuming a disproportionate amount of resources.
  • When optimizing agent workflows to improve cache-hit rates.
  • When preparing a summary of agent activity for project management or cost-tracking purposes.

Advantages

  • Provides a clear, visual breakdown of complex session data.
  • Enables drill-down analysis into specific projects and subagents.
  • Offers automated identification of performance bottlenecks and anomalies.
  • Produces a portable, single-file HTML report for easy sharing and review.

Usage examples

“Generate a session report for the last 30 days.”
The agent analyzes the last month of Claude Code activity and saves a timestamped HTML report to the current directory.
“Create a report of all my Claude Code usage.”
The agent processes all available session transcripts and generates a comprehensive, interactive usage report.

Install

  1. Download the skill as a ZIP:

    https://mp-staging.autogineer.com/agent-skills/anthropics/session-report/download
  2. Unzip it into the cross-client skills folder of your project:

    mkdir -p .agents/skills && unzip session-report.zip -d .agents/skills

Documentation by Anthropic

Session Report

Produce a self-contained HTML report of Claude Code usage and save it to the current working directory.

Steps

  1. Get data. Run the bundled analyzer (default window: last 7 days; honor a different range if the user passed one, e.g. 24h, 30d, or all). The script analyze-sessions.mjs lives in the same directory as this SKILL.md — use its absolute path:

    node <skill-dir>/analyze-sessions.mjs --json --since 7d > /tmp/session-report.json

    For all-time, omit --since.

  2. Read /tmp/session-report.json. Skim overall, by_project, by_subagent_type, by_skill, cache_breaks, top_prompts.

  3. Copy the template (also bundled alongside this SKILL.md) to the output path in the current working directory:

    cp <skill-dir>/template.html ./session-report-$(date +%Y%m%d-%H%M).html
  4. Edit the output file (use Edit, not Write — preserve the template's JS/CSS):

    • Replace the contents of <script id="report-data" type="application/json"> with the full JSON from step 1. The page's JS renders the hero total, all tables, bars, and drill-downs from this blob automatically.

    • Fill the <!-- AGENT: anomalies --> block with 3–5 one-line findings. Express figures as a % of total tokens wherever possible (total = overall.input_tokens.total + overall.output_tokens). One line per finding, exact markup:

      <div class="take bad"><div class="fig">41.2%</div><div class="txt"><b>cc-monitor</b> consumed 41% of the week across just 3 sessions</div></div>

      Classes: .take bad for waste/anomalies (red), .take good for healthy signals (green), .take info for neutral facts (blue). The .fig is one short number (a %, a count, or a multiplier like 12×). The .txt is one plain-English sentence naming the project/skill/prompt; wrap the subject in <b>. Look for: a project or skill eating a disproportionate share, cache-hit <85%, a single prompt >2% of total, subagent types averaging >1M tokens/call, cache breaks clustering.

    • Fill the <!-- AGENT: optimizations --> block (at the bottom of the page) with 1–4 <div class="callout"> suggestions tied to specific rows (e.g. "/weekly-status spawned 7 subagents for 8.1% of total — scope it to fewer parallel agents").

    • Do not restructure existing sections.

  5. Report the saved file path to the user. Do not open it or render it.

Notes

  • The template is the source of interactivity (sorting, expand/collapse, block-char bars). Your job is data + narrative, not markup.

  • Keep commentary terse and specific — reference actual project names, numbers, timestamps from the JSON.

  • top_prompts already includes subagent tokens and rolls task-notification continuations into the originating prompt.

  • If the JSON is >2MB, trim top_prompts to 100 entries and cache_breaks to 100 before embedding (they should already be capped).

Files

analyze-sessions.mjs

#!/usr/bin/env node
/* eslint-disable */
/**
 * analyze-sessions.js
 *
 * Scans ~/.claude/projects/**.jsonl transcript files and reports token usage,
 * message counts, runtime, cache breaks, subagent and skill activity.
 *
 * Output is human-readable text by default; pass --json for machine-readable.
 *
 * Usage:
 *   node scripts/analyze-sessions.js [--dir <projects-dir>] [--json] [--since <ISO|7d|24h>] [--top N]
 *
 * Notes on JSONL structure (discovered empirically):
 *  - One API response is split into MULTIPLE `type:"assistant"` entries (one per
 *    content block). They share the same `requestId` / `message.id`, and only the
 *    LAST one carries the final `output_tokens`. We dedupe by requestId and keep
 *    the max output_tokens to avoid 3-10x overcounting.
 *  - `type:"user"` entries include tool_result messages, interrupt markers,
 *    compact summaries and meta-injected text. A "human" message is one where
 *    isSidechain/isMeta/isCompactSummary are falsy and the content is a plain
 *    string (or text block) that isn't a tool_result or interrupt marker.
 *  - Subagent transcripts live in <project>/<sessionId>/subagents/*.jsonl with a
 *    sibling *.meta.json containing {agentType}. When meta is absent we fall back
 *    to the filename label (`agent-a<label>-<hash>.jsonl` → label) or "fork".
 *  - Resumed sessions can re-serialize prior entries into a new file; we dedupe
 *    globally by entry `uuid` so replayed history isn't double-counted.
 */

import fs from 'fs'
import os from 'os'
import path from 'path'
import readline from 'readline'

// ---------------------------------------------------------------------------
// CLI args
// ---------------------------------------------------------------------------
const argv = process.argv.slice(2)
function flag(name, dflt) {
  const i = argv.indexOf(name)
  if (i === -1) return dflt
  const v = argv[i + 1]
  return v === undefined || v.startsWith('--') ? true : v
}
const ROOT = flag('--dir', path.join(os.homedir(), '.claude', 'projects'))
const AS_JSON = argv.includes('--json')
const TOP_N = parseInt(flag('--top', '15'), 10)
const SINCE = parseSince(flag('--since', null))
const CACHE_BREAK_THRESHOLD = parseInt(flag('--cache-break', '100000'), 10)
const IDLE_GAP_MS = 5 * 60 * 1000 // gaps >5min don't count toward "active" time

function parseSince(s) {
  if (!s) return null
  const m = /^(\d+)([dh])$/.exec(s)
  if (m) {
    const ms = m[2] === 'd' ? 86400000 : 3600000
    return new Date(Date.now() - parseInt(m[1], 10) * ms)
  }
  const d = new Date(s)
  return isNaN(d) ? null : d
}

// ---------------------------------------------------------------------------
// Stats container
// ---------------------------------------------------------------------------
function newStats() {
  return {
    sessions: new Set(),
    apiCalls: 0,
    inputUncached: 0, // usage.input_tokens
    inputCacheCreate: 0, // usage.cache_creation_input_tokens
    inputCacheRead: 0, // usage.cache_read_input_tokens
    outputTokens: 0,
    humanMessages: 0,
    wallClockMs: 0,
    activeMs: 0,
    cacheBreaks: [], // [{ts, session, project, uncached, total}]
    subagentCalls: 0,
    subagentTokens: 0, // total (in+out) inside subagent transcripts
    skillInvocations: {}, // name -> count
    firstTs: null,
    lastTs: null,
  }
}

function addUsage(s, u) {
  s.apiCalls++
  s.inputUncached += u.input_tokens || 0
  s.inputCacheCreate += u.cache_creation_input_tokens || 0
  s.inputCacheRead += u.cache_read_input_tokens || 0
  s.outputTokens += u.output_tokens || 0
}

// ---------------------------------------------------------------------------
// File discovery
// ---------------------------------------------------------------------------
function* walk(dir) {
  let ents
  try {
    ents = fs.readdirSync(dir, { withFileTypes: true })
  } catch {
    return
  }
  for (const e of ents) {
    const p = path.join(dir, e.name)
    if (e.isDirectory()) yield* walk(p)
    else if (e.isFile() && e.name.endsWith('.jsonl')) yield p
  }
}

function classifyFile(p) {
  // returns { project, sessionId, kind, agentId?, agentTypeHint? }
  // agentTypeHint is from meta.json or filename label; final type is resolved
  // in main() after the parent-transcript map is built.
  const rel = path.relative(ROOT, p)
  const parts = rel.split(path.sep)
  const project = parts[0]
  const subIdx = parts.indexOf('subagents')
  if (subIdx !== -1) {
    const sessionId = parts[subIdx - 1]
    const base = path.basename(p, '.jsonl')
    const agentId = base.replace(/^agent-/, '')
    return {
      project,
      sessionId,
      kind: 'subagent',
      agentId,
      agentTypeHint:
        inferAgentTypeFromMeta(p) || inferAgentTypeFromFilename(base),
    }
  }
  if (parts.includes('workflows')) {
    const sessionId = parts[1]
    return { project, sessionId, kind: 'subagent', agentTypeHint: 'workflow' }
  }
  const sessionId = path.basename(p, '.jsonl')
  return { project, sessionId, kind: 'main' }
}

function inferAgentTypeFromMeta(jsonlPath) {
  const metaPath = jsonlPath.replace(/\.jsonl$/, '.meta.json')
  try {
    const m = JSON.parse(fs.readFileSync(metaPath, 'utf8'))
    if (m && typeof m.agentType === 'string') return m.agentType
  } catch {
    /* no meta */
  }
  return null
}

function inferAgentTypeFromFilename(base) {
  // agentId = 'a' + hex16  OR  'a' + label + '-' + hex16  (src/utils/uuid.ts)
  const m = /^agent-a([a-zA-Z_][\w-]*?)-[0-9a-f]{6,}$/.exec(base)
  if (m) return m[1] // internal background fork label
  return null // unlabeled — resolve via agentIdToType map or default to 'fork'
}

// ---------------------------------------------------------------------------
// Per-file streaming parse
// ---------------------------------------------------------------------------
const seenUuids = new Set() // global dedupe across resumed sessions
const seenRequestIds = new Set() // global dedupe for usage accounting
const toolUseIdToType = new Map() // tool_use id -> subagent_type (from Agent/Task tool_use)
const agentIdToType = new Map() // agentId -> subagent_type (linked via tool_result)
const toolUseIdToPrompt = new Map() // tool_use id -> promptKey (Agent spawned during this prompt)
const agentIdToPrompt = new Map() // agentId -> promptKey
const prompts = new Map() // promptKey -> { text, ts, project, sessionId, ...usage }
const sessionTurns = new Map() // sessionId -> [promptKey, ...] in transcript order
const sessionSpans = new Map() // sessionId -> {project, firstTs, lastTs, tokens}

function promptRecord(key, init) {
  let r = prompts.get(key)
  if (!r) {
    r = {
      text: init.text,
      ts: init.ts,
      project: init.project,
      sessionId: init.sessionId,
      apiCalls: 0,
      subagentCalls: 0,
      inputUncached: 0,
      inputCacheCreate: 0,
      inputCacheRead: 0,
      outputTokens: 0,
    }
    prompts.set(key, r)
  }
  return r
}

async function processFile(p, info, buckets) {
  const rl = readline.createInterface({
    input: fs.createReadStream(p, { encoding: 'utf8' }),
    crlfDelay: Infinity,
  })

  // Per-file: dedupe API calls by requestId, keep the one with max output_tokens.
  // We collect first, then commit, because earlier blocks have stale output counts.
  const fileApiCalls = new Map() // key -> {usage, ts}
  let firstTs = null
  let lastTs = null
  let prevTs = null
  let activeMs = 0
  let currentSkill = null // skill attribution for this turn
  // Prompt attribution: in main files this is set on each human message; in
  // subagent files it's inherited from the spawning prompt (via agentIdToPrompt).
  let currentPrompt =
    info.kind === 'subagent' && info.agentId
      ? agentIdToPrompt.get(info.agentId) || null
      : null

  const project = buckets.project
  const overall = buckets.overall
  const subagent = buckets.subagent // may be null
  const skillStats = buckets.skillStats // map name -> stats

  for await (const line of rl) {
    if (!line) continue
    let e
    try {
      e = JSON.parse(line)
    } catch {
      continue
    }

    // global uuid dedupe (resumed sessions replay history)
    if (e.uuid) {
      if (seenUuids.has(e.uuid)) continue
      seenUuids.add(e.uuid)
    }

    // timestamp tracking
    if (e.timestamp) {
      const ts = Date.parse(e.timestamp)
      if (!isNaN(ts)) {
        if (SINCE && ts < SINCE.getTime()) continue
        if (firstTs === null) firstTs = ts
        if (prevTs !== null) {
          const gap = ts - prevTs
          if (gap > 0 && gap < IDLE_GAP_MS) activeMs += gap
        }
        prevTs = ts
        lastTs = ts
      }
    }

    if (e.type === 'user') {
      // Link Agent tool_result -> agentId for type + prompt attribution.
      const tur = e.toolUseResult
      if (tur && tur.agentId) {
        const c0 = Array.isArray(e.message?.content)
          ? e.message.content[0]
          : null
        const tuid = c0 && c0.tool_use_id
        if (tuid) {
          const st = toolUseIdToType.get(tuid)
          if (st) agentIdToType.set(tur.agentId, st)
          const pk = toolUseIdToPrompt.get(tuid)
          if (pk) {
            agentIdToPrompt.set(tur.agentId, pk)
            const r = prompts.get(pk)
            if (r) r.subagentCalls++
          }
        }
      }
      handleUser(
        e,
        info,
        { project, overall, subagent },
        v => {
          currentSkill = v
        },
        pk => {
          currentPrompt = pk
        },
      )
      continue
    }

    if (e.type === 'assistant') {
      const msg = e.message || {}
      const usage = msg.usage
      // detect Skill / Agent tool calls in content
      if (Array.isArray(msg.content)) {
        for (const c of msg.content) {
          if (c && c.type === 'tool_use') {
            if (c.name === 'Skill' && c.input && c.input.skill) {
              const sk = String(c.input.skill)
              bumpSkill(overall, sk)
              bumpSkill(project, sk)
              if (subagent) bumpSkill(subagent, sk)
              currentSkill = sk
            }
            if (c.name === 'Agent' || c.name === 'Task') {
              if (c.input && c.input.subagent_type) {
                toolUseIdToType.set(c.id, String(c.input.subagent_type))
              }
              if (currentPrompt) toolUseIdToPrompt.set(c.id, currentPrompt)
            }
          }
        }
      }
      if (!usage) continue
      const key =
        e.requestId ||
        (msg.id && msg.id.startsWith('msg_0') && msg.id.length > 10
          ? msg.id
          : null) ||
        `${p}:${e.uuid || ''}`
      const prev = fileApiCalls.get(key)
      if (
        !prev ||
        (usage.output_tokens || 0) >= (prev.usage.output_tokens || 0)
      ) {
        fileApiCalls.set(key, {
          usage,
          ts: e.timestamp,
          skill: currentSkill,
          prompt: currentPrompt,
        })
      }
      continue
    }
  }

  // commit timestamps
  if (firstTs !== null && lastTs !== null) {
    const wall = lastTs - firstTs
    for (const s of [overall, project, subagent].filter(Boolean)) {
      s.wallClockMs += wall
      s.activeMs += activeMs
      if (!s.firstTs || firstTs < s.firstTs) s.firstTs = firstTs
      if (!s.lastTs || lastTs > s.lastTs) s.lastTs = lastTs
    }
  }

  // session span (for by_day timeline) — subagent files roll into parent sessionId
  let span = sessionSpans.get(info.sessionId)
  if (!span) {
    span = { project: info.project, firstTs: null, lastTs: null, tokens: 0 }
    sessionSpans.set(info.sessionId, span)
  }
  if (firstTs !== null) {
    if (span.firstTs === null || firstTs < span.firstTs) span.firstTs = firstTs
    if (span.lastTs === null || lastTs > span.lastTs) span.lastTs = lastTs
  }

  // commit API calls
  for (const [key, { usage, ts, skill, prompt }] of fileApiCalls) {
    if (key && seenRequestIds.has(key)) continue
    seenRequestIds.add(key)

    const tot =
      (usage.input_tokens || 0) +
      (usage.cache_creation_input_tokens || 0) +
      (usage.cache_read_input_tokens || 0) +
      (usage.output_tokens || 0)
    span.tokens += tot

    const targets = [overall, project]
    if (subagent) targets.push(subagent)
    if (skill && skillStats) {
      if (!skillStats.has(skill)) skillStats.set(skill, newStats())
      targets.push(skillStats.get(skill))
    }
    for (const s of targets) addUsage(s, usage)

    if (prompt) {
      const r = prompts.get(prompt)
      if (r) {
        r.apiCalls++
        r.inputUncached += usage.input_tokens || 0
        r.inputCacheCreate += usage.cache_creation_input_tokens || 0
        r.inputCacheRead += usage.cache_read_input_tokens || 0
        r.outputTokens += usage.output_tokens || 0
      }
    }

    // subagent token accounting on parent buckets
    if (info.kind === 'subagent') {
      overall.subagentTokens += tot
      project.subagentTokens += tot
      if (subagent) subagent.subagentTokens += tot
    }

    // cache break detection
    const uncached =
      (usage.input_tokens || 0) + (usage.cache_creation_input_tokens || 0)
    if (uncached > CACHE_BREAK_THRESHOLD) {
      const total = uncached + (usage.cache_read_input_tokens || 0)
      const cb = {
        ts,
        session: info.sessionId,
        project: info.project,
        uncached,
        total,
        kind: info.kind,
        agentType: info.agentType,
        prompt,
      }
      overall.cacheBreaks.push(cb)
      project.cacheBreaks.push(cb)
      if (subagent) subagent.cacheBreaks.push(cb)
    }
  }

  // only count this file toward session/subagent tallies if it had in-range entries
  if (firstTs !== null || fileApiCalls.size > 0) {
    for (const s of [overall, project, subagent].filter(Boolean)) {
      s.sessions.add(info.sessionId)
    }
    if (info.kind === 'subagent') {
      overall.subagentCalls++
      project.subagentCalls++
      if (subagent) subagent.subagentCalls++
    }
  }
}

function handleUser(
  e,
  info,
  { project, overall, subagent },
  setSkill,
  setPrompt,
) {
  if (e.isMeta || e.isCompactSummary) return
  const content = e.message && e.message.content
  let isToolResult = false
  let text = null
  if (typeof content === 'string') {
    text = content
  } else if (Array.isArray(content)) {
    const first = content[0]
    if (first && first.type === 'tool_result') isToolResult = true
    else if (first && first.type === 'text') text = first.text || ''
  }
  if (isToolResult) return

  let slashCmd = null
  if (text) {
    // Auto-continuations (task notifications, scheduled wakeups) are not new
    // human prompts; keep attributing to the previously active prompt.
    if (
      text.startsWith('<task-notification') ||
      text.startsWith('<scheduled-wakeup') ||
      text.startsWith('<background-task')
    ) {
      return
    }
    const m = /<command-(?:name|message)>\/?([^<]+)<\/command-/.exec(text)
    if (m) {
      slashCmd = m[1].trim()
      bumpSkill(overall, slashCmd)
      bumpSkill(project, slashCmd)
      if (subagent) bumpSkill(subagent, slashCmd)
      setSkill(slashCmd)
    } else {
      setSkill(null) // plain human message resets skill attribution
    }
    if (text.startsWith('[Request interrupted')) return
  }

  // Only count as human message / start a prompt in main (non-sidechain) transcripts
  if (info.kind === 'main' && !e.isSidechain) {
    overall.humanMessages++
    project.humanMessages++
    const pk = e.uuid || `${info.sessionId}:${e.timestamp}`
    promptRecord(pk, {
      text: promptPreview(text, slashCmd),
      ts: e.timestamp,
      project: info.project,
      sessionId: info.sessionId,
    })
    setPrompt(pk)
    let turns = sessionTurns.get(info.sessionId)
    if (!turns) sessionTurns.set(info.sessionId, (turns = []))
    turns.push(pk)
  }
}

function promptPreview(text, slashCmd) {
  if (slashCmd) return `/${slashCmd}`
  if (!text) return '(non-text)'
  const t = text
    .replace(/<[^>]+>/g, ' ')
    .replace(/\s+/g, ' ')
    .trim()
  return t.length > 240 ? t.slice(0, 237) + '…' : t
}

// ±2 user messages around a given prompt, with the api-call count that
// followed each one. Used for drill-down in the HTML report.
function buildContext(pk) {
  const r = prompts.get(pk)
  if (!r) return null
  const turns = sessionTurns.get(r.sessionId)
  if (!turns) return null
  const i = turns.indexOf(pk)
  if (i === -1) return null
  const lo = Math.max(0, i - 2)
  const hi = Math.min(turns.length, i + 3)
  return turns.slice(lo, hi).map((k, j) => {
    const t = prompts.get(k) || {}
    return {
      text: t.text || '',
      ts: t.ts || null,
      calls: t.apiCalls || 0,
      here: lo + j === i,
    }
  })
}

function bumpSkill(s, name) {
  s.skillInvocations[name] = (s.skillInvocations[name] || 0) + 1
}

const _btCache = new Map()
function birthtime(p) {
  let t = _btCache.get(p)
  if (t === undefined) {
    try {
      t = fs.statSync(p).birthtimeMs
    } catch {
      t = 0
    }
    _btCache.set(p, t)
  }
  return t
}

// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
async function main() {
  const overall = newStats()
  const perProject = new Map() // project -> stats
  const perSubagent = new Map() // agentType -> stats
  const perSkill = new Map() // skill -> stats (token-attributed)

  // Classify, then sort main files before subagent files. Fork-style subagents
  // replay parent entries with identical uuids; processing parents first ensures
  // the global uuid-dedupe attributes those entries to the parent, not the fork.
  // Among subagents, sort by birthtime so a parent subagent is processed before
  // any nested children it spawned (needed for prompt-attribution propagation).
  const files = [...walk(ROOT)]
    .map(p => ({ p, info: classifyFile(p) }))
    .sort((a, b) => {
      const ka = a.info.kind === 'main' ? 0 : 1
      const kb = b.info.kind === 'main' ? 0 : 1
      if (ka !== kb) return ka - kb
      if (ka === 1) return birthtime(a.p) - birthtime(b.p)
      return 0
    })
  let n = 0
  for (const { p, info } of files) {
    if (!perProject.has(info.project)) perProject.set(info.project, newStats())
    const project = perProject.get(info.project)

    let subagent = null
    if (info.kind === 'subagent') {
      // Resolve agent type: meta.json/filename hint > parent-transcript map > 'fork'
      const at =
        info.agentTypeHint ||
        (info.agentId && agentIdToType.get(info.agentId)) ||
        'fork'
      info.agentType = at
      if (!perSubagent.has(at)) perSubagent.set(at, newStats())
      subagent = perSubagent.get(at)
    }

    await processFile(p, info, {
      overall,
      project,
      subagent,
      skillStats: perSkill,
    })
    n++
    if (!AS_JSON && n % 200 === 0) {
      process.stderr.write(`\r  scanned ${n}/${files.length} files…`)
    }
  }
  if (!AS_JSON)
    process.stderr.write(`\r  scanned ${n}/${files.length} files.\n`)

  // Drop empty buckets (created for files that had no in-range entries under --since)
  for (const m of [perProject, perSubagent, perSkill]) {
    for (const [k, v] of m) {
      if (v.apiCalls === 0 && v.sessions.size === 0) m.delete(k)
    }
  }

  if (AS_JSON) {
    printJson({ overall, perProject, perSubagent, perSkill })
  } else {
    printText({ overall, perProject, perSubagent, perSkill })
  }
}

// ---------------------------------------------------------------------------
// Output
// ---------------------------------------------------------------------------
function fmt(n) {
  if (n >= 1e9) return (n / 1e9).toFixed(2) + 'B'
  if (n >= 1e6) return (n / 1e6).toFixed(2) + 'M'
  if (n >= 1e3) return (n / 1e3).toFixed(1) + 'k'
  return String(n)
}
function pct(a, b) {
  return b > 0 ? ((100 * a) / b).toFixed(1) + '%' : '—'
}
function hrs(ms) {
  return (ms / 3600000).toFixed(1)
}

function summarize(s) {
  const inTotal = s.inputUncached + s.inputCacheCreate + s.inputCacheRead
  return {
    sessions: s.sessions.size,
    api_calls: s.apiCalls,
    input_tokens: {
      uncached: s.inputUncached,
      cache_create: s.inputCacheCreate,
      cache_read: s.inputCacheRead,
      total: inTotal,
      pct_cached:
        inTotal > 0 ? +((100 * s.inputCacheRead) / inTotal).toFixed(1) : 0,
    },
    output_tokens: s.outputTokens,
    human_messages: s.humanMessages,
    hours: { wall_clock: +hrs(s.wallClockMs), active: +hrs(s.activeMs) },
    cache_breaks_over_100k: s.cacheBreaks.length,
    subagent: {
      calls: s.subagentCalls,
      total_tokens: s.subagentTokens,
      avg_tokens_per_call:
        s.subagentCalls > 0
          ? Math.round(s.subagentTokens / s.subagentCalls)
          : 0,
    },
    skill_invocations: s.skillInvocations,
    span: s.firstTs
      ? {
          from: new Date(s.firstTs).toISOString(),
          to: new Date(s.lastTs).toISOString(),
        }
      : null,
  }
}

function printJson({ overall, perProject, perSubagent, perSkill }) {
  const out = {
    root: ROOT,
    generated_at: new Date().toISOString(),
    overall: summarize(overall),
    cache_breaks: overall.cacheBreaks
      .sort((a, b) => b.uncached - a.uncached)
      .slice(0, 100)
      .map(({ prompt, ...b }) => ({
        ...b,
        context: prompt ? buildContext(prompt) : null,
      })),
    by_project: Object.fromEntries(
      [...perProject].map(([k, v]) => [k, summarize(v)]),
    ),
    by_subagent_type: Object.fromEntries(
      [...perSubagent].map(([k, v]) => [k, summarize(v)]),
    ),
    by_skill: Object.fromEntries(
      [...perSkill].map(([k, v]) => [k, summarize(v)]),
    ),
    top_prompts: topPrompts(100),
    by_day: buildByDay(),
  }
  process.stdout.write(JSON.stringify(out, null, 2) + '\n')
}

// Group sessions into local-date buckets for the timeline view. A session is
// placed on the day its first message landed; tokens for that session (incl.
// subagents) count toward that day even if it ran past midnight.
function buildByDay() {
  const DOW = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
  const days = new Map() // yyyy-mm-dd -> {date, dow, tokens, sessions:[]}
  for (const [id, s] of sessionSpans) {
    if (s.firstTs === null || s.tokens === 0) continue
    const d0 = new Date(s.firstTs)
    const key = `${d0.getFullYear()}-${String(d0.getMonth() + 1).padStart(2, '0')}-${String(d0.getDate()).padStart(2, '0')}`
    let day = days.get(key)
    if (!day) {
      day = { date: key, dow: DOW[d0.getDay()], tokens: 0, sessions: [] }
      days.set(key, day)
    }
    const base = new Date(
      d0.getFullYear(),
      d0.getMonth(),
      d0.getDate(),
    ).getTime()
    day.tokens += s.tokens
    day.sessions.push({
      id,
      project: s.project,
      tokens: s.tokens,
      start_min: Math.max(0, Math.round((s.firstTs - base) / 60000)),
      end_min: Math.max(1, Math.round((s.lastTs - base) / 60000)),
    })
  }
  for (const d of days.values()) {
    // peak concurrency via 10-min buckets, capped at 24h for display
    const b = new Array(144).fill(0)
    for (const s of d.sessions) {
      const lo = Math.min(143, Math.floor(s.start_min / 10))
      const hi = Math.min(144, Math.ceil(Math.min(s.end_min, 1440) / 10))
      for (let i = lo; i < hi; i++) b[i]++
    }
    d.peak = Math.max(0, ...b)
    d.peak_at_min = d.peak > 0 ? b.indexOf(d.peak) * 10 : 0
    d.sessions.sort((a, b) => a.start_min - b.start_min)
  }
  return [...days.values()].sort((a, b) => a.date.localeCompare(b.date))
}

function promptTotal(r) {
  return (
    r.inputUncached + r.inputCacheCreate + r.inputCacheRead + r.outputTokens
  )
}

function topPrompts(n) {
  return [...prompts.entries()]
    .filter(([, r]) => r.apiCalls > 0)
    .sort((a, b) => promptTotal(b[1]) - promptTotal(a[1]))
    .slice(0, n)
    .map(([pk, r]) => ({
      ts: r.ts,
      project: r.project,
      session: r.sessionId,
      text: r.text,
      api_calls: r.apiCalls,
      subagent_calls: r.subagentCalls,
      total_tokens: promptTotal(r),
      input: {
        uncached: r.inputUncached,
        cache_create: r.inputCacheCreate,
        cache_read: r.inputCacheRead,
      },
      output: r.outputTokens,
      context: buildContext(pk),
    }))
}

function printText({ overall, perProject, perSubagent, perSkill }) {
  const line = (...a) => console.log(...a)
  const hr = () => line('─'.repeat(78))

  line()
  line(`Claude Code session analysis — ${ROOT}`)
  if (SINCE) line(`(since ${SINCE.toISOString()})`)
  hr()
  printBlock('OVERALL', overall)

  hr()
  line(
    `CACHE BREAKS (>${fmt(CACHE_BREAK_THRESHOLD)} uncached input on a single call)`,
  )
  const breaks = overall.cacheBreaks
    .sort((a, b) => b.uncached - a.uncached)
    .slice(0, TOP_N)
  if (breaks.length === 0) line('  none')
  for (const b of breaks) {
    line(
      `  ${fmt(b.uncached).padStart(8)} uncached / ${fmt(b.total).padStart(8)} total  ` +
        `${(b.ts || '').slice(0, 19)}  ${b.project}` +
        (b.kind === 'subagent' ? `  [${b.agentType}]` : ''),
    )
  }
  if (overall.cacheBreaks.length > TOP_N)
    line(`  … ${overall.cacheBreaks.length - TOP_N} more`)

  hr()
  line(
    'MOST EXPENSIVE PROMPTS (total tokens incl. subagents spawned during the turn)',
  )
  const top = topPrompts(TOP_N)
  if (top.length === 0) line('  none')
  for (const r of top) {
    const inTot = r.input.uncached + r.input.cache_create + r.input.cache_read
    line(
      `  ${fmt(r.total_tokens).padStart(8)}  ` +
        `(in ${fmt(inTot)} ${pct(r.input.cache_read, inTot)} cached, out ${fmt(r.output)})  ` +
        `${r.api_calls} calls` +
        (r.subagent_calls ? `, ${r.subagent_calls} subagents` : '') +
        `  ${(r.ts || '').slice(0, 16)}  ${r.project}`,
    )
    line(`           "${r.text}"`)
  }
  line(
    '  (note: internal background forks like task_summary/compact are not attributed to a prompt)',
  )

  hr()
  line('BY PROJECT (top by total input tokens)')
  const projects = [...perProject.entries()].sort(
    (a, b) => totalIn(b[1]) - totalIn(a[1]),
  )
  for (const [name, s] of projects.slice(0, TOP_N)) {
    printBlock(name, s, '  ')
    line()
  }
  if (projects.length > TOP_N)
    line(`  … ${projects.length - TOP_N} more projects`)

  hr()
  line('BY SUBAGENT TYPE')
  const agents = [...perSubagent.entries()].sort(
    (a, b) => totalIn(b[1]) - totalIn(a[1]),
  )
  for (const [name, s] of agents) {
    printBlock(name, s, '  ')
    line()
  }

  hr()
  line(
    'BY SKILL / SLASH COMMAND (tokens attributed = from invocation until next human msg)',
  )
  const skills = [...perSkill.entries()].sort(
    (a, b) => totalIn(b[1]) - totalIn(a[1]),
  )
  for (const [name, s] of skills.slice(0, TOP_N)) {
    printBlock(name, s, '  ')
    line()
  }
  if (skills.length > TOP_N) line(`  … ${skills.length - TOP_N} more`)
  line()
}

function totalIn(s) {
  return s.inputUncached + s.inputCacheCreate + s.inputCacheRead
}

function printBlock(title, s, indent = '') {
  const inTotal = totalIn(s)
  console.log(`${indent}${title}`)
  console.log(
    `${indent}  sessions: ${s.sessions.size}   api calls: ${s.apiCalls}   human msgs: ${s.humanMessages}`,
  )
  console.log(
    `${indent}  input:  ${fmt(inTotal)} total  ` +
      `(uncached ${fmt(s.inputUncached)}, cache-create ${fmt(s.inputCacheCreate)}, cache-read ${fmt(s.inputCacheRead)} = ${pct(s.inputCacheRead, inTotal)} cached)`,
  )
  console.log(`${indent}  output: ${fmt(s.outputTokens)}`)
  console.log(
    `${indent}  hours:  ${hrs(s.wallClockMs)} wall-clock, ${hrs(s.activeMs)} active (gaps >5m excluded)`,
  )
  console.log(
    `${indent}  cache breaks >${fmt(CACHE_BREAK_THRESHOLD)}: ${s.cacheBreaks.length}`,
  )
  console.log(
    `${indent}  subagents: ${s.subagentCalls} calls, ${fmt(s.subagentTokens)} tokens, avg ${fmt(
      s.subagentCalls ? Math.round(s.subagentTokens / s.subagentCalls) : 0,
    )}/call`,
  )
  const topSkills = Object.entries(s.skillInvocations)
    .sort((a, b) => b[1] - a[1])
    .slice(0, 5)
  if (topSkills.length)
    console.log(
      `${indent}  skills: ${topSkills.map(([k, v]) => `${k}×${v}`).join(', ')}`,
    )
}

main().catch(e => {
  console.error(e)
  process.exit(1)
})

Requirements and compatibility

Written for: Claude Code, Claude apps

Works with: Claude Code, Claude apps, OpenAI Codex, GitHub Copilot, Cursor, Gemini CLI, Kiro, OpenCode, Goose, Amp, Roo Code, JetBrains Junie, Factory Droid, Devin (ex-Windsurf), Mistral Vibe, OpenHands, Letta

“Works with” means the tool reads the open Agent Skills format; a skill written for one tool may use features others lack.

Needs: Needs Node.js

Security

What our scanner noticed· Low risk

  • Scripts that your agent may run

This skill includes scripts that your agent may run. Read them before you use it.

A static scan is not a guarantee. Skills are third-party content: review a skill before you let an agent use it.

Attribution and licence

Published by
Anthropic
Publisher
Anthropic (Official)
Source
anthropics/claude-plugins-official · plugins/session-report/skills/session-report · commit 0412be7
Licence
Apache License 2.0 (redistribution allowed)
  • LICENSE (added by AutoGineer from plugins/session-report/LICENSE)
  • ATTRIBUTION.md (written by AutoGineer)
Synced
Also distributed in
cline/skills

AutoGineer is not affiliated with or endorsed by Anthropic.

Download ZIP

The ZIP holds the skill’s files, its licence files and an ATTRIBUTION.md. Download session-report.zip