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

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

/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.
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
| Mod | Network | Runs processes | Files | Calls a model | Sends data anywhere |
|---|---|---|---|---|---|
| agent-race | No | No | Reads and writes ~/.claude/agent-race/<race>/ | No | No |
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.
hooks/register.tsx 275 lines1import { 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}
275hooks/race.ts 123 lines1/** 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}
123types/index.d.ts 27 lines1export 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