add singal abort for hooks

This commit is contained in:
DennisYu07 2026-03-23 16:02:54 +08:00
parent a0041191a7
commit 8bd7cf2cda
16 changed files with 344 additions and 52 deletions

View file

@ -41,6 +41,7 @@ import {
Storage, Storage,
SessionEndReason, SessionEndReason,
SessionStartSource, SessionStartSource,
type PermissionMode,
} from '@qwen-code/qwen-code-core'; } from '@qwen-code/qwen-code-core';
import { buildResumedHistoryItems } from './utils/resumeHistoryUtils.js'; import { buildResumedHistoryItems } from './utils/resumeHistoryUtils.js';
import { validateAuthMethod } from '../config/auth.js'; import { validateAuthMethod } from '../config/auth.js';
@ -308,7 +309,11 @@ export const AppContainer = (props: AppContainerProps) => {
if (hookSystem) { if (hookSystem) {
hookSystem hookSystem
.fireSessionStartEvent(sessionStartSource, config.getModel() ?? '') .fireSessionStartEvent(
sessionStartSource,
config.getModel() ?? '',
String(config.getApprovalMode()) as PermissionMode,
)
.then(() => { .then(() => {
debugLogger.debug('SessionStart event completed successfully'); debugLogger.debug('SessionStart event completed successfully');
}) })

View file

@ -59,6 +59,7 @@ describe('clearCommand', () => {
}), }),
getModel: () => 'test-model', getModel: () => 'test-model',
getToolRegistry: () => undefined, getToolRegistry: () => undefined,
getApprovalMode: () => 'default',
}, },
}, },
session: { session: {
@ -108,6 +109,7 @@ describe('clearCommand', () => {
expect(mockFireSessionStartEvent).toHaveBeenCalledWith( expect(mockFireSessionStartEvent).toHaveBeenCalledWith(
SessionStartSource.Clear, SessionStartSource.Clear,
'test-model', 'test-model',
expect.any(String), // permissionMode
); );
// SessionEnd should be called before SessionStart // SessionEnd should be called before SessionStart

View file

@ -13,6 +13,7 @@ import {
SessionStartSource, SessionStartSource,
ToolNames, ToolNames,
SkillTool, SkillTool,
type PermissionMode,
} from '@qwen-code/qwen-code-core'; } from '@qwen-code/qwen-code-core';
export const clearCommand: SlashCommand = { export const clearCommand: SlashCommand = {
@ -72,6 +73,7 @@ export const clearCommand: SlashCommand = {
?.fireSessionStartEvent( ?.fireSessionStartEvent(
SessionStartSource.Clear, SessionStartSource.Clear,
config.getModel() ?? '', config.getModel() ?? '',
String(config.getApprovalMode()) as PermissionMode,
); );
} catch (err) { } catch (err) {
config.getDebugLogger().warn(`SessionStart hook failed: ${err}`); config.getDebugLogger().warn(`SessionStart hook failed: ${err}`);

View file

@ -9,6 +9,7 @@ import {
SessionService, SessionService,
type Config, type Config,
SessionStartSource, SessionStartSource,
type PermissionMode,
} from '@qwen-code/qwen-code-core'; } from '@qwen-code/qwen-code-core';
import { buildResumedHistoryItems } from '../utils/resumeHistoryUtils.js'; import { buildResumedHistoryItems } from '../utils/resumeHistoryUtils.js';
import type { UseHistoryManagerReturn } from './useHistoryManager.js'; import type { UseHistoryManagerReturn } from './useHistoryManager.js';
@ -78,6 +79,7 @@ export function useResumeCommand(
?.fireSessionStartEvent( ?.fireSessionStartEvent(
SessionStartSource.Resume, SessionStartSource.Resume,
config.getModel() ?? '', config.getModel() ?? '',
String(config.getApprovalMode()) as PermissionMode,
); );
} catch (err) { } catch (err) {
config.getDebugLogger().warn(`SessionStart hook failed: ${err}`); config.getDebugLogger().warn(`SessionStart hook failed: ${err}`);

View file

@ -810,19 +810,33 @@ export class Config {
return; return;
} }
// Check if request was aborted
if (request.signal?.aborted) {
this.messageBus?.publish({
type: MessageBusType.HOOK_EXECUTION_RESPONSE,
correlationId: request.correlationId,
success: false,
error: new Error('Hook execution cancelled (aborted)'),
} as HookExecutionResponse);
return;
}
// Execute the appropriate hook based on eventName // Execute the appropriate hook based on eventName
let result; let result;
const input = request.input || {}; const input = request.input || {};
const signal = request.signal;
switch (request.eventName) { switch (request.eventName) {
case 'UserPromptSubmit': case 'UserPromptSubmit':
result = await hookSystem.fireUserPromptSubmitEvent( result = await hookSystem.fireUserPromptSubmitEvent(
(input['prompt'] as string) || '', (input['prompt'] as string) || '',
signal,
); );
break; break;
case 'Stop': case 'Stop':
result = await hookSystem.fireStopEvent( result = await hookSystem.fireStopEvent(
(input['stop_hook_active'] as boolean) || false, (input['stop_hook_active'] as boolean) || false,
(input['last_assistant_message'] as string) || '', (input['last_assistant_message'] as string) || '',
signal,
); );
break; break;
case 'PreToolUse': { case 'PreToolUse': {
@ -832,6 +846,7 @@ export class Config {
(input['tool_use_id'] as string) || '', (input['tool_use_id'] as string) || '',
(input['permission_mode'] as PermissionMode | undefined) ?? (input['permission_mode'] as PermissionMode | undefined) ??
PermissionMode.Default, PermissionMode.Default,
signal,
); );
break; break;
} }
@ -842,6 +857,7 @@ export class Config {
(input['tool_response'] as Record<string, unknown>) || {}, (input['tool_response'] as Record<string, unknown>) || {},
(input['tool_use_id'] as string) || '', (input['tool_use_id'] as string) || '',
(input['permission_mode'] as PermissionMode) || 'default', (input['permission_mode'] as PermissionMode) || 'default',
signal,
); );
break; break;
case 'PostToolUseFailure': case 'PostToolUseFailure':
@ -852,6 +868,7 @@ export class Config {
(input['error'] as string) || '', (input['error'] as string) || '',
input['is_interrupt'] as boolean | undefined, input['is_interrupt'] as boolean | undefined,
(input['permission_mode'] as PermissionMode) || 'default', (input['permission_mode'] as PermissionMode) || 'default',
signal,
); );
break; break;
case 'Notification': case 'Notification':
@ -860,6 +877,7 @@ export class Config {
(input['notification_type'] as NotificationType) || (input['notification_type'] as NotificationType) ||
'permission_prompt', 'permission_prompt',
(input['title'] as string) || undefined, (input['title'] as string) || undefined,
signal,
); );
break; break;
case 'PermissionRequest': case 'PermissionRequest':
@ -871,6 +889,7 @@ export class Config {
(input['permission_suggestions'] as (input['permission_suggestions'] as
| PermissionSuggestion[] | PermissionSuggestion[]
| undefined) || undefined, | undefined) || undefined,
signal,
); );
break; break;
case 'SubagentStart': case 'SubagentStart':
@ -879,6 +898,7 @@ export class Config {
(input['agent_type'] as string) || '', (input['agent_type'] as string) || '',
(input['permission_mode'] as PermissionMode) || (input['permission_mode'] as PermissionMode) ||
PermissionMode.Default, PermissionMode.Default,
signal,
); );
break; break;
case 'SubagentStop': case 'SubagentStop':
@ -890,6 +910,7 @@ export class Config {
(input['stop_hook_active'] as boolean) || false, (input['stop_hook_active'] as boolean) || false,
(input['permission_mode'] as PermissionMode) || (input['permission_mode'] as PermissionMode) ||
PermissionMode.Default, PermissionMode.Default,
signal,
); );
break; break;
default: default:

View file

@ -90,10 +90,17 @@ export class MessageBus extends EventEmitter {
request: Omit<TRequest, 'correlationId'>, request: Omit<TRequest, 'correlationId'>,
responseType: TResponse['type'], responseType: TResponse['type'],
timeoutMs: number = 60000, timeoutMs: number = 60000,
signal?: AbortSignal,
): Promise<TResponse> { ): Promise<TResponse> {
const correlationId = randomUUID(); const correlationId = randomUUID();
return new Promise<TResponse>((resolve, reject) => { return new Promise<TResponse>((resolve, reject) => {
// Check if already aborted
if (signal?.aborted) {
reject(new Error('Request aborted'));
return;
}
const timeoutId = setTimeout(() => { const timeoutId = setTimeout(() => {
cleanup(); cleanup();
reject(new Error(`Request timed out waiting for ${responseType}`)); reject(new Error(`Request timed out waiting for ${responseType}`));
@ -102,8 +109,20 @@ export class MessageBus extends EventEmitter {
const cleanup = () => { const cleanup = () => {
clearTimeout(timeoutId); clearTimeout(timeoutId);
this.unsubscribe(responseType, responseHandler); this.unsubscribe(responseType, responseHandler);
if (signal) {
signal.removeEventListener('abort', abortHandler);
}
}; };
const abortHandler = () => {
cleanup();
reject(new Error('Request aborted'));
};
if (signal) {
signal.addEventListener('abort', abortHandler);
}
const responseHandler = (response: TResponse) => { const responseHandler = (response: TResponse) => {
// Check if this response matches our request // Check if this response matches our request
if ( if (

View file

@ -109,6 +109,8 @@ export interface HookExecutionRequest {
eventName: string; eventName: string;
input: Record<string, unknown>; input: Record<string, unknown>;
correlationId: string; correlationId: string;
/** Optional AbortSignal to cancel hook execution */
signal?: AbortSignal;
} }
export interface HookExecutionResponse { export interface HookExecutionResponse {

View file

@ -535,7 +535,7 @@ export class GeminiClient {
return new Turn(this.getChat(), prompt_id); return new Turn(this.getChat(), prompt_id);
} }
const compressed = await this.tryCompressChat(prompt_id, false); const compressed = await this.tryCompressChat(prompt_id, false, signal);
if (compressed.compressionStatus === CompressionStatus.COMPRESSED) { if (compressed.compressionStatus === CompressionStatus.COMPRESSED) {
yield { type: GeminiEventType.ChatCompressed, value: compressed }; yield { type: GeminiEventType.ChatCompressed, value: compressed };
@ -677,7 +677,13 @@ export class GeminiClient {
} }
// Fire Stop hook through MessageBus (only if hooks are enabled) // Fire Stop hook through MessageBus (only if hooks are enabled)
// This must be done before any early returns to ensure hooks are always triggered // This must be done before any early returns to ensure hooks are always triggered
if (hooksEnabled && messageBus && !turn.pendingToolCalls.length) { if (
hooksEnabled &&
messageBus &&
!turn.pendingToolCalls.length &&
signal &&
!signal.aborted
) {
// Get response text from the chat history // Get response text from the chat history
const history = this.getHistory(); const history = this.getHistory();
const lastModelMessage = history const lastModelMessage = history
@ -700,9 +706,16 @@ export class GeminiClient {
stop_hook_active: true, stop_hook_active: true,
last_assistant_message: responseText, last_assistant_message: responseText,
}, },
signal,
}, },
MessageBusType.HOOK_EXECUTION_RESPONSE, MessageBusType.HOOK_EXECUTION_RESPONSE,
); );
// Check if aborted after hook execution
if (signal.aborted) {
return turn;
}
const hookOutput = response.output const hookOutput = response.output
? createHookOutput('Stop', response.output) ? createHookOutput('Stop', response.output)
: undefined; : undefined;
@ -714,6 +727,11 @@ export class GeminiClient {
stopOutput?.isBlockingDecision() || stopOutput?.isBlockingDecision() ||
stopOutput?.shouldStopExecution() stopOutput?.shouldStopExecution()
) { ) {
// Check if aborted before continuing
if (signal.aborted) {
return turn;
}
// Emit system message if provided (e.g., "🔄 Ralph iteration 5") // Emit system message if provided (e.g., "🔄 Ralph iteration 5")
if (stopOutput.systemMessage) { if (stopOutput.systemMessage) {
yield { yield {
@ -844,6 +862,7 @@ export class GeminiClient {
async tryCompressChat( async tryCompressChat(
prompt_id: string, prompt_id: string,
force: boolean = false, force: boolean = false,
signal?: AbortSignal,
): Promise<ChatCompressionInfo> { ): Promise<ChatCompressionInfo> {
const compressionService = new ChatCompressionService(); const compressionService = new ChatCompressionService();
@ -854,6 +873,7 @@ export class GeminiClient {
this.config.getModel(), this.config.getModel(),
this.config, this.config,
this.hasFailedCompressionAttempt, this.hasFailedCompressionAttempt,
signal,
); );
// Handle compression result // Handle compression result

View file

@ -81,6 +81,7 @@ export async function firePreToolUseHook(
toolInput: Record<string, unknown>, toolInput: Record<string, unknown>,
toolUseId: string, toolUseId: string,
permissionMode: string, permissionMode: string,
signal?: AbortSignal,
): Promise<PreToolUseHookResult> { ): Promise<PreToolUseHookResult> {
if (!messageBus) { if (!messageBus) {
return { shouldProceed: true }; return { shouldProceed: true };
@ -100,6 +101,7 @@ export async function firePreToolUseHook(
tool_input: toolInput, tool_input: toolInput,
tool_use_id: toolUseId, tool_use_id: toolUseId,
}, },
signal,
}, },
MessageBusType.HOOK_EXECUTION_RESPONSE, MessageBusType.HOOK_EXECUTION_RESPONSE,
); );
@ -178,6 +180,7 @@ export async function firePostToolUseHook(
toolResponse: Record<string, unknown>, toolResponse: Record<string, unknown>,
toolUseId: string, toolUseId: string,
permissionMode: string, permissionMode: string,
signal?: AbortSignal,
): Promise<PostToolUseHookResult> { ): Promise<PostToolUseHookResult> {
if (!messageBus) { if (!messageBus) {
return { shouldStop: false }; return { shouldStop: false };
@ -198,6 +201,7 @@ export async function firePostToolUseHook(
tool_response: toolResponse, tool_response: toolResponse,
tool_use_id: toolUseId, tool_use_id: toolUseId,
}, },
signal,
}, },
MessageBusType.HOOK_EXECUTION_RESPONSE, MessageBusType.HOOK_EXECUTION_RESPONSE,
); );
@ -255,6 +259,7 @@ export async function firePostToolUseFailureHook(
errorMessage: string, errorMessage: string,
isInterrupt?: boolean, isInterrupt?: boolean,
permissionMode?: string, permissionMode?: string,
signal?: AbortSignal,
): Promise<PostToolUseFailureHookResult> { ): Promise<PostToolUseFailureHookResult> {
if (!messageBus) { if (!messageBus) {
return {}; return {};
@ -276,6 +281,7 @@ export async function firePostToolUseFailureHook(
error: errorMessage, error: errorMessage,
is_interrupt: isInterrupt, is_interrupt: isInterrupt,
}, },
signal,
}, },
MessageBusType.HOOK_EXECUTION_RESPONSE, MessageBusType.HOOK_EXECUTION_RESPONSE,
); );
@ -319,6 +325,7 @@ export async function fireNotificationHook(
message: string, message: string,
notificationType: NotificationType, notificationType: NotificationType,
title?: string, title?: string,
signal?: AbortSignal,
): Promise<NotificationHookResult> { ): Promise<NotificationHookResult> {
if (!messageBus) { if (!messageBus) {
return {}; return {};
@ -337,6 +344,7 @@ export async function fireNotificationHook(
notification_type: notificationType, notification_type: notificationType,
title, title,
}, },
signal,
}, },
MessageBusType.HOOK_EXECUTION_RESPONSE, MessageBusType.HOOK_EXECUTION_RESPONSE,
); );
@ -390,6 +398,7 @@ export async function firePermissionRequestHook(
toolInput: Record<string, unknown>, toolInput: Record<string, unknown>,
permissionMode: string, permissionMode: string,
permissionSuggestions?: PermissionSuggestion[], permissionSuggestions?: PermissionSuggestion[],
signal?: AbortSignal,
): Promise<PermissionRequestHookResult> { ): Promise<PermissionRequestHookResult> {
if (!messageBus) { if (!messageBus) {
return { hasDecision: false }; return { hasDecision: false };
@ -409,6 +418,7 @@ export async function firePermissionRequestHook(
permission_mode: permissionMode, permission_mode: permissionMode,
permission_suggestions: permissionSuggestions, permission_suggestions: permissionSuggestions,
}, },
signal,
}, },
MessageBusType.HOOK_EXECUTION_RESPONSE, MessageBusType.HOOK_EXECUTION_RESPONSE,
); );

View file

@ -712,6 +712,7 @@ describe('HookEventHandler', () => {
expect.any(Object), // input object expect.any(Object), // input object
expect.any(Function), // onHookStart callback expect.any(Function), // onHookStart callback
expect.any(Function), // onHookEnd callback expect.any(Function), // onHookEnd callback
undefined, // signal
); );
}); });

View file

@ -64,13 +64,19 @@ export class HookEventHandler {
*/ */
async fireUserPromptSubmitEvent( async fireUserPromptSubmitEvent(
prompt: string, prompt: string,
signal?: AbortSignal,
): Promise<AggregatedHookResult> { ): Promise<AggregatedHookResult> {
const input: UserPromptSubmitInput = { const input: UserPromptSubmitInput = {
...this.createBaseInput(HookEventName.UserPromptSubmit), ...this.createBaseInput(HookEventName.UserPromptSubmit),
prompt, prompt,
}; };
return this.executeHooks(HookEventName.UserPromptSubmit, input); return this.executeHooks(
HookEventName.UserPromptSubmit,
input,
undefined,
signal,
);
} }
/** /**
@ -80,6 +86,7 @@ export class HookEventHandler {
async fireStopEvent( async fireStopEvent(
stopHookActive: boolean = false, stopHookActive: boolean = false,
lastAssistantMessage: string = '', lastAssistantMessage: string = '',
signal?: AbortSignal,
): Promise<AggregatedHookResult> { ): Promise<AggregatedHookResult> {
const input: StopInput = { const input: StopInput = {
...this.createBaseInput(HookEventName.Stop), ...this.createBaseInput(HookEventName.Stop),
@ -87,7 +94,7 @@ export class HookEventHandler {
last_assistant_message: lastAssistantMessage, last_assistant_message: lastAssistantMessage,
}; };
return this.executeHooks(HookEventName.Stop, input); return this.executeHooks(HookEventName.Stop, input, undefined, signal);
} }
/** /**
@ -99,6 +106,7 @@ export class HookEventHandler {
model: string, model: string,
permissionMode?: PermissionMode, permissionMode?: PermissionMode,
agentType?: AgentType, agentType?: AgentType,
signal?: AbortSignal,
): Promise<AggregatedHookResult> { ): Promise<AggregatedHookResult> {
const input: SessionStartInput = { const input: SessionStartInput = {
...this.createBaseInput(HookEventName.SessionStart), ...this.createBaseInput(HookEventName.SessionStart),
@ -109,9 +117,14 @@ export class HookEventHandler {
}; };
// Pass source as context for matcher filtering // Pass source as context for matcher filtering
return this.executeHooks(HookEventName.SessionStart, input, { return this.executeHooks(
trigger: source, HookEventName.SessionStart,
}); input,
{
trigger: source,
},
signal,
);
} }
/** /**
@ -120,6 +133,7 @@ export class HookEventHandler {
*/ */
async fireSessionEndEvent( async fireSessionEndEvent(
reason: SessionEndReason, reason: SessionEndReason,
signal?: AbortSignal,
): Promise<AggregatedHookResult> { ): Promise<AggregatedHookResult> {
const input: SessionEndInput = { const input: SessionEndInput = {
...this.createBaseInput(HookEventName.SessionEnd), ...this.createBaseInput(HookEventName.SessionEnd),
@ -127,9 +141,14 @@ export class HookEventHandler {
}; };
// Pass reason as context for matcher filtering // Pass reason as context for matcher filtering
return this.executeHooks(HookEventName.SessionEnd, input, { return this.executeHooks(
trigger: reason, HookEventName.SessionEnd,
}); input,
{
trigger: reason,
},
signal,
);
} }
/** /**
@ -141,6 +160,7 @@ export class HookEventHandler {
toolInput: Record<string, unknown>, toolInput: Record<string, unknown>,
toolUseId: string, toolUseId: string,
permissionMode: PermissionMode, permissionMode: PermissionMode,
signal?: AbortSignal,
): Promise<AggregatedHookResult> { ): Promise<AggregatedHookResult> {
const input: PreToolUseInput = { const input: PreToolUseInput = {
...this.createBaseInput(HookEventName.PreToolUse), ...this.createBaseInput(HookEventName.PreToolUse),
@ -151,9 +171,14 @@ export class HookEventHandler {
}; };
// Pass tool name as context for matcher filtering // Pass tool name as context for matcher filtering
return this.executeHooks(HookEventName.PreToolUse, input, { return this.executeHooks(
toolName, HookEventName.PreToolUse,
}); input,
{
toolName,
},
signal,
);
} }
/** /**
@ -166,6 +191,7 @@ export class HookEventHandler {
toolResponse: Record<string, unknown>, toolResponse: Record<string, unknown>,
toolUseId: string, toolUseId: string,
permissionMode: PermissionMode, permissionMode: PermissionMode,
signal?: AbortSignal,
): Promise<AggregatedHookResult> { ): Promise<AggregatedHookResult> {
const input: PostToolUseInput = { const input: PostToolUseInput = {
...this.createBaseInput(HookEventName.PostToolUse), ...this.createBaseInput(HookEventName.PostToolUse),
@ -177,9 +203,14 @@ export class HookEventHandler {
}; };
// Pass tool name as context for matcher filtering // Pass tool name as context for matcher filtering
return this.executeHooks(HookEventName.PostToolUse, input, { return this.executeHooks(
toolName, HookEventName.PostToolUse,
}); input,
{
toolName,
},
signal,
);
} }
/** /**
@ -193,6 +224,7 @@ export class HookEventHandler {
errorMessage: string, errorMessage: string,
isInterrupt?: boolean, isInterrupt?: boolean,
permissionMode?: PermissionMode, permissionMode?: PermissionMode,
signal?: AbortSignal,
): Promise<AggregatedHookResult> { ): Promise<AggregatedHookResult> {
const input: PostToolUseFailureInput = { const input: PostToolUseFailureInput = {
...this.createBaseInput(HookEventName.PostToolUseFailure), ...this.createBaseInput(HookEventName.PostToolUseFailure),
@ -205,9 +237,14 @@ export class HookEventHandler {
}; };
// Pass tool name as context for matcher filtering // Pass tool name as context for matcher filtering
return this.executeHooks(HookEventName.PostToolUseFailure, input, { return this.executeHooks(
toolName, HookEventName.PostToolUseFailure,
}); input,
{
toolName,
},
signal,
);
} }
/** /**
@ -217,6 +254,7 @@ export class HookEventHandler {
async firePreCompactEvent( async firePreCompactEvent(
trigger: PreCompactTrigger, trigger: PreCompactTrigger,
customInstructions: string = '', customInstructions: string = '',
signal?: AbortSignal,
): Promise<AggregatedHookResult> { ): Promise<AggregatedHookResult> {
const input: PreCompactInput = { const input: PreCompactInput = {
...this.createBaseInput(HookEventName.PreCompact), ...this.createBaseInput(HookEventName.PreCompact),
@ -225,9 +263,14 @@ export class HookEventHandler {
}; };
// Pass trigger as context for matcher filtering // Pass trigger as context for matcher filtering
return this.executeHooks(HookEventName.PreCompact, input, { return this.executeHooks(
trigger, HookEventName.PreCompact,
}); input,
{
trigger,
},
signal,
);
} }
/** /**
@ -237,6 +280,7 @@ export class HookEventHandler {
message: string, message: string,
notificationType: NotificationType, notificationType: NotificationType,
title?: string, title?: string,
signal?: AbortSignal,
): Promise<AggregatedHookResult> { ): Promise<AggregatedHookResult> {
const input: NotificationInput = { const input: NotificationInput = {
...this.createBaseInput(HookEventName.Notification), ...this.createBaseInput(HookEventName.Notification),
@ -246,9 +290,14 @@ export class HookEventHandler {
}; };
// Pass notification_type as context for matcher filtering // Pass notification_type as context for matcher filtering
return this.executeHooks(HookEventName.Notification, input, { return this.executeHooks(
notificationType, HookEventName.Notification,
}); input,
{
notificationType,
},
signal,
);
} }
/** /**
@ -260,6 +309,7 @@ export class HookEventHandler {
toolInput: Record<string, unknown>, toolInput: Record<string, unknown>,
permissionMode: PermissionMode, permissionMode: PermissionMode,
permissionSuggestions?: PermissionSuggestion[], permissionSuggestions?: PermissionSuggestion[],
signal?: AbortSignal,
): Promise<AggregatedHookResult> { ): Promise<AggregatedHookResult> {
const input: PermissionRequestInput = { const input: PermissionRequestInput = {
...this.createBaseInput(HookEventName.PermissionRequest), ...this.createBaseInput(HookEventName.PermissionRequest),
@ -270,9 +320,14 @@ export class HookEventHandler {
}; };
// Pass tool name as context for matcher filtering // Pass tool name as context for matcher filtering
return this.executeHooks(HookEventName.PermissionRequest, input, { return this.executeHooks(
toolName, HookEventName.PermissionRequest,
}); input,
{
toolName,
},
signal,
);
} }
/** /**
@ -283,6 +338,7 @@ export class HookEventHandler {
agentId: string, agentId: string,
agentType: AgentType | string, agentType: AgentType | string,
permissionMode: PermissionMode, permissionMode: PermissionMode,
signal?: AbortSignal,
): Promise<AggregatedHookResult> { ): Promise<AggregatedHookResult> {
const input: SubagentStartInput = { const input: SubagentStartInput = {
...this.createBaseInput(HookEventName.SubagentStart), ...this.createBaseInput(HookEventName.SubagentStart),
@ -292,9 +348,14 @@ export class HookEventHandler {
}; };
// Pass agentType as context for matcher filtering // Pass agentType as context for matcher filtering
return this.executeHooks(HookEventName.SubagentStart, input, { return this.executeHooks(
agentType: String(agentType), HookEventName.SubagentStart,
}); input,
{
agentType: String(agentType),
},
signal,
);
} }
/** /**
@ -308,6 +369,7 @@ export class HookEventHandler {
lastAssistantMessage: string, lastAssistantMessage: string,
stopHookActive: boolean, stopHookActive: boolean,
permissionMode: PermissionMode, permissionMode: PermissionMode,
signal?: AbortSignal,
): Promise<AggregatedHookResult> { ): Promise<AggregatedHookResult> {
const input: SubagentStopInput = { const input: SubagentStopInput = {
...this.createBaseInput(HookEventName.SubagentStop), ...this.createBaseInput(HookEventName.SubagentStop),
@ -320,9 +382,14 @@ export class HookEventHandler {
}; };
// Pass agentType as context for matcher filtering // Pass agentType as context for matcher filtering
return this.executeHooks(HookEventName.SubagentStop, input, { return this.executeHooks(
agentType: String(agentType), HookEventName.SubagentStop,
}); input,
{
agentType: String(agentType),
},
signal,
);
} }
/** /**
@ -333,6 +400,7 @@ export class HookEventHandler {
eventName: HookEventName, eventName: HookEventName,
input: HookInput, input: HookInput,
context?: HookEventContext, context?: HookEventContext,
signal?: AbortSignal,
): Promise<AggregatedHookResult> { ): Promise<AggregatedHookResult> {
try { try {
// Create execution plan // Create execution plan
@ -363,6 +431,7 @@ export class HookEventHandler {
input, input,
onHookStart, onHookStart,
onHookEnd, onHookEnd,
signal,
) )
: await this.hookRunner.executeHooksParallel( : await this.hookRunner.executeHooksParallel(
plan.hookConfigs, plan.hookConfigs,
@ -370,6 +439,7 @@ export class HookEventHandler {
input, input,
onHookStart, onHookStart,
onHookEnd, onHookEnd,
signal,
); );
// Aggregate results // Aggregate results

View file

@ -46,20 +46,38 @@ const EXIT_CODE_NON_BLOCKING_ERROR = 1;
export class HookRunner { export class HookRunner {
/** /**
* Execute a single hook * Execute a single hook
* @param hookConfig Hook configuration
* @param eventName Event name
* @param input Hook input
* @param signal Optional AbortSignal to cancel hook execution
*/ */
async executeHook( async executeHook(
hookConfig: HookConfig, hookConfig: HookConfig,
eventName: HookEventName, eventName: HookEventName,
input: HookInput, input: HookInput,
signal?: AbortSignal,
): Promise<HookExecutionResult> { ): Promise<HookExecutionResult> {
const startTime = Date.now(); const startTime = Date.now();
// Check if already aborted before starting
if (signal?.aborted) {
const hookId = hookConfig.name || hookConfig.command || 'unknown';
return {
hookConfig,
eventName,
success: false,
error: new Error(`Hook execution cancelled (aborted): ${hookId}`),
duration: 0,
};
}
try { try {
return await this.executeCommandHook( return await this.executeCommandHook(
hookConfig, hookConfig,
eventName, eventName,
input, input,
startTime, startTime,
signal,
); );
} catch (error) { } catch (error) {
const duration = Date.now() - startTime; const duration = Date.now() - startTime;
@ -79,6 +97,7 @@ export class HookRunner {
/** /**
* Execute multiple hooks in parallel * Execute multiple hooks in parallel
* @param signal Optional AbortSignal to cancel hook execution
*/ */
async executeHooksParallel( async executeHooksParallel(
hookConfigs: HookConfig[], hookConfigs: HookConfig[],
@ -86,10 +105,11 @@ export class HookRunner {
input: HookInput, input: HookInput,
onHookStart?: (config: HookConfig, index: number) => void, onHookStart?: (config: HookConfig, index: number) => void,
onHookEnd?: (config: HookConfig, result: HookExecutionResult) => void, onHookEnd?: (config: HookConfig, result: HookExecutionResult) => void,
signal?: AbortSignal,
): Promise<HookExecutionResult[]> { ): Promise<HookExecutionResult[]> {
const promises = hookConfigs.map(async (config, index) => { const promises = hookConfigs.map(async (config, index) => {
onHookStart?.(config, index); onHookStart?.(config, index);
const result = await this.executeHook(config, eventName, input); const result = await this.executeHook(config, eventName, input, signal);
onHookEnd?.(config, result); onHookEnd?.(config, result);
return result; return result;
}); });
@ -99,6 +119,7 @@ export class HookRunner {
/** /**
* Execute multiple hooks sequentially * Execute multiple hooks sequentially
* @param signal Optional AbortSignal to cancel hook execution
*/ */
async executeHooksSequential( async executeHooksSequential(
hookConfigs: HookConfig[], hookConfigs: HookConfig[],
@ -106,14 +127,24 @@ export class HookRunner {
input: HookInput, input: HookInput,
onHookStart?: (config: HookConfig, index: number) => void, onHookStart?: (config: HookConfig, index: number) => void,
onHookEnd?: (config: HookConfig, result: HookExecutionResult) => void, onHookEnd?: (config: HookConfig, result: HookExecutionResult) => void,
signal?: AbortSignal,
): Promise<HookExecutionResult[]> { ): Promise<HookExecutionResult[]> {
const results: HookExecutionResult[] = []; const results: HookExecutionResult[] = [];
let currentInput = input; let currentInput = input;
for (let i = 0; i < hookConfigs.length; i++) { for (let i = 0; i < hookConfigs.length; i++) {
// Check if aborted before each hook
if (signal?.aborted) {
break;
}
const config = hookConfigs[i]; const config = hookConfigs[i];
onHookStart?.(config, i); onHookStart?.(config, i);
const result = await this.executeHook(config, eventName, currentInput); const result = await this.executeHook(
config,
eventName,
currentInput,
signal,
);
onHookEnd?.(config, result); onHookEnd?.(config, result);
results.push(result); results.push(result);
@ -184,12 +215,18 @@ export class HookRunner {
/** /**
* Execute a command hook * Execute a command hook
* @param hookConfig Hook configuration
* @param eventName Event name
* @param input Hook input
* @param startTime Start time for duration calculation
* @param signal Optional AbortSignal to cancel hook execution
*/ */
private async executeCommandHook( private async executeCommandHook(
hookConfig: HookConfig, hookConfig: HookConfig,
eventName: HookEventName, eventName: HookEventName,
input: HookInput, input: HookInput,
startTime: number, startTime: number,
signal?: AbortSignal,
): Promise<HookExecutionResult> { ): Promise<HookExecutionResult> {
const timeout = hookConfig.timeout ?? DEFAULT_HOOK_TIMEOUT; const timeout = hookConfig.timeout ?? DEFAULT_HOOK_TIMEOUT;
@ -212,6 +249,7 @@ export class HookRunner {
let stdout = ''; let stdout = '';
let stderr = ''; let stderr = '';
let timedOut = false; let timedOut = false;
let aborted = false;
const shellConfig = getShellConfiguration(); const shellConfig = getShellConfiguration();
const command = this.expandCommand( const command = this.expandCommand(
@ -239,19 +277,36 @@ export class HookRunner {
}, },
); );
// Helper to kill child process
const killChild = () => {
if (!child.killed) {
child.kill('SIGTERM');
// Force kill after 2 seconds
setTimeout(() => {
if (!child.killed) {
child.kill('SIGKILL');
}
}, 2000);
}
};
// Set up timeout // Set up timeout
const timeoutHandle = setTimeout(() => { const timeoutHandle = setTimeout(() => {
timedOut = true; timedOut = true;
child.kill('SIGTERM'); killChild();
// Force kill after 5 seconds
setTimeout(() => {
if (!child.killed) {
child.kill('SIGKILL');
}
}, 5000);
}, timeout); }, timeout);
// Set up abort handler
const abortHandler = () => {
aborted = true;
clearTimeout(timeoutHandle);
killChild();
};
if (signal) {
signal.addEventListener('abort', abortHandler);
}
// Send input to stdin // Send input to stdin
if (child.stdin) { if (child.stdin) {
child.stdin.on('error', (err: NodeJS.ErrnoException) => { child.stdin.on('error', (err: NodeJS.ErrnoException) => {
@ -303,8 +358,25 @@ export class HookRunner {
// Handle process exit // Handle process exit
child.on('close', (exitCode) => { child.on('close', (exitCode) => {
clearTimeout(timeoutHandle); clearTimeout(timeoutHandle);
// Clean up abort listener
if (signal) {
signal.removeEventListener('abort', abortHandler);
}
const duration = Date.now() - startTime; const duration = Date.now() - startTime;
if (aborted) {
resolve({
hookConfig,
eventName,
success: false,
error: new Error('Hook execution cancelled (aborted)'),
stdout,
stderr,
duration,
});
return;
}
if (timedOut) { if (timedOut) {
resolve({ resolve({
hookConfig, hookConfig,

View file

@ -207,6 +207,7 @@ describe('HookSystem', () => {
expect(mockHookEventHandler.fireStopEvent).toHaveBeenCalledWith( expect(mockHookEventHandler.fireStopEvent).toHaveBeenCalledWith(
true, true,
'last message', 'last message',
undefined,
); );
expect(result).toBeDefined(); expect(result).toBeDefined();
}); });
@ -228,6 +229,7 @@ describe('HookSystem', () => {
expect(mockHookEventHandler.fireStopEvent).toHaveBeenCalledWith( expect(mockHookEventHandler.fireStopEvent).toHaveBeenCalledWith(
false, false,
'', '',
undefined,
); );
}); });
@ -269,7 +271,7 @@ describe('HookSystem', () => {
expect( expect(
mockHookEventHandler.fireUserPromptSubmitEvent, mockHookEventHandler.fireUserPromptSubmitEvent,
).toHaveBeenCalledWith('test prompt'); ).toHaveBeenCalledWith('test prompt', undefined);
expect(result).toBeDefined(); expect(result).toBeDefined();
}); });
@ -291,7 +293,7 @@ describe('HookSystem', () => {
expect( expect(
mockHookEventHandler.fireUserPromptSubmitEvent, mockHookEventHandler.fireUserPromptSubmitEvent,
).toHaveBeenCalledWith('my custom prompt'); ).toHaveBeenCalledWith('my custom prompt', undefined);
}); });
it('should return undefined when no final output', async () => { it('should return undefined when no final output', async () => {
@ -382,6 +384,7 @@ describe('HookSystem', () => {
'gpt-4', 'gpt-4',
undefined, undefined,
undefined, undefined,
undefined,
); );
expect(result).toBeDefined(); expect(result).toBeDefined();
}); });
@ -412,6 +415,7 @@ describe('HookSystem', () => {
'claude-3', 'claude-3',
PermissionMode.AutoEdit, PermissionMode.AutoEdit,
AgentType.Custom, AgentType.Custom,
undefined,
); );
}); });
@ -458,6 +462,7 @@ describe('HookSystem', () => {
expect(mockHookEventHandler.fireSessionEndEvent).toHaveBeenCalledWith( expect(mockHookEventHandler.fireSessionEndEvent).toHaveBeenCalledWith(
SessionEndReason.Other, SessionEndReason.Other,
undefined,
); );
expect(result).toBeDefined(); expect(result).toBeDefined();
}); });
@ -480,6 +485,7 @@ describe('HookSystem', () => {
expect(mockHookEventHandler.fireSessionEndEvent).toHaveBeenCalledWith( expect(mockHookEventHandler.fireSessionEndEvent).toHaveBeenCalledWith(
SessionEndReason.Other, SessionEndReason.Other,
undefined,
); );
}); });
@ -531,6 +537,7 @@ describe('HookSystem', () => {
{ command: 'ls' }, { command: 'ls' },
'toolu_test123', 'toolu_test123',
PermissionMode.AutoEdit, PermissionMode.AutoEdit,
undefined,
); );
expect(result).toBeDefined(); expect(result).toBeDefined();
}); });
@ -561,6 +568,7 @@ describe('HookSystem', () => {
{ path: '/test.txt', content: 'test' }, { path: '/test.txt', content: 'test' },
'toolu_test456', 'toolu_test456',
PermissionMode.Yolo, PermissionMode.Yolo,
undefined,
); );
}); });
@ -674,6 +682,7 @@ describe('HookSystem', () => {
{ output: 'file1.txt\nfile2.txt' }, { output: 'file1.txt\nfile2.txt' },
'toolu_test123', 'toolu_test123',
PermissionMode.AutoEdit, PermissionMode.AutoEdit,
undefined,
); );
expect(result).toBeDefined(); expect(result).toBeDefined();
}); });
@ -706,6 +715,7 @@ describe('HookSystem', () => {
{ content: 'file content' }, { content: 'file content' },
'toolu_test456', 'toolu_test456',
PermissionMode.Plan, PermissionMode.Plan,
undefined,
); );
}); });
@ -794,6 +804,7 @@ describe('HookSystem', () => {
'Command not found', 'Command not found',
false, false,
PermissionMode.AutoEdit, PermissionMode.AutoEdit,
undefined,
); );
expect(result).toBeDefined(); expect(result).toBeDefined();
}); });
@ -830,6 +841,7 @@ describe('HookSystem', () => {
'Permission denied', 'Permission denied',
true, true,
PermissionMode.Yolo, PermissionMode.Yolo,
undefined,
); );
}); });
@ -861,6 +873,7 @@ describe('HookSystem', () => {
'Error occurred', 'Error occurred',
undefined, undefined,
undefined, undefined,
undefined,
); );
}); });
@ -941,6 +954,7 @@ describe('HookSystem', () => {
expect(mockHookEventHandler.firePreCompactEvent).toHaveBeenCalledWith( expect(mockHookEventHandler.firePreCompactEvent).toHaveBeenCalledWith(
PreCompactTrigger.Auto, PreCompactTrigger.Auto,
'', '',
undefined,
); );
expect(result).toBeDefined(); expect(result).toBeDefined();
}); });
@ -964,6 +978,7 @@ describe('HookSystem', () => {
expect(mockHookEventHandler.firePreCompactEvent).toHaveBeenCalledWith( expect(mockHookEventHandler.firePreCompactEvent).toHaveBeenCalledWith(
PreCompactTrigger.Manual, PreCompactTrigger.Manual,
'', '',
undefined,
); );
}); });
@ -989,6 +1004,7 @@ describe('HookSystem', () => {
expect(mockHookEventHandler.firePreCompactEvent).toHaveBeenCalledWith( expect(mockHookEventHandler.firePreCompactEvent).toHaveBeenCalledWith(
PreCompactTrigger.Auto, PreCompactTrigger.Auto,
'Custom compression instructions', 'Custom compression instructions',
undefined,
); );
}); });
@ -1065,6 +1081,7 @@ describe('HookSystem', () => {
'Test notification message', 'Test notification message',
NotificationType.PermissionPrompt, NotificationType.PermissionPrompt,
'Permission needed', 'Permission needed',
undefined,
); );
expect(result).toBeDefined(); expect(result).toBeDefined();
}); });
@ -1093,6 +1110,7 @@ describe('HookSystem', () => {
'Qwen Code is waiting for your input', 'Qwen Code is waiting for your input',
NotificationType.IdlePrompt, NotificationType.IdlePrompt,
'Waiting for input', 'Waiting for input',
undefined,
); );
}); });
@ -1119,6 +1137,7 @@ describe('HookSystem', () => {
'Authentication successful', 'Authentication successful',
NotificationType.AuthSuccess, NotificationType.AuthSuccess,
undefined, undefined,
undefined,
); );
}); });
@ -1194,6 +1213,7 @@ describe('HookSystem', () => {
'Dialog shown to user', 'Dialog shown to user',
NotificationType.ElicitationDialog, NotificationType.ElicitationDialog,
'Dialog', 'Dialog',
undefined,
); );
}); });
}); });
@ -1226,6 +1246,7 @@ describe('HookSystem', () => {
{ command: 'ls -la' }, { command: 'ls -la' },
PermissionMode.Default, PermissionMode.Default,
undefined, undefined,
undefined,
); );
expect(result).toBeDefined(); expect(result).toBeDefined();
// Type assertion needed because getPermissionDecision is specific to PermissionRequestHookOutput // Type assertion needed because getPermissionDecision is specific to PermissionRequestHookOutput
@ -1259,6 +1280,7 @@ describe('HookSystem', () => {
{ command: 'npm test' }, { command: 'npm test' },
PermissionMode.Default, PermissionMode.Default,
suggestions, suggestions,
undefined,
); );
}); });
@ -1354,6 +1376,7 @@ describe('HookSystem', () => {
'agent-123', 'agent-123',
'code-reviewer', 'code-reviewer',
PermissionMode.Default, PermissionMode.Default,
undefined,
); );
expect(result).toBeDefined(); expect(result).toBeDefined();
}); });
@ -1382,6 +1405,7 @@ describe('HookSystem', () => {
'agent-456', 'agent-456',
AgentType.Bash, AgentType.Bash,
PermissionMode.Yolo, PermissionMode.Yolo,
undefined,
); );
}); });
@ -1468,6 +1492,7 @@ describe('HookSystem', () => {
'Final output from subagent', 'Final output from subagent',
false, false,
PermissionMode.Default, PermissionMode.Default,
undefined,
); );
expect(result).toBeDefined(); expect(result).toBeDefined();
}); });
@ -1502,6 +1527,7 @@ describe('HookSystem', () => {
'last message from agent', 'last message from agent',
true, true,
PermissionMode.Plan, PermissionMode.Plan,
undefined,
); );
}); });

View file

@ -89,9 +89,12 @@ export class HookSystem {
async fireUserPromptSubmitEvent( async fireUserPromptSubmitEvent(
prompt: string, prompt: string,
signal?: AbortSignal,
): Promise<DefaultHookOutput | undefined> { ): Promise<DefaultHookOutput | undefined> {
const result = const result = await this.hookEventHandler.fireUserPromptSubmitEvent(
await this.hookEventHandler.fireUserPromptSubmitEvent(prompt); prompt,
signal,
);
return result.finalOutput return result.finalOutput
? createHookOutput('UserPromptSubmit', result.finalOutput) ? createHookOutput('UserPromptSubmit', result.finalOutput)
: undefined; : undefined;
@ -100,10 +103,12 @@ export class HookSystem {
async fireStopEvent( async fireStopEvent(
stopHookActive: boolean = false, stopHookActive: boolean = false,
lastAssistantMessage: string = '', lastAssistantMessage: string = '',
signal?: AbortSignal,
): Promise<DefaultHookOutput | undefined> { ): Promise<DefaultHookOutput | undefined> {
const result = await this.hookEventHandler.fireStopEvent( const result = await this.hookEventHandler.fireStopEvent(
stopHookActive, stopHookActive,
lastAssistantMessage, lastAssistantMessage,
signal,
); );
return result.finalOutput return result.finalOutput
? createHookOutput('Stop', result.finalOutput) ? createHookOutput('Stop', result.finalOutput)
@ -115,12 +120,14 @@ export class HookSystem {
model: string, model: string,
permissionMode?: PermissionMode, permissionMode?: PermissionMode,
agentType?: AgentType, agentType?: AgentType,
signal?: AbortSignal,
): Promise<DefaultHookOutput | undefined> { ): Promise<DefaultHookOutput | undefined> {
const result = await this.hookEventHandler.fireSessionStartEvent( const result = await this.hookEventHandler.fireSessionStartEvent(
source, source,
model, model,
permissionMode, permissionMode,
agentType, agentType,
signal,
); );
return result.finalOutput return result.finalOutput
? createHookOutput('SessionStart', result.finalOutput) ? createHookOutput('SessionStart', result.finalOutput)
@ -129,8 +136,12 @@ export class HookSystem {
async fireSessionEndEvent( async fireSessionEndEvent(
reason: SessionEndReason, reason: SessionEndReason,
signal?: AbortSignal,
): Promise<DefaultHookOutput | undefined> { ): Promise<DefaultHookOutput | undefined> {
const result = await this.hookEventHandler.fireSessionEndEvent(reason); const result = await this.hookEventHandler.fireSessionEndEvent(
reason,
signal,
);
return result.finalOutput return result.finalOutput
? createHookOutput('SessionEnd', result.finalOutput) ? createHookOutput('SessionEnd', result.finalOutput)
: undefined; : undefined;
@ -144,12 +155,14 @@ export class HookSystem {
toolInput: Record<string, unknown>, toolInput: Record<string, unknown>,
toolUseId: string, toolUseId: string,
permissionMode: PermissionMode, permissionMode: PermissionMode,
signal?: AbortSignal,
): Promise<DefaultHookOutput | undefined> { ): Promise<DefaultHookOutput | undefined> {
const result = await this.hookEventHandler.firePreToolUseEvent( const result = await this.hookEventHandler.firePreToolUseEvent(
toolName, toolName,
toolInput, toolInput,
toolUseId, toolUseId,
permissionMode, permissionMode,
signal,
); );
return result.finalOutput return result.finalOutput
? createHookOutput('PreToolUse', result.finalOutput) ? createHookOutput('PreToolUse', result.finalOutput)
@ -165,6 +178,7 @@ export class HookSystem {
toolResponse: Record<string, unknown>, toolResponse: Record<string, unknown>,
toolUseId: string, toolUseId: string,
permissionMode: PermissionMode, permissionMode: PermissionMode,
signal?: AbortSignal,
): Promise<DefaultHookOutput | undefined> { ): Promise<DefaultHookOutput | undefined> {
const result = await this.hookEventHandler.firePostToolUseEvent( const result = await this.hookEventHandler.firePostToolUseEvent(
toolName, toolName,
@ -172,6 +186,7 @@ export class HookSystem {
toolResponse, toolResponse,
toolUseId, toolUseId,
permissionMode, permissionMode,
signal,
); );
return result.finalOutput return result.finalOutput
? createHookOutput('PostToolUse', result.finalOutput) ? createHookOutput('PostToolUse', result.finalOutput)
@ -188,6 +203,7 @@ export class HookSystem {
errorMessage: string, errorMessage: string,
isInterrupt?: boolean, isInterrupt?: boolean,
permissionMode?: PermissionMode, permissionMode?: PermissionMode,
signal?: AbortSignal,
): Promise<DefaultHookOutput | undefined> { ): Promise<DefaultHookOutput | undefined> {
const result = await this.hookEventHandler.firePostToolUseFailureEvent( const result = await this.hookEventHandler.firePostToolUseFailureEvent(
toolUseId, toolUseId,
@ -196,6 +212,7 @@ export class HookSystem {
errorMessage, errorMessage,
isInterrupt, isInterrupt,
permissionMode, permissionMode,
signal,
); );
return result.finalOutput return result.finalOutput
? createHookOutput('PostToolUseFailure', result.finalOutput) ? createHookOutput('PostToolUseFailure', result.finalOutput)
@ -208,10 +225,12 @@ export class HookSystem {
async firePreCompactEvent( async firePreCompactEvent(
trigger: PreCompactTrigger, trigger: PreCompactTrigger,
customInstructions: string = '', customInstructions: string = '',
signal?: AbortSignal,
): Promise<DefaultHookOutput | undefined> { ): Promise<DefaultHookOutput | undefined> {
const result = await this.hookEventHandler.firePreCompactEvent( const result = await this.hookEventHandler.firePreCompactEvent(
trigger, trigger,
customInstructions, customInstructions,
signal,
); );
return result.finalOutput return result.finalOutput
? createHookOutput('PreCompact', result.finalOutput) ? createHookOutput('PreCompact', result.finalOutput)
@ -225,11 +244,13 @@ export class HookSystem {
message: string, message: string,
notificationType: NotificationType, notificationType: NotificationType,
title?: string, title?: string,
signal?: AbortSignal,
): Promise<DefaultHookOutput | undefined> { ): Promise<DefaultHookOutput | undefined> {
const result = await this.hookEventHandler.fireNotificationEvent( const result = await this.hookEventHandler.fireNotificationEvent(
message, message,
notificationType, notificationType,
title, title,
signal,
); );
return result.finalOutput return result.finalOutput
? createHookOutput('Notification', result.finalOutput) ? createHookOutput('Notification', result.finalOutput)
@ -243,11 +264,13 @@ export class HookSystem {
agentId: string, agentId: string,
agentType: AgentType | string, agentType: AgentType | string,
permissionMode: PermissionMode, permissionMode: PermissionMode,
signal?: AbortSignal,
): Promise<DefaultHookOutput | undefined> { ): Promise<DefaultHookOutput | undefined> {
const result = await this.hookEventHandler.fireSubagentStartEvent( const result = await this.hookEventHandler.fireSubagentStartEvent(
agentId, agentId,
agentType, agentType,
permissionMode, permissionMode,
signal,
); );
return result.finalOutput return result.finalOutput
? createHookOutput('SubagentStart', result.finalOutput) ? createHookOutput('SubagentStart', result.finalOutput)
@ -264,6 +287,7 @@ export class HookSystem {
lastAssistantMessage: string, lastAssistantMessage: string,
stopHookActive: boolean, stopHookActive: boolean,
permissionMode: PermissionMode, permissionMode: PermissionMode,
signal?: AbortSignal,
): Promise<DefaultHookOutput | undefined> { ): Promise<DefaultHookOutput | undefined> {
const result = await this.hookEventHandler.fireSubagentStopEvent( const result = await this.hookEventHandler.fireSubagentStopEvent(
agentId, agentId,
@ -272,6 +296,7 @@ export class HookSystem {
lastAssistantMessage, lastAssistantMessage,
stopHookActive, stopHookActive,
permissionMode, permissionMode,
signal,
); );
return result.finalOutput return result.finalOutput
? createHookOutput('SubagentStop', result.finalOutput) ? createHookOutput('SubagentStop', result.finalOutput)
@ -286,12 +311,14 @@ export class HookSystem {
toolInput: Record<string, unknown>, toolInput: Record<string, unknown>,
permissionMode: PermissionMode, permissionMode: PermissionMode,
permissionSuggestions?: PermissionSuggestion[], permissionSuggestions?: PermissionSuggestion[],
signal?: AbortSignal,
): Promise<DefaultHookOutput | undefined> { ): Promise<DefaultHookOutput | undefined> {
const result = await this.hookEventHandler.firePermissionRequestEvent( const result = await this.hookEventHandler.firePermissionRequestEvent(
toolName, toolName,
toolInput, toolInput,
permissionMode, permissionMode,
permissionSuggestions, permissionSuggestions,
signal,
); );
return result.finalOutput return result.finalOutput
? createHookOutput('PermissionRequest', result.finalOutput) ? createHookOutput('PermissionRequest', result.finalOutput)

View file

@ -14,6 +14,7 @@ import { getCompressionPrompt } from '../core/prompts.js';
import { getResponseText } from '../utils/partUtils.js'; import { getResponseText } from '../utils/partUtils.js';
import { logChatCompression } from '../telemetry/loggers.js'; import { logChatCompression } from '../telemetry/loggers.js';
import { makeChatCompressionEvent } from '../telemetry/types.js'; import { makeChatCompressionEvent } from '../telemetry/types.js';
import type { PermissionMode } from '../hooks/types.js';
import { SessionStartSource, PreCompactTrigger } from '../hooks/types.js'; import { SessionStartSource, PreCompactTrigger } from '../hooks/types.js';
/** /**
@ -84,6 +85,7 @@ export class ChatCompressionService {
model: string, model: string,
config: Config, config: Config,
hasFailedCompressionAttempt: boolean, hasFailedCompressionAttempt: boolean,
signal?: AbortSignal,
): Promise<{ newHistory: Content[] | null; info: ChatCompressionInfo }> { ): Promise<{ newHistory: Content[] | null; info: ChatCompressionInfo }> {
const curatedHistory = chat.getHistory(true); const curatedHistory = chat.getHistory(true);
const threshold = const threshold =
@ -130,7 +132,7 @@ export class ChatCompressionService {
if (hookSystem) { if (hookSystem) {
const trigger = force ? PreCompactTrigger.Manual : PreCompactTrigger.Auto; const trigger = force ? PreCompactTrigger.Manual : PreCompactTrigger.Auto;
try { try {
await hookSystem.firePreCompactEvent(trigger, ''); await hookSystem.firePreCompactEvent(trigger, '', signal);
} catch (err) { } catch (err) {
config.getDebugLogger().warn(`PreCompact hook failed: ${err}`); config.getDebugLogger().warn(`PreCompact hook failed: ${err}`);
} }
@ -276,9 +278,18 @@ export class ChatCompressionService {
// Fire SessionStart event after successful compression // Fire SessionStart event after successful compression
try { try {
const permissionMode = String(
config.getApprovalMode(),
) as PermissionMode;
await config await config
.getHookSystem() .getHookSystem()
?.fireSessionStartEvent(SessionStartSource.Compact, model ?? ''); ?.fireSessionStartEvent(
SessionStartSource.Compact,
model ?? '',
permissionMode,
undefined,
signal,
);
} catch (err) { } catch (err) {
config.getDebugLogger().warn(`SessionStart hook failed: ${err}`); config.getDebugLogger().warn(`SessionStart hook failed: ${err}`);
} }

View file

@ -525,6 +525,7 @@ class AgentToolInvocation extends BaseToolInvocation<AgentParams, ToolResult> {
agentId, agentId,
agentType, agentType,
PermissionMode.Default, PermissionMode.Default,
signal,
); );
// Inject additional context from hook output into subagent context // Inject additional context from hook output into subagent context
@ -572,6 +573,7 @@ class AgentToolInvocation extends BaseToolInvocation<AgentParams, ToolResult> {
subagent.getFinalText(), subagent.getFinalText(),
stopHookActive, stopHookActive,
PermissionMode.Default, PermissionMode.Default,
signal,
); );
const typedStopOutput = stopHookOutput as const typedStopOutput = stopHookOutput as