How pi-web's AgentSession Lifecycle Works with the In-Process Model
Pi-web runs the Pi Agent inside the same Node.js process as the web UI, using a global registry and locking mechanism in lib/rpc-manager.ts to manage session creation, idle shutdown, and command routing without external processes.
The agegr/pi-web repository implements a unique in-process architecture where Pi Agent sessions execute directly within the Node.js runtime serving the web interface. This design eliminates inter-process communication overhead by wrapping the SDK's AgentSession in a custom AgentSessionWrapper registered in global memory. Understanding the pi-web AgentSession lifecycle reveals how the system handles concurrent starts, idle timeouts, and graceful shutdowns while maintaining thread-safe access to shared resources.
Global Session Registry and Startup Locking
The lifecycle begins with two global data structures defined in lib/rpc-manager.ts. The globalThis.__piSessions Map stores an AgentSessionWrapper instance for every live session, keyed by the real session ID. The getRegistry() function initializes this map and registers a cleanup hook to prevent memory leaks during development hot-reloads (lines 66-74).
To prevent race conditions when multiple HTTP requests target the same temporary session key, the system uses globalThis.__piStartLocks. This Map stores pending promises for each key currently being initialized. The getLocks() accessor and the check within startRpcSession ensure that concurrent requests for the same logical session wait on a single initialization promise rather than spawning duplicate SDK instances (lines 78-84).
Session Creation via startRpcSession
When a client POSTs to /api/agent/new, the route handler invokes startRpcSession exported from lib/rpc-manager.ts:
export async function startRpcSession(
sessionId: string,
sessionFile: string,
cwd: string | undefined,
options: RpcSessionStartOptions = {}
): Promise<{ session: AgentSessionWrapper; realSessionId: string }> { … }
The function implements several critical lifecycle controls:
- Existence Check: If the session already exists in the registry and
isAlive()returns true, the existing wrapper is returned immediately (line 65). - Lock Acquisition: If
globalThis.__piStartLockscontains an entry for thesessionId, the function awaits that pending promise (line 67). - SessionManager Initialization: For new sessions, it opens or creates a
SessionManagerfor the specified working directory (lines 70-76). - Busy-CWD Protection: A
trackStartingSessioncounter protects the global busy-directory query while the session initializes, preventing conflicting operations during startup (lines 78-84).
Once the underlying Pi SDK session is ready, the code constructs an AgentSessionWrapper around the SDK's AgentSessionLike object. Calling wrapper.start() subscribes to underlying SDK events, initializes the 10-minute idle-shutdown timer, and notifies global listeners of the running-state change (lines 111-124). The wrapper is then stored in globalThis.__piSessions under the real session ID (a UUID generated by the SDK), and the temporary key is removed from the lock map.
AgentSessionWrapper Runtime Facade
The AgentSessionWrapper class serves as the runtime façade that translates between the Pi SDK's raw events and the web UI's expectations. It manages several concurrent concerns:
Event Publishing and Subscription
The wrapper maintains an internal array of listeners added via onEvent(). When the underlying SDK emits an event, the wrapper's emit() method loops through all registered listeners, allowing multiple UI components to observe session activity simultaneously.
Idle Shutdown Management
To conserve resources, the wrapper implements automatic cleanup via resetIdleTimer(). This method creates a 10-minute timer that triggers shutdown() when the session remains inactive (lines 150-160). The timer resets on every incoming command, ensuring active sessions persist while idle ones terminate gracefully.
Prompt Admission Control
The acquirePromptAdmission() method (lines 140-148) ensures that prompts are serialized through the session. This prevents race conditions where multiple user inputs could interleave unpredictably during asynchronous operations.
Extension UI Context
When tools request user interface elements, createExtensionUiContext() builds helper functions (select, confirm, notify, etc.) that translate into extension_ui_request events sent to the web frontend (lines 195-258). This allows the in-process agent to render interactive dialogs without breaking the single-process architecture.
Fork and Navigation Handling
The wrapper's send() method implements special handling for fork and navigate_tree commands. When processing a fork (lines 143-176), the wrapper delegates to the SDK's fork method, creates the new session file, and then explicitly calls shutdown() to clean up the parent wrapper. For navigate_tree commands (lines 180-186), it delegates to SDK methods without terminating the session.
Shutdown and Destroy Sequence
The shutdown() method (lines 210-235) implements graceful termination by waiting for active extensions to finish, emitting a session_shutdown event to notify listeners, and then invoking destroy(). The destroy() method disposes the underlying SDK session, clears all timers and UI state, and removes the wrapper from globalThis.__piSessions, completing the lifecycle.
HTTP API Entry Points
The HTTP layer in app/api/agent/new/route.ts and app/api/agent/[id]/route.ts orchestrates the primitives defined in rpc-manager.ts:
- Creating Sessions:
POST /api/agent/newgenerates a temporary unique key, callsstartRpcSessionwith an empty file string for new sessions, and returns the real session ID to the client. It also adds the working directory to the allowed-roots cache (lines 47-63). - Command Routing:
POST /api/agent/[id]attempts to locate an existing wrapper viagetRpcSession. If found, it forwards the command usingsession.send(body). If the session does not exist, it resolves the JSON-L file path from the ID and creates the wrapper on-demand before forwarding (lines 18-38). - State Queries:
GET /api/agent/[id]returns the current session state by invokingsession.send({type:"get_state"})(lines 58-66).
Running-State Broadcasting
A global Set named __piRunningListeners maintains callbacks interested in session activity changes. The notifyRunningChange() function computes the current set of running session IDs using getRunningRpcSessionIds() and emits a snapshot to all registered listeners (lines 126-144). The UI sidebar subscribes via subscribeRunningSessions() to receive live updates without polling, enabling real-time session status indicators across the interface.
Cleanup and Concurrency Guarantees
The in-process model relies on three mechanisms to ensure system stability:
- Idle Timeout: Automatic shutdown after 10 minutes of inactivity prevents resource exhaustion from abandoned sessions.
- Fork Shutdown: Explicit
shutdown()calls after fork operations ensure parent wrappers are removed from the registry, preventing ghost sessions. - Concurrent Start Protection: The
__piStartLocksmap guarantees that only one SDK instance initializes per logical session key, eliminating race conditions during rapid successive requests.
Summary
- Pi-web executes Pi Agent sessions inside the Node.js process serving the web UI, eliminating IPC overhead.
lib/rpc-manager.tsmaintainsglobalThis.__piSessionsandglobalThis.__piStartLocksto track active wrappers and prevent duplicate initializations.startRpcSessionhandles existence checks, locking, andAgentSessionWrapperinstantiation, returning the real SDK-generated UUID to clients.AgentSessionWrappermanages event broadcasting, 10-minute idle timeouts, prompt serialization, extension UI contexts, and graceful shutdown sequences.- HTTP routes in
app/api/agent/provide RESTful access to these primitives, creating sessions on-demand and routing commands to the in-process wrappers.
Frequently Asked Questions
What happens if two requests try to start the same session simultaneously?
The globalThis.__piStartLocks Map in lib/rpc-manager.ts stores a pending promise for each session key currently being initialized. Subsequent requests awaiting the same key automatically wait on that promise rather than spawning duplicate SDK instances, ensuring only one AgentSessionWrapper exists per logical session.
How does pi-web handle idle sessions?
Each AgentSessionWrapper initializes a 10-minute timer via resetIdleTimer() when the session starts. This timer resets on every incoming command. If no activity occurs within the timeout window, the wrapper automatically calls shutdown(), which emits termination events and removes the session from the global registry to free memory.
What is the difference between shutdown() and destroy() in AgentSessionWrapper?
shutdown() is the graceful termination method that waits for extensions to finish and emits session_shutdown events to notify listeners. destroy() performs the actual resource cleanup, disposing the underlying SDK session, clearing timers and UI state, and removing the wrapper from globalThis.__piSessions. The lifecycle typically calls shutdown() first, which then invokes destroy().
Can I interact with a session programmatically without using the HTTP API?
Yes. You can import startRpcSession and getRpcSession directly from @/lib/rpc-manager to create or retrieve AgentSessionWrapper instances within the same Node.js process. This allows server-side code to send commands via wrapper.send() and subscribe to events via wrapper.onEvent() without making HTTP requests.
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 →