# AgentSession Lifecycle Management in Pi Web: How globalThis Registries Survive Next.js Hot Reloads

> Discover how Pi Web manages AgentSession lifecycles using globalThis registries, ensuring persistence through Next.js hot reloads with deduplication and timeouts.

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

---

**Pi Web stores every active chat session in global registries attached to `globalThis`, enabling sessions to persist across Next.js hot reloads while providing deduplicated creation, idle timeouts, and real-time UI synchronization.**

In the `agegr/pi-web` repository, **AgentSession lifecycle management** relies on a clever use of global state to solve a fundamental Next.js limitation: module replacement during development does not reset the global object. By attaching session registries to `globalThis`, Pi Web ensures that long-running `AgentSession` instances remain alive even when server code reloads.

## Global Registries on `globalThis`

Pi Web maintains three distinct registries on `globalThis` to coordinate session state across the application:

- **`__piSessions`** – Maps session IDs to `AgentSessionWrapper` instances. Created lazily via `getRegistry()` in [`lib/rpc-manager.ts`](https://github.com/agegr/pi-web/blob/main/lib/rpc-manager.ts)【L69-L78】, this registry ensures that subsequent requests for the same session ID receive the existing live wrapper rather than spawning duplicates.

- **`__piStartLocks`** – Prevents race conditions during session initialization. Managed by `getLocks()`【L81-L83】, this registry stores in-flight promises for sessions currently being created, guaranteeing that parallel `startRpcSession` calls for the same ID await the same initialization promise.

- **`__piRunningListeners`** – Tracks Server-Sent Events (SSE) listeners that require live updates about which sessions are actively running. Accessed through `getRunningListeners()`【L106-L108】, this enables the sidebar UI to reflect real-time session activity.

## Starting a Session via `startRpcSession`

When a client POSTs to `/api/agent/new`, the system invokes `startRpcSession(sessionId, sessionFile, cwd, toolNames?)`. The implementation follows a strict seven-step flow to ensure safe, idempotent session creation:

1. **Registry Lookup** – Checks `__piSessions` for an existing alive wrapper:
   ```typescript
   const existing = registry.get(sessionId);
   if (existing?.isAlive()) return { session: existing, realSessionId: sessionId };
   ```

2. **Lock Acquisition** – Checks `__piStartLocks` to prevent duplicate creation:
   ```typescript
   const inflight = locks.get(sessionId);
   if (inflight) return inflight;
   ```

3. **SDK Initialization** – Calls `initTheme()` and `createAgentSessionServices()` to configure the Pi SDK environment.

4. **Session File Management** – Opens an existing `.jsonl` file or creates a fresh one using `SessionManager.open()` or `SessionManager.create()`.

5. **Tool Configuration** – When `toolNames` are provided, they are passed to the SDK via `toolsOption` and merged with extension-provided tools:
   ```typescript
   if (toolNames && toolNames.length > 0) {
       inner.setActiveToolsByName(withExtensionTools(inner, toolNames));
   }
   ```

6. **Wrapper Instantiation** – Wraps the raw `AgentSessionLike` in `AgentSessionWrapper`, providing a uniform API for the UI layer.

7. **Registration and Binding** – Stores the wrapper in `__piSessions`, registers a destroy callback for cleanup, and invokes `wrapper.beginExtensionBinding()` to activate extensions.

The entire operation executes within an async closure cached in `__piStartLocks`, with the lock automatically cleared once initialization completes.

## Inside `AgentSessionWrapper`: State and Timeout Management

The `AgentSessionWrapper` class (defined in [`lib/rpc-manager.ts`](https://github.com/agegr/pi-web/blob/main/lib/rpc-manager.ts)) augments the raw SDK session with lifecycle bookkeeping and UI-specific behaviors:

- **Event Broadcasting** – The `start()` method subscribes to inner session events, forwards them to UI listeners, and triggers sidebar updates via `notifyRunningChange()`【L44-L55】.

- **Idle Timeout Handling** – `resetIdleTimer()` implements a 10-minute inactivity threshold. If no prompt, stream, compaction, or bash operation runs during this period, the timer automatically invokes `destroy()`【L61-L68】:
  ```typescript
  setTimeout(..., 10 * 60 * 1000);
  ```

- **Graceful Shutdown** – The `destroy()` method marks the wrapper as dead (`this._alive = false`), clears pending timers, aborts active bash processes, unsubscribes from events, resolves pending UI promises, and notifies running-state listeners【L12-L24】.

- **Running-State Detection** – `isRunning()` reports active operations by checking `this._alive` and flags like `promptRunning`, `streaming`, `compacting`, or `bash`【L40-L42】.

## Forking and Session Termination

Session forking represents a special lifecycle transition. When processing a `"fork"` command, the system creates a new `.jsonl` file and registers a new session ID, then **immediately destroys the current wrapper** to prevent the old ID from pointing to stale state【L26-L28】:

```typescript
this.destroy();

```

This ensures that the original session ID becomes invalid while the forked session continues under its new identity.

## Broadcasting Real-Time Session State

The UI sidebar consumes SSE streams from `/api/agent/running/events` to display active sessions. The `notifyRunningChange()` function coordinates this by:

1. Collecting all alive session IDs reporting `isRunning()`.
2. Serializing the set and comparing it against the previous snapshot.
3. Emitting updates to every listener in `__piRunningListeners`【L24-L32】:

```typescript
export function notifyRunningChange(): void {
    const ids = getRunningRpcSessionIds();
    const snapshot = JSON.stringify([...ids].sort());
    if (snapshot === lastRunningSnapshot) return;
    lastRunningSnapshot = snapshot;
    for (const listener of getRunningListeners()) {
        try { listener(ids); } catch {}
    }
}

```

This mechanism fires whenever a wrapper starts, destroys, or changes its running state, keeping the sidebar synchronized without polling.

## Complete Lifecycle Flow

The full **AgentSession lifecycle management** flow in Pi Web operates as follows:

1. **Request** – Client initiates a session; `startRpcSession` checks `__piSessions` and `__piStartLocks`.
2. **Creation** – New `AgentSessionWrapper` instantiates, registers in `__piSessions`, and binds extensions.
3. **Activity** – UI sends commands via `wrapper.send()`; the wrapper manages idle timers, invokes extensions, and forwards SDK events.
4. **Idle Monitoring** – `resetIdleTimer()` tracks inactivity; after 10 minutes of no operations, automatic `destroy()` triggers.
5. **Cleanup** – `destroy()` removes the wrapper from `__piSessions`, aborts processes, and broadcasts final state changes via `notifyRunningChange()`.

Because these registries live on `globalThis`, they survive Next.js hot-reloads, ensuring that a long-running bash command or active stream persists even when developer code changes reload the server modules.

## Summary

- **Global registries** (`__piSessions`, `__piStartLocks`, `__piRunningListeners`) attached to `globalThis` enable session persistence across Next.js hot reloads.
- **`startRpcSession`** implements idempotent session creation through registry checks and promise locks to prevent duplicate initialization.
- **`AgentSessionWrapper`** manages a 10-minute idle timeout, graceful shutdown procedures, and real-time event broadcasting.
- **Fork handling** destroys the original wrapper after creating a new session file, ensuring clean state transitions.
- **`notifyRunningChange`** synchronizes the UI sidebar via SSE by broadcasting running-state snapshots to registered listeners.

## Frequently Asked Questions

### How does Pi Web prevent duplicate sessions when multiple requests hit the same endpoint simultaneously?

Pi Web uses the `__piStartLocks` registry on `globalThis` to store in-flight initialization promises. When `startRpcSession` detects an existing promise for a session ID, it returns that promise instead of creating a new session, ensuring all concurrent callers receive the same `AgentSessionWrapper` instance once initialization completes.

### What happens to active sessions when Next.js reloads the server code during development?

Because Pi Web stores session registries on `globalThis` rather than in module-level variables, the session objects survive hot reloads. The `__piSessions` registry retains references to live `AgentSessionWrapper` instances, allowing long-running operations like bash commands or streaming responses to continue uninterrupted across code changes.

### How does the automatic cleanup mechanism work for idle sessions?

Each `AgentSessionWrapper` maintains an idle timer via `resetIdleTimer()`. If the session records no activity (no running prompts, streams, compaction, or bash operations) for 10 minutes, the timer automatically triggers `destroy()`. This method removes the wrapper from `__piSessions`, aborts any subprocesses, unsubscribes from events, and notifies UI listeners to update the sidebar.

### Can a session be manually terminated before the idle timeout expires?

Yes. While the 10-minute idle timeout handles automatic cleanup, you can manually invoke `session.destroy()` on any `AgentSessionWrapper` instance. This immediately marks the session as dead, clears resources, and removes it from the global registry, making the session ID available for fresh initialization if needed.