Merge branch 'main' into pb/tui-status-coalesce

This commit is contained in:
Mario Zechner 2026-01-01 00:27:54 +01:00
commit ac6f5006a9
216 changed files with 14479 additions and 8725 deletions

View file

@ -3,21 +3,21 @@
*/
import { access, readFile, stat } from "node:fs/promises";
import type { Attachment } from "@mariozechner/pi-agent-core";
import type { ImageContent } from "@mariozechner/pi-ai";
import chalk from "chalk";
import { resolve } from "path";
import { resolveReadPath } from "../core/tools/path-utils.js";
import { detectSupportedImageMimeTypeFromFile } from "../utils/mime.js";
export interface ProcessedFiles {
textContent: string;
imageAttachments: Attachment[];
text: string;
images: ImageContent[];
}
/** Process @file arguments into text content and image attachments */
export async function processFileArguments(fileArgs: string[]): Promise<ProcessedFiles> {
let textContent = "";
const imageAttachments: Attachment[] = [];
let text = "";
const images: ImageContent[] = [];
for (const fileArg of fileArgs) {
// Expand and resolve path (handles ~ expansion and macOS screenshot Unicode spaces)
@ -45,24 +45,21 @@ export async function processFileArguments(fileArgs: string[]): Promise<Processe
const content = await readFile(absolutePath);
const base64Content = content.toString("base64");
const attachment: Attachment = {
id: `file-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
const attachment: ImageContent = {
type: "image",
fileName: absolutePath.split("/").pop() || absolutePath,
mimeType,
size: stats.size,
content: base64Content,
data: base64Content,
};
imageAttachments.push(attachment);
images.push(attachment);
// Add text reference to image
textContent += `<file name="${absolutePath}"></file>\n`;
text += `<file name="${absolutePath}"></file>\n`;
} else {
// Handle text file
try {
const content = await readFile(absolutePath, "utf-8");
textContent += `<file name="${absolutePath}">\n${content}\n</file>\n`;
text += `<file name="${absolutePath}">\n${content}\n</file>\n`;
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error);
console.error(chalk.red(`Error: Could not read file ${absolutePath}: ${message}`));
@ -71,5 +68,5 @@ export async function processFileArguments(fileArgs: string[]): Promise<Processe
}
}
return { textContent, imageAttachments };
return { text, images };
}

View file

@ -75,6 +75,11 @@ export function getDocsPath(): string {
return resolve(join(getPackageDir(), "docs"));
}
/** Get path to examples directory */
export function getExamplesPath(): string {
return resolve(join(getPackageDir(), "examples"));
}
/** Get path to CHANGELOG.md */
export function getChangelogPath(): string {
return resolve(join(getPackageDir(), "CHANGELOG.md"));

File diff suppressed because it is too large Load diff

View file

@ -94,8 +94,8 @@ export class AuthStorage {
/**
* Get credential for a provider.
*/
get(provider: string): AuthCredential | null {
return this.data[provider] ?? null;
get(provider: string): AuthCredential | undefined {
return this.data[provider] ?? undefined;
}
/**
@ -191,7 +191,7 @@ export class AuthStorage {
* 4. Environment variable
* 5. Fallback resolver (models.json custom providers)
*/
async getApiKey(provider: string): Promise<string | null> {
async getApiKey(provider: string): Promise<string | undefined> {
// Runtime override takes highest priority
const runtimeKey = this.runtimeOverrides.get(provider);
if (runtimeKey) {
@ -230,6 +230,6 @@ export class AuthStorage {
if (envKey) return envKey;
// Fall back to custom resolver (e.g., models.json custom providers)
return this.fallbackResolver?.(provider) ?? null;
return this.fallbackResolver?.(provider) ?? undefined;
}
}

View file

@ -29,8 +29,8 @@ export interface BashExecutorOptions {
export interface BashResult {
/** Combined stdout + stderr output (sanitized, possibly truncated) */
output: string;
/** Process exit code (null if killed/cancelled) */
exitCode: number | null;
/** Process exit code (undefined if killed/cancelled) */
exitCode: number | undefined;
/** Whether the command was cancelled via signal */
cancelled: boolean;
/** Whether the output was truncated */
@ -88,7 +88,7 @@ export function executeBash(command: string, options?: BashExecutorOptions): Pro
child.kill();
resolve({
output: "",
exitCode: null,
exitCode: undefined,
cancelled: true,
truncated: false,
});
@ -154,7 +154,7 @@ export function executeBash(command: string, options?: BashExecutorOptions): Pro
resolve({
output: truncationResult.truncated ? truncationResult.content : fullOutput,
exitCode: code,
exitCode: cancelled ? undefined : code,
cancelled,
truncated: truncationResult.truncated,
fullOutputPath: tempFilePath,

View file

@ -1,530 +0,0 @@
/**
* Context compaction for long sessions.
*
* Pure functions for compaction logic. The session manager handles I/O,
* and after compaction the session is reloaded.
*/
import type { AppMessage } from "@mariozechner/pi-agent-core";
import type { AssistantMessage, Model, Usage } from "@mariozechner/pi-ai";
import { complete } from "@mariozechner/pi-ai";
import { messageTransformer } from "./messages.js";
import type { CompactionEntry, SessionEntry } from "./session-manager.js";
// ============================================================================
// Types
// ============================================================================
export interface CompactionSettings {
enabled: boolean;
reserveTokens: number;
keepRecentTokens: number;
}
export const DEFAULT_COMPACTION_SETTINGS: CompactionSettings = {
enabled: true,
reserveTokens: 16384,
keepRecentTokens: 20000,
};
// ============================================================================
// Token calculation
// ============================================================================
/**
* Calculate total context tokens from usage.
* Uses the native totalTokens field when available, falls back to computing from components.
*/
export function calculateContextTokens(usage: Usage): number {
return usage.totalTokens || usage.input + usage.output + usage.cacheRead + usage.cacheWrite;
}
/**
* Get usage from an assistant message if available.
* Skips aborted and error messages as they don't have valid usage data.
*/
function getAssistantUsage(msg: AppMessage): Usage | null {
if (msg.role === "assistant" && "usage" in msg) {
const assistantMsg = msg as AssistantMessage;
if (assistantMsg.stopReason !== "aborted" && assistantMsg.stopReason !== "error" && assistantMsg.usage) {
return assistantMsg.usage;
}
}
return null;
}
/**
* Find the last non-aborted assistant message usage from session entries.
*/
export function getLastAssistantUsage(entries: SessionEntry[]): Usage | null {
for (let i = entries.length - 1; i >= 0; i--) {
const entry = entries[i];
if (entry.type === "message") {
const usage = getAssistantUsage(entry.message);
if (usage) return usage;
}
}
return null;
}
/**
* Check if compaction should trigger based on context usage.
*/
export function shouldCompact(contextTokens: number, contextWindow: number, settings: CompactionSettings): boolean {
if (!settings.enabled) return false;
return contextTokens > contextWindow - settings.reserveTokens;
}
// ============================================================================
// Cut point detection
// ============================================================================
/**
* Estimate token count for a message using chars/4 heuristic.
* This is conservative (overestimates tokens).
*/
export function estimateTokens(message: AppMessage): number {
let chars = 0;
// Handle bashExecution messages
if (message.role === "bashExecution") {
const bash = message as unknown as { command: string; output: string };
chars = bash.command.length + bash.output.length;
return Math.ceil(chars / 4);
}
// Handle user messages
if (message.role === "user") {
const content = (message as { content: string | Array<{ type: string; text?: string }> }).content;
if (typeof content === "string") {
chars = content.length;
} else if (Array.isArray(content)) {
for (const block of content) {
if (block.type === "text" && block.text) {
chars += block.text.length;
}
}
}
return Math.ceil(chars / 4);
}
// Handle assistant messages
if (message.role === "assistant") {
const assistant = message as AssistantMessage;
for (const block of assistant.content) {
if (block.type === "text") {
chars += block.text.length;
} else if (block.type === "thinking") {
chars += block.thinking.length;
} else if (block.type === "toolCall") {
chars += block.name.length + JSON.stringify(block.arguments).length;
}
}
return Math.ceil(chars / 4);
}
// Handle tool results
if (message.role === "toolResult") {
const toolResult = message as { content: Array<{ type: string; text?: string }> };
for (const block of toolResult.content) {
if (block.type === "text" && block.text) {
chars += block.text.length;
}
}
return Math.ceil(chars / 4);
}
return 0;
}
/**
* Find valid cut points: indices of user, assistant, or bashExecution messages.
* Never cut at tool results (they must follow their tool call).
* When we cut at an assistant message with tool calls, its tool results follow it
* and will be kept.
* BashExecutionMessage is treated like a user message (user-initiated context).
*/
function findValidCutPoints(entries: SessionEntry[], startIndex: number, endIndex: number): number[] {
const cutPoints: number[] = [];
for (let i = startIndex; i < endIndex; i++) {
const entry = entries[i];
if (entry.type === "message") {
const role = entry.message.role;
// user, assistant, and bashExecution are valid cut points
// toolResult must stay with its preceding tool call
if (role === "user" || role === "assistant" || role === "bashExecution") {
cutPoints.push(i);
}
}
}
return cutPoints;
}
/**
* Find the user message (or bashExecution) that starts the turn containing the given entry index.
* Returns -1 if no turn start found before the index.
* BashExecutionMessage is treated like a user message for turn boundaries.
*/
export function findTurnStartIndex(entries: SessionEntry[], entryIndex: number, startIndex: number): number {
for (let i = entryIndex; i >= startIndex; i--) {
const entry = entries[i];
if (entry.type === "message") {
const role = entry.message.role;
if (role === "user" || role === "bashExecution") {
return i;
}
}
}
return -1;
}
export interface CutPointResult {
/** Index of first entry to keep */
firstKeptEntryIndex: number;
/** Index of user message that starts the turn being split, or -1 if not splitting */
turnStartIndex: number;
/** Whether this cut splits a turn (cut point is not a user message) */
isSplitTurn: boolean;
}
/**
* Find the cut point in session entries that keeps approximately `keepRecentTokens`.
*
* Algorithm: Walk backwards from newest, accumulating estimated message sizes.
* Stop when we've accumulated >= keepRecentTokens. Cut at that point.
*
* Can cut at user OR assistant messages (never tool results). When cutting at an
* assistant message with tool calls, its tool results come after and will be kept.
*
* Returns CutPointResult with:
* - firstKeptEntryIndex: the entry index to start keeping from
* - turnStartIndex: if cutting mid-turn, the user message that started that turn
* - isSplitTurn: whether we're cutting in the middle of a turn
*
* Only considers entries between `startIndex` and `endIndex` (exclusive).
*/
export function findCutPoint(
entries: SessionEntry[],
startIndex: number,
endIndex: number,
keepRecentTokens: number,
): CutPointResult {
const cutPoints = findValidCutPoints(entries, startIndex, endIndex);
if (cutPoints.length === 0) {
return { firstKeptEntryIndex: startIndex, turnStartIndex: -1, isSplitTurn: false };
}
// Walk backwards from newest, accumulating estimated message sizes
let accumulatedTokens = 0;
let cutIndex = startIndex; // Default: keep everything in range
for (let i = endIndex - 1; i >= startIndex; i--) {
const entry = entries[i];
if (entry.type !== "message") continue;
// Estimate this message's size
const messageTokens = estimateTokens(entry.message);
accumulatedTokens += messageTokens;
// Check if we've exceeded the budget
if (accumulatedTokens >= keepRecentTokens) {
// Find the closest valid cut point at or after this entry
for (let c = 0; c < cutPoints.length; c++) {
if (cutPoints[c] >= i) {
cutIndex = cutPoints[c];
break;
}
}
break;
}
}
// Scan backwards from cutIndex to include any non-message entries (bash, settings, etc.)
while (cutIndex > startIndex) {
const prevEntry = entries[cutIndex - 1];
// Stop at compaction boundaries
if (prevEntry.type === "compaction") {
break;
}
if (prevEntry.type === "message") {
// Stop if we hit any message
break;
}
// Include this non-message entry (bash, settings change, etc.)
cutIndex--;
}
// Determine if this is a split turn
const cutEntry = entries[cutIndex];
const isUserMessage = cutEntry.type === "message" && cutEntry.message.role === "user";
const turnStartIndex = isUserMessage ? -1 : findTurnStartIndex(entries, cutIndex, startIndex);
return {
firstKeptEntryIndex: cutIndex,
turnStartIndex,
isSplitTurn: !isUserMessage && turnStartIndex !== -1,
};
}
// ============================================================================
// Summarization
// ============================================================================
const SUMMARIZATION_PROMPT = `You are performing a CONTEXT CHECKPOINT COMPACTION. Create a handoff summary for another LLM that will resume the task.
Include:
- Current progress and key decisions made
- Important context, constraints, or user preferences
- Absolute file paths of any relevant files that were read or modified
- What remains to be done (clear next steps)
- Any critical data, examples, or references needed to continue
Be concise, structured, and focused on helping the next LLM seamlessly continue the work.`;
/**
* Generate a summary of the conversation using the LLM.
*/
export async function generateSummary(
currentMessages: AppMessage[],
model: Model<any>,
reserveTokens: number,
apiKey: string,
signal?: AbortSignal,
customInstructions?: string,
): Promise<string> {
const maxTokens = Math.floor(0.8 * reserveTokens);
const prompt = customInstructions
? `${SUMMARIZATION_PROMPT}\n\nAdditional focus: ${customInstructions}`
: SUMMARIZATION_PROMPT;
// Transform custom messages (like bashExecution) to LLM-compatible messages
const transformedMessages = messageTransformer(currentMessages);
const summarizationMessages = [
...transformedMessages,
{
role: "user" as const,
content: [{ type: "text" as const, text: prompt }],
timestamp: Date.now(),
},
];
const response = await complete(model, { messages: summarizationMessages }, { maxTokens, signal, apiKey });
const textContent = response.content
.filter((c): c is { type: "text"; text: string } => c.type === "text")
.map((c) => c.text)
.join("\n");
return textContent;
}
// ============================================================================
// Compaction Preparation (for hooks)
// ============================================================================
export interface CompactionPreparation {
cutPoint: CutPointResult;
/** Messages that will be summarized and discarded */
messagesToSummarize: AppMessage[];
/** Messages that will be kept after the summary (recent turns) */
messagesToKeep: AppMessage[];
tokensBefore: number;
boundaryStart: number;
}
export function prepareCompaction(entries: SessionEntry[], settings: CompactionSettings): CompactionPreparation | null {
if (entries.length > 0 && entries[entries.length - 1].type === "compaction") {
return null;
}
let prevCompactionIndex = -1;
for (let i = entries.length - 1; i >= 0; i--) {
if (entries[i].type === "compaction") {
prevCompactionIndex = i;
break;
}
}
const boundaryStart = prevCompactionIndex + 1;
const boundaryEnd = entries.length;
const lastUsage = getLastAssistantUsage(entries);
const tokensBefore = lastUsage ? calculateContextTokens(lastUsage) : 0;
const cutPoint = findCutPoint(entries, boundaryStart, boundaryEnd, settings.keepRecentTokens);
const historyEnd = cutPoint.isSplitTurn ? cutPoint.turnStartIndex : cutPoint.firstKeptEntryIndex;
// Messages to summarize (will be discarded after summary)
const messagesToSummarize: AppMessage[] = [];
for (let i = boundaryStart; i < historyEnd; i++) {
const entry = entries[i];
if (entry.type === "message") {
messagesToSummarize.push(entry.message);
}
}
// Messages to keep (recent turns, kept after summary)
const messagesToKeep: AppMessage[] = [];
for (let i = cutPoint.firstKeptEntryIndex; i < boundaryEnd; i++) {
const entry = entries[i];
if (entry.type === "message") {
messagesToKeep.push(entry.message);
}
}
return { cutPoint, messagesToSummarize, messagesToKeep, tokensBefore, boundaryStart };
}
// ============================================================================
// Main compaction function
// ============================================================================
const TURN_PREFIX_SUMMARIZATION_PROMPT = `You are performing a CONTEXT CHECKPOINT COMPACTION for a split turn.
This is the PREFIX of a turn that was too large to keep in full. The SUFFIX (recent work) is being kept.
Create a handoff summary that captures:
- What the user originally asked for in this turn
- Key decisions and progress made early in this turn
- Important context needed to understand the kept suffix
Be concise. Focus on information needed to understand the retained recent work.`;
/**
* Calculate compaction and generate summary.
* Returns the CompactionEntry to append to the session file.
*
* @param entries - All session entries
* @param model - Model to use for summarization
* @param settings - Compaction settings
* @param apiKey - API key for LLM
* @param signal - Optional abort signal
* @param customInstructions - Optional custom focus for the summary
*/
export async function compact(
entries: SessionEntry[],
model: Model<any>,
settings: CompactionSettings,
apiKey: string,
signal?: AbortSignal,
customInstructions?: string,
): Promise<CompactionEntry> {
// Don't compact if the last entry is already a compaction
if (entries.length > 0 && entries[entries.length - 1].type === "compaction") {
throw new Error("Already compacted");
}
// Find previous compaction boundary
let prevCompactionIndex = -1;
for (let i = entries.length - 1; i >= 0; i--) {
if (entries[i].type === "compaction") {
prevCompactionIndex = i;
break;
}
}
const boundaryStart = prevCompactionIndex + 1;
const boundaryEnd = entries.length;
// Get token count before compaction
const lastUsage = getLastAssistantUsage(entries);
const tokensBefore = lastUsage ? calculateContextTokens(lastUsage) : 0;
// Find cut point (entry index) within the valid range
const cutResult = findCutPoint(entries, boundaryStart, boundaryEnd, settings.keepRecentTokens);
// Extract messages for history summary (before the turn that contains the cut point)
const historyEnd = cutResult.isSplitTurn ? cutResult.turnStartIndex : cutResult.firstKeptEntryIndex;
const historyMessages: AppMessage[] = [];
for (let i = boundaryStart; i < historyEnd; i++) {
const entry = entries[i];
if (entry.type === "message") {
historyMessages.push(entry.message);
}
}
// Include previous summary if there was a compaction
if (prevCompactionIndex >= 0) {
const prevCompaction = entries[prevCompactionIndex] as CompactionEntry;
historyMessages.unshift({
role: "user",
content: `Previous session summary:\n${prevCompaction.summary}`,
timestamp: Date.now(),
});
}
// Extract messages for turn prefix summary (if splitting a turn)
const turnPrefixMessages: AppMessage[] = [];
if (cutResult.isSplitTurn) {
for (let i = cutResult.turnStartIndex; i < cutResult.firstKeptEntryIndex; i++) {
const entry = entries[i];
if (entry.type === "message") {
turnPrefixMessages.push(entry.message);
}
}
}
// Generate summaries (can be parallel if both needed) and merge into one
let summary: string;
if (cutResult.isSplitTurn && turnPrefixMessages.length > 0) {
// Generate both summaries in parallel
const [historyResult, turnPrefixResult] = await Promise.all([
historyMessages.length > 0
? generateSummary(historyMessages, model, settings.reserveTokens, apiKey, signal, customInstructions)
: Promise.resolve("No prior history."),
generateTurnPrefixSummary(turnPrefixMessages, model, settings.reserveTokens, apiKey, signal),
]);
// Merge into single summary
summary = `${historyResult}\n\n---\n\n**Turn Context (split turn):**\n\n${turnPrefixResult}`;
} else {
// Just generate history summary
summary = await generateSummary(
historyMessages,
model,
settings.reserveTokens,
apiKey,
signal,
customInstructions,
);
}
return {
type: "compaction",
timestamp: new Date().toISOString(),
summary,
firstKeptEntryIndex: cutResult.firstKeptEntryIndex,
tokensBefore,
};
}
/**
* Generate a summary for a turn prefix (when splitting a turn).
*/
async function generateTurnPrefixSummary(
messages: AppMessage[],
model: Model<any>,
reserveTokens: number,
apiKey: string,
signal?: AbortSignal,
): Promise<string> {
const maxTokens = Math.floor(0.5 * reserveTokens); // Smaller budget for turn prefix
const transformedMessages = messageTransformer(messages);
const summarizationMessages = [
...transformedMessages,
{
role: "user" as const,
content: [{ type: "text" as const, text: TURN_PREFIX_SUMMARIZATION_PROMPT }],
timestamp: Date.now(),
},
];
const response = await complete(model, { messages: summarizationMessages }, { maxTokens, signal, apiKey });
return response.content
.filter((c): c is { type: "text"; text: string } => c.type === "text")
.map((c) => c.text)
.join("\n");
}

View file

@ -0,0 +1,343 @@
/**
* Branch summarization for tree navigation.
*
* When navigating to a different point in the session tree, this generates
* a summary of the branch being left so context isn't lost.
*/
import type { AgentMessage } from "@mariozechner/pi-agent-core";
import type { Model } from "@mariozechner/pi-ai";
import { completeSimple } from "@mariozechner/pi-ai";
import {
convertToLlm,
createBranchSummaryMessage,
createCompactionSummaryMessage,
createHookMessage,
} from "../messages.js";
import type { ReadonlySessionManager, SessionEntry } from "../session-manager.js";
import { estimateTokens } from "./compaction.js";
import {
computeFileLists,
createFileOps,
extractFileOpsFromMessage,
type FileOperations,
formatFileOperations,
SUMMARIZATION_SYSTEM_PROMPT,
serializeConversation,
} from "./utils.js";
// ============================================================================
// Types
// ============================================================================
export interface BranchSummaryResult {
summary?: string;
readFiles?: string[];
modifiedFiles?: string[];
aborted?: boolean;
error?: string;
}
/** Details stored in BranchSummaryEntry.details for file tracking */
export interface BranchSummaryDetails {
readFiles: string[];
modifiedFiles: string[];
}
export type { FileOperations } from "./utils.js";
export interface BranchPreparation {
/** Messages extracted for summarization, in chronological order */
messages: AgentMessage[];
/** File operations extracted from tool calls */
fileOps: FileOperations;
/** Total estimated tokens in messages */
totalTokens: number;
}
export interface CollectEntriesResult {
/** Entries to summarize, in chronological order */
entries: SessionEntry[];
/** Common ancestor between old and new position, if any */
commonAncestorId: string | null;
}
export interface GenerateBranchSummaryOptions {
/** Model to use for summarization */
model: Model<any>;
/** API key for the model */
apiKey: string;
/** Abort signal for cancellation */
signal: AbortSignal;
/** Optional custom instructions for summarization */
customInstructions?: string;
/** Tokens reserved for prompt + LLM response (default 16384) */
reserveTokens?: number;
}
// ============================================================================
// Entry Collection
// ============================================================================
/**
* Collect entries that should be summarized when navigating from one position to another.
*
* Walks from oldLeafId back to the common ancestor with targetId, collecting entries
* along the way. Does NOT stop at compaction boundaries - those are included and their
* summaries become context.
*
* @param session - Session manager (read-only access)
* @param oldLeafId - Current position (where we're navigating from)
* @param targetId - Target position (where we're navigating to)
* @returns Entries to summarize and the common ancestor
*/
export function collectEntriesForBranchSummary(
session: ReadonlySessionManager,
oldLeafId: string | null,
targetId: string,
): CollectEntriesResult {
// If no old position, nothing to summarize
if (!oldLeafId) {
return { entries: [], commonAncestorId: null };
}
// Find common ancestor (deepest node that's on both paths)
const oldPath = new Set(session.getBranch(oldLeafId).map((e) => e.id));
const targetPath = session.getBranch(targetId);
// targetPath is root-first, so iterate backwards to find deepest common ancestor
let commonAncestorId: string | null = null;
for (let i = targetPath.length - 1; i >= 0; i--) {
if (oldPath.has(targetPath[i].id)) {
commonAncestorId = targetPath[i].id;
break;
}
}
// Collect entries from old leaf back to common ancestor
const entries: SessionEntry[] = [];
let current: string | null = oldLeafId;
while (current && current !== commonAncestorId) {
const entry = session.getEntry(current);
if (!entry) break;
entries.push(entry);
current = entry.parentId;
}
// Reverse to get chronological order
entries.reverse();
return { entries, commonAncestorId };
}
// ============================================================================
// Entry to Message Conversion
// ============================================================================
/**
* Extract AgentMessage from a session entry.
* Similar to getMessageFromEntry in compaction.ts but also handles compaction entries.
*/
function getMessageFromEntry(entry: SessionEntry): AgentMessage | undefined {
switch (entry.type) {
case "message":
// Skip tool results - context is in assistant's tool call
if (entry.message.role === "toolResult") return undefined;
return entry.message;
case "custom_message":
return createHookMessage(entry.customType, entry.content, entry.display, entry.details, entry.timestamp);
case "branch_summary":
return createBranchSummaryMessage(entry.summary, entry.fromId, entry.timestamp);
case "compaction":
return createCompactionSummaryMessage(entry.summary, entry.tokensBefore, entry.timestamp);
// These don't contribute to conversation content
case "thinking_level_change":
case "model_change":
case "custom":
case "label":
return undefined;
}
}
/**
* Prepare entries for summarization with token budget.
*
* Walks entries from NEWEST to OLDEST, adding messages until we hit the token budget.
* This ensures we keep the most recent context when the branch is too long.
*
* Also collects file operations from:
* - Tool calls in assistant messages
* - Existing branch_summary entries' details (for cumulative tracking)
*
* @param entries - Entries in chronological order
* @param tokenBudget - Maximum tokens to include (0 = no limit)
*/
export function prepareBranchEntries(entries: SessionEntry[], tokenBudget: number = 0): BranchPreparation {
const messages: AgentMessage[] = [];
const fileOps = createFileOps();
let totalTokens = 0;
// First pass: collect file ops from ALL entries (even if they don't fit in token budget)
// This ensures we capture cumulative file tracking from nested branch summaries
// Only extract from pi-generated summaries (fromHook !== true), not hook-generated ones
for (const entry of entries) {
if (entry.type === "branch_summary" && !entry.fromHook && entry.details) {
const details = entry.details as BranchSummaryDetails;
if (Array.isArray(details.readFiles)) {
for (const f of details.readFiles) fileOps.read.add(f);
}
if (Array.isArray(details.modifiedFiles)) {
// Modified files go into both edited and written for proper deduplication
for (const f of details.modifiedFiles) {
fileOps.edited.add(f);
}
}
}
}
// Second pass: walk from newest to oldest, adding messages until token budget
for (let i = entries.length - 1; i >= 0; i--) {
const entry = entries[i];
const message = getMessageFromEntry(entry);
if (!message) continue;
// Extract file ops from assistant messages (tool calls)
extractFileOpsFromMessage(message, fileOps);
const tokens = estimateTokens(message);
// Check budget before adding
if (tokenBudget > 0 && totalTokens + tokens > tokenBudget) {
// If this is a summary entry, try to fit it anyway as it's important context
if (entry.type === "compaction" || entry.type === "branch_summary") {
if (totalTokens < tokenBudget * 0.9) {
messages.unshift(message);
totalTokens += tokens;
}
}
// Stop - we've hit the budget
break;
}
messages.unshift(message);
totalTokens += tokens;
}
return { messages, fileOps, totalTokens };
}
// ============================================================================
// Summary Generation
// ============================================================================
const BRANCH_SUMMARY_PREAMBLE = `The user explored a different conversation branch before returning here.
Summary of that exploration:
`;
const BRANCH_SUMMARY_PROMPT = `Create a structured summary of this conversation branch for context when returning later.
Use this EXACT format:
## Goal
[What was the user trying to accomplish in this branch?]
## Constraints & Preferences
- [Any constraints, preferences, or requirements mentioned]
- [Or "(none)" if none were mentioned]
## Progress
### Done
- [x] [Completed tasks/changes]
### In Progress
- [ ] [Work that was started but not finished]
### Blocked
- [Issues preventing progress, if any]
## Key Decisions
- **[Decision]**: [Brief rationale]
## Next Steps
1. [What should happen next to continue this work]
Keep each section concise. Preserve exact file paths, function names, and error messages.`;
/**
* Generate a summary of abandoned branch entries.
*
* @param entries - Session entries to summarize (chronological order)
* @param options - Generation options
*/
export async function generateBranchSummary(
entries: SessionEntry[],
options: GenerateBranchSummaryOptions,
): Promise<BranchSummaryResult> {
const { model, apiKey, signal, customInstructions, reserveTokens = 16384 } = options;
// Token budget = context window minus reserved space for prompt + response
const contextWindow = model.contextWindow || 128000;
const tokenBudget = contextWindow - reserveTokens;
const { messages, fileOps } = prepareBranchEntries(entries, tokenBudget);
if (messages.length === 0) {
return { summary: "No content to summarize" };
}
// Transform to LLM-compatible messages, then serialize to text
// Serialization prevents the model from treating it as a conversation to continue
const llmMessages = convertToLlm(messages);
const conversationText = serializeConversation(llmMessages);
// Build prompt
const instructions = customInstructions || BRANCH_SUMMARY_PROMPT;
const promptText = `<conversation>\n${conversationText}\n</conversation>\n\n${instructions}`;
const summarizationMessages = [
{
role: "user" as const,
content: [{ type: "text" as const, text: promptText }],
timestamp: Date.now(),
},
];
// Call LLM for summarization
const response = await completeSimple(
model,
{ systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, messages: summarizationMessages },
{ apiKey, signal, maxTokens: 2048 },
);
// Check if aborted or errored
if (response.stopReason === "aborted") {
return { aborted: true };
}
if (response.stopReason === "error") {
return { error: response.errorMessage || "Summarization failed" };
}
let summary = response.content
.filter((c): c is { type: "text"; text: string } => c.type === "text")
.map((c) => c.text)
.join("\n");
// Prepend preamble to provide context about the branch summary
summary = BRANCH_SUMMARY_PREAMBLE + summary;
// Compute file lists and append to summary
const { readFiles, modifiedFiles } = computeFileLists(fileOps);
summary += formatFileOperations(readFiles, modifiedFiles);
return {
summary: summary || "No summary generated",
readFiles,
modifiedFiles,
};
}

View file

@ -0,0 +1,742 @@
/**
* Context compaction for long sessions.
*
* Pure functions for compaction logic. The session manager handles I/O,
* and after compaction the session is reloaded.
*/
import type { AgentMessage } from "@mariozechner/pi-agent-core";
import type { AssistantMessage, Model, Usage } from "@mariozechner/pi-ai";
import { complete, completeSimple } from "@mariozechner/pi-ai";
import { convertToLlm, createBranchSummaryMessage, createHookMessage } from "../messages.js";
import type { CompactionEntry, SessionEntry } from "../session-manager.js";
import {
computeFileLists,
createFileOps,
extractFileOpsFromMessage,
type FileOperations,
formatFileOperations,
SUMMARIZATION_SYSTEM_PROMPT,
serializeConversation,
} from "./utils.js";
// ============================================================================
// File Operation Tracking
// ============================================================================
/** Details stored in CompactionEntry.details for file tracking */
export interface CompactionDetails {
readFiles: string[];
modifiedFiles: string[];
}
/**
* Extract file operations from messages and previous compaction entries.
*/
function extractFileOperations(
messages: AgentMessage[],
entries: SessionEntry[],
prevCompactionIndex: number,
): FileOperations {
const fileOps = createFileOps();
// Collect from previous compaction's details (if pi-generated)
if (prevCompactionIndex >= 0) {
const prevCompaction = entries[prevCompactionIndex] as CompactionEntry;
if (!prevCompaction.fromHook && prevCompaction.details) {
const details = prevCompaction.details as CompactionDetails;
if (Array.isArray(details.readFiles)) {
for (const f of details.readFiles) fileOps.read.add(f);
}
if (Array.isArray(details.modifiedFiles)) {
for (const f of details.modifiedFiles) fileOps.edited.add(f);
}
}
}
// Extract from tool calls in messages
for (const msg of messages) {
extractFileOpsFromMessage(msg, fileOps);
}
return fileOps;
}
// ============================================================================
// Message Extraction
// ============================================================================
/**
* Extract AgentMessage from an entry if it produces one.
* Returns undefined for entries that don't contribute to LLM context.
*/
function getMessageFromEntry(entry: SessionEntry): AgentMessage | undefined {
if (entry.type === "message") {
return entry.message;
}
if (entry.type === "custom_message") {
return createHookMessage(entry.customType, entry.content, entry.display, entry.details, entry.timestamp);
}
if (entry.type === "branch_summary") {
return createBranchSummaryMessage(entry.summary, entry.fromId, entry.timestamp);
}
return undefined;
}
/** Result from compact() - SessionManager adds uuid/parentUuid when saving */
export interface CompactionResult<T = unknown> {
summary: string;
firstKeptEntryId: string;
tokensBefore: number;
/** Hook-specific data (e.g., ArtifactIndex, version markers for structured compaction) */
details?: T;
}
// ============================================================================
// Types
// ============================================================================
export interface CompactionSettings {
enabled: boolean;
reserveTokens: number;
keepRecentTokens: number;
}
export const DEFAULT_COMPACTION_SETTINGS: CompactionSettings = {
enabled: true,
reserveTokens: 16384,
keepRecentTokens: 20000,
};
// ============================================================================
// Token calculation
// ============================================================================
/**
* Calculate total context tokens from usage.
* Uses the native totalTokens field when available, falls back to computing from components.
*/
export function calculateContextTokens(usage: Usage): number {
return usage.totalTokens || usage.input + usage.output + usage.cacheRead + usage.cacheWrite;
}
/**
* Get usage from an assistant message if available.
* Skips aborted and error messages as they don't have valid usage data.
*/
function getAssistantUsage(msg: AgentMessage): Usage | undefined {
if (msg.role === "assistant" && "usage" in msg) {
const assistantMsg = msg as AssistantMessage;
if (assistantMsg.stopReason !== "aborted" && assistantMsg.stopReason !== "error" && assistantMsg.usage) {
return assistantMsg.usage;
}
}
return undefined;
}
/**
* Find the last non-aborted assistant message usage from session entries.
*/
export function getLastAssistantUsage(entries: SessionEntry[]): Usage | undefined {
for (let i = entries.length - 1; i >= 0; i--) {
const entry = entries[i];
if (entry.type === "message") {
const usage = getAssistantUsage(entry.message);
if (usage) return usage;
}
}
return undefined;
}
/**
* Check if compaction should trigger based on context usage.
*/
export function shouldCompact(contextTokens: number, contextWindow: number, settings: CompactionSettings): boolean {
if (!settings.enabled) return false;
return contextTokens > contextWindow - settings.reserveTokens;
}
// ============================================================================
// Cut point detection
// ============================================================================
/**
* Estimate token count for a message using chars/4 heuristic.
* This is conservative (overestimates tokens).
*/
export function estimateTokens(message: AgentMessage): number {
let chars = 0;
switch (message.role) {
case "user": {
const content = (message as { content: string | Array<{ type: string; text?: string }> }).content;
if (typeof content === "string") {
chars = content.length;
} else if (Array.isArray(content)) {
for (const block of content) {
if (block.type === "text" && block.text) {
chars += block.text.length;
}
}
}
return Math.ceil(chars / 4);
}
case "assistant": {
const assistant = message as AssistantMessage;
for (const block of assistant.content) {
if (block.type === "text") {
chars += block.text.length;
} else if (block.type === "thinking") {
chars += block.thinking.length;
} else if (block.type === "toolCall") {
chars += block.name.length + JSON.stringify(block.arguments).length;
}
}
return Math.ceil(chars / 4);
}
case "hookMessage":
case "toolResult": {
if (typeof message.content === "string") {
chars = message.content.length;
} else {
for (const block of message.content) {
if (block.type === "text" && block.text) {
chars += block.text.length;
}
if (block.type === "image") {
chars += 4800; // Estimate images as 4000 chars, or 1200 tokens
}
}
}
return Math.ceil(chars / 4);
}
case "bashExecution": {
chars = message.command.length + message.output.length;
return Math.ceil(chars / 4);
}
case "branchSummary":
case "compactionSummary": {
chars = message.summary.length;
return Math.ceil(chars / 4);
}
}
return 0;
}
/**
* Find valid cut points: indices of user, assistant, custom, or bashExecution messages.
* Never cut at tool results (they must follow their tool call).
* When we cut at an assistant message with tool calls, its tool results follow it
* and will be kept.
* BashExecutionMessage is treated like a user message (user-initiated context).
*/
function findValidCutPoints(entries: SessionEntry[], startIndex: number, endIndex: number): number[] {
const cutPoints: number[] = [];
for (let i = startIndex; i < endIndex; i++) {
const entry = entries[i];
switch (entry.type) {
case "message": {
const role = entry.message.role;
switch (role) {
case "bashExecution":
case "hookMessage":
case "branchSummary":
case "compactionSummary":
case "user":
case "assistant":
cutPoints.push(i);
break;
case "toolResult":
break;
}
break;
}
case "thinking_level_change":
case "model_change":
case "compaction":
case "branch_summary":
case "custom":
case "custom_message":
case "label":
}
// branch_summary and custom_message are user-role messages, valid cut points
if (entry.type === "branch_summary" || entry.type === "custom_message") {
cutPoints.push(i);
}
}
return cutPoints;
}
/**
* Find the user message (or bashExecution) that starts the turn containing the given entry index.
* Returns -1 if no turn start found before the index.
* BashExecutionMessage is treated like a user message for turn boundaries.
*/
export function findTurnStartIndex(entries: SessionEntry[], entryIndex: number, startIndex: number): number {
for (let i = entryIndex; i >= startIndex; i--) {
const entry = entries[i];
// branch_summary and custom_message are user-role messages, can start a turn
if (entry.type === "branch_summary" || entry.type === "custom_message") {
return i;
}
if (entry.type === "message") {
const role = entry.message.role;
if (role === "user" || role === "bashExecution") {
return i;
}
}
}
return -1;
}
export interface CutPointResult {
/** Index of first entry to keep */
firstKeptEntryIndex: number;
/** Index of user message that starts the turn being split, or -1 if not splitting */
turnStartIndex: number;
/** Whether this cut splits a turn (cut point is not a user message) */
isSplitTurn: boolean;
}
/**
* Find the cut point in session entries that keeps approximately `keepRecentTokens`.
*
* Algorithm: Walk backwards from newest, accumulating estimated message sizes.
* Stop when we've accumulated >= keepRecentTokens. Cut at that point.
*
* Can cut at user OR assistant messages (never tool results). When cutting at an
* assistant message with tool calls, its tool results come after and will be kept.
*
* Returns CutPointResult with:
* - firstKeptEntryIndex: the entry index to start keeping from
* - turnStartIndex: if cutting mid-turn, the user message that started that turn
* - isSplitTurn: whether we're cutting in the middle of a turn
*
* Only considers entries between `startIndex` and `endIndex` (exclusive).
*/
export function findCutPoint(
entries: SessionEntry[],
startIndex: number,
endIndex: number,
keepRecentTokens: number,
): CutPointResult {
const cutPoints = findValidCutPoints(entries, startIndex, endIndex);
if (cutPoints.length === 0) {
return { firstKeptEntryIndex: startIndex, turnStartIndex: -1, isSplitTurn: false };
}
// Walk backwards from newest, accumulating estimated message sizes
let accumulatedTokens = 0;
let cutIndex = cutPoints[0]; // Default: keep from first message (not header)
for (let i = endIndex - 1; i >= startIndex; i--) {
const entry = entries[i];
if (entry.type !== "message") continue;
// Estimate this message's size
const messageTokens = estimateTokens(entry.message);
accumulatedTokens += messageTokens;
// Check if we've exceeded the budget
if (accumulatedTokens >= keepRecentTokens) {
// Find the closest valid cut point at or after this entry
for (let c = 0; c < cutPoints.length; c++) {
if (cutPoints[c] >= i) {
cutIndex = cutPoints[c];
break;
}
}
break;
}
}
// Scan backwards from cutIndex to include any non-message entries (bash, settings, etc.)
while (cutIndex > startIndex) {
const prevEntry = entries[cutIndex - 1];
// Stop at session header or compaction boundaries
if (prevEntry.type === "compaction") {
break;
}
if (prevEntry.type === "message") {
// Stop if we hit any message
break;
}
// Include this non-message entry (bash, settings change, etc.)
cutIndex--;
}
// Determine if this is a split turn
const cutEntry = entries[cutIndex];
const isUserMessage = cutEntry.type === "message" && cutEntry.message.role === "user";
const turnStartIndex = isUserMessage ? -1 : findTurnStartIndex(entries, cutIndex, startIndex);
return {
firstKeptEntryIndex: cutIndex,
turnStartIndex,
isSplitTurn: !isUserMessage && turnStartIndex !== -1,
};
}
// ============================================================================
// Summarization
// ============================================================================
const SUMMARIZATION_PROMPT = `The messages above are a conversation to summarize. Create a structured context checkpoint summary that another LLM will use to continue the work.
Use this EXACT format:
## Goal
[What is the user trying to accomplish? Can be multiple items if the session covers different tasks.]
## Constraints & Preferences
- [Any constraints, preferences, or requirements mentioned by user]
- [Or "(none)" if none were mentioned]
## Progress
### Done
- [x] [Completed tasks/changes]
### In Progress
- [ ] [Current work]
### Blocked
- [Issues preventing progress, if any]
## Key Decisions
- **[Decision]**: [Brief rationale]
## Next Steps
1. [Ordered list of what should happen next]
## Critical Context
- [Any data, examples, or references needed to continue]
- [Or "(none)" if not applicable]
Keep each section concise. Preserve exact file paths, function names, and error messages.`;
const UPDATE_SUMMARIZATION_PROMPT = `The messages above are NEW conversation messages to incorporate into the existing summary provided in <previous-summary> tags.
Update the existing structured summary with new information. RULES:
- PRESERVE all existing information from the previous summary
- ADD new progress, decisions, and context from the new messages
- UPDATE the Progress section: move items from "In Progress" to "Done" when completed
- UPDATE "Next Steps" based on what was accomplished
- PRESERVE exact file paths, function names, and error messages
- If something is no longer relevant, you may remove it
Use this EXACT format:
## Goal
[Preserve existing goals, add new ones if the task expanded]
## Constraints & Preferences
- [Preserve existing, add new ones discovered]
## Progress
### Done
- [x] [Include previously done items AND newly completed items]
### In Progress
- [ ] [Current work - update based on progress]
### Blocked
- [Current blockers - remove if resolved]
## Key Decisions
- **[Decision]**: [Brief rationale] (preserve all previous, add new)
## Next Steps
1. [Update based on current state]
## Critical Context
- [Preserve important context, add new if needed]
Keep each section concise. Preserve exact file paths, function names, and error messages.`;
/**
* Generate a summary of the conversation using the LLM.
* If previousSummary is provided, uses the update prompt to merge.
*/
export async function generateSummary(
currentMessages: AgentMessage[],
model: Model<any>,
reserveTokens: number,
apiKey: string,
signal?: AbortSignal,
customInstructions?: string,
previousSummary?: string,
): Promise<string> {
const maxTokens = Math.floor(0.8 * reserveTokens);
// Use update prompt if we have a previous summary, otherwise initial prompt
let basePrompt = previousSummary ? UPDATE_SUMMARIZATION_PROMPT : SUMMARIZATION_PROMPT;
if (customInstructions) {
basePrompt = `${basePrompt}\n\nAdditional focus: ${customInstructions}`;
}
// Serialize conversation to text so model doesn't try to continue it
// Convert to LLM messages first (handles custom types like bashExecution, hookMessage, etc.)
const llmMessages = convertToLlm(currentMessages);
const conversationText = serializeConversation(llmMessages);
// Build the prompt with conversation wrapped in tags
let promptText = `<conversation>\n${conversationText}\n</conversation>\n\n`;
if (previousSummary) {
promptText += `<previous-summary>\n${previousSummary}\n</previous-summary>\n\n`;
}
promptText += basePrompt;
const summarizationMessages = [
{
role: "user" as const,
content: [{ type: "text" as const, text: promptText }],
timestamp: Date.now(),
},
];
const response = await completeSimple(
model,
{ systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, messages: summarizationMessages },
{ maxTokens, signal, apiKey, reasoning: "high" },
);
if (response.stopReason === "error") {
throw new Error(`Summarization failed: ${response.errorMessage || "Unknown error"}`);
}
const textContent = response.content
.filter((c): c is { type: "text"; text: string } => c.type === "text")
.map((c) => c.text)
.join("\n");
return textContent;
}
// ============================================================================
// Compaction Preparation (for hooks)
// ============================================================================
export interface CompactionPreparation {
/** UUID of first entry to keep */
firstKeptEntryId: string;
/** Messages that will be summarized and discarded */
messagesToSummarize: AgentMessage[];
/** Messages that will be turned into turn prefix summary (if splitting) */
turnPrefixMessages: AgentMessage[];
/** Whether this is a split turn (cut point in middle of turn) */
isSplitTurn: boolean;
tokensBefore: number;
/** Summary from previous compaction, for iterative update */
previousSummary?: string;
/** File operations extracted from messagesToSummarize */
fileOps: FileOperations;
/** Compaction settions from settings.jsonl */
settings: CompactionSettings;
}
export function prepareCompaction(
pathEntries: SessionEntry[],
settings: CompactionSettings,
): CompactionPreparation | undefined {
if (pathEntries.length > 0 && pathEntries[pathEntries.length - 1].type === "compaction") {
return undefined;
}
let prevCompactionIndex = -1;
for (let i = pathEntries.length - 1; i >= 0; i--) {
if (pathEntries[i].type === "compaction") {
prevCompactionIndex = i;
break;
}
}
const boundaryStart = prevCompactionIndex + 1;
const boundaryEnd = pathEntries.length;
const lastUsage = getLastAssistantUsage(pathEntries);
const tokensBefore = lastUsage ? calculateContextTokens(lastUsage) : 0;
const cutPoint = findCutPoint(pathEntries, boundaryStart, boundaryEnd, settings.keepRecentTokens);
// Get UUID of first kept entry
const firstKeptEntry = pathEntries[cutPoint.firstKeptEntryIndex];
if (!firstKeptEntry?.id) {
return undefined; // Session needs migration
}
const firstKeptEntryId = firstKeptEntry.id;
const historyEnd = cutPoint.isSplitTurn ? cutPoint.turnStartIndex : cutPoint.firstKeptEntryIndex;
// Messages to summarize (will be discarded after summary)
const messagesToSummarize: AgentMessage[] = [];
for (let i = boundaryStart; i < historyEnd; i++) {
const msg = getMessageFromEntry(pathEntries[i]);
if (msg) messagesToSummarize.push(msg);
}
// Messages for turn prefix summary (if splitting a turn)
const turnPrefixMessages: AgentMessage[] = [];
if (cutPoint.isSplitTurn) {
for (let i = cutPoint.turnStartIndex; i < cutPoint.firstKeptEntryIndex; i++) {
const msg = getMessageFromEntry(pathEntries[i]);
if (msg) turnPrefixMessages.push(msg);
}
}
// Get previous summary for iterative update
let previousSummary: string | undefined;
if (prevCompactionIndex >= 0) {
const prevCompaction = pathEntries[prevCompactionIndex] as CompactionEntry;
previousSummary = prevCompaction.summary;
}
// Extract file operations from messages and previous compaction
const fileOps = extractFileOperations(messagesToSummarize, pathEntries, prevCompactionIndex);
// Also extract file ops from turn prefix if splitting
if (cutPoint.isSplitTurn) {
for (const msg of turnPrefixMessages) {
extractFileOpsFromMessage(msg, fileOps);
}
}
return {
firstKeptEntryId,
messagesToSummarize,
turnPrefixMessages,
isSplitTurn: cutPoint.isSplitTurn,
tokensBefore,
previousSummary,
fileOps,
settings,
};
}
// ============================================================================
// Main compaction function
// ============================================================================
const TURN_PREFIX_SUMMARIZATION_PROMPT = `This is the PREFIX of a turn that was too large to keep. The SUFFIX (recent work) is retained.
Summarize the prefix to provide context for the retained suffix:
## Original Request
[What did the user ask for in this turn?]
## Early Progress
- [Key decisions and work done in the prefix]
## Context for Suffix
- [Information needed to understand the retained recent work]
Be concise. Focus on what's needed to understand the kept suffix.`;
/**
* Generate summaries for compaction using prepared data.
* Returns CompactionResult - SessionManager adds uuid/parentUuid when saving.
*
* @param preparation - Pre-calculated preparation from prepareCompaction()
* @param customInstructions - Optional custom focus for the summary
*/
export async function compact(
preparation: CompactionPreparation,
model: Model<any>,
apiKey: string,
customInstructions?: string,
signal?: AbortSignal,
): Promise<CompactionResult> {
const {
firstKeptEntryId,
messagesToSummarize,
turnPrefixMessages,
isSplitTurn,
tokensBefore,
previousSummary,
fileOps,
settings,
} = preparation;
// Generate summaries (can be parallel if both needed) and merge into one
let summary: string;
if (isSplitTurn && turnPrefixMessages.length > 0) {
// Generate both summaries in parallel
const [historyResult, turnPrefixResult] = await Promise.all([
messagesToSummarize.length > 0
? generateSummary(
messagesToSummarize,
model,
settings.reserveTokens,
apiKey,
signal,
customInstructions,
previousSummary,
)
: Promise.resolve("No prior history."),
generateTurnPrefixSummary(turnPrefixMessages, model, settings.reserveTokens, apiKey, signal),
]);
// Merge into single summary
summary = `${historyResult}\n\n---\n\n**Turn Context (split turn):**\n\n${turnPrefixResult}`;
} else {
// Just generate history summary
summary = await generateSummary(
messagesToSummarize,
model,
settings.reserveTokens,
apiKey,
signal,
customInstructions,
previousSummary,
);
}
// Compute file lists and append to summary
const { readFiles, modifiedFiles } = computeFileLists(fileOps);
summary += formatFileOperations(readFiles, modifiedFiles);
if (!firstKeptEntryId) {
throw new Error("First kept entry has no UUID - session may need migration");
}
return {
summary,
firstKeptEntryId,
tokensBefore,
details: { readFiles, modifiedFiles } as CompactionDetails,
};
}
/**
* Generate a summary for a turn prefix (when splitting a turn).
*/
async function generateTurnPrefixSummary(
messages: AgentMessage[],
model: Model<any>,
reserveTokens: number,
apiKey: string,
signal?: AbortSignal,
): Promise<string> {
const maxTokens = Math.floor(0.5 * reserveTokens); // Smaller budget for turn prefix
const transformedMessages = convertToLlm(messages);
const summarizationMessages = [
...transformedMessages,
{
role: "user" as const,
content: [{ type: "text" as const, text: TURN_PREFIX_SUMMARIZATION_PROMPT }],
timestamp: Date.now(),
},
];
const response = await complete(model, { messages: summarizationMessages }, { maxTokens, signal, apiKey });
if (response.stopReason === "error") {
throw new Error(`Turn prefix summarization failed: ${response.errorMessage || "Unknown error"}`);
}
return response.content
.filter((c): c is { type: "text"; text: string } => c.type === "text")
.map((c) => c.text)
.join("\n");
}

View file

@ -0,0 +1,7 @@
/**
* Compaction and summarization utilities.
*/
export * from "./branch-summarization.js";
export * from "./compaction.js";
export * from "./utils.js";

View file

@ -0,0 +1,154 @@
/**
* Shared utilities for compaction and branch summarization.
*/
import type { AgentMessage } from "@mariozechner/pi-agent-core";
import type { Message } from "@mariozechner/pi-ai";
// ============================================================================
// File Operation Tracking
// ============================================================================
export interface FileOperations {
read: Set<string>;
written: Set<string>;
edited: Set<string>;
}
export function createFileOps(): FileOperations {
return {
read: new Set(),
written: new Set(),
edited: new Set(),
};
}
/**
* Extract file operations from tool calls in an assistant message.
*/
export function extractFileOpsFromMessage(message: AgentMessage, fileOps: FileOperations): void {
if (message.role !== "assistant") return;
if (!("content" in message) || !Array.isArray(message.content)) return;
for (const block of message.content) {
if (typeof block !== "object" || block === null) continue;
if (!("type" in block) || block.type !== "toolCall") continue;
if (!("arguments" in block) || !("name" in block)) continue;
const args = block.arguments as Record<string, unknown> | undefined;
if (!args) continue;
const path = typeof args.path === "string" ? args.path : undefined;
if (!path) continue;
switch (block.name) {
case "read":
fileOps.read.add(path);
break;
case "write":
fileOps.written.add(path);
break;
case "edit":
fileOps.edited.add(path);
break;
}
}
}
/**
* Compute final file lists from file operations.
* Returns readFiles (files only read, not modified) and modifiedFiles.
*/
export function computeFileLists(fileOps: FileOperations): { readFiles: string[]; modifiedFiles: string[] } {
const modified = new Set([...fileOps.edited, ...fileOps.written]);
const readOnly = [...fileOps.read].filter((f) => !modified.has(f)).sort();
const modifiedFiles = [...modified].sort();
return { readFiles: readOnly, modifiedFiles };
}
/**
* Format file operations as XML tags for summary.
*/
export function formatFileOperations(readFiles: string[], modifiedFiles: string[]): string {
const sections: string[] = [];
if (readFiles.length > 0) {
sections.push(`<read-files>\n${readFiles.join("\n")}\n</read-files>`);
}
if (modifiedFiles.length > 0) {
sections.push(`<modified-files>\n${modifiedFiles.join("\n")}\n</modified-files>`);
}
if (sections.length === 0) return "";
return `\n\n${sections.join("\n\n")}`;
}
// ============================================================================
// Message Serialization
// ============================================================================
/**
* Serialize LLM messages to text for summarization.
* This prevents the model from treating it as a conversation to continue.
* Call convertToLlm() first to handle custom message types.
*/
export function serializeConversation(messages: Message[]): string {
const parts: string[] = [];
for (const msg of messages) {
if (msg.role === "user") {
const content =
typeof msg.content === "string"
? msg.content
: msg.content
.filter((c): c is { type: "text"; text: string } => c.type === "text")
.map((c) => c.text)
.join("");
if (content) parts.push(`[User]: ${content}`);
} else if (msg.role === "assistant") {
const textParts: string[] = [];
const thinkingParts: string[] = [];
const toolCalls: string[] = [];
for (const block of msg.content) {
if (block.type === "text") {
textParts.push(block.text);
} else if (block.type === "thinking") {
thinkingParts.push(block.thinking);
} else if (block.type === "toolCall") {
const args = block.arguments as Record<string, unknown>;
const argsStr = Object.entries(args)
.map(([k, v]) => `${k}=${JSON.stringify(v)}`)
.join(", ");
toolCalls.push(`${block.name}(${argsStr})`);
}
}
if (thinkingParts.length > 0) {
parts.push(`[Assistant thinking]: ${thinkingParts.join("\n")}`);
}
if (textParts.length > 0) {
parts.push(`[Assistant]: ${textParts.join("\n")}`);
}
if (toolCalls.length > 0) {
parts.push(`[Assistant tool calls]: ${toolCalls.join("; ")}`);
}
} else if (msg.role === "toolResult") {
const content = msg.content
.filter((c): c is { type: "text"; text: string } => c.type === "text")
.map((c) => c.text)
.join("");
if (content) {
parts.push(`[Tool result]: ${content}`);
}
}
}
return parts.join("\n\n");
}
// ============================================================================
// Summarization System Prompt
// ============================================================================
export const SUMMARIZATION_SYSTEM_PROMPT = `You are a context summarization assistant. Your task is to read a conversation between a user and an AI coding assistant, then produce a structured summary following the exact format specified.
Do NOT continue the conversation. Do NOT respond to any questions in the conversation. ONLY output the structured summary.`;

View file

@ -4,14 +4,18 @@
export { discoverAndLoadCustomTools, loadCustomTools } from "./loader.js";
export type {
AgentToolResult,
AgentToolUpdateCallback,
CustomAgentTool,
CustomTool,
CustomToolAPI,
CustomToolContext,
CustomToolFactory,
CustomToolResult,
CustomToolSessionEvent,
CustomToolsLoadResult,
CustomToolUIContext,
ExecResult,
LoadedCustomTool,
RenderResultOptions,
SessionEvent,
ToolAPI,
ToolUIContext,
} from "./types.js";
export { wrapCustomTool, wrapCustomTools } from "./wrapper.js";

View file

@ -7,7 +7,6 @@
* for custom tools that depend on pi packages.
*/
import { spawn } from "node:child_process";
import * as fs from "node:fs";
import { createRequire } from "node:module";
import * as os from "node:os";
@ -15,15 +14,10 @@ import * as path from "node:path";
import { fileURLToPath } from "node:url";
import { createJiti } from "jiti";
import { getAgentDir, isBunBinary } from "../../config.js";
import type { ExecOptions } from "../exec.js";
import { execCommand } from "../exec.js";
import type { HookUIContext } from "../hooks/types.js";
import type {
CustomToolFactory,
CustomToolsLoadResult,
ExecOptions,
ExecResult,
LoadedCustomTool,
ToolAPI,
} from "./types.js";
import type { CustomToolAPI, CustomToolFactory, CustomToolsLoadResult, LoadedCustomTool } from "./types.js";
// Create require function to resolve module paths at runtime
const require = createRequire(import.meta.url);
@ -87,97 +81,18 @@ function resolveToolPath(toolPath: string, cwd: string): string {
return path.resolve(cwd, expanded);
}
/**
* Execute a command and return stdout/stderr/code.
* Supports cancellation via AbortSignal and timeout.
*/
async function execCommand(command: string, args: string[], cwd: string, options?: ExecOptions): Promise<ExecResult> {
return new Promise((resolve) => {
const proc = spawn(command, args, {
cwd,
shell: false,
stdio: ["ignore", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
let killed = false;
let timeoutId: NodeJS.Timeout | undefined;
const killProcess = () => {
if (!killed) {
killed = true;
proc.kill("SIGTERM");
// Force kill after 5 seconds if SIGTERM doesn't work
setTimeout(() => {
if (!proc.killed) {
proc.kill("SIGKILL");
}
}, 5000);
}
};
// Handle abort signal
if (options?.signal) {
if (options.signal.aborted) {
killProcess();
} else {
options.signal.addEventListener("abort", killProcess, { once: true });
}
}
// Handle timeout
if (options?.timeout && options.timeout > 0) {
timeoutId = setTimeout(() => {
killProcess();
}, options.timeout);
}
proc.stdout.on("data", (data) => {
stdout += data.toString();
});
proc.stderr.on("data", (data) => {
stderr += data.toString();
});
proc.on("close", (code) => {
if (timeoutId) clearTimeout(timeoutId);
if (options?.signal) {
options.signal.removeEventListener("abort", killProcess);
}
resolve({
stdout,
stderr,
code: code ?? 0,
killed,
});
});
proc.on("error", (err) => {
if (timeoutId) clearTimeout(timeoutId);
if (options?.signal) {
options.signal.removeEventListener("abort", killProcess);
}
resolve({
stdout,
stderr: stderr || err.message,
code: 1,
killed,
});
});
});
}
/**
* Create a no-op UI context for headless modes.
*/
function createNoOpUIContext(): HookUIContext {
return {
select: async () => null,
select: async () => undefined,
confirm: async () => false,
input: async () => null,
input: async () => undefined,
notify: () => {},
custom: async () => undefined as never,
setEditorText: () => {},
getEditorText: () => "",
};
}
@ -191,7 +106,7 @@ function createNoOpUIContext(): HookUIContext {
*/
async function loadToolWithBun(
resolvedPath: string,
sharedApi: ToolAPI,
sharedApi: CustomToolAPI,
): Promise<{ tools: LoadedCustomTool[] | null; error: string | null }> {
try {
// Try to import directly - will work for tools without @mariozechner/* imports
@ -236,7 +151,7 @@ async function loadToolWithBun(
async function loadTool(
toolPath: string,
cwd: string,
sharedApi: ToolAPI,
sharedApi: CustomToolAPI,
): Promise<{ tools: LoadedCustomTool[] | null; error: string | null }> {
const resolvedPath = resolveToolPath(toolPath, cwd);
@ -296,9 +211,10 @@ export async function loadCustomTools(
const seenNames = new Set<string>(builtInToolNames);
// Shared API object - all tools get the same instance
const sharedApi: ToolAPI = {
const sharedApi: CustomToolAPI = {
cwd,
exec: (command: string, args: string[], options?: ExecOptions) => execCommand(command, args, cwd, options),
exec: (command: string, args: string[], options?: ExecOptions) =>
execCommand(command, args, options?.cwd ?? cwd, options),
ui: createNoOpUIContext(),
hasUI: false,
};

View file

@ -5,56 +5,56 @@
* They can provide custom rendering for tool calls and results in the TUI.
*/
import type { AgentTool, AgentToolResult, AgentToolUpdateCallback } from "@mariozechner/pi-ai";
import type { AgentToolResult, AgentToolUpdateCallback } from "@mariozechner/pi-agent-core";
import type { Model } from "@mariozechner/pi-ai";
import type { Component } from "@mariozechner/pi-tui";
import type { Static, TSchema } from "@sinclair/typebox";
import type { Theme } from "../../modes/interactive/theme/theme.js";
import type { ExecOptions, ExecResult } from "../exec.js";
import type { HookUIContext } from "../hooks/types.js";
import type { SessionEntry } from "../session-manager.js";
import type { ModelRegistry } from "../model-registry.js";
import type { ReadonlySessionManager } from "../session-manager.js";
/** Alias for clarity */
export type ToolUIContext = HookUIContext;
export type CustomToolUIContext = HookUIContext;
/** Re-export for custom tools to use in execute signature */
export type { AgentToolUpdateCallback };
export type { AgentToolResult, AgentToolUpdateCallback };
export interface ExecResult {
stdout: string;
stderr: string;
code: number;
/** True if the process was killed due to signal or timeout */
killed?: boolean;
}
export interface ExecOptions {
/** AbortSignal to cancel the process */
signal?: AbortSignal;
/** Timeout in milliseconds */
timeout?: number;
}
// Re-export for backward compatibility
export type { ExecOptions, ExecResult } from "../exec.js";
/** API passed to custom tool factory (stable across session changes) */
export interface ToolAPI {
export interface CustomToolAPI {
/** Current working directory */
cwd: string;
/** Execute a command */
exec(command: string, args: string[], options?: ExecOptions): Promise<ExecResult>;
/** UI methods for user interaction (select, confirm, input, notify) */
ui: ToolUIContext;
/** UI methods for user interaction (select, confirm, input, notify, custom) */
ui: CustomToolUIContext;
/** Whether UI is available (false in print/RPC mode) */
hasUI: boolean;
}
/**
* Context passed to tool execute and onSession callbacks.
* Provides access to session state and model information.
*/
export interface CustomToolContext {
/** Session manager (read-only) */
sessionManager: ReadonlySessionManager;
/** Model registry - use for API key resolution and model retrieval */
modelRegistry: ModelRegistry;
/** Current model (may be undefined if no model is selected yet) */
model: Model<any> | undefined;
}
/** Session event passed to onSession callback */
export interface SessionEvent {
/** All session entries (including pre-compaction history) */
entries: SessionEntry[];
/** Current session file path, or null in --no-session mode */
sessionFile: string | null;
/** Previous session file path, or null for "start" and "new" */
previousSessionFile: string | null;
export interface CustomToolSessionEvent {
/** Reason for the session event */
reason: "start" | "switch" | "branch" | "new";
reason: "start" | "switch" | "branch" | "new" | "tree" | "shutdown";
/** Previous session file path, or undefined for "start", "new", and "shutdown" */
previousSessionFile: string | undefined;
}
/** Rendering options passed to renderResult */
@ -65,60 +65,89 @@ export interface RenderResultOptions {
isPartial: boolean;
}
export type CustomToolResult<TDetails = any> = AgentToolResult<TDetails>;
/**
* Custom tool with optional lifecycle and rendering methods.
* Custom tool definition.
*
* The execute signature inherited from AgentTool includes an optional onUpdate callback
* for streaming progress updates during long-running operations:
* - The callback emits partial results to subscribers (e.g. TUI/RPC), not to the LLM.
* - Partial updates should use the same TDetails type as the final result (use a union if needed).
* Custom tools are standalone - they don't extend AgentTool directly.
* When loaded, they are wrapped in an AgentTool for the agent to use.
*
* The execute callback receives a ToolContext with access to session state,
* model registry, and current model.
*
* @example
* ```typescript
* type Details =
* | { status: "running"; step: number; total: number }
* | { status: "done"; count: number };
* const factory: CustomToolFactory = (pi) => ({
* name: "my_tool",
* label: "My Tool",
* description: "Does something useful",
* parameters: Type.Object({ input: Type.String() }),
*
* async execute(toolCallId, params, signal, onUpdate) {
* const items = params.items || [];
* for (let i = 0; i < items.length; i++) {
* onUpdate?.({
* content: [{ type: "text", text: `Step ${i + 1}/${items.length}...` }],
* details: { status: "running", step: i + 1, total: items.length },
* });
* await processItem(items[i], signal);
* async execute(toolCallId, params, onUpdate, ctx, signal) {
* // Access session state via ctx.sessionManager
* // Access model registry via ctx.modelRegistry
* // Current model via ctx.model
* return { content: [{ type: "text", text: "Done" }] };
* },
*
* onSession(event, ctx) {
* if (event.reason === "shutdown") {
* // Cleanup
* }
* // Reconstruct state from ctx.sessionManager.getEntries()
* }
* return { content: [{ type: "text", text: "Done" }], details: { status: "done", count: items.length } };
* }
* });
* ```
*
* Progress updates are rendered via renderResult with isPartial: true.
*/
export interface CustomAgentTool<TParams extends TSchema = TSchema, TDetails = any>
extends AgentTool<TParams, TDetails> {
/** Called on session start/switch/branch/clear - use to reconstruct state from entries */
onSession?: (event: SessionEvent) => void | Promise<void>;
export interface CustomTool<TParams extends TSchema = TSchema, TDetails = any> {
/** Tool name (used in LLM tool calls) */
name: string;
/** Human-readable label for UI */
label: string;
/** Description for LLM */
description: string;
/** Parameter schema (TypeBox) */
parameters: TParams;
/**
* Execute the tool.
* @param toolCallId - Unique ID for this tool call
* @param params - Parsed parameters matching the schema
* @param onUpdate - Callback for streaming partial results (for UI, not LLM)
* @param ctx - Context with session manager, model registry, and current model
* @param signal - Optional abort signal for cancellation
*/
execute(
toolCallId: string,
params: Static<TParams>,
onUpdate: AgentToolUpdateCallback<TDetails> | undefined,
ctx: CustomToolContext,
signal?: AbortSignal,
): Promise<AgentToolResult<TDetails>>;
/** Called on session lifecycle events - use to reconstruct state or cleanup resources */
onSession?: (event: CustomToolSessionEvent, ctx: CustomToolContext) => void | Promise<void>;
/** Custom rendering for tool call display - return a Component */
renderCall?: (args: Static<TParams>, theme: Theme) => Component;
/** Custom rendering for tool result display - return a Component */
renderResult?: (result: AgentToolResult<TDetails>, options: RenderResultOptions, theme: Theme) => Component;
/** Called when session ends - cleanup resources */
dispose?: () => Promise<void> | void;
renderResult?: (result: CustomToolResult<TDetails>, options: RenderResultOptions, theme: Theme) => Component;
}
/** Factory function that creates a custom tool or array of tools */
export type CustomToolFactory = (
pi: ToolAPI,
) => CustomAgentTool<any> | CustomAgentTool[] | Promise<CustomAgentTool | CustomAgentTool[]>;
pi: CustomToolAPI,
) => CustomTool<any, any> | CustomTool<any, any>[] | Promise<CustomTool<any, any> | CustomTool<any, any>[]>;
/** Loaded custom tool with metadata */
/** Loaded custom tool with metadata and wrapped AgentTool */
export interface LoadedCustomTool {
/** Original path (as specified) */
path: string;
/** Resolved absolute path */
resolvedPath: string;
/** The tool instance */
tool: CustomAgentTool;
/** The original custom tool instance */
tool: CustomTool;
}
/** Result from loading custom tools */
@ -126,5 +155,5 @@ export interface CustomToolsLoadResult {
tools: LoadedCustomTool[];
errors: Array<{ path: string; error: string }>;
/** Update the UI context for all loaded tools. Call when mode initializes. */
setUIContext(uiContext: ToolUIContext, hasUI: boolean): void;
setUIContext(uiContext: CustomToolUIContext, hasUI: boolean): void;
}

View file

@ -0,0 +1,28 @@
/**
* Wraps CustomTool instances into AgentTool for use with the agent.
*/
import type { AgentTool } from "@mariozechner/pi-agent-core";
import type { CustomTool, CustomToolContext, LoadedCustomTool } from "./types.js";
/**
* Wrap a CustomTool into an AgentTool.
* The wrapper injects the ToolContext into execute calls.
*/
export function wrapCustomTool(tool: CustomTool, getContext: () => CustomToolContext): AgentTool {
return {
name: tool.name,
label: tool.label,
description: tool.description,
parameters: tool.parameters,
execute: (toolCallId, params, signal, onUpdate) =>
tool.execute(toolCallId, params, onUpdate, getContext(), signal),
};
}
/**
* Wrap all loaded custom tools into AgentTools.
*/
export function wrapCustomTools(loadedTools: LoadedCustomTool[], getContext: () => CustomToolContext): AgentTool[] {
return loadedTools.map((lt) => wrapCustomTool(lt.tool, getContext));
}

View file

@ -0,0 +1,104 @@
/**
* Shared command execution utilities for hooks and custom tools.
*/
import { spawn } from "node:child_process";
/**
* Options for executing shell commands.
*/
export interface ExecOptions {
/** AbortSignal to cancel the command */
signal?: AbortSignal;
/** Timeout in milliseconds */
timeout?: number;
/** Working directory */
cwd?: string;
}
/**
* Result of executing a shell command.
*/
export interface ExecResult {
stdout: string;
stderr: string;
code: number;
killed: boolean;
}
/**
* Execute a shell command and return stdout/stderr/code.
* Supports timeout and abort signal.
*/
export async function execCommand(
command: string,
args: string[],
cwd: string,
options?: ExecOptions,
): Promise<ExecResult> {
return new Promise((resolve) => {
const proc = spawn(command, args, {
cwd,
shell: false,
stdio: ["ignore", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
let killed = false;
let timeoutId: NodeJS.Timeout | undefined;
const killProcess = () => {
if (!killed) {
killed = true;
proc.kill("SIGTERM");
// Force kill after 5 seconds if SIGTERM doesn't work
setTimeout(() => {
if (!proc.killed) {
proc.kill("SIGKILL");
}
}, 5000);
}
};
// Handle abort signal
if (options?.signal) {
if (options.signal.aborted) {
killProcess();
} else {
options.signal.addEventListener("abort", killProcess, { once: true });
}
}
// Handle timeout
if (options?.timeout && options.timeout > 0) {
timeoutId = setTimeout(() => {
killProcess();
}, options.timeout);
}
proc.stdout?.on("data", (data) => {
stdout += data.toString();
});
proc.stderr?.on("data", (data) => {
stderr += data.toString();
});
proc.on("close", (code) => {
if (timeoutId) clearTimeout(timeoutId);
if (options?.signal) {
options.signal.removeEventListener("abort", killProcess);
}
resolve({ stdout, stderr, code: code ?? 0, killed });
});
proc.on("error", (_err) => {
if (timeoutId) clearTimeout(timeoutId);
if (options?.signal) {
options.signal.removeEventListener("abort", killProcess);
}
resolve({ stdout, stderr, code: 1, killed });
});
});
}

View file

@ -1,4 +1,4 @@
import type { AgentState } from "@mariozechner/pi-agent-core";
import type { AgentMessage, AgentState } from "@mariozechner/pi-agent-core";
import type { AssistantMessage, ImageContent, Message, ToolResultMessage, UserMessage } from "@mariozechner/pi-ai";
import { existsSync, readFileSync, writeFileSync } from "fs";
import hljs from "highlight.js";
@ -7,7 +7,6 @@ import { homedir } from "os";
import * as path from "path";
import { basename } from "path";
import { APP_NAME, getCustomThemesDir, getThemesDir, VERSION } from "../config.js";
import { type BashExecutionMessage, isBashExecutionMessage } from "./messages.js";
import type { SessionManager } from "./session-manager.js";
// ============================================================================
@ -122,7 +121,7 @@ function resolveColorValue(
}
/** Load theme JSON from built-in or custom themes directory. */
function loadThemeJson(name: string): ThemeJson | null {
function loadThemeJson(name: string): ThemeJson | undefined {
// Try built-in themes first
const themesDir = getThemesDir();
const builtinPath = path.join(themesDir, `${name}.json`);
@ -130,7 +129,7 @@ function loadThemeJson(name: string): ThemeJson | null {
try {
return JSON.parse(readFileSync(builtinPath, "utf-8")) as ThemeJson;
} catch {
return null;
return undefined;
}
}
@ -141,11 +140,11 @@ function loadThemeJson(name: string): ThemeJson | null {
try {
return JSON.parse(readFileSync(customPath, "utf-8")) as ThemeJson;
} catch {
return null;
return undefined;
}
}
return null;
return undefined;
}
/** Build complete theme colors object, resolving theme JSON values against defaults. */
@ -821,110 +820,138 @@ function formatToolExecution(
return { html, bgColor };
}
function formatMessage(message: Message, toolResultsMap: Map<string, ToolResultMessage>, colors: ThemeColors): string {
function formatMessage(
message: AgentMessage,
toolResultsMap: Map<string, ToolResultMessage>,
colors: ThemeColors,
): string {
let html = "";
const timestamp = (message as { timestamp?: number }).timestamp;
const timestampHtml = timestamp ? `<div class="message-timestamp">${formatTimestamp(timestamp)}</div>` : "";
// Handle bash execution messages (user-executed via ! command)
if (isBashExecutionMessage(message)) {
const bashMsg = message as unknown as BashExecutionMessage;
const isError = bashMsg.cancelled || (bashMsg.exitCode !== 0 && bashMsg.exitCode !== null);
switch (message.role) {
case "bashExecution": {
const isError =
message.cancelled ||
(message.exitCode !== 0 && message.exitCode !== null && message.exitCode !== undefined);
html += `<div class="tool-execution user-bash${isError ? " user-bash-error" : ""}">`;
html += timestampHtml;
html += `<div class="tool-command">$ ${escapeHtml(bashMsg.command)}</div>`;
html += `<div class="tool-execution user-bash${isError ? " user-bash-error" : ""}">`;
html += timestampHtml;
html += `<div class="tool-command">$ ${escapeHtml(message.command)}</div>`;
if (bashMsg.output) {
const lines = bashMsg.output.split("\n");
html += formatExpandableOutput(lines, 10);
if (message.output) {
const lines = message.output.split("\n");
html += formatExpandableOutput(lines, 10);
}
if (message.cancelled) {
html += `<div class="bash-status warning">(cancelled)</div>`;
} else if (message.exitCode !== 0 && message.exitCode !== null && message.exitCode !== undefined) {
html += `<div class="bash-status error">(exit ${message.exitCode})</div>`;
}
if (message.truncated && message.fullOutputPath) {
html += `<div class="bash-truncation warning">Output truncated. Full output: ${escapeHtml(message.fullOutputPath)}</div>`;
}
html += `</div>`;
break;
}
case "user": {
const userMsg = message as UserMessage;
let textContent = "";
const images: ImageContent[] = [];
if (bashMsg.cancelled) {
html += `<div class="bash-status warning">(cancelled)</div>`;
} else if (bashMsg.exitCode !== 0 && bashMsg.exitCode !== null) {
html += `<div class="bash-status error">(exit ${bashMsg.exitCode})</div>`;
}
if (bashMsg.truncated && bashMsg.fullOutputPath) {
html += `<div class="bash-truncation warning">Output truncated. Full output: ${escapeHtml(bashMsg.fullOutputPath)}</div>`;
}
html += `</div>`;
return html;
}
if (message.role === "user") {
const userMsg = message as UserMessage;
let textContent = "";
const images: ImageContent[] = [];
if (typeof userMsg.content === "string") {
textContent = userMsg.content;
} else {
for (const block of userMsg.content) {
if (block.type === "text") {
textContent += block.text;
} else if (block.type === "image") {
images.push(block as ImageContent);
if (typeof userMsg.content === "string") {
textContent = userMsg.content;
} else {
for (const block of userMsg.content) {
if (block.type === "text") {
textContent += block.text;
} else if (block.type === "image") {
images.push(block as ImageContent);
}
}
}
}
html += `<div class="user-message">${timestampHtml}`;
html += `<div class="user-message">${timestampHtml}`;
// Render images first
if (images.length > 0) {
html += `<div class="message-images">`;
for (const img of images) {
html += `<img src="data:${img.mimeType};base64,${img.data}" alt="User uploaded image" class="message-image" />`;
// Render images first
if (images.length > 0) {
html += `<div class="message-images">`;
for (const img of images) {
html += `<img src="data:${img.mimeType};base64,${img.data}" alt="User uploaded image" class="message-image" />`;
}
html += `</div>`;
}
// Render text as markdown (server-side)
if (textContent.trim()) {
html += `<div class="markdown-content">${renderMarkdown(textContent)}</div>`;
}
html += `</div>`;
break;
}
case "assistant": {
html += timestampHtml ? `<div class="assistant-message">${timestampHtml}` : "";
// Render text as markdown (server-side)
if (textContent.trim()) {
html += `<div class="markdown-content">${renderMarkdown(textContent)}</div>`;
}
html += `</div>`;
} else if (message.role === "assistant") {
const assistantMsg = message as AssistantMessage;
html += timestampHtml ? `<div class="assistant-message">${timestampHtml}` : "";
for (const content of assistantMsg.content) {
if (content.type === "text" && content.text.trim()) {
// Render markdown server-side
html += `<div class="assistant-text markdown-content">${renderMarkdown(content.text)}</div>`;
} else if (content.type === "thinking" && content.thinking.trim()) {
html += `<div class="thinking-text">${escapeHtml(content.thinking.trim()).replace(/\n/g, "<br>")}</div>`;
for (const content of message.content) {
if (content.type === "text" && content.text.trim()) {
// Render markdown server-side
html += `<div class="assistant-text markdown-content">${renderMarkdown(content.text)}</div>`;
} else if (content.type === "thinking" && content.thinking.trim()) {
html += `<div class="thinking-text">${escapeHtml(content.thinking.trim()).replace(/\n/g, "<br>")}</div>`;
}
}
}
for (const content of assistantMsg.content) {
if (content.type === "toolCall") {
const toolResult = toolResultsMap.get(content.id);
const { html: toolHtml, bgColor } = formatToolExecution(
content.name,
content.arguments as Record<string, unknown>,
toolResult,
colors,
);
html += `<div class="tool-execution" style="background-color: ${bgColor}">${toolHtml}</div>`;
for (const content of message.content) {
if (content.type === "toolCall") {
const toolResult = toolResultsMap.get(content.id);
const { html: toolHtml, bgColor } = formatToolExecution(
content.name,
content.arguments as Record<string, unknown>,
toolResult,
colors,
);
html += `<div class="tool-execution" style="background-color: ${bgColor}">${toolHtml}</div>`;
}
}
}
const hasToolCalls = assistantMsg.content.some((c) => c.type === "toolCall");
if (!hasToolCalls) {
if (assistantMsg.stopReason === "aborted") {
html += '<div class="error-text">Aborted</div>';
} else if (assistantMsg.stopReason === "error") {
html += `<div class="error-text">Error: ${escapeHtml(assistantMsg.errorMessage || "Unknown error")}</div>`;
const hasToolCalls = message.content.some((c) => c.type === "toolCall");
if (!hasToolCalls) {
if (message.stopReason === "aborted") {
html += '<div class="error-text">Aborted</div>';
} else if (message.stopReason === "error") {
html += `<div class="error-text">Error: ${escapeHtml(message.errorMessage || "Unknown error")}</div>`;
}
}
}
if (timestampHtml) {
html += "</div>";
if (timestampHtml) {
html += "</div>";
}
break;
}
case "toolResult":
// Tool results are rendered inline with tool calls
break;
case "hookMessage":
// Hook messages with display:true shown as info boxes
if (message.display) {
const content = typeof message.content === "string" ? message.content : JSON.stringify(message.content);
html += `<div class="hook-message">${timestampHtml}<div class="hook-type">[${escapeHtml(message.customType)}]</div><div class="markdown-content">${renderMarkdown(content)}</div></div>`;
}
break;
case "compactionSummary":
// Rendered separately via formatCompaction
break;
case "branchSummary":
// Rendered as compaction-like summary
html += `<div class="compaction-container expanded"><div class="compaction-content"><div class="compaction-summary"><div class="compaction-summary-header">Branch Summary</div><div class="compaction-summary-content">${escapeHtml(message.summary).replace(/\n/g, "<br>")}</div></div></div></div>`;
break;
default: {
// Exhaustive check
const _exhaustive: never = message;
}
}
@ -995,7 +1022,7 @@ function generateHtml(data: ParsedSessionData, filename: string, colors: ThemeCo
const lastModelInfo = lastProvider ? `${lastProvider}/${lastModel}` : lastModel;
const contextWindow = data.contextWindow || 0;
const contextPercent = contextWindow > 0 ? ((contextTokens / contextWindow) * 100).toFixed(1) : null;
const contextPercent = contextWindow > 0 ? ((contextTokens / contextWindow) * 100).toFixed(1) : undefined;
let messagesHtml = "";
for (const event of data.sessionEvents) {
@ -1343,6 +1370,9 @@ export function exportSessionToHtml(
const opts: ExportOptions = typeof options === "string" ? { outputPath: options } : options || {};
const sessionFile = sessionManager.getSessionFile();
if (!sessionFile) {
throw new Error("Cannot export in-memory session to HTML");
}
const content = readFileSync(sessionFile, "utf8");
const data = parseSessionFile(content);

View file

@ -1,39 +1,13 @@
export { discoverAndLoadHooks, type LoadedHook, type LoadHooksResult, loadHooks, type SendHandler } from "./loader.js";
export { type HookErrorListener, HookRunner } from "./runner.js";
export { wrapToolsWithHooks, wrapToolWithHooks } from "./tool-wrapper.js";
export type {
AgentEndEvent,
AgentStartEvent,
BashToolResultEvent,
CustomToolResultEvent,
EditToolResultEvent,
ExecResult,
FindToolResultEvent,
GrepToolResultEvent,
HookAPI,
HookError,
HookEvent,
HookEventContext,
HookFactory,
HookUIContext,
LsToolResultEvent,
ReadToolResultEvent,
SessionEvent,
SessionEventResult,
ToolCallEvent,
ToolCallEventResult,
ToolResultEvent,
ToolResultEventResult,
TurnEndEvent,
TurnStartEvent,
WriteToolResultEvent,
} from "./types.js";
// biome-ignore assist/source/organizeImports: biome is not smart
export {
isBashToolResult,
isEditToolResult,
isFindToolResult,
isGrepToolResult,
isLsToolResult,
isReadToolResult,
isWriteToolResult,
} from "./types.js";
discoverAndLoadHooks,
loadHooks,
type AppendEntryHandler,
type LoadedHook,
type LoadHooksResult,
type SendMessageHandler,
} from "./loader.js";
export { execCommand, HookRunner, type HookErrorListener } from "./runner.js";
export { wrapToolsWithHooks, wrapToolWithHooks } from "./tool-wrapper.js";
export type * from "./types.js";
export type { ReadonlySessionManager } from "../session-manager.js";

View file

@ -7,10 +7,11 @@ import { createRequire } from "node:module";
import * as os from "node:os";
import * as path from "node:path";
import { fileURLToPath } from "node:url";
import type { Attachment } from "@mariozechner/pi-agent-core";
import { createJiti } from "jiti";
import { getAgentDir } from "../../config.js";
import type { HookAPI, HookFactory } from "./types.js";
import type { HookMessage } from "../messages.js";
import { execCommand } from "./runner.js";
import type { ExecOptions, HookAPI, HookFactory, HookMessageRenderer, RegisteredCommand } from "./types.js";
// Create require function to resolve module paths at runtime
const require = createRequire(import.meta.url);
@ -47,9 +48,17 @@ function getAliases(): Record<string, string> {
type HandlerFn = (...args: unknown[]) => Promise<unknown>;
/**
* Send handler type for pi.send().
* Send message handler type for pi.sendMessage().
*/
export type SendHandler = (text: string, attachments?: Attachment[]) => void;
export type SendMessageHandler = <T = unknown>(
message: Pick<HookMessage<T>, "customType" | "content" | "display" | "details">,
triggerTurn?: boolean,
) => void;
/**
* Append entry handler type for pi.appendEntry().
*/
export type AppendEntryHandler = <T = unknown>(customType: string, data?: T) => void;
/**
* Registered handlers for a loaded hook.
@ -61,8 +70,14 @@ export interface LoadedHook {
resolvedPath: string;
/** Map of event type to handler functions */
handlers: Map<string, HandlerFn[]>;
/** Set the send handler for this hook's pi.send() */
setSendHandler: (handler: SendHandler) => void;
/** Map of customType to hook message renderer */
messageRenderers: Map<string, HookMessageRenderer>;
/** Map of command name to registered command */
commands: Map<string, RegisteredCommand>;
/** Set the send message handler for this hook's pi.sendMessage() */
setSendMessageHandler: (handler: SendMessageHandler) => void;
/** Set the append entry handler for this hook's pi.appendEntry() */
setAppendEntryHandler: (handler: AppendEntryHandler) => void;
}
/**
@ -110,32 +125,62 @@ function resolveHookPath(hookPath: string, cwd: string): string {
}
/**
* Create a HookAPI instance that collects handlers.
* Returns the API and a function to set the send handler later.
* Create a HookAPI instance that collects handlers, renderers, and commands.
* Returns the API, maps, and a function to set the send message handler later.
*/
function createHookAPI(handlers: Map<string, HandlerFn[]>): {
function createHookAPI(
handlers: Map<string, HandlerFn[]>,
cwd: string,
): {
api: HookAPI;
setSendHandler: (handler: SendHandler) => void;
messageRenderers: Map<string, HookMessageRenderer>;
commands: Map<string, RegisteredCommand>;
setSendMessageHandler: (handler: SendMessageHandler) => void;
setAppendEntryHandler: (handler: AppendEntryHandler) => void;
} {
let sendHandler: SendHandler = () => {
let sendMessageHandler: SendMessageHandler = () => {
// Default no-op until mode sets the handler
};
let appendEntryHandler: AppendEntryHandler = () => {
// Default no-op until mode sets the handler
};
const messageRenderers = new Map<string, HookMessageRenderer>();
const commands = new Map<string, RegisteredCommand>();
const api: HookAPI = {
// Cast to HookAPI - the implementation is more general (string event names)
// but the interface has specific overloads for type safety in hooks
const api = {
on(event: string, handler: HandlerFn): void {
const list = handlers.get(event) ?? [];
list.push(handler);
handlers.set(event, list);
},
send(text: string, attachments?: Attachment[]): void {
sendHandler(text, attachments);
sendMessage<T = unknown>(message: HookMessage<T>, triggerTurn?: boolean): void {
sendMessageHandler(message, triggerTurn);
},
appendEntry<T = unknown>(customType: string, data?: T): void {
appendEntryHandler(customType, data);
},
registerMessageRenderer<T = unknown>(customType: string, renderer: HookMessageRenderer<T>): void {
messageRenderers.set(customType, renderer as HookMessageRenderer);
},
registerCommand(name: string, options: { description?: string; handler: RegisteredCommand["handler"] }): void {
commands.set(name, { name, ...options });
},
exec(command: string, args: string[], options?: ExecOptions) {
return execCommand(command, args, options?.cwd ?? cwd, options);
},
} as HookAPI;
return {
api,
setSendHandler: (handler: SendHandler) => {
sendHandler = handler;
messageRenderers,
commands,
setSendMessageHandler: (handler: SendMessageHandler) => {
sendMessageHandler = handler;
},
setAppendEntryHandler: (handler: AppendEntryHandler) => {
appendEntryHandler = handler;
},
};
}
@ -164,13 +209,24 @@ async function loadHook(hookPath: string, cwd: string): Promise<{ hook: LoadedHo
// Create handlers map and API
const handlers = new Map<string, HandlerFn[]>();
const { api, setSendHandler } = createHookAPI(handlers);
const { api, messageRenderers, commands, setSendMessageHandler, setAppendEntryHandler } = createHookAPI(
handlers,
cwd,
);
// Call factory to register handlers
factory(api);
return {
hook: { path: hookPath, resolvedPath, handlers, setSendHandler },
hook: {
path: hookPath,
resolvedPath,
handlers,
messageRenderers,
commands,
setSendMessageHandler,
setAppendEntryHandler,
},
error: null,
};
} catch (err) {

View file

@ -2,120 +2,46 @@
* Hook runner - executes hooks and manages their lifecycle.
*/
import { spawn } from "node:child_process";
import type { LoadedHook, SendHandler } from "./loader.js";
import type { AgentMessage } from "@mariozechner/pi-agent-core";
import type { Model } from "@mariozechner/pi-ai";
import type { ModelRegistry } from "../model-registry.js";
import type { SessionManager } from "../session-manager.js";
import type { AppendEntryHandler, LoadedHook, SendMessageHandler } from "./loader.js";
import type {
ExecOptions,
ExecResult,
BeforeAgentStartEvent,
BeforeAgentStartEventResult,
ContextEvent,
ContextEventResult,
HookContext,
HookError,
HookEvent,
HookEventContext,
HookMessageRenderer,
HookUIContext,
SessionEvent,
SessionEventResult,
RegisteredCommand,
SessionBeforeCompactResult,
SessionBeforeTreeResult,
ToolCallEvent,
ToolCallEventResult,
ToolResultEventResult,
} from "./types.js";
/**
* Default timeout for hook execution (30 seconds).
*/
const DEFAULT_TIMEOUT = 30000;
/**
* Listener for hook errors.
*/
export type HookErrorListener = (error: HookError) => void;
/**
* Execute a command and return stdout/stderr/code.
* Supports cancellation via AbortSignal and timeout.
*/
async function exec(command: string, args: string[], cwd: string, options?: ExecOptions): Promise<ExecResult> {
return new Promise((resolve) => {
const proc = spawn(command, args, { cwd, shell: false });
let stdout = "";
let stderr = "";
let killed = false;
let timeoutId: NodeJS.Timeout | undefined;
const killProcess = () => {
if (!killed) {
killed = true;
proc.kill("SIGTERM");
// Force kill after 5 seconds if SIGTERM doesn't work
setTimeout(() => {
if (!proc.killed) {
proc.kill("SIGKILL");
}
}, 5000);
}
};
// Handle abort signal
if (options?.signal) {
if (options.signal.aborted) {
killProcess();
} else {
options.signal.addEventListener("abort", killProcess, { once: true });
}
}
// Handle timeout
if (options?.timeout && options.timeout > 0) {
timeoutId = setTimeout(() => {
killProcess();
}, options.timeout);
}
proc.stdout?.on("data", (data) => {
stdout += data.toString();
});
proc.stderr?.on("data", (data) => {
stderr += data.toString();
});
proc.on("close", (code) => {
if (timeoutId) clearTimeout(timeoutId);
if (options?.signal) {
options.signal.removeEventListener("abort", killProcess);
}
resolve({ stdout, stderr, code: code ?? 0, killed });
});
proc.on("error", (_err) => {
if (timeoutId) clearTimeout(timeoutId);
if (options?.signal) {
options.signal.removeEventListener("abort", killProcess);
}
resolve({ stdout, stderr, code: 1, killed });
});
});
}
/**
* Create a promise that rejects after a timeout.
*/
function createTimeout(ms: number): { promise: Promise<never>; clear: () => void } {
let timeoutId: NodeJS.Timeout;
const promise = new Promise<never>((_, reject) => {
timeoutId = setTimeout(() => reject(new Error(`Hook timed out after ${ms}ms`)), ms);
});
return {
promise,
clear: () => clearTimeout(timeoutId),
};
}
// Re-export execCommand for backward compatibility
export { execCommand } from "../exec.js";
/** No-op UI context used when no UI is available */
const noOpUIContext: HookUIContext = {
select: async () => null,
select: async () => undefined,
confirm: async () => false,
input: async () => null,
input: async () => undefined,
notify: () => {},
custom: async () => undefined as never,
setEditorText: () => {},
getEditorText: () => "",
};
/**
@ -126,26 +52,57 @@ export class HookRunner {
private uiContext: HookUIContext;
private hasUI: boolean;
private cwd: string;
private sessionFile: string | null;
private timeout: number;
private sessionManager: SessionManager;
private modelRegistry: ModelRegistry;
private errorListeners: Set<HookErrorListener> = new Set();
private getModel: () => Model<any> | undefined = () => undefined;
constructor(hooks: LoadedHook[], cwd: string, timeout: number = DEFAULT_TIMEOUT) {
constructor(hooks: LoadedHook[], cwd: string, sessionManager: SessionManager, modelRegistry: ModelRegistry) {
this.hooks = hooks;
this.uiContext = noOpUIContext;
this.hasUI = false;
this.cwd = cwd;
this.sessionFile = null;
this.timeout = timeout;
this.sessionManager = sessionManager;
this.modelRegistry = modelRegistry;
}
/**
* Set the UI context for hooks.
* Call this when the mode initializes and UI is available.
* Initialize HookRunner with all required context.
* Modes call this once the agent session is fully set up.
*/
setUIContext(uiContext: HookUIContext, hasUI: boolean): void {
this.uiContext = uiContext;
this.hasUI = hasUI;
initialize(options: {
/** Function to get the current model */
getModel: () => Model<any> | undefined;
/** Handler for hooks to send messages */
sendMessageHandler: SendMessageHandler;
/** Handler for hooks to append entries */
appendEntryHandler: AppendEntryHandler;
/** UI context for interactive prompts */
uiContext?: HookUIContext;
/** Whether UI is available */
hasUI?: boolean;
}): void {
this.getModel = options.getModel;
for (const hook of this.hooks) {
hook.setSendMessageHandler(options.sendMessageHandler);
hook.setAppendEntryHandler(options.appendEntryHandler);
}
this.uiContext = options.uiContext ?? noOpUIContext;
this.hasUI = options.hasUI ?? false;
}
/**
* Get the UI context (set by mode).
*/
getUIContext(): HookUIContext | null {
return this.uiContext;
}
/**
* Get whether UI is available.
*/
getHasUI(): boolean {
return this.hasUI;
}
/**
@ -155,23 +112,6 @@ export class HookRunner {
return this.hooks.map((h) => h.path);
}
/**
* Set the session file path.
*/
setSessionFile(sessionFile: string | null): void {
this.sessionFile = sessionFile;
}
/**
* Set the send handler for all hooks' pi.send().
* Call this when the mode initializes.
*/
setSendHandler(handler: SendHandler): void {
for (const hook of this.hooks) {
hook.setSendHandler(handler);
}
}
/**
* Subscribe to hook errors.
* @returns Unsubscribe function
@ -184,7 +124,10 @@ export class HookRunner {
/**
* Emit an error to all listeners.
*/
private emitError(error: HookError): void {
/**
* Emit an error to all error listeners.
*/
emitError(error: HookError): void {
for (const listener of this.errorListeners) {
listener(error);
}
@ -203,26 +146,90 @@ export class HookRunner {
return false;
}
/**
* Get a message renderer for the given customType.
* Returns the first renderer found across all hooks, or undefined if none.
*/
getMessageRenderer(customType: string): HookMessageRenderer | undefined {
for (const hook of this.hooks) {
const renderer = hook.messageRenderers.get(customType);
if (renderer) {
return renderer;
}
}
return undefined;
}
/**
* Get all registered commands from all hooks.
*/
getRegisteredCommands(): RegisteredCommand[] {
const commands: RegisteredCommand[] = [];
for (const hook of this.hooks) {
for (const command of hook.commands.values()) {
commands.push(command);
}
}
return commands;
}
/**
* Get a registered command by name.
* Returns the first command found across all hooks, or undefined if none.
*/
getCommand(name: string): RegisteredCommand | undefined {
for (const hook of this.hooks) {
const command = hook.commands.get(name);
if (command) {
return command;
}
}
return undefined;
}
/**
* Create the event context for handlers.
*/
private createContext(): HookEventContext {
private createContext(): HookContext {
return {
exec: (command: string, args: string[], options?: ExecOptions) => exec(command, args, this.cwd, options),
ui: this.uiContext,
hasUI: this.hasUI,
cwd: this.cwd,
sessionFile: this.sessionFile,
sessionManager: this.sessionManager,
modelRegistry: this.modelRegistry,
model: this.getModel(),
};
}
/**
* Emit an event to all hooks.
* Returns the result from session/tool_result events (if any handler returns one).
* Check if event type is a session "before_*" event that can be cancelled.
*/
async emit(event: HookEvent): Promise<SessionEventResult | ToolResultEventResult | undefined> {
private isSessionBeforeEvent(
type: string,
): type is
| "session_before_switch"
| "session_before_new"
| "session_before_branch"
| "session_before_compact"
| "session_before_tree" {
return (
type === "session_before_switch" ||
type === "session_before_new" ||
type === "session_before_branch" ||
type === "session_before_compact" ||
type === "session_before_tree"
);
}
/**
* Emit an event to all hooks.
* Returns the result from session before_* / tool_result events (if any handler returns one).
*/
async emit(
event: HookEvent,
): Promise<SessionBeforeCompactResult | SessionBeforeTreeResult | ToolResultEventResult | undefined> {
const ctx = this.createContext();
let result: SessionEventResult | ToolResultEventResult | undefined;
let result: SessionBeforeCompactResult | SessionBeforeTreeResult | ToolResultEventResult | undefined;
for (const hook of this.hooks) {
const handlers = hook.handlers.get(event.type);
@ -230,21 +237,11 @@ export class HookRunner {
for (const handler of handlers) {
try {
// No timeout for before_compact events (like tool_call, they may take a while)
const isBeforeCompact = event.type === "session" && (event as SessionEvent).reason === "before_compact";
let handlerResult: unknown;
const handlerResult = await handler(event, ctx);
if (isBeforeCompact) {
handlerResult = await handler(event, ctx);
} else {
const timeout = createTimeout(this.timeout);
handlerResult = await Promise.race([handler(event, ctx), timeout.promise]);
timeout.clear();
}
// For session events, capture the result (for before_* cancellation)
if (event.type === "session" && handlerResult) {
result = handlerResult as SessionEventResult;
// For session before_* events, capture the result (for cancellation)
if (this.isSessionBeforeEvent(event.type) && handlerResult) {
result = handlerResult as SessionBeforeCompactResult | SessionBeforeTreeResult;
// If cancelled, stop processing further hooks
if (result.cancel) {
return result;
@ -298,4 +295,79 @@ export class HookRunner {
return result;
}
/**
* Emit a context event to all hooks.
* Handlers are chained - each gets the previous handler's output (if any).
* Returns the final modified messages, or the original if no modifications.
*
* Note: Messages are already deep-copied by the caller (pi-ai preprocessor).
*/
async emitContext(messages: AgentMessage[]): Promise<AgentMessage[]> {
const ctx = this.createContext();
let currentMessages = messages;
for (const hook of this.hooks) {
const handlers = hook.handlers.get("context");
if (!handlers || handlers.length === 0) continue;
for (const handler of handlers) {
try {
const event: ContextEvent = { type: "context", messages: currentMessages };
const handlerResult = await handler(event, ctx);
if (handlerResult && (handlerResult as ContextEventResult).messages) {
currentMessages = (handlerResult as ContextEventResult).messages!;
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
this.emitError({
hookPath: hook.path,
event: "context",
error: message,
});
}
}
}
return currentMessages;
}
/**
* Emit before_agent_start event to all hooks.
* Returns the first message to inject (if any handler returns one).
*/
async emitBeforeAgentStart(
prompt: string,
images?: import("@mariozechner/pi-ai").ImageContent[],
): Promise<BeforeAgentStartEventResult | undefined> {
const ctx = this.createContext();
let result: BeforeAgentStartEventResult | undefined;
for (const hook of this.hooks) {
const handlers = hook.handlers.get("before_agent_start");
if (!handlers || handlers.length === 0) continue;
for (const handler of handlers) {
try {
const event: BeforeAgentStartEvent = { type: "before_agent_start", prompt, images };
const handlerResult = await handler(event, ctx);
// Take the first message returned
if (handlerResult && (handlerResult as BeforeAgentStartEventResult).message && !result) {
result = handlerResult as BeforeAgentStartEventResult;
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
this.emitError({
hookPath: hook.path,
event: "before_agent_start",
error: message,
});
}
}
}
return result;
}
}

View file

@ -2,7 +2,7 @@
* Tool wrapper - wraps tools with hook callbacks for interception.
*/
import type { AgentTool, AgentToolUpdateCallback } from "@mariozechner/pi-ai";
import type { AgentTool, AgentToolUpdateCallback } from "@mariozechner/pi-agent-core";
import type { HookRunner } from "./runner.js";
import type { ToolCallEventResult, ToolResultEventResult } from "./types.js";
@ -46,30 +46,46 @@ export function wrapToolWithHooks<T>(tool: AgentTool<any, T>, hookRunner: HookRu
}
// Execute the actual tool, forwarding onUpdate for progress streaming
const result = await tool.execute(toolCallId, params, signal, onUpdate);
try {
const result = await tool.execute(toolCallId, params, signal, onUpdate);
// Emit tool_result event - hooks can modify the result
if (hookRunner.hasHandlers("tool_result")) {
const resultResult = (await hookRunner.emit({
type: "tool_result",
toolName: tool.name,
toolCallId,
input: params,
content: result.content,
details: result.details,
isError: false,
})) as ToolResultEventResult | undefined;
// Emit tool_result event - hooks can modify the result
if (hookRunner.hasHandlers("tool_result")) {
const resultResult = (await hookRunner.emit({
type: "tool_result",
toolName: tool.name,
toolCallId,
input: params,
content: result.content,
details: result.details,
isError: false,
})) as ToolResultEventResult | undefined;
// Apply modifications if any
if (resultResult) {
return {
content: resultResult.content ?? result.content,
details: (resultResult.details ?? result.details) as T,
};
// Apply modifications if any
if (resultResult) {
return {
content: resultResult.content ?? result.content,
details: (resultResult.details ?? result.details) as T,
};
}
}
}
return result;
return result;
} catch (err) {
// Emit tool_result event for errors so hooks can observe failures
if (hookRunner.hasHandlers("tool_result")) {
await hookRunner.emit({
type: "tool_result",
toolName: tool.name,
toolCallId,
input: params,
content: [{ type: "text", text: err instanceof Error ? err.message : String(err) }],
details: undefined,
isError: true,
});
}
throw err; // Re-throw original error for agent-loop
}
},
};
}

View file

@ -5,10 +5,17 @@
* and interact with the user via UI primitives.
*/
import type { AppMessage, Attachment } from "@mariozechner/pi-agent-core";
import type { ImageContent, Model, TextContent, ToolResultMessage } from "@mariozechner/pi-ai";
import type { CutPointResult } from "../compaction.js";
import type { CompactionEntry, SessionEntry } from "../session-manager.js";
import type { AgentMessage } from "@mariozechner/pi-agent-core";
import type { ImageContent, Message, Model, TextContent, ToolResultMessage } from "@mariozechner/pi-ai";
import type { Component, TUI } from "@mariozechner/pi-tui";
import type { Theme } from "../../modes/interactive/theme/theme.js";
import type { CompactionPreparation, CompactionResult } from "../compaction/index.js";
import type { ExecOptions, ExecResult } from "../exec.js";
import type { HookMessage } from "../messages.js";
import type { ModelRegistry } from "../model-registry.js";
import type { BranchSummaryEntry, CompactionEntry, ReadonlySessionManager, SessionEntry } from "../session-manager.js";
import type { EditToolDetails } from "../tools/edit.js";
import type {
BashToolDetails,
FindToolDetails,
@ -17,27 +24,8 @@ import type {
ReadToolDetails,
} from "../tools/index.js";
// ============================================================================
// Execution Context
// ============================================================================
/**
* Result of executing a command via ctx.exec()
*/
export interface ExecResult {
stdout: string;
stderr: string;
code: number;
/** True if the process was killed due to signal or timeout */
killed?: boolean;
}
export interface ExecOptions {
/** AbortSignal to cancel the process */
signal?: AbortSignal;
/** Timeout in milliseconds */
timeout?: number;
}
// Re-export for backward compatibility
export type { ExecOptions, ExecResult } from "../exec.js";
/**
* UI context for hooks to request interactive UI from the harness.
@ -50,7 +38,7 @@ export interface HookUIContext {
* @param options - Array of string options
* @returns Selected option string, or null if cancelled
*/
select(title: string, options: string[]): Promise<string | null>;
select(title: string, options: string[]): Promise<string | undefined>;
/**
* Show a confirmation dialog.
@ -60,97 +48,225 @@ export interface HookUIContext {
/**
* Show a text input dialog.
* @returns User input, or null if cancelled
* @returns User input, or undefined if cancelled
*/
input(title: string, placeholder?: string): Promise<string | null>;
input(title: string, placeholder?: string): Promise<string | undefined>;
/**
* Show a notification to the user.
*/
notify(message: string, type?: "info" | "warning" | "error"): void;
/**
* Show a custom component with keyboard focus.
* The factory receives TUI, theme, and a done() callback to close the component.
* Can be async for fire-and-forget work (don't await the work, just start it).
*
* @param factory - Function that creates the component. Call done() when finished.
* @returns Promise that resolves with the value passed to done()
*
* @example
* // Sync factory
* const result = await ctx.ui.custom((tui, theme, done) => {
* const component = new MyComponent(tui, theme);
* component.onFinish = (value) => done(value);
* return component;
* });
*
* // Async factory with fire-and-forget work
* const result = await ctx.ui.custom(async (tui, theme, done) => {
* const loader = new CancellableLoader(tui, theme.fg("accent"), theme.fg("muted"), "Working...");
* loader.onAbort = () => done(null);
* doWork(loader.signal).then(done); // Don't await - fire and forget
* return loader;
* });
*/
custom<T>(
factory: (
tui: TUI,
theme: Theme,
done: (result: T) => void,
) => (Component & { dispose?(): void }) | Promise<Component & { dispose?(): void }>,
): Promise<T>;
/**
* Set the text in the core input editor.
* Use this to pre-fill the input box with generated content (e.g., prompt templates, extracted questions).
* @param text - Text to set in the editor
*/
setEditorText(text: string): void;
/**
* Get the current text from the core input editor.
* @returns Current editor text
*/
getEditorText(): string;
}
/**
* Context passed to hook event handlers.
* Context passed to hook event and command handlers.
*/
export interface HookEventContext {
/** Execute a command and return stdout/stderr/code */
exec(command: string, args: string[], options?: ExecOptions): Promise<ExecResult>;
export interface HookContext {
/** UI methods for user interaction */
ui: HookUIContext;
/** Whether UI is available (false in print mode) */
hasUI: boolean;
/** Current working directory */
cwd: string;
/** Path to session file, or null if --no-session */
sessionFile: string | null;
/** Session manager (read-only) - use pi.sendMessage()/pi.appendEntry() for writes */
sessionManager: ReadonlySessionManager;
/** Model registry - use for API key resolution and model retrieval */
modelRegistry: ModelRegistry;
/** Current model (may be undefined if no model is selected yet) */
model: Model<any> | undefined;
}
// ============================================================================
// Events
// Session Events
// ============================================================================
/**
* Base fields shared by all session events.
*/
interface SessionEventBase {
type: "session";
/** All session entries (including pre-compaction history) */
entries: SessionEntry[];
/** Current session file path, or null in --no-session mode */
sessionFile: string | null;
/** Previous session file path, or null for "start" and "new" */
previousSessionFile: string | null;
/** Fired on initial session load */
export interface SessionStartEvent {
type: "session_start";
}
/**
* Event data for session events.
* Discriminated union based on reason.
*
* Lifecycle:
* - start: Initial session load
* - before_switch / switch: Session switch (e.g., /resume command)
* - before_new / new: New session (e.g., /new command)
* - before_branch / branch: Session branch (e.g., /branch command)
* - before_compact / compact: Before/after context compaction
* - shutdown: Process exit (SIGINT/SIGTERM)
*
* "before_*" events fire before the action and can be cancelled via SessionEventResult.
* Other events fire after the action completes.
*/
/** Fired before switching to another session (can be cancelled) */
export interface SessionBeforeSwitchEvent {
type: "session_before_switch";
/** Session file we're switching to */
targetSessionFile: string;
}
/** Fired after switching to another session */
export interface SessionSwitchEvent {
type: "session_switch";
/** Session file we came from */
previousSessionFile: string | undefined;
}
/** Fired before creating a new session (can be cancelled) */
export interface SessionBeforeNewEvent {
type: "session_before_new";
}
/** Fired after creating a new session */
export interface SessionNewEvent {
type: "session_new";
}
/** Fired before branching a session (can be cancelled) */
export interface SessionBeforeBranchEvent {
type: "session_before_branch";
/** ID of the entry to branch from */
entryId: string;
}
/** Fired after branching a session */
export interface SessionBranchEvent {
type: "session_branch";
previousSessionFile: string | undefined;
}
/** Fired before context compaction (can be cancelled or customized) */
export interface SessionBeforeCompactEvent {
type: "session_before_compact";
/** Compaction preparation with messages to summarize, file ops, previous summary, etc. */
preparation: CompactionPreparation;
/** Branch entries (root to current leaf). Use to inspect custom state or previous compactions. */
branchEntries: SessionEntry[];
/** Optional user-provided instructions for the summary */
customInstructions?: string;
/** Abort signal - hooks should pass this to LLM calls and check it periodically */
signal: AbortSignal;
}
/** Fired after context compaction */
export interface SessionCompactEvent {
type: "session_compact";
compactionEntry: CompactionEntry;
/** Whether the compaction entry was provided by a hook */
fromHook: boolean;
}
/** Fired on process exit (SIGINT/SIGTERM) */
export interface SessionShutdownEvent {
type: "session_shutdown";
}
/** Preparation data for tree navigation (used by session_before_tree event) */
export interface TreePreparation {
/** Node being switched to */
targetId: string;
/** Current active leaf (being abandoned), null if no current position */
oldLeafId: string | null;
/** Common ancestor of target and old leaf, null if no common ancestor */
commonAncestorId: string | null;
/** Entries to summarize (old leaf back to common ancestor or compaction) */
entriesToSummarize: SessionEntry[];
/** Whether user chose to summarize */
userWantsSummary: boolean;
}
/** Fired before navigating to a different node in the session tree (can be cancelled) */
export interface SessionBeforeTreeEvent {
type: "session_before_tree";
/** Preparation data for the navigation */
preparation: TreePreparation;
/** Abort signal - honors Escape during summarization (model available via ctx.model) */
signal: AbortSignal;
}
/** Fired after navigating to a different node in the session tree */
export interface SessionTreeEvent {
type: "session_tree";
/** The new active leaf, null if navigated to before first entry */
newLeafId: string | null;
/** Previous active leaf, null if there was no position */
oldLeafId: string | null;
/** Branch summary entry if one was created */
summaryEntry?: BranchSummaryEntry;
/** Whether summary came from hook */
fromHook?: boolean;
}
/** Union of all session event types */
export type SessionEvent =
| (SessionEventBase & {
reason: "start" | "switch" | "new" | "before_switch" | "before_new" | "shutdown";
})
| (SessionEventBase & {
reason: "branch" | "before_branch";
/** Index of the turn to branch from */
targetTurnIndex: number;
})
| (SessionEventBase & {
reason: "before_compact";
cutPoint: CutPointResult;
/** Summary from previous compaction, if any. Include this in your summary to preserve context. */
previousSummary?: string;
/** Messages that will be summarized and discarded */
messagesToSummarize: AppMessage[];
/** Messages that will be kept after the summary (recent turns) */
messagesToKeep: AppMessage[];
tokensBefore: number;
customInstructions?: string;
model: Model<any>;
/** Resolve API key for any model (checks settings, OAuth, env vars) */
resolveApiKey: (model: Model<any>) => Promise<string | undefined>;
/** Abort signal - hooks should pass this to LLM calls and check it periodically */
signal: AbortSignal;
})
| (SessionEventBase & {
reason: "compact";
compactionEntry: CompactionEntry;
tokensBefore: number;
/** Whether the compaction entry was provided by a hook */
fromHook: boolean;
});
| SessionStartEvent
| SessionBeforeSwitchEvent
| SessionSwitchEvent
| SessionBeforeNewEvent
| SessionNewEvent
| SessionBeforeBranchEvent
| SessionBranchEvent
| SessionBeforeCompactEvent
| SessionCompactEvent
| SessionShutdownEvent
| SessionBeforeTreeEvent
| SessionTreeEvent;
/**
* Event data for context event.
* Fired before each LLM call, allowing hooks to modify context non-destructively.
* Original session messages are NOT modified - only the messages sent to the LLM are affected.
*/
export interface ContextEvent {
type: "context";
/** Messages about to be sent to the LLM (deep copy, safe to modify) */
messages: AgentMessage[];
}
/**
* Event data for before_agent_start event.
* Fired after user submits a prompt but before the agent loop starts.
* Allows hooks to inject context that will be persisted and visible in TUI.
*/
export interface BeforeAgentStartEvent {
type: "before_agent_start";
/** The user's prompt text */
prompt: string;
/** Any images attached to the prompt */
images?: ImageContent[];
}
/**
* Event data for agent_start event.
@ -165,7 +281,7 @@ export interface AgentStartEvent {
*/
export interface AgentEndEvent {
type: "agent_end";
messages: AppMessage[];
messages: AgentMessage[];
}
/**
@ -183,7 +299,7 @@ export interface TurnStartEvent {
export interface TurnEndEvent {
type: "turn_end";
turnIndex: number;
message: AppMessage;
message: AgentMessage;
toolResults: ToolResultMessage[];
}
@ -231,7 +347,7 @@ export interface ReadToolResultEvent extends ToolResultEventBase {
/** Tool result event for edit tool */
export interface EditToolResultEvent extends ToolResultEventBase {
toolName: "edit";
details: undefined;
details: EditToolDetails | undefined;
}
/** Tool result event for write tool */
@ -307,6 +423,8 @@ export function isLsToolResult(e: ToolResultEvent): e is LsToolResultEvent {
*/
export type HookEvent =
| SessionEvent
| ContextEvent
| BeforeAgentStartEvent
| AgentStartEvent
| AgentEndEvent
| TurnStartEvent
@ -318,6 +436,15 @@ export type HookEvent =
// Event Results
// ============================================================================
/**
* Return type for context event handlers.
* Allows hooks to modify messages before they're sent to the LLM.
*/
export interface ContextEventResult {
/** Modified messages to send instead of the original */
messages?: Message[];
}
/**
* Return type for tool_call event handlers.
* Allows hooks to block tool execution.
@ -343,16 +470,68 @@ export interface ToolResultEventResult {
}
/**
* Return type for session event handlers.
* Allows hooks to cancel "before_*" actions.
* Return type for before_agent_start event handlers.
* Allows hooks to inject context before the agent runs.
*/
export interface SessionEventResult {
/** If true, cancel the pending action (switch, clear, or branch) */
export interface BeforeAgentStartEventResult {
/** Message to inject into context (persisted to session, visible in TUI) */
message?: Pick<HookMessage, "customType" | "content" | "display" | "details">;
}
/** Return type for session_before_switch handlers */
export interface SessionBeforeSwitchResult {
/** If true, cancel the switch */
cancel?: boolean;
/** If true (for before_branch only), skip restoring conversation to branch point while still creating the branched session file */
}
/** Return type for session_before_new handlers */
export interface SessionBeforeNewResult {
/** If true, cancel the new session */
cancel?: boolean;
}
/** Return type for session_before_branch handlers */
export interface SessionBeforeBranchResult {
/**
* If true, abort the branch entirely. No new session file is created,
* conversation stays unchanged.
*/
cancel?: boolean;
/**
* If true, the branch proceeds (new session file created, session state updated)
* but the in-memory conversation is NOT rewound to the branch point.
*
* Use case: git-checkpoint hook that restores code state separately.
* The hook handles state restoration itself, so it doesn't want the
* agent's conversation to be rewound (which would lose recent context).
*
* - `cancel: true` nothing happens, user stays in current session
* - `skipConversationRestore: true` branch happens, but messages stay as-is
* - neither branch happens AND messages rewind to branch point (default)
*/
skipConversationRestore?: boolean;
/** Custom compaction entry (for before_compact event) */
compactionEntry?: CompactionEntry;
}
/** Return type for session_before_compact handlers */
export interface SessionBeforeCompactResult {
/** If true, cancel the compaction */
cancel?: boolean;
/** Custom compaction result - SessionManager adds id/parentId */
compaction?: CompactionResult;
}
/** Return type for session_before_tree handlers */
export interface SessionBeforeTreeResult {
/** If true, cancel the navigation entirely */
cancel?: boolean;
/**
* Custom summary (skips default summarizer).
* Only used if preparation.userWantsSummary is true.
*/
summary?: {
summary: string;
details?: unknown;
};
}
// ============================================================================
@ -361,29 +540,134 @@ export interface SessionEventResult {
/**
* Handler function type for each event.
* Handlers can return R, undefined, or void (bare return statements).
*/
export type HookHandler<E, R = void> = (event: E, ctx: HookEventContext) => Promise<R>;
// biome-ignore lint/suspicious/noConfusingVoidType: void allows bare return statements in handlers
export type HookHandler<E, R = undefined> = (event: E, ctx: HookContext) => Promise<R | void> | R | void;
export interface HookMessageRenderOptions {
/** Whether the view is expanded */
expanded: boolean;
}
/**
* Renderer for hook messages.
* Hooks register these to provide custom TUI rendering for their message types.
*/
export type HookMessageRenderer<T = unknown> = (
message: HookMessage<T>,
options: HookMessageRenderOptions,
theme: Theme,
) => Component | undefined;
/**
* Command registration options.
*/
export interface RegisteredCommand {
name: string;
description?: string;
handler: (args: string, ctx: HookContext) => Promise<void>;
}
/**
* HookAPI passed to hook factory functions.
* Hooks use pi.on() to subscribe to events and pi.send() to inject messages.
* Hooks use pi.on() to subscribe to events and pi.sendMessage() to inject messages.
*/
export interface HookAPI {
// biome-ignore lint/suspicious/noConfusingVoidType: void allows handlers to not return anything
on(event: "session", handler: HookHandler<SessionEvent, SessionEventResult | void>): void;
// Session events
on(event: "session_start", handler: HookHandler<SessionStartEvent>): void;
on(event: "session_before_switch", handler: HookHandler<SessionBeforeSwitchEvent, SessionBeforeSwitchResult>): void;
on(event: "session_switch", handler: HookHandler<SessionSwitchEvent>): void;
on(event: "session_before_new", handler: HookHandler<SessionBeforeNewEvent, SessionBeforeNewResult>): void;
on(event: "session_new", handler: HookHandler<SessionNewEvent>): void;
on(event: "session_before_branch", handler: HookHandler<SessionBeforeBranchEvent, SessionBeforeBranchResult>): void;
on(event: "session_branch", handler: HookHandler<SessionBranchEvent>): void;
on(
event: "session_before_compact",
handler: HookHandler<SessionBeforeCompactEvent, SessionBeforeCompactResult>,
): void;
on(event: "session_compact", handler: HookHandler<SessionCompactEvent>): void;
on(event: "session_shutdown", handler: HookHandler<SessionShutdownEvent>): void;
on(event: "session_before_tree", handler: HookHandler<SessionBeforeTreeEvent, SessionBeforeTreeResult>): void;
on(event: "session_tree", handler: HookHandler<SessionTreeEvent>): void;
// Context and agent events
on(event: "context", handler: HookHandler<ContextEvent, ContextEventResult>): void;
on(event: "before_agent_start", handler: HookHandler<BeforeAgentStartEvent, BeforeAgentStartEventResult>): void;
on(event: "agent_start", handler: HookHandler<AgentStartEvent>): void;
on(event: "agent_end", handler: HookHandler<AgentEndEvent>): void;
on(event: "turn_start", handler: HookHandler<TurnStartEvent>): void;
on(event: "turn_end", handler: HookHandler<TurnEndEvent>): void;
on(event: "tool_call", handler: HookHandler<ToolCallEvent, ToolCallEventResult | undefined>): void;
on(event: "tool_result", handler: HookHandler<ToolResultEvent, ToolResultEventResult | undefined>): void;
on(event: "tool_call", handler: HookHandler<ToolCallEvent, ToolCallEventResult>): void;
on(event: "tool_result", handler: HookHandler<ToolResultEvent, ToolResultEventResult>): void;
/**
* Send a message to the agent.
* If the agent is streaming, the message is queued.
* If the agent is idle, a new agent loop is started.
* Send a custom message to the session. Creates a CustomMessageEntry that
* participates in LLM context and can be displayed in the TUI.
*
* Use this when you want the LLM to see the message content.
* For hook state that should NOT be sent to the LLM, use appendEntry() instead.
*
* @param message - The message to send
* @param message.customType - Identifier for your hook (used for filtering on reload)
* @param message.content - Message content (string or TextContent/ImageContent array)
* @param message.display - Whether to show in TUI (true = styled display, false = hidden)
* @param message.details - Optional hook-specific metadata (not sent to LLM)
* @param triggerTurn - If true and agent is idle, triggers a new LLM turn. Default: false.
* If agent is streaming, message is queued and triggerTurn is ignored.
*/
send(text: string, attachments?: Attachment[]): void;
sendMessage<T = unknown>(
message: Pick<HookMessage<T>, "customType" | "content" | "display" | "details">,
triggerTurn?: boolean,
): void;
/**
* Append a custom entry to the session for hook state persistence.
* Creates a CustomEntry that does NOT participate in LLM context.
*
* Use this to store hook-specific data that should persist across session reloads
* but should NOT be sent to the LLM. On reload, scan session entries for your
* customType to reconstruct hook state.
*
* For messages that SHOULD be sent to the LLM, use sendMessage() instead.
*
* @param customType - Identifier for your hook (used for filtering on reload)
* @param data - Hook-specific data to persist (must be JSON-serializable)
*
* @example
* // Store permission state
* pi.appendEntry("permissions", { level: "full", grantedAt: Date.now() });
*
* // On reload, reconstruct state from entries
* pi.on("session", async (event, ctx) => {
* if (event.reason === "start") {
* const entries = event.sessionManager.getEntries();
* const myEntries = entries.filter(e => e.type === "custom" && e.customType === "permissions");
* // Reconstruct state from myEntries...
* }
* });
*/
appendEntry<T = unknown>(customType: string, data?: T): void;
/**
* Register a custom renderer for CustomMessageEntry with a specific customType.
* The renderer is called when rendering the entry in the TUI.
* Return nothing to use the default renderer.
*/
registerMessageRenderer<T = unknown>(customType: string, renderer: HookMessageRenderer<T>): void;
/**
* Register a custom slash command.
* Handler receives HookCommandContext.
*/
registerCommand(name: string, options: { description?: string; handler: RegisteredCommand["handler"] }): void;
/**
* Execute a shell command and return stdout/stderr/code.
* Supports timeout and abort signal.
*/
exec(command: string, args: string[], options?: ExecOptions): Promise<ExecResult>;
}
/**

View file

@ -7,29 +7,29 @@ export {
type AgentSessionConfig,
type AgentSessionEvent,
type AgentSessionEventListener,
type CompactionResult,
type ModelCycleResult,
type PromptOptions,
type SessionStats,
} from "./agent-session.js";
export { type BashExecutorOptions, type BashResult, executeBash } from "./bash-executor.js";
export type { CompactionResult } from "./compaction/index.js";
export {
type CustomAgentTool,
type CustomTool,
type CustomToolAPI,
type CustomToolFactory,
type CustomToolsLoadResult,
type CustomToolUIContext,
discoverAndLoadCustomTools,
type ExecResult,
type LoadedCustomTool,
loadCustomTools,
type RenderResultOptions,
type ToolAPI,
type ToolUIContext,
} from "./custom-tools/index.js";
export {
type HookAPI,
type HookContext,
type HookError,
type HookEvent,
type HookEventContext,
type HookFactory,
HookRunner,
type HookUIContext,

View file

@ -1,16 +1,27 @@
/**
* Custom message types and transformers for the coding agent.
*
* Extends the base AppMessage type with coding-agent specific message types,
* Extends the base AgentMessage type with coding-agent specific message types,
* and provides a transformer to convert them to LLM-compatible messages.
*/
import type { AppMessage } from "@mariozechner/pi-agent-core";
import type { Message } from "@mariozechner/pi-ai";
import type { AgentMessage } from "@mariozechner/pi-agent-core";
import type { ImageContent, Message, TextContent } from "@mariozechner/pi-ai";
// ============================================================================
// Custom Message Types
// ============================================================================
export const COMPACTION_SUMMARY_PREFIX = `The conversation history before this point was compacted into the following summary:
<summary>
`;
export const COMPACTION_SUMMARY_SUFFIX = `
</summary>`;
export const BRANCH_SUMMARY_PREFIX = `The following is a summary of a branch that this conversation came back from:
<summary>
`;
export const BRANCH_SUMMARY_SUFFIX = `</summary>`;
/**
* Message type for bash executions via the ! command.
@ -19,35 +30,50 @@ export interface BashExecutionMessage {
role: "bashExecution";
command: string;
output: string;
exitCode: number | null;
exitCode: number | undefined;
cancelled: boolean;
truncated: boolean;
fullOutputPath?: string;
timestamp: number;
}
// Extend CustomMessages via declaration merging
/**
* Message type for hook-injected messages via sendMessage().
* These are custom messages that hooks can inject into the conversation.
*/
export interface HookMessage<T = unknown> {
role: "hookMessage";
customType: string;
content: string | (TextContent | ImageContent)[];
display: boolean;
details?: T;
timestamp: number;
}
export interface BranchSummaryMessage {
role: "branchSummary";
summary: string;
fromId: string;
timestamp: number;
}
export interface CompactionSummaryMessage {
role: "compactionSummary";
summary: string;
tokensBefore: number;
timestamp: number;
}
// Extend CustomAgentMessages via declaration merging
declare module "@mariozechner/pi-agent-core" {
interface CustomMessages {
interface CustomAgentMessages {
bashExecution: BashExecutionMessage;
hookMessage: HookMessage;
branchSummary: BranchSummaryMessage;
compactionSummary: CompactionSummaryMessage;
}
}
// ============================================================================
// Type Guards
// ============================================================================
/**
* Type guard for BashExecutionMessage.
*/
export function isBashExecutionMessage(msg: AppMessage | Message): msg is BashExecutionMessage {
return (msg as BashExecutionMessage).role === "bashExecution";
}
// ============================================================================
// Message Formatting
// ============================================================================
/**
* Convert a BashExecutionMessage to user message text for LLM context.
*/
@ -60,7 +86,7 @@ export function bashExecutionToText(msg: BashExecutionMessage): string {
}
if (msg.cancelled) {
text += "\n\n(command cancelled)";
} else if (msg.exitCode !== null && msg.exitCode !== 0) {
} else if (msg.exitCode !== null && msg.exitCode !== undefined && msg.exitCode !== 0) {
text += `\n\nCommand exited with code ${msg.exitCode}`;
}
if (msg.truncated && msg.fullOutputPath) {
@ -69,34 +95,95 @@ export function bashExecutionToText(msg: BashExecutionMessage): string {
return text;
}
// ============================================================================
// Message Transformer
// ============================================================================
export function createBranchSummaryMessage(summary: string, fromId: string, timestamp: string): BranchSummaryMessage {
return {
role: "branchSummary",
summary,
fromId,
timestamp: new Date(timestamp).getTime(),
};
}
export function createCompactionSummaryMessage(
summary: string,
tokensBefore: number,
timestamp: string,
): CompactionSummaryMessage {
return {
role: "compactionSummary",
summary: summary,
tokensBefore,
timestamp: new Date(timestamp).getTime(),
};
}
/** Convert CustomMessageEntry to AgentMessage format */
export function createHookMessage(
customType: string,
content: string | (TextContent | ImageContent)[],
display: boolean,
details: unknown | undefined,
timestamp: string,
): HookMessage {
return {
role: "hookMessage",
customType,
content,
display,
details,
timestamp: new Date(timestamp).getTime(),
};
}
/**
* Transform AppMessages (including custom types) to LLM-compatible Messages.
* Transform AgentMessages (including custom types) to LLM-compatible Messages.
*
* This is used by:
* - Agent's messageTransformer option (for prompt calls)
* - Agent's transormToLlm option (for prompt calls and queued messages)
* - Compaction's generateSummary (for summarization)
* - Custom hooks and tools
*/
export function messageTransformer(messages: AppMessage[]): Message[] {
export function convertToLlm(messages: AgentMessage[]): Message[] {
return messages
.map((m): Message | null => {
if (isBashExecutionMessage(m)) {
// Convert bash execution to user message
return {
role: "user",
content: [{ type: "text", text: bashExecutionToText(m) }],
timestamp: m.timestamp,
};
.map((m): Message | undefined => {
switch (m.role) {
case "bashExecution":
return {
role: "user",
content: [{ type: "text", text: bashExecutionToText(m) }],
timestamp: m.timestamp,
};
case "hookMessage": {
const content = typeof m.content === "string" ? [{ type: "text" as const, text: m.content }] : m.content;
return {
role: "user",
content,
timestamp: m.timestamp,
};
}
case "branchSummary":
return {
role: "user",
content: [{ type: "text" as const, text: BRANCH_SUMMARY_PREFIX + m.summary + BRANCH_SUMMARY_SUFFIX }],
timestamp: m.timestamp,
};
case "compactionSummary":
return {
role: "user",
content: [
{ type: "text" as const, text: COMPACTION_SUMMARY_PREFIX + m.summary + COMPACTION_SUMMARY_SUFFIX },
],
timestamp: m.timestamp,
};
case "user":
case "assistant":
case "toolResult":
return m;
default:
// biome-ignore lint/correctness/noSwitchDeclarations: fine
const _exhaustiveCheck: never = m;
return undefined;
}
// Pass through standard LLM roles
if (m.role === "user" || m.role === "assistant" || m.role === "toolResult") {
return m as Message;
}
// Filter out unknown message types
return null;
})
.filter((m): m is Message => m !== null);
.filter((m) => m !== undefined);
}

View file

@ -90,11 +90,11 @@ function resolveApiKeyConfig(keyConfig: string): string | undefined {
export class ModelRegistry {
private models: Model<Api>[] = [];
private customProviderApiKeys: Map<string, string> = new Map();
private loadError: string | null = null;
private loadError: string | undefined = undefined;
constructor(
readonly authStorage: AuthStorage,
private modelsJsonPath: string | null = null,
private modelsJsonPath: string | undefined = undefined,
) {
// Set up fallback resolver for custom provider API keys
this.authStorage.setFallbackResolver((provider) => {
@ -114,14 +114,14 @@ export class ModelRegistry {
*/
refresh(): void {
this.customProviderApiKeys.clear();
this.loadError = null;
this.loadError = undefined;
this.loadModels();
}
/**
* Get any error from loading models.json (null if no error).
* Get any error from loading models.json (undefined if no error).
*/
getError(): string | null {
getError(): string | undefined {
return this.loadError;
}
@ -160,9 +160,9 @@ export class ModelRegistry {
}
}
private loadCustomModels(modelsJsonPath: string): { models: Model<Api>[]; error: string | null } {
private loadCustomModels(modelsJsonPath: string): { models: Model<Api>[]; error: string | undefined } {
if (!existsSync(modelsJsonPath)) {
return { models: [], error: null };
return { models: [], error: undefined };
}
try {
@ -186,7 +186,7 @@ export class ModelRegistry {
this.validateConfig(config);
// Parse models
return { models: this.parseModels(config), error: null };
return { models: this.parseModels(config), error: undefined };
} catch (error) {
if (error instanceof SyntaxError) {
return {
@ -294,14 +294,14 @@ export class ModelRegistry {
/**
* Find a model by provider and ID.
*/
find(provider: string, modelId: string): Model<Api> | null {
return this.models.find((m) => m.provider === provider && m.id === modelId) ?? null;
find(provider: string, modelId: string): Model<Api> | undefined {
return this.models.find((m) => m.provider === provider && m.id === modelId) ?? undefined;
}
/**
* Get API key for a model.
*/
async getApiKey(model: Model<Api>): Promise<string | null> {
async getApiKey(model: Model<Api>): Promise<string | undefined> {
return this.authStorage.getApiKey(model.provider);
}

View file

@ -44,9 +44,9 @@ function isAlias(id: string): boolean {
/**
* Try to match a pattern to a model from the available models list.
* Returns the matched model or null if no match found.
* Returns the matched model or undefined if no match found.
*/
function tryMatchModel(modelPattern: string, availableModels: Model<Api>[]): Model<Api> | null {
function tryMatchModel(modelPattern: string, availableModels: Model<Api>[]): Model<Api> | undefined {
// Check for provider/modelId format (provider is everything before the first /)
const slashIndex = modelPattern.indexOf("/");
if (slashIndex !== -1) {
@ -75,7 +75,7 @@ function tryMatchModel(modelPattern: string, availableModels: Model<Api>[]): Mod
);
if (matches.length === 0) {
return null;
return undefined;
}
// Separate into aliases and dated versions
@ -94,9 +94,9 @@ function tryMatchModel(modelPattern: string, availableModels: Model<Api>[]): Mod
}
export interface ParsedModelResult {
model: Model<Api> | null;
model: Model<Api> | undefined;
thinkingLevel: ThinkingLevel;
warning: string | null;
warning: string | undefined;
}
/**
@ -116,14 +116,14 @@ export function parseModelPattern(pattern: string, availableModels: Model<Api>[]
// Try exact match first
const exactMatch = tryMatchModel(pattern, availableModels);
if (exactMatch) {
return { model: exactMatch, thinkingLevel: "off", warning: null };
return { model: exactMatch, thinkingLevel: "off", warning: undefined };
}
// No match - try splitting on last colon if present
const lastColonIndex = pattern.lastIndexOf(":");
if (lastColonIndex === -1) {
// No colons, pattern simply doesn't match any model
return { model: null, thinkingLevel: "off", warning: null };
return { model: undefined, thinkingLevel: "off", warning: undefined };
}
const prefix = pattern.substring(0, lastColonIndex);
@ -193,9 +193,9 @@ export async function resolveModelScope(patterns: string[], modelRegistry: Model
}
export interface InitialModelResult {
model: Model<Api> | null;
model: Model<Api> | undefined;
thinkingLevel: ThinkingLevel;
fallbackMessage: string | null;
fallbackMessage: string | undefined;
}
/**
@ -227,7 +227,7 @@ export async function findInitialModel(options: {
modelRegistry,
} = options;
let model: Model<Api> | null = null;
let model: Model<Api> | undefined;
let thinkingLevel: ThinkingLevel = "off";
// 1. CLI args take priority
@ -237,7 +237,7 @@ export async function findInitialModel(options: {
console.error(chalk.red(`Model ${cliProvider}/${cliModel} not found`));
process.exit(1);
}
return { model: found, thinkingLevel: "off", fallbackMessage: null };
return { model: found, thinkingLevel: "off", fallbackMessage: undefined };
}
// 2. Use first model from scoped models (skip if continuing/resuming)
@ -245,7 +245,7 @@ export async function findInitialModel(options: {
return {
model: scopedModels[0].model,
thinkingLevel: scopedModels[0].thinkingLevel,
fallbackMessage: null,
fallbackMessage: undefined,
};
}
@ -257,7 +257,7 @@ export async function findInitialModel(options: {
if (defaultThinkingLevel) {
thinkingLevel = defaultThinkingLevel;
}
return { model, thinkingLevel, fallbackMessage: null };
return { model, thinkingLevel, fallbackMessage: undefined };
}
}
@ -270,16 +270,16 @@ export async function findInitialModel(options: {
const defaultId = defaultModelPerProvider[provider];
const match = availableModels.find((m) => m.provider === provider && m.id === defaultId);
if (match) {
return { model: match, thinkingLevel: "off", fallbackMessage: null };
return { model: match, thinkingLevel: "off", fallbackMessage: undefined };
}
}
// If no default found, use first available
return { model: availableModels[0], thinkingLevel: "off", fallbackMessage: null };
return { model: availableModels[0], thinkingLevel: "off", fallbackMessage: undefined };
}
// 5. No model found
return { model: null, thinkingLevel: "off", fallbackMessage: null };
return { model: undefined, thinkingLevel: "off", fallbackMessage: undefined };
}
/**
@ -288,10 +288,10 @@ export async function findInitialModel(options: {
export async function restoreModelFromSession(
savedProvider: string,
savedModelId: string,
currentModel: Model<Api> | null,
currentModel: Model<Api> | undefined,
shouldPrintMessages: boolean,
modelRegistry: ModelRegistry,
): Promise<{ model: Model<Api> | null; fallbackMessage: string | null }> {
): Promise<{ model: Model<Api> | undefined; fallbackMessage: string | undefined }> {
const restoredModel = modelRegistry.find(savedProvider, savedModelId);
// Check if restored model exists and has a valid API key
@ -301,7 +301,7 @@ export async function restoreModelFromSession(
if (shouldPrintMessages) {
console.log(chalk.dim(`Restored model: ${savedProvider}/${savedModelId}`));
}
return { model: restoredModel, fallbackMessage: null };
return { model: restoredModel, fallbackMessage: undefined };
}
// Model not found or no API key - fall back
@ -327,7 +327,7 @@ export async function restoreModelFromSession(
if (availableModels.length > 0) {
// Try to find a default model from known providers
let fallbackModel: Model<Api> | null = null;
let fallbackModel: Model<Api> | undefined;
for (const provider of Object.keys(defaultModelPerProvider) as KnownProvider[]) {
const defaultId = defaultModelPerProvider[provider];
const match = availableModels.find((m) => m.provider === provider && m.id === defaultId);
@ -353,5 +353,5 @@ export async function restoreModelFromSession(
}
// No models available
return { model: null, fallbackMessage: null };
return { model: undefined, fallbackMessage: undefined };
}

View file

@ -29,17 +29,22 @@
* ```
*/
import { Agent, ProviderTransport, type ThinkingLevel } from "@mariozechner/pi-agent-core";
import { Agent, type ThinkingLevel } from "@mariozechner/pi-agent-core";
import type { Model } from "@mariozechner/pi-ai";
import { join } from "path";
import { getAgentDir } from "../config.js";
import { AgentSession } from "./agent-session.js";
import { AuthStorage } from "./auth-storage.js";
import { discoverAndLoadCustomTools, type LoadedCustomTool } from "./custom-tools/index.js";
import type { CustomAgentTool } from "./custom-tools/types.js";
import {
type CustomToolsLoadResult,
discoverAndLoadCustomTools,
type LoadedCustomTool,
wrapCustomTools,
} from "./custom-tools/index.js";
import type { CustomTool } from "./custom-tools/types.js";
import { discoverAndLoadHooks, HookRunner, type LoadedHook, wrapToolsWithHooks } from "./hooks/index.js";
import type { HookFactory } from "./hooks/types.js";
import { messageTransformer } from "./messages.js";
import { convertToLlm } from "./messages.js";
import { ModelRegistry } from "./model-registry.js";
import { SessionManager } from "./session-manager.js";
import { type Settings, SettingsManager, type SkillsSettings } from "./settings-manager.js";
@ -99,7 +104,7 @@ export interface CreateAgentSessionOptions {
/** Built-in tools to use. Default: codingTools [read, bash, edit, write] */
tools?: Tool[];
/** Custom tools (replaces discovery). */
customTools?: Array<{ path?: string; tool: CustomAgentTool }>;
customTools?: Array<{ path?: string; tool: CustomTool }>;
/** Additional custom tool paths to load (merged with discovery). */
additionalCustomToolPaths?: string[];
@ -127,18 +132,15 @@ export interface CreateAgentSessionResult {
/** The created session */
session: AgentSession;
/** Custom tools result (for UI context setup in interactive mode) */
customToolsResult: {
tools: LoadedCustomTool[];
setUIContext: (uiContext: any, hasUI: boolean) => void;
};
customToolsResult: CustomToolsLoadResult;
/** Warning if session was restored with a different model than saved */
modelFallbackMessage?: string;
}
// Re-exports
export type { CustomAgentTool } from "./custom-tools/types.js";
export type { HookAPI, HookFactory } from "./hooks/types.js";
export type { CustomTool } from "./custom-tools/types.js";
export type { HookAPI, HookContext, HookFactory } from "./hooks/types.js";
export type { Settings, SkillsSettings } from "./settings-manager.js";
export type { Skill } from "./skills.js";
export type { FileSlashCommand } from "./slash-commands.js";
@ -219,7 +221,7 @@ export async function discoverHooks(
export async function discoverCustomTools(
cwd?: string,
agentDir?: string,
): Promise<Array<{ path: string; tool: CustomAgentTool }>> {
): Promise<Array<{ path: string; tool: CustomTool }>> {
const resolvedCwd = cwd ?? process.cwd();
const resolvedAgentDir = agentDir ?? getDefaultAgentDir();
@ -311,7 +313,6 @@ export function loadSettings(cwd?: string, agentDir?: string): Settings {
shellPath: manager.getShellPath(),
collapseChangelog: manager.getCollapseChangelog(),
hooks: manager.getHookPaths(),
hookTimeout: manager.getHookTimeout(),
customTools: manager.getCustomToolPaths(),
skills: manager.getSkillsSettings(),
terminal: { showImages: manager.getShowImages() },
@ -340,7 +341,10 @@ function createFactoryFromLoadedHook(loaded: LoadedHook): HookFactory {
function createLoadedHooksFromDefinitions(definitions: Array<{ path?: string; factory: HookFactory }>): LoadedHook[] {
return definitions.map((def) => {
const handlers = new Map<string, Array<(...args: unknown[]) => Promise<unknown>>>();
let sendHandler: (text: string, attachments?: any[]) => void = () => {};
const messageRenderers = new Map<string, any>();
const commands = new Map<string, any>();
let sendMessageHandler: (message: any, triggerTurn?: boolean) => void = () => {};
let appendEntryHandler: (customType: string, data?: any) => void = () => {};
const api = {
on: (event: string, handler: (...args: unknown[]) => Promise<unknown>) => {
@ -348,8 +352,17 @@ function createLoadedHooksFromDefinitions(definitions: Array<{ path?: string; fa
list.push(handler);
handlers.set(event, list);
},
send: (text: string, attachments?: any[]) => {
sendHandler(text, attachments);
sendMessage: (message: any, triggerTurn?: boolean) => {
sendMessageHandler(message, triggerTurn);
},
appendEntry: (customType: string, data?: any) => {
appendEntryHandler(customType, data);
},
registerMessageRenderer: (customType: string, renderer: any) => {
messageRenderers.set(customType, renderer);
},
registerCommand: (name: string, options: any) => {
commands.set(name, { name, ...options });
},
};
@ -359,8 +372,13 @@ function createLoadedHooksFromDefinitions(definitions: Array<{ path?: string; fa
path: def.path ?? "<inline>",
resolvedPath: def.path ?? "<inline>",
handlers,
setSendHandler: (handler: (text: string, attachments?: any[]) => void) => {
sendHandler = handler;
messageRenderers,
commands,
setSendMessageHandler: (handler: (message: any, triggerTurn?: boolean) => void) => {
sendMessageHandler = handler;
},
setAppendEntryHandler: (handler: (customType: string, data?: any) => void) => {
appendEntryHandler = handler;
},
};
});
@ -490,7 +508,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
const builtInTools = options.tools ?? createCodingTools(cwd);
time("createCodingTools");
let customToolsResult: { tools: LoadedCustomTool[]; setUIContext: (ctx: any, hasUI: boolean) => void };
let customToolsResult: CustomToolsLoadResult;
if (options.customTools !== undefined) {
// Use provided custom tools
const loadedTools: LoadedCustomTool[] = options.customTools.map((ct) => ({
@ -500,24 +518,24 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
}));
customToolsResult = {
tools: loadedTools,
errors: [],
setUIContext: () => {},
};
} else {
// Discover custom tools, merging with additional paths
const configuredPaths = [...settingsManager.getCustomToolPaths(), ...(options.additionalCustomToolPaths ?? [])];
const result = await discoverAndLoadCustomTools(configuredPaths, cwd, Object.keys(allTools), agentDir);
customToolsResult = await discoverAndLoadCustomTools(configuredPaths, cwd, Object.keys(allTools), agentDir);
time("discoverAndLoadCustomTools");
for (const { path, error } of result.errors) {
for (const { path, error } of customToolsResult.errors) {
console.error(`Failed to load custom tool "${path}": ${error}`);
}
customToolsResult = result;
}
let hookRunner: HookRunner | null = null;
let hookRunner: HookRunner | undefined;
if (options.hooks !== undefined) {
if (options.hooks.length > 0) {
const loadedHooks = createLoadedHooksFromDefinitions(options.hooks);
hookRunner = new HookRunner(loadedHooks, cwd, settingsManager.getHookTimeout());
hookRunner = new HookRunner(loadedHooks, cwd, sessionManager, modelRegistry);
}
} else {
// Discover hooks, merging with additional paths
@ -528,11 +546,19 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
console.error(`Failed to load hook "${path}": ${error}`);
}
if (hooks.length > 0) {
hookRunner = new HookRunner(hooks, cwd, settingsManager.getHookTimeout());
hookRunner = new HookRunner(hooks, cwd, sessionManager, modelRegistry);
}
}
let allToolsArray: Tool[] = [...builtInTools, ...customToolsResult.tools.map((lt) => lt.tool as unknown as Tool)];
// Wrap custom tools with context getter (agent is assigned below, accessed at execute time)
let agent: Agent;
const wrappedCustomTools = wrapCustomTools(customToolsResult.tools, () => ({
sessionManager,
modelRegistry,
model: agent.state.model,
}));
let allToolsArray: Tool[] = [...builtInTools, ...wrappedCustomTools];
time("combineTools");
if (hookRunner) {
allToolsArray = wrapToolsWithHooks(allToolsArray, hookRunner) as Tool[];
@ -564,34 +590,43 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
const slashCommands = options.slashCommands ?? discoverSlashCommands(cwd, agentDir);
time("discoverSlashCommands");
const agent = new Agent({
agent = new Agent({
initialState: {
systemPrompt,
model,
thinkingLevel,
tools: allToolsArray,
},
messageTransformer,
convertToLlm,
transformContext: hookRunner
? async (messages) => {
return hookRunner.emitContext(messages);
}
: undefined,
queueMode: settingsManager.getQueueMode(),
transport: new ProviderTransport({
getApiKey: async () => {
const currentModel = agent.state.model;
if (!currentModel) {
throw new Error("No model selected");
}
const key = await modelRegistry.getApiKey(currentModel);
if (!key) {
throw new Error(`No API key found for provider "${currentModel.provider}"`);
}
return key;
},
}),
getApiKey: async () => {
const currentModel = agent.state.model;
if (!currentModel) {
throw new Error("No model selected");
}
const key = await modelRegistry.getApiKey(currentModel);
if (!key) {
throw new Error(`No API key found for provider "${currentModel.provider}"`);
}
return key;
},
});
time("createAgent");
// Restore messages if session has existing data
if (hasExistingSession) {
agent.replaceMessages(existingSession.messages);
} else {
// Save initial model and thinking level for new sessions so they can be restored on resume
if (model) {
sessionManager.appendModelChange(model.provider, model.id);
}
sessionManager.appendThinkingLevelChange(thinkingLevel);
}
const session = new AgentSession({

File diff suppressed because it is too large Load diff

View file

@ -8,6 +8,10 @@ export interface CompactionSettings {
keepRecentTokens?: number; // default: 20000
}
export interface BranchSummarySettings {
reserveTokens?: number; // default: 16384 (tokens reserved for prompt + LLM response)
}
export interface RetrySettings {
enabled?: boolean; // default: true
maxRetries?: number; // default: 3
@ -38,12 +42,12 @@ export interface Settings {
queueMode?: "all" | "one-at-a-time";
theme?: string;
compaction?: CompactionSettings;
branchSummary?: BranchSummarySettings;
retry?: RetrySettings;
hideThinkingBlock?: boolean;
shellPath?: string; // Custom shell path (e.g., for Cygwin users on Windows)
collapseChangelog?: boolean; // Show condensed changelog after update (use /changelog for full)
hooks?: string[]; // Array of hook file paths
hookTimeout?: number; // Timeout for hook execution in ms (default: 30000)
customTools?: string[]; // Array of custom tool file paths
skills?: SkillsSettings;
terminal?: TerminalSettings;
@ -255,6 +259,12 @@ export class SettingsManager {
};
}
getBranchSummarySettings(): { reserveTokens: number } {
return {
reserveTokens: this.settings.branchSummary?.reserveTokens ?? 16384,
};
}
getRetryEnabled(): boolean {
return this.settings.retry?.enabled ?? true;
}
@ -303,7 +313,7 @@ export class SettingsManager {
}
getHookPaths(): string[] {
return this.settings.hooks ?? [];
return [...(this.settings.hooks ?? [])];
}
setHookPaths(paths: string[]): void {
@ -311,17 +321,8 @@ export class SettingsManager {
this.save();
}
getHookTimeout(): number {
return this.settings.hookTimeout ?? 30000;
}
setHookTimeout(timeout: number): void {
this.globalSettings.hookTimeout = timeout;
this.save();
}
getCustomToolPaths(): string[] {
return this.settings.customTools ?? [];
return [...(this.settings.customTools ?? [])];
}
setCustomToolPaths(paths: string[]): void {
@ -349,9 +350,9 @@ export class SettingsManager {
enableClaudeProject: this.settings.skills?.enableClaudeProject ?? true,
enablePiUser: this.settings.skills?.enablePiUser ?? true,
enablePiProject: this.settings.skills?.enablePiProject ?? true,
customDirectories: this.settings.skills?.customDirectories ?? [],
ignoredSkills: this.settings.skills?.ignoredSkills ?? [],
includeSkills: this.settings.skills?.includeSkills ?? [],
customDirectories: [...(this.settings.skills?.customDirectories ?? [])],
ignoredSkills: [...(this.settings.skills?.ignoredSkills ?? [])],
includeSkills: [...(this.settings.skills?.includeSkills ?? [])],
};
}

View file

@ -5,7 +5,7 @@
import chalk from "chalk";
import { existsSync, readFileSync } from "fs";
import { join, resolve } from "path";
import { getAgentDir, getDocsPath, getReadmePath } from "../config.js";
import { getAgentDir, getDocsPath, getExamplesPath, getReadmePath } from "../config.js";
import type { SkillsSettings } from "./settings-manager.js";
import { formatSkillsForPrompt, loadSkills, type Skill } from "./skills.js";
import type { ToolName } from "./tools/index.js";
@ -202,9 +202,10 @@ export function buildSystemPrompt(options: BuildSystemPromptOptions = {}): strin
return prompt;
}
// Get absolute paths to documentation
// Get absolute paths to documentation and examples
const readmePath = getReadmePath();
const docsPath = getDocsPath();
const examplesPath = getExamplesPath();
// Build tools list based on selected tools
const tools = selectedTools || (["read", "bash", "edit", "write"] as ToolName[]);
@ -279,7 +280,9 @@ ${guidelines}
Documentation:
- Main documentation: ${readmePath}
- Additional docs: ${docsPath}
- When asked about: custom models/providers (README sufficient), themes (docs/theme.md), skills (docs/skills.md), hooks (docs/hooks.md), custom tools (docs/custom-tools.md), RPC (docs/rpc.md)`;
- Examples: ${examplesPath} (hooks, custom tools, SDK)
- When asked to create: custom models/providers (README.md), hooks (docs/hooks.md, examples/hooks/), custom tools (docs/custom-tools.md, docs/tui.md, examples/custom-tools/), themes (docs/theme.md), skills (docs/skills.md)
- Always read the doc, examples, AND follow .md cross-references before implementing`;
if (appendSection) {
prompt += appendSection;

View file

@ -2,7 +2,7 @@ import { randomBytes } from "node:crypto";
import { createWriteStream } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { AgentTool } from "@mariozechner/pi-ai";
import type { AgentTool } from "@mariozechner/pi-agent-core";
import { Type } from "@sinclair/typebox";
import { spawn } from "child_process";
import { getShellConfig, killProcessTree } from "../../utils/shell.js";

View file

@ -1,4 +1,4 @@
import type { AgentTool } from "@mariozechner/pi-ai";
import type { AgentTool } from "@mariozechner/pi-agent-core";
import { Type } from "@sinclair/typebox";
import * as Diff from "diff";
import { constants } from "fs";
@ -23,8 +23,13 @@ function restoreLineEndings(text: string, ending: "\r\n" | "\n"): string {
/**
* Generate a unified diff string with line numbers and context
* Returns both the diff string and the first changed line number (in the new file)
*/
function generateDiffString(oldContent: string, newContent: string, contextLines = 4): string {
function generateDiffString(
oldContent: string,
newContent: string,
contextLines = 4,
): { diff: string; firstChangedLine: number | undefined } {
const parts = Diff.diffLines(oldContent, newContent);
const output: string[] = [];
@ -36,6 +41,7 @@ function generateDiffString(oldContent: string, newContent: string, contextLines
let oldLineNum = 1;
let newLineNum = 1;
let lastWasChange = false;
let firstChangedLine: number | undefined;
for (let i = 0; i < parts.length; i++) {
const part = parts[i];
@ -45,6 +51,11 @@ function generateDiffString(oldContent: string, newContent: string, contextLines
}
if (part.added || part.removed) {
// Capture the first changed line (in the new file)
if (firstChangedLine === undefined) {
firstChangedLine = newLineNum;
}
// Show the change
for (const line of raw) {
if (part.added) {
@ -113,7 +124,7 @@ function generateDiffString(oldContent: string, newContent: string, contextLines
}
}
return output.join("\n");
return { diff: output.join("\n"), firstChangedLine };
}
const editSchema = Type.Object({
@ -122,6 +133,13 @@ const editSchema = Type.Object({
newText: Type.String({ description: "New text to replace the old text with" }),
});
export interface EditToolDetails {
/** Unified diff of the changes made */
diff: string;
/** Line number of the first change in the new file (for editor navigation) */
firstChangedLine?: number;
}
export function createEditTool(cwd: string): AgentTool<typeof editSchema> {
return {
name: "edit",
@ -138,7 +156,7 @@ export function createEditTool(cwd: string): AgentTool<typeof editSchema> {
return new Promise<{
content: Array<{ type: "text"; text: string }>;
details: { diff: string } | undefined;
details: EditToolDetails | undefined;
}>((resolve, reject) => {
// Check if already aborted
if (signal?.aborted) {
@ -257,6 +275,7 @@ export function createEditTool(cwd: string): AgentTool<typeof editSchema> {
signal.removeEventListener("abort", onAbort);
}
const diffResult = generateDiffString(normalizedContent, normalizedNewContent);
resolve({
content: [
{
@ -264,7 +283,7 @@ export function createEditTool(cwd: string): AgentTool<typeof editSchema> {
text: `Successfully replaced text in ${path}.`,
},
],
details: { diff: generateDiffString(normalizedContent, normalizedNewContent) },
details: { diff: diffResult.diff, firstChangedLine: diffResult.firstChangedLine },
});
} catch (error: any) {
// Clean up abort handler

View file

@ -1,4 +1,4 @@
import type { AgentTool } from "@mariozechner/pi-ai";
import type { AgentTool } from "@mariozechner/pi-agent-core";
import { Type } from "@sinclair/typebox";
import { spawnSync } from "child_process";
import { existsSync } from "fs";

View file

@ -1,5 +1,5 @@
import { createInterface } from "node:readline";
import type { AgentTool } from "@mariozechner/pi-ai";
import type { AgentTool } from "@mariozechner/pi-agent-core";
import { Type } from "@sinclair/typebox";
import { spawn } from "child_process";
import { readFileSync, type Stats, statSync } from "fs";

View file

@ -1,5 +1,3 @@
import type { AgentTool } from "@mariozechner/pi-ai";
export { type BashToolDetails, bashTool, createBashTool } from "./bash.js";
export { createEditTool, editTool } from "./edit.js";
export { createFindTool, type FindToolDetails, findTool } from "./find.js";
@ -9,6 +7,7 @@ export { createReadTool, type ReadToolDetails, readTool } from "./read.js";
export type { TruncationResult } from "./truncate.js";
export { createWriteTool, writeTool } from "./write.js";
import type { AgentTool } from "@mariozechner/pi-agent-core";
import { bashTool, createBashTool } from "./bash.js";
import { createEditTool, editTool } from "./edit.js";
import { createFindTool, findTool } from "./find.js";

View file

@ -1,4 +1,4 @@
import type { AgentTool } from "@mariozechner/pi-ai";
import type { AgentTool } from "@mariozechner/pi-agent-core";
import { Type } from "@sinclair/typebox";
import { existsSync, readdirSync, statSync } from "fs";
import nodePath from "path";

View file

@ -1,4 +1,5 @@
import type { AgentTool, ImageContent, TextContent } from "@mariozechner/pi-ai";
import type { AgentTool } from "@mariozechner/pi-agent-core";
import type { ImageContent, TextContent } from "@mariozechner/pi-ai";
import { Type } from "@sinclair/typebox";
import { constants } from "fs";
import { access, readFile } from "fs/promises";

View file

@ -1,4 +1,4 @@
import type { AgentTool } from "@mariozechner/pi-ai";
import type { AgentTool } from "@mariozechner/pi-agent-core";
import { Type } from "@sinclair/typebox";
import { mkdir, writeFile } from "fs/promises";
import { dirname } from "path";

View file

@ -4,7 +4,6 @@ export {
type AgentSessionConfig,
type AgentSessionEvent,
type AgentSessionEventListener,
type CompactionResult,
type ModelCycleResult,
type PromptOptions,
type SessionStats,
@ -13,56 +12,43 @@ export {
export { type ApiKeyCredential, type AuthCredential, AuthStorage, type OAuthCredential } from "./core/auth-storage.js";
// Compaction
export {
type BranchPreparation,
type BranchSummaryResult,
type CollectEntriesResult,
type CompactionResult,
type CutPointResult,
calculateContextTokens,
collectEntriesForBranchSummary,
compact,
DEFAULT_COMPACTION_SETTINGS,
estimateTokens,
type FileOperations,
findCutPoint,
findTurnStartIndex,
type GenerateBranchSummaryOptions,
generateBranchSummary,
generateSummary,
getLastAssistantUsage,
prepareBranchEntries,
serializeConversation,
shouldCompact,
} from "./core/compaction.js";
} from "./core/compaction/index.js";
// Custom tools
export type {
AgentToolUpdateCallback,
CustomAgentTool,
CustomTool,
CustomToolAPI,
CustomToolContext,
CustomToolFactory,
CustomToolSessionEvent,
CustomToolsLoadResult,
CustomToolUIContext,
ExecResult,
LoadedCustomTool,
RenderResultOptions,
SessionEvent as ToolSessionEvent,
ToolAPI,
ToolUIContext,
} from "./core/custom-tools/index.js";
export { discoverAndLoadCustomTools, loadCustomTools } from "./core/custom-tools/index.js";
export type {
AgentEndEvent,
AgentStartEvent,
BashToolResultEvent,
CustomToolResultEvent,
EditToolResultEvent,
FindToolResultEvent,
GrepToolResultEvent,
HookAPI,
HookEvent,
HookEventContext,
HookFactory,
HookUIContext,
LsToolResultEvent,
ReadToolResultEvent,
SessionEvent,
SessionEventResult,
ToolCallEvent,
ToolCallEventResult,
ToolResultEvent,
ToolResultEventResult,
TurnEndEvent,
TurnStartEvent,
WriteToolResultEvent,
} from "./core/hooks/index.js";
export type * from "./core/hooks/index.js";
// Hook system types and type guards
export {
isBashToolResult,
@ -73,7 +59,7 @@ export {
isReadToolResult,
isWriteToolResult,
} from "./core/hooks/index.js";
export { messageTransformer } from "./core/messages.js";
export { convertToLlm } from "./core/messages.js";
export { ModelRegistry } from "./core/model-registry.js";
// SDK for programmatic usage
export {
@ -102,25 +88,33 @@ export {
discoverSkills,
discoverSlashCommands,
type FileSlashCommand,
// Hook types
type HookAPI,
type HookContext,
type HookFactory,
loadSettings,
// Pre-built tools (use process.cwd())
readOnlyTools,
} from "./core/sdk.js";
export {
type BranchSummaryEntry,
buildSessionContext,
type CompactionEntry,
createSummaryMessage,
CURRENT_SESSION_VERSION,
type CustomEntry,
type CustomMessageEntry,
type FileEntry,
getLatestCompactionEntry,
type ModelChangeEntry,
migrateSessionEntries,
parseSessionEntries,
type SessionContext as LoadedSession,
type SessionContext,
type SessionEntry,
type SessionEntryBase,
type SessionHeader,
type SessionInfo,
SessionManager,
type SessionMessageEntry,
SUMMARY_PREFIX,
SUMMARY_SUFFIX,
type ThinkingLevelChangeEntry,
} from "./core/session-manager.js";
export {
@ -160,5 +154,7 @@ export {
} from "./core/tools/index.js";
// Main entry point
export { main } from "./main.js";
// UI components for hooks
export { BorderedLoader } from "./modes/interactive/components/bordered-loader.js";
// Theme utilities for custom tools
export { getMarkdownTheme } from "./modes/interactive/theme/theme.js";

View file

@ -5,8 +5,7 @@
* createAgentSession() options. The SDK does the heavy lifting.
*/
import type { Attachment } from "@mariozechner/pi-agent-core";
import { supportsXhigh } from "@mariozechner/pi-ai";
import { type ImageContent, supportsXhigh } from "@mariozechner/pi-ai";
import chalk from "chalk";
import { existsSync } from "fs";
import { join } from "path";
@ -34,10 +33,10 @@ import { initTheme, stopThemeWatcher } from "./modes/interactive/theme/theme.js"
import { getChangelogPath, getNewEntries, parseChangelog } from "./utils/changelog.js";
import { ensureTool } from "./utils/tools-manager.js";
async function checkForNewVersion(currentVersion: string): Promise<string | null> {
async function checkForNewVersion(currentVersion: string): Promise<string | undefined> {
try {
const response = await fetch("https://registry.npmjs.org/@mariozechner/pi -coding-agent/latest");
if (!response.ok) return null;
if (!response.ok) return undefined;
const data = (await response.json()) as { version?: string };
const latestVersion = data.version;
@ -46,26 +45,26 @@ async function checkForNewVersion(currentVersion: string): Promise<string | null
return latestVersion;
}
return null;
return undefined;
} catch {
return null;
return undefined;
}
}
async function runInteractiveMode(
session: AgentSession,
version: string,
changelogMarkdown: string | null,
changelogMarkdown: string | undefined,
modelFallbackMessage: string | undefined,
modelsJsonError: string | null,
modelsJsonError: string | undefined,
migratedProviders: string[],
versionCheckPromise: Promise<string | null>,
versionCheckPromise: Promise<string | undefined>,
initialMessages: string[],
customTools: LoadedCustomTool[],
setToolUIContext: (uiContext: HookUIContext, hasUI: boolean) => void,
initialMessage?: string,
initialAttachments?: Attachment[],
fdPath: string | null = null,
initialImages?: ImageContent[],
fdPath: string | undefined = undefined,
): Promise<void> {
const mode = new InteractiveMode(session, version, changelogMarkdown, customTools, setToolUIContext, fdPath);
@ -77,7 +76,7 @@ async function runInteractiveMode(
}
});
mode.renderInitialMessages(session.state);
mode.renderInitialMessages();
if (migratedProviders.length > 0) {
mode.showWarning(`Migrated credentials to auth.json: ${migratedProviders.join(", ")}`);
@ -93,7 +92,7 @@ async function runInteractiveMode(
if (initialMessage) {
try {
await session.prompt(initialMessage, { attachments: initialAttachments });
await session.prompt(initialMessage, { images: initialImages });
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
mode.showError(errorMessage);
@ -122,31 +121,31 @@ async function runInteractiveMode(
async function prepareInitialMessage(parsed: Args): Promise<{
initialMessage?: string;
initialAttachments?: Attachment[];
initialImages?: ImageContent[];
}> {
if (parsed.fileArgs.length === 0) {
return {};
}
const { textContent, imageAttachments } = await processFileArguments(parsed.fileArgs);
const { text, images } = await processFileArguments(parsed.fileArgs);
let initialMessage: string;
if (parsed.messages.length > 0) {
initialMessage = textContent + parsed.messages[0];
initialMessage = text + parsed.messages[0];
parsed.messages.shift();
} else {
initialMessage = textContent;
initialMessage = text;
}
return {
initialMessage,
initialAttachments: imageAttachments.length > 0 ? imageAttachments : undefined,
initialImages: images.length > 0 ? images : undefined,
};
}
function getChangelogForDisplay(parsed: Args, settingsManager: SettingsManager): string | null {
function getChangelogForDisplay(parsed: Args, settingsManager: SettingsManager): string | undefined {
if (parsed.continue || parsed.resume) {
return null;
return undefined;
}
const lastVersion = settingsManager.getLastChangelogVersion();
@ -166,10 +165,10 @@ function getChangelogForDisplay(parsed: Args, settingsManager: SettingsManager):
}
}
return null;
return undefined;
}
function createSessionManager(parsed: Args, cwd: string): SessionManager | null {
function createSessionManager(parsed: Args, cwd: string): SessionManager | undefined {
if (parsed.noSession) {
return SessionManager.inMemory();
}
@ -184,8 +183,8 @@ function createSessionManager(parsed: Args, cwd: string): SessionManager | null
if (parsed.sessionDir) {
return SessionManager.create(cwd, parsed.sessionDir);
}
// Default case (new session) returns null, SDK will create one
return null;
// Default case (new session) returns undefined, SDK will create one
return undefined;
}
/** Discover SYSTEM.md file if no CLI system prompt was provided */
@ -208,7 +207,7 @@ function discoverSystemPromptFile(): string | undefined {
function buildSessionOptions(
parsed: Args,
scopedModels: ScopedModel[],
sessionManager: SessionManager | null,
sessionManager: SessionManager | undefined,
modelRegistry: ModelRegistry,
): CreateAgentSessionOptions {
const options: CreateAgentSessionOptions = {};
@ -330,7 +329,7 @@ export async function main(args: string[]) {
}
const cwd = process.cwd();
const { initialMessage, initialAttachments } = await prepareInitialMessage(parsed);
const { initialMessage, initialImages } = await prepareInitialMessage(parsed);
time("prepareInitialMessage");
const isInteractive = !parsed.print && parsed.mode === undefined;
const mode = parsed.mode || "text";
@ -409,7 +408,7 @@ export async function main(args: string[]) {
if (mode === "rpc") {
await runRpcMode(session);
} else if (isInteractive) {
const versionCheckPromise = checkForNewVersion(VERSION).catch(() => null);
const versionCheckPromise = checkForNewVersion(VERSION).catch(() => undefined);
const changelogMarkdown = getChangelogForDisplay(parsed, settingsManager);
if (scopedModels.length > 0) {
@ -438,11 +437,11 @@ export async function main(args: string[]) {
customToolsResult.tools,
customToolsResult.setUIContext,
initialMessage,
initialAttachments,
initialImages,
fdPath,
);
} else {
await runPrintMode(session, mode, parsed.messages, initialMessage, initialAttachments);
await runPrintMode(session, mode, parsed.messages, initialMessage, initialImages);
stopThemeWatcher();
if (process.stdout.writableLength > 0) {
await new Promise<void>((resolve) => process.stdout.once("drain", resolve));

View file

@ -53,16 +53,15 @@ export class AssistantMessageComponent extends Container {
if (this.hideThinkingBlock) {
// Show static "Thinking..." label when hidden
this.contentContainer.addChild(new Text(theme.fg("muted", "Thinking..."), 1, 0));
this.contentContainer.addChild(new Text(theme.italic(theme.fg("thinkingText", "Thinking...")), 1, 0));
if (hasTextAfter) {
this.contentContainer.addChild(new Spacer(1));
}
} else {
// Thinking traces in muted color, italic
// Use Markdown component with default text style for consistent styling
// Thinking traces in thinkingText color, italic
this.contentContainer.addChild(
new Markdown(content.thinking.trim(), 1, 0, getMarkdownTheme(), {
color: (text: string) => theme.fg("muted", text),
color: (text: string) => theme.fg("thinkingText", text),
italic: true,
}),
);

View file

@ -21,7 +21,7 @@ export class BashExecutionComponent extends Container {
private command: string;
private outputLines: string[] = [];
private status: "running" | "complete" | "cancelled" | "error" = "running";
private exitCode: number | null = null;
private exitCode: number | undefined = undefined;
private loader: Loader;
private truncationResult?: TruncationResult;
private fullOutputPath?: string;
@ -90,13 +90,17 @@ export class BashExecutionComponent extends Container {
}
setComplete(
exitCode: number | null,
exitCode: number | undefined,
cancelled: boolean,
truncationResult?: TruncationResult,
fullOutputPath?: string,
): void {
this.exitCode = exitCode;
this.status = cancelled ? "cancelled" : exitCode !== 0 && exitCode !== null ? "error" : "complete";
this.status = cancelled
? "cancelled"
: exitCode !== 0 && exitCode !== undefined && exitCode !== null
? "error"
: "complete";
this.truncationResult = truncationResult;
this.fullOutputPath = fullOutputPath;

View file

@ -0,0 +1,41 @@
import { CancellableLoader, Container, Spacer, Text, type TUI } from "@mariozechner/pi-tui";
import type { Theme } from "../theme/theme.js";
import { DynamicBorder } from "./dynamic-border.js";
/** Loader wrapped with borders for hook UI */
export class BorderedLoader extends Container {
private loader: CancellableLoader;
constructor(tui: TUI, theme: Theme, message: string) {
super();
this.addChild(new DynamicBorder());
this.addChild(new Spacer(1));
this.loader = new CancellableLoader(
tui,
(s) => theme.fg("accent", s),
(s) => theme.fg("muted", s),
message,
);
this.addChild(this.loader);
this.addChild(new Spacer(1));
this.addChild(new Text(theme.fg("muted", "esc cancel"), 1, 0));
this.addChild(new Spacer(1));
this.addChild(new DynamicBorder());
}
get signal(): AbortSignal {
return this.loader.signal;
}
set onAbort(fn: (() => void) | undefined) {
this.loader.onAbort = fn;
}
handleInput(data: string): void {
this.loader.handleInput(data);
}
dispose(): void {
this.loader.dispose();
}
}

View file

@ -0,0 +1,42 @@
import { Box, Markdown, Spacer, Text } from "@mariozechner/pi-tui";
import type { BranchSummaryMessage } from "../../../core/messages.js";
import { getMarkdownTheme, theme } from "../theme/theme.js";
/**
* Component that renders a branch summary message with collapsed/expanded state.
* Uses same background color as hook messages for visual consistency.
*/
export class BranchSummaryMessageComponent extends Box {
private expanded = false;
private message: BranchSummaryMessage;
constructor(message: BranchSummaryMessage) {
super(1, 1, (t) => theme.bg("customMessageBg", t));
this.message = message;
this.updateDisplay();
}
setExpanded(expanded: boolean): void {
this.expanded = expanded;
this.updateDisplay();
}
private updateDisplay(): void {
this.clear();
const label = theme.fg("customMessageLabel", `\x1b[1m[branch]\x1b[22m`);
this.addChild(new Text(label, 0, 0));
this.addChild(new Spacer(1));
if (this.expanded) {
const header = "**Branch Summary**\n\n";
this.addChild(
new Markdown(header + this.message.summary, 0, 0, getMarkdownTheme(), {
color: (text: string) => theme.fg("customMessageText", text),
}),
);
} else {
this.addChild(new Text(theme.fg("customMessageText", "Branch summary (ctrl+o to expand)"), 0, 0));
}
}
}

View file

@ -0,0 +1,45 @@
import { Box, Markdown, Spacer, Text } from "@mariozechner/pi-tui";
import type { CompactionSummaryMessage } from "../../../core/messages.js";
import { getMarkdownTheme, theme } from "../theme/theme.js";
/**
* Component that renders a compaction message with collapsed/expanded state.
* Uses same background color as hook messages for visual consistency.
*/
export class CompactionSummaryMessageComponent extends Box {
private expanded = false;
private message: CompactionSummaryMessage;
constructor(message: CompactionSummaryMessage) {
super(1, 1, (t) => theme.bg("customMessageBg", t));
this.message = message;
this.updateDisplay();
}
setExpanded(expanded: boolean): void {
this.expanded = expanded;
this.updateDisplay();
}
private updateDisplay(): void {
this.clear();
const tokenStr = this.message.tokensBefore.toLocaleString();
const label = theme.fg("customMessageLabel", `\x1b[1m[compaction]\x1b[22m`);
this.addChild(new Text(label, 0, 0));
this.addChild(new Spacer(1));
if (this.expanded) {
const header = `**Compacted from ${tokenStr} tokens**\n\n`;
this.addChild(
new Markdown(header + this.message.summary, 0, 0, getMarkdownTheme(), {
color: (text: string) => theme.fg("customMessageText", text),
}),
);
} else {
this.addChild(
new Text(theme.fg("customMessageText", `Compacted from ${tokenStr} tokens (ctrl+o to expand)`), 0, 0),
);
}
}
}

View file

@ -1,52 +0,0 @@
import { Container, Markdown, Spacer, Text } from "@mariozechner/pi-tui";
import { getMarkdownTheme, theme } from "../theme/theme.js";
/**
* Component that renders a compaction indicator with collapsed/expanded state.
* Collapsed: shows "Context compacted from X tokens"
* Expanded: shows the full summary rendered as markdown (like a user message)
*/
export class CompactionComponent extends Container {
private expanded = false;
private tokensBefore: number;
private summary: string;
constructor(tokensBefore: number, summary: string) {
super();
this.tokensBefore = tokensBefore;
this.summary = summary;
this.updateDisplay();
}
setExpanded(expanded: boolean): void {
this.expanded = expanded;
this.updateDisplay();
}
private updateDisplay(): void {
this.clear();
if (this.expanded) {
// Show header + summary as markdown (like user message)
this.addChild(new Spacer(1));
const header = `**Context compacted from ${this.tokensBefore.toLocaleString()} tokens**\n\n`;
this.addChild(
new Markdown(header + this.summary, 1, 1, getMarkdownTheme(), {
bgColor: (text: string) => theme.bg("userMessageBg", text),
color: (text: string) => theme.fg("userMessageText", text),
}),
);
this.addChild(new Spacer(1));
} else {
// Collapsed: simple text in warning color with token count
const tokenStr = this.tokensBefore.toLocaleString();
this.addChild(
new Text(
theme.fg("warning", `Earlier messages compacted from ${tokenStr} tokens (ctrl+o to expand)`),
1,
1,
),
);
}
}
}

View file

@ -0,0 +1,96 @@
import type { TextContent } from "@mariozechner/pi-ai";
import type { Component } from "@mariozechner/pi-tui";
import { Box, Container, Markdown, Spacer, Text } from "@mariozechner/pi-tui";
import type { HookMessageRenderer } from "../../../core/hooks/types.js";
import type { HookMessage } from "../../../core/messages.js";
import { getMarkdownTheme, theme } from "../theme/theme.js";
/**
* Component that renders a custom message entry from hooks.
* Uses distinct styling to differentiate from user messages.
*/
export class HookMessageComponent extends Container {
private message: HookMessage<unknown>;
private customRenderer?: HookMessageRenderer;
private box: Box;
private customComponent?: Component;
private _expanded = false;
constructor(message: HookMessage<unknown>, customRenderer?: HookMessageRenderer) {
super();
this.message = message;
this.customRenderer = customRenderer;
this.addChild(new Spacer(1));
// Create box with purple background (used for default rendering)
this.box = new Box(1, 1, (t) => theme.bg("customMessageBg", t));
this.rebuild();
}
setExpanded(expanded: boolean): void {
if (this._expanded !== expanded) {
this._expanded = expanded;
this.rebuild();
}
}
private rebuild(): void {
// Remove previous content component
if (this.customComponent) {
this.removeChild(this.customComponent);
this.customComponent = undefined;
}
this.removeChild(this.box);
// Try custom renderer first - it handles its own styling
if (this.customRenderer) {
try {
const component = this.customRenderer(this.message, { expanded: this._expanded }, theme);
if (component) {
// Custom renderer provides its own styled component
this.customComponent = component;
this.addChild(component);
return;
}
} catch {
// Fall through to default rendering
}
}
// Default rendering uses our box
this.addChild(this.box);
this.box.clear();
// Default rendering: label + content
const label = theme.fg("customMessageLabel", `\x1b[1m[${this.message.customType}]\x1b[22m`);
this.box.addChild(new Text(label, 0, 0));
this.box.addChild(new Spacer(1));
// Extract text content
let text: string;
if (typeof this.message.content === "string") {
text = this.message.content;
} else {
text = this.message.content
.filter((c): c is TextContent => c.type === "text")
.map((c) => c.text)
.join("\n");
}
// Limit lines when collapsed
if (!this._expanded) {
const lines = text.split("\n");
if (lines.length > 5) {
text = `${lines.slice(0, 5).join("\n")}\n...`;
}
}
this.box.addChild(
new Markdown(text, 0, 0, getMarkdownTheme(), {
color: (text: string) => theme.fg("customMessageText", text),
}),
);
}
}

View file

@ -36,18 +36,18 @@ export class ModelSelectorComponent extends Container {
private allModels: ModelItem[] = [];
private filteredModels: ModelItem[] = [];
private selectedIndex: number = 0;
private currentModel: Model<any> | null;
private currentModel?: Model<any>;
private settingsManager: SettingsManager;
private modelRegistry: ModelRegistry;
private onSelectCallback: (model: Model<any>) => void;
private onCancelCallback: () => void;
private errorMessage: string | null = null;
private errorMessage?: string;
private tui: TUI;
private scopedModels: ReadonlyArray<ScopedModelItem>;
constructor(
tui: TUI,
currentModel: Model<any> | null,
currentModel: Model<any> | undefined,
settingsManager: SettingsManager,
modelRegistry: ModelRegistry,
scopedModels: ReadonlyArray<ScopedModelItem>,

View file

@ -11,7 +11,7 @@ import {
type TUI,
} from "@mariozechner/pi-tui";
import stripAnsi from "strip-ansi";
import type { CustomAgentTool } from "../../../core/custom-tools/types.js";
import type { CustomTool } from "../../../core/custom-tools/types.js";
import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize } from "../../../core/tools/truncate.js";
import { getLanguageFromPath, highlightCode, theme } from "../theme/theme.js";
import { renderDiff } from "./diff.js";
@ -55,7 +55,7 @@ export class ToolExecutionComponent extends Container {
private expanded = false;
private showImages: boolean;
private isPartial = true;
private customTool?: CustomAgentTool;
private customTool?: CustomTool;
private ui: TUI;
private result?: {
content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>;
@ -67,7 +67,7 @@ export class ToolExecutionComponent extends Container {
toolName: string,
args: any,
options: ToolExecutionOptions = {},
customTool: CustomAgentTool | undefined,
customTool: CustomTool | undefined,
ui: TUI,
) {
super();
@ -415,10 +415,14 @@ export class ToolExecutionComponent extends Container {
} else if (this.toolName === "edit") {
const rawPath = this.args?.file_path || this.args?.path || "";
const path = shortenPath(rawPath);
text =
theme.fg("toolTitle", theme.bold("edit")) +
" " +
(path ? theme.fg("accent", path) : theme.fg("toolOutput", "..."));
// Build path display, appending :line if we have a successful result with line info
let pathDisplay = path ? theme.fg("accent", path) : theme.fg("toolOutput", "...");
if (this.result && !this.result.isError && this.result.details?.firstChangedLine) {
pathDisplay += theme.fg("warning", `:${this.result.details.firstChangedLine}`);
}
text = `${theme.fg("toolTitle", theme.bold("edit"))} ${pathDisplay}`;
if (this.result) {
if (this.result.isError) {

View file

@ -0,0 +1,866 @@
import {
type Component,
Container,
Input,
isArrowDown,
isArrowLeft,
isArrowRight,
isArrowUp,
isBackspace,
isCtrlC,
isCtrlO,
isEnter,
isEscape,
isShiftCtrlO,
Spacer,
Text,
TruncatedText,
truncateToWidth,
} from "@mariozechner/pi-tui";
import type { SessionTreeNode } from "../../../core/session-manager.js";
import { theme } from "../theme/theme.js";
import { DynamicBorder } from "./dynamic-border.js";
/** Gutter info: position (displayIndent where connector was) and whether to show │ */
interface GutterInfo {
position: number; // displayIndent level where the connector was shown
show: boolean; // true = show │, false = show spaces
}
/** Flattened tree node for navigation */
interface FlatNode {
node: SessionTreeNode;
/** Indentation level (each level = 3 chars) */
indent: number;
/** Whether to show connector (├─ or └─) - true if parent has multiple children */
showConnector: boolean;
/** If showConnector, true = last sibling (└─), false = not last (├─) */
isLast: boolean;
/** Gutter info for each ancestor branch point */
gutters: GutterInfo[];
/** True if this node is a root under a virtual branching root (multiple roots) */
isVirtualRootChild: boolean;
}
/** Filter mode for tree display */
type FilterMode = "default" | "no-tools" | "user-only" | "labeled-only" | "all";
/**
* Tree list component with selection and ASCII art visualization
*/
/** Tool call info for lookup */
interface ToolCallInfo {
name: string;
arguments: Record<string, unknown>;
}
class TreeList implements Component {
private flatNodes: FlatNode[] = [];
private filteredNodes: FlatNode[] = [];
private selectedIndex = 0;
private currentLeafId: string | null;
private maxVisibleLines: number;
private filterMode: FilterMode = "default";
private searchQuery = "";
private toolCallMap: Map<string, ToolCallInfo> = new Map();
private multipleRoots = false;
private activePathIds: Set<string> = new Set();
public onSelect?: (entryId: string) => void;
public onCancel?: () => void;
public onLabelEdit?: (entryId: string, currentLabel: string | undefined) => void;
constructor(tree: SessionTreeNode[], currentLeafId: string | null, maxVisibleLines: number) {
this.currentLeafId = currentLeafId;
this.maxVisibleLines = maxVisibleLines;
this.multipleRoots = tree.length > 1;
this.flatNodes = this.flattenTree(tree);
this.buildActivePath();
this.applyFilter();
// Start with current leaf selected
const leafIndex = this.filteredNodes.findIndex((n) => n.node.entry.id === currentLeafId);
if (leafIndex !== -1) {
this.selectedIndex = leafIndex;
} else {
this.selectedIndex = Math.max(0, this.filteredNodes.length - 1);
}
}
/** Build the set of entry IDs on the path from root to current leaf */
private buildActivePath(): void {
this.activePathIds.clear();
if (!this.currentLeafId) return;
// Build a map of id -> entry for parent lookup
const entryMap = new Map<string, FlatNode>();
for (const flatNode of this.flatNodes) {
entryMap.set(flatNode.node.entry.id, flatNode);
}
// Walk from leaf to root
let currentId: string | null = this.currentLeafId;
while (currentId) {
this.activePathIds.add(currentId);
const node = entryMap.get(currentId);
if (!node) break;
currentId = node.node.entry.parentId ?? null;
}
}
private flattenTree(roots: SessionTreeNode[]): FlatNode[] {
const result: FlatNode[] = [];
this.toolCallMap.clear();
// Indentation rules:
// - At indent 0: stay at 0 unless parent has >1 children (then +1)
// - At indent 1: children always go to indent 2 (visual grouping of subtree)
// - At indent 2+: stay flat for single-child chains, +1 only if parent branches
// Stack items: [node, indent, justBranched, showConnector, isLast, gutters, isVirtualRootChild]
type StackItem = [SessionTreeNode, number, boolean, boolean, boolean, GutterInfo[], boolean];
const stack: StackItem[] = [];
// Determine which subtrees contain the active leaf (to sort current branch first)
// Use iterative post-order traversal to avoid stack overflow
const containsActive = new Map<SessionTreeNode, boolean>();
const leafId = this.currentLeafId;
{
// Build list in pre-order, then process in reverse for post-order effect
const allNodes: SessionTreeNode[] = [];
const preOrderStack: SessionTreeNode[] = [...roots];
while (preOrderStack.length > 0) {
const node = preOrderStack.pop()!;
allNodes.push(node);
// Push children in reverse so they're processed left-to-right
for (let i = node.children.length - 1; i >= 0; i--) {
preOrderStack.push(node.children[i]);
}
}
// Process in reverse (post-order): children before parents
for (let i = allNodes.length - 1; i >= 0; i--) {
const node = allNodes[i];
let has = leafId !== null && node.entry.id === leafId;
for (const child of node.children) {
if (containsActive.get(child)) {
has = true;
}
}
containsActive.set(node, has);
}
}
// Add roots in reverse order, prioritizing the one containing the active leaf
// If multiple roots, treat them as children of a virtual root that branches
const multipleRoots = roots.length > 1;
const orderedRoots = [...roots].sort((a, b) => Number(containsActive.get(b)) - Number(containsActive.get(a)));
for (let i = orderedRoots.length - 1; i >= 0; i--) {
const isLast = i === orderedRoots.length - 1;
stack.push([orderedRoots[i], multipleRoots ? 1 : 0, multipleRoots, multipleRoots, isLast, [], multipleRoots]);
}
while (stack.length > 0) {
const [node, indent, justBranched, showConnector, isLast, gutters, isVirtualRootChild] = stack.pop()!;
// Extract tool calls from assistant messages for later lookup
const entry = node.entry;
if (entry.type === "message" && entry.message.role === "assistant") {
const content = (entry.message as { content?: unknown }).content;
if (Array.isArray(content)) {
for (const block of content) {
if (typeof block === "object" && block !== null && "type" in block && block.type === "toolCall") {
const tc = block as { id: string; name: string; arguments: Record<string, unknown> };
this.toolCallMap.set(tc.id, { name: tc.name, arguments: tc.arguments });
}
}
}
}
result.push({ node, indent, showConnector, isLast, gutters, isVirtualRootChild });
const children = node.children;
const multipleChildren = children.length > 1;
// Order children so the branch containing the active leaf comes first
const orderedChildren = (() => {
const prioritized: SessionTreeNode[] = [];
const rest: SessionTreeNode[] = [];
for (const child of children) {
if (containsActive.get(child)) {
prioritized.push(child);
} else {
rest.push(child);
}
}
return [...prioritized, ...rest];
})();
// Calculate child indent
let childIndent: number;
if (multipleChildren) {
// Parent branches: children get +1
childIndent = indent + 1;
} else if (justBranched && indent > 0) {
// First generation after a branch: +1 for visual grouping
childIndent = indent + 1;
} else {
// Single-child chain: stay flat
childIndent = indent;
}
// Build gutters for children
// If this node showed a connector, add a gutter entry for descendants
// Only add gutter if connector is actually displayed (not suppressed for virtual root children)
const connectorDisplayed = showConnector && !isVirtualRootChild;
// When connector is displayed, add a gutter entry at the connector's position
// Connector is at position (displayIndent - 1), so gutter should be there too
const currentDisplayIndent = this.multipleRoots ? Math.max(0, indent - 1) : indent;
const connectorPosition = Math.max(0, currentDisplayIndent - 1);
const childGutters: GutterInfo[] = connectorDisplayed
? [...gutters, { position: connectorPosition, show: !isLast }]
: gutters;
// Add children in reverse order
for (let i = orderedChildren.length - 1; i >= 0; i--) {
const childIsLast = i === orderedChildren.length - 1;
stack.push([
orderedChildren[i],
childIndent,
multipleChildren,
multipleChildren,
childIsLast,
childGutters,
false,
]);
}
}
return result;
}
private applyFilter(): void {
// Remember currently selected node to preserve cursor position
const previouslySelectedId = this.filteredNodes[this.selectedIndex]?.node.entry.id;
const searchTokens = this.searchQuery.toLowerCase().split(/\s+/).filter(Boolean);
this.filteredNodes = this.flatNodes.filter((flatNode) => {
const entry = flatNode.node.entry;
const isCurrentLeaf = entry.id === this.currentLeafId;
// Skip assistant messages with only tool calls (no text) unless error/aborted
// Always show current leaf so active position is visible
if (entry.type === "message" && entry.message.role === "assistant" && !isCurrentLeaf) {
const msg = entry.message as { stopReason?: string; content?: unknown };
const hasText = this.hasTextContent(msg.content);
const isErrorOrAborted = msg.stopReason && msg.stopReason !== "stop" && msg.stopReason !== "toolUse";
// Only hide if no text AND not an error/aborted message
if (!hasText && !isErrorOrAborted) {
return false;
}
}
// Apply filter mode
let passesFilter = true;
// Entry types hidden in default view (settings/bookkeeping)
const isSettingsEntry =
entry.type === "label" ||
entry.type === "custom" ||
entry.type === "model_change" ||
entry.type === "thinking_level_change";
switch (this.filterMode) {
case "user-only":
// Just user messages
passesFilter = entry.type === "message" && entry.message.role === "user";
break;
case "no-tools":
// Default minus tool results
passesFilter = !isSettingsEntry && !(entry.type === "message" && entry.message.role === "toolResult");
break;
case "labeled-only":
// Just labeled entries
passesFilter = flatNode.node.label !== undefined;
break;
case "all":
// Show everything
passesFilter = true;
break;
default:
// Default mode: hide settings/bookkeeping entries
passesFilter = !isSettingsEntry;
break;
}
if (!passesFilter) return false;
// Apply search filter
if (searchTokens.length > 0) {
const nodeText = this.getSearchableText(flatNode.node).toLowerCase();
return searchTokens.every((token) => nodeText.includes(token));
}
return true;
});
// Try to preserve cursor on the same node after filtering
if (previouslySelectedId) {
const newIndex = this.filteredNodes.findIndex((n) => n.node.entry.id === previouslySelectedId);
if (newIndex !== -1) {
this.selectedIndex = newIndex;
return;
}
}
// Fall back: clamp index if out of bounds
if (this.selectedIndex >= this.filteredNodes.length) {
this.selectedIndex = Math.max(0, this.filteredNodes.length - 1);
}
}
/** Get searchable text content from a node */
private getSearchableText(node: SessionTreeNode): string {
const entry = node.entry;
const parts: string[] = [];
if (node.label) {
parts.push(node.label);
}
switch (entry.type) {
case "message": {
const msg = entry.message;
parts.push(msg.role);
if ("content" in msg && msg.content) {
parts.push(this.extractContent(msg.content));
}
if (msg.role === "bashExecution") {
const bashMsg = msg as { command?: string };
if (bashMsg.command) parts.push(bashMsg.command);
}
break;
}
case "custom_message": {
parts.push(entry.customType);
if (typeof entry.content === "string") {
parts.push(entry.content);
} else {
parts.push(this.extractContent(entry.content));
}
break;
}
case "compaction":
parts.push("compaction");
break;
case "branch_summary":
parts.push("branch summary", entry.summary);
break;
case "model_change":
parts.push("model", entry.modelId);
break;
case "thinking_level_change":
parts.push("thinking", entry.thinkingLevel);
break;
case "custom":
parts.push("custom", entry.customType);
break;
case "label":
parts.push("label", entry.label ?? "");
break;
}
return parts.join(" ");
}
invalidate(): void {}
getSearchQuery(): string {
return this.searchQuery;
}
getSelectedNode(): SessionTreeNode | undefined {
return this.filteredNodes[this.selectedIndex]?.node;
}
updateNodeLabel(entryId: string, label: string | undefined): void {
for (const flatNode of this.flatNodes) {
if (flatNode.node.entry.id === entryId) {
flatNode.node.label = label;
break;
}
}
}
private getFilterLabel(): string {
switch (this.filterMode) {
case "no-tools":
return " [no-tools]";
case "user-only":
return " [user]";
case "labeled-only":
return " [labeled]";
case "all":
return " [all]";
default:
return "";
}
}
render(width: number): string[] {
const lines: string[] = [];
if (this.filteredNodes.length === 0) {
lines.push(truncateToWidth(theme.fg("muted", " No entries found"), width));
lines.push(truncateToWidth(theme.fg("muted", ` (0/0)${this.getFilterLabel()}`), width));
return lines;
}
const startIndex = Math.max(
0,
Math.min(
this.selectedIndex - Math.floor(this.maxVisibleLines / 2),
this.filteredNodes.length - this.maxVisibleLines,
),
);
const endIndex = Math.min(startIndex + this.maxVisibleLines, this.filteredNodes.length);
for (let i = startIndex; i < endIndex; i++) {
const flatNode = this.filteredNodes[i];
const entry = flatNode.node.entry;
const isSelected = i === this.selectedIndex;
// Build line: cursor + prefix + path marker + label + content
const cursor = isSelected ? theme.fg("accent", " ") : " ";
// If multiple roots, shift display (roots at 0, not 1)
const displayIndent = this.multipleRoots ? Math.max(0, flatNode.indent - 1) : flatNode.indent;
// Build prefix with gutters at their correct positions
// Each gutter has a position (displayIndent where its connector was shown)
const connector =
flatNode.showConnector && !flatNode.isVirtualRootChild ? (flatNode.isLast ? "└─ " : "├─ ") : "";
const connectorPosition = connector ? displayIndent - 1 : -1;
// Build prefix char by char, placing gutters and connector at their positions
const totalChars = displayIndent * 3;
const prefixChars: string[] = [];
for (let i = 0; i < totalChars; i++) {
const level = Math.floor(i / 3);
const posInLevel = i % 3;
// Check if there's a gutter at this level
const gutter = flatNode.gutters.find((g) => g.position === level);
if (gutter) {
if (posInLevel === 0) {
prefixChars.push(gutter.show ? "│" : " ");
} else {
prefixChars.push(" ");
}
} else if (connector && level === connectorPosition) {
// Connector at this level
if (posInLevel === 0) {
prefixChars.push(flatNode.isLast ? "└" : "├");
} else if (posInLevel === 1) {
prefixChars.push("─");
} else {
prefixChars.push(" ");
}
} else {
prefixChars.push(" ");
}
}
const prefix = prefixChars.join("");
// Active path marker - shown right before the entry text
const isOnActivePath = this.activePathIds.has(entry.id);
const pathMarker = isOnActivePath ? theme.fg("accent", "• ") : "";
const label = flatNode.node.label ? theme.fg("warning", `[${flatNode.node.label}] `) : "";
const content = this.getEntryDisplayText(flatNode.node, isSelected);
let line = cursor + theme.fg("dim", prefix) + pathMarker + label + content;
if (isSelected) {
line = theme.bg("selectedBg", line);
}
lines.push(truncateToWidth(line, width));
}
lines.push(
truncateToWidth(
theme.fg("muted", ` (${this.selectedIndex + 1}/${this.filteredNodes.length})${this.getFilterLabel()}`),
width,
),
);
return lines;
}
private getEntryDisplayText(node: SessionTreeNode, isSelected: boolean): string {
const entry = node.entry;
let result: string;
const normalize = (s: string) => s.replace(/[\n\t]/g, " ").trim();
switch (entry.type) {
case "message": {
const msg = entry.message;
const role = msg.role;
if (role === "user") {
const msgWithContent = msg as { content?: unknown };
const content = normalize(this.extractContent(msgWithContent.content));
result = theme.fg("accent", "user: ") + content;
} else if (role === "assistant") {
const msgWithContent = msg as { content?: unknown; stopReason?: string; errorMessage?: string };
const textContent = normalize(this.extractContent(msgWithContent.content));
if (textContent) {
result = theme.fg("success", "assistant: ") + textContent;
} else if (msgWithContent.stopReason === "aborted") {
result = theme.fg("success", "assistant: ") + theme.fg("muted", "(aborted)");
} else if (msgWithContent.errorMessage) {
const errMsg = normalize(msgWithContent.errorMessage).slice(0, 80);
result = theme.fg("success", "assistant: ") + theme.fg("error", errMsg);
} else {
result = theme.fg("success", "assistant: ") + theme.fg("muted", "(no content)");
}
} else if (role === "toolResult") {
const toolMsg = msg as { toolCallId?: string; toolName?: string };
const toolCall = toolMsg.toolCallId ? this.toolCallMap.get(toolMsg.toolCallId) : undefined;
if (toolCall) {
result = theme.fg("muted", this.formatToolCall(toolCall.name, toolCall.arguments));
} else {
result = theme.fg("muted", `[${toolMsg.toolName ?? "tool"}]`);
}
} else if (role === "bashExecution") {
const bashMsg = msg as { command?: string };
result = theme.fg("dim", `[bash]: ${normalize(bashMsg.command ?? "")}`);
} else {
result = theme.fg("dim", `[${role}]`);
}
break;
}
case "custom_message": {
const content =
typeof entry.content === "string"
? entry.content
: entry.content
.filter((c): c is { type: "text"; text: string } => c.type === "text")
.map((c) => c.text)
.join("");
result = theme.fg("customMessageLabel", `[${entry.customType}]: `) + normalize(content);
break;
}
case "compaction": {
const tokens = Math.round(entry.tokensBefore / 1000);
result = theme.fg("borderAccent", `[compaction: ${tokens}k tokens]`);
break;
}
case "branch_summary":
result = theme.fg("warning", `[branch summary]: `) + normalize(entry.summary);
break;
case "model_change":
result = theme.fg("dim", `[model: ${entry.modelId}]`);
break;
case "thinking_level_change":
result = theme.fg("dim", `[thinking: ${entry.thinkingLevel}]`);
break;
case "custom":
result = theme.fg("dim", `[custom: ${entry.customType}]`);
break;
case "label":
result = theme.fg("dim", `[label: ${entry.label ?? "(cleared)"}]`);
break;
default:
result = "";
}
return isSelected ? theme.bold(result) : result;
}
private extractContent(content: unknown): string {
const maxLen = 200;
if (typeof content === "string") return content.slice(0, maxLen);
if (Array.isArray(content)) {
let result = "";
for (const c of content) {
if (typeof c === "object" && c !== null && "type" in c && c.type === "text") {
result += (c as { text: string }).text;
if (result.length >= maxLen) return result.slice(0, maxLen);
}
}
return result;
}
return "";
}
private hasTextContent(content: unknown): boolean {
if (typeof content === "string") return content.trim().length > 0;
if (Array.isArray(content)) {
for (const c of content) {
if (typeof c === "object" && c !== null && "type" in c && c.type === "text") {
const text = (c as { text?: string }).text;
if (text && text.trim().length > 0) return true;
}
}
}
return false;
}
private formatToolCall(name: string, args: Record<string, unknown>): string {
const shortenPath = (p: string): string => {
const home = process.env.HOME || process.env.USERPROFILE || "";
if (home && p.startsWith(home)) return `~${p.slice(home.length)}`;
return p;
};
switch (name) {
case "read": {
const path = shortenPath(String(args.path || args.file_path || ""));
const offset = args.offset as number | undefined;
const limit = args.limit as number | undefined;
let display = path;
if (offset !== undefined || limit !== undefined) {
const start = offset ?? 1;
const end = limit !== undefined ? start + limit - 1 : "";
display += `:${start}${end ? `-${end}` : ""}`;
}
return `[read: ${display}]`;
}
case "write": {
const path = shortenPath(String(args.path || args.file_path || ""));
return `[write: ${path}]`;
}
case "edit": {
const path = shortenPath(String(args.path || args.file_path || ""));
return `[edit: ${path}]`;
}
case "bash": {
const rawCmd = String(args.command || "");
const cmd = rawCmd
.replace(/[\n\t]/g, " ")
.trim()
.slice(0, 50);
return `[bash: ${cmd}${rawCmd.length > 50 ? "..." : ""}]`;
}
case "grep": {
const pattern = String(args.pattern || "");
const path = shortenPath(String(args.path || "."));
return `[grep: /${pattern}/ in ${path}]`;
}
case "find": {
const pattern = String(args.pattern || "");
const path = shortenPath(String(args.path || "."));
return `[find: ${pattern} in ${path}]`;
}
case "ls": {
const path = shortenPath(String(args.path || "."));
return `[ls: ${path}]`;
}
default: {
// Custom tool - show name and truncated JSON args
const argsStr = JSON.stringify(args).slice(0, 40);
return `[${name}: ${argsStr}${JSON.stringify(args).length > 40 ? "..." : ""}]`;
}
}
}
handleInput(keyData: string): void {
if (isArrowUp(keyData)) {
this.selectedIndex = this.selectedIndex === 0 ? this.filteredNodes.length - 1 : this.selectedIndex - 1;
} else if (isArrowDown(keyData)) {
this.selectedIndex = this.selectedIndex === this.filteredNodes.length - 1 ? 0 : this.selectedIndex + 1;
} else if (isArrowLeft(keyData)) {
// Page up
this.selectedIndex = Math.max(0, this.selectedIndex - this.maxVisibleLines);
} else if (isArrowRight(keyData)) {
// Page down
this.selectedIndex = Math.min(this.filteredNodes.length - 1, this.selectedIndex + this.maxVisibleLines);
} else if (isEnter(keyData)) {
const selected = this.filteredNodes[this.selectedIndex];
if (selected && this.onSelect) {
this.onSelect(selected.node.entry.id);
}
} else if (isEscape(keyData)) {
if (this.searchQuery) {
this.searchQuery = "";
this.applyFilter();
} else {
this.onCancel?.();
}
} else if (isCtrlC(keyData)) {
this.onCancel?.();
} else if (isShiftCtrlO(keyData)) {
// Cycle filter backwards
const modes: FilterMode[] = ["default", "no-tools", "user-only", "labeled-only", "all"];
const currentIndex = modes.indexOf(this.filterMode);
this.filterMode = modes[(currentIndex - 1 + modes.length) % modes.length];
this.applyFilter();
} else if (isCtrlO(keyData)) {
// Cycle filter forwards: default → no-tools → user-only → labeled-only → all → default
const modes: FilterMode[] = ["default", "no-tools", "user-only", "labeled-only", "all"];
const currentIndex = modes.indexOf(this.filterMode);
this.filterMode = modes[(currentIndex + 1) % modes.length];
this.applyFilter();
} else if (isBackspace(keyData)) {
if (this.searchQuery.length > 0) {
this.searchQuery = this.searchQuery.slice(0, -1);
this.applyFilter();
}
} else if (keyData === "l" && !this.searchQuery) {
const selected = this.filteredNodes[this.selectedIndex];
if (selected && this.onLabelEdit) {
this.onLabelEdit(selected.node.entry.id, selected.node.label);
}
} else {
const hasControlChars = [...keyData].some((ch) => {
const code = ch.charCodeAt(0);
return code < 32 || code === 0x7f || (code >= 0x80 && code <= 0x9f);
});
if (!hasControlChars && keyData.length > 0) {
this.searchQuery += keyData;
this.applyFilter();
}
}
}
}
/** Component that displays the current search query */
class SearchLine implements Component {
constructor(private treeList: TreeList) {}
invalidate(): void {}
render(width: number): string[] {
const query = this.treeList.getSearchQuery();
if (query) {
return [truncateToWidth(` ${theme.fg("muted", "Search:")} ${theme.fg("accent", query)}`, width)];
}
return [truncateToWidth(` ${theme.fg("muted", "Search:")}`, width)];
}
handleInput(_keyData: string): void {}
}
/** Label input component shown when editing a label */
class LabelInput implements Component {
private input: Input;
private entryId: string;
public onSubmit?: (entryId: string, label: string | undefined) => void;
public onCancel?: () => void;
constructor(entryId: string, currentLabel: string | undefined) {
this.entryId = entryId;
this.input = new Input();
if (currentLabel) {
this.input.setValue(currentLabel);
}
}
invalidate(): void {}
render(width: number): string[] {
const lines: string[] = [];
const indent = " ";
const availableWidth = width - indent.length;
lines.push(truncateToWidth(`${indent}${theme.fg("muted", "Label (empty to remove):")}`, width));
lines.push(...this.input.render(availableWidth).map((line) => truncateToWidth(`${indent}${line}`, width)));
lines.push(truncateToWidth(`${indent}${theme.fg("dim", "enter: save esc: cancel")}`, width));
return lines;
}
handleInput(keyData: string): void {
if (isEnter(keyData)) {
const value = this.input.getValue().trim();
this.onSubmit?.(this.entryId, value || undefined);
} else if (isEscape(keyData)) {
this.onCancel?.();
} else {
this.input.handleInput(keyData);
}
}
}
/**
* Component that renders a session tree selector for navigation
*/
export class TreeSelectorComponent extends Container {
private treeList: TreeList;
private labelInput: LabelInput | null = null;
private labelInputContainer: Container;
private treeContainer: Container;
private onLabelChangeCallback?: (entryId: string, label: string | undefined) => void;
constructor(
tree: SessionTreeNode[],
currentLeafId: string | null,
terminalHeight: number,
onSelect: (entryId: string) => void,
onCancel: () => void,
onLabelChange?: (entryId: string, label: string | undefined) => void,
) {
super();
this.onLabelChangeCallback = onLabelChange;
const maxVisibleLines = Math.max(5, Math.floor(terminalHeight / 2));
this.treeList = new TreeList(tree, currentLeafId, maxVisibleLines);
this.treeList.onSelect = onSelect;
this.treeList.onCancel = onCancel;
this.treeList.onLabelEdit = (entryId, currentLabel) => this.showLabelInput(entryId, currentLabel);
this.treeContainer = new Container();
this.treeContainer.addChild(this.treeList);
this.labelInputContainer = new Container();
this.addChild(new Spacer(1));
this.addChild(new DynamicBorder());
this.addChild(new Text(theme.bold(" Session Tree"), 1, 0));
this.addChild(
new TruncatedText(theme.fg("muted", " ↑/↓: move. ←/→: page. l: label. ^O/⇧^O: filter. Type to search"), 0, 0),
);
this.addChild(new SearchLine(this.treeList));
this.addChild(new DynamicBorder());
this.addChild(new Spacer(1));
this.addChild(this.treeContainer);
this.addChild(this.labelInputContainer);
this.addChild(new Spacer(1));
this.addChild(new DynamicBorder());
if (tree.length === 0) {
setTimeout(() => onCancel(), 100);
}
}
private showLabelInput(entryId: string, currentLabel: string | undefined): void {
this.labelInput = new LabelInput(entryId, currentLabel);
this.labelInput.onSubmit = (id, label) => {
this.treeList.updateNodeLabel(id, label);
this.onLabelChangeCallback?.(id, label);
this.hideLabelInput();
};
this.labelInput.onCancel = () => this.hideLabelInput();
this.treeContainer.clear();
this.labelInputContainer.clear();
this.labelInputContainer.addChild(this.labelInput);
}
private hideLabelInput(): void {
this.labelInput = null;
this.labelInputContainer.clear();
this.treeContainer.clear();
this.treeContainer.addChild(this.treeList);
}
handleInput(keyData: string): void {
if (this.labelInput) {
this.labelInput.handleInput(keyData);
} else {
this.treeList.handleInput(keyData);
}
}
getTreeList(): TreeList {
return this.treeList;
}
}

View file

@ -14,7 +14,7 @@ import { theme } from "../theme/theme.js";
import { DynamicBorder } from "./dynamic-border.js";
interface UserMessageItem {
index: number; // Index in the full messages array
id: string; // Entry ID in the session
text: string; // The message text
timestamp?: string; // Optional timestamp if available
}
@ -25,7 +25,7 @@ interface UserMessageItem {
class UserMessageList implements Component {
private messages: UserMessageItem[] = [];
private selectedIndex: number = 0;
public onSelect?: (messageIndex: number) => void;
public onSelect?: (entryId: string) => void;
public onCancel?: () => void;
private maxVisible: number = 10; // Max messages visible
@ -101,7 +101,7 @@ class UserMessageList implements Component {
else if (isEnter(keyData)) {
const selected = this.messages[this.selectedIndex];
if (selected && this.onSelect) {
this.onSelect(selected.index);
this.onSelect(selected.id);
}
}
// Escape - cancel
@ -125,7 +125,7 @@ class UserMessageList implements Component {
export class UserMessageSelectorComponent extends Container {
private messageList: UserMessageList;
constructor(messages: UserMessageItem[], onSelect: (messageIndex: number) => void, onCancel: () => void) {
constructor(messages: UserMessageItem[], onSelect: (entryId: string) => void, onCancel: () => void) {
super();
// Add header

View file

@ -5,13 +5,9 @@ import { getMarkdownTheme, theme } from "../theme/theme.js";
* Component that renders a user message
*/
export class UserMessageComponent extends Container {
constructor(text: string, isFirst: boolean) {
constructor(text: string) {
super();
// Add spacer before user message (except first one)
if (!isFirst) {
this.addChild(new Spacer(1));
}
this.addChild(new Spacer(1));
this.addChild(
new Markdown(text, 1, 1, getMarkdownTheme(), {
bgColor: (text: string) => theme.bg("userMessageBg", text),

View file

@ -11,10 +11,12 @@
"dimGray": "#666666",
"darkGray": "#505050",
"accent": "#8abeb7",
"selectedBg": "#3a3a4a",
"userMsgBg": "#343541",
"toolPendingBg": "#282832",
"toolSuccessBg": "#283228",
"toolErrorBg": "#3c2828"
"toolErrorBg": "#3c2828",
"customMsgBg": "#2d2838"
},
"colors": {
"accent": "accent",
@ -27,9 +29,14 @@
"muted": "gray",
"dim": "dimGray",
"text": "",
"thinkingText": "gray",
"selectedBg": "selectedBg",
"userMessageBg": "userMsgBg",
"userMessageText": "",
"customMessageBg": "customMsgBg",
"customMessageText": "",
"customMessageLabel": "#9575cd",
"toolPendingBg": "toolPendingBg",
"toolSuccessBg": "toolSuccessBg",
"toolErrorBg": "toolErrorBg",

View file

@ -10,10 +10,12 @@
"mediumGray": "#6c6c6c",
"dimGray": "#8a8a8a",
"lightGray": "#b0b0b0",
"selectedBg": "#d0d0e0",
"userMsgBg": "#e8e8e8",
"toolPendingBg": "#e8e8f0",
"toolSuccessBg": "#e8f0e8",
"toolErrorBg": "#f0e8e8"
"toolErrorBg": "#f0e8e8",
"customMsgBg": "#ede7f6"
},
"colors": {
"accent": "teal",
@ -26,9 +28,14 @@
"muted": "mediumGray",
"dim": "dimGray",
"text": "",
"thinkingText": "mediumGray",
"selectedBg": "selectedBg",
"userMessageBg": "userMsgBg",
"userMessageText": "",
"customMessageBg": "customMsgBg",
"customMessageText": "",
"customMessageLabel": "#7e57c2",
"toolPendingBg": "toolPendingBg",
"toolSuccessBg": "toolSuccessBg",
"toolErrorBg": "toolErrorBg",

View file

@ -47,6 +47,9 @@
"text",
"userMessageBg",
"userMessageText",
"customMessageBg",
"customMessageText",
"customMessageLabel",
"toolPendingBg",
"toolSuccessBg",
"toolErrorBg",
@ -122,6 +125,18 @@
"$ref": "#/$defs/colorValue",
"description": "User message text color"
},
"customMessageBg": {
"$ref": "#/$defs/colorValue",
"description": "Custom message background (hook-injected messages)"
},
"customMessageText": {
"$ref": "#/$defs/colorValue",
"description": "Custom message text color"
},
"customMessageLabel": {
"$ref": "#/$defs/colorValue",
"description": "Custom message type label color"
},
"toolPendingBg": {
"$ref": "#/$defs/colorValue",
"description": "Tool execution box (pending state)"

View file

@ -34,9 +34,14 @@ const ThemeJsonSchema = Type.Object({
muted: ColorValueSchema,
dim: ColorValueSchema,
text: ColorValueSchema,
// Backgrounds & Content Text (7 colors)
thinkingText: ColorValueSchema,
// Backgrounds & Content Text (11 colors)
selectedBg: ColorValueSchema,
userMessageBg: ColorValueSchema,
userMessageText: ColorValueSchema,
customMessageBg: ColorValueSchema,
customMessageText: ColorValueSchema,
customMessageLabel: ColorValueSchema,
toolPendingBg: ColorValueSchema,
toolSuccessBg: ColorValueSchema,
toolErrorBg: ColorValueSchema,
@ -94,7 +99,10 @@ export type ThemeColor =
| "muted"
| "dim"
| "text"
| "thinkingText"
| "userMessageText"
| "customMessageText"
| "customMessageLabel"
| "toolTitle"
| "toolOutput"
| "mdHeading"
@ -127,7 +135,13 @@ export type ThemeColor =
| "thinkingXhigh"
| "bashMode";
export type ThemeBg = "userMessageBg" | "toolPendingBg" | "toolSuccessBg" | "toolErrorBg";
export type ThemeBg =
| "selectedBg"
| "userMessageBg"
| "customMessageBg"
| "toolPendingBg"
| "toolSuccessBg"
| "toolErrorBg";
type ColorMode = "truecolor" | "256color";
@ -482,7 +496,14 @@ function createTheme(themeJson: ThemeJson, mode?: ColorMode): Theme {
const resolvedColors = resolveThemeColors(themeJson.colors, themeJson.vars);
const fgColors: Record<ThemeColor, string | number> = {} as Record<ThemeColor, string | number>;
const bgColors: Record<ThemeBg, string | number> = {} as Record<ThemeBg, string | number>;
const bgColorKeys: Set<string> = new Set(["userMessageBg", "toolPendingBg", "toolSuccessBg", "toolErrorBg"]);
const bgColorKeys: Set<string> = new Set([
"selectedBg",
"userMessageBg",
"customMessageBg",
"toolPendingBg",
"toolSuccessBg",
"toolErrorBg",
]);
for (const [key, value] of Object.entries(resolvedColors)) {
if (bgColorKeys.has(key)) {
bgColors[key as ThemeBg] = value;

View file

@ -6,8 +6,7 @@
* - `pi --mode json "prompt"` - JSON event stream
*/
import type { Attachment } from "@mariozechner/pi-agent-core";
import type { AssistantMessage } from "@mariozechner/pi-ai";
import type { AssistantMessage, ImageContent } from "@mariozechner/pi-ai";
import type { AgentSession } from "../core/agent-session.js";
/**
@ -18,38 +17,36 @@ import type { AgentSession } from "../core/agent-session.js";
* @param mode Output mode: "text" for final response only, "json" for all events
* @param messages Array of prompts to send
* @param initialMessage Optional first message (may contain @file content)
* @param initialAttachments Optional attachments for the initial message
* @param initialImages Optional images for the initial message
*/
export async function runPrintMode(
session: AgentSession,
mode: "text" | "json",
messages: string[],
initialMessage?: string,
initialAttachments?: Attachment[],
initialImages?: ImageContent[],
): Promise<void> {
// Load entries once for session start events
const entries = session.sessionManager.getEntries();
// Hook runner already has no-op UI context by default (set in main.ts)
// Set up hooks for print mode (no UI)
const hookRunner = session.hookRunner;
if (hookRunner) {
// Use actual session file if configured (via --session), otherwise null
hookRunner.setSessionFile(session.sessionFile);
hookRunner.initialize({
getModel: () => session.model,
sendMessageHandler: (message, triggerTurn) => {
session.sendHookMessage(message, triggerTurn).catch((e) => {
console.error(`Hook sendMessage failed: ${e instanceof Error ? e.message : String(e)}`);
});
},
appendEntryHandler: (customType, data) => {
session.sessionManager.appendCustomEntry(customType, data);
},
});
hookRunner.onError((err) => {
console.error(`Hook error (${err.hookPath}): ${err.error}`);
});
// No-op send handler for print mode (single-shot, no async messages)
hookRunner.setSendHandler(() => {
console.error("Warning: pi.send() is not supported in print mode");
});
// Emit session event
// Emit session_start event
await hookRunner.emit({
type: "session",
entries,
sessionFile: session.sessionFile,
previousSessionFile: null,
reason: "start",
type: "session_start",
});
}
@ -57,12 +54,17 @@ export async function runPrintMode(
for (const { tool } of session.customTools) {
if (tool.onSession) {
try {
await tool.onSession({
entries,
sessionFile: session.sessionFile,
previousSessionFile: null,
reason: "start",
});
await tool.onSession(
{
reason: "start",
previousSessionFile: undefined,
},
{
sessionManager: session.sessionManager,
modelRegistry: session.modelRegistry,
model: session.model,
},
);
} catch (_err) {
// Silently ignore tool errors
}
@ -79,7 +81,7 @@ export async function runPrintMode(
// Send initial message with attachments
if (initialMessage) {
await session.prompt(initialMessage, { attachments: initialAttachments });
await session.prompt(initialMessage, { images: initialImages });
}
// Send remaining messages

View file

@ -6,9 +6,11 @@
import { type ChildProcess, spawn } from "node:child_process";
import * as readline from "node:readline";
import type { AgentEvent, AppMessage, Attachment, ThinkingLevel } from "@mariozechner/pi-agent-core";
import type { CompactionResult, SessionStats } from "../../core/agent-session.js";
import type { AgentEvent, AgentMessage, ThinkingLevel } from "@mariozechner/pi-agent-core";
import type { ImageContent } from "@mariozechner/pi-ai";
import type { SessionStats } from "../../core/agent-session.js";
import type { BashResult } from "../../core/bash-executor.js";
import type { CompactionResult } from "../../core/compaction/index.js";
import type { RpcCommand, RpcResponse, RpcSessionState } from "./rpc-types.js";
// ============================================================================
@ -166,8 +168,8 @@ export class RpcClient {
* Returns immediately after sending; use onEvent() to receive streaming events.
* Use waitForIdle() to wait for completion.
*/
async prompt(message: string, attachments?: Attachment[]): Promise<void> {
await this.send({ type: "prompt", message, attachments });
async prompt(message: string, images?: ImageContent[]): Promise<void> {
await this.send({ type: "prompt", message, images });
}
/**
@ -324,17 +326,17 @@ export class RpcClient {
* Branch from a specific message.
* @returns Object with `text` (the message text) and `cancelled` (if hook cancelled)
*/
async branch(entryIndex: number): Promise<{ text: string; cancelled: boolean }> {
const response = await this.send({ type: "branch", entryIndex });
async branch(entryId: string): Promise<{ text: string; cancelled: boolean }> {
const response = await this.send({ type: "branch", entryId });
return this.getData(response);
}
/**
* Get messages available for branching.
*/
async getBranchMessages(): Promise<Array<{ entryIndex: number; text: string }>> {
async getBranchMessages(): Promise<Array<{ entryId: string; text: string }>> {
const response = await this.send({ type: "get_branch_messages" });
return this.getData<{ messages: Array<{ entryIndex: number; text: string }> }>(response).messages;
return this.getData<{ messages: Array<{ entryId: string; text: string }> }>(response).messages;
}
/**
@ -348,9 +350,9 @@ export class RpcClient {
/**
* Get all messages in the session.
*/
async getMessages(): Promise<AppMessage[]> {
async getMessages(): Promise<AgentMessage[]> {
const response = await this.send({ type: "get_messages" });
return this.getData<{ messages: AppMessage[] }>(response).messages;
return this.getData<{ messages: AgentMessage[] }>(response).messages;
}
// =========================================================================
@ -403,9 +405,9 @@ export class RpcClient {
/**
* Send prompt and wait for completion, returning all events.
*/
async promptAndWait(message: string, attachments?: Attachment[], timeout = 60000): Promise<AgentEvent[]> {
async promptAndWait(message: string, images?: ImageContent[], timeout = 60000): Promise<AgentEvent[]> {
const eventsPromise = this.collectEvents(timeout);
await this.prompt(message, attachments);
await this.prompt(message, images);
return eventsPromise;
}

View file

@ -51,17 +51,17 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
* Create a hook UI context that uses the RPC protocol.
*/
const createHookUIContext = (): HookUIContext => ({
async select(title: string, options: string[]): Promise<string | null> {
async select(title: string, options: string[]): Promise<string | undefined> {
const id = crypto.randomUUID();
return new Promise((resolve, reject) => {
pendingHookRequests.set(id, {
resolve: (response: RpcHookUIResponse) => {
if ("cancelled" in response && response.cancelled) {
resolve(null);
resolve(undefined);
} else if ("value" in response) {
resolve(response.value);
} else {
resolve(null);
resolve(undefined);
}
},
reject,
@ -89,17 +89,17 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
});
},
async input(title: string, placeholder?: string): Promise<string | null> {
async input(title: string, placeholder?: string): Promise<string | undefined> {
const id = crypto.randomUUID();
return new Promise((resolve, reject) => {
pendingHookRequests.set(id, {
resolve: (response: RpcHookUIResponse) => {
if ("cancelled" in response && response.cancelled) {
resolve(null);
resolve(undefined);
} else if ("value" in response) {
resolve(response.value);
} else {
resolve(null);
resolve(undefined);
}
},
reject,
@ -118,37 +118,51 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
notifyType: type,
} as RpcHookUIRequest);
},
});
// Load entries once for session start events
const entries = session.sessionManager.getEntries();
async custom() {
// Custom UI not supported in RPC mode
return undefined as never;
},
setEditorText(text: string): void {
// Fire and forget - host can implement editor control
output({
type: "hook_ui_request",
id: crypto.randomUUID(),
method: "set_editor_text",
text,
} as RpcHookUIRequest);
},
getEditorText(): string {
// Synchronous method can't wait for RPC response
// Host should track editor state locally if needed
return "";
},
});
// Set up hooks with RPC-based UI context
const hookRunner = session.hookRunner;
if (hookRunner) {
hookRunner.setUIContext(createHookUIContext(), false);
hookRunner.setSessionFile(session.sessionFile);
hookRunner.initialize({
getModel: () => session.agent.state.model,
sendMessageHandler: (message, triggerTurn) => {
session.sendHookMessage(message, triggerTurn).catch((e) => {
output(error(undefined, "hook_send", e.message));
});
},
appendEntryHandler: (customType, data) => {
session.sessionManager.appendCustomEntry(customType, data);
},
uiContext: createHookUIContext(),
hasUI: false,
});
hookRunner.onError((err) => {
output({ type: "hook_error", hookPath: err.hookPath, event: err.event, error: err.error });
});
// Set up send handler for pi.send()
hookRunner.setSendHandler((text, attachments) => {
// In RPC mode, just queue or prompt based on streaming state
if (session.isStreaming) {
session.queueMessage(text);
} else {
session.prompt(text, { attachments }).catch((e) => {
output(error(undefined, "hook_send", e.message));
});
}
});
// Emit session event
// Emit session_start event
await hookRunner.emit({
type: "session",
entries,
sessionFile: session.sessionFile,
previousSessionFile: null,
reason: "start",
type: "session_start",
});
}
@ -157,12 +171,17 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
for (const { tool } of session.customTools) {
if (tool.onSession) {
try {
await tool.onSession({
entries,
sessionFile: session.sessionFile,
previousSessionFile: null,
reason: "start",
});
await tool.onSession(
{
previousSessionFile: undefined,
reason: "start",
},
{
sessionManager: session.sessionManager,
modelRegistry: session.modelRegistry,
model: session.model,
},
);
} catch (_err) {
// Silently ignore tool errors
}
@ -185,10 +204,10 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
case "prompt": {
// Don't await - events will stream
// Hook commands and file slash commands are handled in session.prompt()
session
.prompt(command.message, {
attachments: command.attachments,
expandSlashCommands: false,
images: command.images,
})
.catch((e) => output(error(id, "prompt", e.message)));
return success(id, "prompt");
@ -344,7 +363,7 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
}
case "branch": {
const result = await session.branch(command.entryIndex);
const result = await session.branch(command.entryId);
return success(id, "branch", { text: result.selectedText, cancelled: result.cancelled });
}

View file

@ -5,10 +5,11 @@
* Responses and events are emitted as JSON lines on stdout.
*/
import type { AppMessage, Attachment, ThinkingLevel } from "@mariozechner/pi-agent-core";
import type { Model } from "@mariozechner/pi-ai";
import type { CompactionResult, SessionStats } from "../../core/agent-session.js";
import type { AgentMessage, ThinkingLevel } from "@mariozechner/pi-agent-core";
import type { ImageContent, Model } from "@mariozechner/pi-ai";
import type { SessionStats } from "../../core/agent-session.js";
import type { BashResult } from "../../core/bash-executor.js";
import type { CompactionResult } from "../../core/compaction/index.js";
// ============================================================================
// RPC Commands (stdin)
@ -16,7 +17,7 @@ import type { BashResult } from "../../core/bash-executor.js";
export type RpcCommand =
// Prompting
| { id?: string; type: "prompt"; message: string; attachments?: Attachment[] }
| { id?: string; type: "prompt"; message: string; images?: ImageContent[] }
| { id?: string; type: "queue_message"; message: string }
| { id?: string; type: "abort" }
| { id?: string; type: "reset" }
@ -52,7 +53,7 @@ export type RpcCommand =
| { id?: string; type: "get_session_stats" }
| { id?: string; type: "export_html"; outputPath?: string }
| { id?: string; type: "switch_session"; sessionPath: string }
| { id?: string; type: "branch"; entryIndex: number }
| { id?: string; type: "branch"; entryId: string }
| { id?: string; type: "get_branch_messages" }
| { id?: string; type: "get_last_assistant_text" }
@ -64,12 +65,12 @@ export type RpcCommand =
// ============================================================================
export interface RpcSessionState {
model: Model<any> | null;
model?: Model<any>;
thinkingLevel: ThinkingLevel;
isStreaming: boolean;
isCompacting: boolean;
queueMode: "all" | "one-at-a-time";
sessionFile: string | null;
sessionFile?: string;
sessionId: string;
autoCompactionEnabled: boolean;
messageCount: number;
@ -149,7 +150,7 @@ export type RpcResponse =
type: "response";
command: "get_branch_messages";
success: true;
data: { messages: Array<{ entryIndex: number; text: string }> };
data: { messages: Array<{ entryId: string; text: string }> };
}
| {
id?: string;
@ -160,7 +161,7 @@ export type RpcResponse =
}
// Messages
| { id?: string; type: "response"; command: "get_messages"; success: true; data: { messages: AppMessage[] } }
| { id?: string; type: "response"; command: "get_messages"; success: true; data: { messages: AgentMessage[] } }
// Error response (any command can fail)
| { id?: string; type: "response"; command: string; success: false; error: string };
@ -180,7 +181,8 @@ export type RpcHookUIRequest =
method: "notify";
message: string;
notifyType?: "info" | "warning" | "error";
};
}
| { type: "hook_ui_request"; id: string; method: "set_editor_text"; text: string };
// ============================================================================
// Hook UI Commands (stdin)

View file

@ -184,14 +184,14 @@ async function downloadTool(tool: "fd" | "rg"): Promise<string> {
// Ensure a tool is available, downloading if necessary
// Returns the path to the tool, or null if unavailable
export async function ensureTool(tool: "fd" | "rg", silent: boolean = false): Promise<string | null> {
export async function ensureTool(tool: "fd" | "rg", silent: boolean = false): Promise<string | undefined> {
const existingPath = getToolPath(tool);
if (existingPath) {
return existingPath;
}
const config = TOOLS[tool];
if (!config) return null;
if (!config) return undefined;
// Tool not found - download it
if (!silent) {
@ -208,6 +208,6 @@ export async function ensureTool(tool: "fd" | "rg", silent: boolean = false): Pr
if (!silent) {
console.log(chalk.yellow(`Failed to download ${config.name}: ${e instanceof Error ? e.message : e}`));
}
return null;
return undefined;
}
}