Skip to content

Commit 2268fa2

Browse files
feat: expose file hook execution events in SDK (#39)
* feat: add SDK hook callbacks Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> * fix: complete SDK hook lifecycle Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> * refactor: replace SDK hook callbacks with setting sources Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> * fix: run session end hooks on close Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> * 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> * refactor: tighten hook stream typing Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> * style: format hook execution example Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> * fix: drop unsupported setting sources from SDK Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --------- Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
1 parent 9e4fe53 commit 2268fa2

16 files changed

Lines changed: 677 additions & 7 deletions

examples/hook-execution.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
/**
2+
* Hook execution streaming example.
3+
*
4+
* Demonstrates how to handle hook execution messages in a session stream.
5+
*
6+
* Usage:
7+
* npx tsx examples/hook-execution.ts
8+
*/
9+
10+
import { DroidMessageType, createSession } from '../src/index.js';
11+
12+
async function main(): Promise<void> {
13+
const prompt = 'Run a simple shell command using Execute tool.';
14+
15+
console.log(`Sending prompt: "${prompt}"\n`);
16+
17+
// Note: To actually see hooks, you need to have hooks configured in your droid settings.
18+
const session = await createSession({ cwd: process.cwd() });
19+
20+
try {
21+
for await (const msg of session.stream(prompt)) {
22+
switch (msg.type) {
23+
case DroidMessageType.Assistant:
24+
process.stdout.write(msg.text);
25+
break;
26+
27+
case DroidMessageType.ToolCall:
28+
console.log(`\n[Tool Call] ${msg.toolUse.name} (${msg.toolUse.id})`);
29+
break;
30+
31+
case DroidMessageType.Hook:
32+
if (msg.status === 'started') {
33+
console.log(
34+
` [Hook Started] ID: ${msg.hookId}, Event: ${msg.eventName}, Command: ${msg.command}`
35+
);
36+
} else {
37+
console.log(
38+
` [Hook ${msg.status}] ID: ${msg.hookId}, Exit Code: ${msg.exitCode}`
39+
);
40+
if (msg.stdout) console.log(` stdout: ${msg.stdout.trim()}`);
41+
if (msg.stderr) console.log(` stderr: ${msg.stderr.trim()}`);
42+
}
43+
break;
44+
45+
case DroidMessageType.ToolResult:
46+
console.log(`[Tool Result] ${msg.isError ? 'Error' : 'OK'}`);
47+
break;
48+
49+
case DroidMessageType.Result:
50+
console.log('\n\n--- Turn complete ---');
51+
break;
52+
}
53+
}
54+
} finally {
55+
await session.close();
56+
}
57+
}
58+
59+
main().catch((err: unknown) => {
60+
console.error('Error:', err);
61+
process.exit(1);
62+
});

src/client.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ import type {
1919
CancelMcpAuthResult,
2020
ClearMcpAuthRequestParams,
2121
ClearMcpAuthResult,
22+
CloseSessionRequestParams,
23+
CloseSessionResult,
2224
CompactSessionRequestParams,
2325
CompactSessionResult,
2426
GetContextStatsResult,
@@ -61,6 +63,7 @@ import {
6163
AuthenticateMcpServerResultSchema,
6264
CancelMcpAuthResultSchema,
6365
ClearMcpAuthResultSchema,
66+
CloseSessionResultSchema,
6467
CompactSessionResultSchema,
6568
GetContextStatsResultSchema,
6669
ExecuteRewindResultSchema,
@@ -241,6 +244,16 @@ export class DroidClient {
241244
);
242245
}
243246

247+
async closeSession(
248+
params: CloseSessionRequestParams = {}
249+
): Promise<CloseSessionResult> {
250+
return this._sessionRpc(
251+
DroidServerMethod.CLOSE_SESSION,
252+
params,
253+
CloseSessionResultSchema
254+
);
255+
}
256+
244257
async killWorkerSession(
245258
params: KillWorkerSessionRequestParams
246259
): Promise<KillWorkerSessionResult> {

src/hooks.ts

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import type { DroidHookEvent } from './schemas/hooks.js';
2+
3+
export type { DroidHookEvent, DroidHookOutput } from './schemas/hooks.js';
4+
5+
export type DroidPermissionMode =
6+
| 'off'
7+
| 'spec'
8+
| 'auto-low'
9+
| 'auto-medium'
10+
| 'auto-high';
11+
12+
export interface BaseDroidHookInput {
13+
session_id: string;
14+
transcript_path: string;
15+
cwd: string;
16+
permission_mode: DroidPermissionMode;
17+
hook_event_name: DroidHookEvent;
18+
message_id?: string;
19+
[key: string]: unknown;
20+
}
21+
22+
export interface ToolHookInput extends BaseDroidHookInput {
23+
hook_event_name: 'PreToolUse' | 'PostToolUse';
24+
tool_name: string;
25+
tool_input: Record<string, unknown>;
26+
tool_response?: unknown;
27+
}
28+
29+
export interface UserPromptSubmitHookInput extends BaseDroidHookInput {
30+
hook_event_name: 'UserPromptSubmit';
31+
prompt: string;
32+
has_images?: boolean;
33+
}
34+
35+
export interface NotificationHookInput extends BaseDroidHookInput {
36+
hook_event_name: 'Notification';
37+
message: string;
38+
notification_type:
39+
| 'permission_prompt'
40+
| 'idle_prompt'
41+
| 'auth_success'
42+
| 'elicitation_dialog';
43+
}
44+
45+
export interface StopHookInput extends BaseDroidHookInput {
46+
hook_event_name: 'Stop';
47+
stop_hook_active: boolean;
48+
tool_execution_count?: number;
49+
elapsed_time?: number;
50+
}
51+
52+
export interface SubagentStopHookInput extends BaseDroidHookInput {
53+
hook_event_name: 'SubagentStop';
54+
task_name: string;
55+
task_result?: string;
56+
task_error?: string;
57+
stop_hook_active: boolean;
58+
}
59+
60+
export interface PreCompactHookInput extends BaseDroidHookInput {
61+
hook_event_name: 'PreCompact';
62+
trigger: 'manual' | 'auto';
63+
custom_instructions?: string;
64+
message_count: number;
65+
estimated_tokens: number;
66+
}
67+
68+
export interface SessionStartHookInput extends BaseDroidHookInput {
69+
hook_event_name: 'SessionStart';
70+
source: 'startup' | 'resume' | 'clear' | 'compact';
71+
previous_session_id?: string;
72+
calling_session_id?: string;
73+
}
74+
75+
export interface SessionEndHookInput extends BaseDroidHookInput {
76+
hook_event_name: 'SessionEnd';
77+
reason: 'clear' | 'logout' | 'prompt_input_exit' | 'other';
78+
session_duration_ms: number;
79+
message_count: number;
80+
}
81+
82+
export type DroidHookInput =
83+
| ToolHookInput
84+
| UserPromptSubmitHookInput
85+
| NotificationHookInput
86+
| StopHookInput
87+
| SubagentStopHookInput
88+
| PreCompactHookInput
89+
| SessionStartHookInput
90+
| SessionEndHookInput
91+
| BaseDroidHookInput;

src/index.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,22 @@ export type {
2222
DroidClientOptions,
2323
} from './client.js';
2424

25+
export type {
26+
BaseDroidHookInput,
27+
DroidHookEvent,
28+
DroidHookInput,
29+
DroidHookOutput,
30+
DroidPermissionMode,
31+
NotificationHookInput,
32+
PreCompactHookInput,
33+
SessionEndHookInput,
34+
SessionStartHookInput,
35+
StopHookInput,
36+
SubagentStopHookInput,
37+
ToolHookInput,
38+
UserPromptSubmitHookInput,
39+
} from './hooks.js';
40+
2541
export {
2642
convertNotificationToStreamMessage,
2743
DroidMessageType,
@@ -59,6 +75,7 @@ export type {
5975
MissionWorkerCompleted,
6076
McpAuthRequired,
6177
McpAuthCompleted,
78+
HookExecution,
6279
StructuredOutput,
6380
StructuredOutputFields,
6481
ErrorEvent,

src/schemas/client.ts

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,8 @@ import {
88
DroidInteractionMode,
99
DroidServerMethod,
1010
McpServerType,
11-
MissionState,
1211
ModelProvider,
12+
MissionState,
1313
ReasoningEffort,
1414
SettingsLevel,
1515
SkillLocation,
@@ -311,6 +311,19 @@ export type InterruptSessionRequestParams = z.infer<
311311
typeof InterruptSessionRequestParamsSchema
312312
>;
313313

314+
/** Parameters for droid.close_session request. */
315+
export const CloseSessionRequestParamsSchema = z
316+
.object({
317+
reason: z
318+
.enum(['clear', 'logout', 'prompt_input_exit', 'other'])
319+
.optional(),
320+
})
321+
.strict();
322+
323+
export type CloseSessionRequestParams = z.infer<
324+
typeof CloseSessionRequestParamsSchema
325+
>;
326+
314327
/** Parameters for droid.kill_worker_session request. */
315328
export const KillWorkerSessionRequestParamsSchema = z
316329
.object({
@@ -634,6 +647,13 @@ export type InterruptSessionRequest = z.infer<
634647
typeof InterruptSessionRequestSchema
635648
>;
636649

650+
export const CloseSessionRequestSchema = JsonRpcRequestSchema.extend({
651+
method: z.literal(DroidServerMethod.CLOSE_SESSION),
652+
params: CloseSessionRequestParamsSchema,
653+
});
654+
655+
export type CloseSessionRequest = z.infer<typeof CloseSessionRequestSchema>;
656+
637657
export const KillWorkerSessionRequestSchema = JsonRpcRequestSchema.extend({
638658
method: z.literal(DroidServerMethod.KILL_WORKER_SESSION),
639659
params: KillWorkerSessionRequestParamsSchema,
@@ -811,6 +831,7 @@ export const ClientRequestSchema = z.discriminatedUnion('method', [
811831
InitializeSessionRequestSchema,
812832
LoadSessionRequestSchema,
813833
AddUserMessageRequestSchema,
834+
CloseSessionRequestSchema,
814835
InterruptSessionRequestSchema,
815836
KillWorkerSessionRequestSchema,
816837
UpdateSessionSettingsRequestSchema,
@@ -904,6 +925,11 @@ export type InterruptSessionResult = z.infer<
904925
typeof InterruptSessionResultSchema
905926
>;
906927

928+
/** Result for droid.close_session response (empty). */
929+
export const CloseSessionResultSchema = EmptyResultSchema;
930+
931+
export type CloseSessionResult = z.infer<typeof CloseSessionResultSchema>;
932+
907933
/** Result for droid.kill_worker_session response (empty). */
908934
export const KillWorkerSessionResultSchema = EmptyResultSchema;
909935

@@ -1114,6 +1140,15 @@ export type InterruptSessionResponse = z.infer<
11141140
typeof InterruptSessionResponseSchema
11151141
>;
11161142

1143+
export const CloseSessionResponseSchema = z.union([
1144+
JsonRpcResponseSuccessSchema.extend({
1145+
result: CloseSessionResultSchema,
1146+
}),
1147+
JsonRpcResponseFailureSchema,
1148+
]);
1149+
1150+
export type CloseSessionResponse = z.infer<typeof CloseSessionResponseSchema>;
1151+
11171152
export const KillWorkerSessionResponseSchema = z.union([
11181153
JsonRpcResponseSuccessSchema.extend({
11191154
result: KillWorkerSessionResultSchema,

src/schemas/enums.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ export enum DroidServerMethod {
1212
INITIALIZE_SESSION = 'droid.initialize_session',
1313
LOAD_SESSION = 'droid.load_session',
1414
ADD_USER_MESSAGE = 'droid.add_user_message',
15+
CLOSE_SESSION = 'droid.close_session',
1516
INTERRUPT_SESSION = 'droid.interrupt_session',
1617
KILL_WORKER_SESSION = 'droid.kill_worker_session',
1718
UPDATE_SESSION_SETTINGS = 'droid.update_session_settings',
@@ -69,6 +70,8 @@ export enum SessionNotificationType {
6970
MISSION_WORKER_COMPLETED = 'mission_worker_completed',
7071
MCP_AUTH_REQUIRED = 'mcp_auth_required',
7172
MCP_AUTH_COMPLETED = 'mcp_auth_completed',
73+
HOOK_EXECUTION_STARTED = 'hook_execution_started',
74+
HOOK_EXECUTION_COMPLETED = 'hook_execution_completed',
7275
STRUCTURED_OUTPUT = 'structured_output',
7376
}
7477

src/schemas/hooks.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { z } from 'zod';
2+
3+
export const DroidHookEventSchema = z.enum([
4+
'PreToolUse',
5+
'PostToolUse',
6+
'Notification',
7+
'UserPromptSubmit',
8+
'Stop',
9+
'SubagentStop',
10+
'PreCompact',
11+
'SessionStart',
12+
'SessionEnd',
13+
]);
14+
15+
export type DroidHookEvent = z.infer<typeof DroidHookEventSchema>;
16+
17+
export const DroidHookSpecificOutputSchema = z
18+
.object({
19+
hookEventName: DroidHookEventSchema.optional(),
20+
permissionDecision: z.enum(['allow', 'deny', 'ask']).optional(),
21+
permissionDecisionReason: z.string().optional(),
22+
additionalContext: z.string().optional(),
23+
updatedInput: z.record(z.unknown()).optional(),
24+
})
25+
.passthrough();
26+
27+
export const DroidHookOutputSchema = z
28+
.object({
29+
continue: z.boolean().optional(),
30+
stopReason: z.string().optional(),
31+
suppressOutput: z.boolean().optional(),
32+
systemMessage: z.string().optional(),
33+
decision: z.enum(['block', 'approve']).optional(),
34+
reason: z.string().optional(),
35+
hookSpecificOutput: DroidHookSpecificOutputSchema.optional(),
36+
})
37+
.passthrough();
38+
39+
export type DroidHookOutput = z.infer<typeof DroidHookOutputSchema>;
40+
41+
export const DroidHookExecutionResultSchema = DroidHookOutputSchema.extend({
42+
exitCode: z.number(),
43+
stdout: z.string(),
44+
stderr: z.string(),
45+
});
46+
47+
export type DroidHookExecutionResult = z.infer<
48+
typeof DroidHookExecutionResultSchema
49+
>;

0 commit comments

Comments
 (0)