# How Pi-Web’s Running-State SSE Prevents Missed agent_end Events in Background Tabs

> Discover how Pi-Web’s running state SSE ensures you never miss agent_end events in background tabs with its polling and visibility triggers. Learn more!

- Repository: [Alex Yang/pi-web](https://github.com/agegr/pi-web)
- Tags: internals
- Published: 2026-07-24

---

**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`](https://github.com/agegr/pi-web/blob/main/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`](https://github.com/agegr/pi-web/blob/main/hooks/useAgentSession.ts):

```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`](https://github.com/agegr/pi-web/blob/main/hooks/useAgentSession.ts). This async function fetches the current agent state and determines whether the UI should terminate the streaming bubble:

```ts
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`](https://github.com/agegr/pi-web/blob/main/hooks/useAgentSession.ts)) manages the EventSource lifecycle:

```ts
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 `setInterval` ensures 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 `reconcileAgentState` checks server-side flags (`isStreaming`, `isPromptRunning`, `isCompacting`) to detect missed **agent_end** events and calls `finishPromptWithoutStream` to update the UI.
- **Stale response guards** prevent race conditions by comparing the current `promptRunId` against the ID captured at request time.
- **SSE auto-reconnect** in `connectEvents` attempts 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.