AgentSession Lifecycle Management in rpc-manager.ts: Complete Technical Guide
AgentSession lifecycle management in 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.
// 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:
- Normalization and lock check — Validates
cwdand checks__piStartLocksfor existing start promises - SDK session creation — Calls
createAgentSessionFromServices()to obtain anAgentSessionLike - Wrapper instantiation — Wraps the SDK session in
AgentSessionWrapperand registers it - Event subscription — Invokes
wrapper.start()to begin streaming
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:
// 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:
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:
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
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 |
.jsonl file I/O for persistence |
Used by wrapper for fork operations |
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 |
Model visibility filtering | Invoked during session initialization |
Summary
- Global registry on
globalThis.__piSessionsensures 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
shutdownPromisemakes 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 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.
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 →