Skip to content

feat: expose file hook execution events in SDK#39

Open
varin-nair-factory wants to merge 8 commits into
mainfrom
vn/sdk-hooks
Open

feat: expose file hook execution events in SDK#39
varin-nair-factory wants to merge 8 commits into
mainfrom
vn/sdk-hooks

Conversation

@varin-nair-factory
Copy link
Copy Markdown
Contributor

@varin-nair-factory varin-nair-factory commented May 14, 2026

Summary

This PR moves SDK hook support to the file/config-based model owned by the Droid CLI. SDK consumers now observe hook activity through structured hook stream events, while hook configuration continues to come from the normal resolved Droid settings hierarchy instead of SDK callback registration or SDK-specific settings-source selection.

Usage examples

Hooks are configured as normal Droid file/config hooks. For example, an SDK caller can run in a project with .factory/settings.json at the git root:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Execute",
        "hooks": [
          {
            "type": "command",
            "command": "echo \"about to run: $tool_name\"",
            "timeout": 5
          }
        ]
      }
    ],
    "SessionEnd": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "echo \"session ended: $reason\""
          }
        ]
      }
    ]
  }
}

Then consume hook execution events from the SDK stream:

import { createSession } from '@factory/droid-sdk';

const session = await createSession({
  cwd: '/path/to/project-with-factory-settings',
});

for await (const message of session.stream('Run npm test')) {
  if (message.type === 'hook') {
    console.log(
      `[${message.status}] ${message.eventName} ${message.command}`,
      message.exitCode
    );
  }
}

When message.type === 'hook', the narrowed message shape is:

type DroidHookEvent =
  | 'PreToolUse'
  | 'PostToolUse'
  | 'Notification'
  | 'UserPromptSubmit'
  | 'Stop'
  | 'SubagentStop'
  | 'PreCompact'
  | 'SessionStart'
  | 'SessionEnd';

interface HookExecution {
  readonly type: 'hook';
  readonly hookId: string;
  readonly eventName?: DroidHookEvent;
  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;
}

Callback hooks are intentionally no longer part of the SDK API. This old shape does not register hooks anymore:

await createSession({
  hooks: {
    PreToolUse: [
      {
        matcher: 'Execute',
        hooks: [async () => ({ /* callback hook output */ })],
      },
    ],
  },
});

What changed

  • Adds hook_execution_started and hook_execution_completed notification schemas, exported hook command/result types, and a public HookExecution stream message.
  • Converts hook notifications into default SDK stream messages with hook id, event name, matcher, tool call id, command, timeout, status, exit code, stdout, and stderr.
  • Keeps SessionEnd reliable by sending close_session during DroidSession.close() before the transport shuts down.
  • Removes the unsupported settingSources SDK API so callers do not pass a field the CLI-side session request handling ignores.
  • Updates tests and examples around file hook loading, close behavior, and hook stream conversion.

Validation

  • npm run typecheck
  • npm run typecheck:examples
  • npm test
  • npm run lint
  • npx prettier --check src tests examples
  • git diff --check
  • Selected file-hook stress scenarios with claude-haiku-4-5-20251001: lifecycle-session-start-end, user-prompt-submit-update, and execute-deny-pre-tool-use

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
@varin-nair-factory varin-nair-factory self-assigned this May 14, 2026
varin-nair-factory and others added 4 commits May 14, 2026 17:11
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>
Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
- 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>
@varin-nair-factory varin-nair-factory changed the title feat: add SDK hook callbacks feat: expose file hook execution events in SDK May 15, 2026
varin-nair-factory and others added 2 commits May 15, 2026 13:55
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>
Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
Comment thread src/session.ts
this._cleanupAbortSignal = null;

try {
await this._client.closeSession({ reason: 'other' }).catch(() => {});
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[warning] close() sends unsupported droid.close_session.

The current Droid JSON-RPC runner in factory-mono does not accept or route DroidServerMethod.CLOSE_SESSION, so this request can be treated as invalid and never resolve by id; session.close() may wait for the default RPC timeout before closing, and SessionEnd hooks still won’t run. Land/require the CLI-side droid.close_session handler first, or gate/fallback this SDK call so close remains fast with older CLIs.

Comment thread src/stream.ts
eventName: notification.hookEventName,
matcher: notification.hookMatcher,
toolCallId: notification.hookToolCallId,
command: hookResult.command,
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[warning] Completed hook messages lose command metadata.

Real CLI hook_execution_completed payloads only include hookId, hookStatus, and results with exitCode/stdout/stderr, while this SDK API claims completed stream messages include eventName, matcher, toolCallId, command, and timeout. For multi-command hooks, consumers can’t tell which command produced which output; include command/timeout in CLI completion payloads or have the SDK correlate completions with the earlier started notification by hookId.

Copy link
Copy Markdown

@factory-ain3sh factory-ain3sh left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed the PR and left 2 warning comments on the close-session protocol contract and hook completion metadata.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants