# AgentSession Lifecycle Management in rpc-manager.ts: Complete Technical Guide

> Master AgentSession lifecycle management in rpc-manager.ts with this technical guide. Learn about global registries, idle timeouts, and prompt admission chains for efficient session handling.

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

---

**AgentSession lifecycle management in [`rpc-manager.ts`](https://github.com/agegr/pi-web/blob/main/rpc-manager.ts) uses a global registry with idle timeouts, a prompt admission chain, and cached shutdown promises to ensure exactly one wrapper exists per session ID while automatically cleaning up inactive sessions.**

The Pi Web codebase in `agegr/pi-web` implements a robust session management layer that wraps the low-level SDK `AgentSessionLike` interface. Understanding this architecture is essential for anyone building extensions, debugging session state issues, or modifying the RPC layer.

## Global Registry and Session Persistence

The foundation of AgentSession lifecycle management is a **global registry** that survives Next.js hot-reloads at lines 71-86 of [`lib/rpc-manager.ts`](https://github.com/agegr/pi-web/blob/main/lib/rpc-manager.ts).

```typescript
// Registry stored on globalThis to persist across hot-reloads
const getRegistry = (): Map<string, AgentSessionWrapper> => {
  const g = globalThis as any;
  if (!g.__piSessions) {
    g.__piSessions = new Map<string, AgentSessionWrapper>();
  }
  return g.__piSessions;
};

```

This `Map<string, AgentSessionWrapper>` guarantees exactly one wrapper instance per session ID. When multiple HTTP requests attempt to create the same session, a separate `__piStartLocks` mechanism (lines 88-93) prevents race conditions by caching the start promise per session ID.

## Session Creation and Initialization

The `startRpcSession()` function orchestrates new session creation through four phases:

1. **Normalization and lock check** — Validates `cwd` and checks `__piStartLocks` for existing start promises
2. **SDK session creation** — Calls `createAgentSessionFromServices()` to obtain an `AgentSessionLike`
3. **Wrapper instantiation** — Wraps the SDK session in `AgentSessionWrapper` and registers it
4. **Event subscription** — Invokes `wrapper.start()` to begin streaming

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

async function createSession(cwd: string, tools?: string[]) {
  const { session } = await startRpcSession({
    cwd,
    toolNames: tools,
  });
  return session.sessionId;
}

```

The wrapper's `start()` method at lines 216-227 subscribes to SDK events, resets the idle timer, and notifies UI listeners that the session is running.

## Prompt Admission and Concurrency Control

Concurrent prompts are serialized through a **prompt admission chain** (`promptAdmissionTail`) at lines 45-53. This chain ensures only one prompt can start a new run at a time, preventing race conditions in multi-request scenarios.

## Idle Timeout and Automatic Shutdown

The `AgentSessionWrapper` implements automatic cleanup through a 10-minute idle timer:

```typescript
// From resetIdleTimer implementation (lines 181-187)
this.idleTimer = setTimeout(() => {
  if (this.isRunning()) {
    this.resetIdleTimer();  // Still busy — restart timer
    return;
  }
  // No activity detected — trigger graceful shutdown
  void this.shutdown().catch(err => 
    console.error("[pi-web] failed to shut down idle session:", err)
  );
}, 10 * 60 * 1000);  // 10 minutes

```

The timer resets after every event in `IDLE_RESET_EVENT_TYPES`. If `isRunning()` returns false—meaning no prompts, streaming, compaction, or Bash jobs are active—the session shuts down automatically.

## Graceful Shutdown Sequence

The `shutdown()` method at lines 797-818 implements a **cached promise pattern** to prevent duplicate shutdown attempts:

```typescript
async shutdown(): Promise<void> {
  if (this.shutdownPromise) return this.shutdownPromise;
  
  this.shutdownPromise = (async () => {
    await this.pendingExtensionBinding;           // Wait for binding completion
    this.emitToExtensions({ type: "session_shutdown" });
    await this.destroy();                          // Final cleanup
  })();
  
  return this.shutdownPromise;
}

```

This caching ensures idempotent shutdown calls across multiple concurrent triggers.

## Destroy and Resource Cleanup

The `destroy()` method at lines 775-795 executes comprehensive cleanup in strict order:

- Clears all timers (idle and scheduled)
- Aborts any running Bash command via `this.bashAbortController`
- Unsubscribes from SDK event streams
- Resolves or cancels pending UI promises
- Clears extension widget state
- Disposes the inner SDK session
- Notifies running-state listeners

## Fork, Navigation, and Session Discovery

Two specialized operations extend the core lifecycle:

**Fork operation** (lines 548-582): Creates a new `.jsonl` file, caches its path, invalidates the session list cache, and shuts down the current wrapper. This enables branching session history without losing state.

**Tree navigation**: Forwards `navigate_tree` requests directly to the SDK.

**Session discovery**: `getRpcSessionInfos()` at lines 444-452 walks the registry to build the UI sidebar's session list, extracting metadata from each wrapper's `SessionManager`.

## Extension Binding Integration

Before a session can process prompts, `ensureExtensionsBound()` (called from `beginExtensionBinding` at lines 44-52) binds UI context and command-action callbacks to SDK extensions. The wrapper exposes `waitUntilReady()` for callers to await this binding completion:

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

async function sendPrompt(sessionId: string, text: string) {
  const wrapper = getRpcSession(sessionId);
  await wrapper.waitUntilReady();  // Ensure extensions bound
  await wrapper.send({ type: "prompt", message: text });
}

```

## Complete Fork Example

```typescript
async function forkFromEntry(sessionId: string, entryId: string) {
  const wrapper = getRpcSession(sessionId);
  const result = await wrapper?.send({
    type: "fork",
    entryId,
  });
  // Returns { cancelled: false, newSessionId } on success
  return result?.newSessionId;
}

```

## Related Files in the Session Architecture

| File | Responsibility | Key Integration Point |
|------|---------------|----------------------|
| [`lib/session-reader.ts`](https://github.com/agegr/pi-web/blob/main/lib/session-reader.ts) | `.jsonl` file I/O for persistence | Used by wrapper for fork operations |
| [`lib/startup-preferences.ts`](https://github.com/agegr/pi-web/blob/main/lib/startup-preferences.ts) | User preference persistence | Affects `startRpcSession()` parameters |
| `app/api/agent/[id]/route.ts` | HTTP-to-wrapper request routing | Calls `send()`, `shutdown()` methods |
| [`lib/model-scope.ts`](https://github.com/agegr/pi-web/blob/main/lib/model-scope.ts) | Model visibility filtering | Invoked during session initialization |

## Summary

- **Global registry** on `globalThis.__piSessions` ensures single-wrapper-per-session semantics across hot-reloads
- **Start locks** (`__piStartLocks`) prevent duplicate session creation races
- **Prompt admission chain** serializes concurrent prompt execution
- **10-minute idle timer** triggers automatic shutdown when no running operations detected
- **Cached `shutdownPromise`** makes shutdown idempotent across multiple callers
- **Ordered `destroy()` cleanup** releases all resources: timers, subprocesses, subscriptions, promises, and widgets
- **Fork operation** branches session history while invalidating related caches

## Frequently Asked Questions

### How does [`rpc-manager.ts`](https://github.com/agegr/pi-web/blob/main/rpc-manager.ts) prevent duplicate sessions with the same ID?

The file uses two mechanisms: `__piStartLocks` caches the start promise during session creation (lines 88-93), and the `globalThis.__piSessions` registry stores only one wrapper per ID (lines 71-86). Multiple concurrent requests for the same session ID receive the same promise from the lock, and subsequent lookups return the existing wrapper from the registry.

### What triggers automatic session shutdown?

An idle timer resets after every event in `IDLE_RESET_EVENT_TYPES`. If 10 minutes pass without activity and `isRunning()` returns false—meaning no prompts, streaming, compaction, or Bash operations—the timer callback invokes `shutdown()`. The session also shuts down explicitly on fork operations or when the API receives a shutdown request.

### Why is the session registry stored on `globalThis`?

Next.js hot-reloads recreate module scope but preserve `globalThis`. Storing the registry there ensures session wrappers survive code changes during development, maintaining client connections and state across iterative saves. Without this, every file edit would orphan active sessions and lose in-progress work.

### What happens if `shutdown()` is called multiple times?

The method caches its promise in `this.shutdownPromise` (lines 797-818). Subsequent calls immediately return the cached promise, ensuring cleanup runs exactly once. This pattern prevents double-disposal of the underlying SDK session and avoids race conditions between idle timeout, explicit API calls, and fork-triggered shutdown.