Desktop Notifications for Claude Code: Never Miss a Completed Task
Overview
When working with Claude Code on complex tasks, you often switch to other work while waiting for completion. The challenge? Knowing exactly when Claude finishes so you can review the results promptly. This post shows you how to configure desktop notifications that alert you the moment Claude Code completes a task.
The Problem
Claude Code can run lengthy operations - refactoring codebases, writing tests, or analyzing large files. During these operations, you might:
- Switch to another VSCode window
- Check emails or documentation
- Work on a different task entirely
Without notifications, you're left constantly checking back, wasting time and breaking focus.
The Solution: OSC Escape Sequences
Operating System Command (OSC) escape sequences allow terminal applications to communicate with their host environment. Modern terminals like VSCode's integrated terminal, iTerm2, and Windows Terminal support OSC sequences for desktop notifications.
The magic sequence:
1# OSC 777 format (VSCode, rxvt-unicode)
2printf '\033]777;notify;Title;Message\007'
3
4# OSC 9 format (iTerm2, Windows Terminal)
5printf '\033]9;Message\007'
When sent to a terminal that supports it, these sequences trigger native desktop notifications - even when the terminal window isn't focused.
VSCode Remote SSH: The Key Use Case
This solution shines brightest when you're working on a remote EC2 instance via VSCode Remote SSH - a common setup for cloud-based development where your compute resources live in AWS but your IDE runs locally.
The Challenge with Remote Development
When Claude Code runs on a remote server:
- Notifications generated on the EC2 instance need to reach your local desktop
- Standard notification systems (like
notify-sendon Linux) only work locally - The remote server has no direct access to your desktop notification system
How OSC Sequences Bridge the Gap
Here's the magic: VSCode's integrated terminal forwards OSC escape sequences from the remote host to your local machine through the SSH connection. The data flow looks like this:
1Remote EC2 Local Machine
2โโโโโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโโโโ
3โ Claude Code โ โ โ
4โ โ โ SSH Tunnel โ โ
5โ notify_osc.sh โ โโโโโโโโโโโโโโ โ VSCode Terminal โ
6โ โ โ โ โ โ
7โ printf '\033]' โ โ OSC Parser โ
8โ โ โ โ โ
9โโโโโโโโโโโโโโโโโโโ โ Desktop Notify โ
10 โโโโโโโโโโโโโโโโโโโ
Required Extension: Terminal Notification
To convert OSC sequences into native desktop notifications, install the Terminal Notification extension by wenbopan:
Features:
- Recognizes OSC 777 (
\033]777;notify;Title;Message\007) and OSC 9 (\033]9;Message\007) sequences - Generates native notifications on macOS, Windows, and Linux
- Click-to-focus: Click a notification to jump directly to the originating terminal tab
- Remote SSH support: Works seamlessly with VSCode Remote SSH
- tmux compatible: Automatically unwraps sequences forwarded through tmux
Requirements:
- VSCode 1.93 or later
- Shell Integration enabled (default for most shells)
Why This Matters for Cloud Development
If you're running Claude Code on an EC2 instance (common for accessing more compute power or keeping development environments isolated), this setup means:
- No local Claude Code installation required - Everything runs on the remote server
- Native notifications on your laptop - Even though the work happens in AWS
- Works across network boundaries - SSH handles the transport layer
- No additional infrastructure - No webhook servers, no polling, just escape sequences
This is particularly valuable when:
- Running Claude Code on a powerful EC2 instance for faster processing
- Working with large codebases that benefit from cloud compute
- Maintaining development environments on remote servers
- Using multiple remote instances for different projects
Implementation
Step 1: Create the Notification Script
Create ~/.claude/hooks/notify_osc.sh:
1#!/bin/bash
2# Send notifications via OSC escape sequences to active terminals
3
4TITLE="${1:-Claude Code}"
5MESSAGE="${2:-Task completed}"
6
7LOG_DIR="$HOME/.claude/hooks"
8LOG_FILE="$LOG_DIR/notification.log"
9mkdir -p "$LOG_DIR"
10
11# Read hook input JSON from stdin
12if [ -t 0 ]; then
13 HOOK_INPUT=""
14else
15 HOOK_INPUT=$(cat)
16fi
17
18# Extract project and task information
19PROJECT_NAME=""
20TASK_SUMMARY=""
21
22if [ -n "$HOOK_INPUT" ] && command -v jq >/dev/null 2>&1; then
23 # Extract project name from cwd
24 CWD=$(echo "$HOOK_INPUT" | jq -r '.cwd // empty' 2>/dev/null)
25 if [ -n "$CWD" ]; then
26 PROJECT_NAME=$(basename "$CWD")
27 fi
28
29 # Extract transcript path
30 TRANSCRIPT_PATH=$(echo "$HOOK_INPUT" | jq -r '.transcript_path // empty' 2>/dev/null)
31
32 # Try to get task description from session file
33 if [ -n "$TRANSCRIPT_PATH" ] && [ -f "$TRANSCRIPT_PATH" ]; then
34 # Method 1: Find first queue-operation enqueue
35 TASK_SUMMARY=$(cat "$TRANSCRIPT_PATH" 2>/dev/null | \
36 jq -r 'select(.type == "queue-operation" and .operation == "enqueue") |
37 .content[].text // empty' 2>/dev/null | \
38 while IFS= read -r line; do
39 # Skip system messages
40 if [[ ! "$line" =~ ^\<(ide_opened_file|system-reminder|command-) ]]; then
41 echo "$line"
42 break
43 fi
44 done | head -c 100)
45
46 # Method 2: Fallback to first user message
47 if [ -z "$TASK_SUMMARY" ]; then
48 TASK_SUMMARY=$(cat "$TRANSCRIPT_PATH" 2>/dev/null | \
49 jq -r 'select(.type == "user") |
50 select(.isMeta == null or .isMeta == false) |
51 if .message.content | type == "array"
52 then .message.content[].text // empty
53 else .message.content end' 2>/dev/null | \
54 while IFS= read -r line; do
55 if [ -n "$line" ] && [ "$line" != "null" ] && \
56 [[ ! "$line" =~ ^\<(ide_opened_file|system-reminder|command-) ]]; then
57 echo "$line"
58 break
59 fi
60 done | head -c 100)
61 fi
62 fi
63fi
64
65# Build enhanced message
66ENHANCED_MESSAGE="$MESSAGE"
67if [ -n "$PROJECT_NAME" ]; then
68 ENHANCED_MESSAGE="[$PROJECT_NAME] $ENHANCED_MESSAGE"
69fi
70if [ -n "$TASK_SUMMARY" ]; then
71 ENHANCED_MESSAGE="$ENHANCED_MESSAGE - Task: $TASK_SUMMARY"
72fi
73
74# Log notification
75{
76 echo "[$(date '+%Y-%m-%d %H:%M:%S')] Notification sent:"
77 echo " Project: ${PROJECT_NAME:-N/A}"
78 echo " Message: $MESSAGE"
79 echo " Task: ${TASK_SUMMARY:-N/A}"
80} >> "$LOG_FILE"
81
82# Send to all writable pts devices
83for pts in /dev/pts/*; do
84 if [ "$pts" = "/dev/pts/ptmx" ]; then
85 continue
86 fi
87
88 if [ -w "$pts" ] 2>/dev/null; then
89 {
90 printf '\033]777;notify;%s;%s\007' "$TITLE" "$ENHANCED_MESSAGE"
91 printf '\033]9;%s: %s\007' "$TITLE" "$ENHANCED_MESSAGE"
92 printf '\a'
93 } > "$pts" 2>/dev/null
94 fi
95done
96
97exit 0
Make it executable:
1chmod +x ~/.claude/hooks/notify_osc.sh
Step 2: Configure Claude Code Hooks
Add to ~/.claude/settings.json:
1{
2 "hooks": {
3 "Stop": [
4 {
5 "hooks": [
6 {
7 "type": "command",
8 "command": "~/.claude/hooks/notify_osc.sh 'Claude Code' 'Task completed, please review results'",
9 "timeout": 10
10 }
11 ]
12 }
13 ],
14 "Notification": [
15 {
16 "matcher": "idle_prompt",
17 "hooks": [
18 {
19 "type": "command",
20 "command": "~/.claude/hooks/notify_osc.sh 'Claude Waiting' 'Claude has been idle for over 60 seconds'",
21 "timeout": 10
22 }
23 ]
24 }
25 ]
26 }
27}
Step 3: Test It
1# Manual test
2~/.claude/hooks/notify_osc.sh "Test" "Hello from Claude Code"
3
4# Check logs
5tail -f ~/.claude/hooks/notification.log
The Multi-Window Challenge
If you're like me, you probably have multiple VSCode Remote SSH windows open to the same server, working on different projects. The basic implementation above sends notifications to all terminal devices, which means:
- Project A completes โ notification appears in Project B's window
- Confusing and potentially distracting
Understanding the Problem
Claude Code hooks run as detached processes without a controlling terminal. When we examine the process tree:
1PID 794379 (bash): TTY_NR: 0
2PID 794302 (zsh): TTY_NR: 0
3PID 779087 (claude): TTY_NR: 0
All processes show TTY_NR: 0 - no controlling terminal. This makes it impossible to determine which VSCode window spawned the hook through standard process inspection.
The Solution: UUID-Based Terminal Mapping
Each VSCode instance has a unique identifier in the VSCODE_IPC_HOOK_CLI environment variable:
1VSCODE_IPC_HOOK_CLI=/run/user/1000/vscode-ipc-785147ca-2a10-4fce-becc-b5f600ca1dec.sock
We can extract this UUID and maintain a mapping to terminal devices.
Manual Registration Script
Create ~/.claude/hooks/register_current_terminal.sh:
1#!/bin/bash
2# Register current terminal for VSCode instance
3# Run this directly in your VSCode terminal
4
5LOG_DIR="$HOME/.claude/hooks"
6MAPPING_FILE="$LOG_DIR/terminal_mapping.txt"
7mkdir -p "$LOG_DIR"
8
9# Get current TTY
10CURRENT_TTY=$(tty)
11if [ "$CURRENT_TTY" = "not a tty" ]; then
12 echo "Error: Not running in a terminal"
13 exit 1
14fi
15
16# Extract VSCode UUID
17VSCODE_UUID=""
18if [ -n "$VSCODE_IPC_HOOK_CLI" ]; then
19 VSCODE_UUID=$(echo "$VSCODE_IPC_HOOK_CLI" | \
20 grep -oP 'vscode-ipc-\K[0-9a-f-]+(?=\.sock)')
21fi
22
23if [ -z "$VSCODE_UUID" ]; then
24 echo "Error: Could not extract VSCode UUID"
25 exit 1
26fi
27
28# Update mapping file
29if [ -f "$MAPPING_FILE" ]; then
30 grep -v "^$VSCODE_UUID:" "$MAPPING_FILE" > "$MAPPING_FILE.tmp" || true
31 mv "$MAPPING_FILE.tmp" "$MAPPING_FILE"
32fi
33
34echo "$VSCODE_UUID:$CURRENT_TTY" >> "$MAPPING_FILE"
35
36echo "โ Registered VSCode UUID: $VSCODE_UUID"
37echo "โ Terminal device: $CURRENT_TTY"
Enhanced Notification Script
Update notify_osc.sh to use the mapping:
1# Near the beginning, after extracting HOOK_INPUT
2MAPPING_FILE="$LOG_DIR/terminal_mapping.txt"
3TARGET_TTY=""
4
5# Extract VSCode UUID from environment
6VSCODE_UUID=""
7if [ -n "$VSCODE_IPC_HOOK_CLI" ]; then
8 VSCODE_UUID=$(echo "$VSCODE_IPC_HOOK_CLI" | \
9 grep -oP 'vscode-ipc-\K[0-9a-f-]+(?=\.sock)')
10
11 if [ -n "$VSCODE_UUID" ] && [ -f "$MAPPING_FILE" ]; then
12 TARGET_TTY=$(grep "^$VSCODE_UUID:" "$MAPPING_FILE" | cut -d: -f2)
13 fi
14fi
15
16# Send notification
17if [ -n "$TARGET_TTY" ] && [ -w "$TARGET_TTY" ]; then
18 # Send to specific terminal only
19 {
20 printf '\033]777;notify;%s;%s\007' "$TITLE" "$ENHANCED_MESSAGE"
21 printf '\033]9;%s: %s\007' "$TITLE" "$ENHANCED_MESSAGE"
22 printf '\a'
23 } > "$TARGET_TTY" 2>/dev/null
24else
25 # Fallback: broadcast to all terminals
26 for pts in /dev/pts/*; do
27 # ... existing broadcast logic
28 done
29fi
Usage
In each VSCode terminal window, run once:
1~/.claude/hooks/register_current_terminal.sh
Verify the mapping:
1cat ~/.claude/hooks/terminal_mapping.txt
2# Output:
3# 785147ca-2a10-4fce-becc-b5f600ca1dec:/dev/pts/2
4# 167d6e75-c42e-487d-9e9f-946e8396dd4f:/dev/pts/6
Now notifications will only appear in the correct VSCode window.
Extracting Task Descriptions
The notification is more useful when it includes what task was being performed. Claude Code stores session data in JSONL files at ~/.claude/projects/.
Session File Structure
1{"type":"queue-operation","operation":"enqueue","content":[
2 {"type":"text","text":"<ide_opened_file>...</ide_opened_file>"},
3 {"type":"text","text":"Actual user task here"}
4]}
Key Insights
Content is an array: The first element is often a system message (
<ide_opened_file>), the actual task is in subsequent elementsMultiple message types: Some sessions use
queue-operation, others use directusermessagesFiltering required: Skip system tags like
<ide_opened_file>,<system-reminder>, and<command-
The jq query that handles all cases:
1jq -r 'select(.type == "user") |
2 select(.isMeta == null or .isMeta == false) |
3 if .message.content | type == "array"
4 then .message.content[].text // empty
5 else .message.content end'
Troubleshooting
No Notifications Appearing
Check terminal support:
1printf '\033]777;notify;Test;Message\007'Verify hook execution:
1tail -f ~/.claude/hooks/notification.logCheck pts devices:
1ls -la /dev/pts/
Notifications in Wrong Window
Run the registration script in your current terminal:
1~/.claude/hooks/register_current_terminal.sh
Task Description Shows "null"
This usually means:
- Session file doesn't exist yet (new session)
jqis not installed- Session file format changed
Install jq:
1# Ubuntu/Debian
2sudo apt-get install jq
3
4# macOS
5brew install jq
Trade-offs
The Stop hook fires every time Claude pauses, not just on task completion. This means you might get notifications during:
- Multi-step tasks (between steps)
- When Claude asks clarifying questions
- Tool execution pauses
For most users, occasional extra notifications are preferable to the alternative - adding LLM-based completion detection that adds 30+ seconds of latency to every notification.
Conclusion
With this setup, you can confidently switch away from Claude Code knowing you'll be alerted the moment it needs your attention. The combination of OSC escape sequences and Claude Code hooks creates a seamless notification experience that works across VSCode Remote SSH sessions.
The multi-window solution using UUID-based terminal mapping ensures notifications reach the right window, and task description extraction provides context about what just completed.
All scripts are available in my dotfiles repository, and I hope this saves you as much context-switching overhead as it has for me.
Resources
- Claude Code Hooks Documentation
- Terminal Notification Extension - VSCode extension for OSC-based notifications
- OSC Escape Sequences Reference
- VSCode Terminal Documentation