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

@ -42,6 +42,7 @@
- Renderers return inner content; the TUI wraps it in a styled Box
- New types: `HookMessage<T>`, `RegisteredCommand`, `HookContext`
- Handler types renamed: `SendHandler``SendMessageHandler`, new `AppendEntryHandler`
- Removed `hookTimeout` setting - hooks no longer have execution timeouts (use Ctrl+C to abort hung hooks)
- **SessionManager**:
- `getSessionFile()` now returns `string | undefined` (undefined for in-memory sessions)
- **Themes**: Custom themes must add `selectedBg`, `customMessageBg`, `customMessageText`, `customMessageLabel` color tokens (50 total)

View file

@ -51,13 +51,10 @@ Additional paths via `settings.json`:
```json
{
"hooks": ["/path/to/hook.ts"],
"hookTimeout": 30000
"hooks": ["/path/to/hook.ts"]
}
```
The `hookTimeout` (default 30s) applies to most events. `tool_call` has no timeout since it may prompt the user.
## Available Imports
| Package | Purpose |
@ -329,7 +326,7 @@ pi.on("context", async (event, ctx) => {
#### tool_call
Fired before tool executes. **Can block.** No timeout.
Fired before tool executes. **Can block.**
```typescript
pi.on("tool_call", async (event, ctx) => {
@ -654,8 +651,8 @@ In print mode, `select()` returns `undefined`, `confirm()` returns `false`, `inp
- Hook errors are logged, agent continues
- `tool_call` errors block the tool (fail-safe)
- Timeout errors (default 30s) are logged but don't block
- Errors display in UI with hook path and message
- If a hook hangs, use Ctrl+C to abort
## Debugging

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 ?? [])];
}