mirror of
https://github.com/anthropics/claude-code.git
synced 2026-01-30 04:02:03 +00:00
feat: Add Notification hook handler for formatting idle notifications
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
This commit is contained in:
@@ -44,6 +44,17 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
],
|
||||||
|
"Notification": [
|
||||||
|
{
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/notification.py",
|
||||||
|
"timeout": 10
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
143
plugins/hookify/hooks/notification.py
Normal file
143
plugins/hookify/hooks/notification.py
Normal file
@@ -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()
|
||||||
@@ -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
|
||||||
@@ -344,3 +344,69 @@ fi
|
|||||||
- Per-project settings
|
- Per-project settings
|
||||||
- Team-specific rules
|
- Team-specific rules
|
||||||
- Dynamic validation criteria
|
- 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
|
||||||
|
|||||||
Reference in New Issue
Block a user