Browse Source

feat(skills): Add cc-session CLI for session log analysis

Zero-dependency bash tool (jq only) with 14 commands: overview, tools,
tool-chain, thinking, errors, conversation, files, turns, agents, cost,
timeline, summary, search. Supports --json output and --project filtering.

Also documents full JSONL schema (verified against live sessions) and
cleanupPeriodDays retention setting (set to 90 days).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
0xDarkMatter 1 month ago
parent
commit
00cbe8623b
2 changed files with 603 additions and 81 deletions
  1. 138 81
      skills/introspect/SKILL.md
  2. 465 0
      skills/introspect/scripts/cc-session

+ 138 - 81
skills/introspect/SKILL.md

@@ -9,114 +9,171 @@ related-skills: [log-ops, data-processing]
 
 Extract actionable intelligence from Claude Code session logs. For general JSONL analysis patterns (filtering, aggregation, cross-file joins), see the `log-ops` skill.
 
+## cc-session CLI
+
+The `scripts/cc-session` script provides zero-dependency analysis (requires only jq + bash). Auto-resolves the current project and most recent session.
+
+```bash
+# Copy to PATH for global access
+cp skills/introspect/scripts/cc-session ~/.local/bin/
+# Or on Windows (Git Bash)
+cp skills/introspect/scripts/cc-session ~/bin/
+```
+
+### Commands
+
+| Command | What It Does |
+|---------|-------------|
+| `cc-session overview` | Entry counts, timing, tool/thinking totals |
+| `cc-session tools` | Tool usage frequency (sorted) |
+| `cc-session tool-chain` | Sequential tool call trace with input summaries |
+| `cc-session thinking` | Full thinking/reasoning blocks |
+| `cc-session thinking-summary` | First 200 chars of each thinking block |
+| `cc-session errors` | Tool results containing error patterns |
+| `cc-session conversation` | Reconstructed user/assistant turns |
+| `cc-session files` | Files read, edited, written (with counts) |
+| `cc-session turns` | Per-turn breakdown (duration, tools used) |
+| `cc-session agents` | Subagent spawns with type and prompt preview |
+| `cc-session cost` | Rough token/cost estimation |
+| `cc-session timeline` | Event timeline with timestamps |
+| `cc-session summary` | Session summaries (compaction boundaries) |
+| `cc-session search <pattern>` | Search across sessions (text content) |
+
+### Options
+
+```
+--project, -p <name>    Filter by project (partial match)
+--dir, -d <pattern>     Filter by directory pattern in project path
+--all                   Search all projects (with search command)
+--recent <n>            Use nth most recent session (default: 1)
+--json                  Output as JSON instead of text
+```
+
+### Examples
+
+```bash
+cc-session overview                              # Current project, latest session
+cc-session tools --recent 2                      # Tools from second-latest session
+cc-session tool-chain                            # Full tool call sequence
+cc-session errors -p claude-mods                 # Errors in claude-mods project
+cc-session thinking | grep -i "decision"         # Search reasoning
+cc-session search "auth" --all                   # Search all projects
+cc-session turns --json | jq '.[] | select(.tools > 5)'  # Complex turns
+cc-session files --json | jq '.edited[:5]'       # Top 5 edited files
+cc-session overview --json                       # Pipe to other tools
+```
+
 ## Analysis Decision Tree
 
 ```
 What do you want to know?
-│
-├─ "What happened in a session?"
-│  ├─ Quick overview ── session summaries (jq select .type == "summary")
-│  ├─ Full conversation ── flow reconstruction (user/assistant turns)
-│  └─ Timeline ── entry type distribution + timestamps
-│
-├─ "How was I using tools?"
-│  ├─ One session ── tool frequency (jq select tool_use | sort | uniq -c)
-│  ├─ All sessions ── cat *.jsonl | same pipeline
-│  └─ Which files touched ── filter by Edit/Write tool names
-│
-├─ "What was I thinking?"
-│  ├─ Full reasoning trace ── extract thinking blocks
-│  ├─ Reasoning about topic X ── thinking + grep filter
-│  └─ Decision points ── thinking blocks with response preview
-│
-├─ "What went wrong?"
-│  ├─ Tool errors ── filter tool_result for error/failed patterns
-│  ├─ Error frequency ── group by error pattern, count
-│  └─ Debug trajectory ── reconstruct steps leading to failure
-│
-├─ "Compare sessions"
-│  ├─ Tool usage diff ── side-by-side uniq -c
-│  └─ Token estimation ── character count / 4
-│
-└─ "Search across sessions"
-   ├─ By keyword ── grep across *.jsonl
-   ├─ By file touched ── grep for filename
-   └─ By date ── find -mtime filter
+|
+|- "What happened in a session?"
+|  |- Quick overview ---- cc-session overview
+|  |- Full conversation -- cc-session conversation
+|  |- Timeline ---------- cc-session timeline
+|  |- Summaries --------- cc-session summary
+|
+|- "How was I using tools?"
+|  |- Frequency ---------- cc-session tools
+|  |- Call sequence ------- cc-session tool-chain
+|  |- Files touched ------- cc-session files
+|
+|- "What was I thinking?"
+|  |- Full reasoning ------ cc-session thinking
+|  |- Quick scan ---------- cc-session thinking-summary
+|  |- Topic search -------- cc-session thinking | grep -i "topic"
+|
+|- "What went wrong?"
+|  |- Tool errors --------- cc-session errors
+|  |- Debug trajectory ---- cc-session tool-chain (trace the sequence)
+|
+|- "Compare sessions"
+|  |- Tool usage diff ----- cc-session tools --recent 1 vs --recent 2
+|  |- Token estimation ---- cc-session cost
+|
+|- "Search across sessions"
+|  |- Current project ----- cc-session search "pattern"
+|  |- All projects -------- cc-session search "pattern" --all
 ```
 
-## Log File Structure
+## Session Log Schema
+
+### File Structure
 
 ```
 ~/.claude/
-├── history.jsonl                              # Global: all user inputs across projects
-├── projects/
-│   └── {project-path}/                        # e.g., X--Dev-claude-mods/
-│       ├── sessions-index.json                # Session metadata index
-│       ├── {session-uuid}.jsonl               # Full session transcript
-│       └── agent-{short-id}.jsonl             # Subagent transcripts
+|- projects/
+|   |- {project-path}/                        # e.g., X--Forge-claude-mods/
+|       |- sessions-index.json                # Session metadata index
+|       |- {session-uuid}.jsonl               # Full session transcript
+|       |- agent-{short-id}.jsonl             # Subagent transcripts
 ```
 
-### Project Path Encoding
+Project paths use double-dash encoding: `X:\Forge\claude-mods` -> `X--Forge-claude-mods`
 
-Project paths use double-dash encoding: `X:\Dev\claude-mods` -> `X--Dev-claude-mods`
+### Entry Types
 
-```bash
-# Find project directory for current path
-project_dir=$(pwd | sed 's/[:\\\/]/-/g' | sed 's/--*/-/g')
-ls ~/.claude/projects/ | grep -i "${project_dir##*-}"
+| Type | Role | Key Fields |
+|------|------|------------|
+| `user` | User messages + tool results | `message.content[].type` = "text" or "tool_result" |
+| `assistant` | Claude responses | `message.content[].type` = "text", "tool_use", or "thinking" |
+| `system` | Turn duration, compaction | `subtype` = "turn_duration" (has `durationMs`) or "compact_boundary" |
+| `progress` | Hook/tool progress events | `data.type`, `toolUseID`, `parentToolUseID` |
+| `file-history-snapshot` | File state checkpoints | `snapshot`, `messageId`, `isSnapshotUpdate` |
+| `queue-operation` | Message queue events | `operation`, `content` |
+| `last-prompt` | Last user prompt cache | `lastPrompt` |
+| `summary` | Compaction summaries | `summary`, `leafUuid` |
+
+### Content Block Types (inside message.content[])
+
+| Block Type | Found In | Fields |
+|-----------|----------|--------|
+| `text` | user, assistant | `.text` |
+| `tool_use` | assistant | `.id`, `.name`, `.input` |
+| `tool_result` | user | `.tool_use_id`, `.content` |
+| `thinking` | assistant | `.thinking`, `.signature` |
+
+### Common Fields (all entry types)
+
+```
+uuid, parentUuid, sessionId, timestamp, type,
+cwd, gitBranch, version, isSidechain, userType
 ```
 
-## Entry Types
+## Session Log Retention
 
-| Type | Contains | Key Fields |
-|------|----------|------------|
-| `user` | User messages | `message.content`, `uuid`, `timestamp` |
-| `assistant` | Claude responses | `message.content[]`, `cwd`, `gitBranch` |
-| `thinking` | Reasoning blocks | `thinking`, `signature` (in content array) |
-| `tool_use` | Tool invocations | `name`, `input`, `id` (in content array) |
-| `tool_result` | Tool outputs | `tool_use_id`, `content` |
-| `summary` | Conversation summaries | `summary`, `leafUuid` |
-| `file-history-snapshot` | File state checkpoints | File contents at point in time |
-| `system` | System context | Initial context, rules |
+By default, Claude Code deletes sessions inactive for 30 days (on startup). Increase to preserve history for analysis.
 
-## Quick Reference
+```json
+// ~/.claude/settings.json
+{
+  "cleanupPeriodDays": 90
+}
+```
 
-| Task | Command Pattern |
-|------|-----------------|
-| List sessions | `ls -lah ~/.claude/projects/$PROJECT/*.jsonl \| grep -v agent` |
-| Entry types | `jq -r '.type' $SESSION.jsonl \| sort \| uniq -c` |
-| Tool stats | `jq -r '... \| select(.type == "tool_use") \| .name' \| sort \| uniq -c` |
-| Extract thinking | `jq -r '... \| select(.type == "thinking") \| .thinking'` |
-| Find errors | `rg -i "error\|failed" $SESSION.jsonl` |
-| Session summaries | `jq -r 'select(.type == "summary") \| .summary'` |
-| User messages | `jq -r 'select(.type == "user") \| .message.content[]?.text'` |
-| Files edited | `jq -r '... \| select(.name == "Edit") \| .input.file_path'` |
+Currently set to 90 days. Adjust based on disk usage (`dust -d 1 ~/.claude/projects/`).
 
-## Using lnav for Interactive Exploration
+## Quick jq Reference
 
-If `lnav` is installed (see `log-ops` prerequisites), it provides SQL-based interactive exploration of session logs:
+For one-off queries when cc-session doesn't cover your need:
 
 ```bash
-# Open a session in lnav (treats JSONL as structured log)
-lnav ~/.claude/projects/$PROJECT/$SESSION.jsonl
-
-# SQL query inside lnav: count tool usage
-;SELECT json_extract(log_body, '$.message.content[0].name') as tool,
-        count(*) as n
- FROM all_logs
- WHERE json_extract(log_body, '$.type') = 'assistant'
- GROUP BY tool ORDER BY n DESC
-```
+# Pipe through cat on Windows (jq file args can fail)
+cat session.jsonl | jq -r 'select(.type == "assistant") | .message.content[]? | select(.type == "tool_use") | .name'
 
-> For large session files (>50MB), use the two-stage rg+jq pipeline from `log-ops` rather than loading everything into jq with `-s`.
+# Two-stage for large files
+rg '"tool_use"' session.jsonl | jq -r '.message.content[]? | select(.type == "tool_use") | .name'
+```
 
 ## Reference Files
 
-| File | Contents | Lines |
-|------|----------|-------|
-| `references/session-analysis.md` | Full jq recipes: session overview, tool stats, thinking extraction, error analysis, search, flow reconstruction, subagent analysis, exports | ~230 |
+| File | Contents |
+|------|----------|
+| `scripts/cc-session` | CLI tool - session analysis with 14 commands, JSON output, project filtering |
+| `references/session-analysis.md` | Raw jq recipes for custom analysis beyond cc-session |
 
 ## See Also
 
-- **log-ops** - General JSONL processing, two-stage pipelines, cross-file correlation, large file strategies
+- **log-ops** - General JSONL processing, two-stage pipelines, cross-file correlation
 - **data-processing** - JSON/YAML/TOML processing with jq and yq

+ 465 - 0
skills/introspect/scripts/cc-session

@@ -0,0 +1,465 @@
+#!/usr/bin/env bash
+# cc-session: Analyze Claude Code session JSONL logs
+# Zero dependencies beyond jq (required) and standard coreutils.
+#
+# Usage:
+#   cc-session <command> [session.jsonl] [options]
+#   cc-session <command> --project <project-name> [options]
+#   cc-session <command> --dir <directory-pattern> [options]
+#
+# If no file given, uses the most recent session in the current project.
+
+set -euo pipefail
+
+CLAUDE_DIR="${CLAUDE_DIR:-$HOME/.claude}"
+PROJECTS_DIR="$CLAUDE_DIR/projects"
+
+# --- Helpers ---
+
+die() { printf 'error: %s\n' "$1" >&2; exit 1; }
+
+usage() {
+  cat <<'USAGE'
+cc-session - Claude Code session log analyzer
+
+COMMANDS:
+  overview           Entry type counts, duration, model info
+  tools              Tool usage frequency (sorted)
+  tool-chain         Sequential tool call trace with timing
+  thinking           Extract thinking/reasoning blocks
+  thinking-summary   First 200 chars of each thinking block
+  errors             Tool results containing errors
+  conversation       Reconstructed user/assistant turns
+  files              Files read, edited, or written
+  turns              Per-turn breakdown (duration, tools, tokens)
+  agents             Subagent spawns and their tool usage
+  search <pattern>   Search across sessions (user + assistant text)
+  cost               Rough token/cost estimation
+  timeline           Event timeline with gaps highlighted
+  summary            Session summaries (compaction boundaries)
+
+OPTIONS:
+  --project, -p <name>    Filter by project name (partial match)
+  --dir, -d <pattern>     Filter by directory pattern in project path
+  --all                   Search all projects (with search command)
+  --recent <n>            Use nth most recent session (default: 1)
+  --json                  Output as JSON instead of text
+
+EXAMPLES:
+  cc-session overview                           # Current project, latest session
+  cc-session tools                              # Tool frequency
+  cc-session tools --recent 2                   # Second most recent session
+  cc-session search "auth" --all                # Search all projects
+  cc-session errors -p claude-mods              # Errors in claude-mods project
+  cc-session tool-chain                         # Full tool call sequence
+  cc-session thinking | grep -i "decision"      # Search reasoning
+  cc-session turns --json | jq '.[] | select(.tools > 5)'
+USAGE
+  exit 0
+}
+
+# Resolve project directory from current working directory
+resolve_project() {
+  local project_filter="${1:-}"
+  if [[ -n "$project_filter" ]]; then
+    # Find by partial match
+    local found
+    found=$(ls "$PROJECTS_DIR" 2>/dev/null | grep -i "$project_filter" | head -1)
+    [[ -n "$found" ]] || die "No project matching '$project_filter'"
+    echo "$PROJECTS_DIR/$found"
+  else
+    # Derive from cwd
+    local encoded
+    encoded=$(pwd | sed 's/[:\\\/]/-/g' | sed 's/--*/-/g')
+    local found
+    found=$(ls "$PROJECTS_DIR" 2>/dev/null | grep -i "${encoded##*-}" | head -1)
+    if [[ -n "$found" ]]; then
+      echo "$PROJECTS_DIR/$found"
+    else
+      die "Cannot determine project from $(pwd). Use --project <name>"
+    fi
+  fi
+}
+
+# Resolve session file
+resolve_session() {
+  local project_dir="$1"
+  local recent="${2:-1}"
+  ls -t "$project_dir"/*.jsonl 2>/dev/null | grep -v 'agent-' | sed -n "${recent}p"
+}
+
+# --- Commands ---
+
+cmd_overview() {
+  local f="$1" json="${2:-}"
+  if [[ "$json" == "json" ]]; then
+    cat "$f" | jq -sc '{
+      file: input_filename,
+      entries: length,
+      types: (group_by(.type) | map({type: .[0].type, count: length})),
+      first_ts: (map(.timestamp | select(.) | strings) | sort | first),
+      last_ts: (map(.timestamp | select(.) | strings) | sort | last),
+      duration_ms: (
+        [.[] | select(.type == "system" and .subtype == "turn_duration") | .durationMs] | add
+      ),
+      turns: ([.[] | select(.type == "system" and .subtype == "turn_duration")] | length),
+      tool_calls: ([.[] | select(.type == "assistant") | .message.content[]? | select(.type == "tool_use")] | length),
+      thinking_blocks: ([.[] | select(.type == "assistant") | .message.content[]? | select(.type == "thinking")] | length),
+      user_messages: ([.[] | select(.type == "user") | .message.content[]? | select(.type == "text")] | length)
+    }' 2>/dev/null
+  else
+    echo "=== Session Overview ==="
+    echo "File: $(basename "$f")"
+    echo ""
+    echo "--- Entry Types ---"
+    cat "$f" | jq -r '.type' | sort | uniq -c | sort -rn
+    echo ""
+    echo "--- Timing ---"
+    cat "$f" | jq -sc '
+      ([.[] | select(.type == "system" and .subtype == "turn_duration") | .durationMs] | add // 0) as $total |
+      ([.[] | select(.type == "system" and .subtype == "turn_duration")] | length) as $turns |
+      "Total time: \($total / 1000 | floor)s (\($total / 60000 | floor)m \(($total / 1000 | floor) % 60)s)\nTurns: \($turns)\nAvg turn: \(if $turns > 0 then ($total / $turns / 1000 | floor) else 0 end)s"
+    ' 2>/dev/null
+    echo ""
+    echo "--- Content ---"
+    cat "$f" | jq -sc '
+      ([.[] | select(.type == "user") | .message.content[]? | select(.type == "text")] | length) as $user_msgs |
+      ([.[] | select(.type == "assistant") | .message.content[]? | select(.type == "tool_use")] | length) as $tools |
+      ([.[] | select(.type == "assistant") | .message.content[]? | select(.type == "thinking")] | length) as $thinking |
+      "User messages: \($user_msgs)\nTool calls: \($tools)\nThinking blocks: \($thinking)"
+    ' 2>/dev/null
+  fi
+}
+
+cmd_tools() {
+  local f="$1" json="${2:-}"
+  if [[ "$json" == "json" ]]; then
+    cat "$f" | jq -sc '[
+      .[] | select(.type == "assistant") | .message.content[]? |
+      select(.type == "tool_use") | .name
+    ] | group_by(.) | map({tool: .[0], count: length}) | sort_by(-.count)' 2>/dev/null
+  else
+    cat "$f" | jq -r '
+      select(.type == "assistant") | .message.content[]? |
+      select(.type == "tool_use") | .name
+    ' | sort | uniq -c | sort -rn
+  fi
+}
+
+cmd_tool_chain() {
+  local f="$1" json="${2:-}"
+  if [[ "$json" == "json" ]]; then
+    cat "$f" | jq -c '
+      select(.type == "assistant") | .message.content[]? |
+      select(.type == "tool_use") |
+      {name, id, input_summary: (
+        if .name == "Bash" then (.input.command | .[0:120])
+        elif .name == "Read" then .input.file_path
+        elif .name == "Write" then .input.file_path
+        elif .name == "Edit" then .input.file_path
+        elif .name == "Grep" then "\(.input.pattern) in \(.input.path // ".")"
+        elif .name == "Glob" then .input.pattern
+        elif .name == "Agent" then "\(.input.subagent_type // "general"): \(.input.description // "")"
+        elif .name == "Skill" then .input.skill
+        elif .name == "WebSearch" then .input.query
+        elif .name == "WebFetch" then .input.url
+        else (.input | tostring | .[0:100])
+        end
+      )}
+    ' 2>/dev/null
+  else
+    cat "$f" | jq -r '
+      select(.type == "assistant") | .message.content[]? |
+      select(.type == "tool_use") |
+      "\(.name | . + " " * (15 - length))  \(
+        if .name == "Bash" then (.input.command | .[0:100])
+        elif .name == "Read" then .input.file_path
+        elif .name == "Write" then .input.file_path
+        elif .name == "Edit" then .input.file_path
+        elif .name == "Grep" then "pattern=\(.input.pattern) path=\(.input.path // ".")"
+        elif .name == "Glob" then .input.pattern
+        elif .name == "Agent" then "\(.input.subagent_type // "general"): \(.input.description // "")"
+        elif .name == "Skill" then .input.skill
+        elif .name == "WebSearch" then .input.query
+        elif .name == "WebFetch" then .input.url
+        else (.input | tostring | .[0:80])
+        end
+      )"
+    ' 2>/dev/null
+  fi
+}
+
+cmd_thinking() {
+  local f="$1"
+  cat "$f" | jq -r '
+    select(.type == "assistant") | .message.content[]? |
+    select(.type == "thinking") | .thinking
+  ' 2>/dev/null
+}
+
+cmd_thinking_summary() {
+  local f="$1" json="${2:-}"
+  if [[ "$json" == "json" ]]; then
+    cat "$f" | jq -sc '[
+      .[] | select(.type == "assistant") | .message.content[]? |
+      select(.type == "thinking") | {preview: (.thinking | .[0:200])}
+    ]' 2>/dev/null
+  else
+    local n=0
+    cat "$f" | jq -r '
+      select(.type == "assistant") | .message.content[]? |
+      select(.type == "thinking") | .thinking | .[0:200] | gsub("\n"; " ")
+    ' 2>/dev/null | while read -r line; do
+      n=$((n + 1))
+      printf "[%d] %s...\n\n" "$n" "$line"
+    done
+  fi
+}
+
+cmd_errors() {
+  local f="$1" json="${2:-}"
+  if [[ "$json" == "json" ]]; then
+    cat "$f" | jq -c '
+      select(.type == "user") | .message.content[]? |
+      select(.type == "tool_result") |
+      select(.content | type == "string" and test("error|Error|ERROR|failed|Failed|FAILED")) |
+      {tool_use_id, error: (.content | .[0:300])}
+    ' 2>/dev/null
+  else
+    cat "$f" | jq -r '
+      select(.type == "user") | .message.content[]? |
+      select(.type == "tool_result") |
+      select(.content | type == "string" and test("error|Error|ERROR|failed|Failed|FAILED")) |
+      "--- tool_use_id: \(.tool_use_id) ---\n\(.content | .[0:300])\n"
+    ' 2>/dev/null
+  fi
+}
+
+cmd_conversation() {
+  local f="$1"
+  cat "$f" | jq -r '
+    if .type == "user" then
+      .message.content[]? | select(.type == "text") | "USER: \(.text)"
+    elif .type == "assistant" then
+      .message.content[]? | select(.type == "text") | "CLAUDE: \(.text | .[0:500])"
+    else empty end
+  ' 2>/dev/null
+}
+
+cmd_files() {
+  local f="$1" json="${2:-}"
+  if [[ "$json" == "json" ]]; then
+    cat "$f" | jq -sc '{
+      read: [.[] | select(.type == "assistant") | .message.content[]? | select(.type == "tool_use" and .name == "Read") | .input.file_path] | group_by(.) | map({file: .[0], count: length}) | sort_by(-.count),
+      edited: [.[] | select(.type == "assistant") | .message.content[]? | select(.type == "tool_use" and .name == "Edit") | .input.file_path] | group_by(.) | map({file: .[0], count: length}) | sort_by(-.count),
+      written: [.[] | select(.type == "assistant") | .message.content[]? | select(.type == "tool_use" and .name == "Write") | .input.file_path] | unique
+    }' 2>/dev/null
+  else
+    echo "=== Files Read ==="
+    cat "$f" | jq -r 'select(.type == "assistant") | .message.content[]? | select(.type == "tool_use" and .name == "Read") | .input.file_path' | sort | uniq -c | sort -rn
+    echo ""
+    echo "=== Files Edited ==="
+    cat "$f" | jq -r 'select(.type == "assistant") | .message.content[]? | select(.type == "tool_use" and .name == "Edit") | .input.file_path' | sort | uniq -c | sort -rn
+    echo ""
+    echo "=== Files Written ==="
+    cat "$f" | jq -r 'select(.type == "assistant") | .message.content[]? | select(.type == "tool_use" and .name == "Write") | .input.file_path' | sort -u
+  fi
+}
+
+cmd_turns() {
+  local f="$1" json="${2:-}"
+  cat "$f" | jq -sc '
+    [.[] | select(.type == "system" and .subtype == "turn_duration")] as $durations |
+    [.[] | select(.type == "assistant")] as $assistants |
+    [range(0; [$durations | length, $assistants | length] | min) |
+      {
+        turn: (. + 1),
+        duration_s: ($durations[.].durationMs / 1000 | floor),
+        tools: ([$assistants[.].message.content[]? | select(.type == "tool_use") | .name] | length),
+        tool_names: ([$assistants[.].message.content[]? | select(.type == "tool_use") | .name]),
+        has_thinking: ([$assistants[.].message.content[]? | select(.type == "thinking")] | length > 0),
+        text_length: ([$assistants[.].message.content[]? | select(.type == "text") | .text | length] | add // 0)
+      }
+    ]
+  ' 2>/dev/null | if [[ "$json" == "json" ]]; then cat; else
+    jq -r '.[] | "Turn \(.turn): \(.duration_s)s, \(.tools) tools [\(.tool_names | join(", "))]\(if .has_thinking then " [thinking]" else "" end)"' 2>/dev/null
+  fi
+}
+
+cmd_agents() {
+  local f="$1" json="${2:-}"
+  if [[ "$json" == "json" ]]; then
+    cat "$f" | jq -c '
+      select(.type == "assistant") | .message.content[]? |
+      select(.type == "tool_use" and .name == "Agent") |
+      {
+        id,
+        subagent_type: (.input.subagent_type // "general-purpose"),
+        description: .input.description,
+        prompt_preview: (.input.prompt | .[0:200]),
+        background: (.input.run_in_background // false),
+        isolation: (.input.isolation // null)
+      }
+    ' 2>/dev/null
+  else
+    cat "$f" | jq -r '
+      select(.type == "assistant") | .message.content[]? |
+      select(.type == "tool_use" and .name == "Agent") |
+      "[\(.input.subagent_type // "general")] \(.input.description // "no description")\n  prompt: \(.input.prompt | .[0:150] | gsub("\n"; " "))...\n"
+    ' 2>/dev/null
+  fi
+}
+
+cmd_search() {
+  local pattern="$1"
+  shift
+  local search_dir="$1"
+
+  rg -l "$pattern" "$search_dir"/*.jsonl 2>/dev/null | while read -r f; do
+    local session
+    session=$(basename "$f" .jsonl)
+    echo "=== $session ==="
+    cat "$f" | jq -r "
+      if .type == \"user\" then
+        .message.content[]? | select(.type == \"text\") | .text
+      elif .type == \"assistant\" then
+        .message.content[]? | select(.type == \"text\") | .text
+      else empty end
+    " 2>/dev/null | grep -i --color=auto -C2 "$pattern" || true
+    echo ""
+  done
+}
+
+cmd_cost() {
+  local f="$1" json="${2:-}"
+  cat "$f" | jq -sc '
+    ([.[] | select(.type == "user") | .message.content[]? | select(.type == "text") | .text | length] | add // 0) as $user_chars |
+    ([.[] | select(.type == "assistant") | .message.content[]? | select(.type == "text") | .text | length] | add // 0) as $asst_chars |
+    ([.[] | select(.type == "assistant") | .message.content[]? | select(.type == "thinking") | .thinking | length] | add // 0) as $think_chars |
+    ($user_chars + $asst_chars + $think_chars) as $total_chars |
+    ($total_chars / 4 | floor) as $est_tokens |
+    {
+      user_chars: $user_chars,
+      assistant_chars: $asst_chars,
+      thinking_chars: $think_chars,
+      total_chars: $total_chars,
+      est_tokens: $est_tokens,
+      est_tokens_k: (($est_tokens / 1000 * 10 | floor) / 10)
+    }
+  ' 2>/dev/null | if [[ "$json" == "json" ]]; then cat; else
+    jq -r '"Token estimate: \(.est_tokens_k)k tokens\n  User: \(.user_chars) chars\n  Assistant: \(.assistant_chars) chars\n  Thinking: \(.thinking_chars) chars"' 2>/dev/null
+  fi
+}
+
+cmd_timeline() {
+  local f="$1"
+  cat "$f" | jq -r '
+    select(.type == "user" or .type == "assistant") |
+    select(.timestamp) |
+    "\(.timestamp)  \(.type | . + " " * (12 - length))  \(
+      if .type == "user" then
+        (.message.content[]? | select(.type == "text") | .text | .[0:80] | gsub("\n"; " ")) // "[tool_result]"
+      else
+        (.message.content[]? |
+          if .type == "tool_use" then "[\(.name)] \(
+            if .name == "Bash" then (.input.command | .[0:60])
+            elif .name == "Read" then .input.file_path
+            elif .name == "Edit" then .input.file_path
+            elif .name == "Write" then .input.file_path
+            else (.input | tostring | .[0:60])
+            end
+          )"
+          elif .type == "text" then .text | .[0:80] | gsub("\n"; " ")
+          elif .type == "thinking" then "[thinking...]"
+          else empty
+          end
+        ) // ""
+      end
+    )"
+  ' 2>/dev/null
+}
+
+cmd_summary() {
+  local f="$1"
+  cat "$f" | jq -r '
+    select(.type == "summary" or (.type == "system" and .subtype == "compact_boundary")) |
+    if .type == "summary" then
+      "=== Summary ===\n\(.summary)\n"
+    else
+      "--- Compaction Boundary ---"
+    end
+  ' 2>/dev/null
+}
+
+# --- Main ---
+
+[[ $# -eq 0 ]] && usage
+
+cmd="$1"
+shift
+
+# Parse options
+session_file=""
+project=""
+recent=1
+output="text"
+search_all=false
+dir_pattern=""
+
+while [[ $# -gt 0 ]]; do
+  case "$1" in
+    --project|-p) project="$2"; shift 2 ;;
+    --dir|-d) dir_pattern="$2"; shift 2 ;;
+    --recent) recent="$2"; shift 2 ;;
+    --json) output="json"; shift ;;
+    --all) search_all=true; shift ;;
+    --help|-h) usage ;;
+    *)
+      if [[ -f "$1" ]]; then
+        session_file="$1"
+      elif [[ "$cmd" == "search" && -z "${search_pattern:-}" ]]; then
+        search_pattern="$1"
+      fi
+      shift
+      ;;
+  esac
+done
+
+# Resolve session file if not given directly
+if [[ -z "$session_file" ]]; then
+  project_dir=$(resolve_project "${project}${dir_pattern:+$dir_pattern}")
+  session_file=$(resolve_session "$project_dir" "$recent")
+  [[ -n "$session_file" ]] || die "No session files found in $project_dir"
+fi
+
+# Execute command
+case "$cmd" in
+  overview)          cmd_overview "$session_file" "$output" ;;
+  tools)             cmd_tools "$session_file" "$output" ;;
+  tool-chain)        cmd_tool_chain "$session_file" "$output" ;;
+  thinking)          cmd_thinking "$session_file" ;;
+  thinking-summary)  cmd_thinking_summary "$session_file" "$output" ;;
+  errors)            cmd_errors "$session_file" "$output" ;;
+  conversation)      cmd_conversation "$session_file" ;;
+  files)             cmd_files "$session_file" "$output" ;;
+  turns)             cmd_turns "$session_file" "$output" ;;
+  agents)            cmd_agents "$session_file" "$output" ;;
+  cost)              cmd_cost "$session_file" "$output" ;;
+  timeline)          cmd_timeline "$session_file" ;;
+  summary)           cmd_summary "$session_file" ;;
+  search)
+    [[ -n "${search_pattern:-}" ]] || die "search requires a pattern"
+    if [[ "$search_all" == true ]]; then
+      for d in "$PROJECTS_DIR"/*/; do
+        echo ">>> $(basename "$d")"
+        cmd_search "$search_pattern" "$d"
+      done
+    else
+      project_dir=$(resolve_project "${project}${dir_pattern:+$dir_pattern}")
+      cmd_search "$search_pattern" "$project_dir"
+    fi
+    ;;
+  *)
+    die "Unknown command: $cmd. Run cc-session --help for usage."
+    ;;
+esac