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
Deterministic, not heuristic Every transition is triggered by a concrete event — a file appearing, a process exiting, a transcript line written. The state machine never polls for ambiguous signals or applies timeouts.

State Transitions

Once a session is active, it moves between exactly three states: working, waiting, and ready. These map directly to the three light colors in the menu bar.

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
+-----------+ user sends | | end_turn / ESC message | working |----------------+ +----------->| | | | +-----+-----+ | | | v | AskUser / | +---------+ | ExitPlan | | ready | | v +---------+ | +-----------+ ^ | | | | +------------| waiting | (process exit | user | | only) | answers +-----------+----------------+

Impossible Transitions

Two transitions are structurally impossible and the state machine enforces this:

Transition Why Impossible
readywaiting Cannot skip working. A blocking tool call can only appear while the agent is actively running.
waitingready 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.
No cancelled state There is no fourth "cancelled" state. Cancellation (ESC) maps directly to ready. The system maintains exactly three states: working, waiting, and ready.

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 = true
  • LastOpenToolName is 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 = false AND LastEventType is one of {assistant_message, 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 when turn_duration is 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:

  1. Process scanner detects a new claude PID not already tracked.
  2. A synthetic session proc-<pid> is created in ready state.
  3. The daemon watches for a .jsonl transcript file to appear via fsnotify.
  4. When the real transcript appears, the pre-session is deleted and replaced by a real session keyed to the transcript path.
  5. If the process exits before any transcript appears, the pre-session is simply cleaned up.
Why pre-sessions matter Without pre-sessions, there would be a gap between opening Claude Code and the first message where Irrlicht would show no session at all. Pre-sessions ensure the menu bar light appears immediately.

Subagent Detection

Claude Code can spawn sub-agents (child tasks). Irrlicht detects parent-child relationships between sessions and exposes this through the SubagentSummary structure.

The summary tracks subagent counts by state:

type SubagentSummary struct {
    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 three core states, the state machine tracks several orthogonal dimensions that do not affect the primary state transition logic but provide additional context.

CompactionState

Tracks whether the agent is compacting its context window:

Value Meaning
not_compacting Normal operation. No compaction in progress.
compacting Agent is actively summarizing context to fit within the window.
post_compact Compaction just completed. Transient state before reverting to not_compacting.

Adapter

Identifies which AI coding agent is running:

  • claude-code — Anthropic's Claude Code CLI
  • codex — OpenAI's Codex CLI

The adapter determines which transcript format is parsed and which process signatures are scanned.

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 cancels the current operation. Irrlicht detects this through the transcript: the last tool result will have is_error set to true.

Cancellation maps directly to ready. There is no intermediate "cancelled" state. The detection logic:

  • LastToolResultWasError = true signals the cancellation occurred.
  • 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. The three-state model (working / waiting / ready) remains clean and unambiguous.