Class: RubynCode::Hooks::ExternalDispatcher

Inherits:
Object
  • Object
show all
Defined in:
lib/rubyn_code/hooks/external_dispatcher.rb

Overview

Dispatches hook events to external commands configured in settings.json.

The dispatcher sits alongside the in-process Hooks::Runner. It does NOT replace it — existing pre/post_tool_use YAML hooks and Ruby callables keep working. New code that wants Claude Code-style control flow (block, stopReason, additionalContext) fires through this dispatcher instead.

Wire it into the agent loop wherever a return value matters:

response = dispatcher.fire(:pre_tool_use, tool_name:, tool_input:)
raise ToolBlockedError, response.reason if response.block?

For events where return values don’t matter (e.g. SessionStart logging), call #fire and ignore the response — but still inspect #stop? when the caller wants to honour a stop signal mid-stream.

Constant Summary collapse

DEFAULT_TIMEOUT =
60

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(project_root:, config: nil, executor: nil, logger: nil) ⇒ ExternalDispatcher

Returns a new instance of ExternalDispatcher.

Parameters:

  • project_root (String)
  • config (Hash<String, Array<Hash>>) (defaults to: nil)

    result of SettingsJsonLoader#load

  • executor (SubprocessExecutor, nil) (defaults to: nil)

    injectable for tests

  • logger (#warn, nil) (defaults to: nil)

    injectable for tests



34
35
36
37
38
39
# File 'lib/rubyn_code/hooks/external_dispatcher.rb', line 34

def initialize(project_root:, config: nil, executor: nil, logger: nil)
  @project_root = project_root
  @config = config || SettingsJsonLoader.new(project_root: project_root).load
  @executor = executor || SubprocessExecutor.new(project_root: project_root)
  @logger = logger || method(:default_log)
end

Instance Attribute Details

#configObject (readonly)

Returns the value of attribute config.



28
29
30
# File 'lib/rubyn_code/hooks/external_dispatcher.rb', line 28

def config
  @config
end

#project_rootObject (readonly)

Returns the value of attribute project_root.



28
29
30
# File 'lib/rubyn_code/hooks/external_dispatcher.rb', line 28

def project_root
  @project_root
end

Instance Method Details

#configured_for?(internal_event) ⇒ Boolean

Returns true if any external hook is configured for this event.

Returns:

  • (Boolean)

    true if any external hook is configured for this event



42
43
44
45
46
47
# File 'lib/rubyn_code/hooks/external_dispatcher.rb', line 42

def configured_for?(internal_event)
  external = EventMap.external(internal_event)
  return false unless external

  Array(@config[external]).any?
end

#fire(internal_event, **payload) ⇒ Response

Fires all configured external hooks for the given internal event.

Each matcher group’s commands are run sequentially (the order they appear in settings.json). Within a group, commands run in declared order. Hook errors and timeouts are logged but do not abort the remaining hooks — matching Claude Code’s “best effort” semantics.

Parameters:

  • internal_event (Symbol)

    one of TO_EXTERNAL keys

  • payload (Hash)

    event-specific payload (tool_name, tool_input, etc.)

Returns:

  • (Response)

    the merged response from all hooks (first block/stop wins; additionalContext is concatenated)



60
61
62
63
64
65
66
67
68
69
70
# File 'lib/rubyn_code/hooks/external_dispatcher.rb', line 60

def fire(internal_event, **payload)
  external = EventMap.external(internal_event)
  return empty_response unless external

  groups = Array(@config[external])
  return empty_response if groups.empty?

  envelope = build_envelope(external, payload)
  collected = collect_responses(groups, envelope, payload)
  Response.new(raw: build_merged(collected, external))
end