# Branching and session trees

> Session branching, tree navigation, cancel-during-compact interactions, and name/event constraints for branches.

- Repository: earendil-works/pi
- GitHub: https://github.com/earendil-works/pi
- Human docs: https://grok-wiki.com/public/docs/earendil-works-pi-7860a70e44d1
- Complete Markdown: https://grok-wiki.com/public/docs/earendil-works-pi-7860a70e44d1/llms-full.txt

## Source Files

- `packages/coding-agent/test/agent-session-branching.test.ts`
- `packages/coding-agent/test/agent-session-tree-navigation.test.ts`
- `packages/coding-agent/test/suite/regressions/3688-tree-cancel-compacting.test.ts`
- `packages/coding-agent/test/suite/regressions/3686-session-name-event.test.ts`
- `packages/coding-agent/test/suite/regressions/5996-session-name-newlines.test.ts`
- `packages/coding-agent/src/core/agent-session.ts`

---

---
title: "Branching and session trees"
description: "Session branching, tree navigation, cancel-during-compact interactions, and name/event constraints for branches."
---

`AgentSession.navigateTree()` moves the active leaf inside one JSONL session file; `AgentSessionRuntime.fork()` extracts a path into a **new** session. Sessions are an append-only tree of entries linked by `id` / `parentId`, with one active `leafId`. Interactive mode exposes this as `/tree`, `/fork`, and `/clone`; SDK and RPC use the same core APIs.

## Session tree model

Each session JSONL file is a tree, not a flat transcript:

| Concept | Behavior |
|---------|----------|
| Entry `id` | Stable id on every tree entry (messages, labels, summaries, …) |
| `parentId` | Parent entry, or `null` for a root |
| Leaf | Current position; next appends attach as children of the leaf |
| Active path | Root → leaf via `SessionManager.getBranch()` |
| Abandoned branches | Remain in the file; only the active path builds LLM context |

```text
[user] ── [assistant] ── [user] ── [assistant] ─┬─ [user]          ← current leaf
                                               │
                                               └─ [branch_summary] ── [user]  ← alternate
```

Context for the model is built from the active path. A `branch_summary` entry on that path becomes a `branchSummary` message (`role: "branchSummary"`) so prior branch work can stay available without replaying the abandoned turns.

## `/tree` vs `/fork` vs `/clone`

| Operation | Same file? | Typical input | Result |
|-----------|------------|---------------|--------|
| `/tree` / `navigateTree` | Yes | Any entry id | Move leaf; optional branch summary |
| `/fork` / `runtime.fork(entryId)` | No (new file) | User message entry | New session before that user message; text returned for re-edit |
| `/clone` / `runtime.fork(leafId, { position: "at" })` | No (new file) | Current leaf | New session containing root→leaf path |

Use **tree navigation** to keep alternatives in one file. Use **fork/clone** when you need a separate session (handoff, export isolation, parallel files).

<Note>
Fork and clone are session-replacement operations on `AgentSessionRuntime`. In-place leaf moves are `AgentSession.navigateTree()`. After fork/clone, extension contexts from the previous session are stale; use `withSession` on the replacement.
</Note>

### Fork semantics

`AgentSessionRuntime.fork(entryId, options?)`:

| Option | Default | Meaning |
|--------|---------|---------|
| `position: "before"` | yes | Fork from a **user** message; new leaf is that message’s parent; returns `selectedText` for the editor |
| `position: "at"` | — | Fork at the given entry (used by clone); no selected text |

Persisted forks call `SessionManager.createBranchedSession()` (root→leaf copy into a new JSONL, with `parentSession` set). In-memory / `--no-session` mode still forks without writing a file. `session_before_fork` can cancel. Unsaved sessions that have never been written throw until the first assistant response is persisted.

`getUserMessagesForForking()` returns `{ entryId, text }[]` for every user message entry (fork picker / RPC `get_fork_messages`).

## In-place navigation (`navigateTree`)

```ts
await session.navigateTree(targetId, {
  summarize?: boolean;
  customInstructions?: string;
  replaceInstructions?: boolean;
  label?: string;
});
// → { editorText?, cancelled, aborted?, summaryEntry? }
```

### Preconditions and errors

| Condition | Result |
|-----------|--------|
| `session.isStreaming` | Throws: `Wait for the current response to finish before navigating the session tree.` |
| `targetId === current leaf` | No-op: `{ cancelled: false }` |
| `summarize: true` and no model | Throws: `No model available for summarization` |
| Unknown `targetId` | Throws: `Entry … not found` |

Interactive `/tree` aborts an in-flight agent turn **before** calling `navigateTree` so the streaming guard does not block UI navigation.

### Leaf placement by target type

| Target | New leaf | Editor text |
|--------|----------|-------------|
| User message | Parent of target (`null` if root) | Target user text |
| `custom_message` | Parent of target | Custom message text |
| Assistant / tool / other | Target itself | None |

Selecting the root user message resets the leaf (`resetLeaf()` → empty conversation) and loads the original prompt into the editor so resubmit creates a sibling branch.

### Labels

If `label` is set:

- With a generated summary → label attaches to the new `branch_summary` entry
- Without a summary → label attaches to the navigation target

## Branch summarization

When leaving a path with `summarize: true`, pi summarizes entries from the old leaf back to the common ancestor with the target (via `collectEntriesForBranchSummary`). Compaction entries on that path are included; their summaries become context for the branch summarizer.

Summary attachment uses `SessionManager.branchWithSummary(newLeafId, …)`:

- New `branch_summary` entry’s `parentId` is the **navigation target position** (`newLeafId`), not the abandoned leaf
- New leaf becomes the summary entry
- Optional `details` (default: `{ readFiles, modifiedFiles }`) and `usage` are stored; usage rolls into session token/cost totals
- `fromHook: true` when an extension supplied the summary text

### Defaults and settings

| Setting | Default | Effect |
|---------|---------|--------|
| `branchSummary.reserveTokens` | `16384` | Tokens reserved for summarizer prompt + response |
| `branchSummary.skipPrompt` | `false` | Interactive only: skip “Summarize branch?” and default to **no** summary |
| `treeFilterMode` | `"default"` | Default filter when opening `/tree` |
| `doubleEscapeAction` | `"tree"` | Empty-editor double-escape: `"tree"`, `"fork"`, or `"none"` |

Custom instructions:

- Default: append as `Additional focus: …` on the built-in branch-summary prompt
- `replaceInstructions: true`: replace the default prompt entirely

### Interactive prompt choices

Unless `branchSummary.skipPrompt` is true, `/tree` asks:

1. No summary  
2. Summarize  
3. Summarize with custom prompt  

Escape during the prompt returns to the tree selector. Escape during generation calls `abortBranchSummary()`.

## Cancel and compact-state interactions

Branch summarization shares the **compacting** signal with manual/auto compaction:

```ts
session.isCompacting  // true while _branchSummaryAbortController is set
session.abortBranchSummary()
```

| Outcome | `navigateTree` return | Leaf / entries | `isCompacting` after |
|---------|----------------------|----------------|----------------------|
| Success | `{ cancelled: false, summaryEntry? }` | Moved; optional summary appended | `false` |
| Extension cancel (`session_before_tree` → `{ cancel: true }`) | `{ cancelled: true }` | Unchanged | `false` (controller cleared in `finally`) |
| Abort mid-summary | `{ cancelled: true, aborted: true }` | Unchanged | `false` |

<Warning>
If `session_before_tree` cancels, no leaf move and no summary entry are written. The abort controller is always cleared in a `finally` block so `isCompacting` does not stick true after a cancelled tree navigation (regression covered for cancel-during-compact/summary state).
</Warning>

During summarization, transient provider failures reuse the same retry settings and events as compaction, with `source: "branchSummary"` (no compaction `reason` field).

## Extension hooks

| Event | When | Handler can |
|-------|------|-------------|
| `session_before_tree` | Before move / summary | `{ cancel: true }`; supply `{ summary, details?, usage? }`; override `customInstructions`, `replaceInstructions`, `label` |
| `session_tree` | After successful navigation | Observe `newLeafId`, `oldLeafId`, `summaryEntry`, `fromExtension` |
| `session_before_fork` | Before fork/clone replacement | Cancel fork/clone |
| `session_info_changed` | After name change | Observe normalized `name` |

```ts
pi.on("session_before_tree", (event) => {
  // event.preparation: targetId, oldLeafId, commonAncestorId,
  //   entriesToSummarize, userWantsSummary, customInstructions, …
  // event.signal — abort signal for long-running work
  if (!event.preparation.userWantsSummary) return;
  return { summary: { summary: "Left branch explored approach B." } };
});

pi.on("session_tree", (event) => {
  // event.newLeafId, event.oldLeafId, event.summaryEntry
});
```

Command context also exposes `navigateTree` and `fork` for extension-driven navigation. After `fork` / `newSession` / `switchSession`, do not reuse a captured `pi` from the old session.

## Session display names

Names are metadata for pickers and footers; they do not change the tree structure.

| Surface | Behavior |
|---------|----------|
| CLI | `pi --name "…"`, `-n` |
| Interactive | `/name <name>` |
| API | `session.setSessionName(name)`, `pi.setSessionName(name)` |
| RPC | `set_session_name` with non-empty trimmed `name` |
| Persistence | `session_info` entry on the active leaf path |

### Constraints

| Rule | Detail |
|------|--------|
| Newlines | `appendSessionInfo` replaces `[\r\n]+` with a single space, then `trim()` |
| Clear name | Empty / whitespace-only name after sanitize stores cleared display name (`getSessionName()` → `undefined`) |
| Events | Every `setSessionName` emits `session_info_changed` on the session subscriber stream **and** to extensions, with the **normalized** name |
| RPC empty | `set_session_name` with only whitespace returns an error (`Session name cannot be empty`) without calling `setSessionName` |

```ts
session.setSessionName("hello\nworld\r\nagain");
// stored + event name: "hello world again"
```

## Programmatic surfaces

### SDK

```ts
// In-place tree navigation (same session file)
const nav = await session.navigateTree(entryId, { summarize: true });

// New session file from a user message
const forked = await runtime.fork(userEntryId);
// Clone current branch
const cloned = await runtime.fork(session.sessionManager.getLeafId()!, { position: "at" });

// Tree inspection
const tree = session.sessionManager.getTree();
const leafId = session.sessionManager.getLeafId();
session.sessionManager.branch(entryId);
session.sessionManager.branchWithSummary(entryId, "Summary…");
```

### RPC

| Command | Role |
|---------|------|
| `get_tree` | Full tree + `leafId` |
| `get_entries` | Append-order entries (incl. abandoned branches); optional `since` cursor |
| `get_fork_messages` | User messages for fork UI |
| `fork` | Fork from user entry |
| `clone` | Fork at current leaf |
| `set_session_name` | Set display name |

There is no dedicated RPC `navigate_tree` command; embedders use the SDK `AgentSession.navigateTree` path or drive tree moves through the session manager when building custom UIs.

### SessionManager branching primitives

| Method | Effect |
|--------|--------|
| `branch(id)` | Set leaf to existing entry; next append creates a new child |
| `resetLeaf()` | Leaf `null` (next append is a new root) |
| `branchWithSummary(id \| null, summary, …)` | Set leaf, append `branch_summary` child |
| `createBranchedSession(leafId)` | Write new session file for root→leaf path |
| `getTree()` / `getChildren(id)` / `getBranch(id?)` | Read structure |

## Troubleshooting

| Symptom | Likely cause | What to check |
|---------|--------------|---------------|
| Navigate throws while agent is streaming | Direct `navigateTree` without abort | Wait for idle or abort; interactive `/tree` aborts first |
| `isCompacting` stuck after cancel | Pre-fix cancel path | Confirm controller cleared; cancel should return `{ cancelled: true }` and `isCompacting === false` |
| Escape mid-summary returns to tree | Expected abort | `aborted: true`; no new entries; leaf unchanged |
| Session name shows newlines / breaks UI | Unsanitized write outside API | Always use `setSessionName` / `appendSessionInfo` |
| Fork fails “session has not been saved yet” | Empty on-disk file before first assistant write | Wait for first persisted assistant response |
| Extension lost after fork | Stale context | Use `withSession` on fork/new/switch |

## Related pages

<CardGroup>
  <Card title="Agent sessions" href="/agent-sessions">
    Session lifecycle, prompt queue, and runtime ownership of a turn.
  </Card>
  <Card title="Context compaction" href="/compaction">
    Auto/manual compaction, shared retry events, and summarization settings.
  </Card>
  <Card title="Session runtime" href="/session-runtime">
    AgentSessionRuntime services, fork/newSession replacement, and embedding without the TUI.
  </Card>
  <Card title="RPC mode" href="/rpc-mode">
    Process integration: get_tree, fork, clone, set_session_name.
  </Card>
  <Card title="SDK" href="/sdk">
    Embed navigateTree, fork, and SessionManager tree APIs.
  </Card>
  <Card title="Extensions" href="/extensions">
    session_before_tree, session_tree, and session_info_changed handlers.
  </Card>
  <Card title="Settings" href="/settings">
    branchSummary.*, treeFilterMode, doubleEscapeAction.
  </Card>
</CardGroup>
