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.
- Registry Lookup: Checks
__piSessionsfor an existing alive wrapper. - Lock Acquisition: Checks
__piStartLocksfor an in‑flight creation promise. - SDK Initialization: Calls
initTheme()andcreateAgentSessionServices()to prepare the Pi SDK environment. - Session Manager: Opens an existing
.jsonllog file or creates a fresh one viaSessionManager.open/create. - Tool Binding: If
toolNamesare provided, activates them viainner.setActiveToolsByName(withExtensionTools(inner, toolNames)). - Wrapping: Encapsulates the raw
AgentSessionLikein anAgentSessionWrapper. - Registration: Stores the wrapper in
__piSessionsand attaches a destroy callback to remove it on teardown. - 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.__piSessionsto persist session instances across Next.js hot‑reloads, mapping session IDs toAgentSessionWrapperobjects. globalThis.__piStartLocksprevents duplicate session creation by caching in‑flight initialization promises.globalThis.__piRunningListenersenables real‑time UI updates via SSE by maintaining callbacks that receive running state changes.- The
AgentSessionWrapperclass 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:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →