In the previous chapter, Remote Session Polling, we built a "Courier" that repeatedly visits the server to fetch updates.
However, simply fetching updates isn't enough. The server sends back hundreds of raw eventsβlogs, internal thoughts, and function calls. If we showed all of this to the user, it would look like The Matrix code. It's overwhelming.
We need a translator. We need to boil down that complex stream of data into a simple status that anyone can understand. This is the Session Phase Lifecycle.
Imagine you are watching a status indicator on your screen while the AI builds a plan. You don't care about the internal JSON packets; you only care about three things:
We solved this by abstracting the entire system state into a Traffic Light model.
We define a TypeScript type called UltraplanPhase with exactly three states:
running: The AI is thinking, writing files, or checking code. The user should sit back and wait.needs_input: The AI has paused. It might be asking a clarifying question ("Which database do you prefer?") or asking for permission to run a command. The user needs to act.plan_ready: The AI has finished the plan. It has stopped and is waiting for the user to approve or reject the proposal.In Chapter 2, we introduced the polling function. Now, let's see how we use the Phase Lifecycle to update our UI.
We pass a simple "callback" function to our poller. Every time the phase changes, this function runs.
import { pollForApprovedExitPlanMode, type UltraplanPhase } from './ccrSession';
// 1. Define how the UI reacts to changes
const updateTrafficLight = (phase: UltraplanPhase) => {
if (phase === 'running') showGreenSpinner();
if (phase === 'needs_input') showYellowAlert();
if (phase === 'plan_ready') showRedReviewButton();
};
// 2. Start the polling loop with the callback
await pollForApprovedExitPlanMode(
sessionId,
60000,
updateTrafficLight // <--- Pass the listener here
);
By using this simple abstraction, the frontend code doesn't need to know anything about "events" or "tools." It just listens for the traffic light to change color.
How do we actually determine the phase? The server doesn't explicitly send "Green" or "Yellow." We have to deduce it by looking at two things:
Here is the decision flow we implement inside the poller:
Let's look at ccrSession.ts to see how this logic is written in code. It happens at the very end of our polling loop.
First, we define the valid states for our system.
/**
* running -> AI is working
* needs_input -> AI stopped to ask a question
* plan_ready -> AI finished the plan, waiting for approval
*/
export type UltraplanPhase = 'running' | 'needs_input' | 'plan_ready'
This is the trickiest part. The server is "Idle" (waiting) if the sessionStatus is idle.
However, we add a safety check: we only consider it truly idle if newEvents.length === 0. If events are still flowing in, the AI is probably still "typing," so we keep the light Green (running) to prevent the UI from flickering Yellow for a split second.
// We only trust "idle" status if no new events arrived.
// Otherwise, the system is technically working/streaming.
const quietIdle =
(sessionStatus === 'idle' || sessionStatus === 'requires_action') &&
newEvents.length === 0
Now we combine everything into a single variable. Order of operations matters here!
const phase: UltraplanPhase = scanner.hasPendingPlan
? 'plan_ready'
: quietIdle
? 'needs_input'
: 'running'
Finally, we check if the phase has changed since the last loop. We don't want to spam the UI with updates if the light is still Green.
// Only fire the callback if the color changed
if (phase !== lastPhase) {
logForDebugging(`[ultraplan] phase ${lastPhase} β ${phase}`)
lastPhase = phase
onPhaseChange?.(phase)
}
In this chapter, we learned how to humanize the AI's complex internal state.
We built a Session Phase Lifecycle that acts as a traffic light:
running): Polling continues silently.needs_input): The poller detects the server is idle and needs the user.plan_ready): The poller detects the plan is finished.
But waitβhow exactly does the scanner know that a plan is pending? How does it tell the difference between a normal chat message and a structured coding plan?
We will explore that machinery in the next chapter.
Next Chapter: Event Stream State Machine
Generated by Code IQ