Shows the state of Claude (working, permission, input, idle, background, compacting) in the iTerm2 tab subtitle.

A Claude Code mod that shows what Claude is doing in the iTerm2 tab subtitle, so a glance at the tab bar tells which session needs you.
iTerm2 only. In any other terminal the mod writes nothing and reports nothing.
Requires macOS, iTerm2 and Claude Code 2.1.296, the version it was tested on. The hooks API it uses is early access and may change between releases.
| The tab shows | Meaning |
|---|---|
| ⚙️ working | Main agent is running |
| 🔐 permission | A permission dialog is waiting for you, the main agent's or a subagent's |
| ❓ input | A question dialog is waiting for you |
💤 idle Oct 10 12:40 | Nothing is running, since that time |
| 🧵 background | Main agent is idle, a subagent is running |
| 📦 compacting | The conversation is being compacted |
| (blank) | The session ended |
/plugin install agent-state --marketplace M-i-k-e-l/agent-state
\(user.claudeState)
The mod reads Claude Code's tool_decision telemetry records to see the moment you approve a permission dialog; no other event says so. It reads them and changes none, and it sends nothing anywhere. This is why claude plugin validate prints "hooks telemetry for: collector". It works with DISABLE_TELEMETRY=1 set.
The mod writes the state to the tab's terminal as an iTerm2 user variable, user.claudeState, which the Subtitle shows. It starts a short shell process only when the state changes, about two for each prompt you send.
/compact may leave 📦 until the next event (untested).claude -p or SDK session shows nothing unless AGENT_STATE_HEADLESS is set.Load the mod from a checkout by setting CLAUDE_CODE_PLUGIN_DIRS to its folder in the env block of ~/.claude/settings.json. Saving a file reloads the mod in every open session; a session that is working then shows 💤 until its next prompt.
Checks: claude plugin validate ., claude plugin test ., and tsc -p . once Claude Code has loaded the mod and written .claude-plugin/types/.
When checking by hand, stay in manual mode for a whole test: it is the easiest way to get a permission dialog.
Two environment variables, both unset by default:
| Variable | Effect when set |
|---|---|
AGENT_STATE_LOG | Names a file; the mod appends one line to it for each write it attempts |
AGENT_STATE_HEADLESS | Lets a session with no person at the prompt (claude -p, the SDK) write to the tab too |
A claude -p run stays silent by default because one started from inside another session's tool call would write over that session's tab.
Design notes, with the signal for each state and the by-hand test table: docs/design.md.
MIT
hooks/register.ts 241 lines1import type { EngineInterface, Register } from 'claude-code'
2
3// Shows Claude's state in the iTerm2 tab through the user variable the profile's
4// Subtitle reads: \(user.claudeState). Only an event that changes what the tab
5// shows starts a process.
6
7const VAR = 'claudeState'
8
9const WORKING = '⚙️ working'
10const PERMISSION = '🔐 permission'
11const INPUT = '❓ input'
12const IDLE = '💤 idle'
13const BACKGROUND = '🧵 background'
14const COMPACTING = '📦 compacting'
15const CLEARED = ''
16
17// $1 variable, $2 the state, $3 "1" to stamp the state with the date and time,
18// $4 the tty a write before this one found, or empty, $5 log line, $6 "1" when no
19// person is at the prompt (a -p run or the SDK).
20// The child has no controlling terminal of its own, so without $4 the tty is found
21// by walking up the process tree to the first ancestor that has one (claude, then
22// its shell). Prints the tty it used, for the next write to pass back. Exits 0 when
23// the state reached the tab, 2 when this session has no tab to write to and never
24// will, so the module stops asking, and 1 for any other failure.
25// Logs the write when the environment variable AGENT_STATE_LOG names a file.
26// A session with no person at the prompt writes only when AGENT_STATE_HEADLESS is
27// set: started from inside another session's tool, it would write over that
28// session's tab.
29const EMIT = `
30case "$TERM_PROGRAM$LC_TERMINAL" in *iTerm*) iterm=1 ;; *) iterm=0 ;; esac
31off=
32if [ "$iterm" = 0 ]; then off='not iTerm2'; fi
33if [ "$6" = 1 ] && [ -z "$AGENT_STATE_HEADLESS" ]; then off='no person at the prompt'; fi
34tty=none
35case "$4" in /dev/*) if [ -w "$4" ]; then tty="$4"; fi ;; esac
36if [ -z "$off" ] && [ "$tty" = none ]; then
37 p=$PPID
38 while [ -n "$p" ] && [ "$p" -gt 1 ]; do
39 t=$(ps -o tty= -p "$p" 2>/dev/null | tr -d ' ')
40 case "$t" in ttys*) if [ -w "/dev/$t" ]; then tty="/dev/$t"; break; fi ;; esac
41 p=$(ps -o ppid= -p "$p" 2>/dev/null | tr -d ' ')
42 done
43fi
44state="$2"
45if [ "$3" = 1 ]; then state="$state $(LC_ALL=C date '+%b %-d %H:%M')"; fi
46rc=-
47if [ -n "$off" ]; then rc="off, $off"; fi
48if [ -z "$off" ] && [ "$tty" != none ]; then
49 printf '\\033]1337;SetUserVar=%s=%s\\007' "$1" "$(printf '%s' "$state" | base64 | tr -d '\\n')" > "$tty" 2>/dev/null
50 rc=$?
51fi
52if [ -n "$AGENT_STATE_LOG" ]; then
53 printf '%s %s tty=%s wrote=[%s] rc=%s\\n' "$(date '+%H:%M:%S')" "$5" "$tty" "$state" "$rc" >> "$AGENT_STATE_LOG"
54fi
55printf '%s' "$tty"
56if [ -n "$off" ]; then exit 2; fi
57[ "$rc" = 0 ]
58`
59
60const loaded = Date.now()
61let seq = 0
62// One write at a time, in the order the events came: two states written at once
63// could land on the tab in the wrong order.
64let queue: Promise<unknown> = Promise.resolve()
65// The tab's tty, as the script last reported it. It does not change while the
66// process lives; a reload starts it over, and the next write searches again.
67let tty: string | undefined
68// The state the tab shows, as last written with success; undefined after a reload,
69// so the first write after one always goes out.
70let shown: string | undefined
71// Set once the script says this session has no tab to write to: no write is tried
72// again until a reload.
73let isOff = false
74// Whether no person is at the prompt, as session.start said; a reload raises
75// session.start again.
76let isHeadless = false
77// Whether the main agent's turn is running: what a finished tool or compaction
78// goes back to. A tool's end that arrives after an interrupt must not write working.
79let isTurnLive = false
80// The main agent's running turn, by id; undefined between turns and after a reload.
81let liveTurn: string | undefined
82
83// Writes the state to the tab; an event with no state leaves the tab alone, and so
84// does one whose state the tab already shows. A repeated idle keeps its first stamp.
85const emit = ($: EngineInterface, line: string, state: string | undefined): Promise<unknown> => {
86 if (state === undefined || isOff) return Promise.resolve()
87 const at = Date.now() - loaded
88 queue = queue.then(async () => {
89 if (state === shown || isOff) return
90 seq += 1
91 const stamped = `#${seq} +${at}ms ${line}`
92 const argv = ['sh', '-c', EMIT, 'sh', VAR, state, state === IDLE ? '1' : '0', tty ?? '', stamped, isHeadless ? '1' : '0']
93 const ran = await $.process.run(argv, { timeoutMs: 5000 }).catch(() => undefined)
94 // A failed search prints "none": caching that would stop every later write.
95 const found = ran?.stdout.trim()
96 if (found?.startsWith('/dev/')) tty = found
97 // A write that failed leaves the tab unknown, so the next one goes out whatever it is.
98 shown = ran?.exitCode === 0 ? state : undefined
99 if (ran?.exitCode === 2) isOff = true
100 })
101 return queue
102}
103
104// The tool calls in flight, each with the agent that made it: a decision names
105// its call, not its agent.
106const calls = new Map<string, string | undefined>()
107
108// The subagents with a permission dialog put to the person. A dialog of theirs
109// outranks working, background and idle: it is the person the session waits on.
110const waiting = new Set<string>()
111
112const who = (agentId: string | undefined): string => agentId ?? 'main'
113
114// What a running main agent shows.
115const active = (): string => (waiting.size > 0 ? PERMISSION : WORKING)
116
117// What the main agent rests in: background while a subagent of it still runs.
118const resting = async ($: EngineInterface): Promise<string> => {
119 if (waiting.size > 0) return PERMISSION
120 const agents = await $.agent.list().catch(() => [])
121 const live = agents.filter(a => a.status === 'running' || a.status === 'pending')
122 return live.length > 0 ? BACKGROUND : IDLE
123}
124
125// The sources of a decision that a person gave at the dialog, for this call alone
126// or with a rule kept for later ones (the first observed, the second from the engine's
127// monitoring docs). A rule, a mode or a hook deciding is not the person answering.
128const BY_PERSON = new Set<unknown>(['user_temporary', 'user_permanent'])
129
130// A subagent's dialog is over when the person approves it, its tool returns or its
131// turn ends: what the tab goes back to, or undefined when that subagent had no dialog up.
132const settled = async ($: EngineInterface, agentId: string): Promise<string | undefined> => {
133 if (!waiting.delete(agentId)) return undefined
134 return isTurnLive ? active() : resting($)
135}
136
137export const register: Register = on => {
138 on('session.start', async ($, e, next) => {
139 const started = await next(e)
140 isHeadless = !e.isInteractive
141 await emit($, `session.start surface=${e.surface} interactive=${e.isInteractive}`, IDLE)
142 return started
143 })
144
145 on('session.end', async ($, e, next) => {
146 // A /clear ends the conversation, not the process, and no session.start follows.
147 await emit($, `session.end reason=${e.reason}`, e.reason === 'clear' ? IDLE : CLEARED)
148 return next(e)
149 })
150
151 on('turn.start', async ($, e, next) => {
152 isTurnLive = true
153 liveTurn = e.turnId
154 await emit($, `turn.start turn=${e.turnId}`, active())
155 return next(e)
156 })
157
158 on('turn.complete', async ($, e, next) => {
159 // A prompt typed over a running turn starts the new turn before the old one's
160 // end is reported (observed): that late end must not put a working tab to idle.
161 const isCurrent = e.agentId === undefined && (liveTurn === undefined || liveTurn === e.turnId)
162 if (isCurrent) {
163 isTurnLive = false
164 liveTurn = undefined
165 }
166 // A subagent killed with its dialog up ends its turn without its tool returning.
167 const state = isCurrent ? await resting($) : e.agentId !== undefined ? await settled($, e.agentId) : undefined
168 await emit($, `turn.complete agent=${who(e.agentId)} reason=${e.reason} turn=${e.turnId}`, state)
169 return next(e)
170 })
171
172 on('tool.call', async ($, e, next) => {
173 const tool = String(e.tool)
174 const isMain = e.agentId === undefined
175 calls.set(e.tool_use_id, e.agentId)
176 await emit($, `tool.call> tool=${tool} agent=${who(e.agentId)}`, isMain && tool === 'AskUserQuestion' ? INPUT : undefined)
177 try {
178 const ran = await next(e)
179 calls.delete(e.tool_use_id)
180 const outcome = ran.deny !== undefined ? 'deny' : ran.isError === true ? 'error' : 'ok'
181 const after = e.agentId === undefined ? (isTurnLive ? active() : undefined) : await settled($, e.agentId)
182 await emit($, `tool.call< tool=${tool} agent=${who(e.agentId)} ${outcome}`, after)
183 return ran
184 } catch (error) {
185 calls.delete(e.tool_use_id)
186 const after = e.agentId === undefined ? undefined : await settled($, e.agentId)
187 await emit($, `tool.call! tool=${tool} agent=${who(e.agentId)} threw`, after)
188 throw error
189 }
190 })
191
192 on('session.compact', async ($, e, next) => {
193 const isMain = e.agentId === undefined && e.trigger !== 'precompute'
194 await emit($, `session.compact> trigger=${e.trigger} agent=${who(e.agentId)}`, isMain ? COMPACTING : undefined)
195 try {
196 const compacted = await next(e)
197 const after = isMain ? (isTurnLive ? WORKING : await resting($)) : undefined
198 await emit($, `session.compact< trigger=${e.trigger} ${'skip' in compacted ? 'skipped' : 'done'}`, after)
199 return compacted
200 } catch (error) {
201 await emit($, `session.compact! trigger=${e.trigger} threw`, isMain ? IDLE : undefined)
202 throw error
203 }
204 })
205
206 // Raised when the dialog is put to the person; a tool.check verdict of `ask`
207 // is not, since it also comes when no dialog is shown.
208 // A question put to the person is tool.call's to name, not a permission.
209 // No event of its own says the person approved: the decision record below does,
210 // and failing it the tab stays on permission until the approved tool ends.
211 on('classic.PermissionRequest', async ($, e, next) => {
212 const isPrompt = e.tool_name !== 'AskUserQuestion'
213 // A subagent's dialog is shown too, and remembered: its end is that
214 // subagent's tool returning, which otherwise writes nothing.
215 if (isPrompt && e.agent_id !== undefined) waiting.add(e.agent_id)
216 await emit(
217 $,
218 `classic.PermissionRequest tool=${e.tool_name} agent=${who(e.agent_id)} mode=${e.permission_mode}`,
219 isPrompt ? PERMISSION : undefined,
220 )
221 return next(e)
222 })
223
224 // The engine logs each decision on a tool call for the operator's collector, with
225 // or without one configured (observed): an accept the person gave, of a call in flight,
226 // is the approval of the dialog that is up, the main agent's or a subagent's. An
227 // accept from a rule, a mode or a hook comes with no dialog of its own, and may
228 // come while another call's dialog is up. Read, never changed.
229 on('telemetry.log', { to: 'collector' }, async ($, e, next) => {
230 const isApproval =
231 e.event === 'tool_decision' && e.attributes.decision === 'accept' && BY_PERSON.has(e.attributes.source)
232 const id = isApproval ? e.attributes.tool_use_id : undefined
233 if (typeof id === 'string' && calls.has(id)) {
234 const agentId = calls.get(id)
235 const state = agentId !== undefined ? await settled($, agentId) : isTurnLive ? active() : undefined
236 await emit($, `tool_decision accept call=${id} agent=${who(agentId)}`, state)
237 }
238 return next(e)
239 })
240}
241