Semantic search over your Claude Code session history, plus a live view of which sessions are actually running.
If you use Claude Code heavily, you have probably hit these:
- You remember a conversation but not its title, and the picker's search can't find it.
- Half your sessions are named
push it,yes, implement, orReview RFC in detail— even when the session went somewhere else entirely. - You have five Claude tabs open and no idea which one is still working.
This is a local sidecar that fixes all three. No API key, no cloud, nothing leaves your machine.
Search by meaning. The struck-through line is what the built-in picker would have shown for the same session:
See which sessions are actually working, with real titles instead of hash names
(note the duplicate webapp-a1 the built-in view cannot disambiguate):
Screenshots use a synthetic demo history, not real sessions.
All three problems have a specific cause in the extension bundle (measured against a real 500+ session, 450 MB history on v2.1.220):
1. Titles are generated once and never refreshed.
Title precedence is customTitle → aiTitle → lastPrompt → summaryHint → firstPrompt. The ai-title record is derived from the opening prompt, then
rewritten byte-identically forever. In the corpus measured, 104 sessions had
more than 5 title records but only 1 distinct value — including a
1200-message session still labelled from its first line. Another 132 sessions
had no ai-title at all, so the picker fell back to raw prompt text.
2. Session search is a substring match on the title.
Fuse.js is bundled — but wired exclusively to file @-mentions
(keys:[{path},{filename}]). Session search is
title.toLowerCase().includes(query). There is no body index and no embedding
code anywhere in the bundle, so searching charts cannot find a session that
only ever said dataviz.
3. Running sessions get hash names.
~/.claude/sessions/<pid>.json labels sessions nameSource:"derived" —
myrepo-93, myrepo-d1, myrepo-5d. Two concurrent sessions can collide on
the same derived name. And lockfile mtime is not an activity signal: live
PIDs were observed with lockfile mtimes 258, 275 and 141 minutes stale.
| built-in | claude-sessions | |
|---|---|---|
| search scope | session title only | every prompt + assistant message |
| matching | substring | embeddings + BM25 + synonyms |
| titles | first prompt, never updated | real summaries of the whole session |
| running sessions | myrepo-d1 |
actual title, live state, current tool |
| pinning | — | yes |
| open a result | — | opens/reveals the tab in VS Code |
Requires macOS or Linux, Python 3.10+, and the claude CLI (for title
generation only — search works without it).
git clone https://github.com/BilalAtique/claude-sessions.git
cd claude-sessions
./install.shinstall.sh creates a venv, installs deps, prefetches the embedding model,
builds the index, and symlinks claude-sessions into ~/.local/bin.
Heads-up: the venv is ~1.1 GB because sentence-transformers pulls in
PyTorch. That is the price of fully-local embeddings with no API key.
Then generate real titles (optional, but it is most of the value):
claude-sessions retitle # ~11s per session, resumable, cached./scripts/install-launchd.sh # server at login + hourly reindex
./scripts/uninstall-launchd.sh # undoTwo agents: the web UI with KeepAlive (restarts if it crashes), and an hourly
index + retitle refresh. Logs land in the data dir.
claude-sessions serve # http://127.0.0.1:7788Web UI — two tabs. search history ranks by meaning and strikes through the
title the picker would have shown when it differs. running auto-refreshes
every 5s with real titles, working/idle/stale state, the tool each session
is executing, and a badge on duplicate derived names.
CLI
claude-sessions search "the chart thing"
claude-sessions search "why was the build slow" -n 5 --project myrepo
claude-sessions live --watch # running sessions, refreshing
claude-sessions open 3f9a1c # open/reveal in VS Code (id prefix ok)
claude-sessions pin 3f9a1c -m "note" # pin / unpin / pins
claude-sessions index # incremental; --full to rebuild
claude-sessions retitle --limit 40
claude-sessions statusClick open ↗ on any row, or claude-sessions open <id>.
This uses a URI route the extension already registers:
vscode://Anthropic.claude-code/open?session=<id>&prompt=<text>
It routes to claude-vscode.primaryEditor.open → createPanel(sessionId, …),
which keeps a sessionPanels map and calls panel.reveal() when that session
already has a panel. So an already-open tab is focused rather than
duplicated, including when it is inactive or in another editor group.
The UI calls /api/open/<id> rather than linking vscode:// directly: a raw
link triggers a browser "open external app?" prompt every time, and is blocked
outright inside the VS Code Simple Browser.
Four tests assert the extension still exposes this route, so an update that removes it fails loudly instead of silently breaking the button.
Add to keybindings.json to open the UI in an editor tab:
{ "key": "cmd+shift+h", "command": "simpleBrowser.show",
"args": "http://127.0.0.1:7788" }Hybrid, because neither signal alone is sufficient — both limits were measured, not assumed:
- Dense —
all-MiniLM-L6-v2(384-dim), fully local. Handles paraphrase, butcosine("the chart thing", "dataviz rendering") = 0.18, so it alone misses domain jargon. - Lexical — SQLite FTS5 + BM25 over message bodies, with a synonym map
(
chart → dataviz, recharts, plot…). Catches the exact jargon the sentence model does not know. - Title/keyword bonus — small boost for hits in title, branch, project or extracted keywords.
Score = 1.0·dense + 0.6·lexical + 0.35·title. Pinned sessions sort first.
Isolating the signals on one query shows why both are needed: dense-only ranked an unrelated session first; lexical-only missed the paraphrase; the fusion got it right.
Vectors are cached in-process as one (n_chunks × 384) float32 matrix and
scored with a single BLAS matmul.
| pure-Python cosine | numpy matmul | |
|---|---|---|
| 8.6k chunks | 950 ms | — |
| 17.3k chunks | ~1.9 s (extrapolated) | 40–100 ms |
| 200k chunks | ~22 s (extrapolated) | ~1 s |
numpy ships with PyTorch, so this added no dependency. The model is preloaded at server startup; the first cold query otherwise costs ~11 s.
MAX_CHUNKS_PER_SESSION = 250, CHUNK_CHARS = 900. Both were originally wrong
in ways worth documenting:
- At a cap of 60, only 31% of turns were indexed and 77 sessions were truncated — the longest ones, which are exactly the hardest to recall. At 250: 65% coverage, 29 still capped.
CHUNK_CHARSwas 1200 (~300 tokens), but MiniLM'smax_seq_lengthis 256. The model silently dropped the tail of every chunk. FTS5 still indexed the full text, which masked the bug entirely. 900 chars fits the window.
Assistant turns are sampled evenly across the whole session rather than from the head, so a long session stays findable by something in its middle. Turns under 40 chars are filtered before sampling — filtering after left the budget under-filled.
Three layers, highest precedence first:
custom_title— a manual rename always wins, never overwritten.llm_title— a real summary from the localclaudeCLI (--model haiku). No API key, no extra cost beyond your existing plan. This is the only layer that can summarize rather than quote:Review RFC in detail→ABC-1234 ledger completeness subtasksyes, lets do it,→XYZ-567 opening balance sync recoverypush it→Agent chart rendering fix deployed and scheduled
derived_title— extractive fallback: first substantive prompt, skipping bare acknowledgements (yes,push it,check now).
Cached in llm_titles keyed by transcript mtime, so a session is re-summarized
only after it changes. Re-running retitle is cheap and resumable.
These titles do not change what the VS Code picker renders — the extension
is a 2.6 MB minified bundle with no plugin surface for its session list, so it
will keep showing push it. This is a parallel surface that hands off into
the extension.
The server binds to loopback, but that alone is not sufficient:
- Token auth on every
/apiroute. Any local process — including a maliciousnpm postinstall— could otherwise read your whole transcript history over127.0.0.1. The token is generated once into<data>/api-tokenwith0600perms and injected into the served page (same-origin), so the UI stays zero-config. - Host-header validation. A web page you visit can reach a loopback port via
DNS rebinding, so any
Hostoutside a localhost allowlist gets421.
TOK=$(cat "$HOME/Library/Application Support/claude-sessions/api-token")
curl -H "X-Api-Token: $TOK" "http://127.0.0.1:7788/api/search?q=charts"Transcripts contain source code, command output and whatever else you pasted. Never expose this on a public interface.
Session transcripts are messier than they look. All of these were hit in practice:
message.contentis sometimes a string, sometimes a list of blocks.- The large majority of
role:"user"records are tool results, not prompts (~4281 of 4915 in one sample). Indexing them buries the human signal. - User-role records also carry harness plumbing:
<task-notification>,<system-reminder>,The user opened the file …, baretoolu_…IDs. An early version leaked these into titles, sois_injected_context()rejects them and is shared by the indexer and the live view. - Live transcripts can end mid-line; the parser tolerates truncated JSON.
- Read each
~/.claude/sessions/<pid>.json. os.kill(pid, 0)for real liveness — dead PIDs dropped.- Resolve the title from the transcript, not the derived name.
- Derive activity from the transcript tail. Lockfile mtime is unusable: observed 481 min stale on a process whose transcript was 350 min old.
- Sample CPU time twice (~0.6 s apart) to catch a session that is computing but has not flushed output.
working = transcript written in the last 90 s, idle = last 15 min, else
stale.
Code and data are separate — the venv is ~1.1 GB and the index grows, so neither belongs in the repo.
| what | where |
|---|---|
| code | this repo |
| index, venv, logs, token | ~/Library/Application Support/claude-sessions/ |
| CLI | ~/.local/bin/claude-sessions |
| env var | overrides |
|---|---|
CLAUDE_SESSIONS_HOME |
data dir (index, venv, logs, token) |
CLAUDE_SESSIONS_DB |
index path only |
CLAUDE_SESSIONS_CLAUDE_DIR |
where to read history from (default ~/.claude) |
The last one is how the demo screenshots are generated against a synthetic history instead of a real one.
"$HOME/Library/Application Support/claude-sessions/venv/bin/python" test_csindex.py104 tests: unit coverage of the plumbing filters and title cleaners; a synthetic
~/.claude/projects tree exercising indexing, search, incremental reindex and
live detection against known-correct expectations; auth and DNS-rebinding
checks; assertions that the VS Code URI route still exists; and checks against
the real local index (no plumbing in titles, every session titled, query
latency, live titles never equal to the derived hash).
| path | role |
|---|---|
csindex/parse.py |
transcript → SessionMeta + turns; plumbing filters |
csindex/index.py |
chunking, embedding, extractive titles, incremental reindex |
csindex/search.py |
hybrid dense + BM25 + synonym expansion |
csindex/retitle.py |
summary titles via the local claude CLI |
csindex/live.py |
running-session detection, activity state, name collisions |
csindex/server.py |
FastAPI JSON API + web UI + auth |
csindex/cli.py |
command line entry point |
- macOS-first. The auto-start is launchd. Linux/Windows branches exist for
the
opencommand but are untested. - 1.1 GB install for a 384-dim model, courtesy of PyTorch.
- Titles cost a
claudecall each (~11 s). The hourly agent does 60. - The synonym map is hand-authored — it only knows the jargon in it.
- Tied to the current transcript layout under
~/.claude/; a format change upstream could require parser updates.
MIT

