How Session Export Generates Iterative Tree Helpers to Prevent Stack Overflow in pi-web
TLDR: The pi-web session export endpoint patches recursive tree traversal functions in generated HTML with iterative stack-based equivalents, eliminating call-stack overflow when rendering sessions with thousands of nested entries.
Deep session trees pose a significant challenge for browser-based rendering. When users export sessions via GET /api/sessions/[id]/export, the pi-coding-agent package generates an HTML visualization containing recursive helper functions. These functions—sortChildren, mapNodes, and markActive—can overflow the JavaScript call stack on deeply nested sessions with 5000+ entries. The app/api/sessions/[id]/export/route.ts handler solves this by transforming the generated HTML before streaming it to the client, replacing recursive implementations with iterative alternatives that use explicit stacks.
The Stack Overflow Problem in Session Export
The original template.js from pi-coding-agent inlines three recursive helpers into every exported HTML file:
| Helper | Purpose | Recursive Pattern |
|---|---|---|
sortChildren |
Sorts each node's children by timestamp | Calls itself on every child after sorting |
mapNodes |
Builds a Map of node IDs to node objects | Recurses into each child to populate the map |
markActive |
Marks nodes on the active path | Traverses depth-first to propagate active flags |
For a linear session chain of 5000 entries, these functions recurse 5000 levels deep—far exceeding typical browser stack limits (~1000–10,000 frames depending on engine and available memory).
Since the template is bundled in an upstream dependency, the export route cannot modify the source directly. Instead, it applies a runtime HTML patch after generation but before the response streams to the client.
How the Patch Mechanism Works
The patchExportHtml function in route.ts performs four coordinated steps to transform the HTML safely and deterministically.
Normalizing Cross-Platform Line Endings
The source file uses CRLF (\r\n) while the pi-coding-agent template uses LF (\n). A small helper ensures consistent matching:
// Lines 25-28 in route.ts
function n(s: string) {
return s.replace(/\r\n/g, '\n');
}
This normalization prevents pattern-matching failures across different operating systems.
Enforcing Single-Replacement Safety
The replaceRequired utility guarantees each pattern appears exactly once, throwing an explicit error if assumptions fail:
// Lines 30-38 in route.ts
function replaceRequired(html: string, search: string, replace: string) {
const normalizedSearch = n(search);
if (html.split(normalizedSearch).length !== 2) {
throw new Error(`Expected exactly one occurrence of: ${search.slice(0, 50)}...`);
}
return html.replace(normalizedSearch, n(replace));
}
This defensive programming catches upstream template changes that would silently bypass the patch.
Iterative Replacement: sortChildren
The recursive sortChildren function transforms into a depth-first stack loop. Children are pushed in reverse order so they pop in original order, preserving traversal sequence:
// Replaces recursive sortChildren (lines 49-60)
function sortChildren(root) {
const stack = [root];
while (stack.length) {
const node = stack.pop();
node.children.sort((a, b) =>
new Date(a.entry.timestamp).getTime() - new Date(b.entry.timestamp).getTime()
);
for (let i = node.children.length - 1; i >= 0; i--) {
stack.push(node.children[i]);
}
}
}
The O(n) stack space replaces O(depth) call-stack frames, bounded only by available heap memory rather than engine stack limits.
Iterative Replacement: mapNodes
The mapNodes helper builds treeNodeMap without recursion using a reversed initial stack:
// Replaces recursive mapNodes (lines 71-78)
const stack = [...tree].reverse();
while (stack.length) {
const node = stack.pop();
treeNodeMap.set(node.entry.id, node);
for (let i = node.children.length - 1; i >= 0; i--) {
stack.push(node.children[i]);
}
}
Reversing the initial tree array ensures the first element processes first despite LIFO stack behavior.
Iterative Replacement: markActive
The most complex transformation, markActive requires post-order traversal—processing children before parents to correctly propagate active flags upward. A two-stack technique achieves this without recursion:
// Replaces recursive markActive (lines 92-108)
function markActive(root) {
const stack1 = [root]; // Build reverse-order list
const stack2 = [];
// First pass: populate stack2 in reverse-preorder
while (stack1.length) {
const node = stack1.pop();
stack2.push(node);
for (const child of node.children) {
stack1.push(child);
}
}
// Second pass: process from leaves to root
while (stack2.length) {
const node = stack2.pop();
let has = activePathIds.has(node.entry.id);
for (const child of node.children) {
if (containsActive.get(child)) has = true;
}
containsActive.set(node, has);
}
}
Why two stacks? Post-order traversal (children before parents) is inherently stateful. The first stack captures nodes in root-to-leaves order; reversing via stack2 yields leaves-to-root processing. This mirrors the recursive pattern of "process children, then compute parent's value" without the call-stack overhead.
Complete Export Handler Flow
After patching, the route attaches proper HTTP semantics:
// Simplified flow from route.ts
export async function GET(request: Request, { params }: { params: { id: string } }) {
const html = await generateExportHtml(params.id); // From pi-coding-agent
const patchedHtml = patchExportHtml(html); // Apply iterative replacements
return new Response(patchedHtml, {
headers: {
'Content-Type': 'text/html; charset=utf-8',
'Content-Disposition': `attachment; filename="session-${params.id}.html"`,
'Cache-Control': 'private, max-age=300',
},
});
}
The response includes Content-Disposition for downloads, short-term private caching, and correct charset negotiation.
Client-Side Usage
Consuming the endpoint from a browser environment:
// Example client-side call
async function downloadSession(sessionId: string) {
const resp = await fetch(`/api/sessions/${sessionId}/export?inline=0`);
if (!resp.ok) throw new Error('Export failed');
const blob = await resp.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = resp.headers.get('Content-Disposition')
?.match(/filename="([^"]+)"/)?.[1]
?? 'session.html';
a.click();
URL.revokeObjectURL(url);
}
The inline=0 query parameter forces attachment disposition; omitting it allows browser inline rendering for preview modes.
Key Files and Responsibilities
| File | Role |
|---|---|
app/api/sessions/[id]/export/route.ts |
Main export handler; implements patchExportHtml with iterative replacements |
@earendil-works/pi-coding-agent |
Upstream package providing template.js with original recursive helpers |
lib/session-reader.ts |
Resolves absolute session file paths via resolveSessionPath |
lib/agent-client.ts |
Lower-level API client for other session interactions |
Summary
- Recursive helpers in pi-coding-agent's template can overflow on deep sessions (5000+ entries)
- HTML patching at the export boundary transforms three recursive functions into iterative stack-based implementations
sortChildrenuses single-stack depth-first traversal with reversed child pushingmapNodesbuilds node maps with simple iteration over an explicit stackmarkActiverequires two-stack post-order traversal to propagate active flags bottom-upreplaceRequiredenforces exact single-match semantics, preventing silent patch failures on upstream changes
Frequently Asked Questions
What causes stack overflow in session export?
Deeply nested session trees trigger excessive recursion in the original sortChildren, mapNodes, and markActive helpers from pi-coding-agent. Each nested level consumes a call-stack frame; browsers typically limit this to roughly 10,000 frames, which deep linear sessions can exhaust. The HTML patch replaces these with iterative loops using heap-allocated stacks instead.
Why patch the HTML instead of fixing pi-coding-agent directly?
The pi-coding-agent package generates self-contained HTML files designed for standalone use without a server. Patching at the export boundary allows pi-web to address the stack issue without forking or modifying the upstream dependency, preserving compatibility and reducing maintenance burden.
How does the two-stack technique in markActive work?
The first stack performs a preorder traversal (root, then children), pushing nodes onto a second stack as they're visited. Because children are pushed after their parent, they appear above the parent on stack2. Popping from stack2 thus yields children before their parent—exactly the post-order sequence needed to compute active flags from leaves upward.
What happens if the upstream template changes?
The replaceRequired utility validates that each pattern occurs exactly once. If pi-coding-agent updates template.js such that a helper's implementation no longer matches the expected recursive pattern, the export route throws an explicit error rather than producing silently broken HTML. This forces immediate attention and patch updates.
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 →