Class: Rubino::UI::Notifier

Inherits:
Object
  • Object
show all
Defined in:
lib/rubino/ui/notifier.rb

Overview

Attention notifications for the moments the agent needs human eyes: a long agentic turn finishing, an approval prompt parking the run on a human decision, or a background subagent blocking on the human (an escalated ask_parent).

Channels — mirroring the dominant pattern across coding agents (Claude Code’s terminal bell + hooks, Codex’s notify hook, aider’s –notifications):

* terminal bell (BEL, "\a") — default on. BEL never moves the cursor,
  so it is safe even while the bottom composer owns the screen; it is
  still routed to the composer's REAL output (never the StdoutProxy,
  whose partial-line buffer would re-ring the byte on every repaint)
  and NEVER into a pipe.
* OSC 9 ("\e]9;msg\a") — additionally emitted on iTerm2
  (TERM_PROGRAM=iTerm.app), which renders it as a native macOS
  notification.
* notifications.command — an optional shell command spawned
  NON-BLOCKING and best-effort per event with RUBINO_EVENT
  (turn_finished | needs_approval | blocked) and RUBINO_MESSAGE in
  its environment; failures are swallowed to the structured log.
  Covers osascript / notify-send users.

Spam control: events landing within COALESCE_SECONDS of the last emitted one are dropped, so a burst (several children blocking at once) rings at most once.

Constant Summary collapse

EVENTS =

Event names the command hook sees via RUBINO_EVENT.

%i[turn_finished needs_approval blocked].freeze
COALESCE_SECONDS =

Burst window: events within this many seconds of the last emitted notification coalesce (are dropped).

1.0

Instance Method Summary collapse

Constructor Details

#initialize(config: nil) ⇒ Notifier

Returns a new instance of Notifier.

Parameters:

  • config (Config::Configuration, nil) (defaults to: nil)

    resolved lazily per event from Rubino.configuration when nil, so a config reload (or a test-injected configuration) is honored without rebuilding the UI.



40
41
42
43
44
# File 'lib/rubino/ui/notifier.rb', line 40

def initialize(config: nil)
  @config          = config
  @mutex           = Mutex.new
  @last_emitted_at = nil
end

Instance Method Details

#blocked(message = "a subagent is waiting on you") ⇒ Object

A background child is blocked on the human (the ⛔ escalated ask_parent banner).



64
65
66
# File 'lib/rubino/ui/notifier.rb', line 64

def blocked(message = "a subagent is waiting on you")
  notify(:blocked, message)
end

#needs_approval(message = "approval required") ⇒ Object

An approval prompt is parked on the human — the main agent’s confirm card, or a background child flipped to :needs_approval.



58
59
60
# File 'lib/rubino/ui/notifier.rb', line 58

def needs_approval(message = "approval required")
  notify(:needs_approval, message)
end

#notify(event, message) ⇒ Object

Emits one notification through every enabled channel. Best-effort: a channel failure is logged and never raised into the turn.



70
71
72
73
74
75
76
77
78
# File 'lib/rubino/ui/notifier.rb', line 70

def notify(event, message)
  return unless enabled?
  return unless mark_emittable!

  emit_bell(message)
  spawn_command(event, message)
rescue StandardError => e
  log_failure(e)
end

#turn_finished(seconds) ⇒ Object

A turn ended after seconds. Quick turns stay silent (notifications.min_turn_seconds): focus detection is unreliable in plain terminals, so duration is the proxy for “the human probably looked away”.



50
51
52
53
54
# File 'lib/rubino/ui/notifier.rb', line 50

def turn_finished(seconds)
  return if seconds.nil? || seconds.to_f < min_turn_seconds

  notify(:turn_finished, "turn finished after #{seconds.to_i}s")
end