import type { ImageContent, Message, Model, TextContent } from "@oh-my-pi/pi-ai"; import type { Component, TUI } from "@oh-my-pi/pi-tui"; import type { ModelRegistry } from "../../config/model-registry"; import type { EditToolDetails } from "../../edit"; import type { ExecOptions, ExecResult } from "../../exec/exec"; import type { Theme } from "../../modes/theme/theme"; import type { HookMessage } from "../../session/messages"; import type { ReadonlySessionManager, SessionManager } from "../../session/session-manager"; import type { BashToolDetails, FindToolDetails, ReadToolDetails, SearchToolDetails } from "../../tools"; import type { AgentEndEvent, AgentStartEvent, AutoCompactionEndEvent, AutoCompactionStartEvent, AutoRetryEndEvent, AutoRetryStartEvent, ContextEvent, SessionBeforeBranchEvent, SessionBeforeBranchResult, SessionBeforeCompactEvent, SessionBeforeCompactResult, SessionBeforeSwitchEvent, SessionBeforeSwitchResult, SessionBeforeTreeEvent, SessionBeforeTreeResult, SessionBranchEvent, SessionCompactEvent, SessionCompactingEvent, SessionCompactingResult, SessionEvent, SessionShutdownEvent, SessionStartEvent, SessionSwitchEvent, SessionTreeEvent, TodoReminderEvent, ToolCallEventResult, ToolResultEventResult, TtsrTriggeredEvent, TurnEndEvent, TurnStartEvent, } from "../shared-events"; // Re-export for backward compatibility export type { ExecOptions, ExecResult } from "../../exec/exec"; /** * UI context for hooks to request interactive UI from the harness. * Each mode (interactive, RPC, print) provides its own implementation. */ // fallow-ignore-next-line code-duplication // Parallel to ExtensionUIContext: hooks expose a deliberately narrower UI // surface — no terminal-input listener, no editor component override, no // theme management — because hooks are invoked from inside the agent loop // and must not be able to seize ownership of the editor. export interface HookUIContext { /** * Show a selector and return the user's choice. * @param title - Title to display * @param options - Array of string options * @returns Selected option string, or null if cancelled */ select(title: string, options: string[]): Promise; /** * Show a confirmation dialog. * @returns true if confirmed, false if cancelled */ confirm(title: string, message: string): Promise; /** * Show a text input dialog. * @returns User input, or undefined if cancelled */ input(title: string, placeholder?: string): Promise; /** * Show a notification to the user. */ notify(message: string, type?: "info" | "warning" | "error"): void; /** * Set status text in the footer/status bar. * Pass undefined as text to clear the status for this key. * Text can include ANSI escape codes for styling. * Note: Newlines, tabs, and carriage returns are replaced with spaces. * The combined status line is truncated to terminal width. * @param key - Unique key to identify this status (e.g., hook name) * @param text - Status text to display, or undefined to clear */ setStatus(key: string, text: string | undefined): 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( factory: ( tui: TUI, theme: Theme, done: (result: T) => void, ) => (Component & { dispose?(): void }) | Promise, ): Promise; /** * 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; /** * Show a multi-line editor for text editing. * Supports Ctrl+G to open external editor ($VISUAL or $EDITOR). * @param title - Title describing what is being edited * @param prefill - Optional initial text * @returns Edited text, or undefined if cancelled (Escape) */ editor(title: string, prefill?: string, options?: { signal?: AbortSignal }): Promise; /** * Get the current theme for styling text with ANSI codes. * Use theme.fg() and theme.bg() to style status text. * * @example * const theme = ctx.ui.theme; * ctx.ui.setStatus("my-hook", theme.fg("success", theme.status.success) + " Ready"); */ readonly theme: Theme; } /** * Context passed to hook event handlers. * For command handlers, see HookCommandContext which extends this with session control methods. */ // fallow-ignore-next-line code-duplication // Parallel to ExtensionContext: hooks see a narrower runtime context (no // model registry mutation, no system prompt access, no shutdown). The // overlap in field names is intentional API symmetry; widening hooks to // match extensions would let hooks call methods that deadlock the agent. 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; /** 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 | undefined; /** Whether the agent is idle (not streaming) */ isIdle(): boolean; /** Abort the current agent operation (fire-and-forget, does not wait) */ abort(): void; /** Whether there are queued messages waiting to be processed */ hasQueuedMessages(): boolean; } /** * Extended context for slash command handlers. * Includes session control methods that are only safe in user-initiated commands. * * These methods are not available in event handlers because they can cause * deadlocks when called from within the agent loop (e.g., tool_call, context events). */ // fallow-ignore-next-line code-duplication // Parallel to ExtensionCommandContext: hooks intentionally omit // `switchSession`, `reload`, `compact`, and `getContextUsage` — those are // safe only from extension command handlers, not from the hook execution // context. export interface HookCommandContext extends HookContext { /** Wait for the agent to finish streaming */ waitForIdle(): Promise; /** * Start a new session, optionally with a setup callback to initialize it. * The setup callback receives a writable SessionManager for the new session. * * @param options.parentSession - Path to parent session for lineage tracking * @param options.setup - Async callback to initialize the new session (e.g., append messages) * @returns Object with `cancelled: true` if a hook cancelled the new session * * @example * // Handoff: summarize current session and start fresh with context * await ctx.newSession({ * parentSession: ctx.sessionManager.getSessionFile(), * setup: async (sm) => { * sm.appendMessage({ role: "user", content: [{ type: "text", text: summary }] }); * } * }); */ newSession(options?: { parentSession?: string; setup?: (sessionManager: SessionManager) => Promise; }): Promise<{ cancelled: boolean }>; /** * Branch from a specific entry, creating a new session file. * * @param entryId - ID of the entry to branch from * @returns Object with `cancelled: true` if a hook cancelled the branch */ branch(entryId: string): Promise<{ cancelled: boolean }>; /** * Navigate to a different point in the session tree (in-place). * * @param targetId - ID of the entry to navigate to * @param options.summarize - Whether to summarize the abandoned branch * @returns Object with `cancelled: true` if a hook cancelled the navigation */ navigateTree(targetId: string, options?: { summarize?: boolean }): Promise<{ cancelled: boolean }>; } // ============================================================================ // Session Events (shared with extensions subsystem) // ============================================================================ export type { ContextEvent, SessionBeforeBranchEvent, SessionBeforeCompactEvent, SessionBeforeSwitchEvent, SessionBeforeTreeEvent, SessionBranchEvent, SessionCompactEvent, SessionCompactingEvent, SessionEvent, SessionShutdownEvent, SessionStartEvent, SessionSwitchEvent, SessionTreeEvent, TreePreparation, } from "../shared-events"; /** * 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[]; } export type { AgentEndEvent, AgentStartEvent, AutoCompactionEndEvent, AutoCompactionStartEvent, AutoRetryEndEvent, AutoRetryStartEvent, TodoReminderEvent, TtsrTriggeredEvent, TurnEndEvent, TurnStartEvent, } from "../shared-events"; /** * Event data for tool_call event. * Fired before a tool is executed. Hooks can block execution. */ export interface ToolCallEvent { type: "tool_call"; /** Tool name (e.g., "bash", "edit", "write") */ toolName: string; /** Tool call ID */ toolCallId: string; /** Tool input parameters */ input: Record; } /** * Base interface for tool_result events. */ interface ToolResultEventBase { type: "tool_result"; /** Tool call ID */ toolCallId: string; /** Tool input parameters */ input: Record; /** Full content array (text and images) */ content: (TextContent | ImageContent)[]; /** Whether the tool execution was an error */ isError?: boolean; } /** Tool result event for bash tool */ export interface BashToolResultEvent extends ToolResultEventBase { toolName: "bash"; details: BashToolDetails | undefined; } /** Tool result event for read tool */ export interface ReadToolResultEvent extends ToolResultEventBase { toolName: "read"; details: ReadToolDetails | undefined; } /** Tool result event for edit tool */ export interface EditToolResultEvent extends ToolResultEventBase { toolName: "edit"; details: EditToolDetails | undefined; } /** Tool result event for write tool */ export interface WriteToolResultEvent extends ToolResultEventBase { toolName: "write"; details: undefined; } /** Tool result event for search tool */ export interface SearchToolResultEvent extends ToolResultEventBase { toolName: "search"; details: SearchToolDetails | undefined; } /** Tool result event for find tool */ export interface FindToolResultEvent extends ToolResultEventBase { toolName: "find"; details: FindToolDetails | undefined; } /** Tool result event for custom/unknown tools */ export interface CustomToolResultEvent extends ToolResultEventBase { toolName: string; details: unknown; } /** * Event data for tool_result event. * Fired after a tool is executed. Hooks can modify the result. * Use toolName to discriminate and get typed details. */ export type ToolResultEvent = | BashToolResultEvent | ReadToolResultEvent | EditToolResultEvent | WriteToolResultEvent | SearchToolResultEvent | FindToolResultEvent | CustomToolResultEvent; /** * Union of all hook event types. */ export type HookEvent = | SessionEvent | ContextEvent | BeforeAgentStartEvent | AgentStartEvent | AgentEndEvent | TurnStartEvent | TurnEndEvent | AutoCompactionStartEvent | AutoCompactionEndEvent | AutoRetryStartEvent | AutoRetryEndEvent | TtsrTriggeredEvent | TodoReminderEvent | ToolCallEvent | ToolResultEvent; // ============================================================================ // 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[]; } export type { ToolCallEventResult, ToolResultEventResult } from "../shared-events"; /** * Return type for before_agent_start event handlers. * Allows hooks to inject context before the agent runs. */ export interface BeforeAgentStartEventResult { /** Message to inject into context (persisted to session, visible in TUI) */ message?: Pick; } export type { SessionBeforeBranchResult, SessionBeforeCompactResult, SessionBeforeSwitchResult, SessionBeforeTreeResult, SessionCompactingResult, } from "../shared-events"; // ============================================================================ // Hook API // ============================================================================ /** * Handler function type for each event. * Handlers can return R, undefined, or void (bare return statements). */ // biome-ignore lint/suspicious/noConfusingVoidType: void allows bare return statements in handlers export type HookHandler = (event: E, ctx: HookContext) => Promise | 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 = ( message: HookMessage, options: HookMessageRenderOptions, theme: Theme, ) => Component | undefined; /** * Command registration options. */ // fallow-ignore-next-line code-duplication // Parallel to extensions' RegisteredCommand: hooks bind to // HookCommandContext and have no argument-completion hook. export interface RegisteredCommand { name: string; description?: string; handler: (args: string, ctx: HookCommandContext) => Promise; } /** * HookAPI passed to hook factory functions. * Hooks use pi.on() to subscribe to events and pi.sendMessage() to inject messages. */ export interface HookAPI { // Session events on(event: "session_start", handler: HookHandler): void; on(event: "session_before_switch", handler: HookHandler): void; on(event: "session_switch", handler: HookHandler): void; on(event: "session_before_branch", handler: HookHandler): void; on(event: "session_branch", handler: HookHandler): void; on( event: "session_before_compact", handler: HookHandler, ): void; on(event: "session.compacting", handler: HookHandler): void; on(event: "session_compact", handler: HookHandler): void; on(event: "session_shutdown", handler: HookHandler): void; on(event: "session_before_tree", handler: HookHandler): void; on(event: "session_tree", handler: HookHandler): void; // Context and agent events on(event: "context", handler: HookHandler): void; on(event: "before_agent_start", handler: HookHandler): void; on(event: "agent_start", handler: HookHandler): void; on(event: "agent_end", handler: HookHandler): void; on(event: "turn_start", handler: HookHandler): void; on(event: "turn_end", handler: HookHandler): void; on(event: "auto_compaction_start", handler: HookHandler): void; on(event: "auto_compaction_end", handler: HookHandler): void; on(event: "auto_retry_start", handler: HookHandler): void; on(event: "auto_retry_end", handler: HookHandler): void; on(event: "ttsr_triggered", handler: HookHandler): void; on(event: "todo_reminder", handler: HookHandler): void; on(event: "tool_call", handler: HookHandler): void; on(event: "tool_result", handler: HookHandler): void; /** * 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 message.attribution - Who initiated the message for billing/attribution semantics ("user" | "agent") * @param options.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. * @param options.deliverAs - How to deliver the message: "steer" or "followUp". */ sendMessage( message: Pick, "customType" | "content" | "display" | "details" | "attribution">, options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" }, ): 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(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(customType: string, renderer: HookMessageRenderer): void; /** * Register a custom slash command. * Handler receives HookCommandContext with session control methods. */ 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; /** File logger for error/warning/debug messages */ logger: typeof import("@oh-my-pi/pi-utils").logger; /** Injected zod-backed typebox shim (legacy/compat — prefer `zod`). */ typebox: typeof import("../typebox"); /** Injected zod module for Zod-authored hooks. */ zod: typeof import("zod/v4"); /** Injected pi-coding-agent exports */ pi: typeof import("../.."); } /** * Hook factory function type. * Hooks export a default function that receives the HookAPI. */ export type HookFactory = (pi: HookAPI) => void; // ============================================================================ // Errors // ============================================================================ /** * Error emitted when a hook fails. */ export interface HookError { hookPath: string; event: string; error: string; }