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. Time-based rules are narrow fallbacks, for example: a permission-gated edit tool left open past 30s reads as a permission prompt, and out-of-band signal holds have a wall-clock ceiling (see hold_expired below).

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

Three 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. A terminal failure is retired instead when the agent resumes the session on its own — Claude Code’s auto-continuation once a usage limit resets (#1858). None of the three is a timeout or has a minimum hold.

Every producer of error reads a transcript. A crashed agent is not one of them, and that is a deliberate call: Irrlicht is not the agent’s parent process, so it gets no exit status on any platform — a segfault, an OOM kill, a kill -9 and a clean exit are the same observation. A version that guessed a crash from what the session looked like when the PID went away painted ordinary endings red (an ESC interrupt, a denied tool prompt), so the guess was removed. A process exit deletes the session row, whatever state the row was in — which means a genuine mid-turn crash disappears silently too. That is the accepted cost of never showing a false one.

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
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 terminal failure is also cleared when the agent itself resumes the session (above). One that neither you nor the agent follows up stays error until its process exits, which deletes the row; only a session with no known process ages out of the list via the idle sweep.

+-----------+ user sends | | end_turn / ESC message | working |----------------+ +----------->| | | | +-----+-----+ | | | \ v | AskUser / | \ provider +---------+ | ExitPlan | \ failure | ready | | v \ +---------+ | +-----------+ \ ^ | | | \ | +------------| waiting | \ | user | | \ | answers +-----------+ \ | v ^ +---------+ | retrying | error |----+ failure's +---------+ turn ends | | user's next message +--------> working (above)

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.

waiting outranks error: a live session that hit a provider failure and then opens a blocking tool reads waiting, because the user really is being asked something. A dead process produces no error at all — its session row is deleted.

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 way out of waiting other than an answer is process exit, which deletes the session rather than moving it to ready.
No cancelled state There is no "cancelled" state. Cancellation (ESC) maps directly to ready — from the user's side a cancelled turn and a finished turn leave the agent in the same place, idle at the prompt. This is unrelated to error, which is reserved for the session's own machinery failing rather than for anything the user did.

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
  • An entry of LastOpenToolNames is a user-blocking tool: AskUserQuestion, ExitPlanMode, or another adapter’s spelling of the same tools (question, ask_user_question, exit_plan_mode, ask_user, request_user_input)

When this predicate fires, the session transitions to waiting. The light turns orange.

IsAgentDone() — triggers ready

Never fires while HasOpenToolCall or HasLiveBackgroundProcess is true. Otherwise three detection paths, checked in order:

  • Authoritative: HookTurnDone (Claude Code’s Stop hook)
  • Primary: LastEventType == "turn_done"
  • Fallback: LastEventType is one of {assistant, assistant_output}

When any 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 (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
    Error   int  // subagents whose session machinery failed
}

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 CLI
  • codex — OpenAI's Codex CLI
  • pi — Pi coding agent
  • aider — 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.json sidecar under ~/.vibe/logs/session)
  • copilot — GitHub Copilot CLI (JSONL events.jsonl under ~/.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)
  • muse — Muse (JSONL session.jsonl under ~/.local/share/muse/sessions/<YYYY>/<MM>/<DD>/<session-id>, with nested subagent copies under each session’s subagent/ directory)
  • junie — JetBrains Junie (JSONL events.jsonl under ~/.junie/sessions, plus process sidecars under ~/.junie/processes)
  • dsh — DeepSeek Harness (zstd-compressed session.v<N>.jsonl.zstd under ~/.dsh/sessions/<cwd-key>/session-<uuid>, relocatable via $DSH_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.
unknown No tokens or model seen yet, or no context window is known for the model, so no utilization can be computed.

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 = true signals 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 21 constants, grouped as the source file itself groups them.

Kind Category When it fires
transcript_new Transcript events An adapter's watcher (the shared fswatcher, or OpenCode's / Hermes Agent's store watcher) reports a session's transcript appearing for the first time.
transcript_activity Transcript events An adapter's watcher reports further writes to an already-known transcript.
transcript_removed Transcript events An adapter's watcher reports a tracked transcript removed.
pid_discovered Process lifecycle A PID is bound to a session (PIDManager.HandlePIDAssigned), before it is registered for exit monitoring.
process_spawned Process lifecycle Declared in the vocabulary; no current daemon path emits it.
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, whatever state the row was in — including a turn that was still open.
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 an agent hook drives the detector — permission/tool hooks (PreToolUse/PostToolUse/etc.), Stop, the Notification idle and permission prompts, and a manual /compact (PreCompact) — from Claude Code or any other hook-installing adapter; 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 Retired: recorded a transcript-invisible UI state (the trust/permission dialog) read off the rendered terminal. No daemon path emits it since terminal read-back was removed (#1875); the kind is kept so frozen recordings stay readable.
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.
hold_expired Held signals An out-of-band signal hold exceeds its wall-clock ceiling. The event records the signal kind, tier, elapsed milliseconds, and ceiling milliseconds.
hook_channel_silent Hook health An adapter has granted and successfully installed hooks but completes the configured number of turns without one hook receipt. The adapter falls back to transcript signals.
hook_channel_recovered Hook health A hook receipt arrives after the adapter was declared silent. The event proves that the hook channel recovered.
hook_hold_released Hook health A hook-tier hold is released because the hook channel was declared silent. This event identifies a channel failure. The hold_expired event identifies one hold that exceeded its time limit.