SLOPSHOPPER

task-router

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…

newpanespinnerguardpromptprocess
★ 3v0.1.0MITupdated 2026-10-14omeerdvrn/o-mods/mods/task-router
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · task-router
│ ┃ task-router ✕ › fix the failing auth test and add an audit log call │ ┃ Nothing routed in this project yet. │ ┃ ⏺ Read(src/auth.ts) │ ┃ Run /route init once, then /route with a ⎿ Read 6 lines │ ┃ task or a plan file. ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · task-router
Nothing routed in this project yet. Run /route init once, then /route with a task or a plan file.
README

task-router

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.

task-router running in Claude Code

Use

  1. /route init in a project: writes .claude/routing/rules.yaml (yours to refine) and the execution rule into the project's CLAUDE.md.
  2. /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.
  3. /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" }.

Pieces

PieceWhere
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 skillsskills/route, skills/route-status
Hooks: subagent start (actual model), finish (status block), unrouted subagents, sidebar, footerhooks/register.tsx
Source 3 files
hooks/register.tsx 265 lines
1import { 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}
265
hooks/crabs.ts 151 lines
1/**
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}
151
types/index.d.ts 26 lines
1/** 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