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

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.
/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
/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.
init creates| Path (default) | What it is |
|---|---|
.claude/coord/RULES_CORE.md | The short rules every worker reads first. Edit section 1 to describe your project. |
.claude/coord/lanes/README.md | The lane-card template. A card is at most 150 lines and is the worker's handoff. |
.claude/coord/lanes/lanes.json | Which lanes exist, where they run, and their generation. |
.claude/coord/briefs/TEMPLATE.md | The brief template: goal, scope, steps, proof, constraints, report. |
.claude/coord/briefs/RESUME.md | The successor startup: read the rules, the card and the backlog, then work. |
.claude/coord/briefs/scheduler.md | A scheduler worker that grants one heavy job at a time, by RAM, battery and the user's activity. |
.claude/coord/briefs/HANDOFF_PROTOCOL.md | How a worker retires and how a successor resumes. |
.claude/coord/reports/questions.jsonl | The question queue: questions for the user, asked at status time and never blocking. |
.claude/claude-mods.json → coordinator | The resolved configuration, shared by every worker. |
With agent-bus installed, init also creates the bus and registers the roles (default: coordinator,scheduler).
/coordinator <verb> runs coord.py <verb>. Sessions also get the CLI as $COORD_CLI.
| Verb | What 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. | |
status | A 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. | |
paused | A 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. | |
config | The resolved configuration. |
backend | How a worker starts | Retire/close |
|---|---|---|
manual (default) | Prints the command, with AGENT_BUS_ROLE set, for you to run in a new terminal. | You close it. |
orca | orca terminal create --worktree path:<repo> --title <role> --command .... The handle is registered on the bus. | orca terminal close --tab |
tmux | tmux new-window -n <role> -c <repo> -e AGENT_BUS_ROLE=<role>, in the current session or a detached coord session. | tmux kill-window |
wt | A 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.
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.
| Option | Default | What it does |
|---|---|---|
repos | . | Comma-separated repo paths workers start in. The first is the default. |
backend | manual | manual, orca, tmux or wt (see Backends). |
shell | auto | How AGENT_BUS_ROLE is set before the command: auto (PowerShell on Windows, POSIX elsewhere), posix, powershell or cmd. |
workerCommand | claude --model {model} "{prompt}" | The worker command template. |
workerPrompt | Read {brief} and execute it. Your role is {role}. | The worker's first prompt. |
workerModel | opus | {model} in the template. spawn --model overrides it per lane. |
maxLanes | 6 | The number of concurrent live lanes. |
retireTokens | 250000 | Context tokens at which a worker is told to update its card and retire (again every further 10%). |
pauseWeeklyPercent | 80 | Weekly usage at which the coordinator is alerted to run the graceful pause. |
pauseSessionPercent | 97 | 5-hour session usage at which every session is told to checkpoint and pause itself. |
probeIntervals | 0:60,70:20,78:10 | The watcher's schedule as weekly%:minutes steps: every 60 min, every 20 from 70%, every 10 from 78%. |
probeCommand | claude --model haiku | The throwaway session the watcher opens to read /usage. |
pauseReminderMin | 10 | Minutes before a silent lane gets one reminder. |
pauseGraceMin | 20 | Minutes before a silent lane is interrupted and its terminal closed. |
busDir | .claude/bus | The agent-bus directory. |
trackerDb | .claude/tasks/tasks.db | The task-tracker db, read-only, for lane tasks. |
rulesFile | .claude/coord/RULES_CORE.md | The rules core. |
briefsDir | .claude/coord/briefs | Briefs, the resume brief, the scheduler brief and the handoff protocol. |
cardsDir | .claude/coord/lanes | Lane cards and lanes.json. |
reportsDir | .claude/coord/reports | Progress logs, the question queue, and the message fallback without the bus. |
minFreeRamGb | 4 | preflight answers WAIT below this. |
minBattery | 40 | preflight answers WAIT below this when not charging. |
python | python | The interpreter for the CLI. |
init:coordinator) is told the live lanes, the open questions and the CLI;AGENT_BUS_ROLE set) is pointed at the rules and its card.Other projects get nothing.
retireTokens, it adds a retirement reminder;pauseWeeklyPercent of the weekly window, it adds a "start the graceful pause" note and a toast, once per crossing;pauseSessionPercent of the 5-hour window, it adds a checkpoint-and-pause notice.coord: N/M lanes, Q question(s) in the coordinator session.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:
$.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.coord usage-watch --start runs it in its own terminal.probeCommand, a cheap model), types /usage, reads the screen and closes it.probeIntervals).orca or tmux backend. With wt or manual, only the coordinator session's own readings are available.<reportsDir>/alerts.jsonl and prints. In the coordinator session the plugin adds a note and a toast./coordinator pause:
coord paused;coord pause --wait):pauseReminderMin;pauseGraceMin, silent lanes get an Escape and are closed. Their card is what they last wrote.<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.
retire and answer print what to tell the worker;<reportsDir>/<role>_progress.md.From the repository root:
claude plugin validate ./coordinator
claude plugin test ./coordinator
python -m unittest discover -s coordinator/scripts/tests
claude --plugin-dir ./coordinator
MIT
hooks/register.ts 282 lines1import 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