# How Pi‑Web Manages AgentSession Lifecycle Using globalThis Registries

> Learn how Pi-Web manages AgentSession lifecycle using globalThis registries to survive Next.js hot-reloads. Discover how it coordinates creation, prevents duplicates, and broadcasts UI states.

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

---

**Pi‑Web stores active AgentSession instances in three `globalThis` registries—`__piSessions`, `__piStartLocks`, and `__piRunningListeners`—to survive Next.js hot‑reloads while coordinating creation, preventing duplicate initialization, and broadcasting real‑time running states to the UI.**

Pi‑Web is a Next.js application that maintains long‑running chat sessions via an in‑process `AgentSession`. Because Next.js hot‑reload replaces modules but preserves the global object, the project attaches all active session state to `globalThis`, ensuring sessions persist across code changes and remain accessible to concurrent API requests.

## The Global Registry Architecture

The session management layer in [`lib/rpc-manager.ts`](https://github.com/agegr/pi-web/blob/main/lib/rpc-manager.ts) creates three lazily‑initialized global registries. These structures provide the foundation for the **AgentSession lifecycle** by decoupling session identity from module scope.

### __piSessions: The Session Identity Map

The `__piSessions` registry maps a session identifier to its corresponding `AgentSessionWrapper` instance. When a client reconnects or refreshes, `startRpcSession` checks this registry via `getRegistry()` (lines 69‑78) to retrieve an existing live session rather than spawning a duplicate.

```typescript
function getRegistry(): Map<string, AgentSessionWrapper> {
  if (!globalThis.__piSessions) {
    globalThis.__piSessions = new Map();
  }
  return globalThis.__piSessions;
}

```

### __piStartLocks: Deduplicating Concurrent Creation

To prevent race conditions when multiple requests initialize the same session simultaneously, `__piStartLocks` stores in‑flight promises. If a second request arrives while the first is still creating the session, it awaits the same promise instead of spawning duplicate SDK instances. This lock registry is accessed via `getLocks()` (lines 81‑83).

```typescript
const locks = getLocks();
const inflight = locks.get(sessionId);
if (inflight) return inflight; // Reuse the pending creation promise

```

### __piRunningListeners: Real‑Time Status Broadcasting

The UI sidebar displays which sessions are currently active via Server‑Sent Events (SSE). The `__piRunningListeners` registry, accessed via `getRunningListeners()` (lines 106‑108), holds callback functions that receive updates whenever a session’s running state changes, enabling live synchronization without polling.

## The Session Creation Flow

When a client POSTs to `/api/agent/new`, the `startRpcSession` function orchestrates the full **AgentSession lifecycle** from initialization to global registration.

1. **Registry Lookup**: Checks `__piSessions` for an existing alive wrapper.
2. **Lock Acquisition**: Checks `__piStartLocks` for an in‑flight creation promise.
3. **SDK Initialization**: Calls `initTheme()` and `createAgentSessionServices()` to prepare the Pi SDK environment.
4. **Session Manager**: Opens an existing `.jsonl` log file or creates a fresh one via `SessionManager.open/create`.
5. **Tool Binding**: If `toolNames` are provided, activates them via `inner.setActiveToolsByName(withExtensionTools(inner, toolNames))`.
6. **Wrapping**: Encapsulates the raw `AgentSessionLike` in an `AgentSessionWrapper`.
7. **Registration**: Stores the wrapper in `__piSessions` and attaches a destroy callback to remove it on teardown.
8. **Extension Binding**: Invokes `wrapper.beginExtensionBinding()` to link UI extensions.

All steps execute within an async closure whose result is cached in `__piStartLocks` until the session is ready.

## Inside AgentSessionWrapper

The `AgentSessionWrapper` class in [`lib/rpc-manager.ts`](https://github.com/agegr/pi-web/blob/main/lib/rpc-manager.ts) adds lifecycle management on top of the raw SDK session.

### Event Broadcasting and State Tracking

The wrapper subscribes to underlying SDK events (lines 44‑55) and forwards them to UI listeners. It maintains internal flags (`promptRunning`, `bashRunning`) to track whether the session is actively processing work.

```typescript
this.unsubscribe = this.inner.subscribe((event) => {
  // Forward to UI and update running flags
  this.notifyRunningChange();
});

```

### Idle Timeout and Automatic Cleanup

Each wrapper manages a 10‑minute idle timer via `resetIdleTimer()` (lines 61‑68). If no prompt, stream, compaction, or bash operation occurs within ten minutes, the timer triggers `destroy()`, automatically cleaning up resources and removing the session from `__piSessions`.

```typescript
this.idleTimer = setTimeout(() => this.destroy(), 10 * 60 * 1000);

```

### Graceful Shutdown and Fork Handling

The `destroy()` method (lines 12‑24) performs complete teardown: it sets `_alive` to false, clears the idle timer, aborts running bash processes, unsubscribes from SDK events, resolves pending UI promises, and removes the session from the global registry before notifying running listeners.

When handling a `"fork"` command, the wrapper creates a new session file, registers the new ID, then immediately calls `destroy()` on itself (lines 26‑28), ensuring the old session ID no longer points to a stale object.

## Broadcasting Running State Changes

Whenever a wrapper starts, destroys, or toggles its running flags, it invokes `notifyRunningChange()` (lines 24‑32). This function collects all alive session IDs reporting `isRunning()` (lines 40‑42), serializes the set, and emits updates to every listener in `__piRunningListeners`. The implementation compares the current snapshot with the previous one to avoid redundant SSE broadcasts.

```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 {}
    }
}

```

## Practical Usage Examples

The following patterns demonstrate how to interact with the global registry system in API routes and client code:

```typescript
// Start or retrieve an existing session
import { startRpcSession } from '@/lib/rpc-manager';

export async function POST(req: Request) {
  const { sessionId, cwd, toolNames } = await req.json();
  const { session } = await startRpcSession(sessionId, '', cwd, toolNames);
  // session is an AgentSessionWrapper stored in globalThis.__piSessions
}

```

```typescript
// Send a command with automatic idle timer management
await session.send({ type: 'prompt', message: 'Refactor this function.' });

```

```typescript
// Fork the session (creates new .jsonl, destroys current wrapper)
const result = await session.send({ type: 'fork', entryId: lastEntryId });
if (!result.cancelled) {
  // New session active; old wrapper removed from registry
}

```

```typescript
// Manual cleanup when explicitly closing a chat
session.destroy(); // Removes from __piSessions and clears timers

```

## Summary

- Pi‑Web uses **`globalThis.__piSessions`** to persist session instances across Next.js hot‑reloads, mapping session IDs to `AgentSessionWrapper` objects.
- **`globalThis.__piStartLocks`** prevents duplicate session creation by caching in‑flight initialization promises.
- **`globalThis.__piRunningListeners`** enables real‑time UI updates via SSE by maintaining callbacks that receive running state changes.
- The **`AgentSessionWrapper`** class manages the lifecycle through idle timers (10‑minute timeout), graceful shutdown, and event broadcasting.
- Session forking creates a new registry entry and destroys the old wrapper to prevent stale references.

## Frequently Asked Questions

### How does Pi‑Web prevent creating duplicate sessions for the same ID?

The `startRpcSession` function in [`lib/rpc-manager.ts`](https://github.com/agegr/pi-web/blob/main/lib/rpc-manager.ts) checks `globalThis.__piStartLocks` before initializing. If an asynchronous creation is already in progress, it returns the existing promise. Once complete, the session is stored in `globalThis.__piSessions` for immediate synchronous retrieval on subsequent requests.

### What happens to AgentSession instances during Next.js hot reloading?

Because the registries reside on `globalThis` rather than module scope, they survive Next.js development server reloads. The `AgentSessionWrapper` instances remain alive and accessible via their session IDs, ensuring long‑running operations like bash commands are not interrupted when code changes trigger a server restart.

### How does the automatic cleanup mechanism work?

Each `AgentSessionWrapper` initializes a 10‑minute idle timer via `resetIdleTimer()`. If the session receives no prompts, streams, compaction, or bash activity within that window, the timer fires and invokes `destroy()`. This method removes the wrapper from `__piSessions`, aborts background processes, and notifies listeners, freeing memory and compute resources.

### Can multiple browser tabs share the same AgentSession?

Yes. Since the session state lives in the server’s `globalThis.__piSessions` registry and is keyed by session ID, any HTTP request or WebSocket connection providing the same ID will receive the identical `AgentSessionWrapper` instance. This design supports synchronization across multiple clients or reconnections after network interruptions.