Flash the iTerm2 pane or tab when Claude needs you, and make the notification jump to it

A Claude Code mod that makes iTerm2 get your attention when Claude needs you: on a permission prompt, a question, an idle wait, or the end of a turn.
| Where you are | What happens |
|---|---|
| Looking at Claude's pane | Nothing |
| Same tab, another split pane selected | Claude's pane background blinks until you select it |
| Same window, another tab | Claude's tab blinks until you switch to it |
| Another app, or another iTerm window | A macOS notification that, when clicked, brings iTerm forward on Claude's window, tab and pane |
The blinking stops when you focus the pane, send a prompt, or after 30 minutes. The pane's own colors are put back and the notification is removed.
brew install terminal-notifierOn first use macOS asks you to allow notifications from terminal-notifier and to let osascript control iTerm. Allow both.
In a Claude Code session, type:
/plugin install iterm-attention --marketplace roymacdonald/iterm-attention
Answer y to add the marketplace, then choose a scope (user scope loads it in every session).
From a shell:
claude plugin install iterm-attention --marketplace roymacdonald/iterm-attention
git clone https://github.com/roymacdonald/iterm-attention ~/.claude/mods/iterm-attention
claude plugin marketplace add ~/.claude/mods/iterm-attention
claude plugin install iterm-attention@roy-mods
A plugin installed from a folder is read from that folder. After editing it, run /reload-plugins in an open session.
A mod can't stop Claude Code's own notification, so you may get two. If so, set Notifications to the terminal bell in /config.
hooks/register.ts listens for Claude Code's Notification and Stop events. It finds its own pane from ITERM_SESSION_ID and checks iTerm about every 0.35 s in the background, so Claude is never held up.hooks/iterm.applescript does the iTerm work: it finds the session, reports whether you're looking at it, sets the pane background, and focuses the session when the notification is clicked.OSC 6), written to the session's tty.hooks/attention.ts holds the pure decision logic.claude plugin validate .
claude plugin test .
To try changes without installing, start a session with claude --plugin-dir ~/.claude/mods/iterm-attention.
hooks/register.ts 98 lines1import type { EngineInterface as Engine, Register } from 'claude-code'
2
3import { flashAction, isFocused, needsNotification, parseProbe, sessionUuid, ATTENTION_TYPES } from './attention'
4import type { Probe } from './attention'
5
6const FLASHING = { plugin: 'iterm-attention', key: 'flashing' } as const
7const TICK_MS = 350
8const GIVE_UP_MS = 30 * 60 * 1000
9
10let stopFlash: (() => void) | undefined
11
12function script($: Engine): string {
13 return `${$.plugin.root}/hooks/iterm.applescript`
14}
15
16async function iterm($: Engine, uuid: string, action: string, bg?: string): Promise<Probe | undefined> {
17 const argv = ['osascript', script($), uuid, action, ...(bg ? [bg] : [])]
18 const { exitCode, stdout } = await $.process.run(argv, { timeoutMs: 5000 })
19 return exitCode === 0 ? parseProbe(stdout) : undefined
20}
21
22async function notify($: Engine, uuid: string, message: string) {
23 const focus = `osascript '${script($)}' ${uuid} focus`
24 await $.process.run([
25 'terminal-notifier',
26 '-title', 'Claude Code',
27 '-message', message || 'Claude needs your attention',
28 '-group', `claude-attention-${uuid}`,
29 '-execute', focus,
30 ])
31}
32
33async function restore($: Engine) {
34 const { value } = await $.state.get(FLASHING)
35 if (!value) return
36 await $.state.set(FLASHING, null)
37 await iterm($, value.sessionId, 'off', value.bg).catch(() => undefined)
38 await $.process.run(['terminal-notifier', '-remove', `claude-attention-${value.sessionId}`]).catch(() => undefined)
39}
40
41async function attend($: Engine, message: string) {
42 if (stopFlash) return
43 const uuid = sessionUuid(await $.env.get('ITERM_SESSION_ID'))
44 if (!uuid) return
45
46 const first = await iterm($, uuid, 'probe')
47 if (!first || isFocused(first)) return
48
49 let isStopped = false
50 stopFlash = () => {
51 isStopped = true
52 }
53 await $.state.set(FLASHING, { sessionId: uuid, bg: first.bg })
54 if (needsNotification(first)) await notify($, uuid, message)
55
56 void (async () => {
57 const startedAt = await $.clock.now()
58 let isLit = true
59 let probe: Probe | undefined = first
60 try {
61 while (!isStopped && probe && !isFocused(probe)) {
62 if ((await $.clock.now()) - startedAt > GIVE_UP_MS) break
63 probe = await iterm($, uuid, flashAction(probe, isLit), first.bg)
64 isLit = !isLit
65 await $.clock.sleep(TICK_MS)
66 }
67 } finally {
68 stopFlash = undefined
69 await restore($)
70 }
71 })()
72}
73
74export const register: Register = on => {
75 // a reload mid-flash leaves the colors changed: put them back
76 on('session.start', ($, e, next) => {
77 void restore($).catch(() => undefined)
78 return next(e)
79 })
80
81 // observe only: the flash runs beside the engine, never in its way
82 on('classic.Notification', ($, e, next) => {
83 if (ATTENTION_TYPES.includes(e.notification_type)) void attend($, e.message).catch(() => undefined)
84 return next(e)
85 })
86
87 on('classic.Stop', ($, e, next) => {
88 void attend($, 'Claude finished and is waiting for you').catch(() => undefined)
89 return next(e)
90 })
91
92 // the person answered (possibly from elsewhere): stop blinking
93 on('prompt.submit', ($, e, next) => {
94 stopFlash?.()
95 return next(e)
96 })
97}
98hooks/attention.ts 51 lines1// Pure decisions, kept apart from the hooks so tests can reach them.
2
3export type Probe = {
4 isFront: boolean
5 isFrontWindow: boolean
6 isTabCurrent: boolean
7 isSessionCurrent: boolean
8 tty: string
9 bg: string
10}
11
12export type FlashAction = 'pane-on' | 'tab-on' | 'off'
13
14export function parseProbe(out: string): Probe | undefined {
15 const parts = out.trim().split('|')
16 if (parts.length !== 6) return undefined
17 const [front, win, tab, sess, tty = '', bg = ''] = parts
18 return {
19 isFront: front === 'true',
20 isFrontWindow: win === 'true',
21 isTabCurrent: tab === 'true',
22 isSessionCurrent: sess === 'true',
23 tty,
24 bg,
25 }
26}
27
28// Looking at it: iTerm in front, our window in front, our tab shown, our pane selected.
29export function isFocused(p: Probe): boolean {
30 return p.isFront && p.isFrontWindow && p.isTabCurrent && p.isSessionCurrent
31}
32
33// Away from iTerm, or in another iTerm window: only a notification can reach them.
34export function needsNotification(p: Probe): boolean {
35 return !p.isFront || !p.isFrontWindow
36}
37
38// Our tab is shown: blink the pane; otherwise blink the tab.
39export function flashAction(p: Probe, isLit: boolean): FlashAction {
40 if (!isLit) return 'off'
41 return p.isTabCurrent ? 'pane-on' : 'tab-on'
42}
43
44// The session's uuid from ITERM_SESSION_ID ("w0t3p0:481DDD8B-...").
45export function sessionUuid(itermSessionId: string | undefined): string | undefined {
46 const uuid = itermSessionId?.split(':')[1]
47 return uuid ? uuid : undefined
48}
49
50export const ATTENTION_TYPES = ['permission_prompt', 'idle_prompt', 'elicitation_dialog']
51types/index.d.ts 9 lines1// the pane's own background while a flash runs, so a reload can put it back
2export type Flashing = { sessionId: string; bg: string } | null
3
4declare module 'claude-code' {
5 interface PluginState {
6 'iterm-attention': { flashing: Flashing }
7 }
8}
9