SLOPSHOPPER

swarm-janitor

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

commandtoastprocesstimer
v0.1.0no licenseupdated 2026-10-18Tatendaz/T-mods/swarm-janitor
A shopper browsing a rack in a slop shop
README

swarm-janitor

Closes herdr tabs of finished swarm agents, and sends your phone a push when an agent waits on you too long.

What it does

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:

  • Closes a tab with 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.
  • Pushes your phone through ~/.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.

Options

OptionDefaultWhat it does
closeMergedtrueClose tabs of merged, clean worktree agents.
blockedMinutes10Minutes blocked before a push.

Limits

  • It never restarts or nudges an agent. It only closes tabs and pushes.
  • Two sessions that start at the same moment can both take the lease for one pass. A second close of the same tab fails with no effect, but a push can go out twice.

Usage cost

None. It runs herdr, git and gh.

Tests

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