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
parentSessionheader 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.sessionIdduring 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 preventsparentSessionchain 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:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →