# Why the AgentSession Wrapper Must Be Destroyed Immediately After Fork

> Learn why the AgentSession wrapper must be destroyed immediately after fork. Avoid corrupting parentSession chains and ensure correct request handling by understanding this critical step.

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

---

**The wrapper must be destroyed immediately after fork because the inner session mutates in-place: its `sessionId` is replaced by the new session's id, so keeping the original wrapper causes subsequent requests to retrieve already-forked state and corrupts the `parentSession` chain.**

The `AgentSession` class in the `agegr/pi-web` repository manages AI agent conversations through a remote procedure call (RPC) interface. When users branch a conversation via the **fork** operation, the session handling code must carefully manage object lifecycles to prevent state contamination across sessions.

## How Fork Mutates Session State In-Place

In [`lib/rpc-manager.ts`](https://github.com/agegr/pi-web/blob/main/lib/rpc-manager.ts), the `AgentSession` wrapper maintains a reference to an **inner** session object that holds the actual conversation state. When a fork occurs, this inner object is modified directly rather than being copied:

```typescript
// lib/rpc-manager.ts — fork command handler
case "fork": {
  // ... validation and entry lookup ...
  const newSessionFile = /* create branched session file */;
  const newSessionId = SessionManager.open(newSessionFile, sessionDir).getSessionId();
  
  // The CRITICAL mutation: inner.sessionId becomes the NEW session's id
  this.inner.sessionId = newSessionId;
  
  cacheSessionPath(newSessionId, newSessionFile);
  invalidateSessionListCache();
  
  // Destroy wrapper to prevent stale reference under old sessionId
  await this.shutdown();  // → this.destroy()
  return { cancelled: false, newSessionId };
}

```

As documented in [`AGENTS.md`](https://github.com/agegr/pi-web/blob/main/AGENTS.md), this design means **"after fork, `inner.sessionId` is the *new* session's id."** The wrapper object, if preserved, would still be registered in `globalThis.__piSessions` under the **original** session ID. Any future request targeting that original ID would retrieve a wrapper whose inner state actually belongs to the forked session.

## The Two Failure Modes If Wrapper Persists

### Corrupted parentSession Chain

The `parentSession` header tracks navigation history and conversation branching. When an unclean wrapper survives:

- The original session's `parentSession` header is overwritten by the forked session's metadata
- Subsequent forks from the original session point to the wrong parent
- History rendering fails to display the correct conversation tree

### Stale State Leakage

The wrapper retains references to mutable session state:

- **Active model configuration** — wrong model appears selected in UI
- **Queued message buffers** — commands send to incorrect session file
- **Streaming flags** — status indicators show wrong conversation activity

## The Destroy Mechanism

The `shutdown()` method in [`lib/rpc-manager.ts`](https://github.com/agegr/pi-web/blob/main/lib/rpc-manager.ts) orchestrates safe cleanup:

```typescript
// lib/rpc-manager.ts — shutdown and destroy implementation
async shutdown(): Promise<void> {
  if (this.shutdownPromise) return this.shutdownPromise;
  
  this.shutdownPromise = (async () => {
    // Clear any pending operations
    this.messageQueue.clear();
    
    // Remove from global registry — CRITICAL step
    this.destroy();
    
    // Notify extensions
    await this.inner.extensionRunner.emit?.({
      type: "session_shutdown",
      reason: "quit",
    });
    
    // Final inner cleanup
    await this.inner.cleanup?.();
  })();
  
  return this.shutdownPromise;
}

destroy(): void {
  // Removes this wrapper from globalThis.__piSessions
  delete globalThis.__piSessions[this.sessionId];
  this.destroyed = true;
}

```

The `destroy()` call is **idempotent and synchronous**, ensuring immediate removal from the global session registry before any asynchronous operations complete.

## Frontend Integration

The UI layer in [`hooks/useAgentSession.ts`](https://github.com/agegr/pi-web/blob/main/hooks/useAgentSession.ts) initiates forks without managing wrapper lifecycle directly:

```typescript
// hooks/useAgentSession.ts — fork initiation
const forkSession = async (entryId: string) => {
  setForkingEntryId(entryId);
  await rpcSession.fork({ entryId });
  // After successful RPC, server has destroyed wrapper
  // Client refreshes session list to see clean state
  await refreshSessions();
};

```

The frontend trusts the RPC layer to handle wrapper destruction, then re-fetches session state to synchronize with the now-clean registry.

## Key Files and Their Roles

| File | Purpose |
|------|---------|
| [`lib/rpc-manager.ts`](https://github.com/agegr/pi-web/blob/main/lib/rpc-manager.ts) | Core RPC handler; implements fork logic and wrapper destruction |
| [`AGENTS.md`](https://github.com/agegr/pi-web/blob/main/AGENTS.md) | Design documentation explaining the fork-then-destroy requirement |
| [`hooks/useAgentSession.ts`](https://github.com/agegr/pi-web/blob/main/hooks/useAgentSession.ts) | Frontend hook triggering fork operations |
| [`lib/session-reader.ts`](https://github.com/agegr/pi-web/blob/main/lib/session-reader.ts) | Session file parsing used during forked session creation |

## Summary

- **In-place mutation** of `inner.sessionId` during fork makes wrapper destruction mandatory
- **Registry corruption** occurs if the old session ID maps to a wrapper with new-session state
- **Immediate `destroy()` call** in the fork handler prevents `parentSession` chain breakage and state leakage
- **Global session map** (`globalThis.__piSessions`) must stay synchronized with actual session files

## Frequently Asked Questions

### What happens if destroy() is not called after fork?

The original session ID remains mapped to a wrapper whose `inner.sessionId` now points to the forked session. Subsequent requests for the original session receive the wrong session object, causing incorrect `parentSession` headers and potential data writes to the wrong conversation file.

### Why mutate inner.sessionId instead of creating a new wrapper?

The design optimizes for memory efficiency and session continuity. The inner session object carries substantial state (message history, tool call results, extension contexts). Mutating in-place avoids expensive deep copies while the explicit `destroy()` call ensures proper cleanup of the registry mapping.

### Can the wrapper be reused for the new session after fork?

No. The wrapper is registered under the **original** session ID in `globalThis.__piSessions`. Even if re-registered under the new ID, this would leave a stale entry under the old ID. Fresh wrapper creation via `SessionManager.open()` ensures clean registry state.

### Where is the global session registry defined?

The `globalThis.__piSessions` object serves as the runtime session registry. It is initialized in [`lib/rpc-manager.ts`](https://github.com/agegr/pi-web/blob/main/lib/rpc-manager.ts) and maps session IDs to `AgentSession` wrapper instances. The `destroy()` method removes entries from this map to prevent stale references.