Skip to content

State Transitions and Operations Guide

This guide documents how houmao-server state values change over time and what operations are appropriate in each state. It is diagram-first: read the statecharts and sequence diagram to understand the state model visually, then consult the prose sections for operational details.

For the definition of each individual state value, see the State Reference Guide. For the internal pipeline architecture, see State Tracking.

Source of truth: Tracker-owned transition logic is implemented in src/houmao/shared_tui_tracking/session.py together with the shared detector/profile contract in src/houmao/shared_tui_tracking/detectors.py and app-owned implementations such as src/houmao/shared_tui_tracking/apps/codex_tui/. The live server tracker in src/houmao/server/tui/tracking.py feeds that shared session and then merges the result with server-owned diagnostics and lifecycle metadata.


Diagnostics Availability

All five diagnostics.availability values and their transitions. The entry state is unknown. Only available enables meaningful surface and turn tracking — all other states degrade surface and turn values.

stateDiagram-v2
    [*] --> unknown : initial state
    unknown --> available : tmux up +<br/>TUI process running +<br/>parse success +<br/>surface present
    unknown --> unavailable : tmux session<br/>not found
    unknown --> tui_down : tmux up but<br/>TUI process absent
    unknown --> error : probe failed or<br/>parse failed

    available --> tui_down : TUI process exits<br/>while tmux stays up
    available --> unavailable : tmux session<br/>disappears
    available --> error : probe failure or<br/>parse failure<br/>on current cycle

    tui_down --> available : TUI process restarts +<br/>parse success
    tui_down --> unavailable : tmux session<br/>disappears
    tui_down --> error : probe failure<br/>on current cycle

    unavailable --> unknown : tmux session<br/>reappears
    unavailable --> error : probe failure<br/>on current cycle

    error --> available : next cycle succeeds<br/>fully
    error --> unavailable : tmux gone on<br/>next cycle
    error --> tui_down : tmux up but<br/>TUI gone on next cycle
    error --> unknown : partial recovery<br/>on next cycle

    note right of available : Gateway state —<br/>only here are surface/turn<br/>values meaningful

Turn Phase

All four turn.phase values and their transitions. The entry state is unknown. The blocked edges are reachable only through the opt-in hook observation channel; all other edges describe TUI-only tracking.

stateDiagram-v2
    [*] --> unknown : initial state

    unknown --> ready : diagnostics available +<br/>ready_posture=yes +<br/>no active evidence +<br/>no ambiguous surface
    unknown --> active : anchor armed or<br/>active evidence detected

    ready --> active : explicit input anchor armed<br/>or active evidence detected
    ready --> unknown : diagnostics degraded<br/>(error / tui_down / unavailable)<br/>or ambiguous interactive surface

    active --> ready : turn completed —<br/>success settled +<br/>ready_posture=yes<br/>OR interrupted/failure +<br/>ready surface<br/>OR host active recovery
    active --> unknown : diagnostics degraded<br/>(error / tui_down / unavailable)

    active --> blocked : hook permission_requested<br/>(hook channel active)
    blocked --> active : hook permission_resolved<br/>OR TUI modal dismissal /<br/>User rejected line
    blocked --> ready : hook terminal event
    blocked --> unknown : diagnostics degraded<br/>(error / tui_down / unavailable)

    note right of unknown : Not an error —<br/>means the system<br/>cannot confidently classify<br/>the current posture

Hook Observation Merge

When the opt-in hook observation channel is active, hook facts merge into the tracker at its existing projection layer. surface.* stays TUI-owned. The merged transitions are:

  1. Hook prompt_submitted enters active from any non-terminal posture, (re)arms the turn, and clears the previous terminal result. A prompt submitted while another prompt is armed enqueues behind it; terminal events consume armed prompts in arrival order. For Kimi, UserPromptSubmit arms an epoch and the following TurnStarted(origin_kind=user) binds the integer turn_id; UserPromptQueued records queue evidence without arming a second epoch.
  2. Only hook permission_requested enters blocked, and it records the blocked tool name when the payload carries one. For Codex it also records the paired tool call from the preceding PreToolUse; for Kimi it records the paired tool_call_id. Kimi emits permission events only under an ask permission policy, so never-ask sessions never enter blocked. TUI parsing never enters blocked.
  3. For Claude, blocked exits to active on hook permission_resolved or TUI modal-dismissal evidence, including a positive User rejected line; the turn remains armed. For Codex, blocked exits on the paired PostToolUse (allow) or on Interrupt (deny, which settles the turn interrupted); TUI evidence does not settle a Codex blocked wait. For Kimi, blocked exits at the paired PermissionResult for both decisions (approved or rejected); the turn returns to active, a rejected call produces a non-terminal PostToolUseFailure, and the turn continues to a normal Stop. TUI evidence does not settle a Kimi blocked wait.
  4. A hook terminal event (Stop, StopFailure, or Codex or Kimi Interrupt) moves active or blocked to ready immediately, without waiting for the TUI repaint or settle window. The repaint window remains visible through surface.* and is reported as hook_terminal_tui_active.
  5. Hook active or blocked holds turn.phase against a ready-looking TUI surface, reported as hook_active_tui_ready or hook_blocked_tui_ready.
  6. maintenance_started marks a maintenance window without invalidating the previous terminal outcome. maintenance_ended restores active when a turn is still armed, otherwise the pre-maintenance posture. Maintenance posture does not close admission by itself; TUI surface facts govern admission during compaction. For Codex and Kimi, the window holds posture across PreCompact/PostCompact, suppresses TUI idle heuristics, and the post-compact turn starts a new turn_id epoch. Kimi emits no SessionStart(source=compact), so the pair is the whole compaction signal.
  7. TUI stale-active and final stable-active recovery rules are unchanged and may return active to ready.
  8. A hook terminal event with no armed prompt is an orphan diagnostic (hook_orphan_terminal) and never manufactures a last_turn. Duplicate terminal deliveries for the same prompt are idempotent; the first terminal result wins.
  9. Events from an older channel generation are dropped before correlation (hook_orphan_generation).
  10. For Codex and Kimi, stream end without Stop, Interrupt, or SessionEnd for an open turn, or SessionEnd with an open turn, publishes presence loss, turn.phase=unknown, and hook_terminal_gap; it never records success. A Kimi headless run that exits cleanly after a latched Stop but without SessionEnd is terminal-settled with the diagnostic headless_session_end_gap.

Kimi Merge Notes

  • Turn correlation is two-phase and ordering-based. UserPromptSubmit arms; TurnStarted(origin_kind=user) binds the integer, session-scoped turn_id, which continues across --continue resume. turn_id is absent on UserPromptSubmit, Stop, and all tool events, so those attribute to the armed epoch by stream order.
  • Interrupt(reason=cancelled) is authoritative for interrupted (the shared Codex carve-out). Esc produces it within about 50 ms; the TUI Interrupted by user notice is corroboration, not authority, and no Stop follows the aborted turn.
  • Permission denial is non-terminal. PermissionResult(rejected) settles blocked, PostToolUseFailure records the denied call, and the turn continues to Stop. Kimi has no deny-aborts-turn semantics.
  • Queued or steered input surfaces as UserPromptQueued only. Steered input produces no second UserPromptSubmit, and the running turn completes once.
  • Subagent activity opens nested scopes (SubagentStart/SubagentStop and nested TurnStarted(origin_kind=system_trigger)). Subagent turn_id values collide with the parent's, so scope, not id, disambiguates them.
  • SessionHeartbeat is presence and liveness evidence only, at a 60-second cadence. The work axis stays unknown, and hooks alone never produce settled.
  • SessionStart is awaited at session creation, but a fresh TUI creates its session lazily on the first message; startup event silence is judged after the first prompt. A resumed session delivers SessionStart(resume) before the first prompt.
  • A strict read-back violation at install time, or any collector failure, degrades the channel to unavailable; tracking then reverts to TUI-only, exactly as the Claude lane specifies.

Last-Turn Settlement With Hooks

  1. A hook Stop sets last_turn.result=success and a hook StopFailure sets known_failure with the provider error class in last_turn.error_class. For Codex and Kimi, Interrupt sets last_turn.result=interrupted. Kimi's StopFailure mapping is defensive because the campaign never observed that event. Settlement is immediate.
  2. last_turn.source is hook_observation for hook-settled turns, or explicit_input when a gateway submission record correlates with the same turn.
  3. Once a hook terminal event latches a turn, the pending TUI settle for that turn is suppressed. For Claude, later TUI evidence is discarded except that TUI temporal interrupt evidence for the same turn epoch, observed outside a maintenance window, may still flip the result to interrupted, reported as hook_success_tui_interrupted. For Codex and Kimi, the latched outcome is final for its epoch and no TUI heuristic rewrites it. Compaction repaints during the same epoch do not count as interrupt evidence.
  4. TUI settlement rules run unchanged when no hook terminal event exists for the turn.
  5. Hook events settle interrupted only through the Codex or Kimi Interrupt event; Claude has no interrupt hook and keeps the outcome TUI-derived.
  6. If the channel goes stale after a hook terminal event latched the turn, the latched last_turn persists through the degraded window with work=unknown and settled=false. On recovery, agreement with a TUI settlement keeps the TUI source. Disagreement applies the hook result as an explicit correction (hook_correction_after_staleness), except that on Claude a TUI interrupted settlement keeps interrupted and on Codex and Kimi the hook latch stands.

Work And Settled

work starts each channel generation as unknown. A Claude turn_completed payload replaces it: listed background tasks set pending, none set clear. A turn_failed event makes no background-work statement and leaves the axis unchanged. The Codex and Kimi adapters publish work=unknown for every sample because neither payload carries a background-task mirror. Whenever the channel is not active, work is unknown.

settled is derived, not stored independently: turn.phase=ready and work=clear. It is false for a degraded channel. work=pending never blocks admission in this release.

Channel Health

State Effect on tracked state
active Hook facts have lifecycle authority; work and settled are published from hook evidence.
unavailable Startup state before any event; after the 15-second startup grace with no event, tracking is TUI-only.
stale No event for more than 30 seconds while the TUI keeps changing; TUI regains lifecycle authority for new evidence, including new settlements. A hook-latched last_turn persists.
disabled_by_policy Terminal policy state from channel metadata; TUI-only.
untrusted_workspace Terminal policy state from channel metadata; TUI-only.
error Event store read failure; a healthy read restores active or unavailable.
stopped Collector or session stopped; terminal. A same-generation collector.json record with status=stopped moves the channel to stopped on the next poll cycle.

Every state other than active is degraded: the channel contributes no new lifecycle authority, admission is byte-equivalent to TUI-only behavior, work is unknown, and settled is false. A hook-latched last_turn is the one persisted hook lifecycle fact during a stale window.

Codex dispatches SessionStart lazily at the first turn start, so an idle session can remain unavailable past the 15-second startup grace until its first prompt; the first event still moves the channel to active. A provider upgrade that changes the trust hash algorithm makes Codex skip the untrusted handlers silently, which the channel observes as startup event silence with a live collector and reports as unavailable (degraded). A channel that was already active and then loses events follows the ordinary staleness rule to stale.

Kimi awaits SessionStart at session creation, but a fresh TUI session is created lazily on the first message, so the startup grace is judged after the first prompt; a resumed session delivers SessionStart(resume) at launch. If the installer's strict read-back rejects the merged [[hooks]] file, the channel starts as unavailable rather than launching a channel the provider would silently void. Kimi loads its hook table once per process, so there is no hot-reload recovery path: a config edit mid-session has no effect until restart.


Last-Turn Result

All four last_turn.result values and their transitions. The entry state is none. The last_turn value is sticky — it only changes when a tracked turn reaches a terminal outcome, not on every poll cycle.

stateDiagram-v2
    [*] --> none : initial state

    none --> success : settle timer fires —<br/>success candidate held<br/>through settle window
    none --> interrupted : interruption signal<br/>detected by detector
    none --> known_failure : failure signal<br/>detected by detector

    success --> success : new turn also<br/>succeeds
    success --> interrupted : new turn<br/>interrupted
    success --> known_failure : new turn<br/>failed
    success --> none : premature success retracted —<br/>surface changed after settle

    interrupted --> success : new turn<br/>succeeds
    interrupted --> interrupted : new turn also<br/>interrupted
    interrupted --> known_failure : new turn<br/>failed

    known_failure --> success : new turn<br/>succeeds
    known_failure --> interrupted : new turn<br/>interrupted
    known_failure --> known_failure : new turn also<br/>failed

    note right of success : Sticky — persists<br/>across poll cycles<br/>until next terminal outcome
    note left of none : Returns here only<br/>if premature success<br/>is retracted

Turn Lifecycle Sequence

A complete turn lifecycle from the consumer's perspective, showing both the successful and interrupted paths.

sequenceDiagram
    participant C as Consumer
    participant S as Server API<br/>/houmao/terminals/
    participant T as LiveSession<br/>Tracker
    participant D as Detector

    C->>S: POST .../input<br/>(send prompt)
    S->>T: note_prompt_submission()
    T->>T: arm explicit_input anchor
    Note over T: turn.phase → active

    loop Poll cycles
        T->>D: detect(output_text,<br/>parsed_surface)
        D-->>T: DetectedTurnSignals
        T->>D: derive_temporal_hints(recent_frames)
        D-->>T: TemporalHintSignals
    end

    alt Successful turn
        D-->>T: active_evidence=true
        Note over T: turn.phase stays active
        D-->>T: success_candidate=true
        T->>T: arm settle timer
        Note over T: waiting for<br/>settle window...
        T->>T: settle timer fires
        Note over T: last_turn.result → success<br/>last_turn.source → explicit_input<br/>turn.phase → ready
    else Interrupted turn
        D-->>T: interrupted=true
        Note over T: cancel settle timer<br/>last_turn.result → interrupted<br/>last_turn.source → explicit_input<br/>turn.phase → ready
    end

    C->>S: GET .../state
    S-->>C: HoumaoTerminal<br/>StateResponse

State Composition

How low-level observations compose upward through the pipeline into public state groups. This reflects the shared module extraction — detectors and mapping helpers live in shared_tui_tracking/, not inside the server module.

flowchart TD
    Probe["tmux probe<br/><i>pane capture + liveness</i>"]
    Proc["process inspection<br/><i>PaneProcessInspector</i>"]
    Parse["official parser<br/><i>OfficialTuiParserAdapter</i>"]
    Surf["HoumaoParsedSurface"]
    Detect["tracked-TUI profile<br/><i>shared_tui_tracking/apps/&lt;app_id&gt;/</i>"]
    Shared["TuiTrackerSession<br/><i>shared_tui_tracking/session.py</i>"]
    MapDiag["diagnostics_availability()<br/><i>shared_tui_tracking/public_state.py</i>"]
    Anchor{"turn anchor<br/>lifecycle"}
    Settle["settle timer<br/><i>ReactiveX kernel</i>"]
    PubDiag["diagnostics<br/>.availability"]
    PubSurf["surface<br/>.accepting_input<br/>.editing_input<br/>.ready_posture"]
    PubTurn["turn<br/>.phase"]
    PubLast["last_turn<br/>.result / .source"]
    Stable["stability<br/>.stable_for_seconds"]
    Hist["recent_transitions"]

    Probe --> Proc --> Parse --> Surf
    Probe --> MapDiag
    Proc --> MapDiag
    Parse --> MapDiag
    Probe --> Detect
    Detect --> Shared
    Shared --> PubSurf
    Shared --> PubTurn
    Shared --> PubLast
    MapDiag --> PubDiag
    Surf --> Anchor
    Anchor --> Settle
    PubDiag --> Stable
    PubSurf --> Stable
    PubTurn --> Stable
    PubLast --> Stable
    Stable --> Hist

Operation Acceptability

What operations are acceptable for major state combinations, using Houmao-native routes.

diagnostics.availability turn.phase Send input (POST /houmao/terminals/{id}/input) Poll state (GET /houmao/terminals/{id}/state) Expect meaningful results
available ready Yes — terminal is idle and ready Yes Yes — all state groups are meaningful
available active No — agent is working Yes — poll for turn completion Yes — watch for last_turn.result to change
available blocked No — provider is waiting for a permission decision (hook-only phase) Yes — poll for resolution Yes — observation_channels[].state and conflict reasons explain the block
available unknown Wait — posture is ambiguous Yes — may resolve shortly Partial — surface values may be approximate
unavailable any No — no terminal to receive input Yes — monitor for recovery No — values are stale or default
tui_down any No — TUI process is not running Yes — monitor for TUI restart No — values reflect last observation before exit
error any No — current sample is untrustworthy Yes — may be transient No — wait for recovery
unknown any Wait — state is not classifiable Yes — may resolve shortly No — insufficient data

Stability and Timing

stable_for_seconds

The stability.stable_for_seconds field indicates how long the current public state has remained unchanged. Consumers should use this to avoid acting on transient states.

Practical guidance: - A last_turn.result=success with stable_for_seconds above the settle window threshold indicates a truly settled outcome - Acting on an unstable success (low stable_for_seconds) risks seeing the result retracted — the settle window may not have elapsed yet - For turn.phase=ready, stability confirms the TUI has been idle, not just momentarily between outputs

Premature Success Retraction

The reducer records the surface signature when a success settles. If a subsequent observation under the same anchor shows a different surface but the detector still reports success_candidate, the previous success is retracted (last_turn.result reverts to none) before the new candidate is evaluated. This prevents a premature success from persisting when the TUI was actually still producing output.

Settle Window

The settle window is a configurable duration in the standalone TuiTrackerSession (TrackerConfig.settle_seconds) during which a success_candidate surface must remain stable before being promoted to success. If any of the following occur during the window, the pending success is cancelled:

  • Diagnostics degrade (availability leaves available)
  • An interrupted signal is detected
  • A known_failure signal is detected
  • active_evidence reappears
  • The surface signature changes (surface is still evolving)

Turn Anchor Effects

explicit_input vs surface_inference

The two anchor sources have different timing characteristics:

explicit_input — armed immediately when the server receives POST /houmao/terminals/{terminal_id}/input and calls note_prompt_submission(): - The exact submission timestamp is known - turn.phase transitions to active immediately - Settle timing starts from a precise anchor point - This is the preferred path for automation

surface_inference — armed by the tracker when it detects that a direct tmux interaction has produced a new turn: - The tracker uses raw tmux snapshots plus tool/version detector rules; parser-derived surface growth is not part of this authority path - The submission moment is estimated from surface changes, not known precisely - Settle times may be slightly longer due to the estimation uncertainty - This path keeps repaint churn, cursor motion, and small prompt edits from manufacturing turn semantics


Reducer Transition Rules

The standalone TuiTrackerSession in shared_tui_tracking/session.py applies a strict priority chain when processing each raw snapshot. The compatibility StreamStateReducer wrapper and the live server host adapter both reuse that same core behavior. Server-owned diagnostics, parser sidecars, and lifecycle enrichment are published alongside this reducer output; they do not become reducer inputs. The hook observation merge described above runs at the host projection layer after this chain and never feeds hook facts into the shared tracker, so the blocked phase does not appear here.

Priority Chain

Each observation is processed through these checks in order. The first matching condition determines the output:

  1. Interrupted signal — If the detector reports interrupted=true, emit turn.phase=ready and last_turn.result=interrupted. Cancel any pending success timer. Clear the armed turn source.

  2. Known failure signal — If the detector reports known_failure=true, emit turn.phase=ready and last_turn.result=known_failure. Cancel any pending success timer. Clear the armed turn source.

  3. Active evidence — If the detector reports active_evidence=true, emit turn.phase=active. Cancel any pending success timer. If no turn source is armed yet, arm surface_inference.

  4. Success candidate — If the detector reports success_candidate=true, emit turn.phase=ready and arm or retain the settle timer. If a previous settled success exists with a different surface signature, retract it first.

  5. Default — No strong signal matched. Cancel any pending success timer. Emit turn.phase=ready if ready_posture=yes, otherwise turn.phase=unknown.

Host-Side Stale-Active Recovery

After the shared tracker publishes its surface / turn / last_turn fields, the live host adapter may still apply one corrective step for unanchored sessions.

That recovery exists to handle stale active evidence that survived in tmux history after the visible surface has already become submit-ready again. The current Codex tracker is the main case that benefits from it.

The host adapter recovers turn.phase=active to turn.phase=ready only when all of the following remain true through the configured recovery window:

  • diagnostics remain available
  • parsed surface is submit-ready (availability=supported, business_state=idle, input_mode=freeform)
  • public surface remains accepting_input=yes and editing_input=no
  • the session is unanchored (lifecycle_authority.turn_anchor_state=absent)
  • remaining shared-tracker active reasons are limited to stale-like status_row evidence

This correction does not set last_turn.result=success. It only fixes the public readiness posture.

Success Timer

When a success_candidate is detected, the reducer arms a settle timer for the configured settle window duration. The timer fires only if: - The latest signals still report success_candidate - No current_error_present - The surface signature has not changed since arming

On fire, the reducer emits last_turn.result=success with the appropriate turn source and clears the armed tracker source.

Cancellation

The success timer is cancelled whenever the priority chain reaches a step before the success_candidate check — i.e., diagnostics degradation, interrupted, known_failure, or active_evidence all cancel a pending success.