diff --git a/CLAUDE.md b/CLAUDE.md index 368067f..5f5a0b6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -47,15 +47,15 @@ Hooks intercept Claude Code events and return JSON decisions: Claude Code Event → Hook Script (stdin: JSON) → AppleScript Dialog → JSON Response (stdout) ``` -**Hook Response Format:** +**PermissionRequest Response Format:** ```json { "hookSpecificOutput": { "hookEventName": "PermissionRequest", "decision": { - "behavior": "allow|deny|ask", + "behavior": "allow|deny", "updatedInput": {}, - "message": "optional context" + "message": "optional context (for deny)" } } } @@ -64,7 +64,23 @@ Claude Code Event → Hook Script (stdin: JSON) → AppleScript Dialog → JSON **Behavior values:** - `allow` - Approve the action - `deny` - Block with optional message -- `ask` - Fall back to terminal prompt (timeout fallback) +- To fall back to terminal prompt: exit with no stdout output (`exit 0`) + +**PreToolUse Response Format:** +```json +{ + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "permissionDecision": "allow|deny|ask", + "permissionDecisionReason": "reason text" + } +} +``` + +**Stop Response Format (to block stopping):** +```json +{"decision": "block", "reason": "User wants to continue: ..."} +``` ## Key Implementation Patterns @@ -81,6 +97,7 @@ Claude Code Event → Hook Script (stdin: JSON) → AppleScript Dialog → JSON ### Context Extraction - Folder path: Last 3 directories via awk for compact display +- Last assistant message: Use `last_assistant_message` field from Stop/SubagentStop hook input (v2.1.47+), fall back to transcript parsing - Last user message: Parse transcript JSON, extract from `message.content` ## Adding New Hooks @@ -89,13 +106,23 @@ Claude Code Event → Hook Script (stdin: JSON) → AppleScript Dialog → JSON 2. Register in `hooks.json`: ```json { - "hooks": [{ - "event": "PermissionRequest|Notification|Stop", - "match_tool": "ToolName", // optional filter - "script": "hooks/your-script.sh" - }] + "hooks": { + "PermissionRequest": [ + { + "matcher": "*", + "hooks": [ + { + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/hooks/your-script.sh", + "timeout": 310 + } + ] + } + ] + } } ``` +Available events: `PreToolUse`, `PermissionRequest`, `PostToolUse`, `Notification`, `Stop`, `SubagentStart`, `SubagentStop`, `SessionStart`, `SessionEnd`, `UserPromptSubmit`, `TaskCompleted`, `TeammateIdle`, `ConfigChange`, `PreCompact` 3. Update `manifest.json` with new hook file ## Adding New Skills diff --git a/plugins/bvdr-interactive-notifications/hooks/interactive-permission.sh b/plugins/bvdr-interactive-notifications/hooks/interactive-permission.sh index bd381bd..bc4f784 100755 --- a/plugins/bvdr-interactive-notifications/hooks/interactive-permission.sh +++ b/plugins/bvdr-interactive-notifications/hooks/interactive-permission.sh @@ -252,8 +252,8 @@ end tell echo "$(date): User reply: $REPLY_TEXT" >> "$LOG_FILE" if [[ "$REPLY_TEXT" == "TIMEOUT" ]] || [[ "$REPLY_TEXT" == "CANCELLED" ]] || [[ -z "$REPLY_TEXT" ]]; then - # Fall back to terminal - echo '{"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"ask"}}}' + # Fall back to terminal prompt (no output = default permission dialog) + exit 0 else # Escape the reply for JSON REPLY_ESCAPED=$(echo "$REPLY_TEXT" | sed 's/\\/\\\\/g; s/"/\\"/g; s/\n/\\n/g') @@ -273,9 +273,8 @@ elif [[ "$RESULT" == *"No"* ]]; then echo "$(date): HOOK $HOOK_ID - Returning deny" >> "$DEBUG_LOG" echo '{"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"deny","message":"User denied via dialog"}}}' else - # Timeout or error - fall back to terminal prompt - echo "$(date): HOOK $HOOK_ID - Returning ask (fallback)" >> "$DEBUG_LOG" - echo '{"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"ask"}}}' + # Timeout or error - fall back to terminal prompt (no output = default permission dialog) + echo "$(date): HOOK $HOOK_ID - Falling back to terminal (timeout/error)" >> "$DEBUG_LOG" fi echo "$(date): HOOK $HOOK_ID - COMPLETE" >> "$DEBUG_LOG" diff --git a/plugins/bvdr-interactive-notifications/hooks/interactive-stop.sh b/plugins/bvdr-interactive-notifications/hooks/interactive-stop.sh index 1743ee0..eb88766 100755 --- a/plugins/bvdr-interactive-notifications/hooks/interactive-stop.sh +++ b/plugins/bvdr-interactive-notifications/hooks/interactive-stop.sh @@ -44,34 +44,38 @@ else FOLDER_PATH="Unknown" fi -# Try to get Claude's last text message from transcript +# Try to get Claude's last message +# Prefer the last_assistant_message field (available since v2.1.47), fall back to transcript parsing LAST_CLAUDE_MSG="" -echo "$(date): DEBUG transcript_path=$TRANSCRIPT_PATH" >> "$LOG_FILE" - -if [ -n "$TRANSCRIPT_PATH" ] && [ -f "$TRANSCRIPT_PATH" ]; then - echo "$(date): DEBUG transcript file exists" >> "$LOG_FILE" - - # Get the most recent assistant message line (searching backwards) - LAST_ASSISTANT_LINE=$(tac "$TRANSCRIPT_PATH" 2>/dev/null | grep -m 1 '"type":"assistant"') - echo "$(date): DEBUG last_assistant_line length=${#LAST_ASSISTANT_LINE}" >> "$LOG_FILE" - - if [ -n "$LAST_ASSISTANT_LINE" ]; then - # Extract all text content blocks and join them - # Handles both array content and edge cases - LAST_CLAUDE_MSG=$(echo "$LAST_ASSISTANT_LINE" | jq -r ' - .message.content // [] | - if type == "array" then - [.[] | select(.type=="text") | .text] | join("\n\n") - elif type == "string" then - . - else - "" - end - ' 2>/dev/null) - echo "$(date): DEBUG extracted_msg length=${#LAST_CLAUDE_MSG}" >> "$LOG_FILE" + +# First try: use last_assistant_message from hook input (fast, reliable) +LAST_CLAUDE_MSG=$(echo "$INPUT" | jq -r '.last_assistant_message // ""' 2>/dev/null) +echo "$(date): DEBUG last_assistant_message field length=${#LAST_CLAUDE_MSG}" >> "$LOG_FILE" + +# Fallback: parse transcript file if last_assistant_message was empty +if [ -z "$LAST_CLAUDE_MSG" ] || [ "$LAST_CLAUDE_MSG" = "null" ]; then + LAST_CLAUDE_MSG="" + echo "$(date): DEBUG falling back to transcript parsing, path=$TRANSCRIPT_PATH" >> "$LOG_FILE" + + if [ -n "$TRANSCRIPT_PATH" ] && [ -f "$TRANSCRIPT_PATH" ]; then + LAST_ASSISTANT_LINE=$(tac "$TRANSCRIPT_PATH" 2>/dev/null | grep -m 1 '"type":"assistant"') + + if [ -n "$LAST_ASSISTANT_LINE" ]; then + LAST_CLAUDE_MSG=$(echo "$LAST_ASSISTANT_LINE" | jq -r ' + .message.content // [] | + if type == "array" then + [.[] | select(.type=="text") | .text] | join("\n\n") + elif type == "string" then + . + else + "" + end + ' 2>/dev/null) + echo "$(date): DEBUG transcript extracted_msg length=${#LAST_CLAUDE_MSG}" >> "$LOG_FILE" + fi + else + echo "$(date): DEBUG transcript file NOT found or path empty" >> "$LOG_FILE" fi -else - echo "$(date): DEBUG transcript file NOT found or path empty" >> "$LOG_FILE" fi # Build dialog diff --git a/plugins/bvdr-smart-permissions/hooks/permission-ai-fallback.sh b/plugins/bvdr-smart-permissions/hooks/permission-ai-fallback.sh index ff467eb..25230fa 100755 --- a/plugins/bvdr-smart-permissions/hooks/permission-ai-fallback.sh +++ b/plugins/bvdr-smart-permissions/hooks/permission-ai-fallback.sh @@ -127,9 +127,9 @@ if [[ "$FIRST_LINE" == "ALLOW" ]]; then echo "{\"hookSpecificOutput\":{\"hookEventName\":\"PermissionRequest\",\"decision\":{\"behavior\":\"allow\",\"message\":\"AI: $REASON_ESCAPED\"}}}" exit 0 elif [[ "$FIRST_LINE" == "DENY" ]]; then - # AI recommends denying, but let the user make the final call via the normal permission dialog. - log "DECISION: DENY (asking user) — $REASON" - echo "{\"hookSpecificOutput\":{\"hookEventName\":\"PermissionRequest\",\"decision\":{\"behavior\":\"ask\",\"message\":\"AI recommends deny: $REASON_ESCAPED\"}}}" + # AI recommends denying — let the user make the final call via the normal permission dialog. + # No output = fall through to default permission dialog. + log "DECISION: DENY (falling back to user) — $REASON" exit 0 else # AI response wasn't clear — fail open to normal dialog