# How Pi Web Uses `globalThis` for Session Lifecycle Management Across Hot-Reloads

> Discover how Pi Web leverages globalThis for resilient session lifecycle management, ensuring state persists through hot-reloads and preventing data loss.

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

---

**The short answer:** Pi Web stores its session registry, start-locks, and running-listeners on `globalThis` in [`lib/rpc-manager.ts`](https://github.com/agegr/pi-web/blob/main/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`](https://github.com/agegr/pi-web/blob/main/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`](https://github.com/agegr/pi-web/blob/main/lib/rpc-manager.ts), the registry is declared and accessed through a lazy-initialization pattern:

```typescript
// 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`:

```typescript
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`:

```typescript
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:

```typescript
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:

```typescript
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()`:

```typescript
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

```typescript
import { getRegistry } from "@/lib/rpc-manager";

const sessions = getRegistry(); // Map<string, AgentSessionWrapper>
console.log(`Active sessions: ${sessions.size}`);

```

### Subscribe to Running Session Updates

```typescript
import { subscribeRunningSessions } from "@/lib/rpc-manager";

const unsubscribe = subscribeRunningSessions((ids) => {
  console.log("Running:", ids);
});

// Cleanup on component unmount
unsubscribe();

```

### Gracefully Shutdown a Session

```typescript
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`](https://github.com/agegr/pi-web/blob/main/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`](https://github.com/agegr/pi-web/blob/main/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.