Add before/after session events with cancellation support

- Merge branch event into session with before_branch/branch reasons
- Add before_switch, before_clear, shutdown reasons
- before_* events can be cancelled with { cancel: true }
- Update RPC commands to return cancelled status
- Add shutdown event on process exit
- New example hooks: confirm-destructive, dirty-repo-guard, auto-commit-on-exit

fixes #278
This commit is contained in:
Mario Zechner 2025-12-22 18:18:38 +01:00
parent 99081fce30
commit 42d7d9d9b6
20 changed files with 426 additions and 124 deletions

View file

@ -5,8 +5,6 @@ export type {
AgentEndEvent,
AgentStartEvent,
BashToolResultEvent,
BranchEvent,
BranchEventResult,
CustomToolResultEvent,
EditToolResultEvent,
ExecResult,
@ -21,6 +19,7 @@ export type {
LsToolResultEvent,
ReadToolResultEvent,
SessionEvent,
SessionEventResult,
ToolCallEvent,
ToolCallEventResult,
ToolResultEvent,

View file

@ -5,13 +5,13 @@
import { spawn } from "node:child_process";
import type { LoadedHook, SendHandler } from "./loader.js";
import type {
BranchEventResult,
ExecOptions,
ExecResult,
HookError,
HookEvent,
HookEventContext,
HookUIContext,
SessionEventResult,
ToolCallEvent,
ToolCallEventResult,
ToolResultEventResult,
@ -217,11 +217,11 @@ export class HookRunner {
/**
* Emit an event to all hooks.
* Returns the result from branch/tool_result events (if any handler returns one).
* Returns the result from session/tool_result events (if any handler returns one).
*/
async emit(event: HookEvent): Promise<BranchEventResult | ToolResultEventResult | undefined> {
async emit(event: HookEvent): Promise<SessionEventResult | ToolResultEventResult | undefined> {
const ctx = this.createContext();
let result: BranchEventResult | ToolResultEventResult | undefined;
let result: SessionEventResult | ToolResultEventResult | undefined;
for (const hook of this.hooks) {
const handlers = hook.handlers.get(event.type);
@ -233,9 +233,13 @@ export class HookRunner {
const handlerResult = await Promise.race([handler(event, ctx), timeout.promise]);
timeout.clear();
// For branch events, capture the result
if (event.type === "branch" && handlerResult) {
result = handlerResult as BranchEventResult;
// For session events, capture the result (for before_* cancellation)
if (event.type === "session" && handlerResult) {
result = handlerResult as SessionEventResult;
// If cancelled, stop processing further hooks
if (result.cancel) {
return result;
}
}
// For tool_result events, capture the result

View file

@ -90,11 +90,9 @@ export interface HookEventContext {
// ============================================================================
/**
* Event data for session event.
* Fired on startup and when session changes (switch or clear).
* Note: branch has its own event that fires BEFORE the branch happens.
* Base fields shared by all session events.
*/
export interface SessionEvent {
interface SessionEventBase {
type: "session";
/** All session entries (including pre-compaction history) */
entries: SessionEntry[];
@ -102,10 +100,32 @@ export interface SessionEvent {
sessionFile: string | null;
/** Previous session file path, or null for "start" and "clear" */
previousSessionFile: string | null;
/** Reason for the session event */
reason: "start" | "switch" | "clear";
}
/**
* Event data for session events.
* Discriminated union based on reason.
*
* Lifecycle:
* - start: Initial session load
* - before_switch / switch: Session switch (e.g., /session command)
* - before_clear / clear: Session clear (e.g., /clear command)
* - before_branch / branch: Session branch (e.g., /branch command)
* - shutdown: Process exit (SIGINT/SIGTERM)
*
* "before_*" events fire before the action and can be cancelled via SessionEventResult.
* Other events fire after the action completes.
*/
export type SessionEvent =
| (SessionEventBase & {
reason: "start" | "switch" | "clear" | "before_switch" | "before_clear" | "shutdown";
})
| (SessionEventBase & {
reason: "branch" | "before_branch";
/** Index of the turn to branch from */
targetTurnIndex: number;
});
/**
* Event data for agent_start event.
* Fired when an agent loop starts (once per user prompt).
@ -256,17 +276,6 @@ export function isLsToolResult(e: ToolResultEvent): e is LsToolResultEvent {
return e.toolName === "ls";
}
/**
* Event data for branch event.
*/
export interface BranchEvent {
type: "branch";
/** Index of the turn to branch from */
targetTurnIndex: number;
/** Full session history */
entries: SessionEntry[];
}
/**
* Union of all hook event types.
*/
@ -277,8 +286,7 @@ export type HookEvent =
| TurnStartEvent
| TurnEndEvent
| ToolCallEvent
| ToolResultEvent
| BranchEvent;
| ToolResultEvent;
// ============================================================================
// Event Results
@ -309,12 +317,12 @@ export interface ToolResultEventResult {
}
/**
* Return type for branch event handlers.
* Allows hooks to control branch behavior.
* Return type for session event handlers.
* Allows hooks to cancel "before_*" actions.
*/
export interface BranchEventResult {
/** If true, skip restoring the conversation (only restore code) */
skipConversationRestore?: boolean;
export interface SessionEventResult {
/** If true, cancel the pending action (switch, clear, or branch) */
cancel?: boolean;
}
// ============================================================================
@ -331,14 +339,14 @@ export type HookHandler<E, R = void> = (event: E, ctx: HookEventContext) => Prom
* Hooks use pi.on() to subscribe to events and pi.send() to inject messages.
*/
export interface HookAPI {
on(event: "session", handler: HookHandler<SessionEvent>): void;
// biome-ignore lint/suspicious/noConfusingVoidType: void allows handlers to not return anything
on(event: "session", handler: HookHandler<SessionEvent, SessionEventResult | void>): 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: "branch", handler: HookHandler<BranchEvent, BranchEventResult | undefined>): void;
/**
* Send a message to the agent.