From 06754ac677644715141804b67bbc73bcc7959937 Mon Sep 17 00:00:00 2001 From: User Date: Thu, 14 May 2026 14:04:54 -0700 Subject: [PATCH 1/8] feat: add SDK hook callbacks Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- src/client.ts | 30 +++++ src/helpers.ts | 5 + src/hooks.ts | 277 +++++++++++++++++++++++++++++++++++++++++ src/index.ts | 20 +++ src/protocol.ts | 53 +++++++- src/schemas/client.ts | 3 + src/schemas/enums.ts | 1 + src/schemas/hooks.ts | 80 ++++++++++++ src/schemas/index.ts | 22 ++++ src/schemas/server.ts | 26 +++- src/session.ts | 17 +++ tests/helpers.test.ts | 5 + tests/hooks.test.ts | 166 ++++++++++++++++++++++++ tests/protocol.test.ts | 105 ++++++++++++++++ 14 files changed, 807 insertions(+), 3 deletions(-) create mode 100644 src/hooks.ts create mode 100644 src/schemas/hooks.ts create mode 100644 tests/hooks.test.ts diff --git a/src/client.ts b/src/client.ts index 74ef0db..c631d76 100644 --- a/src/client.ts +++ b/src/client.ts @@ -4,6 +4,7 @@ import { ConnectionError, SessionError } from './errors.js'; import { ProtocolEngine, type AskUserHandler, + type HookRequestHandler, type NotificationCallback, type NotificationFilter, type PermissionHandler, @@ -90,6 +91,10 @@ import { SESSION_INIT_TIMEOUT, } from './schemas/constants.js'; import { DroidServerMethod, ToolConfirmationOutcome } from './schemas/enums.js'; +import type { + ExecuteHooksRequestParams, + ExecuteHooksResult, +} from './schemas/hooks.js'; import { SessionNotificationParamsSchema } from './schemas/server.js'; import type { AskUserRequestParams, @@ -103,6 +108,8 @@ export type ClientPermissionHandler = PermissionHandler; export type ClientAskUserHandler = AskUserHandler; +export type ClientHookRequestHandler = HookRequestHandler; + interface ClientNotificationListener { readonly callback: NotificationCallback; readonly filter?: NotificationFilter; @@ -133,6 +140,9 @@ export class DroidClient { /** Client-level ask-user handler. */ private _askUserHandler: ClientAskUserHandler | null = null; + /** Client-level hook handler. */ + private _hookHandler: ClientHookRequestHandler | null = null; + constructor(options: DroidClientOptions) { this._engine = new ProtocolEngine({ transport: options.transport, @@ -149,6 +159,7 @@ export class DroidClient { this._engine.setAskUserHandler((params) => this._dispatchAskUserRequest(params) ); + this._engine.setHookHandler((params) => this._dispatchHookRequest(params)); } private async _rpc( @@ -481,6 +492,14 @@ export class DroidClient { this._askUserHandler = null; } + setHookHandler(handler: ClientHookRequestHandler): void { + this._hookHandler = handler; + } + + clearHookHandler(): void { + this._hookHandler = null; + } + async close(): Promise { if (this._closed) { return; @@ -490,6 +509,7 @@ export class DroidClient { this._notificationListeners.length = 0; this._permissionHandler = null; this._askUserHandler = null; + this._hookHandler = null; await this._engine.close(); } @@ -540,6 +560,16 @@ export class DroidClient { return handler(params); } + private _dispatchHookRequest( + params: ExecuteHooksRequestParams + ): ExecuteHooksResult | Promise { + const handler = this._hookHandler; + if (handler == null) { + return { results: [] }; + } + return handler(params); + } + private _ensureNotClosed(): void { if (this._closed) { throw new ConnectionError( diff --git a/src/helpers.ts b/src/helpers.ts index 9642de3..e53f3e9 100644 --- a/src/helpers.ts +++ b/src/helpers.ts @@ -16,6 +16,7 @@ import type { DroidInteractionMode, ReasoningEffort, } from './schemas/enums.js'; +import type { SdkHookRegistration } from './schemas/hooks.js'; import { SessionNotificationSchema, type SessionNotificationPayload, @@ -255,6 +256,7 @@ export interface SessionInitOptions extends ToolSelectionOverrides { type ResolvedSessionInitOptions = Omit & { mcpServers?: McpServerConfig[]; + sdkHooks?: SdkHookRegistration[]; }; export function buildInitParams( @@ -282,6 +284,9 @@ export function buildInitParams( ...(options.mcpServers !== undefined && { mcpServers: options.mcpServers, }), + ...(options.sdkHooks !== undefined && { + sdkHooks: options.sdkHooks, + }), ...(options.enabledToolIds !== undefined && { enabledToolIds: options.enabledToolIds, }), diff --git a/src/hooks.ts b/src/hooks.ts new file mode 100644 index 0000000..1d09258 --- /dev/null +++ b/src/hooks.ts @@ -0,0 +1,277 @@ +import type { + DroidHookEvent, + DroidHookExecutionResult, + DroidHookOutput, + ExecuteHooksRequestParams, + ExecuteHooksResult, + SdkHookRegistration, +} from './schemas/hooks.js'; + +export type { DroidHookEvent, DroidHookOutput } from './schemas/hooks.js'; + +export type DroidPermissionMode = + | 'off' + | 'spec' + | 'auto-low' + | 'auto-medium' + | 'auto-high'; + +export interface BaseDroidHookInput { + session_id: string; + transcript_path: string; + cwd: string; + permission_mode: DroidPermissionMode; + hook_event_name: DroidHookEvent; + message_id?: string; + [key: string]: unknown; +} + +export interface ToolHookInput extends BaseDroidHookInput { + hook_event_name: 'PreToolUse' | 'PostToolUse'; + tool_name: string; + tool_input: Record; + tool_response?: unknown; +} + +export interface UserPromptSubmitHookInput extends BaseDroidHookInput { + hook_event_name: 'UserPromptSubmit'; + prompt: string; + has_images?: boolean; +} + +export interface NotificationHookInput extends BaseDroidHookInput { + hook_event_name: 'Notification'; + message: string; + notification_type: + | 'permission_prompt' + | 'idle_prompt' + | 'auth_success' + | 'elicitation_dialog'; +} + +export interface StopHookInput extends BaseDroidHookInput { + hook_event_name: 'Stop'; + stop_hook_active: boolean; + tool_execution_count?: number; + elapsed_time?: number; +} + +export interface SubagentStopHookInput extends BaseDroidHookInput { + hook_event_name: 'SubagentStop'; + task_name: string; + task_result?: string; + task_error?: string; + stop_hook_active: boolean; +} + +export interface PreCompactHookInput extends BaseDroidHookInput { + hook_event_name: 'PreCompact'; + trigger: 'manual' | 'auto'; + custom_instructions?: string; + message_count: number; + estimated_tokens: number; +} + +export interface SessionStartHookInput extends BaseDroidHookInput { + hook_event_name: 'SessionStart'; + source: 'startup' | 'resume' | 'clear' | 'compact'; + previous_session_id?: string; + calling_session_id?: string; +} + +export interface SessionEndHookInput extends BaseDroidHookInput { + hook_event_name: 'SessionEnd'; + reason: 'clear' | 'logout' | 'prompt_input_exit' | 'other'; + session_duration_ms: number; + message_count: number; +} + +export type DroidHookInput = + | ToolHookInput + | UserPromptSubmitHookInput + | NotificationHookInput + | StopHookInput + | SubagentStopHookInput + | PreCompactHookInput + | SessionStartHookInput + | SessionEndHookInput + | BaseDroidHookInput; + +export type DroidHookCallback = ( + input: DroidHookInput, + toolUseId: string | undefined, + context: { signal: AbortSignal } +) => DroidHookOutput | Promise; + +export interface DroidHookMatcher { + matcher?: string; + timeout?: number; + hooks: DroidHookCallback[]; +} + +export type DroidHooks = Partial>; + +export type HookRequestHandler = ( + params: ExecuteHooksRequestParams +) => Promise | ExecuteHooksResult; + +const DEFAULT_HOOK_TIMEOUT_SECONDS = 60; +const DROID_HOOK_EVENTS: DroidHookEvent[] = [ + 'PreToolUse', + 'PostToolUse', + 'Notification', + 'UserPromptSubmit', + 'Stop', + 'SubagentStop', + 'PreCompact', + 'SessionStart', + 'SessionEnd', +]; + +export function buildSdkHookRegistrations( + hooks: DroidHooks | undefined +): SdkHookRegistration[] | undefined { + if (!hooks) return undefined; + + const registrations: SdkHookRegistration[] = []; + for (const eventName of DROID_HOOK_EVENTS) { + const matchers = hooks[eventName]; + for (const matcher of matchers ?? []) { + if (matcher.hooks.length === 0) continue; + registrations.push({ + eventName, + ...(matcher.matcher !== undefined && { matcher: matcher.matcher }), + ...(matcher.timeout !== undefined && { timeout: matcher.timeout }), + }); + } + } + + return registrations.length > 0 ? registrations : undefined; +} + +export function createHookRequestHandler( + hooks: DroidHooks +): HookRequestHandler { + return async ({ eventName, input, matcher, toolUseId }) => { + const groups = hooks[eventName] ?? []; + const matchingGroups = groups.filter((group) => + matchesHookMatcher(group.matcher, matcher) + ); + + const results = await Promise.all( + matchingGroups.flatMap((group) => + group.hooks.map((hook) => + executeCallbackWithTimeout( + hook, + toDroidHookInput(input, eventName), + toolUseId, + group.timeout + ) + ) + ) + ); + + return { results }; + }; +} + +export function matchesHookMatcher( + configuredMatcher: string | undefined, + actualMatcher: string | undefined +): boolean { + if ( + configuredMatcher === undefined || + configuredMatcher === '' || + configuredMatcher === '*' || + actualMatcher === undefined + ) { + return true; + } + + if (configuredMatcher === actualMatcher) { + return true; + } + + try { + return new RegExp(configuredMatcher).test(actualMatcher); + } catch { + return false; + } +} + +function toDroidHookInput( + input: Record, + eventName: DroidHookEvent +): DroidHookInput { + return { + ...input, + session_id: + typeof input.session_id === 'string' ? input.session_id : 'unknown', + transcript_path: + typeof input.transcript_path === 'string' ? input.transcript_path : '', + cwd: typeof input.cwd === 'string' ? input.cwd : '', + permission_mode: toPermissionMode(input.permission_mode), + hook_event_name: eventName, + ...(typeof input.message_id === 'string' && { + message_id: input.message_id, + }), + }; +} + +function toPermissionMode(value: unknown): DroidPermissionMode { + switch (value) { + case 'spec': + case 'auto-low': + case 'auto-medium': + case 'auto-high': + return value; + default: + return 'off'; + } +} + +async function executeCallbackWithTimeout( + hook: DroidHookCallback, + input: DroidHookInput, + toolUseId: string | undefined, + timeoutSeconds = DEFAULT_HOOK_TIMEOUT_SECONDS +): Promise { + const controller = new AbortController(); + let timeout: ReturnType | undefined; + + try { + const timeoutPromise = new Promise((resolve) => { + timeout = setTimeout(() => { + controller.abort(); + resolve({ + exitCode: 1, + stdout: '', + stderr: `Hook callback timed out after ${timeoutSeconds} seconds`, + }); + }, timeoutSeconds * 1000); + }); + + const callbackPromise = (async () => + hook(input, toolUseId, { + signal: controller.signal, + }))().then( + (output): DroidHookExecutionResult => ({ + exitCode: 0, + stdout: '', + stderr: '', + ...(output ?? {}), + }), + (error): DroidHookExecutionResult => ({ + exitCode: 1, + stdout: '', + stderr: error instanceof Error ? error.message : String(error), + }) + ); + + return await Promise.race([callbackPromise, timeoutPromise]); + } finally { + if (timeout) { + clearTimeout(timeout); + } + } +} diff --git a/src/index.ts b/src/index.ts index efe11ee..9d75239 100644 --- a/src/index.ts +++ b/src/index.ts @@ -18,10 +18,30 @@ export type { export { DroidClient } from './client.js'; export type { ClientAskUserHandler, + ClientHookRequestHandler, ClientPermissionHandler, DroidClientOptions, } from './client.js'; +export type { + BaseDroidHookInput, + DroidHookCallback, + DroidHookEvent, + DroidHookInput, + DroidHookMatcher, + DroidHookOutput, + DroidHooks, + DroidPermissionMode, + NotificationHookInput, + PreCompactHookInput, + SessionEndHookInput, + SessionStartHookInput, + StopHookInput, + SubagentStopHookInput, + ToolHookInput, + UserPromptSubmitHookInput, +} from './hooks.js'; + export { convertNotificationToStreamMessage, DroidMessageType, diff --git a/src/protocol.ts b/src/protocol.ts index 6dc35f3..c0d1226 100644 --- a/src/protocol.ts +++ b/src/protocol.ts @@ -18,6 +18,12 @@ import { JsonRpcErrorCode, ToolConfirmationOutcome, } from './schemas/enums.js'; +import { + ExecuteHooksRequestParamsSchema, + ExecuteHooksResultSchema, + type ExecuteHooksRequestParams, + type ExecuteHooksResult, +} from './schemas/hooks.js'; import { AskUserRequestParamsSchema, AskUserResultSchema, @@ -43,6 +49,10 @@ export type AskUserHandler = ( params: AskUserRequestParams ) => AskUserResult | Promise; +export type HookRequestHandler = ( + params: ExecuteHooksRequestParams +) => ExecuteHooksResult | Promise; + export type NotificationCallback = ( notification: Record ) => void; @@ -73,6 +83,7 @@ export class ProtocolEngine { private readonly _notificationListeners = new Set(); private _permissionHandler: PermissionHandler | null = null; private _askUserHandler: AskUserHandler | null = null; + private _hookHandler: HookRequestHandler | null = null; private _transportError: Error | null = null; private _closed = false; @@ -196,6 +207,14 @@ export class ProtocolEngine { this._askUserHandler = null; } + setHookHandler(handler: HookRequestHandler): void { + this._hookHandler = handler; + } + + clearHookHandler(): void { + this._hookHandler = null; + } + get isHealthy(): boolean { return !this._closed && this._transportError === null; } @@ -213,6 +232,7 @@ export class ProtocolEngine { this._permissionHandler = null; this._askUserHandler = null; + this._hookHandler = null; this._notificationListeners.clear(); await this._transport.close(); @@ -310,6 +330,8 @@ export class ProtocolEngine { await this._handlePermissionRequest(requestId, params); } else if (method === DroidClientMethod.ASK_USER) { await this._handleAskUserRequest(requestId, params); + } else if (method === DroidClientMethod.EXECUTE_HOOKS) { + await this._handleHookRequest(requestId, params); } } @@ -382,6 +404,35 @@ export class ProtocolEngine { } } + private async _handleHookRequest( + requestId: string, + params: unknown + ): Promise { + const handler = this._hookHandler; + + if (handler == null) { + this._sendResponse( + requestId, + ExecuteHooksResultSchema.parse({ results: [] }) + ); + return; + } + + try { + const parsedParams = ExecuteHooksRequestParamsSchema.parse(params); + const result = await Promise.resolve(handler(parsedParams)); + this._sendResponse(requestId, ExecuteHooksResultSchema.parse(result)); + } catch (exc) { + const errorMessage = exc instanceof Error ? exc.message : String(exc); + this._sendErrorResponse( + requestId, + JsonRpcErrorCode.INTERNAL_ERROR, + 'Failed to handle hook request', + errorMessage + ); + } + } + /** * Handle a transport error. * Sets the sticky transport error and rejects all pending requests. @@ -398,7 +449,7 @@ export class ProtocolEngine { private _sendResponse( requestId: string, - result: RequestPermissionResult | AskUserResult + result: RequestPermissionResult | AskUserResult | ExecuteHooksResult ): void { const response: Record = { jsonrpc: JSONRPC_VERSION, diff --git a/src/schemas/client.ts b/src/schemas/client.ts index 5e8d3f2..a1bcd7d 100644 --- a/src/schemas/client.ts +++ b/src/schemas/client.ts @@ -14,6 +14,7 @@ import { SettingsLevel, SkillLocation, } from './enums.js'; +import { SdkHookRegistrationSchema } from './hooks.js'; import { McpRegistryServerSchema, McpServerStatusInfoSchema, @@ -251,6 +252,7 @@ export const InitializeSessionRequestParamsSchema = z sessionSource: SessionSourceSchema.optional(), tags: z.array(SessionTagSchema).optional(), mcpOAuthCallbackUri: z.string().optional(), + sdkHooks: z.array(SdkHookRegistrationSchema).optional(), }) .strict(); @@ -264,6 +266,7 @@ export const LoadSessionRequestParamsSchema = z sessionId: z.string(), mcpServers: z.array(McpServerConfigSchema).optional(), mcpOAuthCallbackUri: z.string().optional(), + sdkHooks: z.array(SdkHookRegistrationSchema).optional(), }) .strict(); diff --git a/src/schemas/enums.ts b/src/schemas/enums.ts index ca595a0..d6246b9 100644 --- a/src/schemas/enums.ts +++ b/src/schemas/enums.ts @@ -42,6 +42,7 @@ export enum DroidClientMethod { SESSION_NOTIFICATION = 'droid.session_notification', REQUEST_PERMISSION = 'droid.request_permission', ASK_USER = 'droid.ask_user', + EXECUTE_HOOKS = 'droid.execute_hooks', } /** Session notification types. */ diff --git a/src/schemas/hooks.ts b/src/schemas/hooks.ts new file mode 100644 index 0000000..1557719 --- /dev/null +++ b/src/schemas/hooks.ts @@ -0,0 +1,80 @@ +import { z } from 'zod'; + +export const DroidHookEventSchema = z.enum([ + 'PreToolUse', + 'PostToolUse', + 'Notification', + 'UserPromptSubmit', + 'Stop', + 'SubagentStop', + 'PreCompact', + 'SessionStart', + 'SessionEnd', +]); + +export type DroidHookEvent = z.infer; + +export const SdkHookRegistrationSchema = z + .object({ + eventName: DroidHookEventSchema, + matcher: z.string().optional(), + timeout: z.number().optional(), + }) + .strict(); + +export type SdkHookRegistration = z.infer; + +export const DroidHookSpecificOutputSchema = z + .object({ + hookEventName: DroidHookEventSchema, + permissionDecision: z.enum(['allow', 'deny', 'ask']).optional(), + permissionDecisionReason: z.string().optional(), + additionalContext: z.string().optional(), + updatedInput: z.record(z.unknown()).optional(), + }) + .passthrough(); + +export const DroidHookOutputSchema = z + .object({ + continue: z.boolean().optional(), + stopReason: z.string().optional(), + suppressOutput: z.boolean().optional(), + systemMessage: z.string().optional(), + decision: z.enum(['block', 'approve']).optional(), + reason: z.string().optional(), + hookSpecificOutput: DroidHookSpecificOutputSchema.optional(), + }) + .passthrough(); + +export type DroidHookOutput = z.infer; + +export const DroidHookExecutionResultSchema = DroidHookOutputSchema.extend({ + exitCode: z.number(), + stdout: z.string(), + stderr: z.string(), +}); + +export type DroidHookExecutionResult = z.infer< + typeof DroidHookExecutionResultSchema +>; + +export const ExecuteHooksRequestParamsSchema = z + .object({ + eventName: DroidHookEventSchema, + input: z.record(z.unknown()), + matcher: z.string().optional(), + toolUseId: z.string().optional(), + }) + .strict(); + +export type ExecuteHooksRequestParams = z.infer< + typeof ExecuteHooksRequestParamsSchema +>; + +export const ExecuteHooksResultSchema = z + .object({ + results: z.array(DroidHookExecutionResultSchema), + }) + .strict(); + +export type ExecuteHooksResult = z.infer; diff --git a/src/schemas/index.ts b/src/schemas/index.ts index e06cbdd..d37c250 100644 --- a/src/schemas/index.ts +++ b/src/schemas/index.ts @@ -99,6 +99,24 @@ export type { SessionStartEvent, } from './session-metadata.js'; +export { + DroidHookEventSchema, + DroidHookExecutionResultSchema, + DroidHookOutputSchema, + DroidHookSpecificOutputSchema, + ExecuteHooksRequestParamsSchema, + ExecuteHooksResultSchema, + SdkHookRegistrationSchema, +} from './hooks.js'; +export type { + DroidHookEvent, + DroidHookExecutionResult, + DroidHookOutput, + ExecuteHooksRequestParams, + ExecuteHooksResult, + SdkHookRegistration, +} from './hooks.js'; + export { Base64ImageSourceSchema, ContentBlockSchema, @@ -484,6 +502,8 @@ export { ErrorDetailSchema, ErrorNotificationSchema, ExecuteToolConfirmationDetailsSchema, + ExecuteHooksRequestSchema, + ExecuteHooksResponseSchema, ExitSpecModeConfirmationDetailsSchema, McpAuthCompletedNotificationSchema, McpAuthRequiredNotificationSchema, @@ -543,6 +563,8 @@ export type { ErrorDetail, ErrorNotification, ExecuteToolConfirmationDetails, + ExecuteHooksRequest, + ExecuteHooksResponse, ExitSpecModeConfirmationDetails, McpAuthCompletedNotification, McpAuthRequiredNotification, diff --git a/src/schemas/server.ts b/src/schemas/server.ts index 7cfeb3b..40adec4 100644 --- a/src/schemas/server.ts +++ b/src/schemas/server.ts @@ -15,6 +15,10 @@ import { ToolConfirmationOutcome, ToolConfirmationType, } from './enums.js'; +import { + ExecuteHooksRequestParamsSchema, + ExecuteHooksResultSchema, +} from './hooks.js'; import { McpServerStatusInfoSchema, McpStatusSummarySchema, @@ -780,11 +784,28 @@ export const AskUserResponseSchema = z.union([ export type AskUserResponse = z.infer; -/** Union over all 3 server → client methods. */ +/** Execute SDK hook callbacks request from the server (server → client). */ +export const ExecuteHooksRequestSchema = JsonRpcRequestSchema.extend({ + method: z.literal(DroidClientMethod.EXECUTE_HOOKS), + params: ExecuteHooksRequestParamsSchema, +}); + +export type ExecuteHooksRequest = z.infer; + +/** Response to droid.execute_hooks. */ +export const ExecuteHooksResponseSchema = z.union([ + JsonRpcResponseSuccessSchema.extend({ result: ExecuteHooksResultSchema }), + JsonRpcResponseFailureSchema, +]); + +export type ExecuteHooksResponse = z.infer; + +/** Union over all server → client methods. */ const _CliRequestOrNotificationSchema = z.union([ SessionNotificationSchema, RequestPermissionRequestSchema, AskUserRequestSchema, + ExecuteHooksRequestSchema, ]); /* eslint-disable @typescript-eslint/consistent-type-assertions -- Zod workaround for deep type inference */ @@ -802,4 +823,5 @@ export const CliRequestOrNotificationSchema: z.ZodType< export type CliRequestOrNotification = | SessionNotification | RequestPermissionRequest - | AskUserRequest; + | AskUserRequest + | ExecuteHooksRequest; diff --git a/src/session.ts b/src/session.ts index 3c0f481..1f39f06 100644 --- a/src/session.ts +++ b/src/session.ts @@ -12,6 +12,11 @@ import type { SessionInitOptions, TransportCreationOptions, } from './helpers.js'; +import { + buildSdkHookRegistrations, + createHookRequestHandler, + type DroidHooks, +} from './hooks.js'; import { startSdkMcpServers } from './mcp.js'; import type { DroidMcpServerConfig } from './mcp.js'; import type { NotificationCallback, NotificationFilter } from './protocol.js'; @@ -61,6 +66,7 @@ export type DroidResult = DroidResultMessage; export interface CreateSessionOptions extends SessionInitOptions, HandlerOptions, TransportCreationOptions { abortSignal?: AbortSignal; + hooks?: DroidHooks; } export interface ResumeSessionOptions extends Pick< @@ -73,6 +79,7 @@ export interface ResumeSessionOptions extends Pick< | 'askUserHandler' | 'transport' | 'abortSignal' + | 'hooks' > { mcpServers?: DroidMcpServerConfig[]; } @@ -387,13 +394,18 @@ export async function createSession( try { sdkMcpServers = await startSdkMcpServers(options.mcpServers); + if (options.hooks) { + client.setHookHandler(createHookRequestHandler(options.hooks)); + } const initParams = buildInitParams({ ...options, mcpServers: sdkMcpServers.mcpServers, + sdkHooks: buildSdkHookRegistrations(options.hooks), }); const initResult = await client.initializeSession(initParams); const session = new DroidSession(client, initResult.sessionId, initResult); session.addCleanup(sdkMcpServers.cleanup); + session.addCleanup(() => client.clearHookHandler()); cleanupInitAbortSignal(); cleanupInitAbortSignal = () => {}; session.setAbortSignalCleanup( @@ -419,13 +431,18 @@ export async function resumeSession( try { sdkMcpServers = await startSdkMcpServers(options.mcpServers); + if (options.hooks) { + client.setHookHandler(createHookRequestHandler(options.hooks)); + } const loadParams: LoadSessionRequestParams = { sessionId, mcpServers: sdkMcpServers.mcpServers, + sdkHooks: buildSdkHookRegistrations(options.hooks), }; const loadResult = await client.loadSession(loadParams); const session = new DroidSession(client, sessionId, loadResult); session.addCleanup(sdkMcpServers.cleanup); + session.addCleanup(() => client.clearHookHandler()); session.setAbortSignalCleanup( wireAbortSignal(options.abortSignal, () => void session.close()) ); diff --git a/tests/helpers.test.ts b/tests/helpers.test.ts index bbd818c..4b8f551 100644 --- a/tests/helpers.test.ts +++ b/tests/helpers.test.ts @@ -336,6 +336,7 @@ describe('buildInitParams', () => { specModeReasoningEffort: 'max' as never, enabledToolIds: ['Read', 'Grep'], disabledToolIds: ['Execute'], + sdkHooks: [{ eventName: 'PreToolUse', matcher: 'Execute', timeout: 30 }], mcpServers: [ { name: 'test-server', @@ -356,6 +357,9 @@ describe('buildInitParams', () => { expect(params.specModeReasoningEffort).toBe('max'); expect(params.enabledToolIds).toEqual(['Read', 'Grep']); expect(params.disabledToolIds).toEqual(['Execute']); + expect(params.sdkHooks).toEqual([ + { eventName: 'PreToolUse', matcher: 'Execute', timeout: 30 }, + ]); expect(params.mcpServers).toHaveLength(1); }); @@ -386,6 +390,7 @@ describe('buildInitParams', () => { expect(params).not.toHaveProperty('specModeModelId'); expect(params).not.toHaveProperty('specModeReasoningEffort'); expect(params).not.toHaveProperty('mcpServers'); + expect(params).not.toHaveProperty('sdkHooks'); expect(params).not.toHaveProperty('enabledToolIds'); expect(params).not.toHaveProperty('disabledToolIds'); }); diff --git a/tests/hooks.test.ts b/tests/hooks.test.ts new file mode 100644 index 0000000..e98ebba --- /dev/null +++ b/tests/hooks.test.ts @@ -0,0 +1,166 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { + buildSdkHookRegistrations, + createHookRequestHandler, + matchesHookMatcher, + type DroidHooks, + type DroidHookOutput, +} from '../src/hooks.js'; + +describe('SDK hooks', () => { + it('builds serializable hook registrations without callbacks', () => { + const hooks: DroidHooks = { + PreToolUse: [ + { + matcher: 'Execute', + timeout: 30, + hooks: [() => ({})], + }, + ], + Stop: [ + { + hooks: [() => ({})], + }, + ], + }; + + expect(buildSdkHookRegistrations(hooks)).toEqual([ + { eventName: 'PreToolUse', matcher: 'Execute', timeout: 30 }, + { eventName: 'Stop' }, + ]); + }); + + it('matches Droid CLI hook matcher semantics', () => { + expect(matchesHookMatcher(undefined, 'Execute')).toBe(true); + expect(matchesHookMatcher('', 'Execute')).toBe(true); + expect(matchesHookMatcher('*', 'Execute')).toBe(true); + expect(matchesHookMatcher('Execute', 'Execute')).toBe(true); + expect(matchesHookMatcher('Edit|Create', 'Create')).toBe(true); + expect(matchesHookMatcher('Read', 'Execute')).toBe(false); + expect(matchesHookMatcher('[', 'Execute')).toBe(false); + }); + + it('runs matching hook callbacks and converts output to execution results', async () => { + const callback = vi.fn(() => ({ + hookSpecificOutput: { + hookEventName: 'PreToolUse' as const, + permissionDecision: 'deny' as const, + permissionDecisionReason: 'blocked', + }, + })); + const handler = createHookRequestHandler({ + PreToolUse: [{ matcher: 'Execute', hooks: [callback] }], + }); + + const result = await handler({ + eventName: 'PreToolUse', + matcher: 'Execute', + toolUseId: 'tool-1', + input: { + hook_event_name: 'PreToolUse', + session_id: 's1', + transcript_path: '', + cwd: '.', + permission_mode: 'off', + tool_name: 'Execute', + tool_input: { command: 'npm test' }, + }, + }); + + expect(callback).toHaveBeenCalledWith( + expect.objectContaining({ tool_name: 'Execute' }), + 'tool-1', + { signal: expect.any(AbortSignal) } + ); + expect(result.results).toEqual([ + { + exitCode: 0, + stdout: '', + stderr: '', + hookSpecificOutput: { + hookEventName: 'PreToolUse', + permissionDecision: 'deny', + permissionDecisionReason: 'blocked', + }, + }, + ]); + }); + + it('converts thrown callback errors to non-throwing hook failures', async () => { + const handler = createHookRequestHandler({ + Stop: [ + { + hooks: [ + () => { + throw new Error('boom'); + }, + ], + }, + ], + }); + + const result = await handler({ + eventName: 'Stop', + input: { + hook_event_name: 'Stop', + session_id: 's1', + transcript_path: '', + cwd: '.', + permission_mode: 'off', + stop_hook_active: false, + }, + }); + + expect(result.results).toEqual([ + { exitCode: 1, stdout: '', stderr: 'boom' }, + ]); + }); + + it('aborts callbacks after the configured timeout', async () => { + vi.useFakeTimers(); + try { + let aborted = false; + const callback = vi.fn( + (_input, _toolUseId, { signal }: { signal: AbortSignal }) => + new Promise(() => { + signal.addEventListener('abort', () => { + aborted = true; + }); + }) + ); + const handler = createHookRequestHandler({ + PreCompact: [{ timeout: 0.01, hooks: [callback] }], + }); + + const promise = handler({ + eventName: 'PreCompact', + input: { + hook_event_name: 'PreCompact', + session_id: 's1', + transcript_path: '', + cwd: '.', + permission_mode: 'off', + trigger: 'manual', + message_count: 1, + estimated_tokens: 1, + }, + }); + + await vi.advanceTimersByTimeAsync(10); + + expect(aborted).toBe(true); + expect(await promise).toEqual({ + results: [ + { + exitCode: 1, + stdout: '', + stderr: 'Hook callback timed out after 0.01 seconds', + }, + ], + }); + } finally { + vi.useRealTimers(); + } + }); +}); diff --git a/tests/protocol.test.ts b/tests/protocol.test.ts index 3f2f97a..64365b3 100644 --- a/tests/protocol.test.ts +++ b/tests/protocol.test.ts @@ -506,6 +506,111 @@ describe('ProtocolEngine', () => { }); }); }); + + describe('hook requests', () => { + it('invokes registered hook handler and sends response', async () => { + engine.setHookHandler((params) => ({ + results: [ + { + exitCode: 0, + stdout: '', + stderr: '', + systemMessage: `handled ${params.eventName}`, + }, + ], + })); + + transport.injectMessage( + makeServerRequest('hook-1', DroidClientMethod.EXECUTE_HOOKS, { + eventName: 'PreToolUse', + input: { + hook_event_name: 'PreToolUse', + session_id: 's1', + transcript_path: '', + cwd: '.', + permission_mode: 'off', + tool_name: 'Execute', + tool_input: { command: 'npm test' }, + }, + matcher: 'Execute', + toolUseId: 'tool-1', + }) + ); + + await vi.waitFor(() => { + expect(transport.sentMessages).toHaveLength(1); + }); + + const response = transport.sentMessages[0] as Record; + expect(response['type']).toBe('response'); + expect(response['id']).toBe('hook-1'); + expect(response['result']).toEqual({ + results: [ + { + exitCode: 0, + stdout: '', + stderr: '', + systemMessage: 'handled PreToolUse', + }, + ], + }); + }); + + it('returns empty hook results when no hook handler is registered', async () => { + transport.injectMessage( + makeServerRequest('hook-2', DroidClientMethod.EXECUTE_HOOKS, { + eventName: 'SessionEnd', + input: { + hook_event_name: 'SessionEnd', + session_id: 's1', + transcript_path: '', + cwd: '.', + permission_mode: 'off', + reason: 'other', + session_duration_ms: 10, + message_count: 1, + }, + }) + ); + + await vi.waitFor(() => { + expect(transport.sentMessages).toHaveLength(1); + }); + + const response = transport.sentMessages[0] as Record; + expect(response['result']).toEqual({ results: [] }); + }); + + it('sends error response when hook handler throws', async () => { + engine.setHookHandler(() => { + throw new Error('hook failure'); + }); + + transport.injectMessage( + makeServerRequest('hook-3', DroidClientMethod.EXECUTE_HOOKS, { + eventName: 'Stop', + input: { + hook_event_name: 'Stop', + session_id: 's1', + transcript_path: '', + cwd: '.', + permission_mode: 'off', + stop_hook_active: false, + }, + }) + ); + + await vi.waitFor(() => { + expect(transport.sentMessages).toHaveLength(1); + }); + + const response = transport.sentMessages[0] as Record; + expect(response['error']).toBeDefined(); + const error = response['error'] as Record; + expect(error['code']).toBe(JsonRpcErrorCode.INTERNAL_ERROR); + expect(error['message']).toBe('Failed to handle hook request'); + }); + }); }); describe('sticky transport error (VAL-PROTOCOL-005)', () => { From e2c94a86a637b4dc7a95a474f4fb0521a1346b49 Mon Sep 17 00:00:00 2001 From: User Date: Thu, 14 May 2026 17:11:22 -0700 Subject: [PATCH 2/8] fix: complete SDK hook lifecycle Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- src/client.ts | 13 +++++++++++++ src/schemas/client.ts | 35 +++++++++++++++++++++++++++++++++++ src/schemas/enums.ts | 1 + src/schemas/hooks.ts | 2 +- src/schemas/index.ts | 8 ++++++++ src/session.ts | 22 +++++++++++++++++++--- tests/hooks.test.ts | 37 +++++++++++++++++++++++++++++++++++++ tests/schemas.test.ts | 5 +++-- tests/session.test.ts | 29 +++++++++++++++++++++++++++++ 9 files changed, 146 insertions(+), 6 deletions(-) diff --git a/src/client.ts b/src/client.ts index c631d76..52d3647 100644 --- a/src/client.ts +++ b/src/client.ts @@ -20,6 +20,8 @@ import type { CancelMcpAuthResult, ClearMcpAuthRequestParams, ClearMcpAuthResult, + CloseSessionRequestParams, + CloseSessionResult, CompactSessionRequestParams, CompactSessionResult, GetContextStatsResult, @@ -62,6 +64,7 @@ import { AuthenticateMcpServerResultSchema, CancelMcpAuthResultSchema, ClearMcpAuthResultSchema, + CloseSessionResultSchema, CompactSessionResultSchema, GetContextStatsResultSchema, ExecuteRewindResultSchema, @@ -252,6 +255,16 @@ export class DroidClient { ); } + async closeSession( + params: CloseSessionRequestParams = {} + ): Promise { + return this._sessionRpc( + DroidServerMethod.CLOSE_SESSION, + params, + CloseSessionResultSchema + ); + } + async killWorkerSession( params: KillWorkerSessionRequestParams ): Promise { diff --git a/src/schemas/client.ts b/src/schemas/client.ts index a1bcd7d..20fe846 100644 --- a/src/schemas/client.ts +++ b/src/schemas/client.ts @@ -314,6 +314,19 @@ export type InterruptSessionRequestParams = z.infer< typeof InterruptSessionRequestParamsSchema >; +/** Parameters for droid.close_session request. */ +export const CloseSessionRequestParamsSchema = z + .object({ + reason: z + .enum(['clear', 'logout', 'prompt_input_exit', 'other']) + .optional(), + }) + .strict(); + +export type CloseSessionRequestParams = z.infer< + typeof CloseSessionRequestParamsSchema +>; + /** Parameters for droid.kill_worker_session request. */ export const KillWorkerSessionRequestParamsSchema = z .object({ @@ -637,6 +650,13 @@ export type InterruptSessionRequest = z.infer< typeof InterruptSessionRequestSchema >; +export const CloseSessionRequestSchema = JsonRpcRequestSchema.extend({ + method: z.literal(DroidServerMethod.CLOSE_SESSION), + params: CloseSessionRequestParamsSchema, +}); + +export type CloseSessionRequest = z.infer; + export const KillWorkerSessionRequestSchema = JsonRpcRequestSchema.extend({ method: z.literal(DroidServerMethod.KILL_WORKER_SESSION), params: KillWorkerSessionRequestParamsSchema, @@ -814,6 +834,7 @@ export const ClientRequestSchema = z.discriminatedUnion('method', [ InitializeSessionRequestSchema, LoadSessionRequestSchema, AddUserMessageRequestSchema, + CloseSessionRequestSchema, InterruptSessionRequestSchema, KillWorkerSessionRequestSchema, UpdateSessionSettingsRequestSchema, @@ -907,6 +928,11 @@ export type InterruptSessionResult = z.infer< typeof InterruptSessionResultSchema >; +/** Result for droid.close_session response (empty). */ +export const CloseSessionResultSchema = EmptyResultSchema; + +export type CloseSessionResult = z.infer; + /** Result for droid.kill_worker_session response (empty). */ export const KillWorkerSessionResultSchema = EmptyResultSchema; @@ -1117,6 +1143,15 @@ export type InterruptSessionResponse = z.infer< typeof InterruptSessionResponseSchema >; +export const CloseSessionResponseSchema = z.union([ + JsonRpcResponseSuccessSchema.extend({ + result: CloseSessionResultSchema, + }), + JsonRpcResponseFailureSchema, +]); + +export type CloseSessionResponse = z.infer; + export const KillWorkerSessionResponseSchema = z.union([ JsonRpcResponseSuccessSchema.extend({ result: KillWorkerSessionResultSchema, diff --git a/src/schemas/enums.ts b/src/schemas/enums.ts index d6246b9..8083c43 100644 --- a/src/schemas/enums.ts +++ b/src/schemas/enums.ts @@ -12,6 +12,7 @@ export enum DroidServerMethod { INITIALIZE_SESSION = 'droid.initialize_session', LOAD_SESSION = 'droid.load_session', ADD_USER_MESSAGE = 'droid.add_user_message', + CLOSE_SESSION = 'droid.close_session', INTERRUPT_SESSION = 'droid.interrupt_session', KILL_WORKER_SESSION = 'droid.kill_worker_session', UPDATE_SESSION_SETTINGS = 'droid.update_session_settings', diff --git a/src/schemas/hooks.ts b/src/schemas/hooks.ts index 1557719..e85b4fe 100644 --- a/src/schemas/hooks.ts +++ b/src/schemas/hooks.ts @@ -26,7 +26,7 @@ export type SdkHookRegistration = z.infer; export const DroidHookSpecificOutputSchema = z .object({ - hookEventName: DroidHookEventSchema, + hookEventName: DroidHookEventSchema.optional(), permissionDecision: z.enum(['allow', 'deny', 'ask']).optional(), permissionDecisionReason: z.string().optional(), additionalContext: z.string().optional(), diff --git a/src/schemas/index.ts b/src/schemas/index.ts index d37c250..97ff646 100644 --- a/src/schemas/index.ts +++ b/src/schemas/index.ts @@ -248,6 +248,10 @@ export { ClearMcpAuthResponseSchema, ClearMcpAuthResultSchema, ClientRequestSchema, + CloseSessionRequestParamsSchema, + CloseSessionRequestSchema, + CloseSessionResponseSchema, + CloseSessionResultSchema, ContextStatsSchema, CompactSessionRequestParamsSchema, CompactSessionRequestSchema, @@ -378,6 +382,10 @@ export type { ClearMcpAuthResponse, ClearMcpAuthResult, ClientRequest, + CloseSessionRequest, + CloseSessionRequestParams, + CloseSessionResponse, + CloseSessionResult, ContextStats, CompactSessionRequest, CompactSessionRequestParams, diff --git a/src/session.ts b/src/session.ts index 1f39f06..74e1f21 100644 --- a/src/session.ts +++ b/src/session.ts @@ -148,16 +148,19 @@ export class DroidSession { private _closed = false; private _cleanupAbortSignal: (() => void) | null = null; private _cleanupCallbacks: Array<() => Promise | void> = []; + private readonly _runSessionEndOnClose: boolean; /** @internal */ constructor( client: DroidClient, sessionId: string, - initResult: InitializeSessionResult | LoadSessionResult + initResult: InitializeSessionResult | LoadSessionResult, + runSessionEndOnClose = false ) { this._client = client; this._sessionId = sessionId; this._initResult = initResult; + this._runSessionEndOnClose = runSessionEndOnClose; } get sessionId(): string { @@ -250,6 +253,9 @@ export class DroidSession { this._cleanupAbortSignal = null; try { + if (this._runSessionEndOnClose) { + await this._client.closeSession({ reason: 'other' }).catch(() => {}); + } await this._client.close(); } finally { const cleanups = this._cleanupCallbacks.splice(0); @@ -403,7 +409,12 @@ export async function createSession( sdkHooks: buildSdkHookRegistrations(options.hooks), }); const initResult = await client.initializeSession(initParams); - const session = new DroidSession(client, initResult.sessionId, initResult); + const session = new DroidSession( + client, + initResult.sessionId, + initResult, + (options.hooks?.SessionEnd?.length ?? 0) > 0 + ); session.addCleanup(sdkMcpServers.cleanup); session.addCleanup(() => client.clearHookHandler()); cleanupInitAbortSignal(); @@ -440,7 +451,12 @@ export async function resumeSession( sdkHooks: buildSdkHookRegistrations(options.hooks), }; const loadResult = await client.loadSession(loadParams); - const session = new DroidSession(client, sessionId, loadResult); + const session = new DroidSession( + client, + sessionId, + loadResult, + (options.hooks?.SessionEnd?.length ?? 0) > 0 + ); session.addCleanup(sdkMcpServers.cleanup); session.addCleanup(() => client.clearHookHandler()); session.setAbortSignalCleanup( diff --git a/tests/hooks.test.ts b/tests/hooks.test.ts index e98ebba..4279c06 100644 --- a/tests/hooks.test.ts +++ b/tests/hooks.test.ts @@ -7,6 +7,7 @@ import { type DroidHooks, type DroidHookOutput, } from '../src/hooks.js'; +import { ExecuteHooksResultSchema } from '../src/schemas/hooks.js'; describe('SDK hooks', () => { it('builds serializable hook registrations without callbacks', () => { @@ -87,6 +88,42 @@ describe('SDK hooks', () => { ]); }); + it('accepts minimal hookSpecificOutput without hookEventName', async () => { + const handler = createHookRequestHandler({ + PreToolUse: [ + { + matcher: 'Execute', + hooks: [ + () => ({ + hookSpecificOutput: { + permissionDecision: 'deny' as const, + }, + }), + ], + }, + ], + }); + + const result = await handler({ + eventName: 'PreToolUse', + matcher: 'Execute', + input: { + hook_event_name: 'PreToolUse', + session_id: 's1', + transcript_path: '', + cwd: '.', + permission_mode: 'off', + tool_name: 'Execute', + tool_input: { command: 'npm test' }, + }, + }); + + expect(() => ExecuteHooksResultSchema.parse(result)).not.toThrow(); + expect(result.results[0]?.hookSpecificOutput).toEqual({ + permissionDecision: 'deny', + }); + }); + it('converts thrown callback errors to non-throwing hook failures', async () => { const handler = createHookRequestHandler({ Stop: [ diff --git a/tests/schemas.test.ts b/tests/schemas.test.ts index dd4e3e7..dff5272 100644 --- a/tests/schemas.test.ts +++ b/tests/schemas.test.ts @@ -134,12 +134,13 @@ import { } from '../src/schemas/index.js'; describe('enums', () => { - it('DroidServerMethod has all 26 methods', () => { + it('DroidServerMethod has all 27 methods', () => { const values = Object.values(DroidServerMethod); - expect(values).toHaveLength(26); + expect(values).toHaveLength(27); expect(values).toContain('droid.initialize_session'); expect(values).toContain('droid.load_session'); expect(values).toContain('droid.add_user_message'); + expect(values).toContain('droid.close_session'); expect(values).toContain('droid.interrupt_session'); expect(values).toContain('droid.kill_worker_session'); expect(values).toContain('droid.update_session_settings'); diff --git a/tests/session.test.ts b/tests/session.test.ts index 9dcdbb4..16b6e1c 100644 --- a/tests/session.test.ts +++ b/tests/session.test.ts @@ -592,6 +592,35 @@ describe('DroidSession', () => { await session.close(); await session.close(); }); + + it('requests graceful close when a SessionEnd SDK hook is registered', async () => { + const transport = new InMemoryTransport(); + await transport.connect(); + + setupFullResponder(transport, 'sess-close-session-end', { + [DroidServerMethod.CLOSE_SESSION]: (id) => { + queueMicrotask(() => { + transport.injectMessage(makeSuccessResponse(id, {})); + }); + }, + }); + + const session = await createSession({ + transport, + hooks: { + SessionEnd: [{ hooks: [() => ({})] }], + }, + }); + + await session.close(); + + const closeMessage = transport.sentMessages.find( + (message) => message['method'] === DroidServerMethod.CLOSE_SESSION + ); + expect(closeMessage).toBeDefined(); + expect(closeMessage?.['params']).toEqual({ reason: 'other' }); + expect(transport.isConnected).toBe(false); + }); }); describe('MCP methods (VAL-API-011)', () => { From 3ef9a5131e48a8eb1a58abeae1e439f5a9f4ebbb Mon Sep 17 00:00:00 2001 From: User Date: Thu, 14 May 2026 18:42:44 -0700 Subject: [PATCH 3/8] refactor: replace SDK hook callbacks with setting sources Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- src/client.ts | 30 ------ src/helpers.ts | 8 +- src/hooks.ts | 188 +---------------------------------- src/index.ts | 4 - src/protocol.ts | 53 +--------- src/schemas/client.ts | 14 ++- src/schemas/enums.ts | 1 - src/schemas/hooks.ts | 31 ------ src/schemas/index.ts | 12 +-- src/schemas/server.ts | 24 +---- src/session.ts | 27 ++--- tests/helpers.test.ts | 10 +- tests/hooks.test.ts | 219 +++++++---------------------------------- tests/protocol.test.ts | 105 -------------------- tests/session.test.ts | 18 +++- 15 files changed, 81 insertions(+), 663 deletions(-) diff --git a/src/client.ts b/src/client.ts index 52d3647..42fdf05 100644 --- a/src/client.ts +++ b/src/client.ts @@ -4,7 +4,6 @@ import { ConnectionError, SessionError } from './errors.js'; import { ProtocolEngine, type AskUserHandler, - type HookRequestHandler, type NotificationCallback, type NotificationFilter, type PermissionHandler, @@ -94,10 +93,6 @@ import { SESSION_INIT_TIMEOUT, } from './schemas/constants.js'; import { DroidServerMethod, ToolConfirmationOutcome } from './schemas/enums.js'; -import type { - ExecuteHooksRequestParams, - ExecuteHooksResult, -} from './schemas/hooks.js'; import { SessionNotificationParamsSchema } from './schemas/server.js'; import type { AskUserRequestParams, @@ -111,8 +106,6 @@ export type ClientPermissionHandler = PermissionHandler; export type ClientAskUserHandler = AskUserHandler; -export type ClientHookRequestHandler = HookRequestHandler; - interface ClientNotificationListener { readonly callback: NotificationCallback; readonly filter?: NotificationFilter; @@ -143,9 +136,6 @@ export class DroidClient { /** Client-level ask-user handler. */ private _askUserHandler: ClientAskUserHandler | null = null; - /** Client-level hook handler. */ - private _hookHandler: ClientHookRequestHandler | null = null; - constructor(options: DroidClientOptions) { this._engine = new ProtocolEngine({ transport: options.transport, @@ -162,7 +152,6 @@ export class DroidClient { this._engine.setAskUserHandler((params) => this._dispatchAskUserRequest(params) ); - this._engine.setHookHandler((params) => this._dispatchHookRequest(params)); } private async _rpc( @@ -505,14 +494,6 @@ export class DroidClient { this._askUserHandler = null; } - setHookHandler(handler: ClientHookRequestHandler): void { - this._hookHandler = handler; - } - - clearHookHandler(): void { - this._hookHandler = null; - } - async close(): Promise { if (this._closed) { return; @@ -522,7 +503,6 @@ export class DroidClient { this._notificationListeners.length = 0; this._permissionHandler = null; this._askUserHandler = null; - this._hookHandler = null; await this._engine.close(); } @@ -573,16 +553,6 @@ export class DroidClient { return handler(params); } - private _dispatchHookRequest( - params: ExecuteHooksRequestParams - ): ExecuteHooksResult | Promise { - const handler = this._hookHandler; - if (handler == null) { - return { results: [] }; - } - return handler(params); - } - private _ensureNotClosed(): void { if (this._closed) { throw new ConnectionError( diff --git a/src/helpers.ts b/src/helpers.ts index e53f3e9..3ff31b5 100644 --- a/src/helpers.ts +++ b/src/helpers.ts @@ -9,6 +9,7 @@ import type { InitializeSessionRequestParams, McpServerConfig, OutputFormat, + SettingSource, SessionTag, } from './schemas/client.js'; import type { @@ -16,7 +17,6 @@ import type { DroidInteractionMode, ReasoningEffort, } from './schemas/enums.js'; -import type { SdkHookRegistration } from './schemas/hooks.js'; import { SessionNotificationSchema, type SessionNotificationPayload, @@ -251,12 +251,12 @@ export interface SessionInitOptions extends ToolSelectionOverrides { specModeModelId?: string; specModeReasoningEffort?: ReasoningEffort; mcpServers?: DroidMcpServerConfig[]; + settingSources?: SettingSource[]; tags?: SessionTag[]; } type ResolvedSessionInitOptions = Omit & { mcpServers?: McpServerConfig[]; - sdkHooks?: SdkHookRegistration[]; }; export function buildInitParams( @@ -284,8 +284,8 @@ export function buildInitParams( ...(options.mcpServers !== undefined && { mcpServers: options.mcpServers, }), - ...(options.sdkHooks !== undefined && { - sdkHooks: options.sdkHooks, + ...(options.settingSources !== undefined && { + settingSources: options.settingSources, }), ...(options.enabledToolIds !== undefined && { enabledToolIds: options.enabledToolIds, diff --git a/src/hooks.ts b/src/hooks.ts index 1d09258..7ff76c0 100644 --- a/src/hooks.ts +++ b/src/hooks.ts @@ -1,11 +1,4 @@ -import type { - DroidHookEvent, - DroidHookExecutionResult, - DroidHookOutput, - ExecuteHooksRequestParams, - ExecuteHooksResult, - SdkHookRegistration, -} from './schemas/hooks.js'; +import type { DroidHookEvent } from './schemas/hooks.js'; export type { DroidHookEvent, DroidHookOutput } from './schemas/hooks.js'; @@ -96,182 +89,3 @@ export type DroidHookInput = | SessionStartHookInput | SessionEndHookInput | BaseDroidHookInput; - -export type DroidHookCallback = ( - input: DroidHookInput, - toolUseId: string | undefined, - context: { signal: AbortSignal } -) => DroidHookOutput | Promise; - -export interface DroidHookMatcher { - matcher?: string; - timeout?: number; - hooks: DroidHookCallback[]; -} - -export type DroidHooks = Partial>; - -export type HookRequestHandler = ( - params: ExecuteHooksRequestParams -) => Promise | ExecuteHooksResult; - -const DEFAULT_HOOK_TIMEOUT_SECONDS = 60; -const DROID_HOOK_EVENTS: DroidHookEvent[] = [ - 'PreToolUse', - 'PostToolUse', - 'Notification', - 'UserPromptSubmit', - 'Stop', - 'SubagentStop', - 'PreCompact', - 'SessionStart', - 'SessionEnd', -]; - -export function buildSdkHookRegistrations( - hooks: DroidHooks | undefined -): SdkHookRegistration[] | undefined { - if (!hooks) return undefined; - - const registrations: SdkHookRegistration[] = []; - for (const eventName of DROID_HOOK_EVENTS) { - const matchers = hooks[eventName]; - for (const matcher of matchers ?? []) { - if (matcher.hooks.length === 0) continue; - registrations.push({ - eventName, - ...(matcher.matcher !== undefined && { matcher: matcher.matcher }), - ...(matcher.timeout !== undefined && { timeout: matcher.timeout }), - }); - } - } - - return registrations.length > 0 ? registrations : undefined; -} - -export function createHookRequestHandler( - hooks: DroidHooks -): HookRequestHandler { - return async ({ eventName, input, matcher, toolUseId }) => { - const groups = hooks[eventName] ?? []; - const matchingGroups = groups.filter((group) => - matchesHookMatcher(group.matcher, matcher) - ); - - const results = await Promise.all( - matchingGroups.flatMap((group) => - group.hooks.map((hook) => - executeCallbackWithTimeout( - hook, - toDroidHookInput(input, eventName), - toolUseId, - group.timeout - ) - ) - ) - ); - - return { results }; - }; -} - -export function matchesHookMatcher( - configuredMatcher: string | undefined, - actualMatcher: string | undefined -): boolean { - if ( - configuredMatcher === undefined || - configuredMatcher === '' || - configuredMatcher === '*' || - actualMatcher === undefined - ) { - return true; - } - - if (configuredMatcher === actualMatcher) { - return true; - } - - try { - return new RegExp(configuredMatcher).test(actualMatcher); - } catch { - return false; - } -} - -function toDroidHookInput( - input: Record, - eventName: DroidHookEvent -): DroidHookInput { - return { - ...input, - session_id: - typeof input.session_id === 'string' ? input.session_id : 'unknown', - transcript_path: - typeof input.transcript_path === 'string' ? input.transcript_path : '', - cwd: typeof input.cwd === 'string' ? input.cwd : '', - permission_mode: toPermissionMode(input.permission_mode), - hook_event_name: eventName, - ...(typeof input.message_id === 'string' && { - message_id: input.message_id, - }), - }; -} - -function toPermissionMode(value: unknown): DroidPermissionMode { - switch (value) { - case 'spec': - case 'auto-low': - case 'auto-medium': - case 'auto-high': - return value; - default: - return 'off'; - } -} - -async function executeCallbackWithTimeout( - hook: DroidHookCallback, - input: DroidHookInput, - toolUseId: string | undefined, - timeoutSeconds = DEFAULT_HOOK_TIMEOUT_SECONDS -): Promise { - const controller = new AbortController(); - let timeout: ReturnType | undefined; - - try { - const timeoutPromise = new Promise((resolve) => { - timeout = setTimeout(() => { - controller.abort(); - resolve({ - exitCode: 1, - stdout: '', - stderr: `Hook callback timed out after ${timeoutSeconds} seconds`, - }); - }, timeoutSeconds * 1000); - }); - - const callbackPromise = (async () => - hook(input, toolUseId, { - signal: controller.signal, - }))().then( - (output): DroidHookExecutionResult => ({ - exitCode: 0, - stdout: '', - stderr: '', - ...(output ?? {}), - }), - (error): DroidHookExecutionResult => ({ - exitCode: 1, - stdout: '', - stderr: error instanceof Error ? error.message : String(error), - }) - ); - - return await Promise.race([callbackPromise, timeoutPromise]); - } finally { - if (timeout) { - clearTimeout(timeout); - } - } -} diff --git a/src/index.ts b/src/index.ts index 9d75239..c1eccce 100644 --- a/src/index.ts +++ b/src/index.ts @@ -18,19 +18,15 @@ export type { export { DroidClient } from './client.js'; export type { ClientAskUserHandler, - ClientHookRequestHandler, ClientPermissionHandler, DroidClientOptions, } from './client.js'; export type { BaseDroidHookInput, - DroidHookCallback, DroidHookEvent, DroidHookInput, - DroidHookMatcher, DroidHookOutput, - DroidHooks, DroidPermissionMode, NotificationHookInput, PreCompactHookInput, diff --git a/src/protocol.ts b/src/protocol.ts index c0d1226..6dc35f3 100644 --- a/src/protocol.ts +++ b/src/protocol.ts @@ -18,12 +18,6 @@ import { JsonRpcErrorCode, ToolConfirmationOutcome, } from './schemas/enums.js'; -import { - ExecuteHooksRequestParamsSchema, - ExecuteHooksResultSchema, - type ExecuteHooksRequestParams, - type ExecuteHooksResult, -} from './schemas/hooks.js'; import { AskUserRequestParamsSchema, AskUserResultSchema, @@ -49,10 +43,6 @@ export type AskUserHandler = ( params: AskUserRequestParams ) => AskUserResult | Promise; -export type HookRequestHandler = ( - params: ExecuteHooksRequestParams -) => ExecuteHooksResult | Promise; - export type NotificationCallback = ( notification: Record ) => void; @@ -83,7 +73,6 @@ export class ProtocolEngine { private readonly _notificationListeners = new Set(); private _permissionHandler: PermissionHandler | null = null; private _askUserHandler: AskUserHandler | null = null; - private _hookHandler: HookRequestHandler | null = null; private _transportError: Error | null = null; private _closed = false; @@ -207,14 +196,6 @@ export class ProtocolEngine { this._askUserHandler = null; } - setHookHandler(handler: HookRequestHandler): void { - this._hookHandler = handler; - } - - clearHookHandler(): void { - this._hookHandler = null; - } - get isHealthy(): boolean { return !this._closed && this._transportError === null; } @@ -232,7 +213,6 @@ export class ProtocolEngine { this._permissionHandler = null; this._askUserHandler = null; - this._hookHandler = null; this._notificationListeners.clear(); await this._transport.close(); @@ -330,8 +310,6 @@ export class ProtocolEngine { await this._handlePermissionRequest(requestId, params); } else if (method === DroidClientMethod.ASK_USER) { await this._handleAskUserRequest(requestId, params); - } else if (method === DroidClientMethod.EXECUTE_HOOKS) { - await this._handleHookRequest(requestId, params); } } @@ -404,35 +382,6 @@ export class ProtocolEngine { } } - private async _handleHookRequest( - requestId: string, - params: unknown - ): Promise { - const handler = this._hookHandler; - - if (handler == null) { - this._sendResponse( - requestId, - ExecuteHooksResultSchema.parse({ results: [] }) - ); - return; - } - - try { - const parsedParams = ExecuteHooksRequestParamsSchema.parse(params); - const result = await Promise.resolve(handler(parsedParams)); - this._sendResponse(requestId, ExecuteHooksResultSchema.parse(result)); - } catch (exc) { - const errorMessage = exc instanceof Error ? exc.message : String(exc); - this._sendErrorResponse( - requestId, - JsonRpcErrorCode.INTERNAL_ERROR, - 'Failed to handle hook request', - errorMessage - ); - } - } - /** * Handle a transport error. * Sets the sticky transport error and rejects all pending requests. @@ -449,7 +398,7 @@ export class ProtocolEngine { private _sendResponse( requestId: string, - result: RequestPermissionResult | AskUserResult | ExecuteHooksResult + result: RequestPermissionResult | AskUserResult ): void { const response: Record = { jsonrpc: JSONRPC_VERSION, diff --git a/src/schemas/client.ts b/src/schemas/client.ts index 20fe846..6216746 100644 --- a/src/schemas/client.ts +++ b/src/schemas/client.ts @@ -8,13 +8,12 @@ import { DroidInteractionMode, DroidServerMethod, McpServerType, - MissionState, ModelProvider, + MissionState, ReasoningEffort, SettingsLevel, SkillLocation, } from './enums.js'; -import { SdkHookRegistrationSchema } from './hooks.js'; import { McpRegistryServerSchema, McpServerStatusInfoSchema, @@ -232,6 +231,13 @@ export const SkillInfoSchema = z export type SkillInfo = z.infer; +export const SettingSourceSchema = z.union([ + z.nativeEnum(SettingsLevel), + z.literal('all'), +]); + +export type SettingSource = z.infer; + /** Parameters for droid.initialize_session request. */ export const InitializeSessionRequestParamsSchema = z .object({ @@ -252,7 +258,7 @@ export const InitializeSessionRequestParamsSchema = z sessionSource: SessionSourceSchema.optional(), tags: z.array(SessionTagSchema).optional(), mcpOAuthCallbackUri: z.string().optional(), - sdkHooks: z.array(SdkHookRegistrationSchema).optional(), + settingSources: z.array(SettingSourceSchema).optional(), }) .strict(); @@ -266,7 +272,7 @@ export const LoadSessionRequestParamsSchema = z sessionId: z.string(), mcpServers: z.array(McpServerConfigSchema).optional(), mcpOAuthCallbackUri: z.string().optional(), - sdkHooks: z.array(SdkHookRegistrationSchema).optional(), + settingSources: z.array(SettingSourceSchema).optional(), }) .strict(); diff --git a/src/schemas/enums.ts b/src/schemas/enums.ts index 8083c43..0aa0c8a 100644 --- a/src/schemas/enums.ts +++ b/src/schemas/enums.ts @@ -43,7 +43,6 @@ export enum DroidClientMethod { SESSION_NOTIFICATION = 'droid.session_notification', REQUEST_PERMISSION = 'droid.request_permission', ASK_USER = 'droid.ask_user', - EXECUTE_HOOKS = 'droid.execute_hooks', } /** Session notification types. */ diff --git a/src/schemas/hooks.ts b/src/schemas/hooks.ts index e85b4fe..00c808b 100644 --- a/src/schemas/hooks.ts +++ b/src/schemas/hooks.ts @@ -14,16 +14,6 @@ export const DroidHookEventSchema = z.enum([ export type DroidHookEvent = z.infer; -export const SdkHookRegistrationSchema = z - .object({ - eventName: DroidHookEventSchema, - matcher: z.string().optional(), - timeout: z.number().optional(), - }) - .strict(); - -export type SdkHookRegistration = z.infer; - export const DroidHookSpecificOutputSchema = z .object({ hookEventName: DroidHookEventSchema.optional(), @@ -57,24 +47,3 @@ export const DroidHookExecutionResultSchema = DroidHookOutputSchema.extend({ export type DroidHookExecutionResult = z.infer< typeof DroidHookExecutionResultSchema >; - -export const ExecuteHooksRequestParamsSchema = z - .object({ - eventName: DroidHookEventSchema, - input: z.record(z.unknown()), - matcher: z.string().optional(), - toolUseId: z.string().optional(), - }) - .strict(); - -export type ExecuteHooksRequestParams = z.infer< - typeof ExecuteHooksRequestParamsSchema ->; - -export const ExecuteHooksResultSchema = z - .object({ - results: z.array(DroidHookExecutionResultSchema), - }) - .strict(); - -export type ExecuteHooksResult = z.infer; diff --git a/src/schemas/index.ts b/src/schemas/index.ts index 97ff646..7b4cba2 100644 --- a/src/schemas/index.ts +++ b/src/schemas/index.ts @@ -104,17 +104,11 @@ export { DroidHookExecutionResultSchema, DroidHookOutputSchema, DroidHookSpecificOutputSchema, - ExecuteHooksRequestParamsSchema, - ExecuteHooksResultSchema, - SdkHookRegistrationSchema, } from './hooks.js'; export type { DroidHookEvent, DroidHookExecutionResult, DroidHookOutput, - ExecuteHooksRequestParams, - ExecuteHooksResult, - SdkHookRegistration, } from './hooks.js'; export { @@ -331,6 +325,7 @@ export { SessionSettingsSchema, SessionSourceSchema, SessionTagSchema, + SettingSourceSchema, SkillInfoSchema, SkillResourceSchema, SseMcpConfigSchema, @@ -464,6 +459,7 @@ export type { SessionSettings, SessionSource, SessionTag, + SettingSource, SkillInfo, SkillResource, SseMcpConfig, @@ -510,8 +506,6 @@ export { ErrorDetailSchema, ErrorNotificationSchema, ExecuteToolConfirmationDetailsSchema, - ExecuteHooksRequestSchema, - ExecuteHooksResponseSchema, ExitSpecModeConfirmationDetailsSchema, McpAuthCompletedNotificationSchema, McpAuthRequiredNotificationSchema, @@ -571,8 +565,6 @@ export type { ErrorDetail, ErrorNotification, ExecuteToolConfirmationDetails, - ExecuteHooksRequest, - ExecuteHooksResponse, ExitSpecModeConfirmationDetails, McpAuthCompletedNotification, McpAuthRequiredNotification, diff --git a/src/schemas/server.ts b/src/schemas/server.ts index 40adec4..204128a 100644 --- a/src/schemas/server.ts +++ b/src/schemas/server.ts @@ -15,10 +15,6 @@ import { ToolConfirmationOutcome, ToolConfirmationType, } from './enums.js'; -import { - ExecuteHooksRequestParamsSchema, - ExecuteHooksResultSchema, -} from './hooks.js'; import { McpServerStatusInfoSchema, McpStatusSummarySchema, @@ -784,28 +780,11 @@ export const AskUserResponseSchema = z.union([ export type AskUserResponse = z.infer; -/** Execute SDK hook callbacks request from the server (server → client). */ -export const ExecuteHooksRequestSchema = JsonRpcRequestSchema.extend({ - method: z.literal(DroidClientMethod.EXECUTE_HOOKS), - params: ExecuteHooksRequestParamsSchema, -}); - -export type ExecuteHooksRequest = z.infer; - -/** Response to droid.execute_hooks. */ -export const ExecuteHooksResponseSchema = z.union([ - JsonRpcResponseSuccessSchema.extend({ result: ExecuteHooksResultSchema }), - JsonRpcResponseFailureSchema, -]); - -export type ExecuteHooksResponse = z.infer; - /** Union over all server → client methods. */ const _CliRequestOrNotificationSchema = z.union([ SessionNotificationSchema, RequestPermissionRequestSchema, AskUserRequestSchema, - ExecuteHooksRequestSchema, ]); /* eslint-disable @typescript-eslint/consistent-type-assertions -- Zod workaround for deep type inference */ @@ -823,5 +802,4 @@ export const CliRequestOrNotificationSchema: z.ZodType< export type CliRequestOrNotification = | SessionNotification | RequestPermissionRequest - | AskUserRequest - | ExecuteHooksRequest; + | AskUserRequest; diff --git a/src/session.ts b/src/session.ts index 74e1f21..b3e998d 100644 --- a/src/session.ts +++ b/src/session.ts @@ -12,11 +12,6 @@ import type { SessionInitOptions, TransportCreationOptions, } from './helpers.js'; -import { - buildSdkHookRegistrations, - createHookRequestHandler, - type DroidHooks, -} from './hooks.js'; import { startSdkMcpServers } from './mcp.js'; import type { DroidMcpServerConfig } from './mcp.js'; import type { NotificationCallback, NotificationFilter } from './protocol.js'; @@ -44,6 +39,7 @@ import type { LoadSessionRequestParams, LoadSessionResult, OutputFormat, + SettingSource, RemoveMcpServerRequestParams, RemoveMcpServerResult, ToggleMcpServerRequestParams, @@ -66,7 +62,7 @@ export type DroidResult = DroidResultMessage; export interface CreateSessionOptions extends SessionInitOptions, HandlerOptions, TransportCreationOptions { abortSignal?: AbortSignal; - hooks?: DroidHooks; + settingSources?: SettingSource[]; } export interface ResumeSessionOptions extends Pick< @@ -79,7 +75,7 @@ export interface ResumeSessionOptions extends Pick< | 'askUserHandler' | 'transport' | 'abortSignal' - | 'hooks' + | 'settingSources' > { mcpServers?: DroidMcpServerConfig[]; } @@ -400,23 +396,18 @@ export async function createSession( try { sdkMcpServers = await startSdkMcpServers(options.mcpServers); - if (options.hooks) { - client.setHookHandler(createHookRequestHandler(options.hooks)); - } const initParams = buildInitParams({ ...options, mcpServers: sdkMcpServers.mcpServers, - sdkHooks: buildSdkHookRegistrations(options.hooks), }); const initResult = await client.initializeSession(initParams); const session = new DroidSession( client, initResult.sessionId, initResult, - (options.hooks?.SessionEnd?.length ?? 0) > 0 + options.settingSources !== undefined ); session.addCleanup(sdkMcpServers.cleanup); - session.addCleanup(() => client.clearHookHandler()); cleanupInitAbortSignal(); cleanupInitAbortSignal = () => {}; session.setAbortSignalCleanup( @@ -442,23 +433,21 @@ export async function resumeSession( try { sdkMcpServers = await startSdkMcpServers(options.mcpServers); - if (options.hooks) { - client.setHookHandler(createHookRequestHandler(options.hooks)); - } const loadParams: LoadSessionRequestParams = { sessionId, mcpServers: sdkMcpServers.mcpServers, - sdkHooks: buildSdkHookRegistrations(options.hooks), + ...(options.settingSources !== undefined && { + settingSources: options.settingSources, + }), }; const loadResult = await client.loadSession(loadParams); const session = new DroidSession( client, sessionId, loadResult, - (options.hooks?.SessionEnd?.length ?? 0) > 0 + options.settingSources !== undefined ); session.addCleanup(sdkMcpServers.cleanup); - session.addCleanup(() => client.clearHookHandler()); session.setAbortSignalCleanup( wireAbortSignal(options.abortSignal, () => void session.close()) ); diff --git a/tests/helpers.test.ts b/tests/helpers.test.ts index 4b8f551..8da01bf 100644 --- a/tests/helpers.test.ts +++ b/tests/helpers.test.ts @@ -25,6 +25,7 @@ import { JSONRPC_VERSION, LEGACY_FACTORY_API_VERSION, SessionNotificationType, + SettingsLevel, ToolConfirmationOutcome, } from '../src/schemas/index.js'; import { InMemoryTransport } from './helpers.js'; @@ -336,7 +337,7 @@ describe('buildInitParams', () => { specModeReasoningEffort: 'max' as never, enabledToolIds: ['Read', 'Grep'], disabledToolIds: ['Execute'], - sdkHooks: [{ eventName: 'PreToolUse', matcher: 'Execute', timeout: 30 }], + settingSources: [SettingsLevel.Project, SettingsLevel.User], mcpServers: [ { name: 'test-server', @@ -357,8 +358,9 @@ describe('buildInitParams', () => { expect(params.specModeReasoningEffort).toBe('max'); expect(params.enabledToolIds).toEqual(['Read', 'Grep']); expect(params.disabledToolIds).toEqual(['Execute']); - expect(params.sdkHooks).toEqual([ - { eventName: 'PreToolUse', matcher: 'Execute', timeout: 30 }, + expect(params.settingSources).toEqual([ + SettingsLevel.Project, + SettingsLevel.User, ]); expect(params.mcpServers).toHaveLength(1); }); @@ -390,7 +392,7 @@ describe('buildInitParams', () => { expect(params).not.toHaveProperty('specModeModelId'); expect(params).not.toHaveProperty('specModeReasoningEffort'); expect(params).not.toHaveProperty('mcpServers'); - expect(params).not.toHaveProperty('sdkHooks'); + expect(params).not.toHaveProperty('settingSources'); expect(params).not.toHaveProperty('enabledToolIds'); expect(params).not.toHaveProperty('disabledToolIds'); }); diff --git a/tests/hooks.test.ts b/tests/hooks.test.ts index 4279c06..d82fcae 100644 --- a/tests/hooks.test.ts +++ b/tests/hooks.test.ts @@ -1,203 +1,52 @@ -import { describe, expect, it, vi } from 'vitest'; +import { describe, expect, it } from 'vitest'; -import { - buildSdkHookRegistrations, - createHookRequestHandler, - matchesHookMatcher, - type DroidHooks, - type DroidHookOutput, -} from '../src/hooks.js'; -import { ExecuteHooksResultSchema } from '../src/schemas/hooks.js'; +import type { ToolHookInput } from '../src/hooks.js'; +import { DroidHookOutputSchema } from '../src/schemas/hooks.js'; -describe('SDK hooks', () => { - it('builds serializable hook registrations without callbacks', () => { - const hooks: DroidHooks = { - PreToolUse: [ - { - matcher: 'Execute', - timeout: 30, - hooks: [() => ({})], - }, - ], - Stop: [ - { - hooks: [() => ({})], - }, - ], - }; - - expect(buildSdkHookRegistrations(hooks)).toEqual([ - { eventName: 'PreToolUse', matcher: 'Execute', timeout: 30 }, - { eventName: 'Stop' }, - ]); - }); - - it('matches Droid CLI hook matcher semantics', () => { - expect(matchesHookMatcher(undefined, 'Execute')).toBe(true); - expect(matchesHookMatcher('', 'Execute')).toBe(true); - expect(matchesHookMatcher('*', 'Execute')).toBe(true); - expect(matchesHookMatcher('Execute', 'Execute')).toBe(true); - expect(matchesHookMatcher('Edit|Create', 'Create')).toBe(true); - expect(matchesHookMatcher('Read', 'Execute')).toBe(false); - expect(matchesHookMatcher('[', 'Execute')).toBe(false); - }); - - it('runs matching hook callbacks and converts output to execution results', async () => { - const callback = vi.fn(() => ({ +describe('hook types and schemas', () => { + it('accepts minimal hookSpecificOutput without hookEventName', () => { + const result = DroidHookOutputSchema.parse({ hookSpecificOutput: { - hookEventName: 'PreToolUse' as const, - permissionDecision: 'deny' as const, - permissionDecisionReason: 'blocked', - }, - })); - const handler = createHookRequestHandler({ - PreToolUse: [{ matcher: 'Execute', hooks: [callback] }], - }); - - const result = await handler({ - eventName: 'PreToolUse', - matcher: 'Execute', - toolUseId: 'tool-1', - input: { - hook_event_name: 'PreToolUse', - session_id: 's1', - transcript_path: '', - cwd: '.', - permission_mode: 'off', - tool_name: 'Execute', - tool_input: { command: 'npm test' }, - }, - }); - - expect(callback).toHaveBeenCalledWith( - expect.objectContaining({ tool_name: 'Execute' }), - 'tool-1', - { signal: expect.any(AbortSignal) } - ); - expect(result.results).toEqual([ - { - exitCode: 0, - stdout: '', - stderr: '', - hookSpecificOutput: { - hookEventName: 'PreToolUse', - permissionDecision: 'deny', - permissionDecisionReason: 'blocked', - }, - }, - ]); - }); - - it('accepts minimal hookSpecificOutput without hookEventName', async () => { - const handler = createHookRequestHandler({ - PreToolUse: [ - { - matcher: 'Execute', - hooks: [ - () => ({ - hookSpecificOutput: { - permissionDecision: 'deny' as const, - }, - }), - ], - }, - ], - }); - - const result = await handler({ - eventName: 'PreToolUse', - matcher: 'Execute', - input: { - hook_event_name: 'PreToolUse', - session_id: 's1', - transcript_path: '', - cwd: '.', - permission_mode: 'off', - tool_name: 'Execute', - tool_input: { command: 'npm test' }, + permissionDecision: 'deny', }, }); - expect(() => ExecuteHooksResultSchema.parse(result)).not.toThrow(); - expect(result.results[0]?.hookSpecificOutput).toEqual({ + expect(result.hookSpecificOutput).toEqual({ permissionDecision: 'deny', }); }); - it('converts thrown callback errors to non-throwing hook failures', async () => { - const handler = createHookRequestHandler({ - Stop: [ - { - hooks: [ - () => { - throw new Error('boom'); - }, - ], - }, - ], - }); - - const result = await handler({ - eventName: 'Stop', - input: { - hook_event_name: 'Stop', - session_id: 's1', - transcript_path: '', - cwd: '.', - permission_mode: 'off', - stop_hook_active: false, + it('preserves hook output fields used by file hooks', () => { + const result = DroidHookOutputSchema.parse({ + continue: false, + stopReason: 'blocked', + systemMessage: 'context', + decision: 'block', + reason: 'policy', + hookSpecificOutput: { + permissionDecision: 'deny', + permissionDecisionReason: 'unsafe', + additionalContext: 'extra', + updatedInput: { command: 'npm test' }, }, }); - expect(result.results).toEqual([ - { exitCode: 1, stdout: '', stderr: 'boom' }, - ]); + expect(result.hookSpecificOutput?.updatedInput).toEqual({ + command: 'npm test', + }); }); - it('aborts callbacks after the configured timeout', async () => { - vi.useFakeTimers(); - try { - let aborted = false; - const callback = vi.fn( - (_input, _toolUseId, { signal }: { signal: AbortSignal }) => - new Promise(() => { - signal.addEventListener('abort', () => { - aborted = true; - }); - }) - ); - const handler = createHookRequestHandler({ - PreCompact: [{ timeout: 0.01, hooks: [callback] }], - }); - - const promise = handler({ - eventName: 'PreCompact', - input: { - hook_event_name: 'PreCompact', - session_id: 's1', - transcript_path: '', - cwd: '.', - permission_mode: 'off', - trigger: 'manual', - message_count: 1, - estimated_tokens: 1, - }, - }); - - await vi.advanceTimersByTimeAsync(10); + it('exports hook input types for command hook authors', () => { + const input: ToolHookInput = { + hook_event_name: 'PreToolUse', + session_id: 's1', + transcript_path: '', + cwd: '.', + permission_mode: 'off', + tool_name: 'Execute', + tool_input: { command: 'npm test' }, + }; - expect(aborted).toBe(true); - expect(await promise).toEqual({ - results: [ - { - exitCode: 1, - stdout: '', - stderr: 'Hook callback timed out after 0.01 seconds', - }, - ], - }); - } finally { - vi.useRealTimers(); - } + expect(input.tool_name).toBe('Execute'); }); }); diff --git a/tests/protocol.test.ts b/tests/protocol.test.ts index 64365b3..3f2f97a 100644 --- a/tests/protocol.test.ts +++ b/tests/protocol.test.ts @@ -506,111 +506,6 @@ describe('ProtocolEngine', () => { }); }); }); - - describe('hook requests', () => { - it('invokes registered hook handler and sends response', async () => { - engine.setHookHandler((params) => ({ - results: [ - { - exitCode: 0, - stdout: '', - stderr: '', - systemMessage: `handled ${params.eventName}`, - }, - ], - })); - - transport.injectMessage( - makeServerRequest('hook-1', DroidClientMethod.EXECUTE_HOOKS, { - eventName: 'PreToolUse', - input: { - hook_event_name: 'PreToolUse', - session_id: 's1', - transcript_path: '', - cwd: '.', - permission_mode: 'off', - tool_name: 'Execute', - tool_input: { command: 'npm test' }, - }, - matcher: 'Execute', - toolUseId: 'tool-1', - }) - ); - - await vi.waitFor(() => { - expect(transport.sentMessages).toHaveLength(1); - }); - - const response = transport.sentMessages[0] as Record; - expect(response['type']).toBe('response'); - expect(response['id']).toBe('hook-1'); - expect(response['result']).toEqual({ - results: [ - { - exitCode: 0, - stdout: '', - stderr: '', - systemMessage: 'handled PreToolUse', - }, - ], - }); - }); - - it('returns empty hook results when no hook handler is registered', async () => { - transport.injectMessage( - makeServerRequest('hook-2', DroidClientMethod.EXECUTE_HOOKS, { - eventName: 'SessionEnd', - input: { - hook_event_name: 'SessionEnd', - session_id: 's1', - transcript_path: '', - cwd: '.', - permission_mode: 'off', - reason: 'other', - session_duration_ms: 10, - message_count: 1, - }, - }) - ); - - await vi.waitFor(() => { - expect(transport.sentMessages).toHaveLength(1); - }); - - const response = transport.sentMessages[0] as Record; - expect(response['result']).toEqual({ results: [] }); - }); - - it('sends error response when hook handler throws', async () => { - engine.setHookHandler(() => { - throw new Error('hook failure'); - }); - - transport.injectMessage( - makeServerRequest('hook-3', DroidClientMethod.EXECUTE_HOOKS, { - eventName: 'Stop', - input: { - hook_event_name: 'Stop', - session_id: 's1', - transcript_path: '', - cwd: '.', - permission_mode: 'off', - stop_hook_active: false, - }, - }) - ); - - await vi.waitFor(() => { - expect(transport.sentMessages).toHaveLength(1); - }); - - const response = transport.sentMessages[0] as Record; - expect(response['error']).toBeDefined(); - const error = response['error'] as Record; - expect(error['code']).toBe(JsonRpcErrorCode.INTERNAL_ERROR); - expect(error['message']).toBe('Failed to handle hook request'); - }); - }); }); describe('sticky transport error (VAL-PROTOCOL-005)', () => { diff --git a/tests/session.test.ts b/tests/session.test.ts index 16b6e1c..1d46a5a 100644 --- a/tests/session.test.ts +++ b/tests/session.test.ts @@ -54,6 +54,10 @@ function setupInitResponder( }) ); }); + } else if (method === DroidServerMethod.CLOSE_SESSION) { + queueMicrotask(() => { + transport.injectMessage(makeSuccessResponse(id, {})); + }); } }); } @@ -86,6 +90,10 @@ function setupLoadResponder( }) ); }); + } else if (method === DroidServerMethod.CLOSE_SESSION) { + queueMicrotask(() => { + transport.injectMessage(makeSuccessResponse(id, {})); + }); } }); } @@ -136,6 +144,10 @@ function setupFullResponder( queueMicrotask(() => { transport.injectMessage(makeSuccessResponse(id, {})); }); + } else if (method === DroidServerMethod.CLOSE_SESSION) { + queueMicrotask(() => { + transport.injectMessage(makeSuccessResponse(id, {})); + }); } else if (method === DroidServerMethod.UPDATE_SESSION_SETTINGS) { queueMicrotask(() => { transport.injectMessage(makeSuccessResponse(id, {})); @@ -593,7 +605,7 @@ describe('DroidSession', () => { await session.close(); }); - it('requests graceful close when a SessionEnd SDK hook is registered', async () => { + it('requests graceful close so file hooks can receive SessionEnd', async () => { const transport = new InMemoryTransport(); await transport.connect(); @@ -607,9 +619,7 @@ describe('DroidSession', () => { const session = await createSession({ transport, - hooks: { - SessionEnd: [{ hooks: [() => ({})] }], - }, + settingSources: [SettingsLevel.Project], }); await session.close(); From ed80304211f1051c9123bf04b5ab21e00056e81d Mon Sep 17 00:00:00 2001 From: User Date: Thu, 14 May 2026 18:54:06 -0700 Subject: [PATCH 4/8] fix: run session end hooks on close Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- src/session.ts | 25 ++++--------------------- tests/run.test.ts | 24 ++++++++++++++++++++++++ tests/session.test.ts | 20 ++++++++++++++++++++ 3 files changed, 48 insertions(+), 21 deletions(-) diff --git a/src/session.ts b/src/session.ts index b3e998d..db1cb8e 100644 --- a/src/session.ts +++ b/src/session.ts @@ -39,7 +39,6 @@ import type { LoadSessionRequestParams, LoadSessionResult, OutputFormat, - SettingSource, RemoveMcpServerRequestParams, RemoveMcpServerResult, ToggleMcpServerRequestParams, @@ -62,7 +61,6 @@ export type DroidResult = DroidResultMessage; export interface CreateSessionOptions extends SessionInitOptions, HandlerOptions, TransportCreationOptions { abortSignal?: AbortSignal; - settingSources?: SettingSource[]; } export interface ResumeSessionOptions extends Pick< @@ -144,19 +142,16 @@ export class DroidSession { private _closed = false; private _cleanupAbortSignal: (() => void) | null = null; private _cleanupCallbacks: Array<() => Promise | void> = []; - private readonly _runSessionEndOnClose: boolean; /** @internal */ constructor( client: DroidClient, sessionId: string, - initResult: InitializeSessionResult | LoadSessionResult, - runSessionEndOnClose = false + initResult: InitializeSessionResult | LoadSessionResult ) { this._client = client; this._sessionId = sessionId; this._initResult = initResult; - this._runSessionEndOnClose = runSessionEndOnClose; } get sessionId(): string { @@ -249,9 +244,7 @@ export class DroidSession { this._cleanupAbortSignal = null; try { - if (this._runSessionEndOnClose) { - await this._client.closeSession({ reason: 'other' }).catch(() => {}); - } + await this._client.closeSession({ reason: 'other' }).catch(() => {}); await this._client.close(); } finally { const cleanups = this._cleanupCallbacks.splice(0); @@ -401,12 +394,7 @@ export async function createSession( mcpServers: sdkMcpServers.mcpServers, }); const initResult = await client.initializeSession(initParams); - const session = new DroidSession( - client, - initResult.sessionId, - initResult, - options.settingSources !== undefined - ); + const session = new DroidSession(client, initResult.sessionId, initResult); session.addCleanup(sdkMcpServers.cleanup); cleanupInitAbortSignal(); cleanupInitAbortSignal = () => {}; @@ -441,12 +429,7 @@ export async function resumeSession( }), }; const loadResult = await client.loadSession(loadParams); - const session = new DroidSession( - client, - sessionId, - loadResult, - options.settingSources !== undefined - ); + const session = new DroidSession(client, sessionId, loadResult); session.addCleanup(sdkMcpServers.cleanup); session.setAbortSignalCleanup( wireAbortSignal(options.abortSignal, () => void session.close()) diff --git a/tests/run.test.ts b/tests/run.test.ts index f2a236c..06e1198 100644 --- a/tests/run.test.ts +++ b/tests/run.test.ts @@ -45,6 +45,10 @@ function setupRunResponder( tokenUsageSessionId: sessionId, }); }); + } else if (method === DroidServerMethod.CLOSE_SESSION) { + queueMicrotask(() => { + transport.injectMessage(makeSuccessResponse(id, {})); + }); } }); } @@ -136,6 +140,10 @@ describe('run()', () => { queueMicrotask(() => { transport.injectMessage(makeErrorResponse(id, -32603, 'send failed')); }); + } else if (method === DroidServerMethod.CLOSE_SESSION) { + queueMicrotask(() => { + transport.injectMessage(makeSuccessResponse(id, {})); + }); } }); @@ -183,6 +191,10 @@ describe('run()', () => { ) ); }); + } else if (method === DroidServerMethod.CLOSE_SESSION) { + queueMicrotask(() => { + transport.injectMessage(makeSuccessResponse(id, {})); + }); } }); @@ -246,6 +258,10 @@ describe('run()', () => { ) ); }); + } else if (method === DroidServerMethod.CLOSE_SESSION) { + queueMicrotask(() => { + transport.injectMessage(makeSuccessResponse(id, {})); + }); } }); @@ -336,6 +352,10 @@ describe('run()', () => { ) ); }); + } else if (method === DroidServerMethod.CLOSE_SESSION) { + queueMicrotask(() => { + transport.injectMessage(makeSuccessResponse(id, {})); + }); } }); @@ -400,6 +420,10 @@ describe('run()', () => { ) ); }); + } else if (method === DroidServerMethod.CLOSE_SESSION) { + queueMicrotask(() => { + transport.injectMessage(makeSuccessResponse(id, {})); + }); } }); diff --git a/tests/session.test.ts b/tests/session.test.ts index 1d46a5a..a5c3117 100644 --- a/tests/session.test.ts +++ b/tests/session.test.ts @@ -389,6 +389,10 @@ describe('DroidSession', () => { transport.injectMessage(makeSuccessResponse(id, {})); sendDefaultStreamSequence(transport); }); + } else if (method === DroidServerMethod.CLOSE_SESSION) { + queueMicrotask(() => { + transport.injectMessage(makeSuccessResponse(id, {})); + }); } }); @@ -461,6 +465,10 @@ describe('DroidSession', () => { structuredOutput: { name: 'Ada' }, }); }); + } else if (method === DroidServerMethod.CLOSE_SESSION) { + queueMicrotask(() => { + transport.injectMessage(makeSuccessResponse(id, {})); + }); } }); @@ -516,6 +524,10 @@ describe('DroidSession', () => { }, }); }); + } else if (method === DroidServerMethod.CLOSE_SESSION) { + queueMicrotask(() => { + transport.injectMessage(makeSuccessResponse(id, {})); + }); } }); @@ -1187,6 +1199,10 @@ describe('DroidSession', () => { ) ); }); + } else if (method === DroidServerMethod.CLOSE_SESSION) { + queueMicrotask(() => { + transport.injectMessage(makeSuccessResponse(id, {})); + }); } }; @@ -1355,6 +1371,10 @@ describe('DroidSession', () => { ) ); }); + } else if (method === DroidServerMethod.CLOSE_SESSION) { + queueMicrotask(() => { + transport.injectMessage(makeSuccessResponse(id, {})); + }); } }; From 216231d9e6213b549b0a9b1ebdfb00d91a23a198 Mon Sep 17 00:00:00 2001 From: User Date: Fri, 15 May 2026 12:48:16 -0700 Subject: [PATCH 5/8] feat: add support for SDK hook execution notifications - Added HOOK_EXECUTION_STARTED and HOOK_EXECUTION_COMPLETED notification types - Implemented Zod schemas for hook commands and results - Updated DroidStreamMessage and DroidMessageType to include 'hook' - Implemented conversion from hook notifications to stream messages - Added tests for hook message structures and conversion - Added runnable example demonstrating hook handling Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- examples/hook-execution.ts | 58 +++++++++++++++++ src/index.ts | 1 + src/schemas/enums.ts | 2 + src/schemas/index.ts | 8 +++ src/schemas/server.ts | 60 +++++++++++++++++ src/stream.ts | 47 +++++++++++++- tests/schemas.test.ts | 4 +- tests/stream.test.ts | 129 ++++++++++++++++++++++++++++++++++++- 8 files changed, 306 insertions(+), 3 deletions(-) create mode 100644 examples/hook-execution.ts diff --git a/examples/hook-execution.ts b/examples/hook-execution.ts new file mode 100644 index 0000000..a082de9 --- /dev/null +++ b/examples/hook-execution.ts @@ -0,0 +1,58 @@ +/** + * Hook execution streaming example. + * + * Demonstrates how to handle hook execution messages in a session stream. + * + * Usage: + * npx tsx examples/hook-execution.ts + */ + +import { DroidMessageType, createSession } from '../src/index.js'; + +async function main(): Promise { + const prompt = 'Run a simple shell command using Execute tool.'; + + console.log(`Sending prompt: "${prompt}"\n`); + + // Note: To actually see hooks, you need to have hooks configured in your droid settings. + const session = await createSession({ cwd: process.cwd() }); + + try { + for await (const msg of session.stream(prompt)) { + switch (msg.type) { + case DroidMessageType.Assistant: + process.stdout.write(msg.text); + break; + + case DroidMessageType.ToolCall: + console.log(`\n[Tool Call] ${msg.toolUse.name} (${msg.toolUse.id})`); + break; + + case DroidMessageType.Hook: + if (msg.status === 'started') { + console.log(` [Hook Started] ID: ${msg.hookId}, Event: ${msg.eventName}, Command: ${msg.command}`); + } else { + console.log(` [Hook ${msg.status}] ID: ${msg.hookId}, Exit Code: ${msg.exitCode}`); + if (msg.stdout) console.log(` stdout: ${msg.stdout.trim()}`); + if (msg.stderr) console.log(` stderr: ${msg.stderr.trim()}`); + } + break; + + case DroidMessageType.ToolResult: + console.log(`[Tool Result] ${msg.isError ? 'Error' : 'OK'}`); + break; + + case DroidMessageType.Result: + console.log('\n\n--- Turn complete ---'); + break; + } + } + } finally { + await session.close(); + } +} + +main().catch((err: unknown) => { + console.error('Error:', err); + process.exit(1); +}); diff --git a/src/index.ts b/src/index.ts index c1eccce..acf2ae4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -75,6 +75,7 @@ export type { MissionWorkerCompleted, McpAuthRequired, McpAuthCompleted, + HookExecution, StructuredOutput, StructuredOutputFields, ErrorEvent, diff --git a/src/schemas/enums.ts b/src/schemas/enums.ts index 0aa0c8a..c2dd815 100644 --- a/src/schemas/enums.ts +++ b/src/schemas/enums.ts @@ -70,6 +70,8 @@ export enum SessionNotificationType { MISSION_WORKER_COMPLETED = 'mission_worker_completed', MCP_AUTH_REQUIRED = 'mcp_auth_required', MCP_AUTH_COMPLETED = 'mcp_auth_completed', + HOOK_EXECUTION_STARTED = 'hook_execution_started', + HOOK_EXECUTION_COMPLETED = 'hook_execution_completed', STRUCTURED_OUTPUT = 'structured_output', } diff --git a/src/schemas/index.ts b/src/schemas/index.ts index 7b4cba2..4c4e753 100644 --- a/src/schemas/index.ts +++ b/src/schemas/index.ts @@ -507,6 +507,10 @@ export { ErrorNotificationSchema, ExecuteToolConfirmationDetailsSchema, ExitSpecModeConfirmationDetailsSchema, + HookCommandSchema, + HookExecutionCompletedNotificationSchema, + HookExecutionStartedNotificationSchema, + HookResultSchema, McpAuthCompletedNotificationSchema, McpAuthRequiredNotificationSchema, McpStatusChangedNotificationSchema, @@ -566,6 +570,10 @@ export type { ErrorNotification, ExecuteToolConfirmationDetails, ExitSpecModeConfirmationDetails, + HookCommand, + HookExecutionCompletedNotification, + HookExecutionStartedNotification, + HookResult, McpAuthCompletedNotification, McpAuthRequiredNotification, McpStatusChangedNotification, diff --git a/src/schemas/server.ts b/src/schemas/server.ts index 204128a..484a71f 100644 --- a/src/schemas/server.ts +++ b/src/schemas/server.ts @@ -398,6 +398,64 @@ export type McpAuthCompletedNotification = z.infer< typeof McpAuthCompletedNotificationSchema >; +/** Hook command metadata included in hook execution notifications. */ +export const HookCommandSchema = z + .object({ + command: z.string(), + timeout: z.number().optional(), + }) + .passthrough(); + +export type HookCommand = z.infer; + +/** Hook execution result included in hook completion notifications. */ +export const HookResultSchema = z + .object({ + exitCode: z.number(), + stdout: z.string(), + stderr: z.string(), + command: z.string().optional(), + timeout: z.number().optional(), + }) + .passthrough(); + +export type HookResult = z.infer; + +/** Hook execution started notification. */ +export const HookExecutionStartedNotificationSchema = z + .object({ + type: z.literal(SessionNotificationType.HOOK_EXECUTION_STARTED), + hookId: z.string(), + hookEventName: z.string(), + hookMatcher: z.string().optional(), + hookCommands: z.array(HookCommandSchema), + hookToolCallId: z.string().optional(), + isParallelExecution: z.boolean().optional(), + parallelGroupId: z.string().optional(), + }) + .passthrough(); + +export type HookExecutionStartedNotification = z.infer< + typeof HookExecutionStartedNotificationSchema +>; + +/** Hook execution completed notification. */ +export const HookExecutionCompletedNotificationSchema = z + .object({ + type: z.literal(SessionNotificationType.HOOK_EXECUTION_COMPLETED), + hookId: z.string(), + hookEventName: z.string().optional(), + hookMatcher: z.string().optional(), + hookToolCallId: z.string().optional(), + hookStatus: z.enum(['completed', 'error']), + hookResults: z.array(HookResultSchema).optional(), + }) + .passthrough(); + +export type HookExecutionCompletedNotification = z.infer< + typeof HookExecutionCompletedNotificationSchema +>; + /** Structured output validation error emitted by Droid. */ export const StructuredOutputErrorSchema = z .object({ @@ -448,6 +506,8 @@ export const SessionNotificationSchemaList = [ MissionWorkerCompletedNotificationSchema, McpAuthRequiredNotificationSchema, McpAuthCompletedNotificationSchema, + HookExecutionStartedNotificationSchema, + HookExecutionCompletedNotificationSchema, StructuredOutputNotificationSchema, ] as const; diff --git a/src/stream.ts b/src/stream.ts index eecd3a4..99a36ba 100644 --- a/src/stream.ts +++ b/src/stream.ts @@ -53,6 +53,7 @@ export const DroidMessageType = { MissionWorkerCompleted: 'mission_worker_completed', McpAuthRequired: 'mcp_auth_required', McpAuthCompleted: 'mcp_auth_completed', + Hook: 'hook', Error: 'error', Result: 'result', } as const; @@ -216,6 +217,20 @@ export interface McpAuthCompleted { readonly message: string; } +export interface HookExecution { + readonly type: 'hook'; + readonly hookId: string; + readonly eventName?: string; + readonly matcher?: string; + readonly toolCallId?: string; + readonly command?: string; + readonly timeout?: number; + readonly status: 'started' | 'completed' | 'error'; + readonly exitCode?: number; + readonly stdout?: string; + readonly stderr?: string; +} + export interface StructuredOutputFields { readonly structuredOutput: JsonObject | null; readonly structuredOutputError: ServerStructuredOutputError | null; @@ -282,6 +297,7 @@ export type DroidStreamMessage = | DroidUserMessage | DroidToolCallMessage | ToolResult + | HookExecution | ErrorEvent | DroidResultMessage; @@ -306,7 +322,8 @@ export type DroidStreamEvent = | MissionWorkerStarted | MissionWorkerCompleted | McpAuthRequired - | McpAuthCompleted; + | McpAuthCompleted + | HookExecution; export type InternalDroidMessage = | DroidStreamEvent @@ -532,6 +549,33 @@ export function convertNotificationToStreamMessage( message: notification.message, }; + case SessionNotificationType.HOOK_EXECUTION_STARTED: + return notification.hookCommands.map((hookCommand) => ({ + type: DroidMessageType.Hook, + hookId: notification.hookId, + eventName: notification.hookEventName, + matcher: notification.hookMatcher, + toolCallId: notification.hookToolCallId, + command: hookCommand.command, + timeout: hookCommand.timeout, + status: 'started', + })); + + case SessionNotificationType.HOOK_EXECUTION_COMPLETED: + return (notification.hookResults ?? []).map((hookResult) => ({ + type: DroidMessageType.Hook, + hookId: notification.hookId, + eventName: notification.hookEventName, + matcher: notification.hookMatcher, + toolCallId: notification.hookToolCallId, + command: hookResult.command, + timeout: hookResult.timeout, + status: notification.hookStatus, + exitCode: hookResult.exitCode, + stdout: hookResult.stdout, + stderr: hookResult.stderr, + })); + case SessionNotificationType.STRUCTURED_OUTPUT: return { type: 'structured_output', @@ -755,6 +799,7 @@ export function isDefaultStreamMessage( message.type === DroidMessageType.User || message.type === DroidMessageType.ToolCall || message.type === DroidMessageType.ToolResult || + message.type === DroidMessageType.Hook || message.type === DroidMessageType.Error || message.type === DroidMessageType.Result ); diff --git a/tests/schemas.test.ts b/tests/schemas.test.ts index dff5272..dc71353 100644 --- a/tests/schemas.test.ts +++ b/tests/schemas.test.ts @@ -176,7 +176,7 @@ describe('enums', () => { expect(DroidClientMethod.ASK_USER).toBe('droid.ask_user'); }); - it('SessionNotificationType has 24 types', () => { + it('SessionNotificationType has 26 types', () => { const values = Object.values(SessionNotificationType); const expectedValues = [ 'assistant_text_delta', @@ -202,6 +202,8 @@ describe('enums', () => { 'mission_worker_completed', 'mcp_auth_required', 'mcp_auth_completed', + 'hook_execution_started', + 'hook_execution_completed', 'structured_output', ]; expect(values).toHaveLength(expectedValues.length); diff --git a/tests/stream.test.ts b/tests/stream.test.ts index 3712adb..bd4c5ee 100644 --- a/tests/stream.test.ts +++ b/tests/stream.test.ts @@ -37,6 +37,7 @@ import type { MissionWorkerCompleted, McpAuthRequired, McpAuthCompleted, + HookExecution, ErrorEvent, DroidMessage, DroidResultMessage, @@ -72,6 +73,7 @@ const expectedDroidMessageTypes = [ 'mission_worker_completed', 'mcp_auth_required', 'mcp_auth_completed', + 'hook', 'error', 'result', ] as const satisfies readonly DroidMessage['type'][]; @@ -329,6 +331,20 @@ describe('DroidMessage types', () => { expect(msg.outcome).toBe(McpAuthOutcome.Success); }); + it('HookExecution has correct structure', () => { + const msg: HookExecution = { + type: 'hook', + hookId: 'hook-1', + eventName: 'PreToolUse', + matcher: 'Execute', + toolCallId: 'tool-1', + command: 'echo hook', + status: 'started', + }; + expect(msg.type).toBe('hook'); + expect(msg.command).toBe('echo hook'); + }); + it('ErrorEvent has correct structure', () => { const msg: ErrorEvent = { type: 'error', @@ -370,7 +386,7 @@ describe('DroidMessage types', () => { expect(msg.tokenUsage!.inputTokens).toBe(100); }); - it('DroidMessage union type allows all 23 types', () => { + it('DroidMessage union type allows all message types', () => { const messages: DroidMessage[] = [ { type: 'assistant', @@ -488,6 +504,19 @@ describe('DroidMessage types', () => { outcome: McpAuthOutcome.Success, message: 'm', }, + { + type: 'hook', + hookId: 'hook-1', + eventName: 'PreToolUse', + matcher: 'Execute', + toolCallId: 'tool-1', + command: 'echo hook', + timeout: 5, + status: 'completed', + exitCode: 0, + stdout: 'ok', + stderr: '', + }, { type: 'error', message: 'err', @@ -1110,6 +1139,91 @@ describe('convertNotificationToStreamMessage', () => { }); }); + describe('hook execution', () => { + it('converts each started hook command to a HookExecution message', () => { + const notification = makeNotification( + SessionNotificationType.HOOK_EXECUTION_STARTED, + { + hookId: 'hook-1', + hookEventName: 'PreToolUse', + hookMatcher: 'Execute', + hookToolCallId: 'tool-1', + hookCommands: [ + { command: 'echo one', timeout: 5 }, + { command: 'echo two' }, + ], + } + ); + const result = convertNotificationToStreamMessage( + notification + ) as HookExecution[]; + + expect(result).toEqual([ + { + type: 'hook', + hookId: 'hook-1', + eventName: 'PreToolUse', + matcher: 'Execute', + toolCallId: 'tool-1', + command: 'echo one', + timeout: 5, + status: 'started', + }, + { + type: 'hook', + hookId: 'hook-1', + eventName: 'PreToolUse', + matcher: 'Execute', + toolCallId: 'tool-1', + command: 'echo two', + timeout: undefined, + status: 'started', + }, + ]); + }); + + it('converts each completed hook result to a HookExecution message', () => { + const notification = makeNotification( + SessionNotificationType.HOOK_EXECUTION_COMPLETED, + { + hookId: 'hook-1', + hookEventName: 'PreToolUse', + hookMatcher: 'Execute', + hookToolCallId: 'tool-1', + hookStatus: 'completed', + hookResults: [ + { + command: 'echo one', + timeout: 5, + exitCode: 0, + stdout: 'one\n', + stderr: '', + }, + ], + } + ); + const result = convertNotificationToStreamMessage( + notification + ) as HookExecution[]; + + expect(result).toEqual([ + { + type: 'hook', + hookId: 'hook-1', + eventName: 'PreToolUse', + matcher: 'Execute', + toolCallId: 'tool-1', + command: 'echo one', + timeout: 5, + status: 'completed', + exitCode: 0, + stdout: 'one\n', + stderr: '', + }, + ]); + }); + }); + describe('structured_output', () => { it('converts successful structured output', () => { const notification = makeNotification( @@ -1289,6 +1403,19 @@ describe('convertNotificationToStreamMessage', () => { outcome: McpAuthOutcome.Success, message: 'm', }, + [SessionNotificationType.HOOK_EXECUTION_STARTED]: { + hookId: 'hook-1', + hookEventName: 'PreToolUse', + hookCommands: [{ command: 'echo hook' }], + }, + [SessionNotificationType.HOOK_EXECUTION_COMPLETED]: { + hookId: 'hook-1', + hookEventName: 'PreToolUse', + hookStatus: 'completed', + hookResults: [ + { command: 'echo hook', exitCode: 0, stdout: '', stderr: '' }, + ], + }, [SessionNotificationType.STRUCTURED_OUTPUT]: { messageId: 'm', structuredOutput: { name: 'Ada' }, From 17094749e9015f329f372341942ac032f0388ffd Mon Sep 17 00:00:00 2001 From: User Date: Fri, 15 May 2026 13:55:51 -0700 Subject: [PATCH 6/8] refactor: tighten hook stream typing Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- src/schemas/server.ts | 5 +++-- src/stream.ts | 3 ++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/schemas/server.ts b/src/schemas/server.ts index 484a71f..e4afabe 100644 --- a/src/schemas/server.ts +++ b/src/schemas/server.ts @@ -15,6 +15,7 @@ import { ToolConfirmationOutcome, ToolConfirmationType, } from './enums.js'; +import { DroidHookEventSchema } from './hooks.js'; import { McpServerStatusInfoSchema, McpStatusSummarySchema, @@ -426,7 +427,7 @@ export const HookExecutionStartedNotificationSchema = z .object({ type: z.literal(SessionNotificationType.HOOK_EXECUTION_STARTED), hookId: z.string(), - hookEventName: z.string(), + hookEventName: DroidHookEventSchema, hookMatcher: z.string().optional(), hookCommands: z.array(HookCommandSchema), hookToolCallId: z.string().optional(), @@ -444,7 +445,7 @@ export const HookExecutionCompletedNotificationSchema = z .object({ type: z.literal(SessionNotificationType.HOOK_EXECUTION_COMPLETED), hookId: z.string(), - hookEventName: z.string().optional(), + hookEventName: DroidHookEventSchema.optional(), hookMatcher: z.string().optional(), hookToolCallId: z.string().optional(), hookStatus: z.enum(['completed', 'error']), diff --git a/src/stream.ts b/src/stream.ts index 99a36ba..d1af7d7 100644 --- a/src/stream.ts +++ b/src/stream.ts @@ -6,6 +6,7 @@ import { SessionNotificationType, ToolConfirmationOutcome, } from './schemas/enums.js'; +import type { DroidHookEvent } from './schemas/hooks.js'; import type { McpServerStatusInfo, McpStatusSummary } from './schemas/mcp.js'; import { FactoryDroidMessageRole, @@ -220,7 +221,7 @@ export interface McpAuthCompleted { export interface HookExecution { readonly type: 'hook'; readonly hookId: string; - readonly eventName?: string; + readonly eventName?: DroidHookEvent; readonly matcher?: string; readonly toolCallId?: string; readonly command?: string; From e8dbf43e6de24b0e4c32f466128aed02a4316f8e Mon Sep 17 00:00:00 2001 From: User Date: Fri, 15 May 2026 13:59:10 -0700 Subject: [PATCH 7/8] style: format hook execution example Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- examples/hook-execution.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/examples/hook-execution.ts b/examples/hook-execution.ts index a082de9..894a2a8 100644 --- a/examples/hook-execution.ts +++ b/examples/hook-execution.ts @@ -30,9 +30,13 @@ async function main(): Promise { case DroidMessageType.Hook: if (msg.status === 'started') { - console.log(` [Hook Started] ID: ${msg.hookId}, Event: ${msg.eventName}, Command: ${msg.command}`); + console.log( + ` [Hook Started] ID: ${msg.hookId}, Event: ${msg.eventName}, Command: ${msg.command}` + ); } else { - console.log(` [Hook ${msg.status}] ID: ${msg.hookId}, Exit Code: ${msg.exitCode}`); + console.log( + ` [Hook ${msg.status}] ID: ${msg.hookId}, Exit Code: ${msg.exitCode}` + ); if (msg.stdout) console.log(` stdout: ${msg.stdout.trim()}`); if (msg.stderr) console.log(` stderr: ${msg.stderr.trim()}`); } From 716fa20f2d08ec57f717bdd85216a6329bd6784a Mon Sep 17 00:00:00 2001 From: User Date: Fri, 15 May 2026 16:33:23 -0700 Subject: [PATCH 8/8] fix: drop unsupported setting sources from SDK Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- src/helpers.ts | 5 ----- src/schemas/client.ts | 9 --------- src/schemas/index.ts | 2 -- src/session.ts | 4 ---- tests/helpers.test.ts | 7 ------- tests/session.test.ts | 1 - 6 files changed, 28 deletions(-) diff --git a/src/helpers.ts b/src/helpers.ts index 3ff31b5..9642de3 100644 --- a/src/helpers.ts +++ b/src/helpers.ts @@ -9,7 +9,6 @@ import type { InitializeSessionRequestParams, McpServerConfig, OutputFormat, - SettingSource, SessionTag, } from './schemas/client.js'; import type { @@ -251,7 +250,6 @@ export interface SessionInitOptions extends ToolSelectionOverrides { specModeModelId?: string; specModeReasoningEffort?: ReasoningEffort; mcpServers?: DroidMcpServerConfig[]; - settingSources?: SettingSource[]; tags?: SessionTag[]; } @@ -284,9 +282,6 @@ export function buildInitParams( ...(options.mcpServers !== undefined && { mcpServers: options.mcpServers, }), - ...(options.settingSources !== undefined && { - settingSources: options.settingSources, - }), ...(options.enabledToolIds !== undefined && { enabledToolIds: options.enabledToolIds, }), diff --git a/src/schemas/client.ts b/src/schemas/client.ts index 6216746..00c58c6 100644 --- a/src/schemas/client.ts +++ b/src/schemas/client.ts @@ -231,13 +231,6 @@ export const SkillInfoSchema = z export type SkillInfo = z.infer; -export const SettingSourceSchema = z.union([ - z.nativeEnum(SettingsLevel), - z.literal('all'), -]); - -export type SettingSource = z.infer; - /** Parameters for droid.initialize_session request. */ export const InitializeSessionRequestParamsSchema = z .object({ @@ -258,7 +251,6 @@ export const InitializeSessionRequestParamsSchema = z sessionSource: SessionSourceSchema.optional(), tags: z.array(SessionTagSchema).optional(), mcpOAuthCallbackUri: z.string().optional(), - settingSources: z.array(SettingSourceSchema).optional(), }) .strict(); @@ -272,7 +264,6 @@ export const LoadSessionRequestParamsSchema = z sessionId: z.string(), mcpServers: z.array(McpServerConfigSchema).optional(), mcpOAuthCallbackUri: z.string().optional(), - settingSources: z.array(SettingSourceSchema).optional(), }) .strict(); diff --git a/src/schemas/index.ts b/src/schemas/index.ts index 4c4e753..5282b99 100644 --- a/src/schemas/index.ts +++ b/src/schemas/index.ts @@ -325,7 +325,6 @@ export { SessionSettingsSchema, SessionSourceSchema, SessionTagSchema, - SettingSourceSchema, SkillInfoSchema, SkillResourceSchema, SseMcpConfigSchema, @@ -459,7 +458,6 @@ export type { SessionSettings, SessionSource, SessionTag, - SettingSource, SkillInfo, SkillResource, SseMcpConfig, diff --git a/src/session.ts b/src/session.ts index db1cb8e..0614055 100644 --- a/src/session.ts +++ b/src/session.ts @@ -73,7 +73,6 @@ export interface ResumeSessionOptions extends Pick< | 'askUserHandler' | 'transport' | 'abortSignal' - | 'settingSources' > { mcpServers?: DroidMcpServerConfig[]; } @@ -424,9 +423,6 @@ export async function resumeSession( const loadParams: LoadSessionRequestParams = { sessionId, mcpServers: sdkMcpServers.mcpServers, - ...(options.settingSources !== undefined && { - settingSources: options.settingSources, - }), }; const loadResult = await client.loadSession(loadParams); const session = new DroidSession(client, sessionId, loadResult); diff --git a/tests/helpers.test.ts b/tests/helpers.test.ts index 8da01bf..bbd818c 100644 --- a/tests/helpers.test.ts +++ b/tests/helpers.test.ts @@ -25,7 +25,6 @@ import { JSONRPC_VERSION, LEGACY_FACTORY_API_VERSION, SessionNotificationType, - SettingsLevel, ToolConfirmationOutcome, } from '../src/schemas/index.js'; import { InMemoryTransport } from './helpers.js'; @@ -337,7 +336,6 @@ describe('buildInitParams', () => { specModeReasoningEffort: 'max' as never, enabledToolIds: ['Read', 'Grep'], disabledToolIds: ['Execute'], - settingSources: [SettingsLevel.Project, SettingsLevel.User], mcpServers: [ { name: 'test-server', @@ -358,10 +356,6 @@ describe('buildInitParams', () => { expect(params.specModeReasoningEffort).toBe('max'); expect(params.enabledToolIds).toEqual(['Read', 'Grep']); expect(params.disabledToolIds).toEqual(['Execute']); - expect(params.settingSources).toEqual([ - SettingsLevel.Project, - SettingsLevel.User, - ]); expect(params.mcpServers).toHaveLength(1); }); @@ -392,7 +386,6 @@ describe('buildInitParams', () => { expect(params).not.toHaveProperty('specModeModelId'); expect(params).not.toHaveProperty('specModeReasoningEffort'); expect(params).not.toHaveProperty('mcpServers'); - expect(params).not.toHaveProperty('settingSources'); expect(params).not.toHaveProperty('enabledToolIds'); expect(params).not.toHaveProperty('disabledToolIds'); }); diff --git a/tests/session.test.ts b/tests/session.test.ts index a5c3117..64cbca4 100644 --- a/tests/session.test.ts +++ b/tests/session.test.ts @@ -631,7 +631,6 @@ describe('DroidSession', () => { const session = await createSession({ transport, - settingSources: [SettingsLevel.Project], }); await session.close();