Every 3 minutes, closes the herdr tab of an idle agent whose worktree PR merged and has nothing uncommitted, and pushes your phone when an agent stays blocked

Closes herdr tabs of finished swarm agents, and sends your phone a push when an agent waits on you too long.
Every 3 minutes, one Claude Code session acts as the janitor. A lease in ~/.claude/state/swarm-janitor.json makes sure only one session does it, so 40 open sessions do not all run it. The janitor renews the lease every pass, and the lease expires after 7 minutes.
The janitor runs herdr agent list, then:
herdr tab close when all of these are true: the agent is idle or done, it is not focused, it is not the janitor's own session, its folder is a git worktree, its branch is not main or master, git status --porcelain is empty, and gh pr list --head <branch> --state merged finds a PR. A toast names the agent and the PR.~/.claude/bin/phone-notify when an agent has been blocked for blockedMinutes. It pushes once per blocked spell. A new spell starts after the agent leaves blocked./janitor runs one pass now and prints what it did.
| Option | Default | What it does |
|---|---|---|
closeMerged | true | Close tabs of merged, clean worktree agents. |
blockedMinutes | 10 | Minutes blocked before a push. |
None. It runs herdr, git and gh.
tests/swarm-janitor.test.ts covers the lease, the push timing, which agents can be closed, and /janitor closing a merged agent and leaving an unmerged one, with fake herdr, git and gh.
claude plugin test ~/.claude/mods/swarm-janitorhooks/register.ts 161 lines1import type { EngineInterface, Register } from 'claude-code'
2
3// The owner kept asking to "close the agents that are idle" and "once agents
4// are done and PR is merged and all green just kill them and the herdr tab".
5// Every 3 minutes one session (the leader, picked through a lock file so 40
6// open sessions do not all do it) closes the tab of each idle or done agent
7// whose worktree branch has a merged PR and no uncommitted work, and pushes
8// the owner's phone once when an agent stays blocked. No model call.
9
10const TICK_MS = 3 * 60_000
11const LEASE_MS = 7 * 60_000
12
13export type Agent = {
14 agent_status?: string
15 cwd?: string
16 name?: string
17 focused?: boolean
18 pane_id?: string
19 tab_id?: string
20 terminal_title_stripped?: string
21 agent_session?: { value?: string }
22}
23
24export type Ledger = {
25 leader?: string
26 at?: number
27 blockedSince: Record<string, number>
28 pushed: string[]
29 closed: { tab: string; name: string; pr: number; at: number }[]
30}
31
32export const emptyLedger = (): Ledger => ({ blockedSince: {}, pushed: [], closed: [] })
33
34/** Whether this session may act now: no other leader has renewed its lease. */
35export function mayLead(ledger: Ledger, self: string, now: number): boolean {
36 return ledger.leader === undefined || ledger.leader === self || now - (ledger.at ?? 0) > LEASE_MS
37}
38
39export const nameOf = (a: Agent) => a.name ?? a.terminal_title_stripped ?? (a.cwd ?? '').split('/').pop() ?? 'agent'
40
41/** Updates blocked times and returns the agents to push about, at most once each per blocked spell. */
42export function blockedToPush(ledger: Ledger, agents: readonly Agent[], now: number, minutes: number): Agent[] {
43 const blocked = new Set<string>()
44 const push: Agent[] = []
45 for (const a of agents) {
46 if (a.agent_status !== 'blocked' || !a.pane_id) continue
47 blocked.add(a.pane_id)
48 const since = (ledger.blockedSince[a.pane_id] ??= now)
49 if (now - since >= minutes * 60_000 && !ledger.pushed.includes(a.pane_id)) {
50 ledger.pushed.push(a.pane_id)
51 push.push(a)
52 }
53 }
54 for (const pane of Object.keys(ledger.blockedSince)) if (!blocked.has(pane)) delete ledger.blockedSince[pane]
55 ledger.pushed = ledger.pushed.filter(p => blocked.has(p))
56 return push
57}
58
59/** Agents worth checking for a merged PR: idle or done, in a worktree, not focused, not this session. */
60export function closeCandidates(agents: readonly Agent[], self: string): Agent[] {
61 return agents.filter(
62 a =>
63 (a.agent_status === 'idle' || a.agent_status === 'done') &&
64 a.focused !== true &&
65 a.agent_session?.value !== self &&
66 a.tab_id !== undefined &&
67 /\/\.?worktrees\//.test(a.cwd ?? ''),
68 )
69}
70
71async function home($: EngineInterface): Promise<string> {
72 return (await $.env.get('HOME')) ?? ''
73}
74
75async function sh($: EngineInterface, argv: string[], cwd?: string): Promise<{ ok: boolean; out: string }> {
76 try {
77 const r = await $.process.run(argv, { ...(cwd ? { cwd } : {}), timeoutMs: 45_000 })
78 return { ok: r.exitCode === 0, out: r.stdout.trim() }
79 } catch {
80 return { ok: false, out: '' }
81 }
82}
83
84async function readLedger($: EngineInterface, path: string): Promise<Ledger> {
85 try {
86 if (!(await $.fs.exists(path))) return emptyLedger()
87 return { ...emptyLedger(), ...(JSON.parse(await $.fs.read(path)) as Partial<Ledger>) }
88 } catch {
89 return emptyLedger()
90 }
91}
92
93/** The merged PR for an agent's worktree branch, when the worktree is clean; null otherwise. */
94async function mergedAndClean($: EngineInterface, cwd: string): Promise<number | null> {
95 const dirs = await sh($, ['git', 'rev-parse', '--path-format=absolute', '--git-dir', '--git-common-dir'], cwd)
96 const [gitDir, common] = dirs.out.split('\n')
97 if (!dirs.ok || !gitDir || gitDir === common) return null
98 const branch = (await sh($, ['git', 'branch', '--show-current'], cwd)).out
99 if (!branch || branch === 'main' || branch === 'master') return null
100 const status = await sh($, ['git', 'status', '--porcelain'], cwd)
101 if (!status.ok || status.out !== '') return null
102 const pr = await sh($, ['gh', 'pr', 'list', '--head', branch, '--state', 'merged', '--limit', '1', '--json', 'number', '--jq', '.[0].number'], cwd)
103 const n = Number(pr.out)
104 return pr.ok && Number.isInteger(n) && n > 0 ? n : null
105}
106
107async function tick($: EngineInterface, options: { closeMerged: boolean; blockedMinutes: number }, force: boolean): Promise<string> {
108 const now = await $.clock.now()
109 const self = await $.session.id()
110 const path = `${await home($)}/.claude/state/swarm-janitor.json`
111 const ledger = await readLedger($, path)
112 if (!force && !mayLead(ledger, self, now)) return `Another session (${ledger.leader?.slice(0, 8)}) is the janitor right now.`
113 ledger.leader = self
114 ledger.at = now
115
116 const list = await sh($, ['herdr', 'agent', 'list'])
117 if (!list.ok) {
118 await $.fs.write(path, JSON.stringify(ledger, null, 2))
119 return 'herdr agent list failed, so nothing was checked.'
120 }
121 const agents = ((JSON.parse(list.out) as { result?: { agents?: Agent[] } }).result?.agents ?? []) as Agent[]
122 const report: string[] = []
123
124 for (const a of blockedToPush(ledger, agents, now, options.blockedMinutes)) {
125 const where = (a.cwd ?? '').replace(/^.*\/Projects\//, '')
126 await sh($, [`${await home($)}/.claude/bin/phone-notify`, `agent ${nameOf(a)} blocked ${options.blockedMinutes}+ min in ${where}: needs your answer`])
127 report.push(`pushed: ${nameOf(a)} blocked`)
128 }
129
130 if (options.closeMerged) {
131 for (const a of closeCandidates(agents, self)) {
132 const pr = await mergedAndClean($, a.cwd!)
133 if (pr === null) continue
134 const closed = await sh($, ['herdr', 'tab', 'close', a.tab_id!])
135 if (!closed.ok) continue
136 ledger.closed = [...ledger.closed, { tab: a.tab_id!, name: nameOf(a), pr, at: now }].slice(-50)
137 report.push(`closed ${nameOf(a)} (#${pr} merged)`)
138 $.ui.toast(`swarm-janitor: closed ${nameOf(a)} (#${pr} merged)`)
139 }
140 }
141
142 await $.fs.write(path, JSON.stringify(ledger, null, 2))
143 const blocked = Object.keys(ledger.blockedSince).length
144 return [report.length > 0 ? report.join('; ') : 'Nothing to close.', `${agents.length} agents, ${blocked} blocked.`].join(' ')
145}
146
147export const register: Register = (on, options) => {
148 const opts = {
149 closeMerged: (options as Record<string, unknown>).closeMerged !== false,
150 blockedMinutes: Number((options as Record<string, unknown>).blockedMinutes ?? 10) || 10,
151 }
152
153 on('session.start', async ($, e, next) => {
154 await $.command.register({ name: 'janitor', description: 'Close merged agents\' herdr tabs and check blocked agents now' })
155 $.clock.every(TICK_MS, () => void tick($, opts, false).catch(() => undefined))
156 return next(e)
157 })
158
159 on('command.run', { command: 'janitor' }, async $ => ({ text: await tick($, opts, true) }))
160}
161