AgentSession Lifecycle Management in Pi Web: How globalThis Registries Survive Next.js Hot Reloads

Pi Web stores every active chat session in global registries attached to globalThis, enabling sessions to persist across Next.js hot reloads while providing deduplicated creation, idle timeouts, and real-time UI synchronization.

In the agegr/pi-web repository, AgentSession lifecycle management relies on a clever use of global state to solve a fundamental Next.js limitation: module replacement during development does not reset the global object. By attaching session registries to globalThis, Pi Web ensures that long-running AgentSession instances remain alive even when server code reloads.

Global Registries on globalThis

Pi Web maintains three distinct registries on globalThis to coordinate session state across the application:

  • __piSessions – Maps session IDs to AgentSessionWrapper instances. Created lazily via getRegistry() in lib/rpc-manager.ts【L69-L78】, this registry ensures that subsequent requests for the same session ID receive the existing live wrapper rather than spawning duplicates.

  • __piStartLocks – Prevents race conditions during session initialization. Managed by getLocks()【L81-L83】, this registry stores in-flight promises for sessions currently being created, guaranteeing that parallel startRpcSession calls for the same ID await the same initialization promise.

  • __piRunningListeners – Tracks Server-Sent Events (SSE) listeners that require live updates about which sessions are actively running. Accessed through getRunningListeners()【L106-L108】, this enables the sidebar UI to reflect real-time session activity.

Starting a Session via startRpcSession

When a client POSTs to /api/agent/new, the system invokes startRpcSession(sessionId, sessionFile, cwd, toolNames?). The implementation follows a strict seven-step flow to ensure safe, idempotent session creation:

  1. Registry Lookup – Checks __piSessions for an existing alive wrapper:

    const existing = registry.get(sessionId);
    if (existing?.isAlive()) return { session: existing, realSessionId: sessionId };
  2. Lock Acquisition – Checks __piStartLocks to prevent duplicate creation:

    const inflight = locks.get(sessionId);
    if (inflight) return inflight;
  3. SDK Initialization – Calls initTheme() and createAgentSessionServices() to configure the Pi SDK environment.

  4. Session File Management – Opens an existing .jsonl file or creates a fresh one using SessionManager.open() or SessionManager.create().

  5. Tool Configuration – When toolNames are provided, they are passed to the SDK via toolsOption and merged with extension-provided tools:

    if (toolNames && toolNames.length > 0) {
        inner.setActiveToolsByName(withExtensionTools(inner, toolNames));
    }
  6. Wrapper Instantiation – Wraps the raw AgentSessionLike in AgentSessionWrapper, providing a uniform API for the UI layer.

  7. Registration and Binding – Stores the wrapper in __piSessions, registers a destroy callback for cleanup, and invokes wrapper.beginExtensionBinding() to activate extensions.

The entire operation executes within an async closure cached in __piStartLocks, with the lock automatically cleared once initialization completes.

Inside AgentSessionWrapper: State and Timeout Management

The AgentSessionWrapper class (defined in lib/rpc-manager.ts) augments the raw SDK session with lifecycle bookkeeping and UI-specific behaviors:

  • Event Broadcasting – The start() method subscribes to inner session events, forwards them to UI listeners, and triggers sidebar updates via notifyRunningChange()【L44-L55】.

  • Idle Timeout Handling – resetIdleTimer() implements a 10-minute inactivity threshold. If no prompt, stream, compaction, or bash operation runs during this period, the timer automatically invokes destroy()【L61-L68】:

    setTimeout(..., 10 * 60 * 1000);
  • Graceful Shutdown – The destroy() method marks the wrapper as dead (this._alive = false), clears pending timers, aborts active bash processes, unsubscribes from events, resolves pending UI promises, and notifies running-state listeners【L12-L24】.

  • Running-State Detection – isRunning() reports active operations by checking this._alive and flags like promptRunning, streaming, compacting, or bash【L40-L42】.

Forking and Session Termination

Session forking represents a special lifecycle transition. When processing a "fork" command, the system creates a new .jsonl file and registers a new session ID, then immediately destroys the current wrapper to prevent the old ID from pointing to stale state【L26-L28】:

this.destroy();

This ensures that the original session ID becomes invalid while the forked session continues under its new identity.

Broadcasting Real-Time Session State

The UI sidebar consumes SSE streams from /api/agent/running/events to display active sessions. The notifyRunningChange() function coordinates this by:

  1. Collecting all alive session IDs reporting isRunning().
  2. Serializing the set and comparing it against the previous snapshot.
  3. Emitting updates to every listener in __piRunningListeners【L24-L32】:
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 {}
    }
}

This mechanism fires whenever a wrapper starts, destroys, or changes its running state, keeping the sidebar synchronized without polling.

Complete Lifecycle Flow

The full AgentSession lifecycle management flow in Pi Web operates as follows:

  1. Request – Client initiates a session; startRpcSession checks __piSessions and __piStartLocks.
  2. Creation – New AgentSessionWrapper instantiates, registers in __piSessions, and binds extensions.
  3. Activity – UI sends commands via wrapper.send(); the wrapper manages idle timers, invokes extensions, and forwards SDK events.
  4. Idle Monitoring – resetIdleTimer() tracks inactivity; after 10 minutes of no operations, automatic destroy() triggers.
  5. Cleanup – destroy() removes the wrapper from __piSessions, aborts processes, and broadcasts final state changes via notifyRunningChange().

Because these registries live on globalThis, they survive Next.js hot-reloads, ensuring that a long-running bash command or active stream persists even when developer code changes reload the server modules.

Summary

  • Global registries (__piSessions, __piStartLocks, __piRunningListeners) attached to globalThis enable session persistence across Next.js hot reloads.
  • startRpcSession implements idempotent session creation through registry checks and promise locks to prevent duplicate initialization.
  • AgentSessionWrapper manages a 10-minute idle timeout, graceful shutdown procedures, and real-time event broadcasting.
  • Fork handling destroys the original wrapper after creating a new session file, ensuring clean state transitions.
  • notifyRunningChange synchronizes the UI sidebar via SSE by broadcasting running-state snapshots to registered listeners.

Frequently Asked Questions

How does Pi Web prevent duplicate sessions when multiple requests hit the same endpoint simultaneously?

Pi Web uses the __piStartLocks registry on globalThis to store in-flight initialization promises. When startRpcSession detects an existing promise for a session ID, it returns that promise instead of creating a new session, ensuring all concurrent callers receive the same AgentSessionWrapper instance once initialization completes.

What happens to active sessions when Next.js reloads the server code during development?

Because Pi Web stores session registries on globalThis rather than in module-level variables, the session objects survive hot reloads. The __piSessions registry retains references to live AgentSessionWrapper instances, allowing long-running operations like bash commands or streaming responses to continue uninterrupted across code changes.

How does the automatic cleanup mechanism work for idle sessions?

Each AgentSessionWrapper maintains an idle timer via resetIdleTimer(). If the session records no activity (no running prompts, streams, compaction, or bash operations) for 10 minutes, the timer automatically triggers destroy(). This method removes the wrapper from __piSessions, aborts any subprocesses, unsubscribes from events, and notifies UI listeners to update the sidebar.

Can a session be manually terminated before the idle timeout expires?

Yes. While the 10-minute idle timeout handles automatic cleanup, you can manually invoke session.destroy() on any AgentSessionWrapper instance. This immediately marks the session as dead, clears resources, and removes it from the global registry, making the session ID available for fresh initialization if needed.

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 →