How Pi-Web’s Running-State SSE Prevents Missed agent_end Events in Background Tabs
Pi-Web uses a periodic reconciliation layer that polls the REST endpoint every 15 seconds and triggers on visibility changes to ensure the UI never misses the final agent_end event, even when the browser suspends the SSE stream in background tabs.
When browser tabs enter the background, the EventSource connection delivering live agent updates may suspend or drop, risking the loss of the critical agent_end signal. The pi-web repository (agegr/pi-web) solves this by combining a Server-Sent Events (SSE) stream with a robust client-side reconciliation mechanism that periodically queries the authoritative server state. This dual-layer approach guarantees that the UI never gets stuck in a perpetual "thinking" state due to missed events.
The Problem with SSE in Background Tabs
Browsers aggressively throttle background tabs to conserve battery and CPU. When a tab is hidden, the EventSource connection to /api/agent/[id]/events may be suspended, buffered out-of-order, or dropped entirely. If the final agent_end event arrives during this window, the client never receives the signal that the agent has finished processing. Without a fallback mechanism, the UI would remain in a streaming state indefinitely, displaying a "thinking" indicator long after the server has gone idle.
The Reconciliation Layer Architecture
To guard against these interruptions, pi-web implements a reconciliation layer in hooks/useAgentSession.ts that operates alongside the SSE stream. This layer treats the REST endpoint /api/agent/[id] as the source of truth and periodically synchronizes the client state with the server.
Periodic Polling While the Agent is Running
While agentRunning is true, a setInterval fires every AGENT_STATE_RECONCILE_MS (15 seconds) to fetch the current state. The effect is defined at lines 354–372 in hooks/useAgentSession.ts:
useEffect(() => {
if (!agentRunning) return;
const reconcile = () => {
const sid = sessionIdRef.current;
if (sid) void reconcileAgentState(sid);
};
const interval = setInterval(reconcile, AGENT_STATE_RECONCILE_MS);
document.addEventListener("visibilitychange", onVisible);
window.addEventListener("online", reconcile);
return () => {
clearInterval(interval);
document.removeEventListener("visibilitychange", onVisible);
window.removeEventListener("online", reconcile);
};
}, [agentRunning, reconcileAgentState]);
This ensures that even if the SSE stream stalls, the client checks the server state at regular intervals.
Visibility and Network Recovery Hooks
The same reconcile function is invoked immediately when the tab becomes visible again or when the network connection recovers. The visibilitychange listener triggers reconciliation when document.visibilityState returns to "visible", while the online event fires when connectivity is restored. These hooks provide rapid catch-up when the user returns to the tab, eliminating the lag that would otherwise occur while waiting for the next polling tick.
State Reconciliation Logic
The core reconciliation routine, reconcileAgentState, lives at lines 218–248 in hooks/useAgentSession.ts. This async function fetches the current agent state and determines whether the UI should terminate the streaming bubble:
const reconcileAgentState = useCallback(async (sid: string) => {
if (!agentRunningRef.current) return;
const runId = promptRunIdRef.current;
try {
const res = await fetch(`/api/agent/${encodeURIComponent(sid)}`);
if (!res.ok) return;
const data = await res.json() as { running?: boolean; state?: AgentStateResponse };
if (promptRunIdRef.current !== runId) return; // stale response guard
const state = data.state;
setIsCompacting(state?.isCompacting ?? false);
setQueuedMessages(normalizeQueuedMessages(state?.queuedMessages));
const busy = data.running && state && (state.isStreaming || state.isPromptRunning || state.isCompacting);
if (busy || !agentRunningRef.current) return;
// Server is idle → finish the client‑side streaming UI
await finishPromptWithoutStream(sid, runId);
} catch {
// Network still down – next tick will retry
}
}, [finishPromptWithoutStream]);
The function implements a stale response guard by capturing promptRunIdRef.current at the start and comparing it after the fetch. If the IDs mismatch, the response is discarded to prevent race conditions. The logic checks three server-side flags—isStreaming, isPromptRunning, and isCompacting—to determine if the agent is truly idle. When all flags are false and data.running is inactive, the client calls finishPromptWithoutStream to close the streaming UI and refresh the session data, effectively simulating the missed agent_end event.
SSE Connection Resilience
Beyond polling, pi-web ensures the SSE stream itself is resilient. The connectEvents function (located at lines 94–138 in hooks/useAgentSession.ts) manages the EventSource lifecycle:
const connectEvents = (sid: string) => {
const es = new EventSource(`/api/agent/${encodeURIComponent(sid)}/events`);
es.onerror = () => {
if (es.readyState === EventSource.CLOSED && agentRunningRef.current) {
setTimeout(() => {
if (agentRunningRef.current) void connectEvents(sid);
}, 1000);
}
};
return es;
};
When the connection closes unexpectedly while the agent is still running, the error handler waits one second and attempts to reconnect. This prevents a permanently broken stream and reduces the window during which events might be missed, complementing the periodic reconciliation layer.
Summary
- Periodic polling every 15 seconds via
setIntervalensures the client periodically verifies the agent state against the REST endpoint/api/agent/[id]. - Visibility and network listeners trigger immediate reconciliation when the user returns to the tab or connectivity is restored.
- State reconciliation in
reconcileAgentStatechecks server-side flags (isStreaming,isPromptRunning,isCompacting) to detect missed agent_end events and callsfinishPromptWithoutStreamto update the UI. - Stale response guards prevent race conditions by comparing the current
promptRunIdagainst the ID captured at request time. - SSE auto-reconnect in
connectEventsattempts to restore dropped connections while the agent is active, minimizing gaps in the event stream.
Frequently Asked Questions
What happens if the SSE connection drops while the tab is hidden?
If the SSE connection drops while the tab is backgrounded, the periodic reconciliation timer continues to run every 15 seconds. When it fires, reconcileAgentState queries the REST endpoint and detects if the server has finished processing. If the agent is idle, the function invokes finishPromptWithoutStream to close the streaming UI, ensuring the agent_end event is effectively recovered even though the SSE stream was interrupted.
How often does pi-web poll the server for state reconciliation?
Pi-web polls the server every 15 seconds (defined by the constant AGENT_STATE_RECONCILE_MS) while the agent is running. Additionally, polling occurs immediately when the document becomes visible again or when the browser reconnects to the network, providing rapid state synchronization beyond the fixed interval.
What specific server-side flags determine if the agent has finished?
The reconciliation logic inspects three boolean flags in the server response: isStreaming, isPromptRunning, and isCompacting. If all three are false and the running property is inactive, the server considers the agent idle. The client then concludes that any missing agent_end event should be handled locally by finalizing the prompt display.
How does pi-web handle stale responses from the reconciliation API?
The reconcileAgentState function captures the current promptRunId at the start of execution and compares it against the value stored in promptRunIdRef.current after the fetch completes. If the values differ, indicating that a new prompt run has started during the network request, the function returns early and discards the stale data. This guard prevents outdated state from overwriting the current session.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →