refactor(hooks): split session events into individual typed events

Major changes:
- Replace monolithic SessionEvent with reason discriminator with individual
  event types: session_start, session_before_switch, session_switch,
  session_before_new, session_new, session_before_branch, session_branch,
  session_before_compact, session_compact, session_shutdown
- Each event has dedicated result type (SessionBeforeSwitchResult, etc.)
- HookHandler type now allows bare return statements (void in return type)
- HookAPI.on() has proper overloads for each event with correct typing

Additional fixes:
- AgentSession now always subscribes to agent in constructor (was only
  subscribing when external subscribe() called, breaking internal handlers)
- Standardize on undefined over null throughout codebase
- HookUIContext methods return undefined instead of null
- SessionManager methods return undefined instead of null
- Simplify hook exports to 'export type * from types.js'
- Add detailed JSDoc for skipConversationRestore vs cancel
- Fix createBranchedSession to rebuild index in persist mode
- newSession() now returns the session file path

Updated all example hooks, tests, and emission sites to use new event types.
This commit is contained in:
Mario Zechner 2025-12-28 20:06:20 +01:00
parent 38d65dfe59
commit d6283f99dc
43 changed files with 2129 additions and 640 deletions

View file

@ -9,49 +9,4 @@ export {
} from "./loader.js";
export { execCommand, HookRunner, type HookErrorListener } from "./runner.js";
export { wrapToolsWithHooks, wrapToolWithHooks } from "./tool-wrapper.js";
export type {
AgentEndEvent,
AgentStartEvent,
BeforeAgentStartEvent,
BeforeAgentStartEventResult,
BashToolResultEvent,
ContextEvent,
ContextEventResult,
CustomToolResultEvent,
EditToolResultEvent,
ExecOptions,
ExecResult,
FindToolResultEvent,
GrepToolResultEvent,
HookAPI,
HookCommandContext,
HookError,
HookEvent,
HookEventContext,
HookFactory,
HookMessageRenderer,
HookMessageRenderOptions,
HookUIContext,
LsToolResultEvent,
ReadonlySessionManager,
ReadToolResultEvent,
RegisteredCommand,
SessionEvent,
SessionEventResult,
ToolCallEvent,
ToolCallEventResult,
ToolResultEvent,
ToolResultEventResult,
TurnEndEvent,
TurnStartEvent,
WriteToolResultEvent,
} from "./types.js";
export {
isBashToolResult,
isEditToolResult,
isFindToolResult,
isGrepToolResult,
isLsToolResult,
isReadToolResult,
isWriteToolResult,
} from "./types.js";
export type * from "./types.js";

View file

@ -17,8 +17,7 @@ import type {
HookMessageRenderer,
HookUIContext,
RegisteredCommand,
SessionEvent,
SessionEventResult,
SessionBeforeCompactResult,
ToolCallEvent,
ToolCallEventResult,
ToolResultEventResult,
@ -53,9 +52,9 @@ function createTimeout(ms: number): { promise: Promise<never>; clear: () => void
/** 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: () => ({ close: () => {}, requestRender: () => {} }),
};
@ -228,12 +227,26 @@ export class HookRunner {
}
/**
* 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" {
return (
type === "session_before_switch" ||
type === "session_before_new" ||
type === "session_before_branch" ||
type === "session_before_compact"
);
}
/**
* 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 | ToolResultEventResult | undefined> {
const ctx = this.createContext();
let result: SessionEventResult | ToolResultEventResult | undefined;
let result: SessionBeforeCompactResult | ToolResultEventResult | undefined;
for (const hook of this.hooks) {
const handlers = hook.handlers.get(event.type);
@ -241,11 +254,10 @@ 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";
// No timeout for session_before_compact events (like tool_call, they may take a while)
let handlerResult: unknown;
if (isBeforeCompact) {
if (event.type === "session_before_compact") {
handlerResult = await handler(event, ctx);
} else {
const timeout = createTimeout(this.timeout);
@ -253,9 +265,9 @@ export class HookRunner {
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;
// If cancelled, stop processing further hooks
if (result.cancel) {
return result;

View file

@ -13,13 +13,7 @@ import type { CompactionPreparation, CompactionResult } from "../compaction.js";
import type { ExecOptions, ExecResult } from "../exec.js";
import type { HookMessage } from "../messages.js";
import type { ModelRegistry } from "../model-registry.js";
import type {
CompactionEntry,
SessionEntry,
SessionHeader,
SessionManager,
SessionTreeNode,
} from "../session-manager.js";
import type { CompactionEntry, SessionManager } from "../session-manager.js";
/**
* Read-only view of SessionManager for hooks.
@ -64,7 +58,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.
@ -74,9 +68,9 @@ 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.
@ -110,69 +104,91 @@ export interface HookEventContext {
}
// ============================================================================
// Events
// Session Events
// ============================================================================
/**
* Base fields shared by all session events.
*/
interface SessionEventBase {
type: "session";
/** 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";
/** Index of the entry in the session (SessionManager.getEntries()) to branch from */
entryIndex: number;
}
/** 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 cut point, messages to summarize/keep, etc. */
preparation: CompactionPreparation;
/** Previous compaction entries, newest first. Use for iterative summarization. */
previousCompactions: CompactionEntry[];
/** Optional user-provided instructions for the summary */
customInstructions?: string;
/** Current model */
model: Model<any>;
/** 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";
}
/** Union of all session event types */
export type SessionEvent =
| (SessionEventBase & {
reason: "start" | "new" | "before_new" | "shutdown";
})
| (SessionEventBase & {
reason: "before_switch";
/** Session file we're switching to */
targetSessionFile: string;
})
| (SessionEventBase & {
reason: "switch";
/** Session file we came from */
previousSessionFile: string | null;
})
| (SessionEventBase & {
reason: "branch" | "before_branch";
/** Index of the turn to branch from */
targetTurnIndex: number;
})
| (SessionEventBase & {
reason: "before_compact";
/** Compaction preparation with cut point, messages to summarize/keep, etc. */
preparation: CompactionPreparation;
/** Previous compaction entries, newest first. Use for iterative summarization. */
previousCompactions: CompactionEntry[];
/** Optional user-provided instructions for the summary */
customInstructions?: string;
/** Current model */
model: Model<any>;
/** Abort signal - hooks should pass this to LLM calls and check it periodically */
signal: AbortSignal;
})
| (SessionEventBase & {
reason: "compact";
compactionEntry: CompactionEntry;
/** Whether the compaction entry was provided by a hook */
fromHook: boolean;
});
| SessionStartEvent
| SessionBeforeSwitchEvent
| SessionSwitchEvent
| SessionBeforeNewEvent
| SessionNewEvent
| SessionBeforeBranchEvent
| SessionBranchEvent
| SessionBeforeCompactEvent
| SessionCompactEvent
| SessionShutdownEvent;
/**
* Event data for context event.
@ -408,16 +424,45 @@ export interface BeforeAgentStartEventResult {
message?: Pick<HookMessage, "customType" | "content" | "display" | "details">;
}
/**
* Return type for session event handlers.
* Allows hooks to cancel "before_*" actions.
*/
export interface SessionEventResult {
/** If true, cancel the pending action (switch, clear, or branch) */
/** 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 result (for before_compact event) - SessionManager adds id/parentId */
}
/** 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;
}
@ -427,8 +472,10 @@ 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: HookEventContext) => Promise<R | void> | R | void;
export interface HookMessageRenderOptions {
/** Whether the view is expanded */
@ -443,7 +490,7 @@ export type HookMessageRenderer<T = unknown> = (
message: HookMessage<T>,
options: HookMessageRenderOptions,
theme: Theme,
) => Component | null;
) => Component | undefined;
/**
* Context passed to hook command handlers.
@ -478,21 +525,30 @@ export interface RegisteredCommand {
* 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;
// biome-ignore lint/suspicious/noConfusingVoidType: void allows handlers to not return anything
on(event: "context", handler: HookHandler<ContextEvent, ContextEventResult | 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: "before_agent_start",
// biome-ignore lint/suspicious/noConfusingVoidType: void allows handlers to not return anything
handler: HookHandler<BeforeAgentStartEvent, BeforeAgentStartEventResult | void>,
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;
// 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 custom message to the session. Creates a CustomMessageEntry that
@ -545,7 +601,7 @@ export interface HookAPI {
/**
* Register a custom renderer for CustomMessageEntry with a specific customType.
* The renderer is called when rendering the entry in the TUI.
* Return null to use the default renderer.
* Return nothing to use the default renderer.
*/
registerMessageRenderer<T = unknown>(customType: string, renderer: HookMessageRenderer<T>): void;