SLOPSHOPPER

coordinator

Run many worker agents from one coordinator session: scaffolded rules, lane cards and briefs, spawn/retire/respawn workers in orca, tmux, Windows Terminal or…

newcommandtoaststatusprocesstimer
v0.2.3MITupdated 2026-10-09azoof-ahmed/claude-mods/coordinator
A shopper browsing a rack in a slop shop
README

coordinator

Run a project from one coordinator session that steers many worker agents. Each worker runs in its own terminal, with a brief and a lane card.

The plugin brings the playbook (a skill), a CLI, a /coordinator command and hooks. The hooks tell each session whether it coordinates or works a lane, and remind workers to retire.

It works on its own. With agent-bus it also messages workers, and with task-tracker it shows each lane's tasks.

Install

/plugin install coordinator --marketplace azoof-ahmed/claude-mods

Answer y to add the marketplace, then pick a scope. Python 3.9 or newer must be on PATH (or set the python option).

To get the whole setup, install the companions too:

/plugin install agent-bus --marketplace azoof-ahmed/claude-mods
/plugin install task-tracker --marketplace azoof-ahmed/claude-mods

Quick start

/coordinator init                         # scaffold rules, templates, the question queue and config
/coordinator spawn worker-a .claude/coord/briefs/worker-a.md
/coordinator status                       # lanes, card states, questions for you, safe-to-close lanes

Or ask Claude: "coordinate this project with three workers". The coordinator skill covers the rest.

What init creates

Path (default)What it is
.claude/coord/RULES_CORE.mdThe short rules every worker reads first. Edit section 1 to describe your project.
.claude/coord/lanes/README.mdThe lane-card template. A card is at most 150 lines and is the worker's handoff.
.claude/coord/lanes/lanes.jsonWhich lanes exist, where they run, and their generation.
.claude/coord/briefs/TEMPLATE.mdThe brief template: goal, scope, steps, proof, constraints, report.
.claude/coord/briefs/RESUME.mdThe successor startup: read the rules, the card and the backlog, then work.
.claude/coord/briefs/scheduler.mdA scheduler worker that grants one heavy job at a time, by RAM, battery and the user's activity.
.claude/coord/briefs/HANDOFF_PROTOCOL.mdHow a worker retires and how a successor resumes.
.claude/coord/reports/questions.jsonlThe question queue: questions for the user, asked at status time and never blocking.
.claude/claude-mods.json → coordinatorThe resolved configuration, shared by every worker.

With agent-bus installed, init also creates the bus and registers the roles (default: coordinator,scheduler).

Commands

/coordinator <verb> runs coord.py <verb>. Sessions also get the CLI as $COORD_CLI.

VerbWhat it does
init [--roles a,b] [--force]Scaffolds the files above. Existing files are kept unless you pass --force.
spawn <role> [brief] [--model M] [--repo P] [--dry-run]Starts a worker on a brief (default: the resume brief) through the backend. It refuses past maxLanes.
retire <role> [--close]Asks the worker, by urgent bus mail, to update its card and stop. --close also closes its terminal.
respawn <role>Closes the worker and starts a successor on the resume brief. The successor picks up from the card.
statusA report: live lanes with their card's State lines, open questions, safe-to-close lanes and the coordinator's inbox.
lanes [--json]Lanes with backend, generation, card age and size, tracker tasks and the last SAFE TO CLOSE / KEEP OPEN.
close-idle [--dry-run]Closes lanes whose last word is SAFE TO CLOSE.
`ask "<q>" [--from R] [--options "a\b"]`Queues a question for the user.
questions [--all]Lists open questions (or all of them).
answer <qid> "<a>"Records the answer and sends it to the asker on the bus.
preflight [--min-ram-gb N] [--min-battery P] [--max-user-idle-s S]GO (exit 0) or WAIT (exit 1) for a heavy job, with the reasons. Works on Windows, Linux and macOS.
usage [--probe] [--json] [--debug]The latest usage reading (session and weekly %). --probe reads /usage now (orca, tmux). --debug saves every raw screen read to <reportsDir>/usage-debug/.
usage-watch [--start] [--once] [--probe] [--debug]Probes usage on the probeIntervals schedule and alerts the coordinator at the thresholds. A reading younger than the interval is reused (and says so); one without numbers never is, and a probe that reads no numbers never replaces a good reading. --probe forces a probe now. --start runs it in its own terminal.
pause [--tick] [--wait]The graceful pause: asks every live lane to checkpoint and stop. --tick advances it; --wait runs it to the end. The plugin ticks it every minute.
pausedA worker reports that it has checkpointed (uses AGENT_BUS_ROLE, or --role).
resume [--dry-run]Respawns every paused lane from its card on the resume brief.
configThe resolved configuration.

Backends

backendHow a worker startsRetire/close
manual (default)Prints the command, with AGENT_BUS_ROLE set, for you to run in a new terminal.You close it.
orcaorca terminal create --worktree path:<repo> --title <role> --command .... The handle is registered on the bus.orca terminal close --tab
tmuxtmux new-window -n <role> -c <repo> -e AGENT_BUS_ROLE=<role>, in the current session or a detached coord session.tmux kill-window
wtA new Windows Terminal tab (wt -w 0 nt --title <role> -d <repo>).You close it.

The worker command comes from two templates:

  • workerCommand, default claude --model {model} "{prompt}";
  • workerPrompt, default Read {brief} and execute it. Your role is {role}.

Add flags such as a permission mode to workerCommand.

Options

Set them in /config, or in settings under pluginConfigs["coordinator"].options. A project's .claude/claude-mods.json (written by init) wins over these, so one repo can differ from your defaults.

OptionDefaultWhat it does
repos.Comma-separated repo paths workers start in. The first is the default.
backendmanualmanual, orca, tmux or wt (see Backends).
shellautoHow AGENT_BUS_ROLE is set before the command: auto (PowerShell on Windows, POSIX elsewhere), posix, powershell or cmd.
workerCommandclaude --model {model} "{prompt}"The worker command template.
workerPromptRead {brief} and execute it. Your role is {role}.The worker's first prompt.
workerModelopus{model} in the template. spawn --model overrides it per lane.
maxLanes6The number of concurrent live lanes.
retireTokens250000Context tokens at which a worker is told to update its card and retire (again every further 10%).
pauseWeeklyPercent80Weekly usage at which the coordinator is alerted to run the graceful pause.
pauseSessionPercent975-hour session usage at which every session is told to checkpoint and pause itself.
probeIntervals0:60,70:20,78:10The watcher's schedule as weekly%:minutes steps: every 60 min, every 20 from 70%, every 10 from 78%.
probeCommandclaude --model haikuThe throwaway session the watcher opens to read /usage.
pauseReminderMin10Minutes before a silent lane gets one reminder.
pauseGraceMin20Minutes before a silent lane is interrupted and its terminal closed.
busDir.claude/busThe agent-bus directory.
trackerDb.claude/tasks/tasks.dbThe task-tracker db, read-only, for lane tasks.
rulesFile.claude/coord/RULES_CORE.mdThe rules core.
briefsDir.claude/coord/briefsBriefs, the resume brief, the scheduler brief and the handoff protocol.
cardsDir.claude/coord/lanesLane cards and lanes.json.
reportsDir.claude/coord/reportsProgress logs, the question queue, and the message fallback without the bus.
minFreeRamGb4preflight answers WAIT below this.
minBattery40preflight answers WAIT below this when not charging.
pythonpythonThe interpreter for the CLI.

What the hooks do

  • Session start, in a project that ran init:
  • a coordinator session (no role, or the role coordinator) is told the live lanes, the open questions and the CLI;
  • a worker (AGENT_BUS_ROLE set) is pointed at the rules and its card.

Other projects get nothing.

  • Every 10 tool calls, the plugin reads the session's context size and usage windows:
  • past retireTokens, it adds a retirement reminder;
  • in the coordinator session, at pauseWeeklyPercent of the weekly window, it adds a "start the graceful pause" note and a toast, once per crossing;
  • in every session, at pauseSessionPercent of the 5-hour window, it adds a checkpoint-and-pause notice.
  • The status line shows coord: N/M lanes, Q question(s) in the coordinator session.

Usage watcher and graceful pause

The goal is to stop at about 80% of the weekly limit without losing work. Going a little over while lanes finish is fine.

Where the readings come from:

  • The coordinator session itself. The plugin reads the usage windows that Claude Code reports with each response ($.session.usage()): the 5-hour session window and the weekly window. It writes them to <reportsDir>/usage.json, so they count as a probe. No extra session is needed while the coordinator is busy.
  • The watcher, for when the coordinator is idle: coord usage-watch --start runs it in its own terminal.
  • It reuses a fresh reading when there is one.
  • Otherwise it opens a throwaway session (probeCommand, a cheap model), types /usage, reads the screen and closes it.
  • It probes every 60 minutes, every 20 minutes from 70% and every 10 minutes from 78% (probeIntervals).
  • Screen reading needs the orca or tmux backend. With wt or manual, only the coordinator session's own readings are available.
  • At the threshold, the watcher sends the coordinator an urgent bus message, once per crossing. Without agent-bus it appends to <reportsDir>/alerts.jsonl and prints. In the coordinator session the plugin adds a note and a toast.

/coordinator pause:

  1. Every live lane gets an urgent PAUSE. Each lane:
  2. finishes or parks its atomic step;
  3. updates its lane card;
  4. releases any scheduler slot;
  5. runs coord paused;
  6. ends with SAFE TO CLOSE.
  7. The plugin advances the pause every minute (or run coord pause --wait):
  8. lanes that checkpointed are closed;
  9. silent lanes get one reminder after pauseReminderMin;
  10. after pauseGraceMin, silent lanes get an Escape and are closed. Their card is what they last wrote.
  11. When every lane is done, it writes <reportsDir>/RESUME_LIST.md.

/coordinator resume respawns every paused lane on the resume brief. Each successor reads its card and its waiting bus mail, and carries on.

Without the companions

  • No agent-bus:
  • retire and answer print what to tell the worker;
  • the rules tell workers to write <reportsDir>/<role>_progress.md.
  • No task-tracker: lanes show no tasks.

Develop

From the repository root:

claude plugin validate ./coordinator
claude plugin test ./coordinator
python -m unittest discover -s coordinator/scripts/tests
claude --plugin-dir ./coordinator

License

MIT

Source 1 files
hooks/register.ts 282 lines
1import type { EngineInterface, Register } from 'claude-code'
2
3/** What `coord.py context` prints. */
4export type CoordContext = {
5  configured: boolean
6  role: string
7  isCoordinator: boolean
8  rules: string
9  card: string | null
10  cardExists: boolean
11  live: string[]
12  questions: number
13  retireTokens: number
14  pauseSessionPercent: number
15  pauseWeeklyPercent: number
16  pause: string | null
17  maxLanes: number
18}
19
20const USAGE_EVERY_CALLS = 10
21const RETIRE_STEP = 0.1 // remind again every further 10% past the threshold
22
23function str(v: unknown, fallback: string): string {
24  return typeof v === 'string' && v !== '' ? v : fallback
25}
26
27function num(v: unknown, fallback: number): number {
28  return typeof v === 'number' && Number.isFinite(v) ? v : fallback
29}
30
31/** A usage window as `$.session.usage()` reports it. */
32export type UsageWindow = { kind: string; percentUsed: number; resetsAt?: string }
33
34/** Which threshold a window is held to: the weekly one or the session (5-hour) one. */
35export function windowKind(kind: string): 'week' | 'session' | undefined {
36  if (/seven|week/i.test(kind)) {
37    return 'week'
38  }
39  if (/five|hour|session/i.test(kind)) {
40    return 'session'
41  }
42  return undefined
43}
44
45/**
46 * The notes a usage reading earns, once per window per crossing. The weekly threshold is the
47 * coordinator's: it starts the graceful pause, and the lanes wait for its pause message. The
48 * session threshold is everyone's: each session checkpoints and pauses itself.
49 */
50export function usageNotes(
51  windows: readonly UsageWindow[],
52  c: Pick<CoordContext, 'isCoordinator' | 'pauseSessionPercent' | 'pauseWeeklyPercent' | 'pause'>,
53  noted: ReadonlySet<string>,
54): { notes: string[]; noted: Set<string> } {
55  const notes: string[] = []
56  const next = new Set(noted)
57  for (const w of windows) {
58    const kind = windowKind(w.kind)
59    const resets = w.resetsAt ? ` (resets ${w.resetsAt})` : ''
60    const pct = Math.round(w.percentUsed)
61    if (kind === 'week') {
62      if (w.percentUsed < c.pauseWeeklyPercent) {
63        next.delete(w.kind)
64      } else if (c.isCoordinator && c.pause !== 'pausing' && c.pause !== 'complete' && !next.has(w.kind)) {
65        next.add(w.kind)
66        notes.push(
67          `coordinator: weekly usage is at ${pct}%${resets}, past the ${c.pauseWeeklyPercent}% pause point. ` +
68            'Start the graceful pause now: `/coordinator pause` (or `coord pause`). Every lane finishes its atomic step, ' +
69            'updates its card and stops; nothing is lost. Going a little over while they finish is fine.',
70        )
71      }
72    } else if (kind === 'session') {
73      if (w.percentUsed < c.pauseSessionPercent) {
74        next.delete(w.kind)
75      } else if (!next.has(w.kind)) {
76        next.add(w.kind)
77        notes.push(
78          `coordinator: the session usage window is at ${pct}%${resets}. Checkpoint: update your lane card, park ` +
79            'the current step, tell the coordinator, and pause until it resets.',
80        )
81      }
82    }
83  }
84  return { notes, noted: next }
85}
86
87/** Runs `coord.py <args>` from this plugin's folder. */
88async function runCoord($: EngineInterface, python: string, args: string[], timeoutMs: number) {
89  return $.process.run([python, `${$.plugin.root}/scripts/coord.py`, ...args], { timeoutMs })
90}
91
92/** Asks coord.py whether this project is coordinated and who this session is. */
93async function readContext($: EngineInterface, python: string): Promise<CoordContext | undefined> {
94  const ran = await runCoord($, python, ['context'], 15000)
95  if (ran.exitCode !== 0) {
96    return undefined
97  }
98  return JSON.parse(ran.stdout) as CoordContext
99}
100
101/** One pass of the graceful pause, then the fresh context; a finished pause is announced. */
102async function tickPause($: EngineInterface, python: string): Promise<CoordContext | undefined> {
103  const ran = await runCoord($, python, ['pause', '--tick'], 120000)
104  const line = ran.stdout.trim().split('\n').pop() ?? ''
105  if (line.includes('complete')) {
106    $.ui.toast(`coordinator: ${line}`)
107  }
108  $.ui.status(`coord: ${line}`)
109  return readContext($, python)
110}
111
112/** The command line a session uses for the coordinator CLI. */
113export function cliFor(python: string, root: string): string {
114  return `${python} "${root}/scripts/coord.py"`
115}
116
117/** The session-start lines for a coordinated project; none for any other project. */
118export function contextLines(c: CoordContext, cli: string): string[] {
119  if (!c.configured) {
120    return []
121  }
122  if (c.isCoordinator) {
123    const q = c.questions > 0 ? ` ${c.questions} question(s) wait for the user: raise them at the next status.` : ''
124    return [
125      `coordinator: this project runs coordinated worker lanes (${c.live.length} live of max ${c.maxLanes}). ` +
126        `If you coordinate here, follow the coordinator skill. CLI: \`${cli}\` (status, lanes, spawn, retire, ` +
127        `respawn, close-idle, questions, answer, preflight).${q}`,
128    ]
129  }
130  const card = c.cardExists ? `your lane card ${c.card}` : `your lane card ${c.card} (not written yet: write it first)`
131  return [
132    `coordinator: you are the worker \`${c.role}\`. Read ${c.rules}, then ${card}. Coordinator CLI: \`${cli}\` ` +
133      `(ask "<question>" queues a question for the user; preflight before heavy jobs). Retire at about ` +
134      `${Math.round(c.retireTokens / 1000)}k context tokens (you will be reminded); end with SAFE TO CLOSE or KEEP OPEN: <reason>.`,
135  ]
136}
137
138/** A reminder once context passes the retirement threshold, again at each further step; undefined otherwise. */
139export function retireReminder(tokens: number, threshold: number, lastLevel: number): { level: number; text: string } | undefined {
140  if (tokens < threshold) {
141    return undefined
142  }
143  const level = Math.floor((tokens - threshold) / (threshold * RETIRE_STEP)) + 1
144  if (level <= lastLevel) {
145    return undefined
146  }
147  return {
148    level,
149    text:
150      `coordinator: context is at ${Math.round(tokens / 1000)}k tokens, past the ${Math.round(threshold / 1000)}k ` +
151      'retirement point. Finish or park the current step, update your lane card (state, next 3 actions, anchors, ' +
152      'open messages), send the coordinator a FINAL, and end with SAFE TO CLOSE so a successor resumes from the card.',
153  }
154}
155
156export const register: Register = (on, options) => {
157  const python = str(options.python, 'python')
158
159  let ctx: CoordContext | undefined
160  let calls = 0
161  let retireLevel = 0
162  let noted: ReadonlySet<string> = new Set<string>()
163  let ticking = false
164
165  on('session.start', async ($, e, next) => {
166    // Bash children (the model's own coord.py runs, shell workers it starts) inherit these.
167    await $.env.set('COORD_CLI', `${$.plugin.root}/scripts/coord.py`)
168    await $.env.set('COORD_REPOS', str(options.repos, '.'))
169    await $.env.set('COORD_BACKEND', str(options.backend, 'manual'))
170    await $.env.set('COORD_SHELL', str(options.shell, 'auto'))
171    await $.env.set('COORD_WORKER_COMMAND', str(options.workerCommand, 'claude --model {model} "{prompt}"'))
172    await $.env.set('COORD_WORKER_PROMPT', str(options.workerPrompt, 'Read {brief} and execute it. Your role is {role}.'))
173    await $.env.set('COORD_WORKER_MODEL', str(options.workerModel, 'opus'))
174    await $.env.set('COORD_MAX_LANES', String(num(options.maxLanes, 6)))
175    await $.env.set('COORD_RETIRE_TOKENS', String(num(options.retireTokens, 250000)))
176    await $.env.set('COORD_PAUSE_SESSION_PERCENT', String(num(options.pauseSessionPercent, 97)))
177    await $.env.set('COORD_PAUSE_WEEKLY_PERCENT', String(num(options.pauseWeeklyPercent, 80)))
178    await $.env.set('COORD_PROBE_INTERVALS', str(options.probeIntervals, '0:60,70:20,78:10'))
179    await $.env.set('COORD_PROBE_COMMAND', str(options.probeCommand, 'claude --model haiku'))
180    await $.env.set('COORD_PAUSE_REMINDER_MIN', String(num(options.pauseReminderMin, 10)))
181    await $.env.set('COORD_PAUSE_GRACE_MIN', String(num(options.pauseGraceMin, 20)))
182    await $.env.set('COORD_BUS_DIR', str(options.busDir, '.claude/bus'))
183    await $.env.set('COORD_TRACKER_DB', str(options.trackerDb, '.claude/tasks/tasks.db'))
184    await $.env.set('COORD_RULES_FILE', str(options.rulesFile, '.claude/coord/RULES_CORE.md'))
185    await $.env.set('COORD_BRIEFS_DIR', str(options.briefsDir, '.claude/coord/briefs'))
186    await $.env.set('COORD_CARDS_DIR', str(options.cardsDir, '.claude/coord/lanes'))
187    await $.env.set('COORD_REPORTS_DIR', str(options.reportsDir, '.claude/coord/reports'))
188    await $.env.set('COORD_MIN_FREE_RAM_GB', String(num(options.minFreeRamGb, 4)))
189    await $.env.set('COORD_MIN_BATTERY', String(num(options.minBattery, 40)))
190    await $.command.register({
191      name: 'coordinator',
192      description: 'Coordinate worker agents: init, status, lanes, spawn, retire, respawn, close-idle, questions, answer, preflight, usage, pause, resume.',
193      argumentHint: '[init|status|lanes|spawn <role> [brief]|retire <role>|respawn <role>|close-idle|questions|answer <id> <text>|preflight|usage|pause|resume]',
194    })
195    // While a graceful pause runs, advance it every minute: close the lanes that checkpointed,
196    // remind the silent ones once, interrupt them after the grace period, write the resume list.
197    $.clock.every(60000, () => {
198      if (ctx?.pause === 'pausing' && !ticking) {
199        ticking = true
200        void tickPause($, python).then(
201          c => {
202            ctx = c ?? ctx
203            ticking = false
204          },
205          () => {
206            ticking = false
207          },
208        )
209      }
210    })
211    return next(e)
212  })
213
214  on('command.run', { command: 'coordinator' }, async ($, e) => {
215    const typed = e.args.trim()
216    const args = typed === '' ? ['status'] : typed.split(/\s+/)
217    const ran = await runCoord($, python, args, 120000)
218    const text = [ran.stdout.trim(), ran.stderr.trim()].filter(t => t !== '').join('\n')
219    const c = await readContext($, python)
220    ctx = c ?? ctx
221    if (c?.configured && c.isCoordinator) {
222      $.ui.status(`coord: ${c.live.length}/${c.maxLanes} lanes${c.questions > 0 ? `, ${c.questions} question(s)` : ''}`)
223    }
224    return { text: text === '' ? `coordinator ${args.join(' ')}: done` : text }
225  })
226
227  on('classic.SessionStart', async ($, e, next) => {
228    const result = await next(e)
229    const c = await readContext($, python)
230    ctx = c ?? ctx
231    if (c === undefined) {
232      return result
233    }
234    const lines = contextLines(c, cliFor(python, $.plugin.root))
235    if (lines.length === 0) {
236      return result
237    }
238    if (c.isCoordinator) {
239      $.ui.status(`coord: ${c.live.length}/${c.maxLanes} lanes${c.questions > 0 ? `, ${c.questions} question(s)` : ''}`)
240    }
241    return { ...result, additionalContext: [...(result.additionalContext ?? []), ...lines] }
242  }).catch(($, e, next) => next(e))
243
244  on('classic.PostToolUse', async ($, e, next) => {
245    const result = await next(e)
246    calls += 1
247    if (ctx === undefined || !ctx.configured || calls % USAGE_EVERY_CALLS !== 0) {
248      return result
249    }
250    const usage = await $.session.usage()
251    const notes: string[] = []
252    const tokens = usage.context.tokens ?? 0
253    const reminder = retireReminder(tokens, ctx.retireTokens, retireLevel)
254    if (reminder !== undefined) {
255      retireLevel = reminder.level
256      notes.push(reminder.text)
257    }
258    const u = usageNotes(usage.rateLimits, ctx, noted)
259    noted = u.noted
260    notes.push(...u.notes)
261    if (ctx.isCoordinator) {
262      // Shared with `coord usage` and the watcher: the coordinator session's own reading counts as a probe.
263      const week = usage.rateLimits.find(w => windowKind(w.kind) === 'week')
264      const session = usage.rateLimits.find(w => windowKind(w.kind) === 'session')
265      if (week !== undefined || session !== undefined) {
266        void runCoord($, python, [
267          'record-usage',
268          ...(week ? ['--week', String(Math.round(week.percentUsed))] : []),
269          ...(session ? ['--session', String(Math.round(session.percentUsed))] : []),
270        ], 15000)
271      }
272      if (u.notes.length > 0) {
273        $.ui.toast('coordinator: usage threshold reached; see the note in the transcript')
274      }
275    }
276    if (notes.length === 0) {
277      return result
278    }
279    return { ...result, additionalContext: [...(result.additionalContext ?? []), ...notes] }
280  }).catch(($, e, next) => next(e))
281}
282