SLOPSHOPPER

agent-watch

Keeps the number of running Claude Code sessions under a limit and shows the ones waiting on you or sitting idle.

newpanebandguardcommandtoast
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · agent-watch
│ ┃ Agents ✕ › fix the failing auth test and add an audit log call │ ┃ 0 of 3 running · 1 idle │ ┃ ○ idle <1m app (this one) ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /agents-list │ ⎿ agent-watch: Agent Watch: 1 idle (limit 3). │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Agents
0 of 3 running · 1 idle ○ idle <1m app (this one)
README

Agent Watch

A Claude Code mod that keeps the number of running Claude Code sessions under a limit.

Run a few sessions side by side and it's easy to lose count, or to forget the one that has been sitting on a permission prompt for twenty minutes. Agent Watch:

  1. Counts every Claude Code session you have open, across terminals and the desktop app. Headless runs (claude -p, scripts, CI) don't count.
  2. Warns you on prompt submit when you're at or over the limit (default 3): Hey Elio, be aware you are already running 3 agents.
  3. Shows which sessions are waiting on you or sit idle, so you find the ones you forgot.

Why

AI agents are fast enough that it's tempting to start one more while the last one is still working, and then one more. Before you know it you're juggling five sessions, switching context all the time and no longer reading what they do. I wrote about this in The AI chaos beast in your head. One of the boundaries from that post is to run two or three agents I can actually follow, instead of five I forget about.

Agent Watch is that boundary, built into Claude Code. The default limit is 3, and it doesn't block you unless you turn on strict mode. It reminds you when you're about to start one more, and points at the sessions you've lost track of.

Screenshot

<!-- TODO: replace with a real screenshot: docs/screenshot.png -->

Screenshot placeholder. Until there's a real one, here is a live session from testing (terminal, trimmed to fit):

❯ /agents-list
  ⎿  agent-watch: Agent Watch: 1 working · 1 waiting · 1 idle (limit 2).
╭──────────────────────────────────────────────────────────────────────────╮
│ 2 of 2 running · 1 working · 1 waiting · 1 idle                        ✕ │
│ ◆ waiting     <1m agent-watch                                            │
│ ○ idle        <1m docs (this one)                                        │
│ ● working     <1m claude-agent-watch-mod                                 │
╰──────────────────────────────────────────────────────────────────────────╯
1 working · 1 waiting · 1 idle (limit 2)
──────────────────────────────────────────────────────────────────────────────
❯
──────────────────────────────────────────────────────────────────────────────
                         agent-watch: Hey eliostruyf, be aware you are already running 2 agents.

Install

In Claude Code:

/plugin marketplace add estruyf/claude-agent-watch-mod
/plugin install agent-watch@agent-watch-mod

Or from a shell:

claude plugin marketplace add estruyf/claude-agent-watch-mod
claude plugin install agent-watch@agent-watch-mod

Restart your Claude Code sessions afterwards. Every session needs the plugin to be counted, since each session reports itself.

Update

/plugin marketplace update agent-watch-mod
/plugin update agent-watch@agent-watch-mod

Or claude plugin marketplace update agent-watch-mod && claude plugin update agent-watch@agent-watch-mod, then restart your sessions.

Use it

WhatWhere
3 working · 1 waiting · 2 idleThe band above the prompt, while other sessions are open. It adds (limit 3) in yellow once you reach the limit, and hides when this is your only session or a survey is showing.
The warning toastOn prompt submit, when the other sessions that are working or waiting reach the limit. A prompt typed while this session's own turn is already running doesn't warn, since that session is counted already.
/agents-listOpens a pane listing every session with its folder, status and time in that state: waiting first, then idle (longest first), then working.
/agents-limit <n>Overrides the limit for every session (stored in the plugin's store). /agents-limit shows the current limit, /agents-limit reset goes back to the configured one.
The forgotten nudgeA toast when another session has been idle or waiting on you longer than idleMinutes, once per session per state change. Only the session you prompted most recently shows it, so you don't get the same toast in every terminal.

Why /agents-list and not /agents? /agents is Claude Code's built-in command for managing subagents, and plugins can't take over a built-in's name.

Session states

StateWhenCounts toward the limit
WorkingA turn is running (prompt.submit, turn.start)yes
WaitingBlocked on you: a permission prompt (classic.PermissionRequest), a permission notification (classic.Notification) or an AskUserQuestionyes
Idleturn.complete fired and no new prompt sinceno, only shown in the band, the pane and the forgotten nudge
Removedsession.end (including /exit and /clear), or no heartbeat for 2 minutesno

Headless runs (claude -p) draw on no surface, so they write no status file and are never counted or shown. A desktop session counts from the moment its surface attaches.

Subagent turns don't change the state. A permission prompt raised by a subagent does make the session waiting, because it blocks on you all the same.

Configure

OptionDefaultWhat it does
limit3How many other sessions may be working or waiting before the warning shows.
idleMinutes30When another session counts as forgotten.
strictfalseAt the limit, hold the prompt instead of only warning. The prompt goes back in the box; press Enter again to send it anyway.
countSubagentsfalseAlso count this session's running background subagents (via $.agent.list()).
name""The name the warning greets you with. Empty uses $USER.

Set them in Claude Code with /plugin configure agent-watch@agent-watch-mod, or from a shell:

claude plugin configure agent-watch@agent-watch-mod            # show the options and which are set
echo '{"limit":"4","strict":"true"}' | claude plugin configure agent-watch@agent-watch-mod --values-stdin

Both write pluginConfigs["agent-watch@agent-watch-mod"].options in your user settings. Restart your sessions to apply.

A /agents-limit override wins over the configured limit until you run /agents-limit reset.

How it works

Every session runs its own copy of the mod and writes its own status file:

~/.claude/agent-watch/<session-id>.json     (under $CLAUDE_CONFIG_DIR when that is set)
{ "id": "...", "cwd": "/path/to/project", "status": "working", "since": 1790966403720, "heartbeat": 1790966433720, "prompted": 1790966403700 }

prompted is the last time you sent a prompt in that session (left out until you do). All sessions read the same files and pick the same nudger: the live session you prompted last, never one that is forgotten itself.

  • One file per session, not a shared store, so sessions never overwrite each other.
  • Heartbeat every 30 s ($.clock.every) rewrites the file. The other sessions' files are read every 10 s so the band stays current.
  • Files with a heartbeat older than 2 minutes are ignored, so a crashed session drops out by itself.
  • On session.end the file is marked ended. $.fs has no delete, so ended and stale files older than an hour are removed with rm -f (on systems without rm they're only ignored).
  • Runtime values (the session list, this session's status, the limit, what has been nudged) live in $.state, so a hot reload keeps them.

Develop

git clone https://github.com/estruyf/claude-agent-watch-mod
cd claude-agent-watch-mod
claude plugin validate plugins/agent-watch     # the manifest and the hooks module
claude plugin validate .                       # the marketplace
claude plugin test plugins/agent-watch         # 32 tests
claude --plugin-dir plugins/agent-watch        # run it; saving a file hot-reloads it

To try options without touching your settings:

claude --plugin-dir plugins/agent-watch \
  --settings '{"pluginConfigs":{"agent-watch@inline":{"options":{"strict":true,"limit":1}}}}'

Layout:

.claude-plugin/marketplace.json      the marketplace, listing ./plugins/agent-watch
plugins/agent-watch/
  .claude-plugin/plugin.json         manifest and userConfig
  hooks/hooks.json                   names the hooks module
  hooks/register.tsx                 every hook: registry, nudge, pane and band
  hooks/shared.ts                    pure helpers (parsing, counting, sorting, formatting)
  types/index.d.ts                   the $.state contract
  tests/*.test.ts                    claude plugin test

.claude-plugin/types/ inside the plugin is written by Claude Code each time it loads the mod and is git-ignored. tsc -p plugins/agent-watch type-checks against it once the mod has loaded once.

License

MIT

Source 3 files
hooks/register.tsx 561 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import {
5  countOf,
6  folderName,
7  forgotten,
8  formatDuration,
9  HEARTBEAT_MS,
10  LOOK_MS,
11  isLive,
12  isPrunable,
13  isSafeId,
14  nudgeKey,
15  nudgerOf,
16  othersActive,
17  parseRecord,
18  readOptions,
19  sortForPane,
20  warningText,
21} from './shared'
22import type { AgentRecord, AgentSelf, AgentStatus, Options } from './shared'
23
24type Engine = EngineInterface
25
26const PANE = 'agent-watch'
27
28const sessions = atom({ plugin: 'agent-watch', key: 'sessions' } as const, [])
29const checkedAt = atom({ plugin: 'agent-watch', key: 'checkedAt' } as const, 0)
30const self = atom({ plugin: 'agent-watch', key: 'self' } as const, null)
31const limit = atom({ plugin: 'agent-watch', key: 'limit' } as const, 3)
32const subagents = atom({ plugin: 'agent-watch', key: 'subagents' } as const, 0)
33const nudged = atom({ plugin: 'agent-watch', key: 'nudged' } as const, [])
34const pendingConfirm = atom(
35  { plugin: 'agent-watch', key: 'pendingConfirm' } as const,
36  null,
37)
38
39// ---------------------------------------------------------------------------
40// Registry: one status file per session in ~/.claude/agent-watch/<id>.json
41// ---------------------------------------------------------------------------
42
43/** `~/.claude/agent-watch`, or under CLAUDE_CONFIG_DIR when that is set. */
44const watchDir = async ($: Engine): Promise<string> => {
45  const configDir = await $.env.get('CLAUDE_CONFIG_DIR')
46  const home = (await $.env.get('HOME')) ?? (await $.env.get('USERPROFILE')) ?? '.'
47  const base = configDir !== undefined && configDir !== '' ? configDir : `${home}/.claude`
48
49  return `${base.replace(/[\\/]+$/, '')}/agent-watch`
50}
51
52const fileOf = (dir: string, id: string) => `${dir}/${id}.json`
53
54const writeRecord = async ($: Engine, record: AgentRecord): Promise<void> => {
55  if (!isSafeId(record.id)) return
56  const dir = await watchDir($)
57  await $.fs.write(fileOf(dir, record.id), `${JSON.stringify(record, null, 2)}\n`)
58}
59
60/**
61 * This session's own status, minted on first use and again when the session
62 * id changed under it (a /clear goes on under a new id).
63 */
64const ensureSelf = async ($: Engine): Promise<AgentSelf> => {
65  const id = await $.session.id()
66  const held = await read($, self)
67  if (held !== null && held.id === id) return held
68
69  const fresh: AgentSelf = { id, status: 'idle', since: await $.clock.now(), before: null }
70  await update($, self, () => fresh)
71
72  return fresh
73}
74
75/**
76 * Writes this session's file with a fresh heartbeat and mirrors it in state.
77 * A session that draws nowhere (a `claude -p` run) writes none and is not
78 * counted: null. A desktop session counts once its surface attaches.
79 */
80const writeSelf = async ($: Engine): Promise<AgentRecord | null> => {
81  const own = await ensureSelf($)
82  if ((await $.session.surfaces()).length === 0) return null
83  const record: AgentRecord = {
84    id: own.id,
85    cwd: await $.session.cwd(),
86    status: own.status,
87    since: own.since,
88    heartbeat: await $.clock.now(),
89    ...(own.prompted !== undefined && { prompted: own.prompted }),
90  }
91  await writeRecord($, record)
92  await update($, sessions, list => {
93    const others = list.filter(one => one.id !== record.id)
94
95    return record.status === 'ended' ? others : [...others, record]
96  })
97
98  return record
99}
100
101/** Moves this session to `status`; `since` only moves when the status does. */
102const moveTo = async (
103  $: Engine,
104  change: (own: AgentSelf) => AgentSelf,
105): Promise<void> => {
106  const own = await ensureSelf($)
107  const next = change(own)
108  if (next === own) return
109  const now = await $.clock.now()
110  await update($, self, () =>
111    next.status === own.status ? next : { ...next, since: now },
112  )
113  await writeSelf($)
114}
115
116const toWorking = ($: Engine) =>
117  moveTo($, own =>
118    own.status === 'working' ? own : { ...own, status: 'working', before: null },
119  )
120
121const toIdle = ($: Engine) =>
122  moveTo($, own => (own.status === 'idle' ? own : { ...own, status: 'idle', before: null }))
123
124/** The person sent a prompt here: this session now leads the nudges. */
125const markPrompted = async ($: Engine): Promise<void> => {
126  const own = await ensureSelf($)
127  const prompted = await $.clock.now()
128  await update($, self, () => ({ ...own, prompted }))
129  await writeSelf($)
130}
131
132/** Blocked on the person: remembers where to return once they answer. */
133const toWaiting = ($: Engine) =>
134  moveTo($, own =>
135    own.status === 'waiting' ? own : { ...own, status: 'waiting', before: own.status },
136  )
137
138/** The person answered: back to what the session was doing before. */
139const resolveWait = ($: Engine) =>
140  moveTo($, own =>
141    own.status !== 'waiting'
142      ? own
143      : { ...own, status: own.before ?? 'working', before: null },
144  )
145
146/** Marks a session's file ended; the session that owns it is going away. */
147const markEnded = async ($: Engine, id: string): Promise<void> => {
148  const own = await read($, self)
149  const now = await $.clock.now()
150  const record: AgentRecord = {
151    id,
152    cwd: await $.session.cwd(),
153    status: 'ended',
154    since: now,
155    heartbeat: now,
156  }
157  // Only a session that wrote a file ends it: a headless run never wrote one.
158  if (await $.fs.exists(fileOf(await watchDir($), id))) await writeRecord($, record)
159  await update($, sessions, list => list.filter(one => one.id !== id))
160  if (own !== null && own.id === id) {
161    const ended: AgentSelf = { ...own, status: 'ended', since: now, before: null }
162    await update($, self, () => ended)
163  }
164}
165
166/** Every status file in the watch folder, parsed; files that do not parse are skipped. */
167const readAll = async ($: Engine): Promise<AgentRecord[]> => {
168  const dir = await watchDir($)
169  const entries = await $.fs.list(dir).catch(() => [])
170  const records = await Promise.all(
171    entries
172      .filter(entry => entry.kind === 'file' && entry.name.endsWith('.json'))
173      .map(async entry => {
174        const text = await $.fs.read(`${dir}/${entry.name}`).catch(() => '')
175
176        return typeof text === 'string' ? parseRecord(text) : undefined
177      }),
178  )
179
180  return records.filter((one): one is AgentRecord => one !== undefined)
181}
182
183/**
184 * Removes ended and stale files older than an hour. `$.fs` has no delete, so
185 * this goes through `rm`; where that is not there the files are only ignored.
186 */
187const prune = async ($: Engine, records: readonly AgentRecord[], now: number) => {
188  const dir = await watchDir($)
189  const old = records.filter(one => isPrunable(one, now) && isSafeId(one.id))
190  if (old.length === 0) return 0
191  const paths = old.map(one => fileOf(dir, one.id))
192  const ran = await $.process
193    .run(['rm', '-f', '--', ...paths], { timeoutMs: 5_000 })
194    .catch(() => undefined)
195
196  return ran?.exitCode === 0 ? old.length : 0
197}
198
199/** Reads the other sessions' files between heartbeats, so the band keeps up. */
200const look = async ($: Engine): Promise<void> => {
201  const own = await ensureSelf($)
202  const now = await $.clock.now()
203  const others = (await readAll($)).filter(one => isLive(one, now) && one.id !== own.id)
204  await update($, sessions, list => [...others, ...list.filter(one => one.id === own.id)])
205  await update($, checkedAt, () => now)
206}
207
208/**
209 * The heartbeat: writes this session's file, reads every session's, keeps the
210 * live ones in state and prunes the old ones.
211 */
212const refresh = async (
213  $: Engine,
214  options: { countSubagents: boolean; isPruning?: boolean },
215): Promise<AgentRecord[]> => {
216  const own = await writeSelf($)
217  const { id } = await ensureSelf($)
218  const now = await $.clock.now()
219  const all = await readAll($)
220  const live = all.filter(one => isLive(one, now) && one.id !== id)
221  const list = own === null || own.status === 'ended' ? live : [...live, own]
222  await update($, sessions, () => list)
223  await update($, checkedAt, () => now)
224
225  if (options.countSubagents) {
226    const running = await $.agent
227      .list()
228      .then(agents => agents.filter(agent => agent.status === 'running').length)
229      .catch(() => 0)
230    await update($, subagents, () => running)
231  }
232  if (options.isPruning !== false) await prune($, all, now)
233
234  return list
235}
236
237// ---------------------------------------------------------------------------
238// Nudge: the warning at the limit and the toast for forgotten sessions
239// ---------------------------------------------------------------------------
240
241/** The `/agents-limit` override from the store, else the configured limit. */
242const loadLimit = async ($: Engine, config: Options): Promise<number> => {
243  const stored = await $.store.get('limit')
244  const value = typeof stored === 'number' && Number.isInteger(stored) && stored > 0
245    ? stored
246    : config.limit
247  await update($, limit, () => value)
248
249  return value
250}
251
252const nameOf = async ($: Engine, config: Options): Promise<string> => {
253  if (config.name !== '') return config.name
254  const user = (await $.env.get('USER')) ?? (await $.env.get('USERNAME'))
255
256  return user !== undefined && user !== '' ? user : 'there'
257}
258
259/**
260 * Counts the other sessions working or waiting (and this one's running
261 * subagents when asked); at or over the limit, warns, or in strict mode holds
262 * the prompt until it is sent a second time.
263 */
264const nudge = async (
265  $: Engine,
266  config: Options,
267  text: string,
268): Promise<{ drop: string } | undefined> => {
269  const list = await refresh($, { countSubagents: config.countSubagents, isPruning: false })
270  const own = await ensureSelf($)
271  const extra = config.countSubagents ? await read($, subagents) : 0
272  const running = othersActive(list, own.id) + extra
273  const max = await loadLimit($, config)
274
275  if (running < max) {
276    await update($, pendingConfirm, () => null)
277    return undefined
278  }
279  const warning = warningText(await nameOf($, config), running)
280  if (!config.strict) {
281    $.ui.toast(warning, { timeoutMs: 6_000 })
282    return undefined
283  }
284  if ((await read($, pendingConfirm)) === text) {
285    await update($, pendingConfirm, () => null)
286    return undefined
287  }
288  await update($, pendingConfirm, () => text)
289  // Put the prompt back so a second Enter sends it.
290  $.clock.after(50, () => {
291    $.prompt.fill({ text }).catch(() => undefined)
292  })
293
294  return { drop: `${warning} The limit is ${max}. Press Enter again to send it anyway.` }
295}
296
297/** One toast for the other sessions idle or waiting longer than idleMinutes. */
298const nudgeForgotten = async ($: Engine, config: Options, list: readonly AgentRecord[]) => {
299  const own = await ensureSelf($)
300  const now = await $.clock.now()
301  const seen = await read($, nudged)
302  const late = forgotten(list, own.id, now, config.idleMinutes)
303  const fresh = late.filter(one => !seen.includes(nudgeKey(one)))
304  // Every session remembers what it saw, nudger or not, so a new nudger does
305  // not repeat a toast; only keys that still stand, so the list never grows.
306  await update($, nudged, () => late.map(nudgeKey))
307  if (fresh.length === 0 || nudgerOf(list, now, config.idleMinutes) !== own.id) return
308
309  const line = (one: AgentRecord) =>
310    `${folderName(one.cwd)} ${one.status === 'waiting' ? 'waiting on you' : 'idle'} for ${formatDuration(now - one.since)}`
311  $.ui.toast(
312    fresh.length === 1
313      ? `Agent Watch: ${line(fresh[0] as AgentRecord)}`
314      : `Agent Watch: ${fresh.length} sessions need you: ${fresh.map(line).join(', ')}`,
315    { timeoutMs: 8_000 },
316  )
317}
318
319const tick = async ($: Engine, config: Options) => {
320  await loadLimit($, config)
321  const list = await refresh($, config)
322  await nudgeForgotten($, config, list)
323}
324
325/** `/agents` is Claude Code's own, so the overview is `/agents-list`. */
326const COMMANDS = [
327  {
328    name: 'agents-list',
329    description: 'List every Claude Code session: waiting, idle and working',
330    immediate: true,
331  },
332  {
333    name: 'agents-limit',
334    description: 'Set how many sessions may run before Agent Watch warns you',
335    argumentHint: '<n> | reset',
336    immediate: true,
337  },
338] as const
339
340const LIMIT_USAGE = 'Usage: /agents-limit <n> (a whole number, 1 or more), or /agents-limit reset'
341
342// ---------------------------------------------------------------------------
343// Overview: the /agents-list pane and the band above the prompt
344// ---------------------------------------------------------------------------
345
346/** Single-width symbols, no emoji. */
347const SYMBOL: Record<AgentStatus, string> = {
348  working: '\u25cf', // ●
349  waiting: '\u25c6', // ◆
350  idle: '\u25cb', // ○
351  ended: '\u00b7', // ·
352}
353
354const COLOR: Record<AgentStatus, string | undefined> = {
355  working: 'green',
356  waiting: 'yellow',
357  idle: undefined,
358  ended: undefined,
359}
360
361/** `3 working · 1 waiting · 2 idle`, leaving out the states with none. */
362const summaryOf = (list: readonly AgentRecord[]): string => {
363  const counts = countOf(list)
364  const parts = [
365    counts.working > 0 ? `${counts.working} working` : '',
366    counts.waiting > 0 ? `${counts.waiting} waiting` : '',
367    counts.idle > 0 ? `${counts.idle} idle` : '',
368  ].filter(Boolean)
369
370  return parts.length > 0 ? parts.join(' \u00b7 ') : 'no sessions'
371}
372
373// ---------------------------------------------------------------------------
374// Hooks
375// ---------------------------------------------------------------------------
376
377/** Notification types that are not a wait on the person. */
378const NOT_WAITING = new Set(['idle_prompt', 'auth_success'])
379
380export const register: Register = (on, options) => {
381  const config = readOptions(options)
382
383  on('session.start', async ($, e, next) => {
384    const started = await next(e)
385    // A name the engine refuses (a built-in's) must not stop the heartbeat.
386    for (const command of COMMANDS) {
387      await $.command.register(command).catch((error: unknown) => {
388        $.ui.log(`agent-watch: /${command.name} not registered: ${String(error)}`)
389      })
390    }
391    await ensureSelf($)
392    await tick($, config)
393    $.clock.every(HEARTBEAT_MS, () => {
394      tick($, config).catch(() => undefined)
395    })
396    $.clock.every(LOOK_MS, () => {
397      look($).catch(() => undefined)
398    })
399
400    return started
401  })
402
403  on('command.run', { command: 'agents-limit' }, async ($, e) => {
404    const arg = e.args.trim()
405    if (arg === '') {
406      const isOverride = (await $.store.get('limit')) !== undefined
407      const max = await loadLimit($, config)
408
409      return { text: `Agent limit: ${max} (${isOverride ? 'set with /agents-limit' : 'from the plugin config'}).` }
410    }
411    if (arg === 'reset') {
412      await $.store.delete('limit')
413      const max = await loadLimit($, config)
414
415      return { text: `Agent limit reset to ${max} from the plugin config.` }
416    }
417    const value = Number(arg)
418    if (!Number.isInteger(value) || value < 1) return { text: LIMIT_USAGE }
419    await $.store.set('limit', value)
420    await update($, limit, () => value)
421
422    return { text: `Agent limit set to ${value}.` }
423  })
424
425  on('command.run', { command: 'agents-list' }, async $ => {
426    const list = await refresh($, { countSubagents: config.countSubagents, isPruning: false })
427    const max = await loadLimit($, config)
428    await $.ui.open({ id: PANE, title: 'Agents' })
429
430    return { text: `Agent Watch: ${summaryOf(list)} (limit ${max}).` }
431  })
432
433  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
434    const { Box, Text } = $.ui.resolve(e)
435    const list = sortForPane(await read($, sessions))
436    const own = await read($, self)
437    const max = await read($, limit)
438    const now = Math.max(await read($, checkedAt), await $.clock.now())
439    const running = list.filter(one => one.status === 'working' || one.status === 'waiting').length
440    const room = Math.max(1, (e.viewport?.rows ?? 24) - 4)
441
442    return (
443      <Box flexDirection="column">
444        <Box>
445          <Text color={running >= max ? 'yellow' : undefined}>
446            {running} of {max} running
447          </Text>
448          <Text dimColor> {'\u00b7'} {summaryOf(list)}</Text>
449        </Box>
450        {list.length === 0 && <Text dimColor>No sessions found yet.</Text>}
451        {list.slice(0, room).map(one => (
452          <Box key={one.id} flexDirection="row" gap={1}>
453            <Text color={COLOR[one.status]}>{SYMBOL[one.status]}</Text>
454            <Text color={COLOR[one.status]}>{one.status.padEnd(7)}</Text>
455            <Text dimColor>{formatDuration(now - one.since).padStart(7)}</Text>
456            <Text wrap="truncate-end" bold={one.id === own?.id}>
457              {folderName(one.cwd)}
458              {one.id === own?.id ? ' (this one)' : ''}
459            </Text>
460          </Box>
461        ))}
462        {list.length > room && <Text dimColor>and {list.length - room} more</Text>}
463      </Box>
464    )
465  })
466
467  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
468    if (e.props.hasSurvey) return next(e)
469    const list = await read($, sessions)
470    const own = await read($, self)
471    // Nothing to show while this is the only session.
472    if (!list.some(one => one.id !== own?.id)) return next(e)
473
474    const { Box, Text } = $.ui.resolve(e)
475    const max = await read($, limit)
476    const running = list.filter(one => one.status === 'working' || one.status === 'waiting').length
477
478    return (
479      <Box>
480        <Text dimColor>{summaryOf(list)}</Text>
481        {running >= max && <Text color="yellow"> (limit {max})</Text>}
482      </Box>
483    )
484  })
485
486  on('prompt.submit', async ($, e, next) => {
487    // Only the person's own prompts, and not one typed over a running turn:
488    // that session already counts.
489    const isPerson = e.origin.kind === 'composer' || e.origin.kind === 'bridge'
490    if (isPerson && e.turnId === undefined) {
491      const held = await nudge($, config, e.text)
492      if (held !== undefined) return held
493    }
494    const result = await next(e)
495    if (result.drop === undefined) {
496      if (isPerson) await markPrompted($)
497      await toWorking($)
498    }
499
500    return result
501  })
502
503  // A subagent's run raises no turn.start: this is always the main loop.
504  on('turn.start', async ($, e, next) => {
505    await toWorking($)
506
507    return next(e)
508  })
509
510  on('turn.complete', async ($, e, next) => {
511    if (e.agentId === undefined) await toIdle($)
512
513    return next(e)
514  })
515
516  on('classic.Notification', async ($, e, next) => {
517    const isWait = e.agent_id === undefined && !NOT_WAITING.has(e.notification_type)
518    if (isWait && (await ensureSelf($)).status === 'working') await toWaiting($)
519
520    return next(e)
521  })
522
523  // A permission dialog blocks on the person whichever loop asked for it.
524  on('classic.PermissionRequest', async ($, e, next) => {
525    await toWaiting($)
526
527    return next(e)
528  })
529
530  // A tool call resolves once its dialog was answered and the tool ran.
531  on('tool.call', async ($, e, next) => {
532    const isQuestion = e.agentId === undefined && String(e.tool) === 'AskUserQuestion'
533    if (isQuestion) await toWaiting($)
534    const ran = await next(e)
535    await resolveWait($)
536
537    return ran
538  })
539
540  // The desktop app attaches its surface after start: count it from then on.
541  on('session.attach', async ($, e, next) => {
542    const attached = await next(e)
543    await writeSelf($).catch(() => undefined)
544
545    return attached
546  })
547
548  on('session.end', async ($, e, next) => {
549    await markEnded($, e.sessionId)
550    const ended = await next(e)
551    // A /clear goes on under a new id, with no session.start of its own.
552    if (e.reason === 'clear') {
553      $.clock.after(500, () => {
554        writeSelf($).catch(() => undefined)
555      })
556    }
557
558    return ended
559  })
560}
561
hooks/shared.ts 151 lines
1import type { AgentRecord, AgentSelf, AgentStatus } from '../types'
2
3export const HEARTBEAT_MS = 30_000
4/** How often the other sessions' files are read between heartbeats. */
5export const LOOK_MS = 10_000
6/** A file whose heartbeat is older than this is a session that is gone. */
7export const STALE_MS = 2 * 60_000
8/** Ended and stale files are removed once they are this old. */
9export const PRUNE_MS = 60 * 60_000
10
11export type Options = {
12  limit: number
13  idleMinutes: number
14  strict: boolean
15  countSubagents: boolean
16  name: string
17}
18
19export const readOptions = (options: Readonly<Record<string, unknown>>): Options => ({
20  limit: positive(options.limit, 3),
21  idleMinutes: positive(options.idleMinutes, 30),
22  strict: options.strict === true,
23  countSubagents: options.countSubagents === true,
24  name: typeof options.name === 'string' ? options.name.trim() : '',
25})
26
27const positive = (value: unknown, fallback: number): number =>
28  typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : fallback
29
30const STATUSES: readonly AgentStatus[] = ['working', 'waiting', 'idle', 'ended']
31
32/** Parses a status file's text; undefined for anything that is not one. */
33export const parseRecord = (text: string): AgentRecord | undefined => {
34  let value: unknown
35  try {
36    value = JSON.parse(text)
37  } catch {
38    return undefined
39  }
40  if (typeof value !== 'object' || value === null) return undefined
41  const { id, cwd, status, since, heartbeat, prompted } = value as Record<string, unknown>
42  const isRecord =
43    typeof id === 'string' &&
44    id !== '' &&
45    typeof cwd === 'string' &&
46    STATUSES.includes(status as AgentStatus) &&
47    typeof since === 'number' &&
48    typeof heartbeat === 'number'
49
50  if (!isRecord) return undefined
51  const record: AgentRecord = { id, cwd, status: status as AgentStatus, since, heartbeat }
52
53  return typeof prompted === 'number' ? { ...record, prompted } : record
54}
55
56export const isStale = (record: AgentRecord, now: number): boolean =>
57  now - record.heartbeat > STALE_MS
58
59/** Live: not ended and heard from within the last two minutes. */
60export const isLive = (record: AgentRecord, now: number): boolean =>
61  record.status !== 'ended' && !isStale(record, now)
62
63/** Ended or stale, and old enough to remove from disk. */
64export const isPrunable = (record: AgentRecord, now: number): boolean =>
65  (record.status === 'ended' || isStale(record, now)) && now - record.heartbeat > PRUNE_MS
66
67/** Only working and waiting sessions count toward the limit. */
68export const isActive = (status: AgentStatus): boolean =>
69  status === 'working' || status === 'waiting'
70
71export type Counts = { working: number; waiting: number; idle: number }
72
73export const countOf = (records: readonly AgentRecord[]): Counts => ({
74  working: records.filter(one => one.status === 'working').length,
75  waiting: records.filter(one => one.status === 'waiting').length,
76  idle: records.filter(one => one.status === 'idle').length,
77})
78
79/** The other live sessions working or waiting: what the limit compares. */
80export const othersActive = (records: readonly AgentRecord[], selfId: string): number =>
81  records.filter(one => one.id !== selfId && isActive(one.status)).length
82
83/** Waiting first, then idle, each longest first, then working. */
84export const sortForPane = (records: readonly AgentRecord[]): AgentRecord[] => {
85  const rank = (status: AgentStatus) =>
86    status === 'waiting' ? 0 : status === 'idle' ? 1 : status === 'working' ? 2 : 3
87
88  return [...records].sort(
89    (a, b) => rank(a.status) - rank(b.status) || a.since - b.since,
90  )
91}
92
93export const folderName = (cwd: string): string =>
94  cwd.split(/[\\/]/).filter(Boolean).at(-1) ?? cwd
95
96export const formatDuration = (ms: number): string => {
97  const minutes = Math.max(0, Math.floor(ms / 60_000))
98  if (minutes < 1) return '<1m'
99  if (minutes < 60) return `${minutes}m`
100  const hours = Math.floor(minutes / 60)
101  if (hours < 24) return `${hours}h ${minutes % 60}m`
102
103  return `${Math.floor(hours / 24)}d ${hours % 24}h`
104}
105
106/** The key a forgotten nudge is remembered by: once per session per state. */
107export const nudgeKey = (record: AgentRecord): string =>
108  `${record.id}:${record.status}:${record.since}`
109
110/** Idle or waiting longer than `idleMinutes`. */
111export const isForgotten = (record: AgentRecord, now: number, idleMinutes: number): boolean =>
112  (record.status === 'idle' || record.status === 'waiting') &&
113  now - record.since > idleMinutes * 60_000
114
115/** Other live sessions idle or waiting longer than `idleMinutes`. */
116export const forgotten = (
117  records: readonly AgentRecord[],
118  selfId: string,
119  now: number,
120  idleMinutes: number,
121): AgentRecord[] =>
122  records.filter(one => one.id !== selfId && isForgotten(one, now, idleMinutes))
123
124/**
125 * The one session that sends the forgotten nudge, so it shows once and not in
126 * every open session: the one the person prompted last (likely the one in
127 * front of them), never one forgotten itself; then the latest state change,
128 * then the lowest id.
129 * Every session reads the same files, so they agree without talking.
130 */
131export const nudgerOf = (
132  records: readonly AgentRecord[],
133  now: number,
134  idleMinutes: number,
135): string | undefined =>
136  records
137    .filter(one => !isForgotten(one, now, idleMinutes))
138    .sort(
139      (a, b) =>
140        (b.prompted ?? 0) - (a.prompted ?? 0) || b.since - a.since || (a.id < b.id ? -1 : 1),
141    )[0]?.id
142
143/** The session id only ever names a file inside the watch folder. */
144export const isSafeId = (id: string): boolean => /^[A-Za-z0-9_-]{1,128}$/.test(id)
145
146/** The toast at the limit. */
147export const warningText = (name: string, running: number): string =>
148  `Hey ${name}, be aware you are already running ${running} ${running === 1 ? 'agent' : 'agents'}.`
149
150export type { AgentRecord, AgentSelf, AgentStatus }
151
types/index.d.ts 47 lines
1/** Where a session stands, as its status file says. */
2export type AgentStatus = 'working' | 'waiting' | 'idle' | 'ended'
3
4/** One session's status file, `~/.claude/agent-watch/<id>.json`. */
5export type AgentRecord = {
6  id: string
7  cwd: string
8  status: AgentStatus
9  /** When the session entered `status`, ms since the epoch. */
10  since: number
11  /** The last time the session wrote its file, ms since the epoch. */
12  heartbeat: number
13  /** The last time the person sent a prompt here; elects who nudges. */
14  prompted?: number
15}
16
17/** This session's own status, the one it writes to its file. */
18export type AgentSelf = {
19  id: string
20  status: AgentStatus
21  since: number
22  /** The status to return to once a wait on the person resolves. */
23  before: AgentStatus | null
24  /** The last time the person sent a prompt here. */
25  prompted?: number
26}
27
28declare module 'claude-code' {
29  interface PluginState {
30    'agent-watch': {
31      /** Every live session read from disk on the last refresh, this one too. */
32      sessions: AgentRecord[]
33      /** When `sessions` was read. */
34      checkedAt: number
35      self: AgentSelf | null
36      /** The limit in force: the `/agents-limit` override, else the config. */
37      limit: number
38      /** This session's running background subagents (countSubagents only). */
39      subagents: number
40      /** `id:status:since` keys already nudged as forgotten. */
41      nudged: string[]
42      /** Strict mode: the prompt held back, waiting for a second Enter. */
43      pendingConfirm: string | null
44    }
45  }
46}
47