#!/bin/bash # # Derived from ShadowTerm's shadowterm-notify.sh (hooks v2026.09.08). Same # payload and wire format; only branding and file names changed: # shadowterm-hook.sh -> shadowntfy-hook.sh # shadowterm-notify.sh -> shadowntfy-notify.sh # shadowterm.conf -> shadowntfy.conf # shadowterm-hook.log -> shadowntfy-hook.log # The SHADOWTERM_* variable names inside the conf are unchanged on purpose. # # ShadowNtfy Notification Hook # Version: 2026.09.08 # Source of truth for Claude Code one-way events → ntfy → phone. # Installed by: curl -fsSL https://shadowntfyapp.pages.dev/install.sh | bash # # Load configuration if [ -f "$HOME/.claude/hooks/shadowntfy.conf" ]; then # shellcheck source=/dev/null source "$HOME/.claude/hooks/shadowntfy.conf" fi NTFY_URL="${SHADOWTERM_NTFY_URL:-http://localhost:8080}" TOPIC="${SHADOWTERM_TOPIC:-claude-code}" AUTH_TOKEN="${SHADOWTERM_AUTH_TOKEN:-}" # Never publish to the public ntfy.sh service. ShadowNtfy only talks to a # self-hosted server; the installer refuses ntfy.sh and this covers a # hand-edited conf. case "$(printf '%s' "$NTFY_URL" | tr '[:upper:]' '[:lower:]' | sed -E 's#^[[:space:]]+##; s#[[:space:]]+$##; s#^[a-z][a-z0-9+.-]*://##; s#^[^@/]*@##; s#[/:?\#].*$##; s#\.$##')" in ntfy.sh|www.ntfy.sh|*%*) exit 0 ;; esac # Per-event toggles. Default OFF for noisy events so upgrades stay quiet # until the user opts in (permissions/questions live in shadowntfy-hook.sh). NOTIFY_TASK_COMPLETE="${SHADOWTERM_NOTIFY_TASK_COMPLETE:-0}" NOTIFY_SESSION_START="${SHADOWTERM_NOTIFY_SESSION_START:-0}" NOTIFY_SESSION_END="${SHADOWTERM_NOTIFY_SESSION_END:-0}" NOTIFY_TOOL_FAIL="${SHADOWTERM_NOTIFY_TOOL_FAIL:-0}" NOTIFY_IDLE="${SHADOWTERM_NOTIFY_IDLE:-0}" NOTIFY_STOP_FAILURE="${SHADOWTERM_NOTIFY_STOP_FAILURE:-1}" NOTIFY_SUBAGENT_STOP="${SHADOWTERM_NOTIFY_SUBAGENT_STOP:-0}" NOTIFY_AGENT_NEEDS_INPUT="${SHADOWTERM_NOTIFY_AGENT_NEEDS_INPUT:-1}" # Usage-limit auto-resume events (Claude Code 2.1.234+): default ON # because they only fire when you are likely away from the keyboard. # (PermissionDenied lives in shadowntfy-hook.sh: it can wait for a # phone "Retry".) NOTIFY_QUOTA="${SHADOWTERM_NOTIFY_QUOTA:-1}" # Read hook input from stdin INPUT=$(cat) # Extract common fields HOOK_EVENT=$(echo "$INPUT" | jq -r '.hook_event_name // "unknown"') SESSION_ID=$(echo "$INPUT" | jq -r '.session_id // "unknown"') CWD=$(echo "$INPUT" | jq -r '.cwd // "unknown"') # Friendly "project on host" label for multi-session users. PROJECT_NAME="$(basename "$CWD" 2>/dev/null)" if [ -z "$PROJECT_NAME" ] || [ "$PROJECT_NAME" = "." ] || [ "$PROJECT_NAME" = "/" ]; then PROJECT_NAME="Claude Code" fi HOST_NAME="$(hostname -s 2>/dev/null || echo unknown)" LABEL="${PROJECT_NAME} on ${HOST_NAME}" # Determine notification details. Explicit toggle 0 skips the POST. case "$HOOK_EVENT" in "Stop") [ "$NOTIFY_TASK_COMPLETE" != "1" ] && exit 0 EVENT_TYPE="task_complete" EVENT_KIND="task_complete" TITLE="$LABEL · Task complete" TAGS="complete" PRIORITY="default" DETAILS="Claude has finished working in $PROJECT_NAME" ;; "StopFailure") [ "$NOTIFY_STOP_FAILURE" != "1" ] && exit 0 EVENT_TYPE="error" EVENT_KIND="turn_failed" TITLE="$LABEL · Turn failed" TAGS="error,stopfailure" PRIORITY="high" ERROR_TYPE=$(echo "$INPUT" | jq -r '.error // .error_type // .message // "unknown"') DETAILS="Claude Code turn failed in $PROJECT_NAME: $ERROR_TYPE" ;; "SessionStart") [ "$NOTIFY_SESSION_START" != "1" ] && exit 0 EVENT_TYPE="session_started" EVENT_KIND="session_started" TITLE="$LABEL · Session started" TAGS="start" PRIORITY="low" SOURCE=$(echo "$INPUT" | jq -r '.source // "unknown"') DETAILS="Claude Code session started in $PROJECT_NAME (source: $SOURCE)" ;; "SessionEnd") [ "$NOTIFY_SESSION_END" != "1" ] && exit 0 EVENT_TYPE="session_ended" EVENT_KIND="session_ended" TITLE="$LABEL · Session ended" TAGS="end" PRIORITY="low" REASON=$(echo "$INPUT" | jq -r '.reason // "unknown"') DETAILS="Claude Code session ended in $PROJECT_NAME (reason: $REASON)" ;; "PostToolUseFailure") [ "$NOTIFY_TOOL_FAIL" != "1" ] && exit 0 EVENT_TYPE="error" EVENT_KIND="tool_failed" TITLE="$LABEL · Tool failed" TAGS="error,failed" PRIORITY="high" FAILED_TOOL=$(echo "$INPUT" | jq -r '.tool_name // "unknown"') ERROR=$(echo "$INPUT" | jq -r '.error // "unknown error"') DETAILS="$FAILED_TOOL failed in $PROJECT_NAME: $ERROR" ;; "SubagentStop") [ "$NOTIFY_SUBAGENT_STOP" != "1" ] && exit 0 EVENT_TYPE="info" EVENT_KIND="subagent_done" TITLE="$LABEL · Subagent finished" TAGS="subagent,complete" PRIORITY="low" AGENT_TYPE=$(echo "$INPUT" | jq -r '.agent_type // .agent_id // "subagent"') DETAILS="Subagent finished in $PROJECT_NAME ($AGENT_TYPE)" ;; "Notification") NOTIFICATION_TYPE=$(echo "$INPUT" | jq -r '.notification_type // "info"') MESSAGE=$(echo "$INPUT" | jq -r '.message // ""') TITLE=$(echo "$INPUT" | jq -r '.title // "Claude Code"') case "$NOTIFICATION_TYPE" in "permission_prompt"|"elicitation_dialog"|"elicitation_url_dialog") # Permission / elicitation prompts are handled by shadowntfy-hook.sh # (blocking + response topic). Avoid double-notifying. exit 0 ;; "elicitation_complete"|"elicitation_response"|"auth_success") # Bookkeeping events, nothing for the phone to do. exit 0 ;; "quota_auto_resume_fired") [ "$NOTIFY_QUOTA" != "1" ] && exit 0 EVENT_TYPE="info" EVENT_KIND="quota_resumed" TAGS="quota,resumed" PRIORITY="default" TITLE="$LABEL · Usage limit reset, Claude resumed" DETAILS="${MESSAGE:-Claude Code continued after the usage limit reset.}" ;; "quota_auto_resume_stale") [ "$NOTIFY_QUOTA" != "1" ] && exit 0 EVENT_TYPE="idle" EVENT_KIND="quota_waiting" TAGS="quota,waiting" PRIORITY="high" TITLE="$LABEL · Usage limit reset, press Enter to resume" DETAILS="${MESSAGE:-The limit reset while the machine slept; Claude Code is waiting for you.}" ;; "quota_auto_resume_disabled") [ "$NOTIFY_QUOTA" != "1" ] && exit 0 EVENT_TYPE="info" EVENT_KIND="quota_stopped" TAGS="quota,stopped" PRIORITY="default" TITLE="$LABEL · Stopped waiting for usage limit" DETAILS="${MESSAGE:-Claude Code ended its wait without continuing.}" ;; "idle_prompt") [ "$NOTIFY_IDLE" != "1" ] && exit 0 EVENT_TYPE="idle" EVENT_KIND="idle" TAGS="idle" PRIORITY="default" TITLE="$LABEL · Waiting for your input" DETAILS="$MESSAGE" ;; "agent_needs_input") [ "$NOTIFY_AGENT_NEEDS_INPUT" != "1" ] && exit 0 EVENT_TYPE="idle" EVENT_KIND="agent_needs_input" TAGS="idle,agent" PRIORITY="high" TITLE="$LABEL · Agent needs input" DETAILS="$MESSAGE" ;; "agent_completed") [ "$NOTIFY_TASK_COMPLETE" != "1" ] && exit 0 EVENT_TYPE="task_complete" EVENT_KIND="task_complete" TAGS="complete,agent" PRIORITY="default" TITLE="$LABEL · Agent completed" DETAILS="$MESSAGE" ;; *) EVENT_TYPE="info" EVENT_KIND="info" TAGS="info" PRIORITY="default" TITLE="$LABEL · $TITLE" DETAILS="$MESSAGE" ;; esac ;; *) exit 0 ;; esac PAYLOAD=$(jq -n \ --arg event_type "$EVENT_TYPE" \ --arg event_kind "${EVENT_KIND:-$EVENT_TYPE}" \ --arg details "$DETAILS" \ --arg session_id "$SESSION_ID" \ --arg cwd "$CWD" \ --arg project "$PROJECT_NAME" \ --arg host "$HOST_NAME" \ --arg hooks_version "2026.09.08" \ '{ event_type: $event_type, event_kind: $event_kind, details: $details, session_id: $session_id, cwd: $cwd, project: $project, host: $host, hooks_version: $hooks_version }') CURL_HEADERS=(-H "Title: $TITLE" -H "Tags: $TAGS" -H "Priority: $PRIORITY") if [ -n "$AUTH_TOKEN" ]; then CURL_HEADERS+=(-H "Authorization: Bearer $AUTH_TOKEN") fi curl -s -X POST "${NTFY_URL}/${TOPIC}" \ "${CURL_HEADERS[@]}" \ -d "$PAYLOAD" > /dev/null exit 0