How the Fork Operation Handles Wrapper Destruction to Prevent Corrupt Parent-Session Chains

The fork operation immediately destroys the AgentSessionWrapper via shutdown() after creating the new session, forcing a fresh wrapper instantiation for the original session and preserving its parent-metadata integrity.

When a user forks a session in Pi Web (agegr/pi-web), the system must spawn a new branch while leaving the original session completely untouched. The critical safeguard preventing chain corruption lives in lib/rpc-manager.ts, where wrapper destruction is orchestrated right at the fork point.

Fork Command Handling in lib/rpc-manager.ts

The AgentSessionWrapper class processes the "fork" command starting around line 43. The method performs three sequential phases: validation, session creation, and wrapper teardown.

Validation and Entry Lookup

Before any file operations, the wrapper verifies prerequisites:

// Lines 44-56 in lib/rpc-manager.ts
if (this.isBashRunning) {
  return { cancelled: true, reason: "Cannot fork while bash is running" };
}
if (!this.isPersisted) {
  return { cancelled: true, reason: "Session must be persisted to fork" };
}
const entry = this.getEntry(command.entryId);
if (!entry) {
  return { cancelled: true, reason: "Entry not found" };
}

These guards ensure the session state is stable enough to duplicate.

Creating the Forked Session

The branch creation logic distinguishes between root entries and nested ones:

  • Root entry (!entry.parentId): Creates a blank session with parentSession pointing to the current file
  • Nested entry: Copies history from the original .jsonl file up to (but not including) the fork point
// Session file creation and ID retrieval
const newSessionId = SessionManager.open(newSessionFile, sessionDir).getSessionId();
cacheSessionPath(newSessionId, newSessionFile);

The SessionManager.open() call returns a session instance whose ID differs from the original—this distinction becomes crucial in the next step.

The shutdown() Destruction Sequence

Immediately after obtaining newSessionId, the wrapper invokes self-destruction:

case "fork": {
  // ... session creation logic ...
  const newSessionId = SessionManager.open(newSessionFile, sessionDir).getSessionId();
  cacheSessionPath(newSessionId, newSessionFile);
  
  await this.shutdown(); // Line ~75
  return { cancelled: false, newSessionId };
}

The shutdown() method (defined later in lib/rpc-manager.ts) performs comprehensive cleanup:

Cleanup Action Purpose
Clears all listeners Prevents event handlers from firing on a stale wrapper
Cancels pending UI responses Aborts any in-flight ask() or customUI() calls
Resets custom UI state and extension widgets Removes visual components tied to this session instance
Cancels idle timer Stops background timeout logic
Removes from globalThis.__piSessions Deletes the global registry entry keyed by the old sessionId
Sets _alive = false Blocks any subsequent command routing via guard checks

Why Wrapper Destruction Prevents Chain Corruption

The destruction step is not merely cleanup—it is architecturally mandatory for parent-session chain integrity. Here's the mechanism:

The AgentSession instance stored inside AgentSessionWrapper mutates its sessionId property during fork. The inner.sessionId field transitions from the original ID to newSessionId to reflect the branched context.

If the wrapper remained registered in globalThis.__piSessions under the original key:

  1. A later UI request for the original session would retrieve the same wrapper instance
  2. That wrapper now reports newSessionId internally, misleading the session manager
  3. The .jsonl file's actual parentSession metadata would be ignored or overwritten
  4. Result: A corrupt chain where the original session appears to have been replaced by its fork

By calling shutdown() and purging the registry entry, the next request for the original session forces SessionManager to instantiate a fresh AgentSessionWrapper from the untouched original .jsonl file. This new wrapper correctly loads and preserves the original parentSession field.

Visualizing the Safe Fork Flow


User clicks Fork
       ↓
AgentSessionWrapper receives "fork" command
       ↓
Create new .jsonl file with copied/blank history
       ↓
Obtain newSessionId via SessionManager.open()
       ↓
await this.shutdown()
   ├─ Clear listeners, timers, UI state
   ├─ Delete from globalThis.__piSessions
   └─ Mark _alive = false
       ↓
Return { cancelled: false, newSessionId }
       ↓
UI loads new session; original session untouched
Next request for original → fresh wrapper → correct parent chain

Client-Side Fork Invocation

The UI layer triggers this flow through the RPC interface. In hooks/useAgentSession.ts:

// Initiating fork from the React hook
const result = await agentSession.send({ 
  type: "fork", 
  entryId: selectedEntryId 
});

if (!result.cancelled && result.newSessionId) {
  // Navigate to or load the new branched session
  window.location.href = `/session/${result.newSessionId}`;
}

The send() method serializes the command to the RPC manager, which executes the fork-and-destroy sequence atomically.

Summary

  • Validation gates in lib/rpc-manager.ts (lines 44‑56) ensure safe fork preconditions
  • shutdown() immediately after session creation removes the wrapper from globalThis.__piSessions and invalidates internal state
  • Fresh wrapper instantiation on next original-session access preserves correct parentSession metadata from the .jsonl file
  • _alive flag and registry deletion prevent command routing to the mutated, stale instance

Frequently Asked Questions

What happens if shutdown() is not called after forking?

Without shutdown(), the wrapper remains in globalThis.__piSessions indexed by the old session ID. Since the internal AgentSession mutated to the new ID, subsequent lookups for the original session would return a wrapper reporting the wrong identity. This corrupts the parent-session chain and can cause data loss or UI inconsistencies.

Where is the parentSession metadata actually stored?

The parentSession field lives in the .jsonl session file itself, not in memory. When SessionManager opens a session file, it reads this metadata to construct the sidebar hierarchy. Fresh wrapper instantiation guarantees this file-level metadata is respected.

Can a fork operation be cancelled mid-process?

Yes. The validation phase (lines 44‑56) returns { cancelled: true, reason: "..." } for blocked conditions like running bash commands or unpersisted sessions. Once past validation and into file creation, the operation commits—there is no rollback, but the original file is never modified, so atomicity is preserved through copy-on-write semantics.

How does the UI distinguish forked sessions from root sessions in the sidebar?

The parentSession field (display-only) determines visual nesting. Root sessions have no parentSession; forked sessions point to their origin file. The sidebar renderer in the Pi Web frontend uses this field to indent and link entries, independent of the runtime sessionId.

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 →