A local web dashboard for every Claude Code session you have ever started — browse, search, rename, tag, archive, resume — plus a live status column showing what each running session is doing right now, and a plan-usage gauge that reads Anthropic’s own numbers. It reads and writes the real JSONL files under ~/.claude/projects/, so every action persists.
Claude Code stores every session as a JSONL file at ~/.claude/projects/{encoded-project-dir}/{uuid}.jsonl. Over weeks and months these accumulate — dozens, hundreds, eventually thousands — and the only way to navigate them is the built-in /resume picker, which shows a flat list of recent sessions with truncated names.
This system replaces that with a real interface: a browser dashboard at http://localhost:5111 that surfaces every session on disk, sortable and searchable, with inline rename / tag / summary / archive / resume. It’s a Python web server (Starlette + uvicorn) plus a single self-contained HTML file. No build step. No frameworks. Mutations are append-only JSONL — the same mechanism Claude Code uses internally — so your changes interoperate cleanly with /rename from inside an active session.
The version documented here goes further than a file browser. It also reads two live sources: Claude Code’s own running-session registry, so the dashboard knows which sessions are alive and which one is sitting there waiting on you; and Anthropic’s usage endpoint, so you can see how much of your 5-hour and weekly limits you have burned without leaving the page. Together those turn an archive browser into something you keep open on a second monitor.
Waiting / Working / Shell / Idle for every running session, polled every 5 seconds. Waiting pulses red and tints the row — that’s a session blocked on you.
Your 5-hour and weekly utilization, read from the same endpoint Claude Code’s own /usage screen calls. Never estimated.
Click a status pill and that session’s terminal is raised, its tab selected, and the caret lands on the prompt.
Opens on Modified newest-first, with running sessions pinned above everything in every sort order. Sort by Status to rank by urgency instead.
Every session across every project. Type to filter metadata; press Enter to search inside the transcript text itself.
Click the name to rename. Add and remove tags from the row. Both persist to the JSONL — and a /rename typed inside a live session shows up here within one poll.
A companion CLI lets a session write its own one-line description at the end of its run, so future-you knows what it was.
One click launches a terminal and reattaches — optionally with Remote Control on and channel plugins loaded, through a single launch builder so no button can ship without them.
Three data sources, one page. The disk tells you what exists, the pid registry tells you what’s alive, and the API tells you what it’s costing you.
~/.claude/projects/, parses each JSONL line by line, and extracts name, summary, tags, turns, timestamps, origin (the original cwd), and the first user message. Sidechain / subagent files are skipped.~/.claude/sessions/<pid>.json — the same registry its own FleetView reads. The server parses those every poll, verifies each entry against the real process table, and reaps the stale ones./api/sessions, /api/search, /api/live, /api/sessions/{id}/rename, …), and serves the dashboard HTML.claude --resume <uuid>. UUID rather than name — emoji-safe across Windows code pages.This is the part that’s genuinely non-obvious, so here it is in full — the build skill below encodes all of it.
The instinct is to wire up lifecycle hooks in settings.json and have them report in. Don’t. Hooks need config edits, they can be silently removed, they miss every session that started before you installed them, and they duplicate state Claude Code is already keeping correctly at ~/.claude/sessions/<pid>.json. Read the registry instead — it’s just JSON, and it carries far more than status:
| Field | Values | What it gives you |
|---|---|---|
| status | busy · shell · idle · waiting | The pill. waiting is the one that matters — it means that session is blocked on you |
| kind | interactive · bg · daemon | Filter background workers out of the human-facing list |
| tempo | active · idle · blocked | A coarser signal than status |
| waitingFor | free text | Exactly what it’s waiting on — put it in the tooltip |
| name / nameSource | string / derived | Live rename sync (see below) |
| pid / procStart | int / timestamp | Liveness + PID-reuse detection |
| cwd / updatedAt | path / ISO | Where it’s working and how fresh the entry is |
Claude Code unlinks its pid file in an exit handler, so a clean quit cleans up after itself. A crash or a hard kill does not. Verify every entry against the live process table before you believe it, and compare the recorded procStart against the process’s real creation time — without that second check, a recycled PID resurrects a dead session and your dashboard confidently shows a ghost.
A live session’s updatedAt changes on nearly every tick. If a ticking Modified time re-sorts the table, you re-render every row every 5 seconds — which replays your entry animation across the whole page and destroys any inline rename the user is halfway through typing.
Repaint cells in place. Only a name change, or a change in which sessions are live, is allowed to re-sort. Wrap your render function in a counter and idle for two poll ticks while testing: it must stay at zero.
If the session runs in a classic console window, the window belongs to claude or an ancestor and walking the process tree finds it. Fine.
If it runs in a modern tabbed terminal, the process walk finds nothing at all — those terminals use a pseudo-console, so the terminal process is not an ancestor of the Claude process. On Windows, UI Automation is the only route that reaches the real tab, and it works because the tab titles are Claude Code’s own session titles. Match the title, call Select() on the tab. On macOS, AppleScript can select a Terminal/iTerm tab by name; on Linux, wmctrl / xdotool. Don’t try to “fix” the process walk — it isn’t broken, it’s just the wrong tool.
On Windows, SetForegroundWindow silently fails under the foreground lock and merely flashes the taskbar button. You need all three: AttachThreadInput to the current foreground thread, SPI_SETFOREGROUNDLOCKTIMEOUT temporarily set to 0 and restored afterwards, and SwitchToThisWindow as a fallback. In testing it was the lock timeout that was actually rejecting the call. Run the whole thing in a worker thread, or the shell hop stalls every concurrent poll.
Because the pid file also carries the session name, a /rename typed inside a running session reaches the dashboard on the next 5-second poll — no Refresh click, no rescan of the whole archive. One guard matters: only adopt the name when nameSource is absent. A value of derived means Claude Code invented the name from the working directory, and adopting that overwrites a real name with noise.
The gauge reads Anthropic’s own OAuth usage endpoint — the same call Claude Code’s /usage screen makes — using the access token already sitting in ~/.claude/.credentials.json. It returns five_hour and seven_day blocks, each with a utilization percentage and an ISO reset time. That’s the whole feature.
The first version of this gauge reconstructed 5-hour windows from the transcripts and weighted token counts by published price ratios, because the real cap isn’t stored anywhere on disk. It was careful, it was well documented, and it read 41% while the real figure was 1%.
A confidently wrong gauge is worse than no gauge. If the endpoint can’t be reached or the token has expired, render a dash. Never a guess.
Re-read the token on every call so a token Claude Code just refreshed is picked up for free. Never implement token refresh yourself — it races with Claude Code’s own refresh and can log you out of the tool you actually work in. An expired token is simply reported as unavailable; the next poll succeeds on its own once Claude Code renews it.
Portability note: the credentials file is the Windows/Linux location. On macOS the token may live in the Keychain instead. If it isn’t on disk, skip the gauge — don’t go hunting for a way to fake it.
Resume, resume-with-plugins, new, new-with-plugins — four endpoints, one function that builds the command line. That’s structural, not stylistic: it’s the only way a future button can’t silently ship without Remote Control, without the name sanitiser, or without the environment scrub. Two details that cost real debugging time:
--remote-control [name] will swallow a trailing prompt argument as its name. Always follow the bare form immediately with another flag.claude command on PATHpip install starlette uvicorn — the build skill checks and prompts if missing~/.claude/sessions/<pid>.json. The skill checks for it and skips the feature cleanly if it isn’t therePROMPT.md below as ~/.claude/skills/ccsm-build/SKILL.mdhttp://localhost:<your-port>. Every Claude Code session you’ve ever started is right there — and the ones running right now are at the top.The build skill produces a working baseline. Anything in the source files is yours to change. Common follow-ups:
/api/live/summary endpoint returns a single plain-text line like 2 LIVE / 1 WAITING / 23% 5H, sized to fit a Stream Deck key face. Point any device that can poll a URL at it.index.html. Swap the accent, swap the fonts, replace the texture — the structure is unopinionated.pip install if missing — then probes for the pid registry and the credentials file rather than assuming they exist.Two files. The README is the human-readable explainer. The Prompt is a Claude Code build skill — drop it in ~/.claude/skills/ccsm-build/SKILL.md, say “build the session manager,” and Claude takes it from there.
# Claude Code Session Manager
A local web dashboard for browsing, searching, renaming, tagging, archiving, resuming and
launching Claude Code CLI sessions — with a live status column showing what every running
session is doing right now, and a plan-usage gauge reading Anthropic's own numbers. It
reads and writes the actual JSONL session files under `~/.claude/projects/`, so every
action persists.
## What This Does
Claude Code stores every session as a JSONL file under
`~/.claude/projects/{encoded-project-dir}/{uuid}.jsonl`. The session manager indexes them,
surfaces them in a sortable / searchable browser interface, and lets you act on them:
rename, tag, summarize, archive, resume, or kick off a new one.
It's a Python web server, not a static site. Every dashboard action mutates the actual
JSONL files on disk.
## The three data sources
| Source | What it is | What it gives the UI |
|---|---|---|
| `~/.claude/projects/**/*.jsonl` | The archive — every session ever | The table: names, tags, turns, timestamps, transcript search |
| `~/.claude/sessions/<pid>.json` | Claude Code's own registry of RUNNING sessions | The Status column, the pin-to-top ordering, live rename sync |
| `GET /api/oauth/usage` (api.anthropic.com) | Your real plan utilization | The 5-hour / weekly usage gauge |
The first is a disk scan, cached and refreshed on demand. The second is polled every 5
seconds. The third is cached for 60 seconds.
## Architecture
- **Scanner** — walks `~/.claude/projects/`, parses each JSONL, exposes a small import
API. Also runnable standalone for grepping sessions from the shell.
- **Server** — Starlette + uvicorn. Imports the scanner. REST endpoints for list / search
/ rename / tag / summary / archive / resume / live / usage / refresh. Mutations append.
- **Dashboard** — one self-contained HTML file. Inline CSS + JS, no framework, no build.
- **Live module** — reads the pid registry, verifies liveness, focuses a session's window.
- **Usage module** — one authenticated GET, cached; runs standalone for debugging.
- **Describe CLI** (optional) — lets a session write its own one-line description.
- **Launcher** (optional) — `.bat` / `.sh`. Double-click to start the server + browser.
## Live status
The Status column reads `~/.claude/sessions/<pid>.json` — one file per running session,
written and maintained by Claude Code itself, the same data its FleetView uses.
| Pill | Meaning |
|---|---|
| **Waiting** | Needs you. Pulses, and tints the row |
| **Working** | Claude is mid-turn |
| **Shell** | Sitting in a shell command |
| **Idle** | Running, nothing happening |
Waiting sessions also put a count in the browser tab title, so a background tab still
tells you something needs you. Click a pill to jump to that session's terminal — the
window is raised, the tab selected, and focus lands on the prompt.
**Do not rebuild this on `settings.json` lifecycle hooks.** Hooks need config edits, can
be silently removed, miss every session that started before they were installed, and
duplicate state Claude Code already keeps correctly.
**Stale pid files are normal** — a clean exit unlinks its file, a crash doesn't. Verify
every entry against the live process table AND compare the recorded process start time
against the real one, or a recycled PID will resurrect a dead session.
## Sort order
Opens on **Modified, newest first** — whatever is in progress is being written to
constantly, so it rises on its own. **Running sessions are then pinned above everything
in every sort order**, so a live session is never buried by its name or its age. Sorting
by Status skips the pin and ranks by urgency instead (waiting → shell → working → idle).
**A ticking Modified time must not trigger a re-sort.** A live session's timestamp changes
on nearly every poll; re-sorting means re-rendering every row every 5 seconds, which
replays the entry animation across the whole table and destroys an in-progress inline
rename. Repaint the cell in place. Only a name change, or a change in which sessions are
live, re-sorts.
## Live rename sync
`/rename` inside a running session appends to the JSONL **and** writes the name into the
pid file. Since the live poll already reads that file, the new name reaches the dashboard
within one tick — no Refresh, no rescan.
Only adopt the name when the registry's `nameSource` field is absent. `derived` means
Claude Code invented it from the working directory; adopting that overwrites a real name
with noise.
## The usage gauge
```
GET https://api.anthropic.com/api/oauth/usage
Authorization: Bearer <access token from ~/.claude/.credentials.json>
anthropic-beta: oauth-2025-04-20
```
This is the endpoint Claude Code's own `/usage` screen calls. It returns `five_hour` and
`seven_day` blocks, each with a `utilization` percentage and an ISO `resets_at`. The bar
tracks the 5-hour session limit; the weekly figure drives the colour too, because on the
higher plans weekly is usually the limit that actually bites.
**Never estimate.** An earlier version of this gauge reconstructed 5-hour windows from the
transcripts and weighted token counts by published price ratios. It reported **41% when
the real number was 1%.** If the endpoint can't be reached or the token has expired, show
a dash — a confidently wrong gauge is worse than no gauge.
**Read the credentials file, never write it.** Re-read on every call so a freshly
refreshed token is picked up for free, and never implement token refresh yourself — it
races with Claude Code's own refresh and can log you out of the tool you work in.
Failure behaviour: no prior reading → unavailable; a failure within 10 minutes of a good
reading → last good value, flagged stale and dimmed; after that → unavailable.
Portability: `~/.claude/.credentials.json` is the Windows / Linux location. On macOS the
token may live in the Keychain instead — if the file isn't there, skip the gauge.
## Launching
All launch endpoints go through ONE builder function. That's structural: it's the only way
a future button can't silently ship without Remote Control, without the name sanitiser, or
without the environment scrub.
- **Remote Control** (`--remote-control [name]`) on every launch means a session started
here can be picked up from your phone or claude.ai. Resume passes the session's name so
it's identifiable in the list; new passes none.
- **A flag with an optional value must never be last.** `--remote-control` bare will
swallow a trailing prompt argument as its name. Follow it immediately with another flag.
- **Sanitise the name:** strip non-ASCII (launcher scripts are written ASCII-safe), strip
shell metacharacters, collapse whitespace, truncate, and fall back to
`Session <first-8-of-uuid>` when there's no name.
- **Resume by UUID, never by name.** Names carry emoji; launcher scripts don't.
- **Scrub the child-session markers from the spawned environment.** A server restarted
from inside a Claude Code terminal inherits variables that make every launched session
believe it's a nested child — and those sessions write NO transcript. They never appear
in this dashboard and can never be resumed. Strip them and force session persistence on.
Verify by file count after a launch, not by reading the startup banner.
## describe — sessions that summarise themselves
A small CLI that writes a session's Description (the `summary` field) from inside the
session, so a session can describe its own work at the end of its run.
```bash
py describe.py --show # resolve + print the target session FIRST
py describe.py --summary "Did X; fixed Y." # final write
py describe.py --preliminary --summary "Goal: …" # marked, non-clobbering seed
py describe.py --summary "…" --session-id <uuid> # explicit target
```
Two kinds of write. **Preliminary** is seeded at the start from the stated goal, carries a
marker, and will not overwrite an existing real description. **Final** is written at the
end and always replaces. A leftover marker in the dashboard therefore means that session
was never closed out — which makes it a feature, not decoration.
**Always `--show` first, or pass `--session-id`.** Target resolution falls back to "the
most recently modified transcript", which is *usually* the current session and is *not
always* — with two sessions open it will happily overwrite the wrong one's description.
Recovery is easy, since the JSONL is append-only and only the last summary entry wins:
read the earlier ones back out of the file and re-write the right one by id.
## JSONL entry types the scanner cares about
| Type | Field | Purpose |
|---|---|---|
| `custom-title` | `customTitle` | Session name |
| `summary` | `summary` | Session description |
| `tag` | `tag` | Session tag (empty string clears) |
| `user` | `message.content` | First non-empty becomes display fallback; counts toward turns |
| `assistant` | `message.content` | Used by transcript search |
| — | `cwd` | Original working directory (Origin) |
| — | `timestamp` | First / last activity (more accurate than file mtime) |
| — | `isSidechain` / `teamName` | If present, skip the entire file (subagent / swarm) |
| — | `forkedFrom.sessionId` | Fork lineage |
The format is append-only. To change a name, summary, or tag, append a new entry — the
last one wins. Never rewrite a session file in place.
## Requirements
- Claude Code CLI installed and on PATH
- Python 3.10+
- `pip install starlette uvicorn`
- A writable folder for the project files
- For live status: a Claude Code version that writes `~/.claude/sessions/<pid>.json`
## Known limitations
- **The server must be running.** With it down, the status column and usage gauge go blank
rather than showing anything wrong. Add it to Task Scheduler / a LaunchAgent / a systemd
unit if you want it always on.
- **Editing the server does nothing until you restart it.** The single most likely reason
"the change didn't work". Identify the process by the PORT IT OWNS, not by matching its
command line — a command-line match also hits any test process that merely mentions the
file path.
- **Renaming a session that isn't running still needs a Refresh** — there's no pid file to
read the new name from.
- **Don't resume a session from the dashboard while it's open elsewhere.** Concurrent
appends to the same JSONL interleave.
- **Jump-to-session needs a title it can match.** Under a tabbed terminal the right tab is
found by title; a session that has no title yet gets its window raised but not its tab
selected.
- **The Desktop app's sidebar generates its own titles server-side** — renames from this
dashboard affect the CLI's view, not that sidebar.
- **Deep search re-reads every transcript per query.** Fine at a hundred sessions; add an
FTS index before a thousand.
- **The path-decode for the Folder column assumes your project paths don't contain
hyphens.** Hyphens render as spaces. Good enough for display.
---
name: ccsm-build
description: >
Build the Claude Code Session Manager — a local web dashboard that browses, searches,
renames, tags, archives, resumes and launches Claude Code CLI sessions stored at
~/.claude/projects/, with a live status column driven by Claude Code's own running-session
registry and a real plan-usage gauge. Trigger when the user says "build the session
manager", "set up CCSM", "create a Claude Code session dashboard", or any close variation.
allowed-tools: Bash Read Write Edit
argument-hint: (no arguments needed)
---
# Claude Code Session Manager — Build Skill
You are building a local web dashboard that surfaces every Claude Code CLI session on disk,
shows which ones are running right now and what each is doing, reports the user's real plan
usage, and lets them browse, search, rename, tag, summarize, archive, resume and launch
sessions. Everything lives on the user's machine; nothing is hosted.
You decide the implementation. This skill gives you a build spec (what the system must do),
an interview (to gather environment specifics), implementation notes for the non-obvious
parts, and a smoke test.
Read the whole skill before you start. Several of the notes exist because the obvious
approach was tried and was wrong.
## Step 1 — Interview
Ask one question at a time. Confirm each answer before moving on. Use the user's exact
answers when writing files.
1. **OS and shell.** Windows (Git Bash / cmd / PowerShell), macOS (zsh / bash), or Linux?
Different OSes get different launcher syntax, different terminal-spawn calls, and a
different window-focus strategy.
2. **Project folder.** Where should the dashboard files be written? Full path. Will hold
the server, the dashboard HTML, and the live / usage modules.
3. **Default working directory.** When the dashboard launches a NEW session, which folder
should it start in? Usually a project root they work in often.
4. **Live status column?** (Recommended: yes.) Shows Waiting / Working / Shell / Idle for
every running session and lets them click through to that terminal. Requires that their
Claude Code writes `~/.claude/sessions/<pid>.json` — you will verify this in Step 2, so
don't promise it yet.
5. **Plan usage gauge?** (Recommended: yes.) Reads their real 5-hour and weekly utilization
from Anthropic's own endpoint using the token already on disk. Read-only. You will
verify the credentials file exists in Step 2.
6. **Remote Control on launched sessions?** (Recommended: yes.) Sessions launched from the
dashboard start with `--remote-control`, so they can be driven from a phone or
claude.ai. Costs nothing if unused.
7. **Self-describing sessions?** (Optional.) A small CLI + companion skill so a session can
write its own one-line description at the end of its run. Say what it does before asking.
8. **Plugin auto-launch.** Some Claude Code plugins (a Discord plugin that lets a session be
controlled from a channel, a Slack equivalent, anything registered with
`claude --channels`) can auto-load at start. Want extra buttons that resume / start WITH
a plugin loaded? If yes, ask for the channel string, e.g.
`plugin:discord@claude-plugins-official`. If no, skip those buttons entirely.
9. **Accent color.** Hex code for buttons, highlights, focus rings. Default `#3b82f6`.
10. **Port.** Default `5111`.
11. **Desktop launcher.** Want a double-clickable file that starts the server and opens the
browser? `.bat` on Windows, `.sh` on macOS / Linux.
## Step 2 — Pre-flight
Run `python --version` (or `python3 --version`). Require >= 3.10.
Check Starlette and uvicorn:
```
python -c "import starlette, uvicorn; print('ok')"
```
If either is missing, run `pip install starlette uvicorn` (or `py -m pip install …` on
Windows if `pip` isn't on PATH). Confirm they import after install.
**Then PROBE, don't assume, for each live feature the user opted into:**
- Live status: does `~/.claude/sessions/` exist and contain `<pid>.json` files? Read one and
print its keys. If the directory is absent or empty while a session is definitely running,
say so plainly and build without the Status column rather than shipping a dead one.
- Usage gauge: does `~/.claude/.credentials.json` exist and contain an access token? On
macOS it may be in the Keychain instead — if you can't find a token on disk, say so and
build without the gauge.
Report what you found before writing any files. A feature that can't work on this machine
is one you skip, not one you fake.
## Step 3 — Build the system
Files you are writing:
- `<project>/server.py` — REST API server.
- `<project>/index.html` — single-page dashboard.
- `<project>/live.py` — running-session state + window focus (if opted in).
- `<project>/usage.py` — plan usage (if opted in).
- `<project>/describe.py` — session self-description CLI (if opted in).
- `~/.local/bin/cc-sessions.py` — scanner module. If `~/.local/bin/` isn't writable or on
PATH, place the scanner alongside `server.py` and import it normally.
- `~/Desktop/Claude Code Session Manager.{bat,sh}` — only if opted in.
### Scanner (`cc-sessions.py`)
Walk `~/.claude/projects/`. Each subdirectory is one project; each `*.jsonl` with a UUID
name is one session. Parse line by line and accumulate per-session state.
Recognized entry types:
- `type: "custom-title"` — session name (`customTitle` field)
- `type: "summary"` — session description (`summary` field)
- `type: "tag"` — session tag (`tag` field; empty string clears)
- `type: "user"` — first non-empty user message becomes `first_message`; counts toward
`turn_count`. Skip messages that are only a `<system-reminder>` block, or only a
`<command-name>` / `<local-command>` block.
- `cwd` field on entries — original working directory (the "Origin"). Take the first seen.
- `timestamp` field — track first and last for created / modified. More accurate than mtime.
- `isSidechain: true` OR `teamName` present — skip the ENTIRE file (subagent / swarm).
- `forkedFrom.sessionId` — record the lineage.
Skip sessions where `cwd`, `first_message` AND `summary` are all empty (junk / aborted).
The "Folder" column comes from decoding the project directory name. Claude Code encodes
paths by replacing `:`, `/` and `\` with `-`. Use the user's home directory, encoded the
same way, as a strip prefix. Example: home `C:\Users\jane` encodes to `C--Users-jane`; for
`C--Users-jane-code-app1`, strip the prefix and convert remaining `-` to space → `code app1`.
Fall back to the raw dir name if the prefix doesn't match.
Expose for import:
- `find_all_sessions(projects_dir)` — list of session dicts, sorted by modified desc
- `scan_session(filepath)` — one session dict, or `None` to skip
- `search_session_text(filepath, query)` — bool, case-insensitive, across user + assistant
- `display_name(session)` — `name` > `summary[:60]` > `first_message[:60]` > `"(unnamed: <short_id>)"`
- `format_time_ago(datetime)` — "just now" / "5m ago" / "3h ago" / "2d ago" / "3mo ago"
Also runnable as a CLI (`--list`, `--count`, `--search`, `--project`, `--debug`,
`--min-turns`, `--json`).
### Live module (`live.py`) — only if opted in
Claude Code maintains one JSON file per running session at `~/.claude/sessions/<pid>.json`.
This is the same registry its own FleetView reads. **Use it. Do NOT build this on
`settings.json` lifecycle hooks** — hooks require config edits, can be silently removed,
miss every session that started before they were installed, and duplicate state Claude Code
is already keeping correctly.
Fields worth reading: `pid`, `sessionId`, `cwd`, `status`, `waitingFor`, `tempo`, `kind`,
`name`, `nameSource`, `procStart`, `updatedAt`, `startedAt`, `version`, `logPath`.
Enums:
| field | values |
|---|---|
| `status` | `busy` · `shell` · `idle` · `waiting` |
| `kind` | `interactive` · `bg` · `daemon` · `daemon-worker` |
| `tempo` | `active` · `idle` · `blocked` |
Treat the schema as discoverable, not guaranteed: read a real file on this machine first
and adapt. Unknown status values must degrade to a neutral pill, never crash the poll.
**Verify liveness on every read.** Claude Code unlinks its pid file in an exit handler, so
a clean quit cleans up but a crash does not. Check the PID against the real process table
AND compare the recorded `procStart` against the process's actual creation time — that
second check is what makes PID reuse detectable. Reap dead files.
Expose:
- `live_sessions()` → dict keyed by session id: status, waiting_for, pid, name, updated_at
- `live_summary()` → counts (live / waiting) for the plain-text endpoint
- `focus_session(session_id)` → raise that session's terminal, return whether it worked
**Focusing a terminal is two different problems:**
1. *Classic console window* — the window belongs to the `claude` process or an ancestor, so
walking the process tree finds it.
2. *Modern tabbed terminal* — it uses a pseudo-console, so the terminal is NOT an ancestor
of the Claude process and the process walk finds nothing. This is expected; don't "fix"
the walk.
- **Windows Terminal:** UI Automation is the only route that reaches the tab. It works
because WT tab titles ARE Claude Code's session titles (possibly prefixed with a status
glyph). Strip the glyph, match the title, call `SelectionItemPattern.Select()`. Do this
from PowerShell unless a UIA binding is already installed — do not add a dependency for
it. Pass titles as ONE JSON argument, not loose tokens; PowerShell's `-File` mode binds
bare tokens positionally and a second title errors out as an unbound parameter.
- **macOS:** AppleScript — `tell app "Terminal"` / `"iTerm"` to select the tab whose name
contains the title.
- **Linux:** `wmctrl -a` or `xdotool search --name`.
**Winning the foreground on Windows needs all three of these, not one:**
1. `AttachThreadInput` to the current foreground thread
2. `SPI_SETFOREGROUNDLOCKTIMEOUT` set to 0 for the duration, then restored — this is the one
that's actually rejecting the call
3. `SwitchToThisWindow` as a fallback (undocumented, but it's what Alt+Tab uses)
`SetForegroundWindow` alone silently fails and only flashes the taskbar button.
Run the focus call in a worker thread — the shell hop takes a few hundred ms and would
otherwise stall every concurrent poll.
Make `live.py` runnable standalone (`python live.py` prints the registry as the dashboard
sees it). Debug it there before wiring it through the server.
### Usage module (`usage.py`) — only if opted in
```
GET https://api.anthropic.com/api/oauth/usage
Authorization: Bearer <claudeAiOauth.accessToken from ~/.claude/.credentials.json>
anthropic-beta: oauth-2025-04-20
```
This is the endpoint Claude Code's own `/usage` screen calls. It returns `five_hour` and
`seven_day` blocks, each with a `utilization` percentage and an ISO `resets_at`. Surface
both: on the higher plans the weekly limit is usually the one that actually bites.
**RULE — never estimate.** Do not reconstruct usage windows from transcript token counts.
That approach has been built and thrown away: it reported 41% while the real figure was 1%.
The gauge shows Anthropic's number or it shows a dash. If this endpoint stops working, the
correct action is to remove the gauge, not to approximate it.
**RULE — read the credentials file, never write it.** Re-read the token on every call so a
token Claude Code just refreshed is picked up for free. Do NOT implement token refresh — it
races with Claude Code's own refresh and can log the user out of the tool they work in. An
expired token is reported as unavailable and recovers on its own.
Cache for 60 seconds so multiple open tabs produce about one request a minute, and warm it
in a background thread at startup so the first paint has it.
Failure behaviour, all four paths:
| Situation | Result |
|---|---|
| Fetch fails, no prior reading | `available: false` — UI shows a dash |
| Fetch fails within 10 min of a good reading | last good reading, flagged stale, dimmed |
| Fetch fails more than 10 min after | `available: false` |
| Token expired / 401 | `available: false`, with the reason named in the tooltip |
Make it runnable standalone too.
### Server (`server.py`)
Starlette app, uvicorn at `127.0.0.1:<port>`. Import the scanner via
`importlib.util.spec_from_file_location` if it lives at `~/.local/bin/cc-sessions.py` (the
dotted name contains a hyphen, so a normal import won't work). In-memory cache of all
sessions, refreshed on startup and on `/api/refresh`.
| Method | Path | Body | Behavior |
|---|---|---|---|
| GET | `/` | — | Serve `index.html` |
| GET | `/api/sessions` | — | Cached sessions; supports `?project=&min_turns=` |
| GET | `/api/search?q=` | — | Display-name match first (fast), then full-transcript search |
| GET | `/api/sessions/{id}/preview` | — | First ~8 user / assistant messages (cleaned) |
| GET | `/api/sessions/{id}/digest` | — | Full scan: tools used, files touched, first / last messages |
| GET | `/api/live` | — | Every running session + counts + current usage reading |
| GET | `/api/live/summary` | — | PLAIN TEXT, e.g. `2 LIVE / 1 WAITING / 23% 5H` — sized for a hardware key face |
| POST | `/api/sessions/{id}/rename` | `{name}` | Append `{"type":"custom-title","customTitle":name}`; update cache |
| POST | `/api/sessions/{id}/tag` | `{tag}` | Append `{"type":"tag","tag":tag}`; empty clears |
| POST | `/api/sessions/{id}/summary` | `{summary}` | Append `{"type":"summary","summary":summary}` |
| POST | `/api/sessions/{id}/archive` | — | Move JSONL to `~/.claude/projects/.archive/<project_dir>/` |
| POST | `/api/sessions/{id}/resume` | — | Launch a terminal resuming this session |
| POST | `/api/sessions/{id}/resume-plugin` | — | (if opted in) same, with `--channels <plugin>` |
| POST | `/api/sessions/{id}/focus` | — | (if opted in) raise that session's terminal; 409 if not running |
| POST | `/api/new-session` | — | Launch a fresh `claude` in the default working dir |
| POST | `/api/new-plugin-session` | — | (if opted in) same, with `--channels <plugin>` |
| POST | `/api/open-folder` | — | Open the project folder in the OS file explorer |
| POST | `/api/refresh` | — | Re-scan from disk |
**Sync live names into the cache on every poll.** When a registry entry carries a name and
its `nameSource` is ABSENT, adopt it — that's a real `/rename` from inside the session, and
adopting it means renames appear within one tick with no rescan. When `nameSource` is
`derived`, ignore it: that name was invented from the working directory and would overwrite
a real one with noise. Send names for EVERY live session on every poll, not just the ones
that changed, so a browser tab left open across a server restart catches up.
**ALL launching goes through ONE builder function.** Four endpoints, one code path. This is
structural, not stylistic — it is the only way a future button can't silently ship without
Remote Control, without the name sanitiser, or without the environment scrub.
The builder must:
- Resume by UUID, never by name.
- Sanitise any name it interpolates: strip non-ASCII, strip shell metacharacters, collapse
whitespace, truncate (~40 chars), fall back to `Session <first-8-of-uuid>`.
- Write launcher scripts `encoding="ascii", errors="replace"`.
- **Never place a flag with an OPTIONAL value last.** `--remote-control [name]` will swallow
a trailing prompt argument as its name. Always follow the bare form with another flag.
Probe if unsure: `claude --remote-control --zzz-not-a-flag` should error on the unknown
option, proving the parser doesn't consume a following `-`-prefixed token.
- **Spawn with a CLEANED environment.** Strip the in-session markers
(`CLAUDE_CODE_CHILD_SESSION`, `CLAUDECODE`, `CLAUDE_CODE_SESSION_ID`,
`CLAUDE_CODE_SKIP_PROMPT_HISTORY`, and any sibling markers you find) and set
`CLAUDE_CODE_FORCE_SESSION_PERSISTENCE=1`. **Why this is critical:** if the server was
started from inside a Claude Code terminal it inherits those markers and passes them to
every session it spawns; Claude Code concludes it's a nested child and writes NO
transcript at all. Those sessions are invisible to this dashboard and can never be
resumed. Silent data loss whose only symptom is one line at startup.
### Dashboard (`index.html`)
Single self-contained HTML file. Inline CSS + JS, no framework, no build step. Dark theme,
the user's accent color for highlights, monospace for data, serif for session names
(editorial feel — an unexpected touch for a dashboard).
Header: open-folder button, search input ("search by name, project, tag, or content…"), `/`
and `Ctrl+K` shortcuts, "+ New" (and "+ Plugin" if opted in), Refresh, and the usage gauge.
Table columns: Name, **Status**, Resume, Folder, Turns, Created, Modified, Tags.
- **Name must be `minmax(220px, 1fr)`, not a bare `1fr`.** A bare `1fr` floors at min-content
and collapses to literally 0px once the fixed columns outgrow the row. Drop columns by
POSITION across your breakpoints so rows and headers can never disagree, and never drop
Status.
- **Default sort: Modified, newest first** — a session being written to right now rises on
its own. **Pin running sessions above everything in every sort order**, so a live session
is never buried by its name or its age. Sorting by Status skips the pin and ranks by
urgency instead (waiting → shell → working → idle).
- Filter chips above the table, auto-populated from the unique `project` values.
- Row interactions: click to expand; click the name to rename inline (Enter saves, Escape
cancels); inline Resume (+ Plugin) button; tags with a click-to-remove `×`.
- Expanded panel: Origin, session ID, editable description, first-message preview,
lazy-loaded tools / files digest, and Archive with a confirm dialog.
- Real-time client-side filtering on every keystroke; Enter (or a "DEEP" pill) runs the
full-transcript search.
Live layer:
- Poll `/api/live` every 5 seconds.
- Four pills: **Waiting** (pulsing, and tint the row — it needs the user), **Working**
(accent, subtle breathing dot), **Shell** (green), **Idle** (grey, still).
- Put the waiting count in `document.title`, so a background tab still says something needs
attention.
- Click a pill → `POST /api/sessions/{id}/focus`.
- Show the usage gauge beside the filter chips: a bar for the 5-hour figure, the weekly
figure driving its colour and surfaced inline once it passes 50%, both reset times in the
tooltip. Render a dash when unavailable, and dim it when stale.
🔴 **NOTHING ON THE POLL MAY TRIGGER A FULL RE-RENDER.** A live session's Modified time
changes almost every tick; re-sorting on that re-renders every row every 5 seconds, replays
the entry animation across the whole table, and destroys an inline rename the user is
halfway through typing. Repaint the status pill and the Modified cell IN PLACE. Only a name
change, or a change in which sessions are live, re-sorts. A live row's Modified value comes
from the registry's `updatedAt` and is only ever moved FORWARD, so it can't go backwards
against the disk scan.
### describe CLI (`describe.py`) — only if opted in
Writes a session's `summary` field from the command line, so a session can describe its own
work at the end of its run.
```
--show resolve + print the target session (tell the user to run this first)
--summary "…" final write; always replaces
--preliminary --summary "…" marked seed; SKIPS if a real description already exists
--session-id <uuid> explicit target
--dry-run
```
- Target resolution: `--session-id` → `$CLAUDE_SESSION_ID` → most recently modified
non-sidechain `.jsonl`. **Warn the user in your report that the fallback is not reliable
when two sessions are open** — the current session's transcript isn't flushed every turn,
so another live session can easily be the more recently modified file.
- POST to the running server so its cache stays in sync; fall back to appending to the JSONL
directly if the server is down, and SAY SO (the dashboard then needs a Refresh).
- Verify the write by reading the file back; exit non-zero if it didn't stick.
- Reconfigure stdout/stderr to UTF-8. On Windows the console codec is cp1252 and printing a
non-ASCII marker character tracebacks AFTER a successful write — which looks exactly like
a failed write and isn't.
### Launcher (optional)
Windows `.bat`:
```bat
@echo off
cd /d "<project>"
start "" http://localhost:<port>
python server.py
```
macOS / Linux `.sh`:
```bash
#!/usr/bin/env bash
cd "<project>"
( sleep 1 && ${OPEN:-open} "http://localhost:<port>" ) &
python3 server.py
```
(`OPEN=open` on macOS, `OPEN=xdg-open` on Linux.)
## Implementation notes
### Append-only mutations
Renames, tags and descriptions are written by APPENDING a line, never by rewriting the file.
The reader takes the LAST entry of each type as authoritative. This matches Claude Code's
own mechanism, so dashboard renames and `/rename` from inside a session co-exist cleanly.
Never open a session JSONL in `"w"` mode.
### Spawning a terminal from a web server
Don't embed a terminal. Write a temporary launcher script to `tempfile.gettempdir()`, then
spawn it detached so it survives the server going away:
- Windows: `subprocess.Popen(["cmd", "/c", "start", title, "cmd", "/k", bat_path], env=clean_env)`
- macOS: `osascript -e 'tell app "Terminal" to do script "…"'`
- Linux: `gnome-terminal -- bash -c "…; exec bash"` (fall back to `xterm -e`)
### Lazy-load digests
A full transcript scan is expensive — don't do it for every session at list time. Fetch
`/api/sessions/{id}/digest` on row expansion and cache it in JS state.
### Concurrent-write hazard
Two processes appending to the same JSONL can interleave mid-line. Take the "don't do that"
approach and document it. Don't take a file lock — on Windows that fights with whatever
Claude Code is doing internally.
### Skip sidechain files
Files where any entry has `isSidechain: true` are subagent transcripts; files with a
`teamName` field are swarm teammates. Drop the whole file. Short-circuit inside
`scan_session` — return `None` the moment you see either marker.
## Step 4 — Smoke test
1. Launch `python "<project>/server.py"` in the background.
2. Wait 2 seconds.
3. `curl http://localhost:<port>/api/sessions` — expect a JSON array (may be empty).
4. `curl -s http://localhost:<port>/ | head -c 200` — expect a `<!DOCTYPE html>` opener.
5. If live status was built: `curl http://localhost:<port>/api/live` — expect valid JSON.
With no sessions running it must return an empty set, NOT an error.
6. If the gauge was built: confirm the usage field is either a real reading or an explicit
`available: false`. **A number you can't trace to the API response is a bug** — if you
find yourself computing one, you've reintroduced the estimator.
7. Kill the background process.
8. If anything fails, debug and re-test before reporting success.
For the frontend, drive it in a real browser and read computed styles rather than eyeballing
a screenshot. Two failures that a screenshot will not show you: a grid column resolving to
0px, and a re-render storm on the poll. Force each status value by writing into your live
state and re-rendering, so you can check all four pills without waiting for a real
`waiting` session to happen — but sample what you need inside the same evaluation, because
the 5-second poll wipes forced state within one tick.
## Step 5 — Report
Tell the user:
> **Claude Code Session Manager built.**
>
> - Files: `<list of paths written>`
> - Launch: `python "<project>/server.py"` (or the desktop launcher)
> - Browser: `http://localhost:<port>`
> - Live features included: `<status column / usage gauge / remote control / describe>`
> - Anything skipped, and why (e.g. no pid registry found on this machine)
>
> ⚠️ **Editing `server.py` does nothing until you restart the server.** It's a
> long-running process — patch it and re-test without restarting and you are testing the
> old code. Identify it by the PORT IT OWNS, not by matching its command line.
## Critical rules
1. Never overwrite an existing `server.py` / `index.html` in the project folder without
asking first. If they exist, check whether a server is already running and confirm it's
the same system before re-writing.
2. Never delete or rewrite a JSONL session file. All mutations are appends.
3. Always pass `--resume <uuid>` to `claude`, never the session name.
4. All launching goes through the single builder, with the cleaned environment. No
hand-written command strings in individual endpoints.
5. 🔴 Never estimate plan usage. Anthropic's number or nothing.
6. 🔴 Never write to `~/.claude/.credentials.json`, and never implement token refresh.
7. Live status comes from the pid registry, never from `settings.json` hooks.
8. Nothing on the 5-second poll may trigger a full re-render.
9. Don't pre-seed sessions or test data. The dashboard reads what's there.
10. On macOS / Linux, omit the Win32 window-foregrounding code — `ctypes.windll` doesn't
exist there. Fall back to `open` / `xdg-open` for the folder button and to AppleScript /
`wmctrl` for focus.
11. If pip-install fails (corporate firewall, no network), tell the user clearly and stop —
don't try to vendor or work around it.
12. If a probe in Step 2 comes back empty, build without that feature and say so. Never ship
a status column or a gauge that can't get data.