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

@ -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

@ -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

@ -67,14 +67,14 @@ export class InteractiveMode {
private version: string;
private isInitialized = false;
private onInputCallback?: (text: string) => void;
private loadingAnimation: Loader | null = null;
private loadingAnimation: Loader | undefined = undefined;
private lastSigintTime = 0;
private lastEscapeTime = 0;
private changelogMarkdown: string | null = null;
private changelogMarkdown: string | undefined = undefined;
// Streaming message tracking
private streamingComponent: AssistantMessageComponent | null = null;
private streamingComponent: AssistantMessageComponent | undefined = undefined;
// Tool execution tracking: toolCallId -> component
private pendingTools = new Map<string, ToolExecutionComponent>();
@ -92,22 +92,22 @@ export class InteractiveMode {
private isBashMode = false;
// Track current bash execution component
private bashComponent: BashExecutionComponent | null = null;
private bashComponent: BashExecutionComponent | undefined = undefined;
// Track pending bash components (shown in pending area, moved to chat on submit)
private pendingBashComponents: BashExecutionComponent[] = [];
// Auto-compaction state
private autoCompactionLoader: Loader | null = null;
private autoCompactionLoader: Loader | undefined = undefined;
private autoCompactionEscapeHandler?: () => void;
// Auto-retry state
private retryLoader: Loader | null = null;
private retryLoader: Loader | undefined = undefined;
private retryEscapeHandler?: () => void;
// Hook UI state
private hookSelector: HookSelectorComponent | null = null;
private hookInput: HookInputComponent | null = null;
private hookSelector: HookSelectorComponent | undefined = undefined;
private hookInput: HookInputComponent | undefined = undefined;
// Custom tools for custom rendering
private customTools: Map<string, LoadedCustomTool>;
@ -126,10 +126,10 @@ export class InteractiveMode {
constructor(
session: AgentSession,
version: string,
changelogMarkdown: string | null = null,
changelogMarkdown: string | undefined = undefined,
customTools: LoadedCustomTool[] = [],
private setToolUIContext: (uiContext: HookUIContext, hasUI: boolean) => void = () => {},
fdPath: string | null = null,
fdPath: string | undefined = undefined,
) {
this.session = session;
this.version = version;
@ -350,7 +350,7 @@ export class InteractiveMode {
await this.emitToolSessionEvent({
entries,
sessionFile: this.session.sessionFile,
previousSessionFile: null,
previousSessionFile: undefined,
reason: "start",
});
@ -395,10 +395,9 @@ export class InteractiveMode {
this.chatContainer.addChild(new Spacer(1));
}
// Emit session event
// Emit session_start event
await hookRunner.emit({
type: "session",
reason: "start",
type: "session_start",
});
}
@ -442,7 +441,7 @@ export class InteractiveMode {
/**
* Show a selector for hooks.
*/
private showHookSelector(title: string, options: string[]): Promise<string | null> {
private showHookSelector(title: string, options: string[]): Promise<string | undefined> {
return new Promise((resolve) => {
this.hookSelector = new HookSelectorComponent(
title,
@ -453,7 +452,7 @@ export class InteractiveMode {
},
() => {
this.hideHookSelector();
resolve(null);
resolve(undefined);
},
);
@ -470,7 +469,7 @@ export class InteractiveMode {
private hideHookSelector(): void {
this.editorContainer.clear();
this.editorContainer.addChild(this.editor);
this.hookSelector = null;
this.hookSelector = undefined;
this.ui.setFocus(this.editor);
this.ui.requestRender();
}
@ -486,7 +485,7 @@ export class InteractiveMode {
/**
* Show a text input for hooks.
*/
private showHookInput(title: string, placeholder?: string): Promise<string | null> {
private showHookInput(title: string, placeholder?: string): Promise<string | undefined> {
return new Promise((resolve) => {
this.hookInput = new HookInputComponent(
title,
@ -497,7 +496,7 @@ export class InteractiveMode {
},
() => {
this.hideHookInput();
resolve(null);
resolve(undefined);
},
);
@ -514,7 +513,7 @@ export class InteractiveMode {
private hideHookInput(): void {
this.editorContainer.clear();
this.editorContainer.addChild(this.editor);
this.hookInput = null;
this.hookInput = undefined;
this.ui.setFocus(this.editor);
this.ui.requestRender();
}
@ -874,7 +873,7 @@ export class InteractiveMode {
}
this.pendingTools.clear();
}
this.streamingComponent = null;
this.streamingComponent = undefined;
this.footer.invalidate();
}
this.ui.requestRender();
@ -920,12 +919,12 @@ export class InteractiveMode {
case "agent_end":
if (this.loadingAnimation) {
this.loadingAnimation.stop();
this.loadingAnimation = null;
this.loadingAnimation = undefined;
this.statusContainer.clear();
}
if (this.streamingComponent) {
this.chatContainer.removeChild(this.streamingComponent);
this.streamingComponent = null;
this.streamingComponent = undefined;
}
this.pendingTools.clear();
this.ui.requestRender();
@ -964,7 +963,7 @@ export class InteractiveMode {
// Stop loader
if (this.autoCompactionLoader) {
this.autoCompactionLoader.stop();
this.autoCompactionLoader = null;
this.autoCompactionLoader = undefined;
this.statusContainer.clear();
}
// Handle result
@ -1018,7 +1017,7 @@ export class InteractiveMode {
// Stop loader
if (this.retryLoader) {
this.retryLoader.stop();
this.retryLoader = null;
this.retryLoader = undefined;
this.statusContainer.clear();
}
// Show error only on final failure (success shows normal response)
@ -1228,10 +1227,9 @@ export class InteractiveMode {
private async shutdown(): Promise<void> {
// Emit shutdown event to hooks
const hookRunner = this.session.hookRunner;
if (hookRunner?.hasHandlers("session")) {
if (hookRunner?.hasHandlers("session_shutdown")) {
await hookRunner.emit({
type: "session",
reason: "shutdown",
type: "session_shutdown",
});
}
@ -1265,7 +1263,7 @@ export class InteractiveMode {
private cycleThinkingLevel(): void {
const newLevel = this.session.cycleThinkingLevel();
if (newLevel === null) {
if (newLevel === undefined) {
this.showStatus("Current model does not support thinking");
} else {
this.footer.updateState(this.session.state);
@ -1277,7 +1275,7 @@ export class InteractiveMode {
private async cycleModel(direction: "forward" | "backward"): Promise<void> {
try {
const result = await this.session.cycleModel(direction);
if (result === null) {
if (result === undefined) {
const msg = this.session.scopedModels.length > 0 ? "Only one model in scope" : "Only one model available";
this.showStatus(msg);
} else {
@ -1612,13 +1610,13 @@ export class InteractiveMode {
// Stop loading animation
if (this.loadingAnimation) {
this.loadingAnimation.stop();
this.loadingAnimation = null;
this.loadingAnimation = undefined;
}
this.statusContainer.clear();
// Clear UI state
this.pendingMessagesContainer.clear();
this.streamingComponent = null;
this.streamingComponent = undefined;
this.pendingTools.clear();
// Switch session via AgentSession (emits hook and tool session events)
@ -1874,7 +1872,7 @@ export class InteractiveMode {
// Stop loading animation
if (this.loadingAnimation) {
this.loadingAnimation.stop();
this.loadingAnimation = null;
this.loadingAnimation = undefined;
}
this.statusContainer.clear();
@ -1884,7 +1882,7 @@ export class InteractiveMode {
// Clear UI state
this.chatContainer.clear();
this.pendingMessagesContainer.clear();
this.streamingComponent = null;
this.streamingComponent = undefined;
this.pendingTools.clear();
this.chatContainer.addChild(new Spacer(1));
@ -1962,12 +1960,12 @@ export class InteractiveMode {
}
} catch (error) {
if (this.bashComponent) {
this.bashComponent.setComplete(null, false);
this.bashComponent.setComplete(undefined, false);
}
this.showError(`Bash command failed: ${error instanceof Error ? error.message : "Unknown error"}`);
}
this.bashComponent = null;
this.bashComponent = undefined;
this.ui.requestRender();
}
@ -1987,7 +1985,7 @@ export class InteractiveMode {
// Stop loading animation
if (this.loadingAnimation) {
this.loadingAnimation.stop();
this.loadingAnimation = null;
this.loadingAnimation = undefined;
}
this.statusContainer.clear();
@ -2039,7 +2037,7 @@ export class InteractiveMode {
stop(): void {
if (this.loadingAnimation) {
this.loadingAnimation.stop();
this.loadingAnimation = null;
this.loadingAnimation = undefined;
}
this.footer.dispose();
if (this.unsubscribe) {

View file

@ -45,10 +45,9 @@ export async function runPrintMode(
hookRunner.setAppendEntryHandler((customType, data) => {
session.sessionManager.appendCustomEntry(customType, data);
});
// Emit session event
// Emit session_start event
await hookRunner.emit({
type: "session",
reason: "start",
type: "session_start",
});
}
@ -59,7 +58,7 @@ export async function runPrintMode(
await tool.onSession({
entries,
sessionFile: session.sessionFile,
previousSessionFile: null,
previousSessionFile: undefined,
reason: "start",
});
} catch (_err) {

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,
@ -144,10 +144,9 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
hookRunner.setAppendEntryHandler((customType, data) => {
session.sessionManager.appendCustomEntry(customType, data);
});
// Emit session event
// Emit session_start event
await hookRunner.emit({
type: "session",
reason: "start",
type: "session_start",
});
}
@ -159,7 +158,7 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
await tool.onSession({
entries,
sessionFile: session.sessionFile,
previousSessionFile: null,
previousSessionFile: undefined,
reason: "start",
});
} catch (_err) {

View file

@ -65,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;