SLOPSHOPPER

iterm-attention

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

newpromptprocess
v0.1.0no licenseupdated 2026-10-14roymacdonald/iterm-attention
A shopper browsing a rack in a slop shop
README

iterm-attention

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 areWhat happens
Looking at Claude's paneNothing
Same tab, another split pane selectedClaude's pane background blinks until you select it
Same window, another tabClaude's tab blinks until you switch to it
Another app, or another iTerm windowA 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.

Requirements

On first use macOS asks you to allow notifications from terminal-notifier and to let osascript control iTerm. Allow both.

Install

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

From a local clone

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.

Duplicate notifications

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.

How it works

  • 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.
  • The tab color is set with iTerm's tab-color escape code (OSC 6), written to the session's tty.
  • hooks/attention.ts holds the pure decision logic.

Development

claude plugin validate .
claude plugin test .

To try changes without installing, start a session with claude --plugin-dir ~/.claude/mods/iterm-attention.

Source 3 files
hooks/register.ts 98 lines
1import 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}
98
hooks/attention.ts 51 lines
1// 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']
51
types/index.d.ts 9 lines
1// 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