From c57e786c15a4334d4756bd7d13bdd6ad06159d99 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 15 Dec 2025 08:01:43 +0000 Subject: [PATCH] feat: Add Notification hook handler for formatting idle notifications MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Format raw JSON IPC messages from workers/teammates into user-friendly display instead of showing raw JSON to users. Changes: - Add notification.py hook handler to hookify plugin that formats idle_notification, status_update, and progress_update messages - Update hookify hooks.json to include Notification event handler - Add Pattern 11 documentation for formatting teammate idle notifications - Add format-idle-notification.sh example script Raw JSON input like: {"type":"idle_notification","from":"worker-1","timestamp":"..."} Now displays as: ⏺ worker-1 ⎿ Status is idle Slack thread: https://anthropic.slack.com/archives/C07VBSHV7EV/p1765785226571409?thread_ts=1765776251.343939&cid=C07VBSHV7EV --- plugins/hookify/hooks/hooks.json | 11 ++ plugins/hookify/hooks/notification.py | 143 ++++++++++++++++++ .../examples/format-idle-notification.sh | 50 ++++++ .../hook-development/references/patterns.md | 66 ++++++++ 4 files changed, 270 insertions(+) create mode 100644 plugins/hookify/hooks/notification.py create mode 100644 plugins/plugin-dev/skills/hook-development/examples/format-idle-notification.sh diff --git a/plugins/hookify/hooks/hooks.json b/plugins/hookify/hooks/hooks.json index d65daca7..fb28bca2 100644 --- a/plugins/hookify/hooks/hooks.json +++ b/plugins/hookify/hooks/hooks.json @@ -44,6 +44,17 @@ } ] } + ], + "Notification": [ + { + "hooks": [ + { + "type": "command", + "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/notification.py", + "timeout": 10 + } + ] + } ] } } diff --git a/plugins/hookify/hooks/notification.py b/plugins/hookify/hooks/notification.py new file mode 100644 index 00000000..d44a34f0 --- /dev/null +++ b/plugins/hookify/hooks/notification.py @@ -0,0 +1,143 @@ +#!/usr/bin/env python3 +"""Notification hook executor for hookify plugin. + +This script is called by Claude Code when notifications are sent. +It formats teammate idle notifications and other IPC messages for display. +""" + +import os +import sys +import json +from datetime import datetime + +# CRITICAL: Add plugin root to Python path for imports +PLUGIN_ROOT = os.environ.get('CLAUDE_PLUGIN_ROOT') +if PLUGIN_ROOT: + parent_dir = os.path.dirname(PLUGIN_ROOT) + if parent_dir not in sys.path: + sys.path.insert(0, parent_dir) + if PLUGIN_ROOT not in sys.path: + sys.path.insert(0, PLUGIN_ROOT) + + +def format_idle_notification(data: dict) -> str: + """Format an idle notification for display. + + Args: + data: The notification data containing type, from, timestamp, etc. + + Returns: + Formatted string for display + """ + worker_name = data.get('from', 'worker') + timestamp = data.get('timestamp', '') + + # Format timestamp if present + time_str = '' + if timestamp: + try: + dt = datetime.fromisoformat(timestamp.replace('Z', '+00:00')) + time_str = dt.strftime('%H:%M:%S') + except (ValueError, AttributeError): + time_str = '' + + # Build the formatted output using the suggested format + lines = [f"⏺ {worker_name}"] + if time_str: + lines.append(f" ⎿ Status is idle ({time_str})") + else: + lines.append(" ⎿ Status is idle") + + return '\n'.join(lines) + + +def format_notification(notification_content: str) -> dict: + """Parse and format a notification message. + + Args: + notification_content: Raw notification content (may be JSON or plain text) + + Returns: + Dict with formatted systemMessage + """ + # Try to parse as JSON first + try: + data = json.loads(notification_content) + + # Check if this is an idle notification + if isinstance(data, dict) and data.get('type') == 'idle_notification': + formatted = format_idle_notification(data) + return {"systemMessage": formatted} + + # Handle other notification types + notification_type = data.get('type', '') if isinstance(data, dict) else '' + + if notification_type == 'status_update': + worker = data.get('from', 'worker') + status = data.get('status', 'unknown') + return {"systemMessage": f"⏺ {worker}\n ⎿ Status: {status}"} + + if notification_type == 'progress_update': + worker = data.get('from', 'worker') + progress = data.get('progress', '') + return {"systemMessage": f"⏺ {worker}\n ⎿ {progress}"} + + # For unknown JSON types, still try to format nicely + if isinstance(data, dict) and 'from' in data: + worker = data.get('from', 'worker') + msg = data.get('message', data.get('status', 'update')) + return {"systemMessage": f"⏺ {worker}\n ⎿ {msg}"} + + except (json.JSONDecodeError, TypeError): + # Not JSON, return as-is + pass + + return {} + + +def main(): + """Main entry point for Notification hook.""" + try: + # Read input from stdin + input_data = json.load(sys.stdin) + + # Get notification content + notification = input_data.get('notification', '') + + # Also check for raw notification data in the input + if not notification and input_data.get('type') == 'idle_notification': + # The input itself is an idle notification + formatted = format_idle_notification(input_data) + result = {"systemMessage": formatted} + elif notification: + # Format the notification content + result = format_notification(notification) + else: + # Check if the input looks like an IPC message + if input_data.get('type') in ['idle_notification', 'status_update', 'progress_update']: + if input_data.get('type') == 'idle_notification': + formatted = format_idle_notification(input_data) + result = {"systemMessage": formatted} + else: + worker = input_data.get('from', 'worker') + status = input_data.get('status', input_data.get('type', 'update')) + result = {"systemMessage": f"⏺ {worker}\n ⎿ {status}"} + else: + result = {} + + # Always output JSON + print(json.dumps(result), file=sys.stdout) + + except Exception as e: + error_output = { + "systemMessage": f"Notification format error: {str(e)}" + } + print(json.dumps(error_output), file=sys.stdout) + + finally: + # ALWAYS exit 0 + sys.exit(0) + + +if __name__ == '__main__': + main() diff --git a/plugins/plugin-dev/skills/hook-development/examples/format-idle-notification.sh b/plugins/plugin-dev/skills/hook-development/examples/format-idle-notification.sh new file mode 100644 index 00000000..d0683840 --- /dev/null +++ b/plugins/plugin-dev/skills/hook-development/examples/format-idle-notification.sh @@ -0,0 +1,50 @@ +#!/bin/bash +# Example: Format teammate idle notification +# +# This script demonstrates how to format raw JSON idle notifications +# into user-friendly display format. +# +# Usage: echo '{"type":"idle_notification","from":"worker-1","timestamp":"..."}' | ./format-idle-notification.sh + +set -euo pipefail + +# Read JSON from stdin +input=$(cat) + +# Parse notification type +notification_type=$(echo "$input" | jq -r '.type // empty' 2>/dev/null || echo "") + +if [[ "$notification_type" == "idle_notification" ]]; then + # Extract fields + worker_name=$(echo "$input" | jq -r '.from // "worker"') + timestamp=$(echo "$input" | jq -r '.timestamp // empty') + + # Format timestamp if present + time_str="" + if [[ -n "$timestamp" ]]; then + # Try to format the timestamp + time_str=$(date -d "$timestamp" '+%H:%M:%S' 2>/dev/null || echo "") + fi + + # Output formatted notification using recommended format: + # ⏺ worker-1 + # ⎿ Status is idle + echo "⏺ $worker_name" + if [[ -n "$time_str" ]]; then + echo " ⎿ Status is idle ($time_str)" + else + echo " ⎿ Status is idle" + fi + + # Output JSON for hook system + if [[ -n "$time_str" ]]; then + jq -n --arg msg "⏺ $worker_name\n ⎿ Status is idle ($time_str)" \ + '{"systemMessage": $msg}' + else + jq -n --arg msg "⏺ $worker_name\n ⎿ Status is idle" \ + '{"systemMessage": $msg}' + fi +else + # Not an idle notification, pass through + echo "$input" +fi diff --git a/plugins/plugin-dev/skills/hook-development/references/patterns.md b/plugins/plugin-dev/skills/hook-development/references/patterns.md index 44753865..4fbc1df4 100644 --- a/plugins/plugin-dev/skills/hook-development/references/patterns.md +++ b/plugins/plugin-dev/skills/hook-development/references/patterns.md @@ -344,3 +344,69 @@ fi - Per-project settings - Team-specific rules - Dynamic validation criteria + +## Pattern 11: Format Teammate Idle Notifications + +Format raw JSON IPC messages from workers/teammates into user-friendly display: + +```json +{ + "Notification": [ + { + "matcher": "*", + "hooks": [ + { + "type": "command", + "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/notification.py", + "timeout": 10 + } + ] + } + ] +} +``` + +**Example script (format-idle-notification.py):** +```python +#!/usr/bin/env python3 +import sys +import json + +def format_idle_notification(data): + """Format idle notification for display.""" + worker_name = data.get('from', 'worker') + # Output format: + # ⏺ worker-1 + # ⎿ Status is idle + return f"⏺ {worker_name}\n ⎿ Status is idle" + +def main(): + input_data = json.load(sys.stdin) + + # Check for idle notification + if input_data.get('type') == 'idle_notification': + formatted = format_idle_notification(input_data) + print(json.dumps({"systemMessage": formatted})) + else: + print(json.dumps({})) + +if __name__ == '__main__': + main() +``` + +**Input (raw JSON IPC message):** +```json +{"type": "idle_notification", "from": "worker-1", "timestamp": "2025-12-15T05:22:40.320Z"} +``` + +**Output (formatted for display):** +``` +⏺ worker-1 + ⎿ Status is idle +``` + +**Use for:** +- Formatting teammate/worker status messages +- Converting internal IPC messages to user-friendly display +- Multi-agent swarm coordination UI +- Any notification that shouldn't show raw JSON to users