# How pi-web Reconciles Running State SSE to Prevent Stale Events

> Learn how pi-web reconciles running state SSE using a periodic loop and GET requests to prevent stale events and ensure accurate session state.

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

---

**pi-web prevents stale running-state events by combining Server-Sent Events (SSE) with a periodic reconciliation loop that verifies the actual session state via `GET /api/agent/[id]` whenever the UI suspects it has missed an update.**

pi-web is an open-source agent runtime that maintains strict synchronization between server-side execution and client-side interface states. When network interruptions or browser throttling cause missed SSE notifications, the system must ensure that UI indicators like "thinking" bubbles accurately reflect the server's actual running state. The reconciliation mechanism implemented in the `useAgentSession` hook guarantees that stale events never persist by cross-referencing the SSE feed against the ground-truth session endpoint.

## The Dual-Feed Synchronization Strategy

pi-web employs a two-pronged approach to maintain a reliable view of an agent's running state. This architecture combines push-based real-time updates with pull-based verification to eliminate drift between the server and client.

### Real-Time Updates via SSE

The primary data feed originates from the Server-Sent Events endpoint at `GET /api/agent/running/events`. This stream broadcasts the current set of running session IDs whenever any session's status changes.

In [`app/api/agent/running/events/route.ts`](https://github.com/agegr/pi-web/blob/main/app/api/agent/running/events/route.ts), the endpoint retrieves the active sessions from the RPC manager and encodes them for the event stream:

```ts
// app/api/agent/running/events/route.ts
export async function GET(request: Request) {
  const { encode } = await import("@/lib/sse");
  const ids = getRunningRpcSessionIds();          // current set
  return new Response(encode({ type: "running", runningSessionIds: ids }), {
    headers: { "Content-Type": "text/event-stream" },
  });
}

```

Whenever any session's `isRunning` flag changes, the `encode` function broadcasts the updated set to all connected clients. This provides immediate UI updates during normal network conditions.

### Periodic State Reconciliation

SSE connections can drop due to network hiccups, background tab throttling, or browser sleep modes. To guard against missed notifications, `useAgentSession` implements a reconciliation loop that polls the definitive session state endpoint `GET /api/agent/[id]`.

The hook fetches the `AgentStateResponse` from `app/api/agent/[id]/route.ts` to obtain the latest `running` flag and full session state. This polling occurs both on a schedule and in response to specific browser events, ensuring the UI never lags behind reality.

## Reconciliation Logic in useAgentSession

The core reconciliation logic resides in [`hooks/useAgentSession.ts`](https://github.com/agegr/pi-web/blob/main/hooks/useAgentSession.ts) (lines 818-852). After each SSE-driven update or periodic check, the client compares its local state against the server's authoritative response:

```ts
// hooks/useAgentSession.ts (excerpt, lines 818-852)
if (serverState.running && state) {
  // Server says still running → keep UI busy
  setAgentPhase(state.isStreaming ? { kind: "waiting_model" } : { kind: "running_command" });
} else if (!serverState.running) {
  // Server says idle but UI still thinks we're busy → clean up
  if (state?.isStreaming) {
    // Finish the streaming bubble
    setAgentPhase({ kind: "waiting_model" });
  }
  // Ensure SSE is re-connected if needed
  reconnectSSE();
}

```

**Key reconciliation behaviors:**

- **Stale UI cleanup:** When `serverState.running` is `false` but the UI still displays a streaming indicator, the hook forces the end of the streaming bubble by calling `setAgentPhase` and clears the running flag.
- **Missed event recovery:** If the UI believes the session is idle but the server reports `running: true`, the client re-opens the SSE stream via `reconnectSSE()` and resumes listening for events.
- **State synchronization:** The hook updates the local `runningSessionIds` set to match the server's ground truth, ensuring subsequent operations reference the correct execution status.

## Handling Browser Throttling and Network Recovery

The reconciliation loop activates aggressively when the user returns to the application after periods of inactivity. In [`hooks/useAgentSession.ts`](https://github.com/agegr/pi-web/blob/main/hooks/useAgentSession.ts), event listeners trigger immediate state verification:

```ts
// hooks/useAgentSession.ts (excerpt)
useEffect(() => {
  const handle = () => reconcileRunningState(); // calls the logic above
  window.addEventListener("visibilitychange", handle);
  window.addEventListener("online", handle);
  return () => {
    window.removeEventListener("visibilitychange", handle);
    window.removeEventListener("online", handle);
  };
}, []);

```

When the `visibilitychange` event fires (user returns to the tab) or the `online` event fires (network connectivity restored), the hook immediately calls `reconcileRunningState()`. This catches any SSE messages missed while the tab was backgrounded or offline, preventing the "stale thinking bubble" problem where the UI incorrectly indicates an active session.

## Summary

- **Dual-feed architecture:** pi-web combines SSE streams from `/api/agent/running/events` with periodic polling of `/api/agent/[id]` to maintain accurate state synchronization.
- **Automatic drift correction:** The `useAgentSession` hook (lines 818-852) compares local UI state against the server's `running` flag, forcing cleanup of stale indicators when the server reports idle status.
- **Resilient to network issues:** Browser events like `visibilitychange` and `online` trigger immediate reconciliation, recovering missed SSE updates caused by tab throttling or disconnections.
- **Centralized session tracking:** The [`lib/rpc-manager.ts`](https://github.com/agegr/pi-web/blob/main/lib/rpc-manager.ts) module maintains the source of truth via `getRunningRpcSessionIds()`, which both the SSE endpoint and reconciliation logic reference.

## Frequently Asked Questions

### How does pi-web detect missed SSE events?

pi-web detects missed SSE events through a **reconciliation loop** in `useAgentSession` that periodically fetches the full session state from `GET /api/agent/[id]`. When the browser fires `visibilitychange` or `online` events, or when the hook's internal timer triggers, it compares the server's `running` flag against the local UI state. Any discrepancy indicates a missed SSE update, prompting immediate state synchronization.

### What happens when the server reports idle but the UI shows running?

When the server reports `running: false` but the UI still displays active indicators, the reconciliation logic in [`hooks/useAgentSession.ts`](https://github.com/agegr/pi-web/blob/main/hooks/useAgentSession.ts) forces an immediate cleanup. It calls `setAgentPhase({ kind: "waiting_model" })` to terminate the streaming bubble and clears the local running flag. This prevents "zombie" loading states that persist after the underlying agent session has completed.

### How does the SSE endpoint track which sessions are running?

The SSE endpoint in [`app/api/agent/running/events/route.ts`](https://github.com/agegr/pi-web/blob/main/app/api/agent/running/events/route.ts) imports `getRunningRpcSessionIds()` from [`lib/rpc-manager.ts`](https://github.com/agegr/pi-web/blob/main/lib/rpc-manager.ts). This function queries the central RPC manager registry that tracks all `AgentSessionWrapper` lifecycles. Whenever any session's `isRunning` status changes, the manager updates its internal set, and the SSE endpoint broadcasts the new collection to connected clients.

### Why does pi-web use both SSE and polling instead of just SSE?

pi-web uses both mechanisms because **SSE connections are unreliable** in real-world browser environments. Background tabs face throttling, mobile browsers drop connections to save battery, and network interruptions occur without notice. The periodic polling acts as a safety net that guarantees eventual consistency, ensuring that even if the SSE stream misses updates, the UI will correct itself within seconds of the next reconciliation check.