On this page
The per-session lifecycle store: an append-only JSONL record of every Claude Code session's start, end, name and user mark, one file per session.
#claudewheel.lifecycle
#claudewheel.lifecycle
The per-session lifecycle store: what happened to one Claude Code session.
Claude Code writes a per-session registry entry only while its process lives, and says nothing at all once the process is gone: a session that exited, or died, leaves no record of having existed. This module owns the record that remains -- an append-only JSONL file per session, under ~/.claudewheel/shared/lifecycle/<session-uuid>.jsonl (:data:LIFECYCLE_DIRNAME, resolved by :attr:claudewheel.shared_store.SharedStore.lifecycle_dir)::
Four kinds of line, discriminated by kind: started (a session began), ended (it stopped, by exiting or by dying), named (it carried a display name) and mark (the user marked it, or cleared the mark). Events are never edited or deleted -- a mark is removed by appending one whose state is null, and a session that starts again after exiting simply gets a second started.
The line shape is NOT this module's to define -------------------------------------------------
.strictspec/lifecycle-event.schema.toml is the authority for the document shape -- the per-line format_version marker, the kind arm set, every field's type, the enums, which fields are required, and the rejection of an unknown key -- and the generated validator (:mod:claudewheel.strictspec_gen.lifecycle_event_validator) enforces it. What this module keeps is what strictspec cannot see:
- ordering events by
at, which is lexical because every timestamp is
fixed-width RFC 3339 UTC (:func:now_timestamp);
- the latest-wins reading of a file (:func:
summarize), including the
cross-event rules: an ended older than the newest started belongs to a previous run, and a mark with a null state clears the one before it;
- the state a reader derives from a lifecycle plus what it can observe about the
process right now (:func:derive_state).
What is tolerated, and what is not ----------------------------------
Exactly one kind of damage is tolerated, and only in one position: a FINAL line that does not end in a newline and does not parse as JSON is an interrupted write, and :func:read_session drops it without a word. Everything else -- a missing or wrong format_version, an unknown kind, an unknown key, a damaged line anywhere but the end -- raises :class:LifecycleError naming the file and the 1-based line number. An absent file and an empty file both yield no events, which is the normal state of a session nothing has recorded yet.
Every write goes through :mod:claudewheel.effects, so a --dry-run records it instead of performing it.
#LifecycleError
A lifecycle file, or a line in one, cannot be read or written.
#StartedEvent
A session began running, under a stated config directory and cwd.
#EndedEvent
A session stopped running, either by exiting or by dying without one.
#NamedEvent
A session carried a display name.
#MarkEvent
The user marked the session, or cleared a previous mark.
#new_event_id
def new_event_id() -> strGenerate a unique event id: <16 hex ns><32 hex uuid4>.
Timestamp-prefixed, so ids sort by creation order, and uuid4-suffixed, so two events minted in the same nanosecond still differ.
#now_timestamp
def now_timestamp(now_ms: int | None=None) -> strRender now_ms (default: now) as RFC 3339 UTC with milliseconds.
One fixed-width spelling, always UTC and always three fractional digits, so lexical string order over these timestamps IS time order -- which is what :func:summarize sorts by.
#parse_timestamp_ms
def parse_timestamp_ms(at: str) -> intRead an at timestamp back into milliseconds since the epoch.
The inverse of :func:now_timestamp, and the only way a lifecycle's timestamps are compared against a wall clock (the sweep's grace period, and derive_state's "starting" window). A +00:00 offset is read as readily as Z: both are legal under the schema's offset datetime.
#session_file
def session_file(lifecycle_dir: Path, session: str) -> PathReturn the lifecycle file of session under lifecycle_dir.
The session string is matched against :data:SESSION_UUID_RE first: it becomes a file name, so a path must never be built out of junk -- a stray .. or separator would address a file outside the store entirely.
#event_to_json
def event_to_json(event: Event) -> strSerialize event to one compact JSON line, without a trailing newline.
Keys come out in schema order -- format_version, the common fields, kind, then the arm's own fields -- and EVERY key is written, including the ones whose value is None: the schema requires each of them present, so an omitted key is a second spelling of "unknown" that a reader could not tell from a writer's mistake.
#_where
def _where(path: Path, lineno: int | None) -> strThe file:line prefix every diagnostic carries.
#_validate_line
def _validate_line(line: str, *, path: Path, lineno: int | None) -> NoneRun the strictspec format_version marker and the full shape validation.
Absence of format_version is an error like any other: this store was born with the marker, so there is no legacy line to accommodate and nothing that could turn the enforcement off.
#parse_event
def parse_event(line: str, *, path: Path, lineno: int) -> EventParse one JSON line into the event its kind selects.
path and lineno name the line in every diagnostic; they are keyword-only because they are the reader's context, not part of the line. Validation runs before anything is bound, so the keyword expansion below sees a document the schema has already accepted in full.
#read_session
def read_session(path: Path) -> list[Event]Read one session's lifecycle file, in file order.
An absent or empty file yields no events. The single tolerated damage is an interrupted write: a FINAL line with no terminating newline that does not parse as JSON is dropped silently, because that is what a process killed mid-append leaves behind. Any other unreadable line raises :class:LifecycleError naming the file and the 1-based line number.
#_needs_separator
def _needs_separator(path: Path) -> boolTrue when path ends mid-line, so an append must lead with a newline.
#append_event
def append_event(lifecycle_dir: Path, event: EventT) -> EventTAppend event to its session's file and return the copy as written.
The event is stamped with an id and an at when it carries neither, and validated BEFORE anything is written, so an invalid event leaves the file exactly as it was -- uncreated, if it did not exist. The caller's own object is never modified; the stamped copy is the return value.
Prior content is never read back and rewritten, so a concurrent writer cannot be clobbered. The one thing read first is the file's final byte: when it is not a newline -- an interrupted write, a hand edit -- a separating newline leads the append so the new event starts its own line instead of being concatenated onto the damaged one. The damaged line stays damaged; :func:read_session will name it.
#SessionLifecycle
What one session's whole file says, read latest-wins.
ended is the session's CURRENT end, not merely the newest ended line: a session that exited and then started again has an ended older than its newest started, which belongs to the previous run and is dropped here -- otherwise a resumed session would read as dead. mark is likewise the mark in force: the newest mark line governs, and a null state on it means the user cleared the mark.
#summarize
def summarize(events: Sequence[Event], *, session: str) -> SessionLifecycleReduce one session's events to the picture a reader uses.
Events are ordered by at -- lexically, which is time order for these fixed-width UTC timestamps -- and a tie is broken by position in the file, the later line winning.
#load_all
def load_all(lifecycle_dir: Path) -> dict[str, SessionLifecycle]Summarize every session file in lifecycle_dir, keyed by session uuid.
An absent directory yields nothing. Only *.jsonl files are read -- a notes.txt someone dropped in is not this store's business -- but a *.jsonl whose stem is not a session uuid IS: nothing but this module writes here, so such a file is either damage or a misunderstanding, and reading the store as if it were not there would hide it.
#sweep_crashed
def sweep_crashed(lifecycle_dir: Path, lifecycles: Mapping[str, SessionLifecycle], *, live_sessions: Set[str], now_ms: int) -> list[EndedEvent]Record an ended for every session that died without writing one.
A session qualifies when it has a started, has no current ended, is not among live_sessions, and started longer than :data:SWEEP_GRACE_MS ago. The grace period is the whole reason a sweep can be trusted: a session writes its started line from a SessionStart hook, before Claude Code has registered its process, so a session launched a moment ago looks exactly like one that crashed.
Returns the events as written, in session order. Idempotent: the next sweep reads the ended this one appended and passes the session by.
#capture_name
def capture_name(lifecycle_dir: Path, lifecycle: SessionLifecycle | None, *, session: str, name: str | None, name_source: str | None) -> NamedEvent | NoneRecord name for session when it is new or has changed.
Claude Code holds a session's display name in its registry, which vanishes with the process; this copies it into the lifecycle store while it can still be read. Returns the event written, or None when there was nothing to record -- no name at all, or the same name from the same source as the last time.
#derive_state
def derive_state(lifecycle: SessionLifecycle | None, *, live: bool, verified: bool, status: str | None, registry_present: bool, now_ms: int) -> strDecide the one state a session is shown in. First rule that matches wins.
The two inputs are deliberately separate: live, verified, status and registry_present are what can be observed about the process RIGHT NOW (from Claude Code's registry, checked against the kernel), and lifecycle is what the store recorded. Observation beats the record, because a live process is a fact no recorded line can contradict -- including a done mark on a session that turns out to still be running.
- live but unverified -- the process exists, but its kernel start token
could not be checked, so its identity is unproven.
- live -- the registry's own status, or
runningwhen it says nothing
this module recognizes.
- a mark in force -- the user's own word about a session nothing is
running.
- a recorded end -- its outcome,
exitedorcrashed. - a registry file with no process behind it -- it died without a
SessionEnd, so crashed.
- a
startedwith no end --startinginside the
:data:SWEEP_GRACE_MS window, crashed after it.
- nothing says it is alive --
exited.
#LIFECYCLE_DIRNAME
from .shared_store import LIFECYCLE_DIRNAME#FORMAT_VERSION
FORMAT_VERSION = 1#SESSION_UUID_RE
SESSION_UUID_RE = re.compile('^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$')#SWEEP_GRACE_MS
SWEEP_GRACE_MS = 60000#STATES
STATES: tuple[str, ...] = ('working', 'shell', 'idle', 'waiting', 'running', 'unverified', 'starting', 'on-hold', 'blocked', 'done', 'crashed', 'exited')#LIVE_STATES
LIVE_STATES = frozenset({'working', 'shell', 'idle', 'waiting', 'running', 'unverified'})#LOOSE_END_STATES
LOOSE_END_STATES = frozenset({'starting', 'on-hold', 'blocked', 'crashed'})#HIDDEN_BY_DEFAULT_STATES
HIDDEN_BY_DEFAULT_STATES = frozenset({'done', 'exited'})#Event
Event = StartedEvent | EndedEvent | NamedEvent | MarkEvent