Skip to content

Instantly share code, notes, and snippets.

@suchasplus
Last active May 18, 2026 13:37
Show Gist options
  • Select an option

  • Save suchasplus/8efd6b10cfe1f0300f79fc6373944e45 to your computer and use it in GitHub Desktop.

Select an option

Save suchasplus/8efd6b10cfe1f0300f79fc6373944e45 to your computer and use it in GitHub Desktop.
List Claude Code sessions named via /rename or --name/-n (UTC+8 output)

claude-sessions

List Claude Code sessions you've named — via the /rename slash command or the --name / -n startup flag.

Reads ~/.claude/projects/*/*.jsonl directly. Output times are in UTC+8 (Asia/Shanghai). No external dependencies — Python stdlib only.

Install

Drop the script into a directory on your PATH:

git clone <this gist>  # or download claude_sessions.py
chmod +x claude_sessions.py
ln -s "$PWD/claude_sessions.py" ~/.local/bin/claude-sessions

Usage

claude-sessions               # full history of every naming event
claude-sessions --latest      # current effective name per session
claude-sessions --source cli  # only sessions started with --name / -n
claude-sessions --source rename
claude-sessions --cwd ctxhub  # filter by cwd substring
claude-sessions --since 2026-05-01
claude-sessions --json
claude-sessions --full        # full session id (default truncates to 8 chars)

Sample output:

time(UTC+8)          name            cwd                                       session   source
-------------------  --------------  ----------------------------------------  --------  ------
2026-04-22 18:41:20  ganymede        ~/agentic/ganymede/k0ns013                b7d9fa24  rename
2026-04-23 16:26:22  arch-refurbish  ~/devs/ctxhub/arch                        7a2f2067  cli
2026-05-08 16:05:42  adcp            ~/devs/ctxhub/adcp                        f770f448  rename
2026-05-18 21:20:18  test-onto       ~/devs/ontologies/.../guide               21e46fe7  cli

How it works

Both /rename NAME and claude --name NAME end up persisting a {"type":"custom-title","customTitle":"NAME",...} record in the session's JSONL log. The script:

  1. Walks every ~/.claude/projects/*/*.jsonl.
  2. For each session, looks for explicit <command-name>/rename</command-name> system events — these carry their own timestamp/cwd and are attributed to source rename.
  3. If no /rename event is present but a custom-title record exists, the name must have been set at startup via --name / -n — attributed to source cli, with timestamp/cwd taken from the first record in the file.
  4. /rename with empty args (clearing the name) is shown as <cleared>; --latest mode drops cleared sessions.

Tests

uv run pytest --cov=claude_sessions --cov-report=term-missing

33 tests, 100% coverage.

Requirements

  • Python 3.12+ (uses zoneinfo from stdlib)
  • Claude Code (any reasonably recent version that writes JSONL session logs)
#!/usr/bin/env python3
"""List Claude Code sessions named via /rename or --name/-n."""
from __future__ import annotations
import argparse
import json
import re
import sys
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import Iterable, Iterator, Sequence
from zoneinfo import ZoneInfo
SHANGHAI = ZoneInfo("Asia/Shanghai")
DEFAULT_ROOT = Path.home() / ".claude" / "projects"
RENAME_ARGS_RE = re.compile(r"<command-args>(.*?)</command-args>", re.S)
@dataclass(frozen=True)
class NamedEvent:
ts_utc: datetime
name: str
cwd: str
session_id: str
source: str # "rename" | "cli"
def _parse_iso(ts: str) -> datetime | None:
try:
return datetime.fromisoformat(ts.replace("Z", "+00:00"))
except (ValueError, AttributeError):
return None
def _decode_project_dir(dir_name: str) -> str:
if dir_name.startswith("-"):
return "/" + dir_name[1:].replace("-", "/")
return dir_name
def parse_jsonl(path: Path) -> list[NamedEvent]:
"""Extract naming events from a single session jsonl file.
Distinguishes two sources:
- /rename command events (system / local_command)
- --name / -n startup (custom-title records with no preceding /rename)
"""
session_id: str | None = None
first_ts: datetime | None = None
first_cwd: str | None = None
custom_titles: list[str] = []
rename_events: list[NamedEvent] = []
try:
fh = path.open("r", encoding="utf-8", errors="replace")
except OSError:
return []
with fh as f:
for line in f:
line = line.strip()
if not line:
continue
try:
obj = json.loads(line)
except json.JSONDecodeError:
continue
if session_id is None and obj.get("sessionId"):
session_id = obj["sessionId"]
if first_ts is None and obj.get("timestamp"):
ts = _parse_iso(obj["timestamp"])
if ts:
first_ts = ts
if first_cwd is None and obj.get("cwd"):
first_cwd = obj["cwd"]
if (
obj.get("type") == "system"
and obj.get("subtype") == "local_command"
and "<command-name>/rename</command-name>" in obj.get("content", "")
):
ts = _parse_iso(obj.get("timestamp", ""))
if ts is None:
continue
m = RENAME_ARGS_RE.search(obj.get("content", ""))
name = m.group(1).strip() if m else ""
rename_events.append(
NamedEvent(
ts_utc=ts,
name=name,
cwd=obj.get("cwd") or "",
session_id=obj.get("sessionId") or "",
source="rename",
)
)
elif obj.get("type") == "custom-title":
title = obj.get("customTitle") or ""
if title:
custom_titles.append(title)
events: list[NamedEvent] = list(rename_events)
if custom_titles and not rename_events and session_id and first_ts:
events.append(
NamedEvent(
ts_utc=first_ts,
name=custom_titles[0],
cwd=first_cwd or _decode_project_dir(path.parent.name),
session_id=session_id,
source="cli",
)
)
return events
def collect_events(root: Path) -> list[NamedEvent]:
events: list[NamedEvent] = []
if not root.exists():
return events
for jsonl in sorted(root.glob("*/*.jsonl")):
events.extend(parse_jsonl(jsonl))
events.sort(key=lambda e: e.ts_utc)
return events
def filter_events(
events: Sequence[NamedEvent],
*,
cwd_substr: str | None = None,
since: datetime | None = None,
source: str | None = None,
) -> list[NamedEvent]:
out: Iterable[NamedEvent] = events
if cwd_substr:
out = (e for e in out if cwd_substr in e.cwd)
if since:
out = (e for e in out if e.ts_utc >= since)
if source and source != "all":
out = (e for e in out if e.source == source)
return list(out)
def latest_per_session(events: Sequence[NamedEvent]) -> list[NamedEvent]:
"""Collapse to current effective name per session.
Latest non-empty name wins. Sessions whose latest event has an empty
name (i.e. cleared via `/rename` with no args) are omitted.
"""
by_session: dict[str, NamedEvent] = {}
for e in sorted(events, key=lambda e: e.ts_utc):
by_session[e.session_id] = e
latest = [e for e in by_session.values() if e.name]
latest.sort(key=lambda e: e.ts_utc)
return latest
def _home_tilde(path: str) -> str:
home = str(Path.home())
if path == home:
return "~"
if path.startswith(home + "/"):
return "~" + path[len(home):]
return path
def format_table(events: Sequence[NamedEvent], *, full_sid: bool = False) -> str:
if not events:
return "(no named sessions)"
headers = ("time(UTC+8)", "name", "cwd", "session", "source")
rows: list[tuple[str, str, str, str, str]] = []
for e in events:
ts_local = e.ts_utc.astimezone(SHANGHAI).strftime("%Y-%m-%d %H:%M:%S")
name = e.name if e.name else "<cleared>"
cwd = _home_tilde(e.cwd) if e.cwd else "?"
sid = e.session_id if full_sid else e.session_id[:8]
rows.append((ts_local, name, cwd, sid, e.source))
widths = [
max(len(headers[i]), max(len(r[i]) for r in rows))
for i in range(len(headers))
]
lines = [
" ".join(headers[i].ljust(widths[i]) for i in range(len(headers))),
" ".join("-" * widths[i] for i in range(len(headers))),
]
for r in rows:
lines.append(" ".join(r[i].ljust(widths[i]) for i in range(len(headers))))
return "\n".join(lines)
def format_json(events: Sequence[NamedEvent]) -> str:
payload = [
{
"time_utc8": e.ts_utc.astimezone(SHANGHAI).isoformat(),
"name": e.name,
"cwd": e.cwd,
"session_id": e.session_id,
"source": e.source,
}
for e in events
]
return json.dumps(payload, ensure_ascii=False, indent=2)
def _parse_since(value: str) -> datetime:
dt = datetime.strptime(value, "%Y-%m-%d")
return dt.replace(tzinfo=SHANGHAI).astimezone(timezone.utc)
def build_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(
prog="claude-sessions",
description="List Claude Code sessions named via /rename or --name/-n.",
)
p.add_argument("--root", type=Path, default=DEFAULT_ROOT,
help="Claude projects root (default: ~/.claude/projects)")
p.add_argument("--latest", action="store_true",
help="Show only the currently effective name per session")
p.add_argument("--cwd", metavar="SUBSTR", help="Filter by cwd substring")
p.add_argument("--since", metavar="YYYY-MM-DD",
help="Only events on/after this date (interpreted in UTC+8)")
p.add_argument("--source", choices=("rename", "cli", "all"), default="all",
help="Filter by source")
p.add_argument("--full", action="store_true", help="Show full session id")
p.add_argument("--json", action="store_true", dest="as_json",
help="Output JSON instead of a table")
return p
def main(argv: Sequence[str] | None = None) -> int:
args = build_parser().parse_args(argv)
since = None
if args.since:
try:
since = _parse_since(args.since)
except ValueError:
print(f"invalid --since: {args.since!r} (expected YYYY-MM-DD)",
file=sys.stderr)
return 2
events = collect_events(args.root)
events = filter_events(events, cwd_substr=args.cwd, since=since,
source=args.source)
if args.latest:
events = latest_per_session(events)
print(format_json(events) if args.as_json
else format_table(events, full_sid=args.full))
return 0
if __name__ == "__main__":
sys.exit(main()) # pragma: no cover
[project]
name = "claude-sessions"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = []
[dependency-groups]
dev = [
"pytest>=9.0.3",
"pytest-cov>=7.1.0",
]
"""Tests for claude_sessions."""
from __future__ import annotations
import json
from datetime import datetime, timezone
from pathlib import Path
import pytest
import claude_sessions as cs
# --- helpers ----------------------------------------------------------------
def _write_jsonl(path: Path, records: list[dict]) -> Path:
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("w", encoding="utf-8") as f:
for r in records:
f.write(json.dumps(r) + "\n")
return path
def _rename_event(sid: str, cwd: str, ts: str, args: str) -> dict:
return {
"type": "system",
"subtype": "local_command",
"content": (
"<command-name>/rename</command-name>\n"
"<command-message>rename</command-message>\n"
f"<command-args>{args}</command-args>"
),
"timestamp": ts,
"cwd": cwd,
"sessionId": sid,
}
def _ts(s: str) -> datetime:
return datetime.fromisoformat(s.replace("Z", "+00:00"))
# --- parse_jsonl ------------------------------------------------------------
def test_parse_jsonl_rename_event(tmp_path: Path) -> None:
f = _write_jsonl(tmp_path / "proj" / "abc.jsonl", [
{"sessionId": "sid-1", "timestamp": "2026-05-01T10:00:00.000Z",
"cwd": "/Users/x/repo", "type": "user", "message": {}},
_rename_event("sid-1", "/Users/x/repo",
"2026-05-01T10:05:00.000Z", "myname"),
])
events = cs.parse_jsonl(f)
assert len(events) == 1
e = events[0]
assert e.name == "myname"
assert e.source == "rename"
assert e.session_id == "sid-1"
assert e.cwd == "/Users/x/repo"
assert e.ts_utc == _ts("2026-05-01T10:05:00.000Z")
def test_parse_jsonl_rename_with_empty_args_yields_cleared(tmp_path: Path) -> None:
f = _write_jsonl(tmp_path / "p" / "s.jsonl", [
{"sessionId": "sid-c", "timestamp": "2026-05-01T10:00:00.000Z",
"cwd": "/x", "type": "user"},
_rename_event("sid-c", "/x", "2026-05-01T10:01:00.000Z", ""),
])
events = cs.parse_jsonl(f)
assert len(events) == 1
assert events[0].name == ""
assert events[0].source == "rename"
def test_parse_jsonl_cli_startup_via_custom_title(tmp_path: Path) -> None:
f = _write_jsonl(tmp_path / "proj" / "n.jsonl", [
{"type": "last-prompt", "sessionId": "sid-n"},
{"type": "custom-title", "customTitle": "test-onto", "sessionId": "sid-n"},
{"type": "agent-name", "agentName": "test-onto", "sessionId": "sid-n"},
{"type": "attachment", "timestamp": "2026-05-18T13:20:18.287Z",
"cwd": "/Users/x/guide", "sessionId": "sid-n"},
])
events = cs.parse_jsonl(f)
assert len(events) == 1
e = events[0]
assert e.name == "test-onto"
assert e.source == "cli"
assert e.cwd == "/Users/x/guide"
assert e.ts_utc == _ts("2026-05-18T13:20:18.287Z")
def test_parse_jsonl_custom_title_with_rename_attributes_to_rename(tmp_path: Path) -> None:
# When both /rename event and custom-title appear, only the rename event
# is emitted (custom-title is just a side-effect of rename).
f = _write_jsonl(tmp_path / "p" / "s.jsonl", [
{"sessionId": "sid-r", "timestamp": "2026-05-01T10:00:00.000Z",
"cwd": "/r", "type": "user"},
_rename_event("sid-r", "/r", "2026-05-01T10:01:00.000Z", "foo"),
{"type": "custom-title", "customTitle": "foo", "sessionId": "sid-r"},
])
events = cs.parse_jsonl(f)
assert len(events) == 1
assert events[0].source == "rename"
def test_parse_jsonl_no_naming_events_returns_empty(tmp_path: Path) -> None:
f = _write_jsonl(tmp_path / "p" / "s.jsonl", [
{"sessionId": "sid-x", "timestamp": "2026-05-01T10:00:00.000Z",
"cwd": "/x", "type": "user"},
])
assert cs.parse_jsonl(f) == []
def test_parse_jsonl_skips_malformed_lines(tmp_path: Path) -> None:
p = tmp_path / "p" / "s.jsonl"
p.parent.mkdir(parents=True)
with p.open("w") as f:
f.write('not-json\n')
f.write('\n')
f.write(json.dumps({"sessionId": "sid-m",
"timestamp": "2026-05-01T10:00:00.000Z",
"cwd": "/m", "type": "user"}) + "\n")
f.write(json.dumps(_rename_event(
"sid-m", "/m", "2026-05-01T10:01:00.000Z", "ok")) + "\n")
events = cs.parse_jsonl(p)
assert len(events) == 1
assert events[0].name == "ok"
def test_parse_jsonl_missing_file_returns_empty(tmp_path: Path) -> None:
assert cs.parse_jsonl(tmp_path / "nope.jsonl") == []
def test_parse_jsonl_skips_rename_with_bad_timestamp(tmp_path: Path) -> None:
bad = _rename_event("sid-bad", "/x", "not-a-timestamp", "x")
good = _rename_event("sid-bad", "/x", "2026-05-01T10:00:00.000Z", "good")
f = _write_jsonl(tmp_path / "p" / "s.jsonl", [
{"sessionId": "sid-bad", "timestamp": "2026-05-01T09:59:00.000Z",
"cwd": "/x", "type": "user"},
bad,
good,
])
events = cs.parse_jsonl(f)
assert [e.name for e in events] == ["good"]
def test_parse_iso_returns_none_for_garbage() -> None:
assert cs._parse_iso("garbage") is None
assert cs._parse_iso("") is None
def test_parse_jsonl_cli_falls_back_to_decoded_dir_when_cwd_absent(
tmp_path: Path,
) -> None:
# Encoded project dir, no cwd in any record
f = _write_jsonl(
tmp_path / "-Users-x-foo" / "s.jsonl",
[
{"sessionId": "sid-d", "timestamp": "2026-05-01T10:00:00.000Z",
"type": "last-prompt"},
{"type": "custom-title", "customTitle": "n", "sessionId": "sid-d"},
],
)
events = cs.parse_jsonl(f)
assert len(events) == 1
assert events[0].cwd == "/Users/x/foo"
# --- _decode_project_dir ----------------------------------------------------
def test_decode_project_dir_with_leading_dash() -> None:
assert cs._decode_project_dir("-Users-x-y") == "/Users/x/y"
def test_decode_project_dir_without_leading_dash() -> None:
assert cs._decode_project_dir("plain") == "plain"
# --- collect_events ---------------------------------------------------------
def test_collect_events_sorts_across_files(tmp_path: Path) -> None:
_write_jsonl(tmp_path / "a" / "1.jsonl", [
{"sessionId": "s1", "timestamp": "2026-05-02T00:00:00.000Z",
"cwd": "/a", "type": "user"},
_rename_event("s1", "/a", "2026-05-02T00:01:00.000Z", "later"),
])
_write_jsonl(tmp_path / "b" / "2.jsonl", [
{"sessionId": "s2", "timestamp": "2026-05-01T00:00:00.000Z",
"cwd": "/b", "type": "user"},
_rename_event("s2", "/b", "2026-05-01T00:01:00.000Z", "earlier"),
])
events = cs.collect_events(tmp_path)
assert [e.name for e in events] == ["earlier", "later"]
def test_collect_events_missing_root_returns_empty(tmp_path: Path) -> None:
assert cs.collect_events(tmp_path / "missing") == []
# --- filter_events ----------------------------------------------------------
@pytest.fixture
def sample_events() -> list[cs.NamedEvent]:
return [
cs.NamedEvent(_ts("2026-05-01T00:00:00Z"), "a", "/repo/one", "s1", "rename"),
cs.NamedEvent(_ts("2026-05-02T00:00:00Z"), "b", "/repo/two", "s2", "cli"),
cs.NamedEvent(_ts("2026-05-03T00:00:00Z"), "c", "/other", "s3", "rename"),
]
def test_filter_by_cwd_substr(sample_events: list[cs.NamedEvent]) -> None:
out = cs.filter_events(sample_events, cwd_substr="repo")
assert [e.name for e in out] == ["a", "b"]
def test_filter_by_since(sample_events: list[cs.NamedEvent]) -> None:
out = cs.filter_events(sample_events,
since=_ts("2026-05-02T00:00:00Z"))
assert [e.name for e in out] == ["b", "c"]
def test_filter_by_source(sample_events: list[cs.NamedEvent]) -> None:
out = cs.filter_events(sample_events, source="cli")
assert [e.name for e in out] == ["b"]
def test_filter_source_all_is_passthrough(sample_events: list[cs.NamedEvent]) -> None:
out = cs.filter_events(sample_events, source="all")
assert len(out) == 3
# --- latest_per_session -----------------------------------------------------
def test_latest_keeps_last_nonempty_per_session() -> None:
events = [
cs.NamedEvent(_ts("2026-05-01T00:00:00Z"), "v1", "/x", "s1", "rename"),
cs.NamedEvent(_ts("2026-05-02T00:00:00Z"), "v2", "/x", "s1", "rename"),
cs.NamedEvent(_ts("2026-05-01T00:00:00Z"), "other", "/y", "s2", "rename"),
]
out = cs.latest_per_session(events)
assert [(e.session_id, e.name) for e in out] == [("s2", "other"), ("s1", "v2")]
def test_latest_drops_sessions_whose_latest_is_cleared() -> None:
events = [
cs.NamedEvent(_ts("2026-05-01T00:00:00Z"), "v1", "/x", "s1", "rename"),
cs.NamedEvent(_ts("2026-05-02T00:00:00Z"), "", "/x", "s1", "rename"),
]
assert cs.latest_per_session(events) == []
# --- formatters -------------------------------------------------------------
def test_format_table_renders_columns(sample_events: list[cs.NamedEvent]) -> None:
out = cs.format_table(sample_events)
assert "time(UTC+8)" in out
# 2026-05-01T00:00:00Z -> Shanghai 2026-05-01 08:00:00
assert "2026-05-01 08:00:00" in out
assert "s1" in out # truncated id
assert "rename" in out
def test_format_table_empty_message() -> None:
assert cs.format_table([]) == "(no named sessions)"
def test_format_table_marks_cleared() -> None:
e = cs.NamedEvent(_ts("2026-05-01T00:00:00Z"), "", "/x", "sid", "rename")
assert "<cleared>" in cs.format_table([e])
def test_format_table_full_sid(sample_events: list[cs.NamedEvent]) -> None:
out = cs.format_table(sample_events, full_sid=True)
# session id should appear in full
assert "s1" in out
def test_format_json_is_valid(sample_events: list[cs.NamedEvent]) -> None:
payload = json.loads(cs.format_json(sample_events))
assert len(payload) == 3
assert payload[0]["name"] == "a"
assert payload[0]["source"] == "rename"
# UTC+8 ISO format
assert payload[0]["time_utc8"].startswith("2026-05-01T08:00:00")
def test_home_tilde_replaces_home(monkeypatch: pytest.MonkeyPatch,
tmp_path: Path) -> None:
monkeypatch.setattr(cs.Path, "home", lambda: tmp_path)
assert cs._home_tilde(str(tmp_path)) == "~"
assert cs._home_tilde(str(tmp_path / "x")) == "~/x"
assert cs._home_tilde("/elsewhere") == "/elsewhere"
# --- _parse_since -----------------------------------------------------------
def test_parse_since_converts_shanghai_midnight_to_utc() -> None:
dt = cs._parse_since("2026-05-18")
# 2026-05-18 00:00 UTC+8 == 2026-05-17 16:00 UTC
assert dt == datetime(2026, 5, 17, 16, 0, tzinfo=timezone.utc)
def test_parse_since_rejects_bad_input() -> None:
with pytest.raises(ValueError):
cs._parse_since("not-a-date")
# --- main / CLI -------------------------------------------------------------
def test_main_table_output(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
_write_jsonl(tmp_path / "p" / "s.jsonl", [
{"sessionId": "sidmain1", "timestamp": "2026-05-01T10:00:00.000Z",
"cwd": "/repo", "type": "user"},
_rename_event("sidmain1", "/repo", "2026-05-01T10:01:00.000Z", "demo"),
])
rc = cs.main(["--root", str(tmp_path)])
assert rc == 0
out = capsys.readouterr().out
assert "demo" in out
assert "time(UTC+8)" in out
def test_main_json_output(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
_write_jsonl(tmp_path / "p" / "s.jsonl", [
{"sessionId": "sidmain2", "timestamp": "2026-05-01T10:00:00.000Z",
"cwd": "/repo", "type": "user"},
_rename_event("sidmain2", "/repo", "2026-05-01T10:01:00.000Z", "demo"),
])
rc = cs.main(["--root", str(tmp_path), "--json"])
assert rc == 0
payload = json.loads(capsys.readouterr().out)
assert payload[0]["name"] == "demo"
def test_main_latest_flag(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
_write_jsonl(tmp_path / "p" / "s.jsonl", [
{"sessionId": "sidmain3", "timestamp": "2026-05-01T10:00:00.000Z",
"cwd": "/r", "type": "user"},
_rename_event("sidmain3", "/r", "2026-05-01T10:01:00.000Z", "first"),
_rename_event("sidmain3", "/r", "2026-05-02T10:01:00.000Z", "second"),
])
rc = cs.main(["--root", str(tmp_path), "--latest", "--json"])
assert rc == 0
payload = json.loads(capsys.readouterr().out)
assert len(payload) == 1
assert payload[0]["name"] == "second"
def test_main_invalid_since(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
rc = cs.main(["--root", str(tmp_path), "--since", "garbage"])
assert rc == 2
err = capsys.readouterr().err
assert "invalid --since" in err
def test_main_empty_root_prints_placeholder(
tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
rc = cs.main(["--root", str(tmp_path / "missing")])
assert rc == 0
assert "no named sessions" in capsys.readouterr().out
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment