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

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:
claude -p, scripts, CI) don't count.Hey Elio, be aware you are already running 3 agents.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.
<!-- 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.
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.
/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.
| What | Where |
|---|---|
3 working · 1 waiting · 2 idle | The 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 toast | On 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-list | Opens 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 nudge | A 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-listand not/agents?/agentsis Claude Code's built-in command for managing subagents, and plugins can't take over a built-in's name.
| State | When | Counts toward the limit |
|---|---|---|
| Working | A turn is running (prompt.submit, turn.start) | yes |
| Waiting | Blocked on you: a permission prompt (classic.PermissionRequest), a permission notification (classic.Notification) or an AskUserQuestion | yes |
| Idle | turn.complete fired and no new prompt since | no, only shown in the band, the pane and the forgotten nudge |
| Removed | session.end (including /exit and /clear), or no heartbeat for 2 minutes | no |
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.
| Option | Default | What it does |
|---|---|---|
limit | 3 | How many other sessions may be working or waiting before the warning shows. |
idleMinutes | 30 | When another session counts as forgotten. |
strict | false | At the limit, hold the prompt instead of only warning. The prompt goes back in the box; press Enter again to send it anyway. |
countSubagents | false | Also 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.
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.
$.clock.every) rewrites the file. The other sessions' files are read every 10 s so the band stays current.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).$.state, so a hot reload keeps them.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.
MIT
hooks/register.tsx 561 lines1import { 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}
561hooks/shared.ts 151 lines1import 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 }
151types/index.d.ts 47 lines1/** 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