surshar / claude-code
Claude Code is an agentic coding tool that lives in your terminal, understands your codebase, and helps you code faster by executing routine tasks, explaining complex code, and handling git workflows - all through natural language commands.
Built by Anthropic · github.com/anthropics
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
| @@ -44,6 +44,17 @@ | ||
| 44 | 44 | } |
| 45 | 45 | ] |
| 46 | 46 | } |
| 47 | + ], | |
| 48 | + "Notification": [ | |
| 49 | + { | |
| 50 | + "hooks": [ | |
| 51 | + { | |
| 52 | + "type": "command", | |
| 53 | + "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/notification.py", | |
| 54 | + "timeout": 10 | |
| 55 | + } | |
| 56 | + ] | |
| 57 | + } | |
| 47 | 58 | ] |
| 48 | 59 | } |
| 49 | 60 | } |
| @@ -1,0 +1,143 @@ | ||
| 1 | +#!/usr/bin/env python3 | |
| 2 | +"""Notification hook executor for hookify plugin. | |
| 3 | + | |
| 4 | +This script is called by Claude Code when notifications are sent. | |
| 5 | +It formats teammate idle notifications and other IPC messages for display. | |
| 6 | +""" | |
| 7 | + | |
| 8 | +import os | |
| 9 | +import sys | |
| 10 | +import json | |
| 11 | +from datetime import datetime | |
| 12 | + | |
| 13 | +# CRITICAL: Add plugin root to Python path for imports | |
| 14 | +PLUGIN_ROOT = os.environ.get('CLAUDE_PLUGIN_ROOT') | |
| 15 | +if PLUGIN_ROOT: | |
| 16 | + parent_dir = os.path.dirname(PLUGIN_ROOT) | |
| 17 | + if parent_dir not in sys.path: | |
| 18 | + sys.path.insert(0, parent_dir) | |
| 19 | + if PLUGIN_ROOT not in sys.path: | |
| 20 | + sys.path.insert(0, PLUGIN_ROOT) | |
| 21 | + | |
| 22 | + | |
| 23 | +def format_idle_notification(data: dict) -> str: | |
| 24 | + """Format an idle notification for display. | |
| 25 | + | |
| 26 | + Args: | |
| 27 | + data: The notification data containing type, from, timestamp, etc. | |
| 28 | + | |
| 29 | + Returns: | |
| 30 | + Formatted string for display | |
| 31 | + """ | |
| 32 | + worker_name = data.get('from', 'worker') | |
| 33 | + timestamp = data.get('timestamp', '') | |
| 34 | + | |
| 35 | + # Format timestamp if present | |
| 36 | + time_str = '' | |
| 37 | + if timestamp: | |
| 38 | + try: | |
| 39 | + dt = datetime.fromisoformat(timestamp.replace('Z', '+00:00')) | |
| 40 | + time_str = dt.strftime('%H:%M:%S') | |
| 41 | + except (ValueError, AttributeError): | |
| 42 | + time_str = '' | |
| 43 | + | |
| 44 | + # Build the formatted output using the suggested format | |
| 45 | + lines = [f"⏺ {worker_name}"] | |
| 46 | + if time_str: | |
| 47 | + lines.append(f" ⎿ Status is idle ({time_str})") | |
| 48 | + else: | |
| 49 | + lines.append(" ⎿ Status is idle") | |
| 50 | + | |
| 51 | + return '\n'.join(lines) | |
| 52 | + | |
| 53 | + | |
| 54 | +def format_notification(notification_content: str) -> dict: | |
| 55 | + """Parse and format a notification message. | |
| 56 | + | |
| 57 | + Args: | |
| 58 | + notification_content: Raw notification content (may be JSON or plain text) | |
| 59 | + | |
| 60 | + Returns: | |
| 61 | + Dict with formatted systemMessage | |
| 62 | + """ | |
| 63 | + # Try to parse as JSON first | |
| 64 | + try: | |
| 65 | + data = json.loads(notification_content) | |
| 66 | + | |
| 67 | + # Check if this is an idle notification | |
| 68 | + if isinstance(data, dict) and data.get('type') == 'idle_notification': | |
| 69 | + formatted = format_idle_notification(data) | |
| 70 | + return {"systemMessage": formatted} | |
| 71 | + | |
| 72 | + # Handle other notification types | |
| 73 | + notification_type = data.get('type', '') if isinstance(data, dict) else '' | |
| 74 | + | |
| 75 | + if notification_type == 'status_update': | |
| 76 | + worker = data.get('from', 'worker') | |
| 77 | + status = data.get('status', 'unknown') | |
| 78 | + return {"systemMessage": f"⏺ {worker}\n ⎿ Status: {status}"} | |
| 79 | + | |
| 80 | + if notification_type == 'progress_update': | |
| 81 | + worker = data.get('from', 'worker') | |
| 82 | + progress = data.get('progress', '') | |
| 83 | + return {"systemMessage": f"⏺ {worker}\n ⎿ {progress}"} | |
| 84 | + | |
| 85 | + # For unknown JSON types, still try to format nicely | |
| 86 | + if isinstance(data, dict) and 'from' in data: | |
| 87 | + worker = data.get('from', 'worker') | |
| 88 | + msg = data.get('message', data.get('status', 'update')) | |
| 89 | + return {"systemMessage": f"⏺ {worker}\n ⎿ {msg}"} | |
| 90 | + | |
| 91 | + except (json.JSONDecodeError, TypeError): | |
| 92 | + # Not JSON, return as-is | |
| 93 | + pass | |
| 94 | + | |
| 95 | + return {} | |
| 96 | + | |
| 97 | + | |
| 98 | +def main(): | |
| 99 | + """Main entry point for Notification hook.""" | |
| 100 | + try: | |
| 101 | + # Read input from stdin | |
| 102 | + input_data = json.load(sys.stdin) | |
| 103 | + | |
| 104 | + # Get notification content | |
| 105 | + notification = input_data.get('notification', '') | |
| 106 | + | |
| 107 | + # Also check for raw notification data in the input | |
| 108 | + if not notification and input_data.get('type') == 'idle_notification': | |
| 109 | + # The input itself is an idle notification | |
| 110 | + formatted = format_idle_notification(input_data) | |
| 111 | + result = {"systemMessage": formatted} | |
| 112 | + elif notification: | |
| 113 | + # Format the notification content | |
| 114 | + result = format_notification(notification) | |
| 115 | + else: | |
| 116 | + # Check if the input looks like an IPC message | |
| 117 | + if input_data.get('type') in ['idle_notification', 'status_update', 'progress_update']: | |
| 118 | + if input_data.get('type') == 'idle_notification': | |
| 119 | + formatted = format_idle_notification(input_data) | |
| 120 | + result = {"systemMessage": formatted} | |
| 121 | + else: | |
| 122 | + worker = input_data.get('from', 'worker') | |
| 123 | + status = input_data.get('status', input_data.get('type', 'update')) | |
| 124 | + result = {"systemMessage": f"⏺ {worker}\n ⎿ {status}"} | |
| 125 | + else: | |
| 126 | + result = {} | |
| 127 | + | |
| 128 | + # Always output JSON | |
| 129 | + print(json.dumps(result), file=sys.stdout) | |
| 130 | + | |
| 131 | + except Exception as e: | |
| 132 | + error_output = { | |
| 133 | + "systemMessage": f"Notification format error: {str(e)}" | |
| 134 | + } | |
| 135 | + print(json.dumps(error_output), file=sys.stdout) | |
| 136 | + | |
| 137 | + finally: | |
| 138 | + # ALWAYS exit 0 | |
| 139 | + sys.exit(0) | |
| 140 | + | |
| 141 | + | |
| 142 | +if __name__ == '__main__': | |
| 143 | + main() |
| @@ -1,0 +1,50 @@ | ||
| 1 | +#!/bin/bash | |
| 2 | +# Example: Format teammate idle notification | |
| 3 | +# | |
| 4 | +# This script demonstrates how to format raw JSON idle notifications | |
| 5 | +# into user-friendly display format. | |
| 6 | +# | |
| 7 | +# Usage: echo '{"type":"idle_notification","from":"worker-1","timestamp":"..."}' | ./format-idle-notification.sh | |
| 8 | + | |
| 9 | +set -euo pipefail | |
| 10 | + | |
| 11 | +# Read JSON from stdin | |
| 12 | +input=$(cat) | |
| 13 | + | |
| 14 | +# Parse notification type | |
| 15 | +notification_type=$(echo "$input" | jq -r '.type // empty' 2>/dev/null || echo "") | |
| 16 | + | |
| 17 | +if [[ "$notification_type" == "idle_notification" ]]; then | |
| 18 | + # Extract fields | |
| 19 | + worker_name=$(echo "$input" | jq -r '.from // "worker"') | |
| 20 | + timestamp=$(echo "$input" | jq -r '.timestamp // empty') | |
| 21 | + | |
| 22 | + # Format timestamp if present | |
| 23 | + time_str="" | |
| 24 | + if [[ -n "$timestamp" ]]; then | |
| 25 | + # Try to format the timestamp | |
| 26 | + time_str=$(date -d "$timestamp" '+%H:%M:%S' 2>/dev/null || echo "") | |
| 27 | + fi | |
| 28 | + | |
| 29 | + # Output formatted notification using recommended format: | |
| 30 | + # ⏺ worker-1 | |
| 31 | + # ⎿ Status is idle | |
| 32 | + echo "⏺ $worker_name" | |
| 33 | + if [[ -n "$time_str" ]]; then | |
| 34 | + echo " ⎿ Status is idle ($time_str)" | |
| 35 | + else | |
| 36 | + echo " ⎿ Status is idle" | |
| 37 | + fi | |
| 38 | + | |
| 39 | + # Output JSON for hook system | |
| 40 | + if [[ -n "$time_str" ]]; then | |
| 41 | + jq -n --arg msg "⏺ $worker_name\n ⎿ Status is idle ($time_str)" \ | |
| 42 | + '{"systemMessage": $msg}' | |
| 43 | + else | |
| 44 | + jq -n --arg msg "⏺ $worker_name\n ⎿ Status is idle" \ | |
| 45 | + '{"systemMessage": $msg}' | |
| 46 | + fi | |
| 47 | +else | |
| 48 | + # Not an idle notification, pass through | |
| 49 | + echo "$input" | |
| 50 | +fi |
| @@ -344,3 +344,69 @@ | ||
| 344 | 344 | - Per-project settings |
| 345 | 345 | - Team-specific rules |
| 346 | 346 | - Dynamic validation criteria |
| 347 | + | |
| 348 | +## Pattern 11: Format Teammate Idle Notifications | |
| 349 | + | |
| 350 | +Format raw JSON IPC messages from workers/teammates into user-friendly display: | |
| 351 | + | |
| 352 | +```json | |
| 353 | +{ | |
| 354 | + "Notification": [ | |
| 355 | + { | |
| 356 | + "matcher": "*", | |
| 357 | + "hooks": [ | |
| 358 | + { | |
| 359 | + "type": "command", | |
| 360 | + "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/notification.py", | |
| 361 | + "timeout": 10 | |
| 362 | + } | |
| 363 | + ] | |
| 364 | + } | |
| 365 | + ] | |
| 366 | +} | |
| 367 | +``` | |
| 368 | + | |
| 369 | +**Example script (format-idle-notification.py):** | |
| 370 | +```python | |
| 371 | +#!/usr/bin/env python3 | |
| 372 | +import sys | |
| 373 | +import json | |
| 374 | + | |
| 375 | +def format_idle_notification(data): | |
| 376 | + """Format idle notification for display.""" | |
| 377 | + worker_name = data.get('from', 'worker') | |
| 378 | + # Output format: | |
| 379 | + # ⏺ worker-1 | |
| 380 | + # ⎿ Status is idle | |
| 381 | + return f"⏺ {worker_name}\n ⎿ Status is idle" | |
| 382 | + | |
| 383 | +def main(): | |
| 384 | + input_data = json.load(sys.stdin) | |
| 385 | + | |
| 386 | + # Check for idle notification | |
| 387 | + if input_data.get('type') == 'idle_notification': | |
| 388 | + formatted = format_idle_notification(input_data) | |
| 389 | + print(json.dumps({"systemMessage": formatted})) | |
| 390 | + else: | |
| 391 | + print(json.dumps({})) | |
| 392 | + | |
| 393 | +if __name__ == '__main__': | |
| 394 | + main() | |
| 395 | +``` | |
| 396 | + | |
| 397 | +**Input (raw JSON IPC message):** | |
| 398 | +```json | |
| 399 | +{"type": "idle_notification", "from": "worker-1", "timestamp": "2025-12-15T05:22:40.320Z"} | |
| 400 | +``` | |
| 401 | + | |
| 402 | +**Output (formatted for display):** | |
| 403 | +``` | |
| 404 | +⏺ worker-1 | |
| 405 | + ⎿ Status is idle | |
| 406 | +``` | |
| 407 | + | |
| 408 | +**Use for:** | |
| 409 | +- Formatting teammate/worker status messages | |
| 410 | +- Converting internal IPC messages to user-friendly display | |
| 411 | +- Multi-agent swarm coordination UI | |
| 412 | +- Any notification that shouldn't show raw JSON to users |