Routes every task to a worker subagent on the right model tier (Haiku, Sonnet, Opus) by rules you own, tracks it in a registry, and shows each model as a…

In testing. task-router is not in the marketplace yet. To try it from a clone of this repository:
claude --plugin-dir mods/task-router
Every task gets labeled by a cheap classifier (Haiku), dispatched to a worker subagent on the right model tier, and tracked in a registry, so you can see at any time what was decided, what runs, and on which model.

/route init in a project: writes .claude/routing/rules.yaml (yours to refine) and the execution rule into the project's CLAUDE.md./route <task> routes one task; /route docs/superpowers/plans/<plan>.md routes a whole plan in order; add --dry-run to classify and route without running workers./route-status opens the sidebar (each model as its Claude crab) and prints the table; /route-status running|haiku|sonnet|opus|unrouted|<task_id> filters or shows one task in full.The footer shows live counts (H:2▶ S:1▶ O:0▶ U:0 | done 7 | failed 1). For Claude Code's status line instead, set in settings.json: "statusLine": { "type": "command", "command": "sh <plugin>/scripts/statusline.sh" }.
| Piece | Where |
|---|---|
| Classifier (haiku, read-only) | agents/task-classifier.md |
| Workers: light (haiku), standard (sonnet), heavy (opus) | agents/worker-*.md |
| Routing rules: dimensions, rules (first match wins), default, escalation | .claude/routing/rules.yaml (default in defaults/) |
| Registry, the one writer (add, update, get, list, decide, escalate, review, plan, status) | scripts/registry.py |
| Registry and decision log | .claude/routing/registry.json, .claude/routing/decisions.log |
| Orchestrator and status skills | skills/route, skills/route-status |
| Hooks: subagent start (actual model), finish (status block), unrouted subagents, sidebar, footer | hooks/register.tsx |
hooks/register.tsx 265 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Row } from '../types'
5import { ACCENT, DEFAULT_MODELS, TIER_NAMES, crabRuns, crabSvg, modelName } from './crabs'
6import type { Tier } from './crabs'
7
8const PANE = 'task-router'
9const TITLE = 'Task router'
10const REFRESH_MS = 5_000
11const REAP_MS = 20_000
12const TIERS: Tier[] = ['haiku', 'sonnet', 'opus', 'unrouted']
13const STATUS_GLYPH: Record<string, string> = {
14 queued: '·', classifying: '◌', routed: '○', running: '▶', done: '✓', failed: '✗', escalated: '↑', blocked: '■',
15}
16const STATUS_COLOR: Record<string, string> = {
17 running: 'warning', done: 'success', failed: 'error', blocked: 'error', escalated: 'permission',
18}
19/** Statuses waiting for a worker. */
20const WAITING = ['queued', 'classifying', 'routed']
21const DONE_SHOWN = 2
22
23/** The tier's model by full name: the one its workers last ran on, else the one it runs on today. */
24function tierModel(all: Row[], tier: Tier): string {
25 if (tier === 'unrouted') return 'Unrouted'
26 const ran = all.filter(row => tierOf(row) === tier && row.ran_on?.startsWith('claude-')).map(row => row.ran_on!)
27 return modelName(ran.at(-1) ?? DEFAULT_MODELS[tier])
28}
29
30/** What a tier is doing, in words: "1 running, 3 queued"; "idle" when nothing is. */
31function stateLine(mine: Row[]): string {
32 const count = (statuses: string[]) => mine.filter(row => statuses.includes(row.status)).length
33 const parts = [
34 [count(['running']), 'running'],
35 [count(WAITING), 'queued'],
36 [count(['failed', 'blocked']), 'failed'],
37 [count(['done']), 'done'],
38 ].filter(([n]) => (n as number) > 0).map(([n, word]) => `${n} ${word}`)
39 return parts.length > 0 ? parts.join(', ') : 'idle'
40}
41
42const rows = atom({ plugin: 'task-router', key: 'rows' } as const, [] as Row[])
43
44function scriptOf($: EngineInterface): string {
45 return `${$.plugin.root.replace(/\/\.claude-plugin\/?$/, '')}/scripts/registry.py`
46}
47
48/** Runs the registry script in the session's project; every registry write goes through it. */
49async function registry($: EngineInterface, args: string[]) {
50 const cwd = await $.session.cwd()
51 return $.process.run(['python3', scriptOf($), ...args], { cwd, timeoutMs: 15_000 })
52}
53
54/** Whether this project uses the router: `/route init` or a first `/route` made its folder. */
55async function isOptedIn($: EngineInterface): Promise<boolean> {
56 const cwd = await $.session.cwd()
57 const top = await $.process.run(['git', 'rev-parse', '--show-toplevel'], { cwd, timeoutMs: 5_000 }).catch(() => null)
58 const root = top?.exitCode === 0 ? top.stdout.trim() : cwd
59 return Boolean(await $.fs.stat(`${root}/.claude/routing`).catch(() => undefined))
60}
61
62async function refresh($: EngineInterface) {
63 const ran = await registry($, ['summary-json']).catch(() => null)
64 if (ran?.exitCode !== 0) return
65 const all = JSON.parse(ran.stdout || '[]') as Row[]
66 await update($, rows, () => all)
67}
68
69function tierOf(row: Row): Tier | null {
70 const group = row.group.toLowerCase()
71 return group === 'haiku' || group === 'sonnet' || group === 'opus' || group === 'unrouted' ? group : null
72}
73
74export const register: Register = on => {
75 on('session.start', async ($, e, next) => {
76 // /route and /route-status are the plugin's skills; the mod answers /route-status itself (below).
77 // Projects that do not use the router get no polling at all; /route-status and the hooks still work there.
78 if (!(await isOptedIn($).catch(() => false))) return next(e)
79 void refresh($)
80 $.clock.every(REFRESH_MS, () => void refresh($))
81 // Background subagents end without a tool result: close their tasks once their loop is gone.
82 $.clock.every(REAP_MS, () => {
83 void (async () => {
84 if (!(await read($, rows)).some(row => row.status === 'running')) return
85 const agents = await $.agent.list()
86 await registry($, ['reap', JSON.stringify(agents.map(agent => ({ id: agent.id, status: agent.status })))])
87 await refresh($)
88 })()
89 })
90
91 return next(e)
92 })
93
94 // Every subagent start: the registry learns which model it really runs on, and registers anything unrouted.
95 on('agent.spawn', async ($, e, next) => {
96 const started = await next(e)
97 if ('deny' in started && started.deny) return started
98 void registry($, [
99 'started',
100 JSON.stringify({
101 subagentType: e.subagentType,
102 description: e.description,
103 prompt: e.prompt.slice(0, 2000),
104 model: 'model' in started ? started.model : undefined,
105 agentId: 'agentId' in started ? started.agentId : undefined,
106 }),
107 ]).then(() => refresh($)).catch(() => undefined)
108
109 return started
110 }).catch(($, e, next) => (next.called ? undefined : next(e)) as never)
111
112 // A subagent's answer: the worker's closing status block moves its task to done, failed or blocked.
113 on('tool.call', { tool: 'Agent' }, async ($, e, next) => {
114 const result = await next(e)
115 const record = 'result' in result ? (result.result as { agentId?: string; content?: { text?: string }[]; resolvedModel?: string } | undefined) : undefined
116 const text = (record?.content ?? []).map(block => block.text ?? '').join('\n')
117 if (record?.agentId && text.trim() !== '') {
118 const isError = 'isError' in result && result.isError === true
119 void registry($, ['stopped', JSON.stringify({ agentId: record.agentId, text: text.slice(-4000), isError, model: record.resolvedModel })])
120 .then(() => refresh($))
121 .catch(() => undefined)
122 }
123
124 return result
125 }).catch(($, e, next) => (next.called ? undefined : next(e)) as never)
126
127 // The execution rule, for projects that use the router; their CLAUDE.md carries it too (/route init).
128 on('prompt.compose', async ($, e, next) => {
129 const composed = await next(e)
130 if (!(await isOptedIn($).catch(() => false))) return composed
131 const text = [
132 '# task-router',
133 'This project routes execution through task-router. When executing an approved implementation plan, run the task-router:route skill in batch mode on the plan file instead of superpowers:subagent-driven-development or superpowers:executing-plans. Superpowers still owns brainstorming, planning, worktrees, debugging and finishing the branch.',
134 `Task state lives in .claude/routing/registry.json and changes only through: python3 "${scriptOf($)}"`,
135 ].join('\n\n')
136
137 return { sections: [...composed.sections, { id: 'task-router:rule', text, scope: 'session' }] }
138 })
139
140 // The skill's command, answered here without a model turn: the table, and the sidebar with the crabs.
141 on('command.run', { command: ['route-status', 'task-router:route-status'] }, async ($, e) => {
142 const filter = e.args.trim()
143 await refresh($)
144 const ran = await registry($, ['status', ...(filter ? [filter] : [])])
145 // The engine leads with the plugin's name; the table starts on the next line.
146 const text = '\n' + (ran.stdout || ran.stderr).trimEnd()
147 if (filter === '') {
148 // The sidebar with the crabs, where the surface places panes; the table prints either way.
149 await $.ui.open({ id: PANE, title: TITLE })
150 }
151 return { text }
152 })
153
154 // The footer: what runs now, in words, and only while something runs or failed.
155 on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
156 const all = await read($, rows)
157 const running = all.filter(row => row.status === 'running').length
158 const failed = all.filter(row => row.status === 'failed' || row.status === 'blocked').length
159 if (running + failed === 0) return next(e)
160 const label = ['router', running > 0 && `${running} running`, failed > 0 && `${failed} failed`].filter(Boolean).join(' ')
161 return next({ ...e, props: { ...e.props, modes: [...e.props.modes, label] } })
162 })
163
164 // The sidebar: a lane per model, its crab in full color while it works and faded while idle.
165 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
166 const { Box, Text, Svg } = $.ui.resolve(e)
167 const all = await read($, rows)
168 const isDesktop = e.surface === 'desktop'
169
170 const crab = (tier: Tier, isIdle: boolean) =>
171 isDesktop && Svg ? (
172 <Svg source={crabSvg(tier, isIdle)} width={64} height={32} alt={`${TIER_NAMES[tier]} crab`} />
173 ) : (
174 <Box flexDirection="column" width={16} flexShrink={0}>
175 {crabRuns(tier, isIdle).map((runs, index) => (
176 <Box key={`crab-${tier}-${index}`} flexDirection="row">
177 {runs.map(run => (
178 <Text color={run.color} backgroundColor={run.background}>{run.text}</Text>
179 ))}
180 </Box>
181 ))}
182 </Box>
183 )
184
185 const taskRow = (row: Row) => (
186 <Box key={`row-${row.task_id}`} flexDirection="row" columnGap={1}>
187 <Box width={1} flexShrink={0}>
188 <Text color={STATUS_COLOR[row.status] ?? 'inactive'}>{STATUS_GLYPH[row.status] ?? '?'}</Text>
189 </Box>
190 <Box flexGrow={1} flexShrink={1}>
191 <Text wrap="truncate-end" dimColor={row.status === 'done'}>{row.title}</Text>
192 </Box>
193 <Box width={6} flexShrink={0}>
194 <Text dimColor>{row.task_id}</Text>
195 </Box>
196 <Box width={4} flexShrink={0} justifyContent="flex-end">
197 <Text dimColor>{row.elapsed}</Text>
198 </Box>
199 </Box>
200 )
201
202 const attention = all.filter(row => !row.parent_id && (['failed', 'blocked', 'escalated'].includes(row.status) || row.mismatch || row.group === 'Unrouted'))
203 const why = (row: Row) => {
204 if (row.group === 'Unrouted') return 'started without /route'
205 if (row.mismatch) return 'ran on a different model than decided'
206 if (row.status === 'escalated') {
207 const chain = [row]
208 for (let child = all.find(one => one.parent_id === row.task_id); child; child = all.find(one => one.parent_id === child!.task_id)) chain.push(child)
209 const last = chain.at(-1)!
210 return `${tierModel(all, tierOf(row) ?? 'sonnet')} failed, ${last.status} on ${tierModel(all, tierOf(last) ?? 'opus')}`
211 }
212 return row.status
213 }
214
215 if (all.length === 0) {
216 return (
217 <Box flexDirection="column" rowGap={1}>
218 <Text wrap="wrap">Nothing routed in this project yet.</Text>
219 <Text dimColor wrap="wrap">Run /route init once, then /route with a task or a plan file.</Text>
220 </Box>
221 )
222 }
223
224 return (
225 <Box flexDirection="column" rowGap={1}>
226 {attention.length > 0 && (
227 <Box flexDirection="column">
228 <Text color="error" bold>Needs attention</Text>
229 {attention.map(row => (
230 <Box key={`need-${row.task_id}`} flexDirection="row" columnGap={1}>
231 <Box width={1} flexShrink={0}>
232 <Text color={row.status === 'escalated' ? 'permission' : 'error'}>{row.group === 'Unrouted' ? '?' : row.status === 'escalated' ? '↑' : '!'}</Text>
233 </Box>
234 <Box flexDirection="column" flexGrow={1}>
235 <Text wrap="truncate-end">{row.title}</Text>
236 <Text dimColor wrap="truncate-end">{why(row)}</Text>
237 </Box>
238 </Box>
239 ))}
240 </Box>
241 )}
242 {TIERS.map(tier => {
243 const mine = all.filter(row => tierOf(row) === tier)
244 if (tier === 'unrouted' && mine.length === 0) return null
245 const isIdle = !mine.some(row => row.status === 'running')
246 const active = mine.filter(row => row.status === 'running' || WAITING.includes(row.status) || row.status === 'failed' || row.status === 'blocked')
247 const done = mine.filter(row => row.status === 'done')
248 const shown = [...active.sort((a, b) => (a.status === 'running' ? -1 : 0) - (b.status === 'running' ? -1 : 0)), ...done.slice(-DONE_SHOWN)]
249 return (
250 <Box key={`tier-${tier}`} flexDirection="row" columnGap={2}>
251 {crab(tier, isIdle)}
252 <Box flexDirection="column" flexGrow={1} flexShrink={1}>
253 <Text color={isIdle ? undefined : ACCENT[tier]} bold>{tierModel(all, tier)}</Text>
254 <Text dimColor>{stateLine(mine)}</Text>
255 {shown.map(taskRow)}
256 {done.length > DONE_SHOWN && <Text dimColor>{`and ${done.length - DONE_SHOWN} more done`}</Text>}
257 </Box>
258 </Box>
259 )
260 })}
261 </Box>
262 )
263 })
264}
265hooks/crabs.ts 151 lines1/**
2 * A Claude crab for each model tier, in half-block pixel art (four rows tall, every terminal font has
3 * them) and as SVG for the desktop app. 16×8 pixels: row 0 for a hat, the head with its eyes, arms out,
4 * four legs; each tier dresses it up.
5 */
6
7export type Tier = 'haiku' | 'sonnet' | 'opus' | 'unrouted'
8
9const CRAB = [
10 '................',
11 '...oooooooooo...',
12 '...ooeooooeoo...',
13 '...oooooooooo...',
14 '.oooooooooooooo.',
15 '...oooooooooo...',
16 '....o.o..o.o....',
17 '....o.o..o.o....',
18]
19
20/** Each tier's look over the crab; `.` leaves the crab as it is. */
21const DRESS: Record<Tier, string[]> = {
22 // Haiku: the quick one. A headband with its tails flying, speed lines behind.
23 haiku: [
24 '................',
25 '.b.bbbbbbbbbb...',
26 'bb..............',
27 's...............',
28 '................',
29 's...............',
30 'ss..............',
31 '................',
32 ],
33 // Sonnet: the writer. Round glasses and a quill.
34 sonnet: [
35 '...............w',
36 '.....g....g...ww',
37 '....g.gggg.g..w.',
38 '..............n.',
39 '................',
40 '................',
41 '................',
42 '................',
43 ],
44 // Opus: the heavyweight. A jewelled crown and a royal cape.
45 opus: [
46 '....y..yy..y....',
47 '....yjyyyyjy....',
48 '................',
49 '..v..........v..',
50 '................',
51 '..v..........v..',
52 '.vv..........vv.',
53 '.vv..........vv.',
54 ],
55 // Unrouted: nobody sent it. A grey crab with a question mark.
56 unrouted: [
57 '.............kkk',
58 '...............k',
59 '..............k.',
60 '................',
61 '..............k.',
62 '................',
63 '................',
64 '................',
65 ],
66}
67
68const BASE: Record<string, string> = { o: '#D4826A', e: '#1B1B1B' }
69
70const COLORS: Record<Tier, Record<string, string>> = {
71 haiku: { ...BASE, b: '#5BC8E8', s: '#A9E4F5' },
72 sonnet: { ...BASE, g: '#F2D27A', w: '#F4F1EA', n: '#3A3A44' },
73 opus: { ...BASE, y: '#F2C230', j: '#E0475A', v: '#7B3FA8' },
74 unrouted: { o: '#8D9299', e: '#1B1B1B', k: '#F2C230' },
75}
76
77/** The tier's own color, for its name and counts next to the crab. */
78export const ACCENT: Record<Tier, string> = { haiku: '#5BC8E8', sonnet: '#A99BF0', opus: '#F2C230', unrouted: '#9AA0A6' }
79
80export const TIER_NAMES: Record<Tier, string> = { haiku: 'Haiku', sonnet: 'Sonnet', opus: 'Opus', unrouted: 'Unrouted' }
81
82/** The models each tier runs on today, until a worker reports the one it really ran on. */
83export const DEFAULT_MODELS: Record<Exclude<Tier, 'unrouted'>, string> = {
84 haiku: 'claude-haiku-5-5',
85 sonnet: 'claude-sonnet-5-5',
86 opus: 'claude-opus-5-5',
87}
88
89/** "claude-opus-5-5" -> "Opus 5.5", "claude-haiku-4-5-20251001" -> "Haiku 4.5", "claude-sonnet-5-5[1m]" -> "Sonnet 5.5 1M". */
90export function modelName(model: string): string {
91 const match = /^claude-([a-z]+)-(\d+)(?:-(\d+))?(?:-\d{8})?(\[1m\])?$/i.exec(model)
92 if (!match) return model
93 const [, family, major, minor, wide] = match
94 const name = `${family![0]!.toUpperCase()}${family!.slice(1)} ${major}${minor ? `.${minor}` : ''}`
95 return wide ? `${name} 1M` : name
96}
97
98export function crabArt(tier: Tier): string[] {
99 const dress = DRESS[tier]
100 return CRAB.map((line, row) =>
101 [...line].map((pixel, column) => {
102 const over = dress[row]?.[column] ?? '.'
103 return over === '.' ? pixel : over
104 }).join(''),
105 )
106}
107
108/** One cell of a drawn row: its glyph, the glyph's color and, when both halves are painted, the background. */
109export type Run = { text: string; color?: string; background?: string }
110
111/** A color pulled most of the way to a quiet grey: an idle tier's crab sits back, a busy one stands out. */
112function faded(hex: string): string {
113 const mix = (at: number, toward: number) => Math.round(parseInt(hex.slice(at, at + 2), 16) * 0.35 + toward * 0.65)
114 return `#${[mix(1, 0x4a), mix(3, 0x4c), mix(5, 0x55)].map(value => value.toString(16).padStart(2, '0')).join('')}`
115}
116
117/** The crab as four rows of half-block cells: each cell holds a column's top and bottom pixel. */
118export function crabRuns(tier: Tier, isIdle = false): Run[][] {
119 const art = crabArt(tier)
120 const colors = isIdle ? Object.fromEntries(Object.entries(COLORS[tier]).map(([key, hex]) => [key, faded(hex)])) : COLORS[tier]
121 const rows: Run[][] = []
122 for (let top = 0; top < art.length; top += 2) {
123 const runs: Run[] = []
124 for (let column = 0; column < 16; column++) {
125 const upper = colors[art[top]![column]!]
126 const lower = colors[art[top + 1]![column]!]
127 if (!upper && !lower) runs.push({ text: ' ' })
128 else if (!upper) runs.push({ text: '▄', color: lower })
129 else if (!lower) runs.push({ text: '▀', color: upper })
130 else if (upper === lower) runs.push({ text: '█', color: upper })
131 else runs.push({ text: '▀', color: upper, background: lower })
132 }
133 rows.push(runs)
134 }
135 return rows
136}
137
138/** The crab as SVG, one square per pixel, for the desktop app. */
139export function crabSvg(tier: Tier, isIdle = false): string {
140 const art = crabArt(tier)
141 const colors = isIdle ? Object.fromEntries(Object.entries(COLORS[tier]).map(([key, hex]) => [key, faded(hex)])) : COLORS[tier]
142 const squares: string[] = []
143 art.forEach((line, y) =>
144 [...line].forEach((pixel, x) => {
145 const fill = colors[pixel]
146 if (fill) squares.push(`<rect x="${x}" y="${y}" width="1" height="1" fill="${fill}"/>`)
147 }),
148 )
149 return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 8" shape-rendering="crispEdges">${squares.join('')}</svg>`
150}
151types/index.d.ts 26 lines1/** One task as the registry script's summary-json gives it, for the sidebar. */
2export type Row = {
3 task_id: string
4 title: string
5 status: string
6 review: string | null
7 plan_ref: string | null
8 parent_id: string | null
9 mismatch: boolean
10 /** Haiku, Sonnet, Opus, Unrouted or Pending (not yet routed). */
11 group: string
12 model: string | null
13 rule: string | null
14 elapsed: string
15 /** The model id the task's subagent really ran on, when a hook saw it. */
16 ran_on: string | null
17}
18
19declare module 'claude-code' {
20 interface PluginState {
21 'task-router': {
22 rows: Row[]
23 }
24 }
25}
26