Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 36 additions & 9 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)"
}
}
}
Expand All @@ -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

Expand All @@ -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
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand All @@ -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"
Expand Down
56 changes: 30 additions & 26 deletions plugins/bvdr-interactive-notifications/hooks/interactive-stop.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down