A terminal window breathing green while a Claude Code session waits for input

If you run more than one Claude Code session at a time, you know the problem: a session finishes, fires off a desktop notification, and that notification either gets lost in the stack or sits there stale long after you’ve moved on. Worse, when five sessions share the same project directory, the notification can’t tell you which one actually needs you.

We wanted something better: a persistent, non-intrusive signal on the terminal itself — the right terminal — that appears the instant a session is waiting and disappears the instant you act. Here’s how we built it, and the one non-obvious trick that makes it work at all.

A terminal window lit a steady green, its title showing a bell and a green dot bouncing across three slots beside an example chat name "Refactor auth module".

The signal in action: the waiting session is lit green and its title animates so you can spot, at a glance, which chat needs you. (Shown here as a steady tint; the live version gently breathes.)

🎯 The Goal

  • Light up only the session that needs attention, not all of them.
  • Make it obvious which chat it is, even across identical working directories.
  • Persist until you actually respond — no auto-dismiss, no timeout.
  • Stay calm and out of the way: a soft glow, not a klaxon.

🪝 Hooks: The Entry Point

Claude Code lets you run shell commands on lifecycle events via hooks in ~/.claude/settings.json. Two of them mean “a human is needed”:

  • Notification — fires on a permission prompt or after an idle pause.
  • Stop — fires the instant a turn finishes, per session.

And three mean “the human is back, stand down”:

  • UserPromptSubmit — you sent a message.
  • PreToolUse — Claude resumed work.
  • SessionEnd — the session closed.

So the lifecycle is simple: the attention hooks turn the signal on, the resume hooks turn it off. Each hook receives a JSON payload on stdin with the session_id, cwd, and transcript_path.

🧨 The Trick: Hooks Have No Terminal

Here’s the wall everyone hits first. The obvious plan is “write some escape codes to the terminal to recolor it.” So you write to /dev/tty from your hook… and nothing happens.

The reason: a hook runs with no controlling terminal of its own. Check it and its tty is ?. There is no /dev/tty to write to. The escape codes vanish into the void.

The fix is to stop looking at the hook’s own process and walk up the process tree to the claude process, which does own a real pseudo-terminal, and write to that slave device directly:

resolve_tty() {
  local pid=$$ ppid tty
  for _ in $(seq 1 8); do
    read -r ppid tty < <(ps -o ppid=,tty= -p "$pid" 2>/dev/null)
    case "$tty" in
      pts/*|tty[0-9]*) printf '/dev/%s' "$tty"; return 0 ;;
    esac
    [ -z "$ppid" ] && break
    pid="$ppid"
  done
  return 1
}

Now you have something like /dev/pts/3 — the real device the terminal emulator is rendering. Write your escape codes there and they show up. That single redirection is the whole ballgame; everything else is decoration.

🎨 Painting the Signal With OSC Codes

Terminals understand OSC (Operating System Command) escape sequences for things like window title and background color. Three are all we need:

# Set the background colour (OSC 11)
printf '\033]11;#164a16\007' > /dev/pts/3
# Reset the background to the terminal default (OSC 111)
printf '\033]111\007'        > /dev/pts/3
# Set the window/tab title (OSC 0)
printf '\033]0;%s\007' "$title" > /dev/pts/3

Because Claude Code draws in the normal screen buffer (not the alternate screen), OSC 11 recolors the whole visible background — so a waiting session literally glows.

🌬️ Breathing, Not Blinking

A static color is easy to tune out, and a hard blink is annoying. So a small detached animator — launched with setsid so it outlives the hook that started it — gently ramps the background through a ramp of greens and back, about twice a second:

shades="#0d2b0d #123912 #164a16 #1c561c #246424 #1c561c #164a16 #123912"
while :; do
  for s in $shades; do
    printf '\033]11;%s\007' "$s" > "$tty_dev" || exit 0
    sleep 0.45
  done
done

Green, deliberately — a finished session is good news, not an error. The breath catches your eye in peripheral vision without screaming.

🏷️ A Title That Tells You Which Chat

The background tells you a session needs you; the title tells you which one. Two details made this work:

  • You can’t set the title’s text color — that’s controlled by your desktop theme. But colored emoji glyphs render in color even in the taskbar. So the marker is a 🔔 bell plus a green dot that bounces left-to-right through black dots — motion the eye catches instantly: 🔔 🟢⚫⚫🔔 ⚫🟢⚫🔔 ⚫⚫🟢.
  • The real chat name is recovered from the transcript. Claude Code writes its auto-generated topic to the transcript as ai-title lines, so the hook reads the latest one and appends it: 🔔 ⚫🟢⚫ Refactor auth module. Now a glance at the tab tells you exactly which conversation is waiting.

🛡️ Keeping It Honest

Unattended signals rot unless you design for cleanup:

  • One pulser per terminal. Each session’s animator is tracked by a session_id-keyed pidfile, and starting a new one first kills any stale pulser still targeting that same tty — so two animators can never fight over the title.
  • Always self-clean. The animator traps EXIT/TERM and resets the background and title on its way out, so a killed or orphaned process can never leave a terminal stuck green.
  • Per session, not global. Because Stop fires for the specific session that finished and the signal is keyed by session_id, only the terminal that needs you lights up — the others stay dark.

⚠️ Caveats Worth Knowing

  • Terminal support varies. OSC 0 (title) is near-universal, but OSC 11/111 (background recolor) is supported by VTE-based terminals (GNOME Terminal, Ptyxis), iTerm2, kitty, and a few others — not all. Inside tmux you need passthrough. The title animation degrades gracefully even where the background doesn’t recolor.
  • ai-title is undocumented. Reading the chat name out of the transcript relies on an internal format that could change between Claude Code versions. There’s a fallback to the directory name if it’s missing.
  • Linux-first. It leans on ps, setsid, and pkill; macOS needs minor tweaks.

📚 Lessons Learned

  • The unit that owns the terminal is not the process you’re running in. Once you internalize that a hook has no tty and walk up to the one that does, a whole category of “why won’t my escape codes show up?” problems disappears.
  • Persistent beats loud. A signal that quietly stays until you act is far more useful than one that fires once and fades.
  • Clean up by construction. Anything that paints the terminal must guarantee it un-paints itself on every exit path, or you’ll eventually stare at a stuck window and curse your past self.

🎯 Final Thought

This started as “I’m tired of missing when a session needs me” and ended as a tidy little system of hooks, a process-tree walk, and three escape codes. No daemon, no dependencies, no cloud — just the terminal you already have, finally telling you when it wants you.

— DankDev


Tags: Claude Code · Terminal · Bash · Hooks · Developer Tools · Linux

Claude CodeTerminalBashHooksDeveloper ToolsLinux

← Back to all posts