How Pi Web Uses `globalThis` for Session Lifecycle Management Across Hot-Reloads
The short answer: Pi Web stores its session registry, start-locks, and running-listeners on globalThis in lib/rpc-manager.ts, allowing AgentSessionWrapper instances to survive Next.js hot-reloads that would otherwise wipe module-scoped state.
Pi Web is a Next.js application that runs Pi CLI sessions inside AgentSessionWrapper objects. Because the Next.js development server hot-reloads modules without restarting Node, ordinary module-level variables get destroyed on every code change. This article explains how Pi Web leverages the global object to maintain stable, long-lived agent sessions across reloads.
The Hot-Reload Problem in Next.js Development
Next.js uses Fast Refresh to update code without losing component state in the browser, but the server-side story is different. When you save a file, the Node.js process:
- Re-executes the changed module and its dependencies
- Creates fresh top-level variables, Maps, and Sets
- Keeps the same process and
globalThisobject
For Pi Web, this is catastrophic without intervention. A Map declared at the top of lib/rpc-manager.ts to track active sessions would empty on every save, killing running agent processes. The solution is to store state on globalThis instead of module scope.
Session Registry: The Global Map of Active Sessions
The heart of Pi Web's session lifecycle management is globalThis.__piSessions. In lib/rpc-manager.ts, the registry is declared and accessed through a lazy-initialization pattern:
// lib/rpc-manager.ts
declare global {
var __piSessions: Map<string, AgentSessionWrapper> | undefined;
}
function getRegistry(): Map<string, AgentSessionWrapper> {
if (!globalThis.__piSessions) {
globalThis.__piSessions = new Map();
}
return globalThis.__piSessions;
}
When startRpcSession() creates a new session, it stores the wrapper in this global Map using registry.set(realSessionId, wrapper). Subsequent requests for the same sessionId retrieve the identical wrapper instance via registry.get(sessionId). Because globalThis persists across hot-reloads, your agent session continues running even while you edit React components.
Start-Locks: Preventing Duplicate Session Creation
Concurrent requests for the same session ID create a race condition. Pi Web solves this with globalThis.__piStartLocks:
declare global {
var __piStartLocks: Map<string, Promise<{ session: AgentSessionWrapper; realSessionId: string }>> | undefined;
}
function getLocks(): Map<string, Promise<...>> {
if (!globalThis.__piStartLocks) {
globalThis.__piStartLocks = new Map();
}
return globalThis.__piStartLocks;
}
When startRpcSession() begins, it checks getLocks().get(sessionId). If a lock exists, it awaits that same promise. If not, it creates the session and stores the creation promise. This guarantees exactly one session per ID even when multiple API requests arrive during a hot-reload.
Running-Listeners: SSE That Survives Reloads
The Pi Web sidebar shows live running sessions via Server-Sent Events. The listener registry lives on globalThis.__piRunningListeners:
declare global {
var __piRunningListeners: Set<(ids: string[]) => void> | undefined;
}
export function subscribeRunningSessions(callback: (ids: string[]) => void): () => void {
const listeners = getRunningListeners();
listeners.add(callback);
return () => listeners.delete(callback);
}
When notifyRunningChange() broadcasts updates, it iterates this same Set that existed before the hot-reload. Connected clients never lose their subscription.
Session Lifecycle: Creation to Cleanup
Starting or Reusing a Session
The startRpcSession() function implements the full lifecycle:
export async function startRpcSession(
sessionId: string,
dontateScene: string,
cwd: string,
options: { toolNames?: string[]; initialModel?: string }
): Promise<{ session: AgentSessionWrapper; realSessionId: string }> {
const registry = getRegistry();
const locks = getLocks();
// Check for existing session (may have survived hot-reload)
const existing = registry.get(sessionId);
if (existing) {
return { session: existing, realSessionId: existing.realSessionId };
}
// Check for in-flight creation
const existingLock = locks.get(sessionId);
if (existingLock) {
return existingLock;
}
// Create new session
const creationPromise = (async () => {
const sessionManager = new SessionManager(cwd, sessionId, dontateScene);
await sessionManager.open();
const services = createSessionServices(...);
const inner = new AgentSession(sessionManager, services, ...);
const wrapper = new AgentSessionWrapper(inner);
registry.set(wrapper.realSessionId, wrapper);
await wrapper.start();
return { session: wrapper, realSessionId: wrapper.realSessionId };
})();
locks.set(sessionId, creationPromise);
creationPromise.finally(() => locks.delete(sessionId));
return creationPromise;
}
The wrapper stored in registry outlives any number of hot-reloads because globalThis.__piSessions is never re-initialized.
Idle Timeout and Resource Management
Each AgentSessionWrapper manages its own idle timer:
class AgentSessionWrapper {
private idleTimer: ReturnType<typeof setTimeout> | null = null;
resetIdleTimer(): void {
if (this.idleTimer) clearTimeout(this.idleTimer);
this.idleTimer = setTimeout(() => this.shutdown(), IDLE_TIMEOUT_MS);
}
async destroy(): Promise<void> {
if (this.idleTimer) clearTimeout(this.idleTimer);
await this.inner.destroy();
}
}
The timer reference lives on the wrapper instance, which persists in the global registry. Hot-reloads don't interrupt timeout tracking.
Process-Exit Cleanup
Pi Web registers cleanup handlers once per process, attached inside getRegistry():
function getRegistry(): Map<string, AgentSessionWrapper> {
if (!globalThis.__piSessions) {
globalThis.__piSessions = new Map();
const cleanup = () => {
for (const wrapper of globalThis.__piSessions!.values()) {
wrapper.destroy().catch(console.error);
}
};
process.once("exit", cleanup);
process.once("SIGINT", cleanup);
process.once("SIGTERM", cleanup);
}
return globalThis.__piSessions;
}
When you finally stop the Next.js dev server, every wrapper receives graceful destruction.
Fork and Navigation: Session ID Mutation
Agent sessions support forking and tree navigation. When these operations occur, the wrapper's realSessionId changes. Pi Web handles this by:
- Removing the old entry:
registry.delete(oldRealSessionId) - Storing under the new ID:
registry.set(newRealSessionId, wrapper)
This prevents stale references while keeping the same wrapper instance alive.
Practical Usage Examples
Access the Global Registry Directly
import { getRegistry } from "@/lib/rpc-manager";
const sessions = getRegistry(); // Map<string, AgentSessionWrapper>
console.log(`Active sessions: ${sessions.size}`);
Subscribe to Running Session Updates
import { subscribeRunningSessions } from "@/lib/rpc-manager";
const unsubscribe = subscribeRunningSessions((ids) => {
console.log("Running:", ids);
});
// Cleanup on component unmount
unsubscribe();
Gracefully Shutdown a Session
import { getRpcSession } from "@/lib/rpc-manager";
async function stopSession(id: string) {
const wrapper = getRpcSession(id);
if (wrapper) {
await wrapper.shutdown(); // Removes from global registry
}
}
Key Source Files
lib/rpc-manager.ts— Core implementation ofglobalThisstorage,AgentSessionWrapper,startRpcSession, and listener managementapp/api/agent/[id]/route.ts— HTTP API endpoint that leverages the global registryapp/api/agent/[id]/events/route.ts— SSE endpoint subscribing to global listener sets
Summary
- Problem: Next.js hot-reloads destroy module-scoped state, killing agent sessions
- Solution: Pi Web stores three critical structures on
globalThis:__piSessions— Map of activeAgentSessionWrapperinstances__piStartLocks— Map of in-flight session creation promises__piRunningListeners— Set of SSE callback functions
- Benefits: Sessions survive unlimited hot-reloads, concurrent requests are deduplicated, and cleanup still occurs on process exit
- Implementation location:
lib/rpc-manager.tswith lazy initialization and reference-counted destruction
Frequently Asked Questions
What happens to active sessions during a Next.js hot-reload?
Nothing. Because Pi Web stores session wrappers on globalThis.__piSessions rather than in module scope, the same AgentSessionWrapper instances and their underlying Pi CLI processes continue running. The new code simply retrieves existing sessions from the global Map.
How does Pi Web prevent creating duplicate sessions for the same ID?
The __piStartLocks Map on globalThis stores promises for in-flight session creation. Concurrent calls to startRpcSession() with the same ID await the same promise, guaranteeing exactly one session per ID even across hot-reloads that interrupt request handling.
Where is the session cleanup logic located?
Cleanup occurs in two places. Per-session idle timeouts are handled by AgentSessionWrapper.resetIdleTimer() and destroy(). Process-level cleanup for all sessions is registered once inside getRegistry() via process.once("exit", ...), process.once("SIGINT", ...), and process.once("SIGTERM", ...).
Can I access the global session registry from my own Next.js API routes?
Yes. Import getRegistry() from @/lib/rpc-manager to access globalThis.__piSessions. This returns the same Map used internally, allowing custom monitoring or management of active agent sessions.
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 →