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:

  1. Re-executes the changed module and its dependencies
  2. Creates fresh top-level variables, Maps, and Sets
  3. Keeps the same process and globalThis object

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:

  1. Removing the old entry: registry.delete(oldRealSessionId)
  2. 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 of globalThis storage, AgentSessionWrapper, startRpcSession, and listener management
  • app/api/agent/[id]/route.ts — HTTP API endpoint that leverages the global registry
  • app/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 active AgentSessionWrapper instances
    • __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.ts with 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:

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 →