Skip to content
claudewheel.lifecycle
Edit
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)::

/.jsonl one line per event, append-only

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

python
def new_event_id() -> str

Generate 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

python
def now_timestamp(now_ms: int | None=None) -> str

Render 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

python
def parse_timestamp_ms(at: str) -> int

Read 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

python
def session_file(lifecycle_dir: Path, session: str) -> Path

Return 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

python
def event_to_json(event: Event) -> str

Serialize 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

python
def _where(path: Path, lineno: int | None) -> str

The file:line prefix every diagnostic carries.

#_validate_line

python
def _validate_line(line: str, *, path: Path, lineno: int | None) -> None

Run 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

python
def parse_event(line: str, *, path: Path, lineno: int) -> Event

Parse 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

python
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

python
def _needs_separator(path: Path) -> bool

True when path ends mid-line, so an append must lead with a newline.

#append_event

python
def append_event(lifecycle_dir: Path, event: EventT) -> EventT

Append 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

python
def summarize(events: Sequence[Event], *, session: str) -> SessionLifecycle

Reduce 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

python
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

python
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

python
def capture_name(lifecycle_dir: Path, lifecycle: SessionLifecycle | None, *, session: str, name: str | None, name_source: str | None) -> NamedEvent | None

Record 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

python
def derive_state(lifecycle: SessionLifecycle | None, *, live: bool, verified: bool, status: str | None, registry_present: bool, now_ms: int) -> str

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

  1. live but unverified -- the process exists, but its kernel start token

could not be checked, so its identity is unproven.

  1. live -- the registry's own status, or running when it says nothing

this module recognizes.

  1. a mark in force -- the user's own word about a session nothing is

running.

  1. a recorded end -- its outcome, exited or crashed.
  2. a registry file with no process behind it -- it died without a

SessionEnd, so crashed.

  1. a started with no end -- starting inside the

:data:SWEEP_GRACE_MS window, crashed after it.

  1. nothing says it is alive -- exited.

#LIFECYCLE_DIRNAME

python
from .shared_store import LIFECYCLE_DIRNAME

#FORMAT_VERSION

python
FORMAT_VERSION = 1

#SESSION_UUID_RE

python
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

python
SWEEP_GRACE_MS = 60000

#STATES

python
STATES: tuple[str, ...] = ('working', 'shell', 'idle', 'waiting', 'running', 'unverified', 'starting', 'on-hold', 'blocked', 'done', 'crashed', 'exited')

#LIVE_STATES

python
LIVE_STATES = frozenset({'working', 'shell', 'idle', 'waiting', 'running', 'unverified'})

#LOOSE_END_STATES

python
LOOSE_END_STATES = frozenset({'starting', 'on-hold', 'blocked', 'crashed'})

#HIDDEN_BY_DEFAULT_STATES

python
HIDDEN_BY_DEFAULT_STATES = frozenset({'done', 'exited'})

#Event

python
Event = StartedEvent | EndedEvent | NamedEvent | MarkEvent
Search