"""`agnes admin sessions` — terminal access to the global Sessions browser. Three subcommands mirroring the new /api/admin/sessions/* endpoints: agnes admin sessions list # all sessions, table view agnes admin sessions list --errors --since 7d # only sessions with tool errors agnes admin sessions show # transcript dump (chronological) agnes admin sessions download # save the raw .jsonl All require admin auth. """ from __future__ import annotations import json as json_lib import sys from pathlib import Path from typing import Optional import typer from cli.client import api_get from cli.commands.admin_activity import _parse_since, _handle_error sessions_app = typer.Typer(help="Browse Claude Code sessions across all users") def _fmt_duration(s: Optional[int]) -> str: if s is None: return "—" if s < 60: return f"{s}s" if s < 3600: return f"{s // 60}m" return f"{s / 3600:.1f}h" def _fmt_ts(iso: Optional[str]) -> str: if not iso: return "—" # Compact for table display: YYYY-MM-DD HH:MM return iso.replace("T", " ")[:16] @sessions_app.command("list") def list_sessions( since: str = typer.Option( "7d", "--since", help="How far back to look (e.g. 1h, 24h, 7d, 30d, or raw minutes)", ), user: Optional[str] = typer.Option( None, "--user", "-u", help="Filter to a single username (the local-part of the email — same string as in the sessions table)", ), errors_only: bool = typer.Option( False, "--errors", help="Show only sessions where at least one tool call failed", ), model: Optional[str] = typer.Option( None, "--model", help="Filter by primary model name (e.g. 'claude-sonnet-4-6')", ), q: Optional[str] = typer.Option( None, "--q", help="Search session_id or filename", ), limit: int = typer.Option(50, "--limit", help="Rows per page (1–200)"), offset: int = typer.Option(0, "--offset"), sort: str = typer.Option( "tool_errors:desc", "--sort", help="Sort spec: `:`. Cols: started_at, tool_calls, tool_errors, active_seconds, username, primary_model", ), as_json: bool = typer.Option(False, "--json"), ): """List sessions across all users. Default sort surfaces error-heavy sessions first so operators can drill into failures quickly. Pass `--sort started_at:desc` for chronological. """ params = { "since_minutes": _parse_since(since), "limit": limit, "offset": offset, "sort": sort, } if user: params["username"] = user if model: params["model"] = model if errors_only: params["only_errors"] = "true" if q: params["q"] = q resp = api_get("/api/admin/sessions/list", params=params) _handle_error(resp, "sessions list") data = resp.json() if as_json: typer.echo(json_lib.dumps(data, indent=2, default=str)) return rows = data.get("rows", []) if not rows: typer.echo("No sessions match these filters.") return # Compact table layout — fits a 120-char terminal. header = f"{'When':<17} {'User':<20} {'Active':>7} {'Tools':>6} {'Errs':>5} {'Model':<28} {'File':<24}" typer.echo(header) typer.echo("-" * len(header)) for r in rows: when = _fmt_ts(r.get("started_at")) uname = (r.get("username") or "—")[:20] active = _fmt_duration(r.get("active_seconds")) tools = str(r.get("tool_calls") or 0) errs = str(r.get("tool_errors") or 0) model_s = (r.get("primary_model") or "—")[:28] fname = (r.get("session_file") or "").split("/")[-1][:24] # Highlight error rows with a leading `!` prefix = "! " if (r.get("tool_errors") or 0) > 0 else " " typer.echo(f"{prefix}{when:<15} {uname:<20} {active:>7} {tools:>6} {errs:>5} {model_s:<28} {fname:<24}") typer.echo("") total = data.get("total", 0) shown_lo = offset + 1 if rows else 0 shown_hi = offset + len(rows) typer.echo(f"Showing {shown_lo}–{shown_hi} of {total}." + (f" Next page: --offset {data['next_offset']}" if data.get("next_offset") is not None else "")) @sessions_app.command("show") def show_transcript( username: str = typer.Argument(..., help="Filesystem username (local-part of email)"), session_file: str = typer.Argument(..., help="Session filename, e.g. abc-123-def.jsonl"), errors_only: bool = typer.Option(False, "--errors", help="Print only tool-result events flagged as errors"), as_json: bool = typer.Option(False, "--json", help="Raw JSON instead of pretty transcript"), ): """Print a session's transcript chronologically. Tool calls are shown with their JSON input; tool results show the flattened text output. Errored tool results are marked with [ERROR]. Use `--errors` to grep to just the failures. """ resp = api_get(f"/api/admin/sessions/{username}/{session_file}/transcript") _handle_error(resp, "sessions show") data = resp.json() if as_json: typer.echo(json_lib.dumps(data, indent=2, default=str)) return summary = data.get("summary") or {} if summary: typer.echo(f"# Session: {username}/{session_file}") typer.echo(f"# Started: {summary.get('started_at') or '—'}") typer.echo(f"# Active: {_fmt_duration(summary.get('active_seconds'))}") typer.echo( f"# Tools: {summary.get('tool_calls') or 0} calls, " f"{summary.get('tool_errors') or 0} errors" ) typer.echo(f"# Model: {summary.get('primary_model') or '—'}") typer.echo("") else: typer.echo(f"# Session: {username}/{session_file} (no summary row — UsageProcessor hasn't run)") typer.echo("") events = data.get("events", []) if errors_only: events = [e for e in events if e.get("kind") == "tool_result" and e.get("is_error")] if not events: typer.echo("No tool errors in this session.") return for ev in events: kind = ev.get("kind") ts = (ev.get("ts") or "")[11:19] if kind == "text": role = ev.get("role", "user") typer.echo(f"--- [{ts}] {role.upper()} ---") typer.echo(ev.get("text") or "") typer.echo("") elif kind == "tool_use": tool = ev.get("tool_name") or "?" typer.echo(f"--- [{ts}] TOOL_USE: {tool} ---") inp = ev.get("input") typer.echo(json_lib.dumps(inp, indent=2) if inp is not None else "—") typer.echo("") elif kind == "tool_result": marker = "TOOL_RESULT [ERROR]" if ev.get("is_error") else "TOOL_RESULT" tu_id = ev.get("tool_use_id") or "" typer.echo(f"--- [{ts}] {marker} (for {tu_id[:8]}…) ---") typer.echo(ev.get("text") or "—") typer.echo("") @sessions_app.command("download") def download( username: str = typer.Argument(...), session_file: str = typer.Argument(...), output: Optional[Path] = typer.Option( None, "--output", "-o", help="Where to write the file. Defaults to ./ in the current dir.", ), ): """Download the raw JSONL of a session. Useful when you want to feed it to another tool, grep with jq, or archive the conversation. Same audit-log entry as the web download. """ resp = api_get( f"/api/admin/sessions/{username}/{session_file}/download", stream=True, ) _handle_error(resp, "sessions download") target = output or Path(session_file) target.parent.mkdir(parents=True, exist_ok=True) n = 0 with target.open("wb") as fh: for chunk in resp.iter_bytes(): fh.write(chunk) n += len(chunk) typer.echo(f"Wrote {n} bytes to {target}") @sessions_app.command("kpis") def kpis( since: str = typer.Option("7d", "--since"), user: Optional[str] = typer.Option(None, "--user", "-u"), errors_only: bool = typer.Option(False, "--errors"), as_json: bool = typer.Option(False, "--json"), ): """Show top-level numbers (sessions, distinct users, error rate).""" params = {"since_minutes": _parse_since(since)} if user: params["username"] = user if errors_only: params["only_errors"] = "true" resp = api_get("/api/admin/sessions/kpis", params=params) _handle_error(resp, "sessions kpis") data = resp.json() if as_json: typer.echo(json_lib.dumps(data, indent=2)) return typer.echo(f"Sessions: {data.get('sessions_total', 0):,}") typer.echo(f"Distinct users: {data.get('distinct_users', 0):,}") typer.echo(f"Sessions w/ errors: {data.get('error_sessions', 0):,}") typer.echo(f"Tool calls total: {data.get('tool_calls_total', 0):,}") typer.echo(f"Tool errors total: {data.get('tool_errors_total', 0):,}") typer.echo(f"Tool error rate: {data.get('tool_error_rate', 0) * 100:.2f}%")