← All AI Builds
Claude Code Skill Pack

Claude Code Session Manager

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.

What Is This?

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.

What It Can Do

Live Status Column

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.

End A Session

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.

Type Into A Running Session

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.

Jump To A Session

Click a status pill and that session’s terminal is raised, its tab selected, and the caret lands on the prompt.

Real Plan Usage

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.

Context Meter Per Session

How full each session’s context window is, as an exact token count read off the transcript tail, live for the running ones.

Real Project Attribution

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.

Closeout Tracking

Which sessions were actually wrapped up and which were abandoned mid-thought — read from the recorded skill run, not inferred from a proxy.

Activity Heatmap

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.

Projects Panel

One tile per project folder, worked-in first, untouched ones dimmed. Two corner actions: copy the path, open it in your file manager.

Card View For What’s Live

Flip the list to golden-rectangle tiles — one per running session, status and title large. Same wrapper, same drawer, same actions.

Browse & Deep Search

Every session across every project. Type to filter metadata; press Enter to search inside the transcript text itself.

Self-Describing Sessions

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.

Drag To Reorder

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.

Resume, Fully Wired

One click launches a terminal and reattaches, with Remote Control on, through a single launch builder so no button can ship without it.

The Four Layers

One page, four sources, and knowing which answers what is most of the design.

LayerWhat it isWhat it gives the UIRefresh
The archive~/.claude/projects/**/*.jsonl — every session everThe table: names, tags, turns, timestamps, transcript searchStartup, 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 RUNNINGStatus column, pin-to-top ordering, live rename sync, jump, endEvery 5 seconds
Plan usageOne authenticated GET to Anthropic’s usage endpointThe two gaugesCached 60s, 30s back-off after a failure
EnrichmentDerived facts nothing writes down: context tokens, project, per-day activity, closeoutContext meters, project tags, the heatmap, the closeout badgeCached 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.

How It Works

1
Scan the archive. A Python scanner walks ~/.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.
2
Enrich it. A second pass derives what the scanner can’t: context occupancy from the tail of each transcript, the project each session really worked in, per-day activity, and whether a closeout skill ever ran. Cached by file size and mtime so unchanged files are never re-read.
3
Watch what’s running. Claude Code writes one JSON file per live session to ~/.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.
4
Meter the plan. A single authenticated GET returns current-window and weekly utilization plus reset times. Cached, warmed at startup, backed off on failure, and rendered as two full-width gauges.
5
Serve. A Starlette server holds the scan in memory, exposes a small REST API (/api/sessions, /api/live, /api/projects, /api/heatmap, …), and serves the dashboard HTML.
6
Mutate by appending. Renames, tags and description edits APPEND a new entry to the JSONL. Last write wins on read. This is exactly what Claude Code does internally, so the two layers coexist.
7
Launch. Resume / New / Closeout write a temporary launcher script and spawn it in a new terminal running claude --resume <uuid>. UUID rather than name — emoji-safe across code pages.
8
End. Terminate the Claude process and the shell wrapper the dashboard launched it in, with a chosen exit code, after re-proving the PID is still the process you think it is.

The Live Layer

This is the part that is genuinely non-obvious, so here it is in full. The build skill below encodes all of it.

Status comes from Claude Code’s own registry, not from hooks

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:

FieldValuesWhat it gives you
statusbusy · shell · idle · waitingThe pill. waiting is the one that matters — that session is blocked on you
kindinteractive · bg · daemonFilter background workers out of the human-facing list
tempoactive · idle · blockedA coarser signal than status
waitingForfree textExactly what it is waiting on — put it in the tooltip
name / nameSourcestring / derivedLive rename sync (see below)
pid / procStartint / timestampLiveness + PID-reuse detection
cwd / updatedAtpath / ISOWhere 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.

Trap 1 — stale pid files are normal

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.

Trap 2 — nothing on the 5-second poll may trigger a full re-render

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.

Trap 3 — decide what rides the poll, and treat it as a closed list

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.

Trap 4 — focusing a terminal tab is two different problems

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.

Trap 5 — raising a window needs three tricks, not one

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.

Ending a session — and why it has to take the window with it

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.

Trap 6 — killing Claude alone leaves a dead terminal on screen

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.

Trap 7 — the exit code is what closes the window

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.

Trap 8 — “the window closed” is not something you can conclude from a dead process

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.

Trap 9 — nothing shown on a live session may launch a second copy of it

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.

Trap 10 — driving a running session means typing into it, and all the safety is in refusing to type

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.

Renames sync themselves

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.

Turn counts have to be tailed, not scanned

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.

A new session must not need a Refresh to appear

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.

When the server stops, the page has to say so

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.

The Enrichment Layer

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.

Context occupancy — read the tail, never the file

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.

Trap 11 — the window size is not recorded anywhere

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.

Project attribution — two signals, weighted

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:

SignalWhy
File paths touchedCount 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 attributionThe 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.

Trap 12 — a value computed 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 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.

Trap 13 — before deriving a state from a proxy, check whether the real event is recorded

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.

Trap 14 — two places showing the same-looking number must count the same event

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.

Make the pass affordable, then cache it

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 Plan Usage Gauge

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.

Trap 15 — do not estimate usage from transcripts

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.

Trap 16 — 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. 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.

Trap 17 — a naive retry makes your own outage

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.

Two gauges, and each one heats itself

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.

Three dims that mean three different things

StateMeans
IdleNo reading at all — shows a dash
StaleReal numbers, not current. The last good reading, inside the staleness window
OfflineThe 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.

Trap 18 — a 3px fill reads as “not working”

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.

Launching — One Builder, No Exceptions

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.

Trap 19 — scrub the child-session markers from the spawned environment

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.

Running The Server Itself

Two lessons here cost more debugging time than anything in the application code, and both are about the process rather than the program.

Trap 20 — a console window is not a status light, it is the process

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.

Trap 21 — a windowless parent makes every console child flash

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:

Trap 22 — editing the server does nothing until you restart it

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 Interface

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.

Define your table columns exactly once

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.

Trap 23 — centre columns in their tracks, not on their words

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.

Trap 24 — a fixed aspect ratio must be a floor, never a ceiling on content

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, …).

Trap 25 — a rebuilt container orphans every listener inside it

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.

A clicked day is a card, not a filter

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.

Trap 26 — a card that reuses rows puts them in the DOM twice

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.

Trap 27 — a derived number goes stale with the payload it arrived in

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.

Trap 28 — a fact that is only recomputed on a manual action is a fact that is wrong most of the time

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:

  • Reusing the cached session object is load-bearing, not just fast. In-memory corrections — an adopted rename, a description written through the API, a live-derived project stamp — survive a rescan instead of being thrown away and re-derived from disk.
  • Keep a set of the files your scanner refuses (sidechains, empties), keyed by the same signature. They never enter the session index, so they never look “already scanned” and would be re-parsed on every sweep forever. Key it by signature rather than by id, because an empty transcript becomes a real session the moment someone types into it.

🔴 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.

Trap 29 — a repaint you don’t think of as a repaint restarts every animation under it

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.

Trap 30 — movement is the wrong way to separate two states in a long list

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.

Derive the geometry, don’t pick it

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:

Colour rules that survived contact

Requirements

Quick Start

1
Save PROMPT.md below as ~/.claude/skills/ccsm-build/SKILL.md
2
Open Claude Code in any project and say: “build the session manager”
3
Claude runs a short interview — OS, project folder, default working directory, where your projects live, accent colour, port, and which of the optional layers you want
4
Claude probes your machine for each live data source before building against it, and tells you plainly if any is missing
5
Claude writes the scanner, enrichment pass, server, dashboard, live/usage modules and (if you opted in) the describe CLI and launcher — tailored to your environment
6
Claude runs a smoke test that checks each optional layer degrades correctly rather than crashing, then reports the launch command
7
Open http://localhost:<your-port>. Every Claude Code session you have ever started is right there — and the ones running right now are at the top.

Customization

The build skill produces a working baseline. Anything in the source files is yours to change. Common follow-ups, roughly in order of value:

What the Build Skill Actually Does

1
Interview. One question at a time, confirming each answer. OS, project folder, default working directory, where your project folders live, Remote Control, live status, usage gauge, End, enrichment, describe CLI, accent colour, port, launcher.
2
Pre-flight. Verifies Python 3.10+, checks Starlette and uvicorn, installs if missing — then probes for the pid registry and the credentials file rather than assuming they exist, and reads a real registry file to confirm its shape.
3
Build. Writes the scanner, enrichment pass, server, dashboard and live modules from the spec in the prompt — substituting your paths, accent colour and port. Skips any layer you declined, entirely.
4
Smoke test. Launches the server windowless, hits the API, checks each optional endpoint degrades correctly with nothing running, verifies no number in the usage payload can be traced to anything but the API response, then stops it. Fails loudly if the wiring is wrong.
5
Report. Tells you the launch command, the URL, which layers made it in, which were skipped and why, and the one rule that will otherwise waste an hour: editing the server does nothing until you restart it.

Get the System

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.

README.md
# 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.
PROMPT.md — Save as ~/.claude/skills/ccsm-build/SKILL.md
---
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.