How Pi‑Web Manages AgentSession Lifecycle Using globalThis Registries

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 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.

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).

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 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.

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.

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.

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:

// 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
}
// Send a command with automatic idle timer management
await session.send({ type: 'prompt', message: 'Refactor this function.' });
// 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
}
// 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 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.

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:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →