SLOPSHOPPER

agent-state

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

newguardprocess
v0.1.0MITupdated 2026-10-10M-i-k-e-l/agent-state
A shopper browsing a rack in a slop shop
README

agent-state

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 showsMeaning
⚙️ workingMain agent is running
🔐 permissionA permission dialog is waiting for you, the main agent's or a subagent's
❓ inputA question dialog is waiting for you
💤 idle Oct 10 12:40Nothing is running, since that time
🧵 backgroundMain agent is idle, a subagent is running
📦 compactingThe conversation is being compacted
(blank)The session ended

Set up

  1. Install the mod:
   /plugin install agent-state --marketplace M-i-k-e-l/agent-state
  1. In iTerm2, open Settings → Profiles → General and set the profile's Subtitle to:
   \(user.claudeState)
  1. Start a new Claude Code session. The tab shows 💤 idle, and ⚙️ working once you send a prompt.

What it reads

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.

How it works

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.

Limits

  • After you approve with "always allow", or if the approval record is missing, the tab may stay on 🔐 until the approved tool ends.
  • An interrupted /compact may leave 📦 until the next event (untested).
  • If something else changes the tab's variable, the tab stays wrong until the state next changes.
  • A claude -p or SDK session shows nothing unless AGENT_STATE_HEADLESS is set.
  • Claude Code on a Linux machine, reached over SSH from iTerm2, shows nothing (read from the code, not tried).
  • Not tried: tmux inside iTerm2, and a permission dialog in auto mode.

Development

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:

VariableEffect when set
AGENT_STATE_LOGNames a file; the mod appends one line to it for each write it attempts
AGENT_STATE_HEADLESSLets 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.

License

MIT

Source 1 files
hooks/register.ts 241 lines
1import 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