Compare commits

...

3 Commits

Author SHA1 Message Date
cottongin
392183692e Disable shell execution hooks to fix broken notifications
beforeShellExecution in hooks.json required a JSON response we never
provided, likely causing Cursor to silently break the entire hook
pipeline. Commenting out those entries (and afterShellExecution) from
HOOKS_CONFIG restores reliable preToolUse/postToolUse delivery. All
Python handler code is retained as dead code for reference.

Also reverts the is_cursor_frontmost() gate in _check_pending — pulses
should fire unconditionally when the approval delay expires.

Made-with: Cursor
2026-03-11 15:24:43 -04:00
cottongin
6610919a58 Show overlay on all Spaces and alongside fullscreen apps
Set canJoinAllSpaces + fullScreenAuxiliary on overlay windows so the
border renders regardless of which Space or fullscreen app is active.
In window mode, fall back to screen-edge border when Cursor isn't
frontmost to avoid a floating rectangle on other Spaces.

Made-with: Cursor
2026-03-11 03:07:12 -04:00
cottongin
23fe6ac101 Keep NSScreen list current across sleep/wake and display changes
Register for NSApplicationDidChangeScreenParametersNotification and
NSWorkspaceDidWakeNotification so the daemon refreshes NSScreen.screens()
after external monitors connect/disconnect or the system wakes from sleep.

Made-with: Cursor
2026-03-11 03:06:47 -04:00
6 changed files with 279 additions and 12 deletions

View File

@@ -1,9 +1,15 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Cursor hook script that notifies the cursor-flasher daemon via Unix socket. """Cursor hook script that notifies the cursor-flasher daemon via Unix socket.
Installed as a Cursor hook (preToolUse, stop) to trigger a window flash Installed as a Cursor hook to trigger a window flash when the agent needs
when the agent needs user attention. Reads hook JSON from stdin, extracts user attention. Reads hook JSON from stdin, extracts workspace and event
workspace and event info, and sends it to the daemon's socket. info, and sends it to the daemon's socket.
Shell-specific hook mapping (beforeShellExecution -> shellApproved,
afterShellExecution -> shellCompleted) is retained but currently dead
code — those hooks are disabled in HOOKS_CONFIG / hooks.json because
beforeShellExecution fires pre-approval and Cursor expects a JSON
response we don't provide.
""" """
import json import json
import os import os
@@ -12,6 +18,11 @@ import sys
SOCKET_PATH = os.path.expanduser("~/.cursor-flasher/flasher.sock") SOCKET_PATH = os.path.expanduser("~/.cursor-flasher/flasher.sock")
_SHELL_EVENT_MAP = {
"beforeShellExecution": "shellApproved",
"afterShellExecution": "shellCompleted",
}
def main() -> None: def main() -> None:
try: try:
@@ -22,8 +33,12 @@ def main() -> None:
workspace_roots = data.get("workspace_roots") or [] workspace_roots = data.get("workspace_roots") or []
workspace = workspace_roots[0] if workspace_roots else "" workspace = workspace_roots[0] if workspace_roots else ""
event = data.get("hook_event_name", "") event = data.get("hook_event_name", "")
tool = data.get("tool_name", "")
mapped_event = _SHELL_EVENT_MAP.get(event)
if mapped_event:
msg = json.dumps({"workspace": workspace, "event": mapped_event, "tool": "Shell"})
else:
tool = data.get("tool_name", "")
msg = json.dumps({"workspace": workspace, "event": event, "tool": tool}) msg = json.dumps({"workspace": workspace, "event": event, "tool": tool})
try: try:

View File

@@ -23,11 +23,27 @@ HOOKS_CONFIG = {
"postToolUseFailure": [ "postToolUseFailure": [
{"command": f"./hooks/{HOOK_SCRIPT_NAME}"} {"command": f"./hooks/{HOOK_SCRIPT_NAME}"}
], ],
# DEAD CODE: shell execution hooks disabled — Cursor expects a JSON
# response from beforeShellExecution that we don't provide, and the
# daemon handlers (shellApproved/shellCompleted) are no-ops or redundant
# with postToolUse. Re-enable by uncommenting if a use is found.
# "beforeShellExecution": [
# {"command": f"./hooks/{HOOK_SCRIPT_NAME}"}
# ],
# "afterShellExecution": [
# {"command": f"./hooks/{HOOK_SCRIPT_NAME}"}
# ],
"stop": [ "stop": [
{"command": f"./hooks/{HOOK_SCRIPT_NAME}"} {"command": f"./hooks/{HOOK_SCRIPT_NAME}"}
], ],
} }
# Events to clean from hooks.json on uninstall, including disabled ones.
_ALL_HOOK_EVENTS = list(HOOKS_CONFIG.keys()) + [
"beforeShellExecution",
"afterShellExecution",
]
def _write_pid() -> None: def _write_pid() -> None:
PID_FILE.parent.mkdir(parents=True, exist_ok=True) PID_FILE.parent.mkdir(parents=True, exist_ok=True)
@@ -110,7 +126,7 @@ def cmd_uninstall(args: argparse.Namespace) -> None:
hooks = config.get("hooks", {}) hooks = config.get("hooks", {})
changed = False changed = False
for event in HOOKS_CONFIG: for event in _ALL_HOOK_EVENTS:
if event in hooks: if event in hooks:
before = len(hooks[event]) before = len(hooks[event])
hooks[event] = [ hooks[event] = [

View File

@@ -6,7 +6,10 @@ import signal
import socket import socket
import time import time
from Cocoa import NSApplication, NSRunLoop, NSDate from Cocoa import (
NSApplication, NSRunLoop, NSDate,
NSNotificationCenter, NSObject, NSScreen, NSWorkspace,
)
from Quartz import ( from Quartz import (
CGEventSourceSecondsSinceLastEventType, CGEventSourceSecondsSinceLastEventType,
kCGEventSourceStateHIDSystemState, kCGEventSourceStateHIDSystemState,
@@ -34,6 +37,26 @@ SOCKET_PATH = os.path.join(SOCKET_DIR, "flasher.sock")
INPUT_DISMISS_GRACE = 0.5 INPUT_DISMISS_GRACE = 0.5
class _DisplayObserver(NSObject):
"""Listens for macOS display-change and wake notifications.
Registering for NSApplicationDidChangeScreenParametersNotification forces
AppKit to keep NSScreen.screens() current in long-running daemon processes.
Without this, the screen list can go stale after sleep/wake cycles, causing
modes like "allscreens" to miss external displays.
"""
def screenParametersChanged_(self, notification):
screens = NSScreen.screens()
count = len(screens) if screens else 0
logger.info("Display configuration changed — %d screen(s) detected", count)
def workspaceDidWake_(self, notification):
screens = NSScreen.screens()
count = len(screens) if screens else 0
logger.info("System woke from sleep — %d screen(s) detected", count)
def _get_system_appearance() -> str: def _get_system_appearance() -> str:
"""Return "dark" or "light" based on the current macOS appearance.""" """Return "dark" or "light" based on the current macOS appearance."""
app = NSApplication.sharedApplication() app = NSApplication.sharedApplication()
@@ -71,11 +94,13 @@ class FlasherDaemon:
self._pending_approvals: dict[str, _PendingApproval] = {} self._pending_approvals: dict[str, _PendingApproval] = {}
self._active_pulses: dict[str, _ActivePulse] = {} self._active_pulses: dict[str, _ActivePulse] = {}
self._cursor_was_frontmost: bool = False self._cursor_was_frontmost: bool = False
self._display_observer: NSObject | None = None
def run(self) -> None: def run(self) -> None:
NSApplication.sharedApplication() NSApplication.sharedApplication()
self._running = True self._running = True
self._setup_socket() self._setup_socket()
self._setup_display_notifications()
signal.signal(signal.SIGTERM, self._handle_signal) signal.signal(signal.SIGTERM, self._handle_signal)
signal.signal(signal.SIGINT, self._handle_signal) signal.signal(signal.SIGINT, self._handle_signal)
@@ -111,6 +136,34 @@ class FlasherDaemon:
self._server.listen(5) self._server.listen(5)
self._server.setblocking(False) self._server.setblocking(False)
def _setup_display_notifications(self) -> None:
"""Subscribe to macOS display-change and wake events.
This is required for NSScreen.screens() to stay current in a
long-running daemon. Without these observers, AppKit may not process
screen-configuration changes after sleep/wake, leaving the screen
list stale until the process is restarted.
"""
self._display_observer = _DisplayObserver.alloc().init()
NSNotificationCenter.defaultCenter().addObserver_selector_name_object_(
self._display_observer,
"screenParametersChanged:",
"NSApplicationDidChangeScreenParametersNotification",
None,
)
NSWorkspace.sharedWorkspace().notificationCenter().addObserver_selector_name_object_(
self._display_observer,
"workspaceDidWake:",
"NSWorkspaceDidWakeNotification",
None,
)
screens = NSScreen.screens()
count = len(screens) if screens else 0
logger.info("Display notifications registered — %d screen(s) currently", count)
def _check_socket(self) -> None: def _check_socket(self) -> None:
if self._server is None: if self._server is None:
return return
@@ -250,6 +303,12 @@ class FlasherDaemon:
self._handle_approval(workspace, tool) self._handle_approval(workspace, tool)
elif event in ("postToolUse", "postToolUseFailure"): elif event in ("postToolUse", "postToolUseFailure"):
self._handle_dismiss(workspace, event, tool) self._handle_dismiss(workspace, event, tool)
# DEAD CODE: shell execution hooks are disabled in hooks.json.
# shellApproved (beforeShellExecution) fires pre-approval so it
# can't distinguish "waiting for user" from "auto-approved".
# shellCompleted (afterShellExecution) is redundant with postToolUse.
elif event in ("shellApproved", "shellCompleted"):
logger.debug("Shell execution hook (disabled): event=%s workspace=%s", event, workspace)
elif event == "stop": elif event == "stop":
self._handle_stop(workspace) self._handle_stop(workspace)
else: else:
@@ -304,15 +363,28 @@ class FlasherDaemon:
self._last_flash[workspace] = now self._last_flash[workspace] = now
def _resolve_frames(self, window_frame: tuple) -> list[tuple]: def _resolve_frames(self, window_frame: tuple) -> list[tuple]:
"""Return frame(s) based on flash_mode config.""" """Return frame(s) based on flash_mode config.
In "window" mode, falls back to screen frame when Cursor is not
frontmost (e.g. different Space or behind fullscreen app) to avoid
drawing a floating rectangle at stale coordinates.
"""
mode = self.config.flash_mode mode = self.config.flash_mode
if mode == "allscreens": if mode == "allscreens":
return all_screen_frames() return all_screen_frames()
if mode == "screen": if mode == "screen":
return [screen_frame_for_window(window_frame)] return [screen_frame_for_window(window_frame)]
if not is_cursor_frontmost():
return [screen_frame_for_window(window_frame)]
return [window_frame] return [window_frame]
def _cleanup(self) -> None: def _cleanup(self) -> None:
if self._display_observer is not None:
NSNotificationCenter.defaultCenter().removeObserver_(self._display_observer)
NSWorkspace.sharedWorkspace().notificationCenter().removeObserver_(
self._display_observer
)
self._display_observer = None
self.overlay.hide() self.overlay.hide()
if self._server is not None: if self._server is not None:
self._server.close() self._server.close()

View File

@@ -11,6 +11,8 @@ from Cocoa import (
NSView, NSView,
NSBezierPath, NSBezierPath,
NSTimer, NSTimer,
NSWindowCollectionBehaviorCanJoinAllSpaces,
NSWindowCollectionBehaviorFullScreenAuxiliary,
) )
from Foundation import NSInsetRect from Foundation import NSInsetRect
@@ -161,6 +163,10 @@ class OverlayManager:
window.setOpaque_(False) window.setOpaque_(False)
window.setBackgroundColor_(NSColor.clearColor()) window.setBackgroundColor_(NSColor.clearColor())
window.setLevel_(2147483631) window.setLevel_(2147483631)
window.setCollectionBehavior_(
NSWindowCollectionBehaviorCanJoinAllSpaces
| NSWindowCollectionBehaviorFullScreenAuxiliary
)
window.setIgnoresMouseEvents_(True) window.setIgnoresMouseEvents_(True)
window.setHasShadow_(False) window.setHasShadow_(False)

View File

@@ -122,6 +122,7 @@ class TestFlasherDaemon:
window = {"title": "my-project", "frame": ((0, 0), (800, 600))} window = {"title": "my-project", "frame": ((0, 0), (800, 600))}
with patch("cursor_flasher.daemon.find_window_by_workspace", return_value=window), \ with patch("cursor_flasher.daemon.find_window_by_workspace", return_value=window), \
patch("cursor_flasher.daemon.is_cursor_frontmost", return_value=True), \
patch("cursor_flasher.daemon.play_alert"), \ patch("cursor_flasher.daemon.play_alert"), \
patch(PATCH_APPEARANCE, return_value="light"): patch(PATCH_APPEARANCE, return_value="light"):
daemon._handle_message( daemon._handle_message(
@@ -132,6 +133,46 @@ class TestFlasherDaemon:
"/path", [((0, 0), (800, 600))], daemon.config.light.completed "/path", [((0, 0), (800, 600))], daemon.config.light.completed
) )
def test_window_mode_falls_back_to_screen_when_cursor_not_frontmost(self):
"""Window mode falls back to screen frame when Cursor isn't frontmost."""
daemon = self._make_daemon(approval_delay=0.0, flash_mode="window")
window = {"title": "proj", "frame": ((0, 0), (800, 600))}
screen = ((0, 0), (1920, 1080))
daemon._handle_message(
json.dumps({"workspace": "/path", "event": "preToolUse", "tool": "Shell"}).encode()
)
with patch("cursor_flasher.daemon.find_window_by_workspace", return_value=window), \
patch("cursor_flasher.daemon.screen_frame_for_window", return_value=screen), \
patch("cursor_flasher.daemon.is_cursor_frontmost", return_value=False), \
patch("cursor_flasher.daemon.play_alert"), \
patch(PATCH_APPEARANCE, return_value="dark"):
daemon._check_pending()
daemon.overlay.add_pulse.assert_called_once_with(
"/path", [screen], daemon.config.dark.running
)
def test_stop_falls_back_to_screen_when_cursor_not_frontmost(self):
"""Stop flash in window mode falls back to screen when Cursor isn't frontmost."""
daemon = self._make_daemon(flash_mode="window")
window = {"title": "proj", "frame": ((0, 0), (800, 600))}
screen = ((0, 0), (1920, 1080))
with patch("cursor_flasher.daemon.find_window_by_workspace", return_value=window), \
patch("cursor_flasher.daemon.screen_frame_for_window", return_value=screen), \
patch("cursor_flasher.daemon.is_cursor_frontmost", return_value=False), \
patch("cursor_flasher.daemon.play_alert"), \
patch(PATCH_APPEARANCE, return_value="dark"):
daemon._handle_message(
json.dumps({"workspace": "/path", "event": "stop"}).encode()
)
daemon.overlay.add_flash.assert_called_once_with(
"/path", [screen], daemon.config.dark.completed
)
def test_allscreens_mode_uses_all_screens(self): def test_allscreens_mode_uses_all_screens(self):
daemon = self._make_daemon(flash_mode="allscreens", approval_delay=0.0) daemon = self._make_daemon(flash_mode="allscreens", approval_delay=0.0)
window = {"title": "my-project", "frame": ((0, 0), (800, 600))} window = {"title": "my-project", "frame": ((0, 0), (800, 600))}
@@ -380,7 +421,7 @@ class TestFlasherDaemon:
) )
with patch("cursor_flasher.daemon.find_window_by_workspace", return_value=window), \ with patch("cursor_flasher.daemon.find_window_by_workspace", return_value=window), \
patch("cursor_flasher.daemon.is_cursor_frontmost", return_value=False), \ patch("cursor_flasher.daemon.is_cursor_frontmost", return_value=True), \
patch("cursor_flasher.daemon.play_alert") as mock_alert, \ patch("cursor_flasher.daemon.play_alert") as mock_alert, \
patch(PATCH_APPEARANCE, return_value="dark"): patch(PATCH_APPEARANCE, return_value="dark"):
daemon._check_pending() daemon._check_pending()
@@ -395,6 +436,7 @@ class TestFlasherDaemon:
window = {"title": "proj", "frame": ((0, 0), (800, 600))} window = {"title": "proj", "frame": ((0, 0), (800, 600))}
with patch("cursor_flasher.daemon.find_window_by_workspace", return_value=window), \ with patch("cursor_flasher.daemon.find_window_by_workspace", return_value=window), \
patch("cursor_flasher.daemon.is_cursor_frontmost", return_value=True), \
patch("cursor_flasher.daemon.play_alert") as mock_alert, \ patch("cursor_flasher.daemon.play_alert") as mock_alert, \
patch(PATCH_APPEARANCE, return_value="dark"): patch(PATCH_APPEARANCE, return_value="dark"):
daemon._handle_message( daemon._handle_message(
@@ -411,6 +453,7 @@ class TestFlasherDaemon:
window = {"title": "proj", "frame": ((0, 0), (800, 600))} window = {"title": "proj", "frame": ((0, 0), (800, 600))}
with patch("cursor_flasher.daemon.find_window_by_workspace", return_value=window), \ with patch("cursor_flasher.daemon.find_window_by_workspace", return_value=window), \
patch("cursor_flasher.daemon.is_cursor_frontmost", return_value=True), \
patch("cursor_flasher.daemon.play_alert") as mock_alert, \ patch("cursor_flasher.daemon.play_alert") as mock_alert, \
patch(PATCH_APPEARANCE, return_value="dark"): patch(PATCH_APPEARANCE, return_value="dark"):
daemon._handle_message( daemon._handle_message(
@@ -434,7 +477,7 @@ class TestFlasherDaemon:
) )
with patch("cursor_flasher.daemon.find_window_by_workspace", return_value=window), \ with patch("cursor_flasher.daemon.find_window_by_workspace", return_value=window), \
patch("cursor_flasher.daemon.is_cursor_frontmost", return_value=False), \ patch("cursor_flasher.daemon.is_cursor_frontmost", return_value=True), \
patch("cursor_flasher.daemon.play_alert"), \ patch("cursor_flasher.daemon.play_alert"), \
patch(PATCH_APPEARANCE, return_value="dark"): patch(PATCH_APPEARANCE, return_value="dark"):
daemon._check_pending() daemon._check_pending()
@@ -448,6 +491,7 @@ class TestFlasherDaemon:
daemon._active_pulses.clear() daemon._active_pulses.clear()
with patch("cursor_flasher.daemon.find_window_by_workspace", return_value=window), \ with patch("cursor_flasher.daemon.find_window_by_workspace", return_value=window), \
patch("cursor_flasher.daemon.is_cursor_frontmost", return_value=True), \
patch("cursor_flasher.daemon.play_alert"), \ patch("cursor_flasher.daemon.play_alert"), \
patch(PATCH_APPEARANCE, return_value="dark"): patch(PATCH_APPEARANCE, return_value="dark"):
daemon._handle_message( daemon._handle_message(
@@ -475,7 +519,7 @@ class TestFlasherDaemon:
) )
with patch("cursor_flasher.daemon.find_window_by_workspace", return_value=window), \ with patch("cursor_flasher.daemon.find_window_by_workspace", return_value=window), \
patch("cursor_flasher.daemon.is_cursor_frontmost", return_value=False), \ patch("cursor_flasher.daemon.is_cursor_frontmost", return_value=True), \
patch("cursor_flasher.daemon.play_alert"), \ patch("cursor_flasher.daemon.play_alert"), \
patch(PATCH_APPEARANCE, return_value="light"): patch(PATCH_APPEARANCE, return_value="light"):
daemon._check_pending() daemon._check_pending()
@@ -500,7 +544,7 @@ class TestFlasherDaemon:
) )
with patch("cursor_flasher.daemon.find_window_by_workspace", side_effect=[win_a, win_b]), \ with patch("cursor_flasher.daemon.find_window_by_workspace", side_effect=[win_a, win_b]), \
patch("cursor_flasher.daemon.is_cursor_frontmost", return_value=False), \ patch("cursor_flasher.daemon.is_cursor_frontmost", return_value=True), \
patch("cursor_flasher.daemon.play_alert"), \ patch("cursor_flasher.daemon.play_alert"), \
patch(PATCH_APPEARANCE, return_value="dark"): patch(PATCH_APPEARANCE, return_value="dark"):
daemon._check_pending() daemon._check_pending()
@@ -533,6 +577,7 @@ class TestFlasherDaemon:
window = {"title": "project-a", "frame": ((0, 0), (800, 600))} window = {"title": "project-a", "frame": ((0, 0), (800, 600))}
with patch("cursor_flasher.daemon.find_window_by_workspace", return_value=window), \ with patch("cursor_flasher.daemon.find_window_by_workspace", return_value=window), \
patch("cursor_flasher.daemon.is_cursor_frontmost", return_value=True), \
patch("cursor_flasher.daemon.play_alert"), \ patch("cursor_flasher.daemon.play_alert"), \
patch(PATCH_APPEARANCE, return_value="dark"): patch(PATCH_APPEARANCE, return_value="dark"):
daemon._handle_message( daemon._handle_message(
@@ -562,3 +607,45 @@ class TestFlasherDaemon:
) )
assert "/a" not in daemon._pending_approvals assert "/a" not in daemon._pending_approvals
assert "/b" in daemon._pending_approvals assert "/b" in daemon._pending_approvals
# --- shellApproved / shellCompleted (disabled, debug-logged only) ---
def test_shellApproved_does_not_cancel_pending(self):
"""shellApproved is a no-op — pending approval must survive."""
daemon = self._make_daemon(approval_delay=10.0)
daemon._handle_message(
json.dumps({"workspace": "/path", "event": "preToolUse", "tool": "Shell"}).encode()
)
assert "/path" in daemon._pending_approvals
daemon._handle_message(
json.dumps({"workspace": "/path", "event": "shellApproved", "tool": "Shell"}).encode()
)
assert "/path" in daemon._pending_approvals
def test_shellCompleted_does_not_dismiss(self):
"""shellCompleted is a no-op — active pulse must survive."""
daemon = self._make_daemon()
daemon._active_pulses["/path"] = MagicMock()
daemon._handle_message(
json.dumps({"workspace": "/path", "event": "shellCompleted", "tool": "Shell"}).encode()
)
daemon.overlay.dismiss_tag.assert_not_called()
assert "/path" in daemon._active_pulses
def test_shellCompleted_does_not_cancel_pending(self):
"""shellCompleted is a no-op — pending approval must survive."""
daemon = self._make_daemon(approval_delay=10.0)
daemon._handle_message(
json.dumps({"workspace": "/path", "event": "preToolUse", "tool": "Shell"}).encode()
)
assert "/path" in daemon._pending_approvals
daemon._handle_message(
json.dumps({"workspace": "/path", "event": "shellCompleted", "tool": "Shell"}).encode()
)
assert "/path" in daemon._pending_approvals

View File

@@ -103,3 +103,74 @@ class TestHookNotify:
os.unlink(sock_path) os.unlink(sock_path)
assert received[0]["workspace"] == "" assert received[0]["workspace"] == ""
def test_beforeShellExecution_mapped_to_shellApproved(self):
sock_path = _short_sock_path()
received = []
server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
server.bind(sock_path)
server.listen(1)
def accept():
conn, _ = server.accept()
data = conn.recv(4096)
received.append(json.loads(data))
conn.close()
t = threading.Thread(target=accept)
t.start()
try:
hook_input = json.dumps({
"workspace_roots": ["/Users/me/project"],
"hook_event_name": "beforeShellExecution",
"command": "npm install",
})
_run_hook_main(hook_input, sock_path)
t.join(timeout=2)
finally:
server.close()
if os.path.exists(sock_path):
os.unlink(sock_path)
assert len(received) == 1
assert received[0]["event"] == "shellApproved"
assert received[0]["tool"] == "Shell"
assert received[0]["workspace"] == "/Users/me/project"
def test_afterShellExecution_mapped_to_shellCompleted(self):
sock_path = _short_sock_path()
received = []
server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
server.bind(sock_path)
server.listen(1)
def accept():
conn, _ = server.accept()
data = conn.recv(4096)
received.append(json.loads(data))
conn.close()
t = threading.Thread(target=accept)
t.start()
try:
hook_input = json.dumps({
"workspace_roots": ["/Users/me/project"],
"hook_event_name": "afterShellExecution",
"command": "npm install",
"output": "added 100 packages",
"duration": 5432,
})
_run_hook_main(hook_input, sock_path)
t.join(timeout=2)
finally:
server.close()
if os.path.exists(sock_path):
os.unlink(sock_path)
assert len(received) == 1
assert received[0]["event"] == "shellCompleted"
assert received[0]["tool"] == "Shell"