A local control tower for every Claude Code session you have ever started. Browse, search, rename, tag, archive, resume — and for the ones running right now, see what each is doing, how full its context window is, which project it is really working on, and end it without touching the terminal. 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 these accumulate — dozens, then hundreds, eventually a gigabyte or more — and the only way to navigate them is the built-in /resume picker: a flat list of recent sessions with truncated names.
This 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 / description / archive / resume. It is a Python web server (Starlette + uvicorn) plus a single self-contained HTML file. No build step, no framework. Mutations are append-only JSONL — the same mechanism Claude Code uses internally — so your edits and a /rename typed inside a live session don’t fight.
But an archive browser is the small half. The version documented here reads four layers at once, and the interesting engineering is in the three that aren’t the disk scan: Claude Code’s own running-session registry, Anthropic’s usage endpoint, and a derived layer that answers questions the transcript never states outright — how full is this session’s context window, which project did it actually build, and was it ever closed out properly. Together those turn a file browser into something you keep open on a second monitor and act from.
Waiting / Working / Shell / Idle for every running session, polled every 5 seconds. Waiting and Working both mark the row and light the card, so a running session is readable without reading its pill — and the one blocked on you is the loudest thing on the page. In the list they differ by shape (a filled row against an outlined one), never by movement.
Stop a running session and close the terminal window it was launched in — which turns out to hinge entirely on the exit code. See the trap below.
Send a slash command to a session that is already open — focus its tab, then type. It refuses unless it can prove which window it is about to type into.
Click a status pill and that session’s terminal is raised, its tab selected, and the caret lands on the prompt.
Two gauges — current window and weekly — read from the same endpoint Claude Code’s own /usage screen calls. Never estimated, each heated by its own number.
How full each session’s context window is, as an exact token count read off the transcript tail, live for the running ones.
Every session’s cwd says the same thing, so it is useless as a project. Derive one from the files it touched and the skills it invoked instead.
Which sessions were actually wrapped up and which were abandoned mid-thought — read from the recorded skill run, not inferred from a proxy.
Whole calendar years, newest on top, rolling into next year on their own. Click a day and it opens as a card — not as a filter you then can’t escape.
One tile per project folder, worked-in first, untouched ones dimmed. Two corner actions: copy the path, open it in your file manager.
Flip the list to golden-rectangle tiles — one per running session, status and title large. Same wrapper, same drawer, same actions.
Every session across every project. Type to filter metadata; press Enter to search inside the transcript text itself.
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.
Pick a row or a card up and put it where you want it. Deliberately not persisted — a reload goes back to Modified newest-first.
One click launches a terminal and reattaches, with Remote Control on, through a single launch builder so no button can ship without it.
One page, four sources, and knowing which answers what is most of the design.
| Layer | What it is | What it gives the UI | Refresh |
|---|---|---|---|
| The archive | ~/.claude/projects/**/*.jsonl — every session ever | The table: names, tags, turns, timestamps, transcript search | Startup, a background sweep every 60s, and the Refresh button — all three the same incremental pass (see Trap 28) |
| The registry | ~/.claude/sessions/<pid>.json — Claude Code’s own record of what is RUNNING | Status column, pin-to-top ordering, live rename sync, jump, end | Every 5 seconds |
| Plan usage | One authenticated GET to Anthropic’s usage endpoint | The two gauges | Cached 60s, 30s back-off after a failure |
| Enrichment | Derived facts nothing writes down: context tokens, project, per-day activity, closeout | Context meters, project tags, the heatmap, the closeout badge | Cached by (size, mtime); live sessions re-derived on a throttle |
The fourth layer is the one people skip, and it is where the dashboard stops being a list. It is also where the subtlest bugs live — see The Enrichment Layer below.
~/.claude/projects/, parses each JSONL line by line, and extracts name, description, tags, turns, timestamps, origin (the original cwd), and the first user message. Sidechain / subagent files are skipped whole.~/.claude/sessions/<pid>.json — the same registry its own FleetView reads. Parse those every poll, verify each entry against the real process table, and reap the stale ones./api/sessions, /api/live, /api/projects, /api/heatmap, …), and serves the dashboard HTML.claude --resume <uuid>. UUID rather than name — emoji-safe across code pages.This is the part that is 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 is 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 — 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 is 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 is working and how fresh the entry is |
Treat that schema as discoverable rather than guaranteed — read a real file on your own machine before building against it, and make an unknown status degrade to a neutral pill instead of crashing the poll.
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. Give every live cell a data-<field>-for="<id>" attribute and write into it. 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.
Everything not on the poll is only re-read when the user clicks Refresh — which means it can go stale under an open tab, and a stale number is indistinguishable from a wrong one. A closeout badge here stayed lit after a successful closeout, so the user clicked it and got a session telling him it was already done. The badge was stale, not wrong, and there is no way to tell those apart by looking. If a fact can change while the tab is open, put it on the poll.
⚠️ Then check the poll’s scope, because that is the half of this rule that failed here. The poll only knows about sessions that are running, so putting the badge on it fixed a session that closed out while still open and did nothing for one that closed out on its way out the door — which is every properly closed session. Two days later that was the next bug. See Trap 28.
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 is 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.
A dashboard that can start a session and jump to one but not finish one will happily show you six things running and offer nothing to do about any of them. Ending is the mirror of launching, and it is harder than it looks for reasons that have nothing to do with killing a process.
If you launched the session as cmd /k <script>, that shell outlives the script by design. Stop the Claude process and the window survives as an empty prompt. Do that a few times and the user has a taskbar full of windows that look like junk — and they will close them, including the one that is your own server. That exact sequence produced two “unexplained crashes” in this project’s history. End the wrapper too.
But only when it is your wrapper. A terminal the user opened themselves and typed claude into has the identical shape, so the process name cannot tell them apart. Check the wrapper’s command line for the shape your own launcher writes. No match → end the session, leave the window alone. “Not ours” is a safe answer, never a failure.
Windows Terminal’s closeOnExit defaults to graceful: it closes a pane when the process exits 0, and deliberately keeps it open on anything else so you can read the error. taskkill /F always exits 1, and there is no flag to change that. So the first two versions of this feature killed everything correctly and left the terminal sitting on screen every single time.
The fix is TerminateProcess(handle, 0) through ctypes, which lets you choose the exit code. It does not take the process tree the way taskkill /T does, so walk descendants yourself — deepest first.
The verification for the version above originally read window closed: yes next to a wrapper PID. Both facts were true and the conclusion was false — a process had died, and that was recorded as a window closing. The user found out by looking at his screen. Count windows and tabs through UI Automation, before and after. And note that enumerating by process gives you one window per process, while a tabbed terminal puts several windows in one process — enumerate from the automation desktop root instead.
Once you add an End button, the user goes looking for it on a running session — and finds it sitting next to Resume and any other “reopen this session” control you shipped. Two of those three launch a second window on the same transcript. Every control on a live row must act on the process that is running: Resume becomes Jump, and so does anything else that would have relaunched. The invariant is checkable in one line on a rendered page — select every launch action inside a live row and assert the result is empty.
Sooner or later you will want the dashboard to send something to a session that is already open — a slash command, a nudge. There is no clean channel for it: the registry’s messaging socket path is null, and the bridge id it carries belongs to the vendor’s own remote service, not to you. Don’t go hunting for one; it has been hunted for. The only route is focus the window, then synthesise keystrokes.
Which means the keys land in whatever window is in front, and your code has no idea which one that is. Refuse to type unless all three hold: the focus call succeeded, it reported that it actually owns the foreground (raising a window is not the same thing — see Trap 5), and on the tab-matching path it confirmed which tab it matched. A miss here doesn’t fail quietly — it types a slash command into whatever the user was reading. Never relax those checks to make a test pass.
Two consequences worth planning for. A session that has never been named cannot be reached this way, because the title you match on is the session’s own name and a fresh one hasn’t written it yet — that is the correct failure, not a bug to route around. And send the Enter separately, after a pause: Claude Code opens its slash-command autocomplete the instant a / arrives, and a newline in the same burst races that menu.
Because the pid file also carries the session name, a /rename typed inside a running session reaches the dashboard on the next poll — no Refresh click, no rescan of the 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. Send names for every live session on every poll, not just the ones that changed, so a tab left open across a server restart catches up.
The registry carries no turn count, and the full disk scan only runs on Refresh — so the Turns column freezes on exactly the row where the number is actually moving. Count incrementally off the end of the transcript instead: remember a byte offset per session, stat() it each tick, and read only what was appended.
Two details make it correct rather than approximately correct. The offset must always be the end of the last complete line — the file is being appended to while you read it, so a half-written line has to be left unconsumed and re-read whole next tick, or it parses as truncated JSON and that turn is lost permanently. And on first sight, count the whole file yourself rather than trusting the scan’s number with an end-of-file offset: the scan finished at some earlier instant, and everything written in the gap would be skipped forever. Owning both numbers is the only way the baseline and the offset cannot disagree.
The in-memory cache rebuilds on startup and on /api/refresh, and nowhere else — so reloading the browser page re-reads the same stale cache and can never reveal a session created after the server started. That is correct behaviour and completely baffling from the outside. Fix it for the case that matters: any running session missing from the cache gets scanned individually on the poll and added, and the poll response carries the new ids so the browser knows to re-fetch. A live session being invisible is the worst failure this dashboard can have.
Everything live on the page comes from one poll. If that poll’s catch swallows failures, a stopped server looks exactly like a quiet one — the page keeps showing its last reading and only gives itself away when the user clicks something and gets “Failed to fetch”, a message that names the symptom and hides the cause.
fetch() rejects with a TypeError when the connection can’t be made at all.Four facts the transcript does not state, and every one of them has a trap in it. This is the layer that makes the dashboard worth opening, and it is also where you can be confidently, invisibly wrong.
A session’s context occupancy is input_tokens + cache_creation_input_tokens + cache_read_input_tokens on its last main-thread assistant entry. That sum is the context the model was holding. It grows monotonically inside a session, so the last block is the current figure — there is never a reason to read a 30 MB transcript. Read the last 256 KB, widen once if no complete line turned up, then give up.
Skip sidechain entries. A subagent runs in its own context window; counting its usage as the session’s reports a number about a window the user isn’t filling.
The transcript records the model but never the variant, and the registry carries no token data, so the denominator has to be decided by you. Pick an evidenced default rather than a guess, raise the cap automatically if a session is ever seen holding more than it (so a bar can never read as over-full), and always print the raw token count next to the bar. That is what keeps this honest: the numerator is exact and stays true whatever the denominator turns out to be.
If the user starts most sessions from one folder, the cwd column reads identically on every row and is dead weight. The real answer is in two places and they get merged:
| Signal | Why |
|---|---|
| File paths touched | Count occurrences of each known project folder across the raw bytes. Decisive in practice — one session here scored 225 hits for one project against 6 for another |
| Skill attribution | The front-door skill that produced a turn names its project outright. Worth roughly 25× a path hit: a session can read a file in a folder it isn’t working on, but it doesn’t invoke that project’s skill by accident |
Match folder names against the live directory listing, so a stray path fragment can never invent a project. And return two different answers rather than one: the primary project (sums to exactly the session count) and every folder the session touched (sums to more). Confusing those two is easy and produces a panel whose label disagrees with its own click result.
A session is indexed the moment it appears, when its transcript is a few kilobytes and has touched nothing — and that empty answer is then served forever. Measured here: a session’s project was derived at 4,659 bytes and still being served when the file had grown to 2.7 MB. Anything derived from a live transcript needs a refresh path. Throttle it (every 45 seconds, and only if the file actually grew), do it off-thread, and drop the state when the session ends so a resume re-derives cleanly.
⚠️ “For live sessions” is not a refresh path, it is half of one, and shipping only that half is what produced Trap 28. Dropping the state when the session ends means the last answer — the one computed from the finished transcript, the only one that was ever going to be complete — is the one you never take. Something has to re-read a session after it stops running.
The “was this session closed out?” badge originally inferred the answer from “does it have a description?”, on the reasoning that the closeout skill writes one. The premise was false — a plain describe command clears the same marker — and the badge was wrong on 182 of 239 sessions, all of them in the flattering direction. Claude Code had been recording the skill attribution in the transcript the whole time. Reading that took the badge to 239/239.
The general form: a label must measure the thing it names. And when a proxy is unavoidable, make the unknown case default to the unflattering answer — never call a session closed on a guess.
The heatmap and the Turns column both said “turns”. For one day the heatmap said 1,922 and the seven session rows underneath it summed to 73. Neither was wrong: the column counted prompts the user typed, the day counter incremented on every assistant line — every tool call, every file read — about 55× more. They were answers to different questions wearing the same word.
The fix is never to relabel one. Count the same event in both, and go further: have the day card’s rows carry that day’s share of each session’s turns, so the column literally adds up to the header. A session spanning two days would otherwise contribute its whole lifetime count to both cards.
Project folders, skills and per-day activity need a whole-file pass; context does not. Keep the pass byte-level — substring prefilters plus a few small regexes, never a JSON parse per line. Measured here: 7 seconds for 1.5 GB across 245 files. Cache the results keyed by (size, mtime) so a file is re-read only when it actually changed, write the cache atomically (temp file plus rename), and warm it in a background thread rather than at import — the startup scan already blocks the server from binding and must not get worse.
⚠️ The cache is keyed by the file, not by the counting rule. Change how you count and it will happily serve the old numbers forever. Delete it and rebuild.
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 a current-window block and a weekly block, each with a utilization percentage and an ISO reset time. That is the whole feature.
The first version of this gauge reconstructed usage 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. If it stops working permanently, delete the gauge.
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 the user out of the tool they 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: that path 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.
Cache the reading for 60 seconds by comparing “now” against the time of the last successful fetch — and that single, reasonable-looking decision is a bug. One failure leaves the cache permanently older than its TTL, so the early-return stops firing and every poll re-hits the endpoint: every 5 seconds instead of every 60. The observed failure is HTTP 429. The retry was manufacturing the exact condition it was retrying against; one blip became a self-sustaining rate-limit loop.
Pace retries with a separate timestamp for “last attempt”. It has to be a second variable: advancing the success clock on failure would also reset the reading’s apparent age, so a stale figure would never cross its staleness threshold and the gauge would show a ten-minute-old number forever instead of admitting it has none. Keep the error reason across back-off ticks too, or the gauge dims for thirty seconds while reporting no cause.
On the higher plans the weekly limit is usually the one that actually bites, so give it its own gauge rather than making it a footnote that appears past 50%. Each gauge’s colour comes from its own percentage. Taking the max of the two and using it for both was right when only one number was on screen; with both showing, reddening the current-window bar because the weekly one is high is a lie about the current window.
| State | Means |
|---|---|
| Idle | No reading at all — shows a dash |
| Stale | Real numbers, not current. The last good reading, inside the staleness window |
| Offline | The dashboard server is unreachable — comes with the offline banner |
Users will ask what the greying means, so make sure each state can answer for itself in a tooltip. A dim that says nothing is the complaint.
An inline pill put a live 6% into a 46px track: about three pixels of colour, which is technically filled and visually nothing. Make the track full width and thick, give the fill a min-width so a live 1% shows as a sliver — and exempt a true 0%, or an unused window looks like a used one.
Resume, new, closeout, and anything you add later: several endpoints, one function that builds the command line. That 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.
--remote-control [name] will swallow a trailing prompt argument as its name. Always follow the bare form immediately with another flag. Probe it if unsure: pass a bogus flag after it and confirm the parser errors on the unknown option rather than consuming it.A server restarted from inside a Claude Code terminal inherits variables that make every session it launches believe it is a nested child — and those sessions write no transcript at all. They never appear in the dashboard and can never be resumed. Silent data loss, and the only visible symptom is one line at startup that scrolls away.
Strip the in-session markers from the environment you hand to the spawn, and set the persistence flag explicitly. Verify by file count after a launch, not by reading the banner.
Two lessons here cost more debugging time than anything in the application code, and both are about the process rather than the program.
Launching the server in a console window means that window IS the server — closing it kills the process. Every restart from a runbook spawned another one, and stopping only the Python inside left the shell behind as an empty window. The taskbar filled with what looked like junk, the user cleared them out the way anyone would, and the dashboard died. Twice that was written up as an unexplained crash.
Run it windowless (pythonw.exe on Windows, or a daemon/agent elsewhere) and send the diagnostics to a log file instead — which is strictly better anyway, because a log file is something an AI session can actually read.
The moment the server has no console of its own, every console-subsystem helper it spawns gets one: PowerShell hops, kill commands, launcher wrappers. What was silent before becomes “a terminal opens and shuts within a second” on every click. Spawn those with the no-window flag — and then verify the flag didn’t suppress a window you actually wanted, because the same flag applied to the launcher wrapper must not hide the Claude session it opens.
Three more things worth building in on day one:
__main__. A windowless interpreter sets them to None, so the first print() raises — and the likeliest printer is the scanner running at import, before __main__ is ever reached.Exception — a bind failure raises SystemExit, which is the most likely cause and the one a narrow catch misses. Append with a timestamp; the pattern across several deaths is worth more than any one of them.This is the single most likely reason a change “didn’t work”. The dashboard is a long-running process, usually started hours earlier. Patch it, POST to it, and you are testing the old code. Identify the server by the port it owns, not by matching its command line — a command-line match also hits every test process that merely mentions the file, which here found 10 processes when exactly one was the server. And give it time to come back: the startup scan runs before the port binds, so a ten-second wait loop reports a dead server that is merely still scanning.
The layout that survived a year of use is title bar / options bar / [sidebar | panel], with three interchangeable panels — Sessions, Projects, Heatmap — and both top bars frozen.
They will otherwise end up written out in five places — the row selector, the header selector, and every breakpoint — and kept in step by hand. One custom property holds the track list; each breakpoint redefines only that plus which columns it hides. Drop columns by position so rows and headers can never disagree, and never give the name column a bare 1fr: it floors at min-content and collapses to literally 0px once the fixed columns outgrow the row. Use minmax().
Compute your breakpoints from the track budget rather than picking round numbers. The ones here were guessed originally, and the table scrolled sideways by 167px at a common desktop width — a horizontal scrollbar nobody sees because it is below the fold.
A right-aligned date column starts its ink well inside its track, which leaves a wide gap on one side of the neighbouring number and a narrow one on the other. The number was measured sitting 21px left of the visual midpoint while being perfectly centred in its own track. The user described it exactly right: “you centered it on the words, not on the column.” Chasing that with a nudge is a measurement that goes stale the moment a format changes; making no column hug an edge is a construction that can’t.
aspect-ratio alone holds the shape and lets the content spill, so the box measures perfect while looking broken. Every card here was exactly 1.618 with its badges hanging 35–55px past its own padding. Pair it with min-height: min-content — the ratio is what the card is whenever it can be, and it grows instead of clipping when it can’t. And size a tile to what it has to say, not to how many fit across.
The overflow itself had a second cause worth knowing: an undeclared grid track is sized by max-content, and a line-clamped box still reports its full unwrapped width. The rows were declared and the column wasn’t, so a long title silently made the track hundreds of pixels wider than its card. Declare both axes as minmax(0, …).
If a region rewrites its own innerHTML — a sidebar, a filter list — then a plain addEventListener at boot survives exactly until the first rebuild, after which the button is still visibly there and silently does nothing. That is far worse than it disappearing. Emit those controls from a builder and re-attach inside the same function, or route them through a document-level delegated handler.
Know which of your regions is which, because the reverse mistake is just as easy: a bar that nothing ever rebuilds should be static markup wired once, and re-emitting it on a timer is how you get a full re-render every five seconds.
The heatmap originally set a day filter and then navigated to the session list — so the click carried the user off the only panel that had a way to clear it, and the filter stuck with no visible exit. The fix is the absence of the state, not a hook that clears it. A “clear on leaving” hook would have fought the panel switch that caused the problem. Open the day as a card over the top instead, with four ways out: a close button, the backdrop, Escape, and clicking the same day again.
Same rule for any filter that can be reached from more than one place: route every entry point through one setter, and reset dependent toggles there. A filter that survives into a different project is invisible state the user never asked for.
Render the day card’s contents with the same row markup as the main list and everything works for free — the status pill, the resume button, the expand behaviour — because the handlers are delegated. But now a running session’s row exists twice, both copies carrying the same live-repaint attributes. Your poll’s lookups must stay querySelectorAll over the whole document. A first-match lookup repaints one copy and leaves the other frozen, and it fails silently — showing stale numbers rather than nothing.
The usage payload carries an absolute reset timestamp, and the module helpfully turns it into “minutes left” on the way past. Both reach the browser, and reaching for the convenient one is the mistake: that figure was computed on the server, when the reading was taken, and the same module deliberately serves a reading up to ten minutes old whenever a refresh fails. Anything time-sensitive must be computed at paint time from the absolute value.
The reason this one is worth a number: it fails plausibly. A clock bar driven by the derived figure keeps moving, keeps agreeing with the text under it, and is simply up to ten minutes behind — there is no wrong-looking state to notice. The general form: when a cached payload contains both a fact and something computed from it, cache the fact and recompute the rest.
Two things on every row — the project tag and the closeout badge — were rebuilt by exactly one event: a click on the Refresh button. Everything the 5-second poll patches in is scoped to sessions that are running right now, and that scope is precisely wrong for both. The closeout command is the last thing a session does, so the session stops being live in the same tick that makes it closed. The project tag is derived from a transcript that was nearly empty when the session first appeared (Trap 12), and nothing re-derives it once the session has stopped growing.
Measured on the day it was reported: the server had been up for a day, the last Refresh click was eighteen hours earlier, and every session started since — seven of them — carried no project and an unclosed badge. One call to the refresh endpoint corrected all seven at once, which is the proof that both derivations were right and only the schedule was missing. Neither symptom is a bug you can find by reading the code that computes the answer.
The fix is a background sweep, and it needs a cheap rescan to be possible at all. The full rescan re-parsed every transcript — 12.1s across 1.5 GB — which is expensive enough that it can only ever be a button. Key it by (size, mtime) per file and reuse the scan of anything that hasn’t moved: 12.1s → 0.17s, and the button itself from ~25s to ~0.3s. Then run it on a 60-second timer in a daemon thread, with the button and the timer calling one shared function so they cannot drift into disagreeing about what a refresh means.
Two details that are easy to get wrong:
🔴 The server being right is only half of it — the browser holds its own copy of every row. Put a revision counter on the poll payload and re-fetch when it moves. Bump it off a comparison of the facts that actually appear on a row, never off “did any file change”: while someone is working, a transcript changes every minute (their own), so the latter re-renders the table under their hands every sixty seconds for nothing. Everything else already repaints in place — only the facts nothing else recomputes are worth a re-render.
Once the surface carries the state — a tile that pulses, a badge that breathes — something is timed to something else, and the obvious way to keep them together is to give both the same duration and let them start at the same moment. They do start together. Then the poll runs.
The status cell was repainted the honest way, cell.innerHTML = pillFor(id), every five seconds. That assignment destroys the pill element and builds a new one, and a new element starts its CSS animation at 0% — so a 1.1s pulse was re-phased every 5s against a tile that had been running continuously since it rendered. Within one cycle the two were visibly beating against each other, and nothing in the code that draws either one is wrong.
Assign only when the string actually changed (if (cell.innerHTML !== html)) — an unchanged status then costs nothing and touches nothing. The general form is worth carrying past this project: anything synchronised to another element’s animation is broken by any rebuild of that element, and “just refreshing the text” is a rebuild.
Two live states worth telling apart at a glance, so both got a tinted row and the busier one got a slow pulse. On the card view — three tiles on screen — it worked. In the list, at 265 rows, it was rejected the moment it was seen: “I don’t like it blinking.”
The reason is worth keeping. An animation only reads as information if the eye is already resting on it; everywhere else in a scrolling list it is motion in the periphery, which costs attention without delivering any. Shape survives scrolling and movement does not. The two states now differ by fill: the blocked one is a filled wash across the row, the busy one is the same accent on the row’s top, left and bottom edges only, both fading out to the right. Neither moves. The pulse stayed exactly where it earns its keep — on the card, where there are three of them and one of them needs you.
Three places in this build had a number that fell out of step with the thing it was supposed to match, and all three were fixed the same way — by making the match structural:
ResizeObserver, because the title is a clamp() on the viewport and the bar wraps at narrow widths. It cannot be a literal.claude command on PATHpip install starlette uvicorn — the build skill checks and prompts if missing~/.claude/sessions/<pid>.json. The skill probes for it and skips the feature cleanly if it isn’t there~/.claude/.credentials.json. Probed the same wayPROMPT.md below as ~/.claude/skills/ccsm-build/SKILL.mdhttp://localhost:<your-port>. Every Claude Code session you have 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, roughly in order of value:
/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. Every other action is already an HTTP POST, so “new session” and “resume last” keys are a one-line curl each.index.html. Swap the accent, swap the fonts, replace the texture — the structure is unopinionated.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 control tower for every Claude Code CLI session on disk. Browse, search, rename,
tag, archive, resume and launch them — and for the ones running right now, see what each is
doing, how full its context window is, which project it is really working on, and end it
without touching the terminal. It reads and writes the actual JSONL 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`. This indexes them, surfaces them in
a sortable / searchable browser interface, and lets you act on them.
It is a Python web server, not a static site. Every dashboard action mutates real files.
## The four layers
| Layer | What it is | What it gives the UI | Refresh |
|---|---|---|---|
| Archive | `~/.claude/projects/**/*.jsonl` | The table: names, tags, turns, timestamps, search | Startup + Refresh button only |
| Registry | `~/.claude/sessions/<pid>.json` — Claude Code's own record of RUNNING sessions | Status column, pin-to-top, live rename, jump, end | Every 5s |
| Plan usage | Anthropic's OAuth usage endpoint | The two gauges | 60s cache, 30s back-off |
| Enrichment | Derived facts nothing writes down | Context meters, project tags, heatmap, closeout badge | Cached by (size, mtime) |
## Architecture
- **Scanner** — walks the projects directory, parses each JSONL, exposes a small import
API. Also runnable standalone for grepping sessions from the shell.
- **Enrichment** — a separate byte-level pass for context tokens, project attribution,
per-day activity and skill attribution. Cached; runs standalone.
- **Server** — Starlette + uvicorn. Imports both. REST endpoints for list / search /
rename / tag / summary / archive / resume / closeout / live / projects / heatmap /
focus / end / 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,
ends a session and its launcher wrapper.
- **Usage module** — one authenticated GET, cached and backed off; runs standalone.
- **Describe CLI** (optional) — lets a session write its own one-line description.
- **Launcher** (optional) — `.bat` / `.sh`. Starts the server windowless + opens the browser.
## Running it
Launch it **windowless** (`pythonw.exe` on Windows; a LaunchAgent or systemd user unit
elsewhere). Diagnostics go to `server.log`; a crash log is written by the process itself.
🔴 **A console window is not a status light — it IS the process.** Every "the server
crashed" incident in this project's history was someone closing a console window. Restarts
spawned one each and killed only the Python inside, so empty windows piled up, looked like
junk, and got cleared out along with the live one.
⚠️ **Editing the server does nothing until you restart it.** Identify it by the PORT it
owns, never by matching its command line — a command-line match also hits every test
process that merely mentions the file.
⚠️ **Give it time to come back.** The startup scan runs before the port binds. A ten-second
wait loop reports a dead server that is merely still scanning.
## Session data format
Each line of a session JSONL is a JSON object. Key entry types:
| Type | Field | Purpose |
|---|---|---|
| `custom-title` | `customTitle` | Session name |
| `summary` | `summary` | Session description |
| `tag` | `tag` | Session tag (empty string clears) |
| `user` | `message.content` | User messages — this is what a "turn" counts |
| `assistant` | `message.usage` | Token counts. Their sum on the LAST non-sidechain entry is the session's context occupancy |
| `assistant` | `message.model` | Note it does NOT record the context-window variant, so the CAP is not on disk anywhere |
| — | `cwd` | Where the session was started |
| — | `timestamp` | ISO timestamp |
| — | `isSidechain` | If true, skip this file entirely (subagent transcript) |
| — | `attributionSkill` | The front-door skill that produced the turn — the basis of project attribution AND of the closeout badge |
**The format is append-only.** To change a name, description or tag, append a new entry —
the last one wins. This is the same mechanism Claude Code uses internally, so dashboard
edits and a `/rename` typed inside a live session coexist cleanly. Never open a session
file in write mode.
## 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. Tints the row, and its card pulses |
| **Working** | Claude is mid-turn. Marks the row too — the accent on its top, left and bottom edges only |
| **Shell** | Sitting in a shell command |
| **Idle** | Running, nothing happening |
| **Ended** | Not running — hollow, no fill, no dot |
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.
**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.
### What rides the 5-second poll, and it is a closed list
`sessions · counts · names · renamed · turns · context · closeout · added · usage`
Everything else — the description text, the project tag, the archive state — is only
re-read on Refresh. **Adding a field here is how you stop something going stale under an
open tab**, and a stale number is indistinguishable from a wrong one.
🔴 **Nothing on the poll may trigger a full re-render.** Repaint cells in place via
`data-<field>-for` attributes. Only a name change, or a change in which sessions are live,
re-sorts. Wrap the render function in a counter and idle two ticks: it must stay at 0.
### Turn counts are tailed, not scanned
The registry carries no turn count and the full scan only runs on Refresh, so the number
freezes on the one row where it is moving. Count incrementally off the end of the
transcript: one `stat()` when nothing was appended, a read of only the new bytes when
something was.
- **The stored offset is always the end of the last COMPLETE line.** The file is being
appended to while you read it; a half-written line must be left unconsumed or it parses
as truncated JSON and that turn is lost forever.
- **On first sight, count the whole file yourself.** Trusting the scan's number with an
EOF offset permanently skips everything written between the scan and the `stat()`.
- **The turn test must mirror the scanner's exactly**, or the number visibly jumps the next
time someone clicks Refresh.
### Ending a session
`POST /api/sessions/{id}/end` stops the session **and closes the terminal window it was
launched in**.
🔴 **Killing the Claude process alone is the wrong answer.** If you launched it as
`cmd /k <script>`, that shell outlives the script — so you get an empty console window
left on screen, which is exactly the pile of dead-looking windows described above.
🔴 **The exit code is what closes the window.** Windows Terminal closes a pane when the
process exits `0` and deliberately keeps it open on anything else. `taskkill /F` always
exits `1`. Use `TerminateProcess(handle, 0)` so you can choose the code — and walk
descendants yourself, since it doesn't take the tree.
🔴 **Only close YOUR wrapper.** A terminal the user opened themselves has the identical
shape, so check the command line for the launcher pattern you write. No match → end the
session, leave the window alone.
🔴 **Prove a window closed by counting windows**, through UI Automation, before and after.
A dead process is a different fact. (And enumerate from the automation desktop root — one
handle per process undercounts a tabbed terminal.)
🔴 **Nothing shown on a live session may launch a second copy of it.** Resume becomes Jump
on a running session, and so does anything else that would relaunch. Checkable in one line
on a rendered page.
## Driving a running session
Sending something to a session that is already open — a slash command, a nudge — has no
clean channel. `messagingSocketPath` in the registry is **null**, and `bridgeSessionId` is
the vendor's own remote bridge, not something you can post to. Don't go hunting; it has been
hunted for. The only route is **focus the window, then synthesise keystrokes.**
🔴 **All of the safety is in refusing to type.** Keys go to the FOREGROUND window and your
code has no idea which one that is. Bail before touching the keyboard unless all three hold:
1. the focus call returned ok, **and**
2. it returned **`focused: true`** — raising a window is not owning the foreground, which is
why that path needs three stacked tricks, **and**
3. on the tab-matching path, **which tab matched**.
A miss doesn't fail quietly — it types a slash command into whatever the user was reading.
**Never relax these to make a test pass.**
⚠️ **A never-named session cannot be reached this way**, because the title you match on is
the session's own name and a fresh one hasn't written it yet. That is the correct failure,
not a bug to route around.
⚠️ **Send the Enter separately, after a pause.** Claude Code opens its slash-command
autocomplete the instant a `/` arrives, and a newline in the same burst races that menu.
## The usage gauge
Reads Anthropic's own OAuth usage endpoint — the same call Claude Code's `/usage` screen
makes — using the token already on disk. Two gauges: current window and weekly.
🔴 **Never estimate.** The first version reconstructed usage windows from transcripts and
read **41% against a real 1%**. If the endpoint can't be reached, show a dash. If it stops
working permanently, delete the gauge.
**Each gauge carries two bars: allowance used, and how much of the window has elapsed.** The
second is computed in the browser from the reset **timestamp** — never from a derived
"minutes left" in the same payload, which was calculated when the reading was taken and goes
stale with it.
🔴 **Never write the credentials file and never refresh the token here.** It races with
Claude Code's own refresh and can log you out of the tool you work in.
🔴 **Pace retries with a SEPARATE "last attempt" clock.** Gating the cache on the last
*successful* fetch means one failure makes every poll re-hit the endpoint — 12× the rate —
and the observed failure is 429, so the retry manufactures the condition it is retrying
against. It has to be a second variable, or a stale reading never ages out.
**Each gauge is heated by its own percentage.** Reddening the current-window bar because
the weekly number is high is a lie about the current window.
## Enrichment
Facts the transcript doesn't state:
| Fact | How |
|---|---|
| Context occupancy | Sum the token fields on the last non-sidechain assistant entry. Read the TAIL — it grows monotonically, so there is never a reason to read a 30 MB file |
| Project | Count project-folder path hits across the raw bytes, plus `attributionSkill` weighted ~25× (a session can read a file in a folder it isn't working on; it doesn't invoke that project's skill by accident) |
| Per-day activity | Count user turns per calendar day, by the SAME rule the Turns column uses |
| Closeout | Read the recorded `attributionSkill` for your closeout skill. Do NOT infer it from "has a description" |
🔴 **Skip sidechains in the context count.** A subagent runs in its own window.
🔴 **The window SIZE is not recorded anywhere** — the denominator is your decision. Print
the exact token count beside the bar so the true number survives whatever you chose, and
raise the cap automatically if a session is ever seen holding more than it.
🔴 **A value derived from a file still being written is a snapshot, not a fact.** One
session's project was derived at 4,659 bytes and served at 2.7 MB. Re-derive for live
sessions on a throttle (45s, and only if the file grew), off-thread.
🔴 **Two places showing the same-looking number must count the same event.** The heatmap
said 1,922 turns for a day whose rows summed to 73 — one counted prompts, the other
counted assistant steps. Both correct; the word was shared. Also make a day card's rows
carry *that day's* share, so the column adds up to the header.
Cache by `(size, mtime)`, write atomically, warm in a background thread. Measured: ~7s for
1.5 GB across 245 files, because the pass is byte-level — substring prefilters and a few
regexes, never a JSON parse per line.
⚠️ **The cache is keyed by the FILE, not by the counting rule.** Change how you count and
it serves the old numbers forever. Delete it and rebuild.
## Launching
**All launching goes through ONE builder.** Resume, new, closeout and anything added later.
That is the only way a future button can't silently ship without Remote Control, without
the name sanitiser, or without the environment scrub.
- Resume by session **UUID**, never by name. Names carry emoji; launcher scripts are ASCII.
- Sanitise anything interpolated into a script: strip non-ASCII, strip shell
metacharacters, collapse whitespace, truncate, fall back to a UUID-derived name.
- **Never place a flag with an OPTIONAL value last.** `--remote-control [name]` will
swallow a trailing prompt argument as its name.
- 🔴 **Scrub the child-session markers from the spawned environment.** A server started
from inside a Claude Code terminal inherits them and passes them on; those sessions write
NO transcript, never appear here, and can never be resumed. Verify by file count after a
launch, not by reading the banner.
- ⚠️ A windowless server makes every console child flash. Spawn helpers with the no-window
flag — and verify it didn't suppress a window you actually wanted.
## Interface notes
- **Default sort: Modified newest-first, with running sessions pinned above everything in
every sort order.** Sorting by Status skips the pin and ranks by urgency instead.
- **Define the table's columns exactly once**, in one custom property; breakpoints redefine
only that. Drop columns by POSITION so rows and headers can't disagree. The name column
is `minmax()`, never a bare `1fr` — that floors at min-content and collapses to 0px.
- **Compute the breakpoints from the track budget.** Guessed ones scrolled sideways by
167px at a common desktop width.
- **A clicked heatmap day opens a card, not a filter.** The filter version navigated away
from the only panel that could clear it. The fix is the absence of the state.
- **A card reusing row markup puts a row in the DOM twice** — keep the poll's lookups
`querySelectorAll` over the whole document, or one copy silently freezes.
- **A rebuilt container orphans its listeners.** Anything inside a region that rewrites its
own `innerHTML` must be re-wired in the same function, or delegated.
- **Aspect ratio is a floor** (`min-height: min-content`), never a ceiling on content. And
declare BOTH grid axes as `minmax(0, …)` — an undeclared track sizes to max-content, and
a line-clamped box still reports its full unwrapped width.
- **Every percentage bar is one solid accent colour.** The length is the reading.
- **Fix legibility at the token, not at the use** — and check the font size first. Nothing
rescues 8px type.
## API endpoints
### Read
| Method | Path | Purpose |
|---|---|---|
| GET | `/` | Serve the dashboard |
| GET | `/api/sessions` | All sessions. Optional `?project=`, `?min_turns=` |
| GET | `/api/search?q=` | Deep transcript search — returns matching session ids |
| GET | `/api/sessions/{id}/preview` | First few messages |
| GET | `/api/sessions/{id}/digest` | Full scan: tools used, files touched, first/last |
| GET | `/api/live` | Running sessions + counts + both usage windows + live context/turns/closeout |
| GET | `/api/live/summary` | Plain text, e.g. `2 LIVE / 1 WAITING / 23% 5H` — for a hardware key |
| GET | `/api/projects` | Every project folder with counts, turns and last-used |
| GET | `/api/heatmap` | `{date: {turns, sessions, ids}}` |
### Write
| Method | Path | Body | Purpose |
|---|---|---|---|
| POST | `/api/sessions/{id}/rename` | `{name}` | Append custom-title |
| POST | `/api/sessions/{id}/tag` | `{tag}` | Append tag (empty clears) |
| POST | `/api/sessions/{id}/summary` | `{summary}` | Append summary |
| POST | `/api/sessions/{id}/archive` | — | Move the JSONL to the archive folder |
| POST | `/api/sessions/{id}/resume` | — | Launch a terminal resuming this session |
| POST | `/api/sessions/{id}/closeout` | — | Same, with a closeout prompt on arrival |
| POST | `/api/sessions/{id}/focus` | — | Raise that session's terminal. 409 if not running |
| POST | `/api/sessions/{id}/closeout-live` | — | Focus a RUNNING session and type the closeout command into it. Refuses unless it can prove which window it is typing into |
| POST | `/api/sessions/{id}/end` | — | Stop it and close its window. 404 if not running, 409 if the pid isn't Claude |
| POST | `/api/new-session` | — | Launch a fresh session |
| POST | `/api/open-folder` | `{project}` optional | Open a folder in the file manager, resolved against the real directory listing |
| POST | `/api/refresh` | — | Re-scan from disk |
## Known limitations
- **The cache only rebuilds on `/api/refresh`.** Reloading the browser re-reads the same
cache. Running sessions self-index on the poll, so a *live* session can't be invisible —
but a dormant one created after startup needs the Refresh button.
- **Two writers on one JSONL can interleave mid-line.** Don't resume a session from here
while that same session is open elsewhere. Don't take a file lock either — on Windows
that fights with whatever Claude Code is doing internally.
- **Deep search re-reads every transcript per query.** Fine at a hundred sessions; an FTS5
index is the upgrade path. Watch the growth rate, not the current size.
- **Ending a session is a hard stop.** Every graceful route is worse: a Ctrl+C hits the
wrapping shell too and parks it on a prompt, and typing `/exit` needs the window focused
first. The transcript is appended per turn, so every completed turn is already on disk
and the session stays resumable — only a turn in flight is lost.
---
name: ccsm-build
description: >
Build the Claude Code Session Manager — a local web dashboard that browses, searches,
renames, tags, archives, resumes, launches and ENDS Claude Code CLI sessions stored at
~/.claude/projects/, with a live status column driven by Claude Code's own running-session
registry, real plan-usage gauges, per-session context meters, derived project attribution
and an activity heatmap. 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 control tower for Claude Code CLI sessions: every session on disk,
which ones are running right now and what each is doing, how full each one's context window
is, which project each really worked on, the user's real plan usage, and the ability to
browse, search, rename, tag, describe, archive, resume, start and END 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.** Almost every note below exists because the
obvious approach was tried and was wrong — usually in a way that looked like it was working.
## 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, a
different window-focus strategy, and a different answer on whether End can close a
terminal window.
2. **Project folder.** Where should the dashboard files be written? Full path.
3. **Default working directory.** When the dashboard launches a NEW session, which folder
should it start in?
4. **Where do your project folders live?** A single parent directory whose subdirectories
are the user's projects (e.g. `~/code`, `~/Documents/Builds`). This is what powers
project attribution, the Projects panel and the project filter. If they don't have one,
skip those features rather than inventing a taxonomy.
5. **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.
6. **End-a-session button?** (Recommended: yes, if live status is in.) Stops a running
session and closes the terminal it was launched in. Say plainly that it is a hard stop
and that only a turn in flight is lost.
7. **Plan usage gauges?** (Recommended: yes.) Reads their real current-window and weekly
utilization from Anthropic's own endpoint using the token already on disk. Read-only.
You will verify the credentials file in Step 2.
8. **Enrichment layer?** (Recommended: yes.) Per-session context meters, project
attribution, the activity heatmap, and the closeout badge. Costs one byte-level pass
over the archive, cached.
9. **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.
10. **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.
11. **A closeout skill name.** (Only if they took enrichment.) The badge reads the recorded
skill attribution, so it needs to know which skill name means "this session was wrapped
up". If they don't have one, skip the badge — do NOT substitute a proxy.
12. **Accent colour.** Hex code for buttons, highlights, bars. Default `#3b82f6`.
13. **Port.** Default `5111`.
14. **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 layer the user opted into:**
- **Live status:** does `~/.claude/sessions/` exist and contain `<pid>.json` files? Read one
and print its keys. Treat the schema as discoverable, not guaranteed — build against what
you actually find. 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 gauges:** 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 gauges.
- **Enrichment:** does the parent projects directory from question 4 exist, and does a
sample transcript actually contain `attributionSkill` and `message.usage` blocks? Grep
one file. If usage blocks are absent, build the context meter as unavailable rather than
computing something else.
- **End:** on Windows, check whether `closeOnExit` is set in the terminal's settings. If it
is explicitly `always`/`never`, say so — the exit-code mechanism below assumes the
default (`graceful`).
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, ending a session (if opted in).
- `<project>/usage.py` — plan usage (if opted in).
- `<project>/enrich.py` — context / project / heatmap / closeout derivation (if opted in).
- `<project>/describe.py` — session self-description CLI (if opted in).
- A scanner module — either at `~/.local/bin/cc-sessions.py` (also useful as a standalone
CLI) or alongside `server.py` if that isn't writable or on PATH.
- A launcher script — only if opted in.
### Scanner
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 meta, compact summaries, tool results with no text,
or only a `<system-reminder>` / `<command-name>` 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 label 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`, `--min-turns`, `--json`).
🔴 **Keep the scanner's output shape stable.** If it doubles as a standalone CLI, new
per-session fields belong in the enrichment module and get bolted on when the server
serializes a session — not added to the scanner's dict.
### Enrichment module (`enrich.py`) — only if opted in
Four derived facts, none of which the transcript states outright.
**1. Context occupancy.** `input_tokens + cache_creation_input_tokens +
cache_read_input_tokens` on the session's **last main-thread assistant entry**. That sum IS
the context the model was holding.
- It grows monotonically inside a session, so the last block is the current figure. **Read
the TAIL** — the last ~256 KB, widened once if no complete line turned up, then give up.
Never read a 30 MB transcript for this.
- 🔴 **Skip sidechain entries.** A subagent runs in its own context window; counting its
usage reports a number about a window the user isn't filling.
- 🔴 **The window SIZE is not recorded anywhere.** The transcript names the model but not
the context variant, and the registry carries no token data — so the denominator is your
decision. Pick an evidenced default from the user's own corpus rather than a guess, raise
the cap a tier automatically if any session is ever seen holding more than it (so a bar
can never read as over-full), and **always print the exact token count beside the bar**.
The numerator is exact; that is what keeps this honest.
**2. Project attribution.** Every session's `cwd` is usually identical, which makes it dead
weight as a project column. Merge two signals instead:
| Signal | Weight |
|---|---|
| Occurrences of each known project folder name across the raw bytes | 1 per hit |
| `attributionSkill` naming a project's front-door skill | ~25 per hit |
The weighting isn't arbitrary: a session can *read* a file in a folder it isn't working on,
but it doesn't invoke that project's skill by accident.
- **Match folder names against the LIVE directory listing** of the parent projects folder,
so a stray path fragment can never invent a project.
- Return **two** answers: `primary` (the one folder that won — sums to exactly the session
count) and `folders` (every folder touched — sums to more). ⚠️ **They are different
questions.** Whatever number a filter's label prints must be computed the same way that
filter selects, or the label will disagree with its own result.
- Ask the user whether a single incidental file read should count as "worked in this
folder". Both answers are defensible; **do not pick it for them.** (A relative cutoff —
"under N% of the winner doesn't count" — is the wrong shape: it made a folder that 64
sessions had touched render as "0 sessions". If tightening is wanted, use an absolute
floor on hit count.)
**3. Per-day activity**, for the heatmap. 🔴 **Count the SAME event the Turns column
counts** — user prompts. Counting assistant lines instead reads about 55× higher, and then
two places on the page show numbers that look like the same thing and aren't. Mirror the
scanner's turn test exactly.
**4. Closeout state.** 🔴 **Read the recorded skill attribution**, i.e. whether the user's
closeout skill name appears in that session's `attributionSkill` values. Do NOT infer it
from "does this session have a description" — that proxy was wrong on **182 of 239**
sessions here, all in the flattering direction, because a plain describe command clears the
same marker. **A label must measure the thing it names.** With no marker available, the
answer is "not closed out" — never call a session closed on a guess.
**Make the pass affordable:**
- It is **byte-level** — substring prefilters plus a few small regexes, never a JSON parse
per line. Measured: ~7 seconds for 1.5 GB across 245 files.
- **Cache results keyed by `(size, mtime)`**, written atomically (temp file + rename).
- **Warm it in a background thread**, never at import — the startup scan already delays the
port binding and must not get worse. Sessions simply carry no project tag for a few
seconds after start.
- ⚠️ **The cache is keyed by the FILE, not by your counting rule.** Change how you count and
it will serve the old numbers forever. Say so in a comment, and delete it when you change
a rule.
🔴 **A value derived from a file that is still being written is a snapshot, not a fact.**
A session is indexed the moment it appears, when its transcript is a few KB and has touched
nothing — and that empty answer is then served forever. Measured here: a project derived at
**4,659 bytes**, still being served at **2.7 MB**. Re-derive for LIVE sessions on the poll,
throttled (every ~45s, and only if the file actually grew), off-thread, and drop the state
when the session ends so a resume re-derives cleanly.
Make it runnable standalone.
### 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.
🔴 **Return WHY it worked, not just that it did** — at minimum `ok`, `focused` (did we end
up owning the foreground, not merely raise a window) and, on the tab path, which title
matched. A caller that wants to type needs all three; see below.
- `end_session(session_id)` → stop it and its launcher wrapper (if opted in)
- `send_text(session_id, text)` → focus, then type into a RUNNING session (if opted in)
**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.
**Ending a session — five rules, and four of them are non-obvious:**
1. 🔴 **Kill the launcher wrapper too, not just Claude.** If you launched via
`cmd /k <script>`, that shell outlives the script — so stopping Claude alone leaves an
empty console window on screen. Do that a few times and the user has a taskbar full of
windows that look like junk, and they will close them, including your own server's.
2. 🔴 **Only close YOUR wrapper.** A terminal the user opened and typed `claude` into has
the identical process shape. Check the wrapper's **command line** for the pattern your
launcher writes (the `/k`, the script extension, your own filename stems). No match →
end the session, leave the window alone. "Not ours" is a safe answer, never a failure.
⚠️ On Windows a process's command line lives in its PEB; a PowerShell hop is a
reasonable way to read it. **Never call that from the poll** — it costs a few hundred ms.
3. 🔴 **The exit code is what closes the window.** Windows Terminal's `closeOnExit` defaults
to *graceful*: it closes a pane on exit code `0` and deliberately keeps it open on
anything else. `taskkill /F` always exits `1`, with no flag to change it — so a correct
kill leaves a dead pane on screen every time. Use `TerminateProcess(handle, 0)` through
ctypes so you can choose the code. It does NOT take the process tree the way
`taskkill /T` does, so walk descendants yourself, deepest first. Keep taskkill only as a
fallback for when a handle can't be opened, and note in a comment that reaching it means
the window survives.
4. 🔴 **Re-prove liveness immediately before killing.** PIDs get recycled and a registry
file can outlive its session. Re-read the pid file for `procStart`, re-run the same
start-time comparison, and additionally require that the process at that pid is actually
named `claude*` — or kill nothing and return a 409. **That, not the confirmation dialog,
is the safety story.**
5. **No optimistic update.** Let the next poll report the death. The page then only ever
shows a death it has confirmed, rather than claiming one the OS might have refused.
⚠️ On macOS / Linux, build End as a plain process stop and **say in your report that it
does not close the terminal window** — the exit-code mechanism is Win32-specific.
⚠️ **Prove a window closed by counting windows, not processes.** A dead process is a
different fact from a closed window, and conflating them is how this shipped broken twice.
Count through UI Automation before and after, enumerating from the desktop root — one
handle per process undercounts a tabbed terminal.
**Typing into a running session — only if the user wants it, and the safety is all in the
refusal:**
There is no local IPC. `messagingSocketPath` in the registry is **null** and
`bridgeSessionId` is the vendor's own remote bridge, not something you can post to. Don't go
hunting for a clean channel — it has been hunted for. The only route is focus-then-type.
🔴 **Synthesised keystrokes go to the FOREGROUND window and your code has no idea which one
that is.** Bail before touching the keyboard unless **all three** hold: the focus call
returned ok, it returned `focused: true` (raising a window is not owning the foreground —
that is why it needs three stacked tricks), and on the tab path it reports **which title it
matched**. A miss doesn't fail quietly; it types a slash command into whatever the user was
reading. **Never relax these to make a test pass**, and make the failure a refusal with a
reason, never a best effort.
⚠️ **A never-named session cannot be reached this way** — the title you match on is the
session's own name, and a fresh session hasn't written one yet. Return "no tab matched" and
type nothing. That is the correct failure.
⚠️ **Send the Enter separately, after a pause.** Claude Code opens its slash-command
autocomplete the instant a `/` arrives; a newline in the same burst races that menu.
⚠️ **Keep the launching path and the typing path as separate endpoints.** One is for a
session that is NOT running, the other only for one that IS — and the front end picks between
them off the live state. Collapsing them is how you get rule 13 wrong.
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 as separate gauges** — on the higher plans the weekly limit is usually the one that
actually bites, and it should not be a footnote that only appears past 50%.
🔴 **Each gauge is heated by its OWN percentage.** Taking the max of the two and using it
for both is right when only one number is on screen; with both showing, reddening the
current-window bar because the weekly one is high is a lie about the current window.
🔴 **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.
🔴 **RULE — pace retries with a SEPARATE "last attempt" clock.** Cache for 60 seconds by
comparing now against the last **successful** fetch — and then gate *retries* on a second
variable. If you don't, one failure leaves the cache permanently older than its TTL, the
early-return stops firing, and every poll re-hits the endpoint: **every 5 seconds instead of
every 60**. The observed failure is HTTP 429, so the retry manufactures the exact condition
it is retrying against — one blip becomes a self-sustaining rate-limit loop. It has to be a
second variable: advancing the success clock on failure would also reset the reading's
apparent age, so a stale figure would never age out and the gauge would show a ten-minute-old
number forever instead of admitting it has none. Keep the last error reason across back-off
ticks, or the gauge dims while reporting no cause.
Warm it in a background thread at startup so the first paint has it.
**Worth adding: a second bar showing how much of the WINDOW has gone.** Two identical tracks
per gauge — allowance used in your accent colour, window elapsed in white — turns two numbers
into one reading: accent ahead of white means the user is burning the allowance faster than
the clock refills it. The payload names when each window resets and never how long it runs,
so the two lengths (5 hours, 168 hours) are declared once as constants; the week's reset lands
on a UTC Sunday midnight, so `resets_at − 168h` is the previous Sunday and the arithmetic
closes.
**Two bars need a key, and it fits on the line that already exists** — the reset time on the
left, two labelled dots on the right. Colour the dots from the same two variables the fills
use, so the key can never name a colour that is no longer on screen, and lay that line out
with `flex-wrap` + `margin-left:auto` rather than `space-between`: they are identical at full
width, but on a narrow tile where the key wraps to its own line, `space-between` throws it
back to the left edge.
🔴 **RULE — compute that bar from the reset TIMESTAMP, never from a derived "minutes left".**
Both are in the same payload, but the derived one was computed on the server *when the reading
was taken*, and you deliberately serve a stale reading for up to ten minutes (see the table
below). A bar driven by it drifts by up to ten minutes while looking perfectly healthy; an
absolute timestamp still yields the right answer at paint time from an hour-old payload. Emit
the empty second track in the unavailable branch and in the first-paint markup too, or the bar
grows a row when the first reading lands and shoves the page down.
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 |
A `force=True` bypass is fine for the CLI. **Do not wire it to a button** — spam-clicking
Refresh recreates the retry storm by hand.
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 a path with a hyphen in the name.
In-memory cache of all sessions, refreshed on startup and on `/api/refresh`.
| Method | Path | 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` | Running sessions + counts + usage + live turns / context / closeout |
| GET | `/api/live/summary` | PLAIN TEXT, e.g. `2 LIVE / 1 WAITING / 23% 5H` — for a hardware key |
| GET | `/api/projects` | Every project folder: sessions, primary count, turns, last used |
| GET | `/api/heatmap` | `{date: {turns, sessions, ids}}` |
| POST | `/api/sessions/{id}/rename` | Append `{"type":"custom-title","customTitle":name}` |
| POST | `/api/sessions/{id}/tag` | Append `{"type":"tag","tag":tag}`; empty clears |
| POST | `/api/sessions/{id}/summary` | Append `{"type":"summary","summary":summary}` |
| POST | `/api/sessions/{id}/archive` | Move JSONL to an archive folder |
| POST | `/api/sessions/{id}/resume` | Launch a terminal resuming this session |
| POST | `/api/sessions/{id}/closeout` | Same, with the closeout prompt on arrival |
| POST | `/api/sessions/{id}/focus` | Raise that session's terminal; 409 if not running |
| POST | `/api/sessions/{id}/closeout-live` | Focus a RUNNING session and type the closeout command in |
| POST | `/api/sessions/{id}/end` | Stop it + its wrapper; 404 if not running, 409 if not Claude |
| POST | `/api/new-session` | Launch a fresh session in the default working dir |
| POST | `/api/open-folder` | Open a folder in the file explorer |
| POST | `/api/refresh` | Re-scan from disk |
🔴 **`/api/open-folder` and any file-serving route must resolve a name against the real
directory listing**, never join it onto a path. Otherwise a crafted name walks out of the
folder.
**Sync live state into the cache on every poll.** Four things:
1. **Names.** When a registry entry carries a name and its `nameSource` is ABSENT, adopt it
— that's a real `/rename` from inside the session. 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.
2. **Turn counts**, incrementally off the transcript tail — see below.
3. **Context occupancy**, which the same tail read gives you for free. A tick with nothing
appended keeps the last reading rather than dropping to 0.
4. **Project + closeout**, re-derived on a throttle for live sessions only.
**Turn counts must be tailed, not scanned.** The registry has no turn count and the full
scan only runs on Refresh, so the Turns column freezes on the one row where the number is
moving. Keep `{offset, turns}` per live session:
- A quiet session costs **one `stat()`**; a busy one costs a read of only the appended bytes.
- 🔴 **The offset is always the end of the last COMPLETE line.** The file is being appended
to while you read it, so a half-written line must be left unconsumed and re-read whole
next tick — otherwise it parses as truncated JSON and that turn is lost forever. Test this
by cutting a real file at random byte positions, including mid-line, and confirming the
total is identical every time.
- 🔴 **On first sight, count the whole file yourself** rather than trusting the scan's count
with an EOF offset. The scan finished at some earlier instant, and every line written in
that gap would be skipped permanently. Owning both numbers is the only way the baseline
and the offset cannot disagree. Above some size cap, fall back to trusting the scan and
tailing from EOF, and note that the count may start low.
- 🔴 **The turn test must mirror the scanner's exactly.** If it drifts, the number visibly
jumps the next time someone clicks Refresh.
- Run it off-thread; that one seeding read must not stall every other tab's poll.
- Drop state for ended sessions, so a resume re-seeds cleanly.
**A live session must never be invisible.** The cache only rebuilds on `/api/refresh`, so
reloading the browser re-reads the same stale cache and can never reveal a session created
after startup. That is correct and completely baffling from the outside. Scan any *running*
session missing from the cache on the poll, add it, and return the new ids in the poll
payload so the browser knows to re-fetch. This is the worst failure the dashboard can have —
if you add another way sessions enter the cache, make sure the live path still wins.
**Decide what rides the poll and treat it as a closed list.** Everything else is only
re-read on Refresh, which means it can go stale under an open tab — and a stale number is
indistinguishable from a wrong one.
**ALL launching goes through ONE builder function.** Several 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.
- Take the arrival prompt as a parameter (so closeout is the same path, not a copy).
- 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.
**Run the server windowless, and log to files.**
🔴 A console window is not a status light — **it IS the process**, and closing it kills the
server. A restart loop that spawns one window per run and kills only the interpreter inside
leaves a pile of empty windows that look like junk; the user clears them out, live one
included. Every "the server crashed" incident in this project's history was that. Use
`pythonw.exe` on Windows (a LaunchAgent / systemd user unit elsewhere), send the startup
banner and scanner warnings to `server.log`, and have the process write its own crash log.
Three details that go with it:
- **Redirect stdout/stderr at MODULE level, not inside `__main__`.** A windowless
interpreter sets them to `None`, so the first `print()` raises — and the likeliest printer
is the scanner running at import, long before `__main__`.
- **Guard against a duplicate launch at the very top of the file**, before the import scan.
Users press the shortcut while it's already running; that is normal. Without the guard a
duplicate scans the whole archive for ~40s, fails to bind, and logs a traceback to the
crash log *every time* — burying the one real crash the file exists to catch.
- **Catch `BaseException` around the server run**, not just `Exception`. A bind failure
raises `SystemExit`, which is the most likely cause and the one a narrow catch misses.
Append entries with a timestamp; the pattern across several deaths is worth more than any
one of them.
⚠️ **A windowless parent makes every console child visible.** `powershell.exe`, `cmd.exe`
and friends get their own window when there's no parent console to attach to. Spawn them
with `CREATE_NO_WINDOW` — and then **verify the flag didn't suppress a window you actually
wanted**, because the same flag on the launcher wrapper must not hide the Claude session it
opens. (It doesn't: `start` allocates a new console for its own child, and the flag applies
only to the wrapper. Confirm it rather than assuming it.)
### Dashboard (`index.html`)
Single self-contained HTML file. Inline CSS + JS, no framework, no build step. Dark theme,
the user's accent colour, monospace for chrome and numeric columns.
**Layout:** title bar / options bar / [sidebar | panel]. Both top bars frozen. Three panels
— Sessions, Projects, Heatmap — with a sidebar holding a full-list/active switch, the panel
nav, a recent-activity strip and a project filter list.
**Table columns:** Name · Status · Resume · Project · Context · Turns · Created · Modified ·
Closed · Expand.
- 🔴 **Define the track list ONCE**, in a single custom property. Breakpoints redefine only
that plus which columns they hide. Written out per breakpoint, it will be five copies kept
in step by hand.
- 🔴 **Drop columns by POSITION** so rows and headers can never disagree.
- 🔴 **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.
- **Compute the breakpoints from the track budget** (name floor + fixed tracks + gaps +
padding + sidebar + scrollbar), don't pick round numbers. Guessed ones here scrolled
sideways by 167px at a common desktop width — invisible, because a horizontal scrollbar
sits below the fold.
- **Centre every column in its own track** rather than aligning to an edge. A right-aligned
date starts its ink well inside its track, which leaves a lopsided gap around the
neighbouring number — measured 21px off-centre while being perfectly centred in its track.
**Sort:** Modified newest-first by default — 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
(waiting → shell → working → idle).
**Row interactions:** click to expand; click the name to rename inline (Enter saves, Escape
cancels); inline Resume; tags with a click-to-remove `×`; a drawer with origin, id, editable
description, first-message preview, a lazily-fetched tools/files digest, Archive with a
confirm, and End when the session is live.
**Live layer:**
- Poll `/api/live` every 5 seconds.
- Pills: **Waiting** (pulsing, and tint the row), **Working** (accent, breathing dot),
**Shell**, **Idle**, and **Ended** for everything else — hollow, outlined, no fill.
⚠️ Do **not** leave the status cell blank for dormant sessions. It is defensible on
density grounds and it reads as a broken feature; separate by weight, not by absence.
- Put the waiting count in `document.title`.
- Click a pill → `POST /api/sessions/{id}/focus`.
- 🔴 **Nothing shown on a LIVE session may launch a second copy of it.** Resume becomes
Jump; a closeout badge becomes Jump. Assert it: on a rendered page, selecting every launch
action inside a live row must return an empty list.
🔴 **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. Give every live cell a `data-<field>-for="<id>"` attribute and
repaint 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.
**Offline handling.** The poll's `catch` must not swallow failures, or a stopped server
looks exactly like a quiet one and the first symptom is a useless "Failed to fetch" toast.
Distinguish a transport failure (`fetch` rejects with a `TypeError`) from an application
error; show a banner after **two consecutive misses** (one miss is a restart, and a banner
that flashes on every restart gets ignored); put it **in the document flow**, not fixed, so
it can't be scrolled past; drain the colour from pills and gauges while down; clear on the
next good poll with no reload.
**Heatmap panel.** Whole calendar years, newest on top.
- 🔴 **Derive the year list from the data and the clock** — earliest year through
`new Date().getFullYear()` — and class each square past/future off today. No dated code and
no scheduled job: next January a new block appears on its own and fills in day by day. Test
it by rendering against an injected future date.
- `grid-auto-flow: column` over 7 rows only lines the weekdays up if the run starts on a
Sunday and ends on a Saturday — that's what leading and trailing pad cells are for.
- Size the cell so a year fits one row down to a sensible width, then floor it and let the
container scroll. ⚠️ `clientWidth` includes the element's own padding, and a weekday
gutter has its own gap — forgetting either overshoots by ~40px, which is exactly enough to
scroll by a hair at the widths where it should have fitted.
- Use ONE intensity ruler across all years, so years stay comparable.
- 🔴 **Clicking a day OPENS it as a card. It is not a filter.** The filter version set state
and then navigated to the session list — carrying the user off the only panel that had a
way to clear it. **The fix is the absence of the state**, not a hook that clears it; a
clear-on-leave hook would fight the panel switch that caused the problem. Four ways out:
close button, backdrop, Escape, clicking the same day again.
- 🔴 **Render the card's contents with your ordinary row markup.** Everything works for free
because the handlers are delegated — but that puts a session's row in the DOM **twice**,
both copies carrying the same live-repaint attributes. Your poll's lookups must stay
`querySelectorAll` over the whole document. A first-match lookup repaints one copy and
leaves the other frozen, **silently**.
- 🔴 **Give the card's rows THAT DAY's share of each session's turns**, so the column adds up
to the header. A session spanning two days otherwise contributes its whole lifetime count
to both cards. And exclude those cells from the live turn repaint, which only knows the
lifetime figure.
- 🔴 **Compute the card's WIDTH from its own column tracks** and write the arithmetic beside
them. Pinned at a chosen pixel value, it silently stopped fitting the moment the tracks
were widened — producing a scrollbar inside the card and an arrow hanging past its edge.
**Card view for live sessions.** A grid of tiles, one per running session.
- 🔴 **`aspect-ratio` is a FLOOR, not a ceiling.** Alone it holds the shape and lets content
spill, so the box measures perfect while looking broken. Pair it with
`min-height: min-content`, and cap the tile's width so a one-column layout doesn't hand it
the whole panel.
- 🔴 **Declare BOTH grid axes as `minmax(0, …)`.** An undeclared track sizes to max-content,
and a line-clamped box still reports its full unwrapped width — which pushed content
35–55px past its own padding here.
- Use `auto-fill`, not `auto-fit`: auto-fit collapses empty tracks, so two live sessions
stretch to fill the row.
- Size a tile to what it has to say, not to how many fit across.
- Keep the wrapper, the drawer and every action identical to the table's, so expand /
rename / tag / archive / end and the in-place repaint all still work.
**Rebuild discipline.** 🔴 If a region rewrites its own `innerHTML` (a sidebar, a filter
list), every control inside it must be emitted from a builder and re-attached in the same
function — or routed through a document-level delegated handler. A plain `addEventListener`
at boot survives until the first rebuild, after which the button is **still visibly there
and silently dead**. Know which regions are which: a bar that nothing rebuilds should be
static markup wired once, and re-emitting it on a timer is a re-render storm.
**Derive geometry, don't pick it.** Measure the frozen bars' height into custom properties
with a `ResizeObserver` (a clamped title and wrapping bars can't be a literal). Share one
width variable between elements that must share an edge. Draw paired icons as paths on one
shared geometry rather than as font glyphs at a shared size — a font's circle and a font's
plus are two designers' opinions about how much of an em to fill.
**Colour.** Every percentage bar is ONE solid accent colour with a `min-width` so 1% is
visible and a true 0% exempted — **the length is the reading**. A white-to-accent ramp was
tried here and read pink at the values the bars actually sit at. Fix legibility at the token,
not at the use — and check the font size before the colour, because nothing rescues 8px type.
### 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
--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_CODE_SESSION_ID`** → most recently modified
non-sidechain `.jsonl`. ⚠️ **Get that variable name exactly right.** A one-character
mistake here means the env branch never fires, every run falls through to the mtime guess,
and the guess is a coin flip whenever two windows are open — because a session's transcript
isn't flushed every turn. That typo caused three separate wrong-session writes before
anyone looked at the variable name. Confirm with `env | grep -i session`.
- 🔴 **`--show` is NOT a safe pre-check for a write.** Each invocation resolves the target
independently, so `--show` naming session A and a `--summary` seconds later can land on
session B. **Always pass `--session-id` on a write.**
- **A blind write should refuse, not guess.** If the env var is absent AND two or more real
sessions were touched recently, exit non-zero and print the candidates. Reads may guess;
writes may not.
- 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.
- A clobbered description is recoverable, because the file is append-only and only the last
summary entry wins. Say so in the docstring.
### Launcher (optional)
Windows `.bat` — note it starts the server **windowless**:
```bat
@echo off
start "" http://localhost:<port>
start "" "<python-dir>\pythonw.exe" "<project>\server.py"
```
macOS / Linux `.sh`:
```bash
#!/usr/bin/env bash
cd "<project>"
( sleep 1 && ${OPEN:-open} "http://localhost:<port>" ) &
nohup python3 server.py > server.log 2>&1 &
```
(`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.
Derived data (the enrichment cache) is the exception and belongs in its own cache file — it
is not user metadata and must not become a new JSONL entry type.
### 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, creationflags=CREATE_NO_WINDOW)`
- macOS: `osascript -e 'tell app "Terminal" to do script "…"'`
- Linux: `gnome-terminal -- bash -c "…; exec bash"` (fall back to `xterm -e`)
Use recognisable filename stems for those scripts — the End feature identifies its own
wrapper by them.
### 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.
### Handle a row for a session that no longer exists
An open tab holds a list from before something was archived. A raw "HTTP 404" from archive /
rename / tag reads as a broken feature. Take a 404 from those three, drop the row, fix the
header count, and say the session is already archived.
### 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 the server in the background, **windowless**.
2. Wait — the startup scan runs BEFORE the port binds, so a short wait loop will report a
dead server that is merely still scanning. Poll for up to 60s.
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 .../api/live` — expect valid JSON. With nothing running it
must return an empty set, NOT an error.
6. If the gauges were 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. If enrichment was built: run it standalone and confirm it finishes, then confirm a second
run is fast (the cache is working). Confirm the context number for one session by reading
that session's last assistant entry by hand.
8. If End was built: do NOT test it on a real session the user cares about. Launch a
throwaway one from the dashboard, give it a prompt so it has a turn on disk, end it, then
check the process is gone, the registry file is gone, the transcript survives — and
**count terminal windows before and after**. ⚠️ Claude Code garbage-collects a zero-turn
transcript on its own, so a session that never took a prompt will look like End destroyed
it.
9. Kill the background process by the PORT it owns, not by a command-line match.
10. 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. Three failures a screenshot will not show you: a grid column resolving to 0px,
a re-render storm on the poll, and content overflowing a card that measures perfectly. Force
each status value by writing into your live state and re-rendering, so you can check all the
pills without waiting for a real `waiting` session — but sample what you need inside the same
evaluation, because the 5-second poll wipes forced state within one tick.
⚠️ **Measure overflow on INK, not on element boxes.** A padded box's bounding rect includes
its padding, so a naive edge probe reported all 47 project names colliding with a corner icon
when none of them were. Use a `Range` over the text contents.
## Step 5 — Report
Tell the user:
> **Claude Code Session Manager built.**
>
> - Files: `<list of paths written>`
> - Launch: the launcher, or the windowless command
> - Browser: `http://localhost:<port>`
> - Layers included: `<live status / end / usage gauges / enrichment / describe>`
> - Anything skipped, and why (e.g. no pid registry found on this machine)
>
> ⚠️ **Editing `server.py` does nothing until you restart the server.** 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.
>
> ⚠️ **The list only rescans disk on the Refresh button.** Reloading the page re-reads the
> same cache. (Running sessions self-index, so a live one can't go missing.)
>
> ⚠️ **Hard-reload the browser after any frontend change** — `index.html` is served from
> cache, and a half-old page reads exactly like a broken edit.
Then offer, but do not build unasked: auto-start at login (which is what makes the live
status trustworthy — it is only true while the server is up).
## 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. 🔴 Never infer a state from a proxy when the real event is recorded. Check first.
10. 🔴 Anything derived from a live transcript needs a refresh path — a value computed from a
file still being written is a snapshot, not a fact. **And that path must not be scoped to
sessions that are running**, or you never take the answer computed from the finished
file, which is the only one that was ever going to be complete.
11. 🔴 The same rule for a cached API payload: cache the absolute FACT and recompute anything
time-sensitive at paint time. A number derived at fetch time goes stale with the payload,
and it goes stale plausibly.
12. 🔴 Two places showing the same-looking number must count the same event. Don't relabel
one; make them agree.
13. 🔴 Run the server windowless. A console window is not a status light, it is the process.
14. 🔴 A control shown on a running session must act on the process that is running, never
launch a second copy.
15. 🔴 If anything synthesises keystrokes, it must REFUSE unless it can prove which window it
is about to type into. A best-effort keystroke lands in whatever the user was reading.
16. Don't pre-seed sessions or test data. The dashboard reads what's there.
17. On macOS / Linux, omit the Win32 window-foregrounding and exit-code code — `ctypes.windll`
doesn't exist there. Fall back to `open` / `xdg-open` for the folder button, AppleScript /
`wmctrl` for focus, and a plain process stop for End (and say it won't close the window).
18. If pip-install fails (corporate firewall, no network), tell the user clearly and stop —
don't try to vendor or work around it.
19. If a probe in Step 2 comes back empty, build without that feature and say so. Never ship
a status column, a gauge or a badge that can't get data.
20. Where a judgment call is genuinely the user's — what counts as "worked in this project",
what the context-window denominator should be — **ask, and record the answer in a comment.
Don't pick it for them silently.**
21. 🔴 No displayed fact may depend on the user clicking Refresh. Anything not on the poll is
recomputed by a background sweep on a timer, and the browser re-fetches off a revision
counter that moves only when a displayed fact actually changed. Make the rescan
incremental — keyed by `(size, mtime)` — before you put it on a timer, or it is too
expensive to be anything but a button.