claude-code-ultimate-guide/examples/hooks/bash/session-summary.sh
Florian BRUNIAUX d1182af4cf docs: v3.27.1 — fact-check corrections, grepai docs, RTK overhaul
Fact-check (README positioning):
- Template count: 120/123 → 108 (ground truth recount)
- Ratio: 14× → 24× (19,000 ÷ 784 = 24.2×)
- everything-cc stars: 31.9k → 45k+ (verified Feb 15)
- Commands count: 20 → 23, hooks: 30 → 31

Added:
- Grepai MCP documentation (semantic search, call graphs)
- 3 hook templates (rtk-baseline, session-summary, session-summary-config)
- 2 resource evaluations (system-prompts update, qmd token savings)

Changed:
- RTK documentation overhaul (v0.7.0 → v0.16.0, rtk-ai org)
- Exports deprecated (kimi.pdf, notebooklm.pdf → deprecated/)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 18:41:45 +01:00

1335 lines
51 KiB
Bash
Executable file

#!/bin/bash
# .claude/hooks/session-summary.sh
# Event: SessionEnd
# Auto-display comprehensive session summary when Claude Code session ends
# Inspired by Gemini CLI session summary feature
#
# v3 - Full Analytics + CLI Config
#
# Displays (configurable sections):
# - Session ID, name, git branch
# - Duration (wall time, active time, turns count, exit reason)
# - Tool calls breakdown with success/error rates
# - Error details (tool name + truncated message)
# - Files touched (read/edited/created with top edited files)
# - Features used (MCP servers, agents, skills, teams, plan mode)
# - Git diff summary (+/- lines, files changed)
# - Lines of code written (via Edit/Write)
# - Model usage (requests, tokens, cache hit rate)
# - Estimated cost (via ccusage or pricing table fallback)
# - RTK token savings (if RTK installed, delta from session start)
# - Conversation ratio (interactive vs auto turns, avg time/turn)
# - Thinking blocks count (off by default)
# - Context window estimate (off by default)
#
# Requirements:
# - jq (required for JSON parsing)
# - ccusage (optional, for accurate cost calculation)
# - rtk (optional, for token savings tracking)
#
# Configuration priority: env vars > config file > defaults
# Config file: ~/.config/session-summary/config.sh
# CLI tool: session-summary-config.sh (show, set, reset, sections, preview, install, log)
#
# Environment variables (SESSION_SUMMARY_*):
# SKIP=1 - Disable summary entirely
# LOG=<path> - Override log directory (default: ~/.claude/logs)
# FILES=0|1 - Files section (default: 1)
# RTK=auto|1|0 - RTK savings (default: auto-detect)
# GIT=0|1 - Git diff summary (default: 1)
# ERRORS=0|1 - Error details (default: 1)
# LOC=0|1 - Lines of code (default: 1)
# RATIO=0|1 - Conversation ratio (default: 1)
# FEATURES=0|1 - Features used (default: 1)
# THINKING=0|1 - Thinking blocks (default: 0)
# CONTEXT=0|1 - Context estimate (default: 0)
# SECTIONS=<csv> - Section order (comma-separated)
#
# Place in: .claude/hooks/session-summary.sh
# Register in: .claude/settings.json under SessionEnd event
# NOTE: Do NOT use Stop event - it fires after every assistant turn, not just at exit
set -euo pipefail
export LC_NUMERIC=C # Ensure consistent decimal separator (bc outputs '.' not ',')
# ═══════════════════════════════════════════════════════════════════════════
# Configuration (priority: env vars > config file > defaults)
# ═══════════════════════════════════════════════════════════════════════════
CONFIG_FILE="${HOME}/.config/session-summary/config.sh"
# Defaults
_DEFAULT_LOG_DIR="$HOME/.claude/logs"
_DEFAULT_SKIP=0
_DEFAULT_FILES=1
_DEFAULT_RTK=auto
_DEFAULT_GIT=1
_DEFAULT_ERRORS=1
_DEFAULT_LOC=1
_DEFAULT_RATIO=1
_DEFAULT_FEATURES=1
_DEFAULT_THINKING=0
_DEFAULT_CONTEXT=0
_DEFAULT_SECTIONS="meta,duration,tools,errors,files,features,git,loc,models,cache,cost,rtk,ratio,thinking,context"
# Load config file (if exists), then overlay env vars
load_config() {
# Start with defaults
LOG_DIR="$_DEFAULT_LOG_DIR"
SKIP="$_DEFAULT_SKIP"
FILES_ENABLED="$_DEFAULT_FILES"
RTK_ENABLED="$_DEFAULT_RTK"
GIT_ENABLED="$_DEFAULT_GIT"
ERRORS_ENABLED="$_DEFAULT_ERRORS"
LOC_ENABLED="$_DEFAULT_LOC"
RATIO_ENABLED="$_DEFAULT_RATIO"
FEATURES_ENABLED="$_DEFAULT_FEATURES"
THINKING_ENABLED="$_DEFAULT_THINKING"
CONTEXT_ENABLED="$_DEFAULT_CONTEXT"
SECTION_ORDER="$_DEFAULT_SECTIONS"
# Layer 2: config file
if [[ -f "$CONFIG_FILE" ]]; then
local val
val=$(bash -c "source '$CONFIG_FILE' 2>/dev/null && echo \"\${LOG_DIR:-}|\${SKIP:-}|\${FILES:-}|\${RTK:-}|\${GIT:-}|\${ERRORS:-}|\${LOC:-}|\${RATIO:-}|\${FEATURES:-}|\${THINKING:-}|\${CONTEXT:-}|\${SECTIONS:-}\"")
IFS='|' read -r _cf_log _cf_skip _cf_files _cf_rtk _cf_git _cf_errors _cf_loc _cf_ratio _cf_features _cf_thinking _cf_context _cf_sections <<< "$val"
[[ -n "$_cf_log" ]] && LOG_DIR="$_cf_log"
[[ -n "$_cf_skip" ]] && SKIP="$_cf_skip"
[[ -n "$_cf_files" ]] && FILES_ENABLED="$_cf_files"
[[ -n "$_cf_rtk" ]] && RTK_ENABLED="$_cf_rtk"
[[ -n "$_cf_git" ]] && GIT_ENABLED="$_cf_git"
[[ -n "$_cf_errors" ]] && ERRORS_ENABLED="$_cf_errors"
[[ -n "$_cf_loc" ]] && LOC_ENABLED="$_cf_loc"
[[ -n "$_cf_ratio" ]] && RATIO_ENABLED="$_cf_ratio"
[[ -n "$_cf_features" ]] && FEATURES_ENABLED="$_cf_features"
[[ -n "$_cf_thinking" ]] && THINKING_ENABLED="$_cf_thinking"
[[ -n "$_cf_context" ]] && CONTEXT_ENABLED="$_cf_context"
[[ -n "$_cf_sections" ]] && SECTION_ORDER="$_cf_sections"
fi
# Layer 3: env vars (highest priority)
[[ -n "${SESSION_SUMMARY_LOG:-}" ]] && LOG_DIR="$SESSION_SUMMARY_LOG"
[[ -n "${SESSION_SUMMARY_SKIP:-}" ]] && SKIP="$SESSION_SUMMARY_SKIP"
[[ -n "${SESSION_SUMMARY_FILES:-}" ]] && FILES_ENABLED="$SESSION_SUMMARY_FILES"
[[ -n "${SESSION_SUMMARY_RTK:-}" ]] && RTK_ENABLED="$SESSION_SUMMARY_RTK"
[[ -n "${SESSION_SUMMARY_GIT:-}" ]] && GIT_ENABLED="$SESSION_SUMMARY_GIT"
[[ -n "${SESSION_SUMMARY_ERRORS:-}" ]] && ERRORS_ENABLED="$SESSION_SUMMARY_ERRORS"
[[ -n "${SESSION_SUMMARY_LOC:-}" ]] && LOC_ENABLED="$SESSION_SUMMARY_LOC"
[[ -n "${SESSION_SUMMARY_RATIO:-}" ]] && RATIO_ENABLED="$SESSION_SUMMARY_RATIO"
[[ -n "${SESSION_SUMMARY_FEATURES:-}" ]] && FEATURES_ENABLED="$SESSION_SUMMARY_FEATURES"
[[ -n "${SESSION_SUMMARY_THINKING:-}" ]] && THINKING_ENABLED="$SESSION_SUMMARY_THINKING"
[[ -n "${SESSION_SUMMARY_CONTEXT:-}" ]] && CONTEXT_ENABLED="$SESSION_SUMMARY_CONTEXT"
[[ -n "${SESSION_SUMMARY_SECTIONS:-}" ]] && SECTION_ORDER="$SESSION_SUMMARY_SECTIONS"
# Auto-detect RTK availability
if [[ "$RTK_ENABLED" == "auto" ]]; then
command -v rtk &>/dev/null && RTK_ENABLED=1 || RTK_ENABLED=0
fi
}
load_config
# Section enablement check
# Always-on: meta, duration, tools, models, cache, cost
# Configurable: files, git, errors, loc, rtk, ratio, features (default ON)
# Configurable: thinking, context (default OFF)
is_section_enabled() {
local section="$1"
case "$section" in
meta|duration|tools|models|cache|cost) return 0 ;; # always on
files) [[ "$FILES_ENABLED" == "1" ]] ;;
git) [[ "$GIT_ENABLED" == "1" ]] ;;
errors) [[ "$ERRORS_ENABLED" == "1" ]] ;;
loc) [[ "$LOC_ENABLED" == "1" ]] ;;
rtk) [[ "$RTK_ENABLED" == "1" ]] ;;
ratio) [[ "$RATIO_ENABLED" == "1" ]] ;;
features) [[ "$FEATURES_ENABLED" == "1" ]] ;;
thinking) [[ "$THINKING_ENABLED" == "1" ]] ;;
context) [[ "$CONTEXT_ENABLED" == "1" ]] ;;
*) return 1 ;;
esac
}
# Output target: /dev/tty bypasses Claude Code's stderr capture for lifecycle hooks
# Falls back to stderr when /dev/tty is unavailable (CI, cron, dry-run in pipes)
if (echo -n "" > /dev/tty) 2>/dev/null; then
OUTPUT_TARGET="/dev/tty"
else
OUTPUT_TARGET="/dev/stderr"
fi
# ANSI colors (respect NO_COLOR)
if [[ -z "${NO_COLOR:-}" ]]; then
BOLD=$'\033[1m'
DIM=$'\033[2m'
CYAN=$'\033[36m'
GREEN=$'\033[32m'
YELLOW=$'\033[33m'
RED=$'\033[31m'
RESET=$'\033[0m'
else
BOLD='' DIM='' CYAN='' GREEN='' YELLOW='' RED='' RESET=''
fi
# Pricing table (per million tokens, as of 2026-02)
# Used as fallback if ccusage is unavailable
get_pricing() {
local model="$1"
local type="$2" # input or output
case "$model" in
claude-opus-4-6)
[[ "$type" == "input" ]] && echo "15.00" || echo "75.00"
;;
claude-sonnet-4-5)
[[ "$type" == "input" ]] && echo "3.00" || echo "15.00"
;;
claude-haiku-4-5)
[[ "$type" == "input" ]] && echo "0.80" || echo "4.00"
;;
*)
echo "0"
;;
esac
}
# ═══════════════════════════════════════════════════════════════════════════
# Helper Functions
# ═══════════════════════════════════════════════════════════════════════════
check_dependencies() {
if ! command -v jq &> /dev/null; then
echo "Error: jq is required but not installed" >&2
echo "Install: brew install jq (macOS) or apt-get install jq (Linux)" >&2
exit 0 # Don't block session end
fi
}
locate_jsonl() {
local session_id="$1"
local cwd="$2"
# Encode project path: /Users/foo/bar -> -Users-foo-bar
local encoded_path
encoded_path=$(echo "$cwd" | tr '/' '-')
local jsonl_path="$HOME/.claude/projects/${encoded_path}/${session_id}.jsonl"
# Fallback: find if encoding wrong
if [[ ! -f "$jsonl_path" ]]; then
jsonl_path=$(find "$HOME/.claude/projects" -name "${session_id}.jsonl" -maxdepth 2 2>/dev/null | head -1)
fi
echo "$jsonl_path"
}
format_duration() {
local ms="$1"
local seconds=$((ms / 1000))
local minutes=$((seconds / 60))
local hours=$((minutes / 60))
if [[ $hours -gt 0 ]]; then
printf "%dh %dm" "$hours" "$((minutes % 60))"
elif [[ $minutes -gt 0 ]]; then
printf "%dm %ds" "$minutes" "$((seconds % 60))"
else
printf "%ds" "$seconds"
fi
}
format_number() {
local num="$1"
if [[ $num -ge 1000000 ]]; then
printf "%.1fM" "$(bc <<< "scale=1; $num / 1000000")"
elif [[ $num -ge 1000 ]]; then
printf "%.1fK" "$(bc <<< "scale=1; $num / 1000")"
else
printf "%d" "$num"
fi
}
shorten_model_name() {
local model="$1"
# claude-sonnet-4-5-20250929 -> claude-sonnet-4-5
echo "$model" | sed -E 's/-(20[0-9]{6})$//'
}
# Parse a numeric value with K/M/B suffix from rtk gain text output
parse_rtk_number() {
local text="$1"
local label="$2"
echo "$text" | awk -v label="$label" '
$0 ~ label {
for (i=1; i<=NF; i++) {
if ($i ~ /^[0-9]/) {
val = $i
gsub(/,/, "", val)
if (val ~ /[Bb]$/) { sub(/[Bb]$/, "", val); printf "%.0f", val * 1000000000 }
else if (val ~ /[Mm]$/) { sub(/[Mm]$/, "", val); printf "%.0f", val * 1000000 }
else if (val ~ /[Kk]$/) { sub(/[Kk]$/, "", val); printf "%.0f", val * 1000 }
else { printf "%.0f", val }
exit
}
}
}
'
}
# Diff "By Command" tables from two rtk gain outputs, return commands with positive delta
# Output: "git status(2), ls(3), git diff(1)"
diff_rtk_commands() {
local baseline="$1"
local current="$2"
{ echo "___BASELINE___"; echo "$baseline"; echo "___CURRENT___"; echo "$current"; echo "___END___"; } | awk '
/^___BASELINE___$/ { section="baseline"; next }
/^___CURRENT___$/ { section="current"; in_table=0; next }
/^___END___$/ {
for (cmd in cur) {
delta = cur[cmd] - (base[cmd] + 0)
if (delta > 0) {
short = cmd
sub(/^rtk /, "", short)
# Truncate long commands
if (length(short) > 20) short = substr(short, 1, 17) "..."
if (length(out) > 0) out = out ", "
out = out short "(" delta ")"
}
}
print out
exit
}
/^By Command:/ { in_table=1; next }
/^─/ { next }
/^Command/ { next }
in_table && /^$/ { in_table=0; next }
in_table && NF >= 5 {
count = $(NF-3)
if (count ~ /^[0-9,]+$/) {
gsub(/,/, "", count)
cmd = ""
for (i=1; i<=NF-4; i++) cmd = cmd (i>1?" ":"") $i
if (section == "baseline") base[cmd] = count + 0
else cur[cmd] = count + 0
}
}
'
}
# Calculate RTK token savings for this session (delta between start and end)
calculate_rtk_savings() {
# Build baseline file path (must match rtk-baseline.sh)
local baseline_key
baseline_key=$(echo "${CLAUDE_PROJECT_DIR:-$(pwd)}" | tr '/' '-')
local baseline_file="/tmp/rtk-baseline${baseline_key}.txt"
# No baseline = no delta possible
if [[ ! -f "$baseline_file" ]]; then
echo ""
return
fi
local baseline_text
baseline_text=$(<"$baseline_file")
local current_text
current_text=$(rtk gain 2>/dev/null) || { echo ""; return; }
# Parse total commands from both snapshots
local start_cmds end_cmds
start_cmds=$(parse_rtk_number "$baseline_text" "Total commands")
end_cmds=$(parse_rtk_number "$current_text" "Total commands")
local delta_cmds=$(( ${end_cmds:-0} - ${start_cmds:-0} ))
# No commands rewritten this session
if [[ $delta_cmds -le 0 ]]; then
rm -f "$baseline_file"
echo ""
return
fi
# Parse all token lines upfront (needed by all 3 approaches + pct calculation)
local start_saved end_saved start_input end_input start_output end_output
start_saved=$(parse_rtk_number "$baseline_text" "Tokens saved")
end_saved=$(parse_rtk_number "$current_text" "Tokens saved")
start_input=$(parse_rtk_number "$baseline_text" "Input tokens")
end_input=$(parse_rtk_number "$current_text" "Input tokens")
start_output=$(parse_rtk_number "$baseline_text" "Output tokens")
end_output=$(parse_rtk_number "$current_text" "Output tokens")
# Token savings: 3 approaches (M/K rounding loses precision for small sessions)
local delta_saved=0 estimated=0
# Approach 1: Direct delta from "Tokens saved" line
delta_saved=$(( ${end_saved:-0} - ${start_saved:-0} ))
# Approach 2: If 0 due to rounding, try (Input - Output) delta
if [[ $delta_saved -le 0 ]]; then
delta_saved=$(( (${end_input:-0} - ${start_input:-0}) - (${end_output:-0} - ${start_output:-0}) ))
fi
# Approach 3: Estimate from global average per command
if [[ $delta_saved -le 0 && ${end_cmds:-0} -gt 0 ]]; then
estimated=1
delta_saved=$(bc <<< "scale=0; ${end_saved:-0} / ${end_cmds:-1} * $delta_cmds")
fi
# Calculate percentage
local pct="0"
local delta_input=$(( ${end_input:-0} - ${start_input:-0} ))
if [[ $estimated -eq 1 ]]; then
# Use global average percentage
if [[ ${end_input:-0} -gt 0 ]]; then
pct=$(bc <<< "scale=0; ${end_saved:-0} * 100 / ${end_input:-1}")
fi
elif [[ ${delta_input:-0} -gt 0 ]]; then
pct=$(bc <<< "scale=0; $delta_saved * 100 / $delta_input")
fi
# Parse per-command deltas from "By Command" table
local cmds_detail
cmds_detail=$(diff_rtk_commands "$baseline_text" "$current_text")
# Clean up baseline file
rm -f "$baseline_file"
# Return JSON
jq -cn \
--argjson cmds "$delta_cmds" \
--argjson tokens_saved "${delta_saved:-0}" \
--arg pct "$pct" \
--argjson estimated "$estimated" \
--arg commands "${cmds_detail:-}" \
'{cmds: $cmds, tokens_saved: $tokens_saved, pct: $pct, estimated: ($estimated == 1), commands: $commands}'
}
# Collect git diff stats (files changed, insertions, deletions)
collect_git_diff() {
local cwd="$1"
if ! command -v git &>/dev/null; then
echo "{}"
return
fi
local stat_line
stat_line=$(git -C "$cwd" diff --stat HEAD 2>/dev/null | tail -1) || { echo "{}"; return; }
if [[ -z "$stat_line" ]]; then
echo "{}"
return
fi
# Parse: " 4 files changed, 142 insertions(+), 37 deletions(-)"
local files_changed=0 insertions=0 deletions=0
files_changed=$(echo "$stat_line" | grep -oE '[0-9]+ files? changed' | grep -oE '[0-9]+' || echo "0")
insertions=$(echo "$stat_line" | grep -oE '[0-9]+ insertion' | grep -oE '[0-9]+' || echo "0")
deletions=$(echo "$stat_line" | grep -oE '[0-9]+ deletion' | grep -oE '[0-9]+' || echo "0")
jq -cn \
--argjson files "${files_changed:-0}" \
--argjson ins "${insertions:-0}" \
--argjson del "${deletions:-0}" \
'{files_changed: $files, insertions: $ins, deletions: $del}'
}
extract_session_data() {
local jsonl_path="$1"
local loc_enabled="${2:-1}"
local features_enabled="${3:-1}"
local errors_enabled="${4:-1}"
local thinking_enabled="${5:-0}"
local context_enabled="${6:-0}"
if [[ ! -f "$jsonl_path" ]]; then
echo "{}"
return
fi
# Single-pass jq extraction using reduce inputs (streaming, memory-efficient)
# Pass feature flags to skip expensive operations when disabled
jq -n --arg jsonl_path "$jsonl_path" \
--argjson loc_on "$loc_enabled" \
--argjson feat_on "$features_enabled" \
--argjson err_on "$errors_enabled" \
--argjson think_on "$thinking_enabled" \
--argjson ctx_on "$context_enabled" '
reduce (inputs | select(. != null and . != "")) as $line (
{
models: {},
tools: {},
tool_errors: 0,
turns: 0,
turn_ms: 0,
first_ts: null,
last_ts: null,
api_requests: 0,
git_branch: null,
files_read: {},
files_edited: {},
files_created: {},
# v3 new fields
pending_tools: {},
error_details: [],
loc_added: 0,
loc_removed: 0,
user_prompts: 0,
thinking_blocks: 0,
peak_input: 0,
mcp_servers: {},
agents: {},
skills: [],
has_teams: false,
has_plan_mode: false
};
if $line.type == "assistant" and $line.message.usage != null then
.api_requests += 1 |
.models[$line.message.model] = {
requests: ((.models[$line.message.model].requests // 0) + 1),
input: ((.models[$line.message.model].input // 0) + $line.message.usage.input_tokens),
output: ((.models[$line.message.model].output // 0) + $line.message.usage.output_tokens),
cache_read: ((.models[$line.message.model].cache_read // 0) + ($line.message.usage.cache_read_input_tokens // 0)),
cache_create: ((.models[$line.message.model].cache_create // 0) + ($line.message.usage.cache_creation_input_tokens // 0))
} |
# Context window tracking (peak input tokens)
if $ctx_on == 1 then
(($line.message.usage.input_tokens + ($line.message.usage.cache_read_input_tokens // 0)) as $ctx |
if $ctx > .peak_input then .peak_input = $ctx else . end)
else . end |
# Thinking blocks count
if $think_on == 1 then
.thinking_blocks += ([($line.message.content[]? | select(.type == "thinking"))] | length)
else . end |
# Extract tool_use blocks: count tools, track files, features, errors, LOC
(reduce ($line.message.content[]? | select(.type == "tool_use")) as $block (.;
.tools[$block.name] = ((.tools[$block.name] // 0) + 1) |
# Track pending tools for error mapping
(if $err_on == 1 and ($block.id // null) != null then
.pending_tools[$block.id] = $block.name
else . end) |
# File tracking
if ($block.name == "Read") and (($block.input.file_path // null) != null) then
.files_read[$block.input.file_path] = true
elif ($block.name == "Edit" or $block.name == "MultiEdit") and (($block.input.file_path // null) != null) then
.files_edited[$block.input.file_path] = ((.files_edited[$block.input.file_path] // 0) + 1) |
# LOC tracking for Edit
if $loc_on == 1 and $block.name == "Edit" then
.loc_removed += (($block.input.old_string // "") | split("\n") | length) |
.loc_added += (($block.input.new_string // "") | split("\n") | length)
else . end
elif ($block.name == "Write") and (($block.input.file_path // null) != null) then
.files_created[$block.input.file_path] = true |
# LOC tracking for Write
if $loc_on == 1 then
.loc_added += (($block.input.content // "") | split("\n") | length)
else . end
else . end |
# Features detection
if $feat_on == 1 then
if ($block.name | startswith("mcp__")) then
(($block.name | split("__") | .[1]) // "unknown") as $server |
.mcp_servers[$server] = ((.mcp_servers[$server] // 0) + 1)
elif $block.name == "Task" then
(($block.input.subagent_type // "unknown") | tostring) as $atype |
.agents[$atype] = ((.agents[$atype] // 0) + 1)
elif $block.name == "Skill" then
.skills += [(($block.input.skill // "unknown") | tostring)]
elif $block.name == "TeamCreate" then
.has_teams = true
elif $block.name == "EnterPlanMode" then
.has_plan_mode = true
else . end
else . end
)) |
# Extract git branch from JSONL entries (fallback for session meta)
(if .git_branch == null and $line.gitBranch != null then .git_branch = $line.gitBranch else . end)
elif $line.type == "user" then
# Count tool errors + map to tool names
(reduce ($line.message.content[]? | select(.type == "tool_result" and .is_error == true)) as $err (.;
.tool_errors += 1 |
if $err_on == 1 then
((.pending_tools[$err.tool_use_id] // "unknown") as $tool_name |
(($err.content // ($err.output // "")) | tostring | if length > 80 then .[:77] + "..." else . end) as $msg |
.error_details += [{tool: $tool_name, message: $msg}])
else . end
)) |
# Count user interactive prompts (messages with text content, not just tool results)
if ([($line.message.content[]? | select(.type == "text"))] | length) > 0 then
.user_prompts += 1
else . end
elif $line.type == "system" and $line.subtype == "turn_duration" then
.turns += 1 | .turn_ms += $line.durationMs
else . end |
if $line.timestamp != null then
(if .first_ts == null then .first_ts = $line.timestamp else . end) |
.last_ts = $line.timestamp
else . end
) |
# Clean up pending_tools (internal tracking only)
del(.pending_tools)
' "$jsonl_path"
}
get_session_meta() {
local session_id="$1"
local cwd="$2"
local encoded_path
encoded_path=$(echo "$cwd" | tr '/' '-')
local index_path="$HOME/.claude/projects/${encoded_path}/sessions-index.json"
if [[ ! -f "$index_path" ]]; then
echo "{}"
return
fi
jq --arg sid "$session_id" '
.entries[]? | select(.sessionId == $sid) | {
summary: .summary,
gitBranch: .gitBranch,
messageCount: .messageCount
}
' "$index_path" 2>/dev/null || echo "{}"
}
calculate_cost() {
local session_id="$1"
local session_data="$2"
# Try ccusage first (with timeout)
if command -v ccusage &> /dev/null; then
local cost
cost=$(timeout 5s ccusage session --id "$session_id" --json --offline 2>/dev/null | jq -r '.totalCost // empty' || echo "")
if [[ -n "$cost" ]]; then
echo "$cost"
return
fi
fi
# Fallback: calculate from pricing table
local total_cost=0
while IFS= read -r model_entry; do
local model
model=$(echo "$model_entry" | jq -r '.model')
local input
input=$(echo "$model_entry" | jq -r '.input')
local output
output=$(echo "$model_entry" | jq -r '.output')
# Shorten model name to match pricing keys
local model_short
model_short=$(shorten_model_name "$model")
local input_price
input_price=$(get_pricing "$model_short" "input")
local output_price
output_price=$(get_pricing "$model_short" "output")
# Cost = (tokens / 1M) * price_per_M
local model_cost
model_cost=$(bc <<< "scale=4; ($input / 1000000) * $input_price + ($output / 1000000) * $output_price")
total_cost=$(bc <<< "scale=4; $total_cost + $model_cost")
done < <(echo "$session_data" | jq -c '.models | to_entries[] | {model: .key, input: .value.input, output: .value.output}')
echo "$total_cost"
}
# ═══════════════════════════════════════════════════════════════════════════
# Section Renderers (each returns a string, empty if no data)
# ═══════════════════════════════════════════════════════════════════════════
# Shared state populated by format_output() before calling renderers
_SD="" # session_data JSON
_SM="" # session_meta JSON
_SID="" # session_id
_BRANCH="" # git branch
_COST="" # cost string
_RTK="" # rtk_data JSON
_GIT_DIFF="" # git diff JSON
_EXIT_REASON="" # exit reason
# Pre-computed values shared across renderers
_TOOL_CALLS_TOTAL=0
_TOOL_OK=0
_TOOL_ERRORS=0
_TOTAL_INPUT=0
_TOTAL_OUTPUT=0
_TOTAL_CACHE_READ=0
_TOTAL_CACHE_CREATE=0
_TURNS=0
_TURN_MS=0
_WALL_MS=0
render_meta() {
local session_name
session_name=$(echo "$_SM" | jq -r '.summary // "Unnamed session"')
local out=""
out+="${DIM}ID:${RESET} ${_SID:0:16}..."$'\n'
out+="${DIM}Name:${RESET} $session_name"$'\n'
out+="${DIM}Branch:${RESET} $_BRANCH"
echo "$out"
}
render_duration() {
local wall_str active_str
wall_str=$(format_duration "$_WALL_MS")
active_str=$(format_duration "$_TURN_MS")
local turn_label="turns"
[[ $_TURNS -eq 1 ]] && turn_label="turn"
local line="${DIM}Duration:${RESET} Wall ${wall_str} | Active ${active_str} | ${_TURNS} ${turn_label}"
[[ -n "$_EXIT_REASON" ]] && line+=" | Exit: ${_EXIT_REASON}"
echo "$line"
}
render_tools() {
local tools_line=""
while IFS= read -r tool_entry; do
local tool count
tool=$(echo "$tool_entry" | jq -r '.tool')
count=$(echo "$tool_entry" | jq -r '.count')
tools_line+=" ${CYAN}$tool:${RESET} $count"
done < <(echo "$_SD" | jq -c '.tools | to_entries[] | {tool: .key, count: .value}' | sort -t':' -k2 -rn | head -8)
local out=""
out+="${DIM}Tool Calls:${RESET} $_TOOL_CALLS_TOTAL ${GREEN}(OK $_TOOL_OK / ERR $_TOOL_ERRORS)${RESET}"$'\n'
out+="$tools_line"
echo "$out"
}
render_errors() {
local error_count
error_count=$(echo "$_SD" | jq '.error_details | length')
[[ "$error_count" == "0" || "$error_count" == "null" ]] && return
local out=""
out+="${DIM}Errors:${RESET} ${RED}${error_count}${RESET}"
# Group errors by tool, show count and first message
local grouped
grouped=$(echo "$_SD" | jq -r '
.error_details | group_by(.tool) | .[] |
(.[0].tool) as $tool |
(length) as $count |
(.[0].message | gsub("\n"; " ")) as $msg |
" \($tool): \"\($msg)\" (x\($count))"
')
[[ -n "$grouped" ]] && out+=$'\n'"$grouped"
echo "$out"
}
render_files() {
local file_stats
file_stats=$(echo "$_SD" | jq '
. as $data |
{
read_only: [.files_read | keys[] | . as $p | select(($data.files_edited | has($p)) | not) | select(($data.files_created | has($p)) | not)] | length,
edited: (.files_edited | length),
created_only: [.files_created | keys[] | . as $p | select(($data.files_edited | has($p)) | not)] | length,
top_edited: [.files_edited | to_entries | sort_by(-.value) | .[:5][] | {name: (.key | split("/") | last), count: .value}]
}
')
local read_only edited created_only
read_only=$(echo "$file_stats" | jq -r '.read_only')
edited=$(echo "$file_stats" | jq -r '.edited')
created_only=$(echo "$file_stats" | jq -r '.created_only')
local total_files=$((read_only + edited + created_only))
[[ $total_files -eq 0 ]] && return
local parts=""
[[ $read_only -gt 0 ]] && parts+="$read_only read"
[[ $edited -gt 0 ]] && { [[ -n "$parts" ]] && parts+=" · "; parts+="$edited edited"; }
[[ $created_only -gt 0 ]] && { [[ -n "$parts" ]] && parts+=" · "; parts+="$created_only created"; }
local out="${DIM}Files:${RESET} $parts"
local top_edited
top_edited=$(echo "$file_stats" | jq -r '.top_edited | map("\(.name) (\(.count) edits)") | join(", ")')
[[ -n "$top_edited" ]] && out+=$'\n'" $top_edited"
echo "$out"
}
render_features() {
local parts=""
# MCP servers
local mcp_count
mcp_count=$(echo "$_SD" | jq '.mcp_servers | length')
if [[ "$mcp_count" != "0" && "$mcp_count" != "null" ]]; then
local mcp_detail
mcp_detail=$(echo "$_SD" | jq -r '.mcp_servers | to_entries | sort_by(-.value) | map("\(.key) x\(.value)") | join(", ")')
parts+="MCP ($mcp_detail)"
fi
# Agents
local agent_count
agent_count=$(echo "$_SD" | jq '.agents | length')
if [[ "$agent_count" != "0" && "$agent_count" != "null" ]]; then
local agent_detail
agent_detail=$(echo "$_SD" | jq -r '.agents | to_entries | sort_by(-.value) | map("\(.key) x\(.value)") | join(", ")')
[[ -n "$parts" ]] && parts+=" · "
parts+="Agents ($agent_detail)"
fi
# Skills
local skill_count
skill_count=$(echo "$_SD" | jq '.skills | unique | length')
if [[ "$skill_count" != "0" && "$skill_count" != "null" ]]; then
local skill_list
skill_list=$(echo "$_SD" | jq -r '.skills | unique | join(", ")')
[[ -n "$parts" ]] && parts+=" · "
parts+="Skills ($skill_list)"
fi
# Teams
local has_teams
has_teams=$(echo "$_SD" | jq -r '.has_teams')
if [[ "$has_teams" == "true" ]]; then
[[ -n "$parts" ]] && parts+=" · "
parts+="Teams"
fi
# Plan mode
local has_plan
has_plan=$(echo "$_SD" | jq -r '.has_plan_mode')
if [[ "$has_plan" == "true" ]]; then
[[ -n "$parts" ]] && parts+=" · "
parts+="Plan mode"
fi
[[ -z "$parts" ]] && return
echo "${DIM}Features:${RESET} $parts"
}
render_git() {
[[ -z "$_GIT_DIFF" || "$_GIT_DIFF" == "{}" ]] && return
local files ins del
files=$(echo "$_GIT_DIFF" | jq -r '.files_changed')
ins=$(echo "$_GIT_DIFF" | jq -r '.insertions')
del=$(echo "$_GIT_DIFF" | jq -r '.deletions')
[[ "$files" == "0" ]] && return
local file_label="files"
[[ "$files" == "1" ]] && file_label="file"
echo "${DIM}Git:${RESET} ${GREEN}+${ins}${RESET} ${RED}-${del}${RESET} lines · ${files} ${file_label} changed"
}
render_loc() {
local loc_added loc_removed
loc_added=$(echo "$_SD" | jq -r '.loc_added // 0')
loc_removed=$(echo "$_SD" | jq -r '.loc_removed // 0')
[[ "$loc_added" == "0" && "$loc_removed" == "0" ]] && return
echo "${DIM}Code:${RESET} ${GREEN}+${loc_added}${RESET} ${RED}-${loc_removed}${RESET} net (via Edit/Write)"
}
render_models() {
local models_section=""
while IFS= read -r model_entry; do
local model requests input output cache_read cache_create
model=$(echo "$model_entry" | jq -r '.model')
requests=$(echo "$model_entry" | jq -r '.requests')
input=$(echo "$model_entry" | jq -r '.input')
output=$(echo "$model_entry" | jq -r '.output')
local model_short input_fmt output_fmt
model_short=$(shorten_model_name "$model")
input_fmt=$(format_number "$input")
output_fmt=$(format_number "$output")
models_section+="$(printf "${CYAN}%-20s${RESET} %4d %7s %6s\n" "$model_short" "$requests" "$input_fmt" "$output_fmt")"
done < <(echo "$_SD" | jq -c '.models | to_entries[] | {model: .key, requests: .value.requests, input: .value.input, output: .value.output}')
local out=""
out+="${DIM}Model Usage${RESET} Reqs Input Output"$'\n'
out+="$models_section"
echo -n "$out"
}
render_cache() {
[[ $_TOTAL_CACHE_READ -eq 0 && $_TOTAL_CACHE_CREATE -eq 0 ]] && return
local cache_hit_rate="0"
local cache_denominator=$((_TOTAL_CACHE_READ + _TOTAL_INPUT))
if [[ $cache_denominator -gt 0 ]]; then
cache_hit_rate=$(bc <<< "scale=0; $_TOTAL_CACHE_READ * 100 / $cache_denominator")
fi
echo "Cache: ${cache_hit_rate}% hit rate ($(format_number $_TOTAL_CACHE_READ) read / $(format_number $_TOTAL_CACHE_CREATE) created)"
}
render_cost() {
[[ -z "$_COST" || "$_COST" == "0" ]] && return
echo "Est. Cost: ${GREEN}\$$(printf "%.3f" "$_COST")${RESET}"
}
render_rtk() {
[[ -z "$_RTK" ]] && return
local rtk_cmds rtk_tokens rtk_pct rtk_estimated rtk_commands
rtk_cmds=$(echo "$_RTK" | jq -r '.cmds')
rtk_tokens=$(echo "$_RTK" | jq -r '.tokens_saved')
rtk_pct=$(echo "$_RTK" | jq -r '.pct')
rtk_estimated=$(echo "$_RTK" | jq -r '.estimated')
rtk_commands=$(echo "$_RTK" | jq -r '.commands')
local est_prefix=""
[[ "$rtk_estimated" == "true" ]] && est_prefix="est. "
local out=""
if [[ $rtk_tokens -gt 0 ]]; then
out="RTK Savings: $rtk_cmds cmds · ~$(format_number "$rtk_tokens") tokens saved (${est_prefix}${rtk_pct}%)"
else
out="RTK Savings: $rtk_cmds cmds rewritten"
fi
[[ -n "$rtk_commands" ]] && out+=$'\n'" $rtk_commands"
echo "$out"
}
render_ratio() {
local user_prompts turns
user_prompts=$(echo "$_SD" | jq -r '.user_prompts // 0')
turns=$_TURNS
[[ $turns -eq 0 ]] && return
local auto_turns=$((turns - user_prompts))
[[ $auto_turns -lt 0 ]] && auto_turns=0
local avg_sec=""
if [[ $_TURN_MS -gt 0 && $turns -gt 0 ]]; then
avg_sec=$(bc <<< "scale=1; $_TURN_MS / 1000 / $turns")
avg_sec="${avg_sec}s/turn"
fi
local turn_label="turns"
[[ $turns -eq 1 ]] && turn_label="turn"
local out="${DIM}Turns:${RESET} ${turns} (${user_prompts} interactive · ${auto_turns} auto)"
[[ -n "$avg_sec" ]] && out+=" · Avg ${avg_sec}"
echo "$out"
}
render_thinking() {
local thinking_blocks
thinking_blocks=$(echo "$_SD" | jq -r '.thinking_blocks // 0')
[[ "$thinking_blocks" == "0" ]] && return
echo "${DIM}Thinking:${RESET} ${thinking_blocks} blocks"
}
render_context() {
local peak_input
peak_input=$(echo "$_SD" | jq -r '.peak_input // 0')
[[ "$peak_input" == "0" ]] && return
# Estimate context limit based on model (200K default)
local ctx_limit=200000
local pct
pct=$(bc <<< "scale=0; $peak_input * 100 / $ctx_limit")
echo "${DIM}Context:${RESET} ~${pct}% peak (est.) · Model limit: $(format_number $ctx_limit)"
}
# ═══════════════════════════════════════════════════════════════════════════
# Output Orchestrator
# ═══════════════════════════════════════════════════════════════════════════
format_output() {
local session_id="$1"
local session_meta="$2"
local session_data="$3"
local cost="$4"
local git_branch="${5:-unknown}"
local rtk_data="${6:-}"
local exit_reason="${7:-}"
local git_diff_data="${8:-}"
# Set shared state for renderers
_SD="$session_data"
_SM="$session_meta"
_SID="$session_id"
_BRANCH="$git_branch"
_COST="$cost"
_RTK="$rtk_data"
_GIT_DIFF="$git_diff_data"
_EXIT_REASON="$exit_reason"
# Extract session data
local api_requests
api_requests=$(echo "$session_data" | jq -r '.api_requests // 0')
# Handle empty session
if [[ $api_requests -eq 0 ]]; then
local session_name
session_name=$(echo "$session_meta" | jq -r '.summary // "Unnamed session"')
local buf=""
buf+=$'\n'
buf+="${BOLD}═══ Session Summary ═══════════════════${RESET}"$'\n'
buf+="${DIM}ID:${RESET} ${session_id:0:16}..."$'\n'
buf+="${DIM}Name:${RESET} $session_name"$'\n'
buf+="${DIM}Branch:${RESET} $git_branch"$'\n'
buf+="${DIM}Status:${RESET} ${YELLOW}Empty session (no API requests)${RESET}"$'\n'
buf+="${BOLD}═══════════════════════════════════════${RESET}"$'\n'
printf '%s\n' "$buf" > "$OUTPUT_TARGET"
return
fi
# Pre-compute shared values for renderers
local first_ts last_ts
first_ts=$(echo "$session_data" | jq -r '.first_ts // 0')
last_ts=$(echo "$session_data" | jq -r '.last_ts // 0')
_TURN_MS=$(echo "$session_data" | jq -r '.turn_ms // 0')
_TURNS=$(echo "$session_data" | jq -r '.turns // 0')
_WALL_MS=0
if [[ $first_ts != "null" && $last_ts != "null" && $first_ts != "0" && $last_ts != "0" ]]; then
_WALL_MS=$(jq -n --arg first "$first_ts" --arg last "$last_ts" '
(($last | split(".")[0] + "Z" | fromdate) - ($first | split(".")[0] + "Z" | fromdate)) * 1000
' 2>/dev/null || echo "0")
fi
_TOOL_ERRORS=$(echo "$session_data" | jq -r '.tool_errors // 0')
_TOOL_CALLS_TOTAL=0
while IFS= read -r tool_entry; do
local count
count=$(echo "$tool_entry" | jq -r '.count')
_TOOL_CALLS_TOTAL=$((_TOOL_CALLS_TOTAL + count))
done < <(echo "$session_data" | jq -c '.tools | to_entries[] | {count: .value}')
_TOOL_OK=$((_TOOL_CALLS_TOTAL > _TOOL_ERRORS ? _TOOL_CALLS_TOTAL - _TOOL_ERRORS : _TOOL_CALLS_TOTAL))
# Pre-compute token totals for cache/cost renderers
_TOTAL_INPUT=0 _TOTAL_OUTPUT=0 _TOTAL_CACHE_READ=0 _TOTAL_CACHE_CREATE=0
while IFS= read -r model_entry; do
local input output cache_read cache_create
input=$(echo "$model_entry" | jq -r '.input')
output=$(echo "$model_entry" | jq -r '.output')
cache_read=$(echo "$model_entry" | jq -r '.cache_read')
cache_create=$(echo "$model_entry" | jq -r '.cache_create')
_TOTAL_INPUT=$((_TOTAL_INPUT + input))
_TOTAL_OUTPUT=$((_TOTAL_OUTPUT + output))
_TOTAL_CACHE_READ=$((_TOTAL_CACHE_READ + cache_read))
_TOTAL_CACHE_CREATE=$((_TOTAL_CACHE_CREATE + cache_create))
done < <(echo "$session_data" | jq -c '.models | to_entries[] | {input: .value.input, output: .value.output, cache_read: .value.cache_read, cache_create: .value.cache_create}')
# Render sections in configured order
local buf=""
buf+=$'\n'
buf+="${BOLD}═══ Session Summary ═══════════════════${RESET}"$'\n'
local section_output
IFS=',' read -ra sections <<< "$SECTION_ORDER"
for section in "${sections[@]}"; do
section=$(echo "$section" | tr -d ' ') # trim whitespace
if is_section_enabled "$section" && type "render_${section}" &>/dev/null; then
section_output=$(render_${section})
if [[ -n "$section_output" ]]; then
buf+="$section_output"$'\n'
fi
fi
done
buf+="${BOLD}═══════════════════════════════════════${RESET}"$'\n'
printf '%s\n' "$buf" > "$OUTPUT_TARGET"
}
log_summary() {
local session_id="$1"
local session_meta="$2"
local session_data="$3"
local cost="$4"
local cwd="$5"
local git_branch="${6:-unknown}"
local rtk_data="${7:-}"
local exit_reason="${8:-}"
local git_diff_data="${9:-}"
# Ensure log directory exists
mkdir -p "$LOG_DIR"
local log_file="$LOG_DIR/session-summaries.jsonl"
# Calculate total tokens
local total_input=0 total_output=0 total_cache_read=0 total_cache_create=0
while IFS= read -r model_entry; do
local input output cache_read cache_create
input=$(echo "$model_entry" | jq -r '.input')
output=$(echo "$model_entry" | jq -r '.output')
cache_read=$(echo "$model_entry" | jq -r '.cache_read')
cache_create=$(echo "$model_entry" | jq -r '.cache_create')
total_input=$((total_input + input))
total_output=$((total_output + output))
total_cache_read=$((total_cache_read + cache_read))
total_cache_create=$((total_cache_create + cache_create))
done < <(echo "$session_data" | jq -c '.models | to_entries[] | {input: .value.input, output: .value.output, cache_read: .value.cache_read, cache_create: .value.cache_create}')
# Calculate wall time for log
local first_ts last_ts
first_ts=$(echo "$session_data" | jq -r '.first_ts // "0"')
last_ts=$(echo "$session_data" | jq -r '.last_ts // "0"')
local duration_wall_ms=0
if [[ $first_ts != "null" && $last_ts != "null" && $first_ts != "0" && $last_ts != "0" ]]; then
duration_wall_ms=$(jq -n --arg first "$first_ts" --arg last "$last_ts" '
(($last | split(".")[0] + "Z" | fromdate) - ($first | split(".")[0] + "Z" | fromdate)) * 1000
' 2>/dev/null || echo "0")
fi
# Cache hit rate
local cache_hit_rate="0"
local cache_denominator=$((total_cache_read + total_input))
if [[ $cache_denominator -gt 0 ]]; then
cache_hit_rate=$(bc <<< "scale=1; $total_cache_read * 100 / $cache_denominator")
[[ "$cache_hit_rate" == .* ]] && cache_hit_rate="0$cache_hit_rate"
fi
# File stats
local file_stats
file_stats=$(echo "$session_data" | jq '
. as $data |
{
read_only: [.files_read | keys[] | . as $p | select(($data.files_edited | has($p)) | not) | select(($data.files_created | has($p)) | not)] | length,
edited: (.files_edited | length),
created: [.files_created | keys[] | . as $p | select(($data.files_edited | has($p)) | not)] | length,
top_edited: [.files_edited | to_entries | sort_by(-.value) | .[:5][] | {name: (.key | split("/") | last), count: .value}]
}
')
# RTK savings for log (JSON or null)
local rtk_json="${rtk_data:-null}"
[[ -z "$rtk_json" ]] && rtk_json="null"
# Git diff for log (JSON or null)
local git_json="${git_diff_data:-null}"
[[ -z "$git_json" || "$git_json" == "{}" ]] && git_json="null"
# v3 new fields from session_data
local error_details loc_added loc_removed user_prompts thinking_blocks peak_input
error_details=$(echo "$session_data" | jq -c '.error_details // []')
loc_added=$(echo "$session_data" | jq -r '.loc_added // 0')
loc_removed=$(echo "$session_data" | jq -r '.loc_removed // 0')
user_prompts=$(echo "$session_data" | jq -r '.user_prompts // 0')
thinking_blocks=$(echo "$session_data" | jq -r '.thinking_blocks // 0')
peak_input=$(echo "$session_data" | jq -r '.peak_input // 0')
# Features for log
local mcp_servers agents skills has_teams has_plan_mode
mcp_servers=$(echo "$session_data" | jq -c '.mcp_servers // {}')
agents=$(echo "$session_data" | jq -c '.agents // {}')
skills=$(echo "$session_data" | jq -c '.skills | unique // []')
has_teams=$(echo "$session_data" | jq -r '.has_teams // false')
has_plan_mode=$(echo "$session_data" | jq -r '.has_plan_mode // false')
# Build log entry
local log_entry
log_entry=$(jq -cn \
--arg timestamp "$(date -u +"%Y-%m-%dT%H:%M:%SZ")" \
--arg session_id "$session_id" \
--arg session_name "$(echo "$session_meta" | jq -r '.summary // "Unnamed"')" \
--arg git_branch "$git_branch" \
--arg project "$cwd" \
--arg exit_reason "${exit_reason:-unknown}" \
--argjson duration_wall_ms "$duration_wall_ms" \
--argjson duration_active_ms "$(echo "$session_data" | jq -r '.turn_ms // 0')" \
--argjson turns "$(echo "$session_data" | jq -r '.turns // 0')" \
--argjson user_prompts "$user_prompts" \
--argjson api_requests "$(echo "$session_data" | jq -r '.api_requests // 0')" \
--argjson tool_calls "$(echo "$session_data" | jq -c '.tools // {}')" \
--argjson tool_errors "$(echo "$session_data" | jq -r '.tool_errors // 0')" \
--argjson error_details "$error_details" \
--argjson models "$(echo "$session_data" | jq -c '.models // {}')" \
--argjson total_input "$total_input" \
--argjson total_output "$total_output" \
--argjson total_cache_read "$total_cache_read" \
--argjson total_cache_create "$total_cache_create" \
--argjson cache_hit_rate "$cache_hit_rate" \
--argjson files "$file_stats" \
--argjson loc_added "$loc_added" \
--argjson loc_removed "$loc_removed" \
--argjson git_diff "$git_json" \
--argjson thinking_blocks "$thinking_blocks" \
--argjson peak_input "$peak_input" \
--argjson mcp_servers "$mcp_servers" \
--argjson agents "$agents" \
--argjson skills "$skills" \
--argjson has_teams "$has_teams" \
--argjson has_plan_mode "$has_plan_mode" \
--argjson rtk_savings "$rtk_json" \
--arg cost_usd "${cost:-0}" \
'{
timestamp: $timestamp,
session_id: $session_id,
session_name: $session_name,
git_branch: $git_branch,
project: $project,
exit_reason: $exit_reason,
duration_wall_ms: $duration_wall_ms,
duration_active_ms: $duration_active_ms,
turns: $turns,
user_prompts: $user_prompts,
api_requests: $api_requests,
tool_calls: $tool_calls,
tool_errors: $tool_errors,
error_details: $error_details,
models: $models,
total_tokens: {
input: $total_input,
output: $total_output,
cache_read: $total_cache_read,
cache_create: $total_cache_create
},
cache_hit_rate: $cache_hit_rate,
files: $files,
loc: { added: $loc_added, removed: $loc_removed },
git_diff: $git_diff,
thinking_blocks: $thinking_blocks,
peak_input: $peak_input,
features: {
mcp_servers: $mcp_servers,
agents: $agents,
skills: $skills,
has_teams: $has_teams,
has_plan_mode: $has_plan_mode
},
rtk_savings: $rtk_savings,
cost_usd: ($cost_usd | tonumber)
}'
)
# Append to log file
echo "$log_entry" >> "$log_file"
}
# ═══════════════════════════════════════════════════════════════════════════
# Main
# ═══════════════════════════════════════════════════════════════════════════
main() {
# Skip if disabled
if [[ "$SKIP" == "1" ]]; then
exit 0
fi
# Check dependencies
check_dependencies
# Read hook input (SessionEnd receives JSON on stdin with session_id, transcript_path, cwd, reason)
local input=""
input=$(cat 2>/dev/null) || true
# Extract session metadata from stdin JSON
local session_id=""
local cwd="${CLAUDE_PROJECT_DIR:-$(pwd)}"
local exit_reason=""
local transcript_path=""
if [[ -n "$input" ]]; then
session_id=$(echo "$input" | jq -r '.session_id // ""')
cwd=$(echo "$input" | jq -r '.cwd // "'"$cwd"'"')
transcript_path=$(echo "$input" | jq -r '.transcript_path // ""')
# Parse exit reason from stdin
local raw_reason
raw_reason=$(echo "$input" | jq -r '.reason // ""')
case "$raw_reason" in
prompt_input_exit|user_exit) exit_reason="user" ;;
clear) exit_reason="clear" ;;
context_limit) exit_reason="context" ;;
api_error) exit_reason="error" ;;
"") exit_reason="" ;;
*) exit_reason="$raw_reason" ;;
esac
fi
# Locate JSONL file: prefer transcript_path from stdin, then locate by session_id
local jsonl_path=""
if [[ -n "$transcript_path" && -f "$transcript_path" ]]; then
jsonl_path="$transcript_path"
# Extract session_id from transcript_path if not set
[[ -z "$session_id" ]] && session_id=$(basename "$jsonl_path" .jsonl)
fi
if [[ -z "$jsonl_path" && -n "$session_id" ]]; then
jsonl_path=$(locate_jsonl "$session_id" "$cwd")
fi
# Fallback: find most recently modified JSONL in project dir
if [[ -z "$jsonl_path" || ! -f "$jsonl_path" ]]; then
local encoded_path
encoded_path=$(echo "$cwd" | tr '/' '-')
local project_dir="$HOME/.claude/projects/${encoded_path}"
if [[ -d "$project_dir" ]]; then
# Use subshell to avoid pipefail+SIGPIPE on ls|head with many files
jsonl_path=$(set +o pipefail; ls -t "$project_dir"/*.jsonl 2>/dev/null | head -1)
fi
# Extract session_id from filename
if [[ -n "$jsonl_path" ]]; then
session_id=$(basename "$jsonl_path" .jsonl)
fi
fi
# No session data found at all
if [[ -z "$jsonl_path" || ! -f "$jsonl_path" ]]; then
exit 0
fi
# Extract session data (pass feature flags to skip expensive ops when disabled)
local session_data
session_data=$(extract_session_data "$jsonl_path" \
"$LOC_ENABLED" "$FEATURES_ENABLED" "$ERRORS_ENABLED" \
"$THINKING_ENABLED" "$CONTEXT_ENABLED")
# Get session metadata
local session_meta
session_meta=$(get_session_meta "$session_id" "$cwd")
# Resolve git branch (session meta first, then JSONL fallback)
local git_branch
git_branch=$(echo "$session_meta" | jq -r '.gitBranch // empty' 2>/dev/null)
if [[ -z "$git_branch" ]]; then
git_branch=$(echo "$session_data" | jq -r '.git_branch // "unknown"')
fi
# Calculate cost
local cost
cost=$(calculate_cost "$session_id" "$session_data")
# Calculate RTK savings (if enabled)
local rtk_data=""
if [[ "$RTK_ENABLED" == "1" ]]; then
rtk_data=$(calculate_rtk_savings)
fi
# Collect git diff (if enabled)
local git_diff_data=""
if is_section_enabled "git"; then
git_diff_data=$(collect_git_diff "$cwd")
fi
# Format and display output
format_output "$session_id" "$session_meta" "$session_data" "$cost" "$git_branch" \
"$rtk_data" "$exit_reason" "$git_diff_data"
# Log summary
log_summary "$session_id" "$session_meta" "$session_data" "$cost" "$cwd" "$git_branch" \
"$rtk_data" "$exit_reason" "$git_diff_data"
exit 0
}
main "$@"