Why the AgentSession Wrapper Must Be Destroyed Immediately After Fork

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, 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:

// 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, 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 orchestrates safe cleanup:

// 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 initiates forks without managing wrapper lifecycle directly:

// 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 Core RPC handler; implements fork logic and wrapper destruction
AGENTS.md Design documentation explaining the fork-then-destroy requirement
hooks/useAgentSession.ts Frontend hook triggering fork operations
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 and maps session IDs to AgentSession wrapper instances. The destroy() method removes entries from this map to prevent stale references.

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:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →