| date | 2026-03-28 | |||
|---|---|---|---|---|
| category | research | |||
| labels |
|
Reverse-engineered from Claude Code v2.1.86 minified source. Covers terminal detection, notification channel selection, escape sequence formats, and the OSC 0 window title lifecycle.
Claude Code detects the host terminal at startup to decide which notification protocol to use. The detection function checks environment variables in a strict priority order — first match wins.
| Priority | Check | Result |
|---|---|---|
| 1 | CURSOR_TRACE_ID set |
"cursor" |
| 2 | VSCODE_GIT_ASKPASS_MAIN contains "cursor" / "windsurf" / "antigravity" |
Respective IDE name |
| 3 | __CFBundleIdentifier (macOS) matches known bundle ID |
See bundle ID table below |
| 4 | TERMINAL_EMULATOR=JetBrains-JediTerm |
"pycharm" |
| 5 | TERM=xterm-ghostty |
"ghostty" |
| 6 | TERM contains "kitty" |
"kitty" |
| 7 | TERM_PROGRAM is set |
Returns value directly |
| 8 | TMUX is set |
"tmux" |
| 9 | STY is set |
"screen" |
| 10 | KONSOLE_VERSION is set |
"konsole" |
| 11 | GNOME_TERMINAL_SERVICE is set |
"gnome-terminal" |
| 12 | XTERM_VERSION is set |
"xterm" |
| 13 | VTE_VERSION is set |
"vte-based" |
| 14 | TERMINATOR_UUID is set |
"terminator" |
| 15 | KITTY_WINDOW_ID is set |
"kitty" |
| 16 | ALACRITTY_LOG is set |
"alacritty" |
| 17 | TILIX_ID is set |
"tilix" |
| 18 | WT_SESSION is set |
"windows-terminal" |
| 19 | SESSIONNAME + TERM=cygwin |
"cygwin" |
| 20 | MSYSTEM is set |
Lowercased value |
| 21 | ConEmuANSI / ConEmuPID / ConEmuTask |
"conemu" |
| 22 | WSL_DISTRO_NAME is set |
"wsl-<distro>" |
| 23 | SSH session detected | "ssh-session" |
| 24 | TERM contains known substring (alacritty, rxvt, termite, foot) |
Matched name |
| 25 | TERM is set |
Returns TERM value |
| 26 | !process.stdout.isTTY |
"non-interactive" |
| 27 | Fallback | null |
__CFBundleIdentifier |
Detected as |
|---|---|
com.googlecode.iterm2 |
"iTerm.app" |
com.apple.Terminal |
"Apple_Terminal" |
com.mitchellh.ghostty |
"ghostty" |
net.kovidgoyal.kitty |
"kitty" |
dev.warp.Warp-Stable |
"WarpTerminal" |
com.microsoft.VSCode |
"vscode" |
TERM_PROGRAM(priority 7) is checked beforeKITTY_WINDOW_ID(priority 15). If both are set,TERM_PROGRAMwins. This matters for embedded terminals where the parent process may setTERM_PROGRAMto something unrecognized.- Kitty detection has three paths: bundle ID (3),
TERMcontaining"kitty"(6), orKITTY_WINDOW_ID(15). TheTERMcheck fires beforeTERM_PROGRAM, soTERM=xterm-kittyworks withoutTERM_PROGRAM. - Ghostty detection has two paths: bundle ID (3) or
TERM=xterm-ghostty(5). Both fire beforeTERM_PROGRAM. - Multiplexers (
tmux,screen) are detected by their own env vars, not byTERM. Inside tmux,TERM_PROGRAMfrom the outer terminal may not be propagated, so Claude Code sees"tmux"instead of the actual terminal.
Once the terminal is detected, Claude Code selects a notification channel. The user can override with the preferredNotifChannel setting (in ~/.claude.json).
| Setting | Protocol | Format |
|---|---|---|
"auto" (default) |
Auto-detected | See below |
"iterm2" |
iTerm2 proprietary | OSC 9 |
"kitty" |
Kitty protocol | OSC 99 |
"ghostty" |
Ghostty protocol | OSC 777 |
"terminal_bell" |
Bell | \x07 |
"iterm2_with_bell" |
iTerm2 + Bell | Both |
"notifications_disabled" |
None | — |
When preferredNotifChannel is "auto", Claude Code maps the detected terminal to a channel:
| Detected terminal | Channel | Notes |
|---|---|---|
"iTerm.app" |
iTerm2 (OSC 9) | |
"kitty" |
Kitty (OSC 99) | Generates random 4-digit notification ID |
"ghostty" |
Ghostty (OSC 777) | |
"Apple_Terminal" |
terminal_bell or none |
Probes Terminal.app via AppleScript first |
| Anything else | no_method_available |
Silent no-op — no notification emitted |
The no_method_available fallback is completely silent. There is no error, no log, no bell. If Claude Code doesn't recognize your terminal, turn-complete notifications simply don't fire.
The richest format. Supports multi-part messages, base64 encoding, and notification lifecycle management.
Sequence: ESC ] 99 ; <metadata> ; <payload> ST
Metadata is colon-separated key=value pairs:
| Key | Purpose | Values |
|---|---|---|
i |
Notification ID | String (Claude Code uses random 4-digit number) |
p |
Payload type | title, body, icon, close, alive, ? |
d |
Done flag | 0 = more parts coming, 1 or absent = display now |
e |
Encoding | 0 = raw UTF-8 (default), 1 = base64 |
a |
Actions | focus, report (click behavior) |
o |
Occasion | always, unfocused, invisible |
u |
Urgency | 0 (low), 1 (normal), 2 (critical) |
Multi-part example (as Claude Code sends it):
ESC]99;i=1234:d=0:p=title;Claude CodeST
ESC]99;i=1234:p=body;Claude is waiting for your inputST
Title is sent first with d=0 (more coming). Body follows and implicitly completes the notification (d absent = done).
Claude Code's idle notification:
- Title:
"Claude Code" - Body:
"Claude is waiting for your input" - Internal
notificationType:"idle_prompt"
Sequence: ESC ] 777 ; notify ; <title> ; <body> ST
Only the notify subcommand triggers a notification. Other subcommands (e.g., precmd) are passed through to the terminal.
Notification: ESC ] 9 ; <body> ST
Body is the notification text. No title field — the terminal typically uses the application name.
Progress bar (iTerm2 only): ESC ] 9 ; 4 ; <type> ; <value> ST
<type> |
Meaning |
|---|---|
0 |
Clear / remove progress |
1 |
Set progress (value = 0–100) |
2 |
Error state |
3 |
Indeterminate |
Claude Code emits OSC 9;4;0; (progress clear) during startup — but only when it detects an iTerm2-compatible terminal. When detected as Kitty or Ghostty, no progress sequences are emitted.
Simple attention signal with no content. Used as the "terminal_bell" channel and as a secondary signal in "iterm2_with_bell" mode.
While not a notification protocol, Claude Code's OSC 0 (set window title) updates are the most reliable indicator of its processing state. These fire regardless of detected terminal or notification channel.
| Prefix | Unicode | Meaning |
|---|---|---|
✳ |
U+2733 (Eight Spoked Asterisk) | Idle / ready for input |
⠂ |
U+2802 (Braille Pattern Dots-2) | Processing (spinner frame 1) |
⠐ |
U+2810 (Braille Pattern Dots-5) | Processing (spinner frame 2) |
The title body changes from "Claude Code" to the current task description once processing begins.
✳ Claude Code ← startup complete, ready for input
⠂ Claude Code ← turn started (input received)
⠐ <task description> ← processing (spinner rotates)
⠂ <task description> ← processing
... ← spinner continues
✳ <task description> ← turn complete, idle
| Transition | Meaning | Reliability |
|---|---|---|
| First OSC 0 (any content) | TUI rendered, ready for input | Always fires |
✳ → spinner (⠂/⠐) |
Turn started | Always fires |
spinner → ✳ |
Turn complete | Always fires, immediate |
During startup, Claude Code may briefly flicker between ✳ and spinner states:
✳ Claude Code ← initial idle
⠂ Claude Code ← brief busy
✳ Claude Code ← back to idle
✳ Claude Code ← stable idle
Applications monitoring these transitions should account for this by ignoring rapid ✳→spinner→✳ transitions during the first few seconds, or by using the first OSC 0 of any kind as the "ready" signal rather than waiting for a specific ✳ prefix.
Claude Code emits OSC 8 to linkify URLs in its output:
ESC]8;id=<id>;<url>ST<visible text>ESC]8;;ST
Not related to notifications.
Empirically observed timings:
| Event | Typical timing |
|---|---|
OSC 0 ✳→spinner |
Within ~1s of prompt submission |
OSC 0 spinner→✳ |
Immediate when processing finishes |
| OSC 99 notification | Delayed — governed by messageIdleNotifThresholdMs |
messageIdleNotifThresholdMs default |
60,000 ms (1 minute) |
The OSC 99/777/9 notification is NOT a "turn complete" signal. It's an "idle for N seconds" signal, fired by a timer after Claude Code has been waiting for input. The actual turn-complete signal is the OSC 0 spinner→✳ transition.
This means:
- OSC 99 may fire minutes after the turn completes
- OSC 99 may not fire at all if the user starts a new turn before the idle threshold
- For real-time busy/idle tracking, use OSC 0 title transitions
- For user-facing desktop notifications ("Claude is waiting"), use OSC 99/777/9
If you're building a terminal application that embeds Claude Code (via node-pty, PTY, or similar), here's what you need to know:
Set one of these in the PTY spawn environment:
| Approach | Env var | Notification format |
|---|---|---|
| Kitty (recommended) | TERM_PROGRAM=kitty |
OSC 99 (richest format) |
| Ghostty | TERM_PROGRAM=ghostty |
OSC 777 |
| iTerm2 | TERM_PROGRAM=iTerm.app |
OSC 9 + progress bar |
| Bell only | User sets preferredNotifChannel=terminal_bell |
\x07 |
Do not set TERM_PROGRAM to an unrecognized value — Claude Code will silently skip all notifications.
- Ready for input: First OSC 0 window title update
- Turn started: OSC 0 title prefix changes from
✳to a braille spinner character - Turn complete: OSC 0 title prefix changes from spinner back to
✳ - Desktop notification: OSC 99/777/9 (delayed, use for user-facing popups only)
Claude Code may dual-emit notifications (e.g., both OSC 99 and bell). Deduplicate on body content within a ~500ms window to avoid double-processing.
- Claude Code v2.1.86 minified source (
~/.local/share/mise/installs/npm-anthropic-ai-claude-code/2.1.86/bin/claude) - Kitty Desktop Notifications Protocol
- Empirical observation via OSC debug catch-all logging in xterm.js v6