# How pi-web's AgentSession Lifecycle Works with the In-Process Model

> Understand the pi-web AgentSession lifecycle in the in-process model. Learn how pi-web manages sessions using a global registry and locking for efficient operation without external processes.

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

---

**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`](https://github.com/agegr/pi-web/blob/main/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`](https://github.com/agegr/pi-web/blob/main/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`](https://github.com/agegr/pi-web/blob/main/lib/rpc-manager.ts):

```typescript
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.__piStartLocks` contains an entry for the `sessionId`, the function awaits that pending promise (line 67).
- **SessionManager Initialization**: For new sessions, it opens or creates a **`SessionManager`** for the specified working directory (lines 70-76).
- **Busy-CWD Protection**: A `trackStartingSession` counter 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`](https://github.com/agegr/pi-web/blob/main/app/api/agent/new/route.ts) and `app/api/agent/[id]/route.ts` orchestrates the primitives defined in [`rpc-manager.ts`](https://github.com/agegr/pi-web/blob/main/rpc-manager.ts):

- **Creating Sessions**: `POST /api/agent/new` generates a temporary unique key, calls `startRpcSession` with 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 via `getRpcSession`. If found, it forwards the command using `session.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 invoking `session.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 `__piStartLocks` map 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.ts`](https://github.com/agegr/pi-web/blob/main/lib/rpc-manager.ts)** maintains `globalThis.__piSessions` and `globalThis.__piStartLocks` to track active wrappers and prevent duplicate initializations.
- **`startRpcSession`** handles existence checks, locking, and `AgentSessionWrapper` instantiation, returning the real SDK-generated UUID to clients.
- **`AgentSessionWrapper`** manages 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`](https://github.com/agegr/pi-web/blob/main/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.