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 toAgentSessionWrapperinstances. Created lazily viagetRegistry()inlib/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 bygetLocks()【L81-L83】, this registry stores in-flight promises for sessions currently being created, guaranteeing that parallelstartRpcSessioncalls 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 throughgetRunningListeners()【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:
-
Registry Lookup – Checks
__piSessionsfor an existing alive wrapper:const existing = registry.get(sessionId); if (existing?.isAlive()) return { session: existing, realSessionId: sessionId }; -
Lock Acquisition – Checks
__piStartLocksto prevent duplicate creation:const inflight = locks.get(sessionId); if (inflight) return inflight; -
SDK Initialization – Calls
initTheme()andcreateAgentSessionServices()to configure the Pi SDK environment. -
Session File Management – Opens an existing
.jsonlfile or creates a fresh one usingSessionManager.open()orSessionManager.create(). -
Tool Configuration – When
toolNamesare provided, they are passed to the SDK viatoolsOptionand merged with extension-provided tools:if (toolNames && toolNames.length > 0) { inner.setActiveToolsByName(withExtensionTools(inner, toolNames)); } -
Wrapper Instantiation – Wraps the raw
AgentSessionLikeinAgentSessionWrapper, providing a uniform API for the UI layer. -
Registration and Binding – Stores the wrapper in
__piSessions, registers a destroy callback for cleanup, and invokeswrapper.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 vianotifyRunningChange()【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 invokesdestroy()【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 checkingthis._aliveand flags likepromptRunning,streaming,compacting, orbash【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:
- Collecting all alive session IDs reporting
isRunning(). - Serializing the set and comparing it against the previous snapshot.
- 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:
- Request – Client initiates a session;
startRpcSessionchecks__piSessionsand__piStartLocks. - Creation – New
AgentSessionWrapperinstantiates, registers in__piSessions, and binds extensions. - Activity – UI sends commands via
wrapper.send(); the wrapper manages idle timers, invokes extensions, and forwards SDK events. - Idle Monitoring –
resetIdleTimer()tracks inactivity; after 10 minutes of no operations, automaticdestroy()triggers. - Cleanup –
destroy()removes the wrapper from__piSessions, aborts processes, and broadcasts final state changes vianotifyRunningChange().
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 toglobalThisenable session persistence across Next.js hot reloads. startRpcSessionimplements idempotent session creation through registry checks and promise locks to prevent duplicate initialization.AgentSessionWrappermanages 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.
notifyRunningChangesynchronizes 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:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →