co-mono/packages/coding-agent/docs/session-tree-plan.md
Mario Zechner ba185b0571 Hook API: replace send() with sendMessage(), add appendEntry() and registerCommand()
Breaking changes to Hook API:
- pi.send(text, attachments?) replaced with pi.sendMessage(message, triggerTurn?)
  - Creates CustomMessageEntry instead of user messages
  - Properly handles queuing during streaming via agent loop
  - Supports optional turn triggering when idle
- New pi.appendEntry(customType, data?) for hook state persistence
- New pi.registerCommand(name, options) for custom slash commands
- Handler types renamed: SendHandler -> SendMessageHandler, new AppendEntryHandler

Implementation:
- AgentSession.sendHookMessage() handles all three cases:
  - Streaming: queues message with _hookData marker, agent loop processes it
  - Not streaming + triggerTurn: appends to state/session, calls agent.continue()
  - Not streaming + no trigger: appends to state/session only
- message_end handler routes based on _hookData presence to correct persistence
- HookRunner gains getRegisteredCommands() and getCommand() methods

New types: HookMessage<T>, RegisteredCommand, CommandContext
2025-12-30 22:42:18 +01:00

8.8 KiB

Session Tree Implementation Plan

Reference: session-tree.md

Phase 1: SessionManager Core

  • Update entry types with id, parentId fields (using SessionEntryBase)
  • Add version field to SessionHeader
  • Change CompactionEntry.firstKeptEntryIndexfirstKeptEntryId
  • Add BranchSummaryEntry type
  • Add CustomEntry type for hooks
  • Add byId: Map<string, SessionEntry> index
  • Add leafId: string tracking
  • Implement getPath(fromId?) tree traversal
  • Implement getTree() returning SessionTreeNode[]
  • Implement getEntry(id) lookup
  • Implement getLeafUuid() and getLeafEntry() helpers
  • Update _buildIndex() to populate byId map
  • Rename saveXXX() to appendXXX() (returns id, advances leaf)
  • Add appendCustomEntry(customType, data) for hooks
  • Update buildSessionContext() to use getPath() traversal

Phase 2: Migration

  • Add CURRENT_SESSION_VERSION = 2 constant
  • Implement migrateV1ToV2() with extensible migration chain
  • Update setSessionFile() to detect version and migrate
  • Implement _rewriteFile() for post-migration persistence
  • Handle firstKeptEntryIndexfirstKeptEntryId conversion 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.ts to work with IDs
  • Update prepareCompaction() to return firstKeptEntryId
  • Update compact() to return CompactionResult with firstKeptEntryId
  • Update AgentSession compaction methods
  • Add firstKeptEntryId to before_compact hook event

Phase 5: Testing

  • migration.test.ts - v1 to v2 migration, idempotency
  • build-context.test.ts - context building with tree structure, compaction, branches
  • tree-traversal.test.ts - append operations, getPath, getTree, branching
  • file-operations.test.ts - loadEntriesFromFile, findMostRecentSession
  • save-entry.test.ts - custom entry integration
  • Update existing compaction tests for new types

Remaining Work

Compaction Refactor

  • Use CompactionResult type for hook return value
  • Make CompactionEntry<T> generic with optional details?: T field for hook-specific data
  • Make CompactionResult<T> generic to match
  • Update SessionEventBase to pass sessionManager and modelRegistry instead of derived fields
  • Update before_compact event:
    • Pass preparation: CompactionPreparation instead of individual fields
    • Pass previousCompactions: CompactionEntry[] (newest first) instead of previousSummary?: string
    • Keep: customInstructions, model, signal
    • Drop: resolveApiKey (use modelRegistry.getApiKey()), cutPoint, entries
  • Update hook example custom-compaction.ts to use new API
  • Update getSessionFile() to return string | undefined for in-memory sessions
  • Update before_switch to have targetSessionFile, switch to have previousSessionFile

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 abandonedLeafId field 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 LabelEntry type with targetId and label fields
  • 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?: string to SessionTreeNode, populated by getTree()
  • Display labels in UI (tree view, path view) - deferred to UI phase
  • /label command - 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 SessionManager
  • buildSessionContext() includes custom_message entries as user messages
  • Exported from main index
  • TUI rendering:
    • display: false - hidden entirely
    • display: 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;

Behavior:

  • If streaming → queue, append after turn ends (never triggers turn)
  • If idle AND triggerTurn: true → append and trigger turn
  • If idle AND triggerTurn: false (default) → just append, no turn
  • TUI updates if display: true

For hook state (CustomEntry), use sessionManager.appendCustomEntry() directly.

New: registerCommand()

interface CommandContext {
  args: string;                    // Everything after /commandname
  session: LimitedAgentSession;    // No prompt(), use sendMessage()
  ui: HookUIContext;
  exec(command: string, args: string[], options?: ExecOptions): Promise<ExecResult>;
}

registerCommand(name: string, options: {
  description?: string;
  handler: (ctx: CommandContext) => Promise<string | void>;
}): void;

Handler return:

  • void - command completed
  • string - text to send as prompt (like file-based slash commands)

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() or branch()?

/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 /branch UI?)

/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