On this page
Read Claude Code's per-session registry into typed, liveness-checked records.
#claudewheel.session_registry
#claudewheel.session_registry
Read Claude Code's per-session registry into typed, liveness-checked records.
Claude Code registers every process it starts under <config_dir>/sessions/<pid>.json -- one JSON document per process, written at startup and unlinked on exit. A crash, a kill -9 or a machine reboot leaves the file behind, so the directory is a set of claims, not a set of live sessions, and the operating system recycles PIDs freely. This module is the one place claudewheel turns those claims into records it can act on.
The record shape (observed live, and against Claude Code 2.1.226's own writer)::
{"pid":1485597,"sessionId":"4d97ca01-...","cwd":"/home/m/Projects", "startedAt":1786521494735,"procStart":"654274470","version":"2.1.226", "peerProtocol":1,"kind":"interactive","entrypoint":"cli", "messagingSocketPath":"/run/user/1000/cc-socks/1485597.sock", "name":"projects-9a","nameSource":"derived","status":"busy", "updatedAt":1786540262239,"statusUpdatedAt":1786540262239}
Two fields carry a start time and they are not interchangeable. startedAt is wall-clock milliseconds -- useful for display, useless for identity, because a reboot resets nothing and two processes can share a millisecond. procStart is the kernel's own start-time token for that process (field 22 of /proc/<pid>/stat, in clock ticks since boot), which is exactly what distinguishes "PID 1485597, the process that wrote this file" from "PID 1485597, whatever the kernel handed that number to afterwards". A record is live only when its PID exists and that token still matches -- the phantom filter.
Where the token cannot be read the filter cannot run. /proc does not exist outside Linux, and Claude Code records procStartFt (a ps -o lstart= string) instead on those platforms; claudewheel does not read that field, so there liveness degrades to plain PID existence. That is the same answer Claude Code's own comparison gives when either side of the pair is unavailable, and it is stated here rather than hidden: on Linux, where the launcher actually runs, the filter is exact.
Kinds and what they mean for policy -----------------------------------
kind is one of interactive, bg, daemon and daemon-worker (Claude Code derives it from CLAUDE_CODE_SESSION_KIND and defaults to interactive). Only an interactive session is a human sitting in front of the profile, and only that blocks a delete or a rename -- a background job or a daemon worker holding the profile is not a reason to refuse, per the program's ruling that deletion offers the user a choice about those rather than a veto. An unlabelled record is read as interactive: the conservative direction, so a future kind claudewheel has not heard of can never silently become deletable.
#SessionRecord
One registry file, parsed, with its liveness already resolved.
live is the phantom-filtered answer: the PID exists and, where the kernel start token is available on both sides, still names the process that wrote the file. proc_start is kept so a caller can tell "no token was recorded" from "the token matched".
#interactive
def interactive(self) -> boolTrue when this record is a human's session rather than background work.
#process_start_token
def process_start_token(pid: int) -> str | NoneThe kernel start-time token of the live process pid, or None.
None means "no answer available", which covers a dead process, a /proc claudewheel may not read, and a platform without /proc at all. The parse mirrors Claude Code's: split after the last ) so a process whose name contains spaces or parentheses cannot shift the field index.
#_pid_exists
def _pid_exists(pid: int) -> boolTrue when pid names a process this machine currently has.
#_is_live
def _is_live(pid: int, proc_start: str | None) -> boolApply the phantom filter to one claim.
#_text
def _text(value: object) -> str | NoneA string field, or None when absent or the wrong type.
#_parse
def _parse(path: Path) -> SessionRecord | NoneParse one registry file, or None when it is not one.
Everything unreadable is skipped rather than raised: the directory belongs to another program, and a torn write from a session starting up must not take down a delete guard.
#read_records
def read_records(config_dir: Path) -> list[SessionRecord]Every parseable registry record under config_dir, live or not.
Sorted by PID so callers and their tests get a stable order. A missing sessions/ directory is an empty registry, not an error.
#live_records
def live_records(config_dir: Path) -> list[SessionRecord]The records under config_dir whose processes are really running.
#live_interactive_records
def live_interactive_records(config_dir: Path) -> list[SessionRecord]The live records that are a human's session rather than background work.
#has_live_interactive
def has_live_interactive(config_dir: Path) -> boolTrue when a human's session is live in config_dir.
The predicate both the delete guard and the rename guard read.