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

@ -30,7 +30,10 @@ import { exportSessionToHtml } from "./export-html.js";
import type {
HookCommandContext,
HookRunner,
SessionEventResult,
SessionBeforeBranchResult,
SessionBeforeCompactResult,
SessionBeforeNewResult,
SessionBeforeSwitchResult,
TurnEndEvent,
TurnStartEvent,
} from "./hooks/index.js";
@ -44,7 +47,7 @@ import { expandSlashCommand, type FileSlashCommand } from "./slash-commands.js";
export type AgentSessionEvent =
| AgentEvent
| { type: "auto_compaction_start"; reason: "threshold" | "overflow" }
| { type: "auto_compaction_end"; result: CompactionResult | null; aborted: boolean; willRetry: boolean }
| { type: "auto_compaction_end"; result: CompactionResult | undefined; aborted: boolean; willRetry: boolean }
| { type: "auto_retry_start"; attempt: number; maxAttempts: number; delayMs: number; errorMessage: string }
| { type: "auto_retry_end"; success: boolean; attempt: number; finalError?: string };
@ -64,7 +67,7 @@ export interface AgentSessionConfig {
/** File-based slash commands for expansion */
fileCommands?: FileSlashCommand[];
/** Hook runner (created in main.ts with wrapped tools) */
hookRunner?: HookRunner | null;
hookRunner?: HookRunner;
/** Custom tools for session lifecycle events */
customTools?: LoadedCustomTool[];
skillsSettings?: Required<SkillsSettings>;
@ -90,7 +93,7 @@ export interface ModelCycleResult {
/** Session statistics for /session command */
export interface SessionStats {
sessionFile: string | null;
sessionFile: string | undefined;
sessionId: string;
userMessages: number;
assistantMessages: number;
@ -138,21 +141,21 @@ export class AgentSession {
private _queuedMessages: string[] = [];
// Compaction state
private _compactionAbortController: AbortController | null = null;
private _autoCompactionAbortController: AbortController | null = null;
private _compactionAbortController: AbortController | undefined = undefined;
private _autoCompactionAbortController: AbortController | undefined = undefined;
// Retry state
private _retryAbortController: AbortController | null = null;
private _retryAbortController: AbortController | undefined = undefined;
private _retryAttempt = 0;
private _retryPromise: Promise<void> | null = null;
private _retryResolve: (() => void) | null = null;
private _retryPromise: Promise<void> | undefined = undefined;
private _retryResolve: (() => void) | undefined = undefined;
// Bash execution state
private _bashAbortController: AbortController | null = null;
private _bashAbortController: AbortController | undefined = undefined;
private _pendingBashMessages: BashExecutionMessage[] = [];
// Hook system
private _hookRunner: HookRunner | null = null;
private _hookRunner: HookRunner | undefined = undefined;
private _turnIndex = 0;
// Custom tools for session lifecycle
@ -169,10 +172,14 @@ export class AgentSession {
this.settingsManager = config.settingsManager;
this._scopedModels = config.scopedModels ?? [];
this._fileCommands = config.fileCommands ?? [];
this._hookRunner = config.hookRunner ?? null;
this._hookRunner = config.hookRunner;
this._customTools = config.customTools ?? [];
this._skillsSettings = config.skillsSettings;
this._modelRegistry = config.modelRegistry;
// Always subscribe to agent events for internal handling
// (session persistence, hooks, auto-compaction, retry logic)
this._unsubscribeAgent = this.agent.subscribe(this._handleAgentEvent);
}
/** Model registry for API key resolution and model discovery */
@ -192,7 +199,7 @@ export class AgentSession {
}
// Track last assistant message for auto-compaction check
private _lastAssistantMessage: AssistantMessage | null = null;
private _lastAssistantMessage: AssistantMessage | undefined = undefined;
/** Internal handler for agent events - shared by subscribe and reconnect */
private _handleAgentEvent = async (event: AgentEvent): Promise<void> => {
@ -246,7 +253,7 @@ export class AgentSession {
// Check auto-retry and auto-compaction after agent completes
if (event.type === "agent_end" && this._lastAssistantMessage) {
const msg = this._lastAssistantMessage;
this._lastAssistantMessage = null;
this._lastAssistantMessage = undefined;
// Check for retryable errors first (overloaded, rate limit, server errors)
if (this._isRetryableError(msg)) {
@ -272,8 +279,8 @@ export class AgentSession {
private _resolveRetry(): void {
if (this._retryResolve) {
this._retryResolve();
this._retryResolve = null;
this._retryPromise = null;
this._retryResolve = undefined;
this._retryPromise = undefined;
}
}
@ -287,7 +294,7 @@ export class AgentSession {
}
/** Find the last assistant message in agent state (including aborted ones) */
private _findLastAssistantMessage(): AssistantMessage | null {
private _findLastAssistantMessage(): AssistantMessage | undefined {
const messages = this.agent.state.messages;
for (let i = messages.length - 1; i >= 0; i--) {
const msg = messages[i];
@ -295,7 +302,7 @@ export class AgentSession {
return msg as AssistantMessage;
}
}
return null;
return undefined;
}
/** Emit hook events based on agent events */
@ -334,11 +341,6 @@ export class AgentSession {
subscribe(listener: AgentSessionEventListener): () => void {
this._eventListeners.push(listener);
// Set up agent subscription if not already done
if (!this._unsubscribeAgent) {
this._unsubscribeAgent = this.agent.subscribe(this._handleAgentEvent);
}
// Return unsubscribe function for this specific listener
return () => {
const index = this._eventListeners.indexOf(listener);
@ -387,8 +389,8 @@ export class AgentSession {
return this.agent.state;
}
/** Current model (may be null if not yet selected) */
get model(): Model<any> | null {
/** Current model (may be undefined if not yet selected) */
get model(): Model<any> | undefined {
return this.agent.state.model;
}
@ -404,7 +406,7 @@ export class AgentSession {
/** Whether auto-compaction is currently running */
get isCompacting(): boolean {
return this._autoCompactionAbortController !== null || this._compactionAbortController !== null;
return this._autoCompactionAbortController !== undefined || this._compactionAbortController !== undefined;
}
/** All messages including custom types like BashExecutionMessage */
@ -417,9 +419,9 @@ export class AgentSession {
return this.agent.getQueueMode();
}
/** Current session file path, or null if sessions are disabled */
get sessionFile(): string | null {
return this.sessionManager.getSessionFile() ?? null;
/** Current session file path, or undefined if sessions are disabled */
get sessionFile(): string | undefined {
return this.sessionManager.getSessionFile();
}
/** Current session ID */
@ -663,12 +665,11 @@ export class AgentSession {
async reset(): Promise<boolean> {
const previousSessionFile = this.sessionFile;
// Emit before_new event (can be cancelled)
if (this._hookRunner?.hasHandlers("session")) {
// Emit session_before_new event (can be cancelled)
if (this._hookRunner?.hasHandlers("session_before_new")) {
const result = (await this._hookRunner.emit({
type: "session",
reason: "before_new",
})) as SessionEventResult | undefined;
type: "session_before_new",
})) as SessionBeforeNewResult | undefined;
if (result?.cancel) {
return false;
@ -682,11 +683,10 @@ export class AgentSession {
this._queuedMessages = [];
this._reconnectToAgent();
// Emit session event with reason "new" to hooks
// Emit session_new event to hooks
if (this._hookRunner) {
await this._hookRunner.emit({
type: "session",
reason: "new",
type: "session_new",
});
}
@ -722,17 +722,17 @@ export class AgentSession {
* Cycle to next/previous model.
* Uses scoped models (from --models flag) if available, otherwise all available models.
* @param direction - "forward" (default) or "backward"
* @returns The new model info, or null if only one model available
* @returns The new model info, or undefined if only one model available
*/
async cycleModel(direction: "forward" | "backward" = "forward"): Promise<ModelCycleResult | null> {
async cycleModel(direction: "forward" | "backward" = "forward"): Promise<ModelCycleResult | undefined> {
if (this._scopedModels.length > 0) {
return this._cycleScopedModel(direction);
}
return this._cycleAvailableModel(direction);
}
private async _cycleScopedModel(direction: "forward" | "backward"): Promise<ModelCycleResult | null> {
if (this._scopedModels.length <= 1) return null;
private async _cycleScopedModel(direction: "forward" | "backward"): Promise<ModelCycleResult | undefined> {
if (this._scopedModels.length <= 1) return undefined;
const currentModel = this.model;
let currentIndex = this._scopedModels.findIndex((sm) => modelsAreEqual(sm.model, currentModel));
@ -759,9 +759,9 @@ export class AgentSession {
return { model: next.model, thinkingLevel: this.thinkingLevel, isScoped: true };
}
private async _cycleAvailableModel(direction: "forward" | "backward"): Promise<ModelCycleResult | null> {
private async _cycleAvailableModel(direction: "forward" | "backward"): Promise<ModelCycleResult | undefined> {
const availableModels = await this._modelRegistry.getAvailable();
if (availableModels.length <= 1) return null;
if (availableModels.length <= 1) return undefined;
const currentModel = this.model;
let currentIndex = availableModels.findIndex((m) => modelsAreEqual(m, currentModel));
@ -816,10 +816,10 @@ export class AgentSession {
/**
* Cycle to next thinking level.
* @returns New level, or null if model doesn't support thinking
* @returns New level, or undefined if model doesn't support thinking
*/
cycleThinkingLevel(): ThinkingLevel | null {
if (!this.supportsThinking()) return null;
cycleThinkingLevel(): ThinkingLevel | undefined {
if (!this.supportsThinking()) return undefined;
const levels = this.getAvailableThinkingLevels();
const currentIndex = levels.indexOf(this.thinkingLevel);
@ -904,19 +904,18 @@ export class AgentSession {
let hookCompaction: CompactionResult | undefined;
let fromHook = false;
if (this._hookRunner?.hasHandlers("session")) {
if (this._hookRunner?.hasHandlers("session_before_compact")) {
// Get previous compactions, newest first
const previousCompactions = entries.filter((e): e is CompactionEntry => e.type === "compaction").reverse();
const result = (await this._hookRunner.emit({
type: "session",
reason: "before_compact",
type: "session_before_compact",
preparation,
previousCompactions,
customInstructions,
model: this.model,
signal: this._compactionAbortController.signal,
})) as SessionEventResult | undefined;
})) as SessionBeforeCompactResult | undefined;
if (result?.cancel) {
throw new Error("Compaction cancelled");
@ -971,8 +970,7 @@ export class AgentSession {
if (this._hookRunner && savedCompactionEntry) {
await this._hookRunner.emit({
type: "session",
reason: "compact",
type: "session_compact",
compactionEntry: savedCompactionEntry,
fromHook,
});
@ -985,7 +983,7 @@ export class AgentSession {
details,
};
} finally {
this._compactionAbortController = null;
this._compactionAbortController = undefined;
this._reconnectToAgent();
}
}
@ -1051,13 +1049,13 @@ export class AgentSession {
try {
if (!this.model) {
this._emit({ type: "auto_compaction_end", result: null, aborted: false, willRetry: false });
this._emit({ type: "auto_compaction_end", result: undefined, aborted: false, willRetry: false });
return;
}
const apiKey = await this._modelRegistry.getApiKey(this.model);
if (!apiKey) {
this._emit({ type: "auto_compaction_end", result: null, aborted: false, willRetry: false });
this._emit({ type: "auto_compaction_end", result: undefined, aborted: false, willRetry: false });
return;
}
@ -1065,29 +1063,28 @@ export class AgentSession {
const preparation = prepareCompaction(entries, settings);
if (!preparation) {
this._emit({ type: "auto_compaction_end", result: null, aborted: false, willRetry: false });
this._emit({ type: "auto_compaction_end", result: undefined, aborted: false, willRetry: false });
return;
}
let hookCompaction: CompactionResult | undefined;
let fromHook = false;
if (this._hookRunner?.hasHandlers("session")) {
if (this._hookRunner?.hasHandlers("session_before_compact")) {
// Get previous compactions, newest first
const previousCompactions = entries.filter((e): e is CompactionEntry => e.type === "compaction").reverse();
const hookResult = (await this._hookRunner.emit({
type: "session",
reason: "before_compact",
type: "session_before_compact",
preparation,
previousCompactions,
customInstructions: undefined,
model: this.model,
signal: this._autoCompactionAbortController.signal,
})) as SessionEventResult | undefined;
})) as SessionBeforeCompactResult | undefined;
if (hookResult?.cancel) {
this._emit({ type: "auto_compaction_end", result: null, aborted: true, willRetry: false });
this._emit({ type: "auto_compaction_end", result: undefined, aborted: true, willRetry: false });
return;
}
@ -1124,7 +1121,7 @@ export class AgentSession {
}
if (this._autoCompactionAbortController.signal.aborted) {
this._emit({ type: "auto_compaction_end", result: null, aborted: true, willRetry: false });
this._emit({ type: "auto_compaction_end", result: undefined, aborted: true, willRetry: false });
return;
}
@ -1140,8 +1137,7 @@ export class AgentSession {
if (this._hookRunner && savedCompactionEntry) {
await this._hookRunner.emit({
type: "session",
reason: "compact",
type: "session_compact",
compactionEntry: savedCompactionEntry,
fromHook,
});
@ -1167,7 +1163,7 @@ export class AgentSession {
}, 100);
}
} catch (error) {
this._emit({ type: "auto_compaction_end", result: null, aborted: false, willRetry: false });
this._emit({ type: "auto_compaction_end", result: undefined, aborted: false, willRetry: false });
if (reason === "overflow") {
throw new Error(
@ -1175,7 +1171,7 @@ export class AgentSession {
);
}
} finally {
this._autoCompactionAbortController = null;
this._autoCompactionAbortController = undefined;
}
}
@ -1267,7 +1263,7 @@ export class AgentSession {
// Aborted during sleep - emit end event so UI can clean up
const attempt = this._retryAttempt;
this._retryAttempt = 0;
this._retryAbortController = null;
this._retryAbortController = undefined;
this._emit({
type: "auto_retry_end",
success: false,
@ -1277,7 +1273,7 @@ export class AgentSession {
this._resolveRetry();
return false;
}
this._retryAbortController = null;
this._retryAbortController = undefined;
// Retry via continue() - use setTimeout to break out of event handler chain
setTimeout(() => {
@ -1329,7 +1325,7 @@ export class AgentSession {
/** Whether auto-retry is currently in progress */
get isRetrying(): boolean {
return this._retryPromise !== null;
return this._retryPromise !== undefined;
}
/** Whether auto-retry is enabled */
@ -1389,7 +1385,7 @@ export class AgentSession {
return result;
} finally {
this._bashAbortController = null;
this._bashAbortController = undefined;
}
}
@ -1402,7 +1398,7 @@ export class AgentSession {
/** Whether a bash command is currently running */
get isBashRunning(): boolean {
return this._bashAbortController !== null;
return this._bashAbortController !== undefined;
}
/** Whether there are pending bash messages waiting to be flushed */
@ -1439,15 +1435,14 @@ export class AgentSession {
* @returns true if switch completed, false if cancelled by hook
*/
async switchSession(sessionPath: string): Promise<boolean> {
const previousSessionFile = this.sessionFile;
const previousSessionFile = this.sessionManager.getSessionFile();
// Emit before_switch event (can be cancelled)
if (this._hookRunner?.hasHandlers("session")) {
// Emit session_before_switch event (can be cancelled)
if (this._hookRunner?.hasHandlers("session_before_switch")) {
const result = (await this._hookRunner.emit({
type: "session",
reason: "before_switch",
type: "session_before_switch",
targetSessionFile: sessionPath,
})) as SessionEventResult | undefined;
})) as SessionBeforeSwitchResult | undefined;
if (result?.cancel) {
return false;
@ -1464,11 +1459,10 @@ export class AgentSession {
// Reload messages
const sessionContext = this.sessionManager.buildSessionContext();
// Emit session event to hooks
// Emit session_switch event to hooks
if (this._hookRunner) {
await this._hookRunner.emit({
type: "session",
reason: "switch",
type: "session_switch",
previousSessionFile,
});
}
@ -1520,13 +1514,12 @@ export class AgentSession {
let skipConversationRestore = false;
// Emit before_branch event (can be cancelled)
if (this._hookRunner?.hasHandlers("session")) {
// Emit session_before_branch event (can be cancelled)
if (this._hookRunner?.hasHandlers("session_before_branch")) {
const result = (await this._hookRunner.emit({
type: "session",
reason: "before_branch",
targetTurnIndex: entryIndex,
})) as SessionEventResult | undefined;
type: "session_before_branch",
entryIndex: entryIndex,
})) as SessionBeforeBranchResult | undefined;
if (result?.cancel) {
return { selectedText, cancelled: true };
@ -1534,27 +1527,20 @@ export class AgentSession {
skipConversationRestore = result?.skipConversationRestore ?? false;
}
// Create branched session ending before the selected message (returns null in --no-session mode)
// User will re-enter/edit the selected message
if (!selectedEntry.parentId) {
throw new Error("Cannot branch from first message");
}
const newSessionFile = this.sessionManager.createBranchedSession(selectedEntry.parentId);
// Update session file if we have one (file-based mode)
if (newSessionFile !== null) {
this.sessionManager.setSessionFile(newSessionFile);
this.sessionManager.newSession();
} else {
this.sessionManager.createBranchedSession(selectedEntry.parentId);
}
// Reload messages from entries (works for both file and in-memory mode)
const sessionContext = this.sessionManager.buildSessionContext();
// Emit branch event to hooks (after branch completes)
// Emit session_branch event to hooks (after branch completes)
if (this._hookRunner) {
await this._hookRunner.emit({
type: "session",
reason: "branch",
targetTurnIndex: entryIndex,
type: "session_branch",
previousSessionFile,
});
}
@ -1664,9 +1650,9 @@ export class AgentSession {
/**
* Get text content of last assistant message.
* Useful for /copy command.
* @returns Text content, or null if no assistant message exists
* @returns Text content, or undefined if no assistant message exists
*/
getLastAssistantText(): string | null {
getLastAssistantText(): string | undefined {
const lastAssistant = this.messages
.slice()
.reverse()
@ -1678,7 +1664,7 @@ export class AgentSession {
return true;
});
if (!lastAssistant) return null;
if (!lastAssistant) return undefined;
let text = "";
for (const content of (lastAssistant as AssistantMessage).content) {
@ -1687,7 +1673,7 @@ export class AgentSession {
}
}
return text.trim() || null;
return text.trim() || undefined;
}
// =========================================================================
@ -1704,7 +1690,7 @@ export class AgentSession {
/**
* Get the hook runner (for setting UI context and error handlers).
*/
get hookRunner(): HookRunner | null {
get hookRunner(): HookRunner | undefined {
return this._hookRunner;
}
@ -1721,7 +1707,7 @@ export class AgentSession {
*/
private async _emitToolSessionEvent(
reason: ToolSessionEvent["reason"],
previousSessionFile: string | null,
previousSessionFile: string | undefined,
): Promise<void> {
const event: ToolSessionEvent = {
entries: this.sessionManager.getEntries(),

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

@ -13,9 +13,9 @@ import type { CompactionEntry, SessionEntry } from "./session-manager.js";
/**
* Extract AgentMessage from an entry if it produces one.
* Returns null for entries that don't contribute to LLM context.
* Returns undefined for entries that don't contribute to LLM context.
*/
function getMessageFromEntry(entry: SessionEntry): AgentMessage | null {
function getMessageFromEntry(entry: SessionEntry): AgentMessage | undefined {
if (entry.type === "message") {
return entry.message;
}
@ -25,7 +25,7 @@ function getMessageFromEntry(entry: SessionEntry): AgentMessage | null {
if (entry.type === "branch_summary") {
return createBranchSummaryMessage(entry.summary, entry.fromId, entry.timestamp);
}
return null;
return undefined;
}
/** Result from compact() - SessionManager adds uuid/parentUuid when saving */
@ -69,20 +69,20 @@ export function calculateContextTokens(usage: Usage): number {
* 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 | null {
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 null;
return undefined;
}
/**
* Find the last non-aborted assistant message usage from session entries.
*/
export function getLastAssistantUsage(entries: SessionEntry[]): Usage | null {
export function getLastAssistantUsage(entries: SessionEntry[]): Usage | undefined {
for (let i = entries.length - 1; i >= 0; i--) {
const entry = entries[i];
if (entry.type === "message") {
@ -90,7 +90,7 @@ export function getLastAssistantUsage(entries: SessionEntry[]): Usage | null {
if (usage) return usage;
}
}
return null;
return undefined;
}
/**
@ -398,9 +398,12 @@ export interface CompactionPreparation {
boundaryStart: number;
}
export function prepareCompaction(entries: SessionEntry[], settings: CompactionSettings): CompactionPreparation | null {
export function prepareCompaction(
entries: SessionEntry[],
settings: CompactionSettings,
): CompactionPreparation | undefined {
if (entries.length > 0 && entries[entries.length - 1].type === "compaction") {
return null;
return undefined;
}
let prevCompactionIndex = -1;
@ -421,7 +424,7 @@ export function prepareCompaction(entries: SessionEntry[], settings: CompactionS
// Get UUID of first kept entry
const firstKeptEntry = entries[cutPoint.firstKeptEntryIndex];
if (!firstKeptEntry?.id) {
return null; // Session needs migration
return undefined; // Session needs migration
}
const firstKeptEntryId = firstKeptEntry.id;

View file

@ -86,9 +86,9 @@ function resolveToolPath(toolPath: string, cwd: string): string {
*/
function createNoOpUIContext(): HookUIContext {
return {
select: async () => null,
select: async () => undefined,
confirm: async () => false,
input: async () => null,
input: async () => undefined,
notify: () => {},
custom: () => ({ close: () => {}, requestRender: () => {} }),
};

View file

@ -38,10 +38,10 @@ export interface ToolAPI {
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;
/** Current session file path, or undefined in --no-session mode */
sessionFile: string | undefined;
/** Previous session file path, or undefined for "start" and "new" */
previousSessionFile: string | undefined;
/** Reason for the session event */
reason: "start" | "switch" | "branch" | "new";
}

View file

@ -121,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`);
@ -129,7 +129,7 @@ function loadThemeJson(name: string): ThemeJson | null {
try {
return JSON.parse(readFileSync(builtinPath, "utf-8")) as ThemeJson;
} catch {
return null;
return undefined;
}
}
@ -140,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. */
@ -831,7 +831,9 @@ function formatMessage(
switch (message.role) {
case "bashExecution": {
const isError = message.cancelled || (message.exitCode !== 0 && message.exitCode !== null);
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;
@ -844,7 +846,7 @@ function formatMessage(
if (message.cancelled) {
html += `<div class="bash-status warning">(cancelled)</div>`;
} else if (message.exitCode !== 0 && message.exitCode !== null) {
} else if (message.exitCode !== 0 && message.exitCode !== null && message.exitCode !== undefined) {
html += `<div class="bash-status error">(exit ${message.exitCode})</div>`;
}
@ -1020,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) {

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;

View file

@ -30,7 +30,7 @@ export interface BashExecutionMessage {
role: "bashExecution";
command: string;
output: string;
exitCode: number | null;
exitCode: number | undefined;
cancelled: boolean;
truncated: boolean;
fullOutputPath?: string;
@ -86,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) {
@ -145,7 +145,7 @@ export function createHookMessage(
*/
export function convertToLlm(messages: AgentMessage[]): Message[] {
return messages
.map((m): Message | null => {
.map((m): Message | undefined => {
switch (m.role) {
case "bashExecution":
return {
@ -182,8 +182,8 @@ export function convertToLlm(messages: AgentMessage[]): Message[] {
default:
// biome-ignore lint/correctness/noSwitchDeclarations: fine
const _exhaustiveCheck: never = m;
return null;
return undefined;
}
})
.filter((m) => 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

@ -530,7 +530,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
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);

View file

@ -450,7 +450,7 @@ export class SessionManager {
private labelsById: Map<string, string> = new Map();
private leafId: string = "";
private constructor(cwd: string, sessionDir: string, sessionFile: string | null, persist: boolean) {
private constructor(cwd: string, sessionDir: string, sessionFile: string | undefined, persist: boolean) {
this.cwd = cwd;
this.sessionDir = sessionDir;
this.persist = persist;
@ -484,7 +484,7 @@ export class SessionManager {
}
}
newSession(): void {
newSession(): string | undefined {
this.sessionId = randomUUID();
const timestamp = new Date().toISOString();
const header: SessionHeader = {
@ -503,6 +503,7 @@ export class SessionManager {
const fileTimestamp = timestamp.replace(/[:.]/g, "-");
this.sessionFile = join(this.getSessionDir(), `${fileTimestamp}_${this.sessionId}.jsonl`);
}
return this.sessionFile;
}
private _buildIndex(): void {
@ -841,9 +842,9 @@ export class SessionManager {
/**
* Create a new session file containing only the path from root to the specified leaf.
* Useful for extracting a single conversation path from a branched session.
* Returns the new session file path, or null if not persisting.
* Returns the new session file path, or undefined if not persisting.
*/
createBranchedSession(leafId: string): string | null {
createBranchedSession(leafId: string): string | undefined {
const path = this.getPath(leafId);
if (path.length === 0) {
throw new Error(`Entry ${leafId} not found`);
@ -883,6 +884,7 @@ export class SessionManager {
// Write fresh label entries at the end
const lastEntryId = pathWithoutLabels[pathWithoutLabels.length - 1]?.id || null;
let parentId = lastEntryId;
const labelEntries: LabelEntry[] = [];
for (const { targetId, label } of labelsToWrite) {
const labelEntry: LabelEntry = {
type: "label",
@ -894,8 +896,12 @@ export class SessionManager {
};
appendFileSync(newSessionFile, `${JSON.stringify(labelEntry)}\n`);
pathEntryIds.add(labelEntry.id);
labelEntries.push(labelEntry);
parentId = labelEntry.id;
}
this.fileEntries = [header, ...pathWithoutLabels, ...labelEntries];
this.sessionId = newSessionId;
this._buildIndex();
return newSessionFile;
}
@ -917,7 +923,7 @@ export class SessionManager {
this.fileEntries = [header, ...pathWithoutLabels, ...labelEntries];
this.sessionId = newSessionId;
this._buildIndex();
return null;
return undefined;
}
/**
@ -927,7 +933,7 @@ export class SessionManager {
*/
static create(cwd: string, sessionDir?: string): SessionManager {
const dir = sessionDir ?? getDefaultSessionDir(cwd);
return new SessionManager(cwd, dir, null, true);
return new SessionManager(cwd, dir, undefined, true);
}
/**
@ -956,12 +962,12 @@ export class SessionManager {
if (mostRecent) {
return new SessionManager(cwd, dir, mostRecent, true);
}
return new SessionManager(cwd, dir, null, true);
return new SessionManager(cwd, dir, undefined, true);
}
/** Create an in-memory session (no file persistence) */
static inMemory(cwd: string = process.cwd()): SessionManager {
return new SessionManager(cwd, "", null, false);
return new SessionManager(cwd, "", undefined, false);
}
/**

View file

@ -38,33 +38,7 @@ export type {
ToolUIContext,
} from "./core/custom-tools/index.js";
export { discoverAndLoadCustomTools, loadCustomTools } from "./core/custom-tools/index.js";
export type {
AgentEndEvent,
AgentStartEvent,
BashToolResultEvent,
BeforeAgentStartEvent,
BeforeAgentStartEventResult,
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,

View file

@ -33,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;
@ -45,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,
initialImages?: ImageContent[],
fdPath: string | null = null,
fdPath: string | undefined = undefined,
): Promise<void> {
const mode = new InteractiveMode(session, version, changelogMarkdown, customTools, setToolUIContext, fdPath);
@ -143,9 +143,9 @@ async function prepareInitialMessage(parsed: Args): Promise<{
};
}
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();
@ -165,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();
}
@ -183,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 */
@ -207,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 = {};
@ -408,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) {

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;

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;
}
}