Remove hook execution timeouts

- Remove timeout logic from HookRunner
- Remove hookTimeout from Settings interface
- Remove getHookTimeout/setHookTimeout methods
- Update CHANGELOG.md and hooks.md

Timeouts were inconsistently applied and caused issues with
legitimate slow operations (LLM calls, user prompts). Users can
use Ctrl+C to abort hung hooks.
This commit is contained in:
Mario Zechner 2025-12-31 12:57:54 +01:00
parent bab343b8bc
commit 88e39471ea
5 changed files with 10 additions and 63 deletions

View file

@ -25,11 +25,6 @@ import type {
ToolResultEventResult,
} from "./types.js";
/**
* Default timeout for hook execution (30 seconds).
*/
const DEFAULT_TIMEOUT = 30000;
/**
* Listener for hook errors.
*/
@ -38,20 +33,6 @@ export type HookErrorListener = (error: HookError) => void;
// Re-export execCommand for backward compatibility
export { execCommand } from "../exec.js";
/**
* Create a promise that rejects after a timeout.
*/
function createTimeout(ms: number): { promise: Promise<never>; clear: () => void } {
let timeoutId: NodeJS.Timeout;
const promise = new Promise<never>((_, reject) => {
timeoutId = setTimeout(() => reject(new Error(`Hook timed out after ${ms}ms`)), ms);
});
return {
promise,
clear: () => clearTimeout(timeoutId),
};
}
/** No-op UI context used when no UI is available */
const noOpUIContext: HookUIContext = {
select: async () => undefined,
@ -71,24 +52,16 @@ export class HookRunner {
private cwd: string;
private sessionManager: SessionManager;
private modelRegistry: ModelRegistry;
private timeout: number;
private errorListeners: Set<HookErrorListener> = new Set();
private getModel: () => Model<any> | undefined = () => undefined;
constructor(
hooks: LoadedHook[],
cwd: string,
sessionManager: SessionManager,
modelRegistry: ModelRegistry,
timeout: number = DEFAULT_TIMEOUT,
) {
constructor(hooks: LoadedHook[], cwd: string, sessionManager: SessionManager, modelRegistry: ModelRegistry) {
this.hooks = hooks;
this.uiContext = noOpUIContext;
this.hasUI = false;
this.cwd = cwd;
this.sessionManager = sessionManager;
this.modelRegistry = modelRegistry;
this.timeout = timeout;
}
/**
@ -262,16 +235,7 @@ export class HookRunner {
for (const handler of handlers) {
try {
// No timeout for session_before_compact events (like tool_call, they may take a while)
let handlerResult: unknown;
if (event.type === "session_before_compact") {
handlerResult = await handler(event, ctx);
} else {
const timeout = createTimeout(this.timeout);
handlerResult = await Promise.race([handler(event, ctx), timeout.promise]);
timeout.clear();
}
const handlerResult = await handler(event, ctx);
// For session before_* events, capture the result (for cancellation)
if (this.isSessionBeforeEvent(event.type) && handlerResult) {
@ -348,9 +312,7 @@ export class HookRunner {
for (const handler of handlers) {
try {
const event: ContextEvent = { type: "context", messages: currentMessages };
const timeout = createTimeout(this.timeout);
const handlerResult = await Promise.race([handler(event, ctx), timeout.promise]);
timeout.clear();
const handlerResult = await handler(event, ctx);
if (handlerResult && (handlerResult as ContextEventResult).messages) {
currentMessages = (handlerResult as ContextEventResult).messages!;
@ -387,9 +349,7 @@ export class HookRunner {
for (const handler of handlers) {
try {
const event: BeforeAgentStartEvent = { type: "before_agent_start", prompt, images };
const timeout = createTimeout(this.timeout);
const handlerResult = await Promise.race([handler(event, ctx), timeout.promise]);
timeout.clear();
const handlerResult = await handler(event, ctx);
// Take the first message returned
if (handlerResult && (handlerResult as BeforeAgentStartEventResult).message && !result) {

View file

@ -313,7 +313,6 @@ export function loadSettings(cwd?: string, agentDir?: string): Settings {
shellPath: manager.getShellPath(),
collapseChangelog: manager.getCollapseChangelog(),
hooks: manager.getHookPaths(),
hookTimeout: manager.getHookTimeout(),
customTools: manager.getCustomToolPaths(),
skills: manager.getSkillsSettings(),
terminal: { showImages: manager.getShowImages() },
@ -536,7 +535,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
if (options.hooks !== undefined) {
if (options.hooks.length > 0) {
const loadedHooks = createLoadedHooksFromDefinitions(options.hooks);
hookRunner = new HookRunner(loadedHooks, cwd, sessionManager, modelRegistry, settingsManager.getHookTimeout());
hookRunner = new HookRunner(loadedHooks, cwd, sessionManager, modelRegistry);
}
} else {
// Discover hooks, merging with additional paths
@ -547,7 +546,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
console.error(`Failed to load hook "${path}": ${error}`);
}
if (hooks.length > 0) {
hookRunner = new HookRunner(hooks, cwd, sessionManager, modelRegistry, settingsManager.getHookTimeout());
hookRunner = new HookRunner(hooks, cwd, sessionManager, modelRegistry);
}
}

View file

@ -48,7 +48,6 @@ export interface Settings {
shellPath?: string; // Custom shell path (e.g., for Cygwin users on Windows)
collapseChangelog?: boolean; // Show condensed changelog after update (use /changelog for full)
hooks?: string[]; // Array of hook file paths
hookTimeout?: number; // Timeout for hook execution in ms (default: 30000)
customTools?: string[]; // Array of custom tool file paths
skills?: SkillsSettings;
terminal?: TerminalSettings;
@ -322,15 +321,6 @@ export class SettingsManager {
this.save();
}
getHookTimeout(): number {
return this.settings.hookTimeout ?? 30000;
}
setHookTimeout(timeout: number): void {
this.globalSettings.hookTimeout = timeout;
this.save();
}
getCustomToolPaths(): string[] {
return [...(this.settings.customTools ?? [])];
}