SLOPSHOPPER

Agent Race

Race Claude Code sessions on a task: live split-screen race track scoreboard

newpaneguardcommandtoaststatus
★ 2v0.1.0MITupdated 2026-10-02OneWave-AI/claude-code-mods/agent-race
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · agent-race
│ ┃ Race ✕ › fix the failing auth test and add an audit log call │ ┃ AGENT RACE │ ┃ /race start <race> [name] in two terminals ⏺ Read(src/auth.ts) │ ┃ to race them. ⎿ Read 6 lines │ ┃ /race demo to watch a simulated race. ⏺ 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 │ │ › /race │ ⎿ agent-race: Race pane opened. /race start <race> to join one, /r │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Race
AGENT RACE /race start <race> [name] in two terminals to race them. /race demo to watch a simulated race.
README

agent-race

Race Claude Code sessions on a task: live split-screen race track scoreboard.

agent-race

/race start <race> [name] in two or more sessions puts them on the same track. The pane shows each session's tool calls, files touched, and test runs live, and /race done crosses the finish line. Good for comparing models or prompts on the same task.

Install

In a Claude Code session (2.1.287 or later):

/plugin marketplace add OneWave-AI/claude-code-mods
/plugin install agent-race@claude-code-mods

Or load this folder for one session: claude --plugin-dir ./agent-race

What it reaches

ModNetworkRuns processesFilesCalls a modelSends data anywhere
agent-raceNoNoReads and writes ~/.claude/agent-race/<race>/NoNo

Run claude plugin validate ./agent-race to list every event it hooks and every call it makes. See the repository README for the full security notes.

Part of claude-code-mods by OneWave AI. MIT licensed.

Source 3 files
hooks/register.tsx 275 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Lane, RaceView } from '../types'
5import {
6  clock,
7  demoTick,
8  isTestCommand,
9  mergeLanes,
10  newLane,
11  ordinal,
12  parseLane,
13  parseTests,
14  progressOf,
15  rank,
16  safeName,
17  track,
18} from './race'
19
20const PANE = 'agent-race'
21const POLL_MS = 1000
22const DEMO_MS = 260
23
24const view = atom({ plugin: 'agent-race', key: 'view' } as const, {
25  race: null,
26  isDemo: false,
27  lanes: [],
28  winner: null,
29} as RaceView)
30
31const BLUE = '#61A5FA'
32const GOLD = '#c2a87e'
33const PAPER = '#E8E2D6'
34const INK = '#0B1121'
35const LANE_COLORS = [BLUE, GOLD, PAPER, '#8FB8E8']
36
37let me: Lane | null = null
38let dir = ''
39let lastGreen = false
40let timer: { cancel: () => void } | null = null
41let frame = 0
42
43const stop = () => {
44  timer?.cancel()
45  timer = null
46}
47
48const raceDir = async ($: EngineInterface, race: string) => {
49  const home = (await $.env.get('HOME')) ?? '~'
50  return `${home}/.claude/agent-race/${race}`
51}
52
53const writeMine = async ($: EngineInterface) => {
54  if (!me || !dir) return
55  me = { ...me, updatedAt: await $.clock.now() }
56  await $.fs.write(`${dir}/${me.id}.json`, JSON.stringify(me))
57}
58
59/** Every lane file of the race, this session's own taken from memory. */
60const readLanes = async ($: EngineInterface, race: string) => {
61  const entries = await $.fs.list(dir).catch(() => [])
62  const lanes: Lane[] = []
63  for (const entry of entries) {
64    if (entry.kind !== 'file' || !entry.name.endsWith('.json')) continue
65    const text = await $.fs.read(`${dir}/${entry.name}`).catch(() => '')
66    const lane = parseLane(text, race)
67    if (lane && lane.id !== me?.id) lanes.push(lane)
68  }
69  if (me) lanes.push(me)
70  return mergeLanes(lanes, await $.clock.now())
71}
72
73const crown = async ($: EngineInterface, lanes: Lane[]) => {
74  const first = rank(lanes)[0]
75  const current = await read($, view)
76  if (!first || first.finishedAt === null || current.winner) return
77  await update($, view, v => ({ ...v, winner: first.name }))
78  $.ui.toast(`FINISH. ${first.name} wins in ${clock(first.finishedAt - first.startedAt)}`, { timeoutMs: 12000 })
79  void $.audio.speak(`${first.name} wins the race`).catch(() => undefined)
80}
81
82const poll = async ($: EngineInterface) => {
83  const current = await read($, view)
84  if (!current.race || current.isDemo) return
85  frame += 1
86  const lanes = await readLanes($, current.race)
87  await update($, view, v => ({ ...v, lanes }))
88  await crown($, lanes)
89  const place = rank(lanes).findIndex(l => l.id === me?.id) + 1
90  $.ui.status(me && place ? `Race ${current.race}: ${ordinal(place)} of ${lanes.length}` : undefined)
91}
92
93const start = async ($: EngineInterface, raw: string, name: string) => {
94  stop()
95  const race = safeName(raw)
96  dir = await raceDir($, race)
97  const now = await $.clock.now()
98  me = newLane(await $.session.id(), name, race, now)
99  lastGreen = false
100  await writeMine($)
101  await update($, view, () => ({ race, isDemo: false, lanes: [me!], winner: null }))
102  timer = $.clock.every(POLL_MS, () => void poll($).catch(() => undefined))
103  await $.ui.open({ id: PANE, title: `Race: ${race}` })
104  return race
105}
106
107const demo = async ($: EngineInterface) => {
108  stop()
109  me = null
110  const now = await $.clock.now()
111  const lanes = [newLane('demo-a', 'Opus', 'demo', now), newLane('demo-b', 'Sonnet', 'demo', now)]
112  await update($, view, () => ({ race: 'demo', isDemo: true, lanes, winner: null }))
113  await $.ui.open({ id: PANE, title: 'Race: demo' })
114  timer = $.clock.every(DEMO_MS, () => {
115    frame += 1
116    void (async () => {
117      const at = await $.clock.now()
118      const v = await read($, view)
119      const next = v.lanes.map((lane, i) => demoTick(lane, i, at, Math.random()))
120      await update($, view, x => ({ ...x, lanes: next }))
121      await crown($, next)
122      if (next.every(l => l.finishedAt !== null)) stop()
123    })().catch(() => undefined)
124  })
125}
126
127const bump = async ($: EngineInterface, change: (lane: Lane) => Lane) => {
128  if (!me || me.finishedAt !== null) return
129  me = change(me)
130  await writeMine($)
131}
132
133const finish = async ($: EngineInterface) => {
134  if (!me || me.finishedAt !== null) return false
135  const at = await $.clock.now()
136  await bump($, lane => ({ ...lane, finishedAt: at }))
137  await poll($)
138  return true
139}
140
141export const register: Register = on => {
142  on('session.start', async ($, e, next) => {
143    await $.command.register({
144      name: 'race',
145      description: 'Race sessions on a task: /race start <race> [name] | done | demo | leave',
146    })
147    return next(e)
148  })
149
150  on('command.run', { command: 'race' }, async ($, e) => {
151    const [verb = '', race = '', ...rest] = e.args.trim().split(/\s+/)
152    if (verb === 'start' || verb === 'join') {
153      if (!race) return { text: 'Usage: /race start <race> [lane name]' }
154      const id = (await $.session.id()).slice(0, 4)
155      const name = rest.join(' ') || `Lane ${id}`
156      const joined = await start($, race, name)
157      return { text: `Joined race "${joined}" as ${name}. Run /race start ${joined} in the other terminal.` }
158    }
159    if (verb === 'done') return { text: (await finish($)) ? 'Crossed the line.' : 'Not in a race, or already finished.' }
160    if (verb === 'demo') {
161      await demo($)
162      return { text: 'Demo race started.' }
163    }
164    if (verb === 'leave') {
165      stop()
166      me = null
167      await update($, view, () => ({ race: null, isDemo: false, lanes: [], winner: null }))
168      $.ui.status(undefined)
169      await $.ui.close({ id: PANE })
170      return { text: 'Left the race.' }
171    }
172    await $.ui.open({ id: PANE, title: 'Race', focus: true })
173    return { text: 'Race pane opened. /race start <race> to join one, /race demo to watch one.' }
174  })
175
176  on('tool.call', async ($, e, next) => {
177    if (!me || me.finishedAt !== null) return next(e)
178    const ran = await next(e)
179    if (ran.deny !== undefined) return ran
180    const tool = String(e.tool)
181    const isEdit = tool === 'Edit' || tool === 'Write' || tool === 'MultiEdit' || tool === 'NotebookEdit'
182    let tests: { passed: number; failed: number } | null = null
183    if (tool === 'Bash') {
184      const command = String((e as { command?: unknown }).command ?? '')
185      const out = ran.result as { stdout?: string; stderr?: string } | undefined
186      if (isTestCommand(command)) tests = parseTests(`${out?.stdout ?? ''}\n${out?.stderr ?? ''}`)
187    }
188    if (tests) lastGreen = tests.failed === 0 && tests.passed > 0
189    await bump($, lane => ({
190      ...lane,
191      tools: lane.tools + 1,
192      edits: lane.edits + (isEdit ? 1 : 0),
193      testsPassed: tests ? tests.passed : lane.testsPassed,
194      testsFailed: lane.testsFailed + (tests?.failed ?? 0),
195    }))
196    return ran
197  })
198
199  on('turn.complete', async ($, e, next) => {
200    if (me && me.finishedAt === null) {
201      const usage = await $.session.usage()
202      await bump($, lane => ({ ...lane, costUsd: usage.cost?.usd ?? lane.costUsd }))
203      if (lastGreen) await finish($)
204    }
205    return next(e)
206  })
207
208  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
209    const { Box, Text } = $.ui.resolve(e)
210    const v = await read($, view)
211    const now = await $.clock.now()
212    const cols = Math.max(40, e.props.bodyColumns || e.viewport?.columns || 80)
213    const width = cols - 4
214
215    if (!v.race) {
216      return (
217        <Box flexDirection="column" paddingX={1}>
218          <Text bold color={BLUE}>AGENT RACE</Text>
219          <Text dimColor>/race start {'<race>'} [name] in two terminals to race them.</Text>
220          <Text dimColor>/race demo to watch a simulated race.</Text>
221        </Box>
222      )
223    }
224
225    const order = rank(v.lanes)
226    const laneRow = (lane: Lane, i: number) => {
227      const color = LANE_COLORS[v.lanes.indexOf(lane) % LANE_COLORS.length]!
228      const t = track(progressOf(lane), width, frame + i)
229      const isDone = lane.finishedAt !== null
230      const elapsed = (lane.finishedAt ?? now) - lane.startedAt
231      return (
232        <Box key={lane.id} flexDirection="column" marginBottom={1}>
233          <Box justifyContent="space-between">
234            <Text>
235              <Text backgroundColor={color} color={INK} bold>{` ${ordinal(i + 1)} `}</Text>
236              <Text bold color={color}>{`  ${lane.name}`}</Text>
237              {lane.id === me?.id && <Text dimColor>  (you)</Text>}
238            </Text>
239            <Text color={isDone ? color : undefined} dimColor={!isDone} bold={isDone}>
240              {isDone ? `FINISHED ${clock(elapsed)}` : clock(elapsed)}
241            </Text>
242          </Box>
243          <Text>
244            <Text color={color}>{t.run}</Text>
245            <Text color={color} bold>{t.runner}</Text>
246            <Text dimColor>{t.rest}</Text>
247            <Text color={PAPER}>{t.flag}</Text>
248          </Text>
249          <Text dimColor>
250            {`${lane.tools} tools   ${lane.edits} edits   ${lane.testsPassed} tests passing${lane.testsFailed ? `   ${lane.testsFailed} failed` : ''}   $${lane.costUsd.toFixed(2)}`}
251          </Text>
252        </Box>
253      )
254    }
255
256    return (
257      <Box flexDirection="column" paddingX={1}>
258        <Box justifyContent="space-between" marginBottom={1}>
259          <Text bold color={BLUE}>{`AGENT RACE  ${v.race.toUpperCase()}`}</Text>
260          <Text dimColor>{`${v.lanes.length} lane${v.lanes.length === 1 ? '' : 's'}${v.isDemo ? ' · demo' : ''}`}</Text>
261        </Box>
262        {order.map(laneRow)}
263        {v.lanes.length === 1 && !v.isDemo && (
264          <Text dimColor>Waiting for a challenger: /race start {v.race} in another terminal.</Text>
265        )}
266        {v.winner && (
267          <Box justifyContent="center" marginTop={1}>
268            <Text backgroundColor={GOLD} color={INK} bold>{`  FINISH  ·  ${v.winner.toUpperCase()} WINS  `}</Text>
269          </Box>
270        )}
271      </Box>
272    )
273  })
274}
275
hooks/race.ts 123 lines
1/** Pure race logic: scoring, ranking, parsing test output, drawing the track. */
2
3import type { Lane } from '../types'
4
5/** Points that put a lane at the finish line before it is declared done. */
6export const COURSE = 60
7const STALE_MS = 10 * 60 * 1000
8
9export const newLane = (id: string, name: string, race: string, now: number): Lane => ({
10  id,
11  name,
12  race,
13  tools: 0,
14  edits: 0,
15  testsPassed: 0,
16  testsFailed: 0,
17  costUsd: 0,
18  startedAt: now,
19  finishedAt: null,
20  updatedAt: now,
21})
22
23/** Edits move a lane most, any tool a little, green tests a push; failures hold it back. */
24export const scoreOf = (lane: Lane) =>
25  lane.edits * 3 + lane.tools + Math.min(lane.testsPassed, 20) - Math.min(lane.testsFailed, 10) * 2
26
27/** 0..1 along the track; only a finished lane reaches 1. */
28export const progressOf = (lane: Lane) =>
29  lane.finishedAt !== null ? 1 : Math.max(0, Math.min(0.95, scoreOf(lane) / COURSE))
30
31/** Finished lanes first by finish time, then the rest by progress. */
32export const rank = (lanes: readonly Lane[]) =>
33  [...lanes].sort((a, b) => {
34    if (a.finishedAt !== null && b.finishedAt !== null) return a.finishedAt - b.finishedAt
35    if (a.finishedAt !== null) return -1
36    if (b.finishedAt !== null) return 1
37    return progressOf(b) - progressOf(a) || a.name.localeCompare(b.name)
38  })
39
40/** Counts from common runners: jest/vitest/bun ("12 passed, 1 failed"), pytest, go, cargo, node:test. */
41export const parseTests = (output: string): { passed: number; failed: number } | null => {
42  // The largest count a runner printed: vitest prints files and tests, jest a summary per suite.
43  const sum = (re: RegExp) => {
44    let top: number | null = null
45    for (const m of output.matchAll(re)) top = Math.max(top ?? 0, Number(m[1]))
46    return top
47  }
48  const passed =
49    sum(/(\d+)\s+(?:tests?\s+)?pass(?:ed|ing)?\b/gi) ??
50    sum(/^# pass\s+(\d+)/gim) ??
51    sum(/test result: ok\. (\d+) passed/g)
52  const failed = sum(/(\d+)\s+(?:tests?\s+)?fail(?:ed|ing|ures?)?\b/gi) ?? sum(/^# fail\s+(\d+)/gim)
53  if (passed === null && failed === null) {
54    if (/^ok\s+\S+/m.test(output) && !/^FAIL/m.test(output)) return { passed: 1, failed: 0 }
55    return null
56  }
57  return { passed: passed ?? 0, failed: failed ?? 0 }
58}
59
60export const isTestCommand = (command: string) =>
61  /\b(test|tests|jest|vitest|pytest|mocha|go test|cargo test|bun test|npm t\b|pnpm t\b)/i.test(command)
62
63/** A lane file's text, or null when it is not a lane of this race. */
64export const parseLane = (text: string, race: string): Lane | null => {
65  try {
66    const lane = JSON.parse(text) as Partial<Lane>
67    if (typeof lane.id !== 'string' || lane.race !== race || typeof lane.name !== 'string') return null
68    return { ...newLane(lane.id, lane.name, race, Number(lane.startedAt) || 0), ...lane } as Lane
69  } catch {
70    return null
71  }
72}
73
74/** Keeps the newest copy of each lane and drops lanes nobody has touched in ten minutes. */
75export const mergeLanes = (lanes: readonly Lane[], now: number) => {
76  const byId = new Map<string, Lane>()
77  for (const lane of lanes) {
78    const had = byId.get(lane.id)
79    if (!had || lane.updatedAt > had.updatedAt) byId.set(lane.id, lane)
80  }
81  return [...byId.values()].filter(lane => lane.finishedAt !== null || now - lane.updatedAt < STALE_MS)
82}
83
84export type Track = { run: string; runner: string; rest: string; flag: string }
85
86/** A track `width` cells wide: the ground covered, the runner, the ground left, the flag. */
87export const track = (progress: number, width: number, frame = 0): Track => {
88  const length = Math.max(4, width - 1)
89  const at = Math.round(Math.max(0, Math.min(1, progress)) * (length - 1))
90  const isDone = progress >= 1
91  const strides = ['▶', '►']
92  return {
93    run: '━'.repeat(at),
94    runner: isDone ? '■' : strides[frame % strides.length]!,
95    rest: '·'.repeat(Math.max(0, length - 1 - at)),
96    flag: '▌',
97  }
98}
99
100export const clock = (ms: number) => {
101  const s = Math.max(0, Math.floor(ms / 1000))
102  return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`
103}
104
105export const ordinal = (n: number) => (n === 1 ? '1st' : n === 2 ? '2nd' : n === 3 ? '3rd' : `${n}th`)
106
107export const safeName = (name: string) =>
108  name.toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40) || 'race'
109
110/** One simulated tick of the demo: each lane gains a little, the second a touch slower. */
111export const demoTick = (lane: Lane, index: number, now: number, roll: number): Lane => {
112  if (lane.finishedAt !== null) return lane
113  const pace = index === 0 ? 0.62 : 0.55
114  const next: Lane = { ...lane, updatedAt: now }
115  if (roll < pace) next.tools += 1
116  if (roll < pace * 0.5) next.edits += 1
117  if (roll > 0.93) next.testsFailed += 1
118  if (roll > 0.85 && roll <= 0.93) next.testsPassed += 3
119  next.costUsd = Math.round((next.costUsd + roll * 0.02) * 1000) / 1000
120  if (scoreOf(next) >= COURSE) next.finishedAt = now
121  return next
122}
123
types/index.d.ts 27 lines
1export type Lane = {
2  id: string
3  name: string
4  race: string
5  tools: number
6  edits: number
7  testsPassed: number
8  testsFailed: number
9  costUsd: number
10  startedAt: number
11  finishedAt: number | null
12  updatedAt: number
13}
14
15export type RaceView = {
16  race: string | null
17  isDemo: boolean
18  lanes: Lane[]
19  winner: string | null
20}
21
22declare module 'claude-code' {
23  interface PluginState {
24    'agent-race': { view: RaceView }
25  }
26}
27