State Machine
Deterministic session state transitions. Files → State → Light.
Session Lifecycle
Every session follows a deterministic lifecycle from process discovery through transcript creation to eventual cleanup. The state machine never guesses — it reacts to concrete filesystem and process events.
| Event | From | To | Mechanism |
|---|---|---|---|
| User opens Claude Code | no session | ready | pgrep -x claude process scanner |
| User types first message | pre-session | real session | fsnotify CREATE on .jsonl |
| Real transcript appears | pre-session | real session takes over | Pre-session deleted, real session registered |
| User exits | any | deleted | kqueue NOTE_EXIT |
| Process killed | any | deleted | kqueue NOTE_EXIT |
| Transcript deleted | any | ready | fsnotify REMOVE |
| Daemon starts with dead PID | session file | deleted | syscall.Kill check |
State Transitions
Once a session is active, it moves between four states: working, waiting, ready, and error. Each maps to one light color in the menu bar.
error means the session's own machinery failed — the provider refused or failed the call, credentials were rejected, the agent process died mid-turn, or Irrlicht could not read the session. It is not a tool failure: a grep that matched nothing and a build that broke are the agent working normally.
Two things clear it, on different conditions. Your next message clears it immediately, before the new turn's outcome is known — someone who has typed again has moved on, and holding the old failure against the new turn would pin the session red for the rest of its life. A completed turn also clears it, but only when the failure was a retrying one; a terminal failure is not retired by a turn boundary. Neither edge is a timeout and neither has a minimum hold.
A process that died mid-turn is always recorded as terminal — a process that exited will not try again — so neither of those applies to it. Its row is kept in error for 12 hours and then reaped, so a crash at 22:00 is still visible at 08:00.
| Trigger | From | To |
|---|---|---|
| User sends message | ready | working |
Tool called (stop_reason=tool_use) |
working | working |
| Tool result returned | working | working |
Turn finished (end_turn) |
working | ready |
| User cancelled (ESC) | working | ready |
AskUserQuestion opened |
working | waiting |
ExitPlanMode opened |
working | waiting |
| User answers / approves | waiting | working |
| Provider refused or failed the call; credentials rejected | working | error |
| Agent process died mid-turn (the session row is kept, not deleted) | working | error |
| User sends the next message after a failure (clears it outright) | error | working |
| A retrying failure's next turn completes | error | ready |
Note that the last two rows clear the failure on different conditions: your next message clears it outright, while a completed turn clears it only when the failure was a retrying one. A process death is terminal, so it leaves error by neither — it ages out of the list instead.
Impossible Transitions
Two transitions are structurally impossible and the state machine enforces this. Both constrain only working, waiting and ready — no impossibility is claimed about error in either direction.
Whether error or waiting wins is decided per cause, not once. A dead process outranks waiting: it leaves any open question unanswerable forever, and sending someone to answer a question in a process that no longer exists is worse than saying nothing. A provider failure on a live session is the other way round — if that session then opens a blocking tool, it reads waiting.
| Transition | Why Impossible |
|---|---|
| ready → waiting | Cannot skip working. A blocking tool call can only appear while the agent is actively running. |
| waiting → ready via content | The agent cannot finish a turn while a blocking tool call is open. The only path from waiting to ready is process exit. |
Detection Logic
The state machine reads raw transcript events and applies two key predicates to determine transitions.
NeedsUserAttention() — triggers waiting
Evaluates to true when all of the following hold:
HasOpenToolCall = trueLastOpenToolNameis one of{AskUserQuestion, ExitPlanMode}
When this predicate fires, the session transitions to waiting. The light turns orange.
IsAgentDone() — triggers ready
Two detection paths, checked in order:
- Primary:
LastEventType == "turn_done" - Fallback:
HasOpenToolCall = falseANDLastEventTypeis one of{assistant, assistant_output}
When either path matches, the session transitions to ready. The light turns green.
Turn Completion Signals
Two transcript event types signal the end of an agent turn:
turn_duration— emitted at the end of each agent turn. This is the primary signal.stop_hook_summary— emitted after stop hooks run. Used as a fallback whenturn_durationis not present.
Pre-Sessions
When the daemon's process scanner (pgrep -x claude) discovers a new Claude Code process, it creates a synthetic pre-session with the ID proc-<pid>. This pre-session exists because the user has opened Claude Code but has not yet sent a message — no transcript file exists yet.
The pre-session lifecycle:
- Process scanner detects a new
claudePID not already tracked. - A synthetic session
proc-<pid>is created in ready state. - The daemon watches for a
.jsonltranscript file to appear via fsnotify. - When the real transcript appears, the pre-session is deleted and replaced by a real session keyed to the transcript path.
- If the process exits before any transcript appears, the pre-session is simply cleaned up.
Subagent Detection
Claude Code can spawn sub-agents (child tasks). Irrlicht detects parent-child relationships between sessions and exposes this through the subagentSummary structure (JSON key subagents).
The summary tracks subagent counts by state:
type subagentSummary struct {
Total int // total subagent count across all states
Working int // subagents currently executing
Waiting int // subagents blocked on user input
Ready int // subagents that have finished their turn
}
The parent session's state display incorporates subagent status. If the parent is ready but a child subagent is working, the parent's effective state adjusts accordingly — the light reflects the most "active" state across the tree.
Orthogonal Axes
Beyond the core states, the state machine tracks several orthogonal dimensions that do not affect the primary state transition logic but provide additional context.
Adapter
Identifies which AI coding agent is running:
claude-code— Anthropic's Claude Code CLIcodex— OpenAI's Codex CLIpi— Pi (Inflection) agentaider— Aider (transcript-driven REPL)opencode— OpenCode (SQLite-backed storage)kiro-cli— AWS Kiro CLI (JSONL transcripts + metadata sidecar)gemini-cli— Google Gemini CLI (JSONL transcripts under~/.gemini/tmp)antigravity— Antigravity CLI/IDE (JSONL transcripts under~/.gemini/antigravity{,-cli}/brain)mistral-vibe— Mistral Vibe (JSONL transcripts +meta.jsonsidecar under~/.vibe/logs/session)copilot— GitHub Copilot CLI (JSONLevents.jsonlunder~/.copilot/session-state, relocatable via$COPILOT_HOME)hermes— Hermes Agent (no transcript files; one shared SQLite store at~/.hermes/state.db, relocatable via$HERMES_HOME)
The adapter determines which transcript format is parsed and which process signatures are scanned. Frontends should look up adapter display name and icon via GET /api/v1/agents rather than hardcoding switches.
PressureLevel
Tracks context window utilization as a pressure indicator:
| Level | Meaning |
|---|---|
safe |
Plenty of context remaining. |
caution |
Context usage is notable but not urgent. |
warning |
Context window is filling up. Compaction may occur soon. |
critical |
Context window nearly full. Compaction is imminent or underway. |
Cancellation
When the user presses ESC during an agent turn, Claude Code writes a user event whose text content starts with [Request interrupted by user. Irrlicht detects this specific marker to distinguish a real ESC from benign tool failures (a grep with no matches, a failing build, a find hitting a protected directory) which also set is_error=true but should not end the turn.
Cancellation maps directly to ready. There is no intermediate "cancelled" state. The detection logic:
LastWasUserInterrupt = truesignals the ESC marker was seen.- The agent stops executing, so
IsAgentDone()fires. - The session transitions to ready.
This is a deliberate design choice. From the user's perspective, a cancelled turn is equivalent to a completed turn — the agent is idle and waiting for the next message. Folding it into ready keeps the state model free of a value that would carry no information the user could act on.
Lifecycle Event Kinds
Underneath the state machine, the daemon records a lower-level stream of typed events for session recording and replay — not just transcript-derived state transitions but also process lifecycle, filesystem, debounce, and parent-child signals. Each event carries a Kind discriminant (core/domain/lifecycle/event.go); this table enumerates all 18 constants, grouped as the source file itself groups them.
| Kind | Category | When it fires |
|---|---|---|
transcript_new |
Transcript events | fswatcher/AgentWatcher detects a session's .jsonl transcript file appearing for the first time. |
transcript_activity |
Transcript events | fswatcher/AgentWatcher observes further writes to an already-known transcript file. |
transcript_removed |
Transcript events | fswatcher/AgentWatcher sees a tracked transcript file deleted. |
pid_discovered |
Process lifecycle | The process scanner (e.g. pgrep -x claude) finds a new agent PID that isn't already tracked. |
process_spawned |
Process lifecycle | A new agent process is observed starting. |
process_exited |
Process lifecycle | A tracked agent process exits (kqueue NOTE_EXIT) and its session row is deleted. This kind means teardown: every consumer removes the session on it. An exit that caught a turn still open is recorded as process_died_midturn instead. |
process_died_midturn |
Process lifecycle | The other outcome of an exit: the PID went away while a turn was still open, so the session is converted to error and kept rather than deleted. Carries the PID and the exit reason. Recorded once per session, on the conversion edge. |
file_event |
Filesystem | Reserved for debounced create/write/remove/rename events on the agent's working directory (per .specs/onboard-agent/07-10-recorder-fidelity.md, WS08); the type is defined but emission is wired by a follow-up PR, not yet live. |
state_transition |
State machine | Emitted as the output of ClassifyState whenever a session's state changes; carries PrevState/NewState/Reason plus a ClassifierInputs snapshot explaining why. |
parent_linked |
Parent-child linkage | A subagent (child) session is linked to its parent session via ParentSessionID. |
debounce_coalesced |
Debounce | Multiple rapid events within the debounce window are coalesced into one; CoalescedCount records how many, for faithful replay. |
debounce_terminal |
Debounce | A terminal event bypasses the debounce window entirely and is recorded immediately rather than coalesced. |
hook_received |
Agent hooks | Emitted by dispatchHookActivity whenever a claudecode permission hook (PreToolUse/PostToolUse/etc.) or a manual /compact (PreCompact) fires; carries the hook name in HookName. The sibling HookData field exists but is currently always empty. |
presession_created |
Pre-sessions | The process scanner creates a synthetic pre-session (proc-<pid>) for a detected process that has no transcript yet. |
presession_removed |
Pre-sessions | A pre-session is cleaned up — either superseded once its real transcript appears, or discarded because the process exited before any transcript showed up. |
task_delta |
Tasks | Emitted once per TaskDelta the tailer folds into a session's task list (create / update / assign_id), carrying TaskOp, TaskID, TaskSubject, and TaskStatus. |
ui_detected |
Terminal / UI | A terminal-backend read-back finds a transcript-invisible UI state on the rendered terminal screen (today: the trust/permission dialog); UIKind is empty on the clearing edge. |
terminal_frame |
Terminal / UI | Reserved for raw terminal frame capture (pipe-pane plus a screen-buffer parser); defined but not emitted yet. |
cache_bloat_detected |
Cache-creation regression | Emitted once per (project, regressing version) pair within a daemon process lifetime, the first time a working session's median cache-creation per turn exceeds the project's p25 baseline × threshold (issue #374); consumed by the ir:agent-releases workflow. |