- Command handler now returns Promise<void> instead of Promise<string | undefined> - To trigger LLM response, use sendMessage() with triggerTurn: true - Simplify _tryExecuteHookCommand to return boolean Added example hook and slash command in .pi/: - .pi/hooks/test-command.ts - /greet command using sendMessage - .pi/commands/review.md - file-based /review command
9.7 KiB
Session Tree Implementation Plan
Reference: session-tree.md
Phase 1: SessionManager Core ✅
- Update entry types with
id,parentIdfields (using SessionEntryBase) - Add
versionfield toSessionHeader - Change
CompactionEntry.firstKeptEntryIndex→firstKeptEntryId - Add
BranchSummaryEntrytype - Add
CustomEntrytype for hooks - Add
byId: Map<string, SessionEntry>index - Add
leafId: stringtracking - Implement
getPath(fromId?)tree traversal - Implement
getTree()returningSessionTreeNode[] - Implement
getEntry(id)lookup - Implement
getLeafUuid()andgetLeafEntry()helpers - Update
_buildIndex()to populatebyIdmap - Rename
saveXXX()toappendXXX()(returns id, advances leaf) - Add
appendCustomEntry(customType, data)for hooks - Update
buildSessionContext()to usegetPath()traversal
Phase 2: Migration ✅
- Add
CURRENT_SESSION_VERSION = 2constant - Implement
migrateV1ToV2()with extensible migration chain - Update
setSessionFile()to detect version and migrate - Implement
_rewriteFile()for post-migration persistence - Handle
firstKeptEntryIndex→firstKeptEntryIdconversion in migration
Phase 3: Branching ✅
- Implement
branch(id)- switch leaf pointer - Implement
branchWithSummary(id, summary)- create summary entry - Implement
createBranchedSession(leafId)- extract path to new file - Update
AgentSession.branch()to use new API
Phase 4: Compaction Integration ✅
- Update
compaction.tsto work with IDs - Update
prepareCompaction()to returnfirstKeptEntryId - Update
compact()to returnCompactionResultwithfirstKeptEntryId - Update
AgentSessioncompaction methods - Add
firstKeptEntryIdtobefore_compacthook event
Phase 5: Testing ✅
migration.test.ts- v1 to v2 migration, idempotencybuild-context.test.ts- context building with tree structure, compaction, branchestree-traversal.test.ts- append operations, getPath, getTree, branchingfile-operations.test.ts- loadEntriesFromFile, findMostRecentSessionsave-entry.test.ts- custom entry integration- Update existing compaction tests for new types
Remaining Work
Compaction Refactor
- Use
CompactionResulttype for hook return value - Make
CompactionEntry<T>generic with optionaldetails?: Tfield for hook-specific data - Make
CompactionResult<T>generic to match - Update
SessionEventBaseto passsessionManagerandmodelRegistryinstead of derived fields - Update
before_compactevent:- Pass
preparation: CompactionPreparationinstead of individual fields - Pass
previousCompactions: CompactionEntry[](newest first) instead ofpreviousSummary?: string - Keep:
customInstructions,model,signal - Drop:
resolveApiKey(usemodelRegistry.getApiKey()),cutPoint,entries
- Pass
- Update hook example
custom-compaction.tsto use new API - Update
getSessionFile()to returnstring | undefinedfor in-memory sessions - Update
before_switchto havetargetSessionFile,switchto havepreviousSessionFile
Reference: #314 - Structured compaction with anchored iterative summarization needs details field to store ArtifactIndex and version markers.
Branch Summary Design
Current type:
export interface BranchSummaryEntry extends SessionEntryBase {
type: "branch_summary";
summary: string;
}
Questions to resolve:
- Add
abandonedLeafIdfield to reference what was abandoned? - Store metadata about why the branch happened?
- Who generates the summary - user, LLM, or both options?
- Design and implement branch summarizer
- Add tests for
branchWithSummary()flow
Entry Labels ✅
- Add
LabelEntrytype withtargetIdandlabelfields - Add
labelsById: Map<string, string>private field - Build labels map in
_buildIndex()via linear scan - Add
getLabel(id)method - Add
appendLabelChange(targetId, label)method (undefined clears) - Update
createBranchedSession()to filter out LabelEntry and recreate from resolved map buildSessionContext()already ignores LabelEntry (only handles message types)- Add
label?: stringtoSessionTreeNode, populated bygetTree() - Display labels in UI (tree view, path view) - deferred to UI phase
/labelcommand - deferred to UI phase
CustomMessageEntry
Hook-injected messages that participate in LLM context. Unlike CustomEntry<T> (for hook state only), these are sent to the model.
export interface CustomMessageEntry<T = unknown> extends SessionEntryBase {
type: "custom_message";
customType: string; // Hook identifier
content: string | (TextContent | ImageContent)[]; // Message content (same as UserMessage)
details?: T; // Hook-specific data for state reconstruction on reload
display: boolean; // Whether to display in TUI
}
Behavior:
- Type definition matching plan
appendCustomMessageEntry(customType, content, display, details?)in SessionManagerbuildSessionContext()includes custom_message entries as user messages- Exported from main index
- TUI rendering:
display: false- hidden entirelydisplay: true- rendered with purple styling (customMessageBg, customMessageText, customMessageLabel theme colors)registerCustomMessageRenderer(customType, renderer)in HookAPI for custom renderers- Renderer returns inner Component, TUI wraps in styled Box
Hook API Changes
Renamed:
renderCustomMessage()→registerCustomMessageRenderer()
New: sendMessage() ✅
Replaces send(). Always creates CustomMessageEntry, never user messages.
type HookMessage<T = unknown> = Pick<CustomMessageEntry<T>, 'customType' | 'content' | 'display' | 'details'>;
sendMessage(message: HookMessage, triggerTurn?: boolean): void;
Implementation:
- Uses agent's queue mechanism with
_hookDatamarker on AppMessage message_endhandler routes based on marker presenceAgentSession.sendHookMessage()handles three cases:- Streaming: queues via
agent.queueMessage(), loop processes and emitsmessage_end - Not streaming + triggerTurn: direct append +
agent.continue() - Not streaming + no trigger: direct append only
- Streaming: queues via
- TUI updates via event (streaming) or explicit rebuild (non-streaming)
New: appendEntry() ✅
For hook state persistence (NOT in LLM context):
appendEntry(customType: string, data?: unknown): void;
Calls sessionManager.appendCustomEntry() directly.
New: registerCommand() (types ✅, wiring TODO)
interface CommandContext {
args: string; // Everything after /commandname
ui: HookUIContext;
hasUI: boolean;
cwd: string;
sessionManager: SessionManager;
modelRegistry: ModelRegistry;
sendMessage: HookAPI['sendMessage'];
exec(command: string, args: string[], options?: ExecOptions): Promise<ExecResult>;
}
registerCommand(name: string, options: {
description?: string;
handler: (ctx: CommandContext) => Promise<string | undefined>;
}): void;
Handler return:
void- command completed (usesendMessage()withtriggerTurn: trueto prompt LLM)
Wiring (all in AgentSession.prompt()):
- Add hook commands to autocomplete in interactive-mode
_tryExecuteHookCommand()in AgentSession handles command execution- Build CommandContext with ui (from hookRunner), exec, sessionManager, etc.
- If handler returns string, use as prompt text
- If handler returns undefined, return early (no LLM call)
- Works for all modes (interactive, RPC, print) via shared AgentSession
New: ui.custom()
For arbitrary hook UI with keyboard focus:
interface HookUIContext {
// ... existing: select, confirm, input, notify
/** Show custom component with keyboard focus. Call done() when finished. */
custom(component: Component, done: () => void): void;
}
See also: CustomEntry<T> for storing hook state that does NOT participate in context.
HTML Export
- Add collapsible sidebar showing full tree structure
- Allow selecting any node in tree to view that path
- Add "reset to session leaf" button
- Render full path (no compaction resolution needed)
- Responsive: collapse sidebar on mobile
UI Commands
Design new commands based on refactored SessionManager:
/branch - Current behavior (creates new session file from path)
- Review if this is still the right UX with tree structure
- Consider: should this use
createBranchedSession()orbranch()?
/branch-here - In-place branching (new)
- Use
branch(id)to move leaf pointer without creating new file - Subsequent messages become new branch in same file
- Design: how to select branch point? (similar to current
/branchUI?)
/branches - List/navigate branches (new)
- Show tree structure or list of branch points
- Allow switching between branches (move leaf pointer)
- Show current position in tree
Notes
- All append methods return the new entry's ID
- Migration rewrites file on first load if version < CURRENT_VERSION
- Existing sessions become linear chains after migration (parentId = previous entry)
- Tree features available immediately after migration
- SessionHeader does NOT have id/parentId (it's metadata, not part of tree)
- Session is append-only: entries cannot be modified or deleted, only branching changes the leaf pointer