Play chess against Claude (or Jev, when a Jev key is set) in a side pane while you work. Click a piece and a square, or type a move; Claude answers through a…

Chess against Claude (or Jev, when a Jev key is configured) in a side pane while you work. Every move Claude makes shows the tokens it cost, as the API reported them, and the pane keeps the running total for the game.
You White vs Claude Black
8 ♖ ♘ ♗ ♕ ♔ ♗ ♘ ♖
7 ♙ ♙ ♙ ♙ ♙ ♙ ♙
6
5 ♙
4 ♟
3 ♞
2 ♟ ♟ ♟ ♟ ♟ ♟ ♟
1 ♜ ♞ ♝ ♛ ♚ ♝ ♜
a b c d e f g h
Your move (White): click a piece
move e4, Nf3, O-O, e7e8q
╭────────────────────────────────╮
│ Claude's tokens (API usage) │
│ last Nc6: 48k │
│ in 240 · out 4 · cache r 48k w 0│
│ game: 96k over 2 moves │
╰────────────────────────────────╯
1... e5 48k out 4
2... Nc6 48k out 4
[ new as white ] [ new as black ] [ resign ] [ close ]
Run /chess to open the board (/chess black to play Black, /chess white for a new game as White) and /chess stop to close it. Closing keeps the game; /chess reopens it where it was.
•, and the pieces it can capture turn red with a × (en passant included). Click another of your pieces to switch, or the same one to drop it. A pawn clicked onto the last rank becomes a queen.e4, Nf3, exd5, O-O, e8=N) or UCI (e2e4, e7e8n).new as white / new as black start over; resign ends the game.Unicode draws White's pieces as outlines and Black's as solid shapes, which only reads right as dark ink on a light background. On a dark Claude Code theme (and on auto) the pane swaps them, so White is solid and Black is a dimmed outline; on a light* theme it keeps the Unicode convention. The theme is read when the session starts and each time you run /chess. A mod cannot see what auto resolved to, so if your terminal is light under auto, set the board option to light.
The board is drawn as 64 fixed 3 x 1 cells on every surface. On Claude Code Desktop (HTML) a square used to size itself to its text, so empty squares came out narrower than ones with a piece and the rows drifted; the cells now have a fixed size, and the desktop gets no-break spaces so the label's padding is not collapsed. The terminal draws exactly what it did before.
Mouse clicks land in the fullscreen layout. Everywhere else, focus the pane (ctrl+x tab), then move with Tab and press with Enter. The Claude Code mobile app has no text field, so there you click.
The full rules are enforced: castling, en passant, promotion, check, checkmate, stalemate, the fifty-move rule, threefold repetition and insufficient material.
In the fullscreen layout the engine docks the pane beside the transcript, floor to ceiling. That layout needs a terminal at least 110 columns wide; turn it on with /tui fullscreen (the session restarts and resumes) or CLAUDE_CODE_NO_FLICKER=1. On the classic layout the same pane sits above the prompt. While the pane is open the status line reads chess: your move · Claude N moves, 96k tokens.
Claude's move comes from $.model.fork: one completion over your session's own transcript, on the session's model, with tools denied. The prompt gives the position (FEN), the moves so far and Claude's legal moves in SAN, and asks for one of them. The fork shares the main thread's prompt cache, and it is the one model call a mod has that returns the API's usage, which is why the numbers are real and not estimated.
What that means for the numbers:
cache r, and it grows as your session grows. in is the uncached input (the chess prompt, a few hundred tokens), out is Claude's reply (a handful), w is what was newly written to the cache.Before the session's first turn there is no transcript to fork. A move Claude makes then (for example when you start as Black in a new session) goes through $.model.complete on fallbackModel instead, a short completion with only the chess prompt, so that move costs a few hundred tokens and the pane notes where it came from. If a call fails (an API error, an empty reply) or Claude still names no legal move after one retry, the pane plays a random legal move for it and says why, so the game never stays on "Claude is thinking".
Put a Jev key in the chess plugin's options and Jev, TypeSafe's System One decision model, plays the other side. The pane then says Jev wherever it said Claude: the header (You White vs Jev Black), the turn line, the token box and the status line.
{ "pluginConfigs": { "chess@skills-dir": { "options": { "typesafeApiKey": "<your key>" } } } }
These are the same options jev-model-router, jev-skill-suggestion and jev-auto-mode take, but each plugin reads only its own pluginConfigs entry, so the key goes under chess@skills-dir too (plain chess with --plugin-dir). /config stores a key there for you and keeps it in secure storage.
typesafeApiKey calls TypeSafe's own API (POST api.typesafe.ai/v1/systemone, model jev-latest); gatewayApiKey calls the Vercel AI Gateway (POST ai-gateway.vercel.sh/v4/ai/evaluation-model, model typesafe-ai/jev). With both, TypeSafe wins. provider forces one, or claude to keep Claude with a key set.choice whose options are exactly the legal moves (SAN, each with a short description), with the position (FEN) and the moves so far as its state. It cannot name an illegal move.not reported rather than a guessed number.timeoutMs (15 s), or an unreadable answer plays a random legal move for Jev and says why, as with Claude.The position and move list are sent to TypeSafe (or the Gateway) on every move of Jev's.
columns: number width asked for the docked pane, 30-100 (default 40)
pieces: string "unicode" glyphs (default) or FEN "letters" (uppercase White)
fallbackModel: string model for a move before the session's first turn (default "haiku")
board: string "theme" (default), "dark" or "light": forces the glyph mapping above
typesafeApiKey: string Jev key for TypeSafe's API (sensitive); set, Jev plays
gatewayApiKey: string Jev key for the Vercel AI Gateway (sensitive); set, Jev plays
provider: string "auto" (default), "typesafe", "gateway" or "claude"
typesafeBaseUrl, typesafeModel, gatewayBaseUrl, gatewayModel: string empty uses the defaults above
timeoutMs: number how long to wait for Jev's move (default 15000)
Declared in .claude-plugin/plugin.json (userConfig). Set them in /config, in user settings (~/.claude/settings.json, never project settings), with --settings <file> or in managed settings, under the plugin's full id:
{ "pluginConfigs": { "chess@skills-dir": { "options": { "pieces": "letters" } } } }
With --plugin-dir the id is plain chess. If the unicode pieces look too alike in your terminal's font, letters draws K Q R B N P for White and k q r b n p for Black.
npx claude-code-templates@latest --mod games/chess
claude
It is written to .claude/skills/chess/, which Claude Code auto-loads as chess@skills-dir in a trusted project (accept the trust prompt once; claude -p never shows it). For one session with hot reload: claude --plugin-dir .claude/skills/chess. claude plugin validate .claude/skills/chess prints every event it hooks and every $ call it makes.
If /chess is missing from the typeahead, the mod did not load: run claude --debug and look for hooks module chess@… loaded in ~/.claude/debug/latest.
claude plugin test .claude/skills/chess
The rules are checked against published perft move counts. The Jev tests check the request (a choice over exactly the legal moves) and how each response is read. The pane tests answer $.model.fork and $.model.complete from a script, mount the pane on the terminal surface, click and type moves, and read the token lines.
Requirements. Mods are on by default in Claude Code 2.1.287+. Written and tested on 2.1.283 against its declarations; on 2.1.283 $.model.fork and $.model.complete resolve { isAnswered, text, usage }, and the mod also reads the older string/null results. Typed against Anthropic's declarations: https://github.com/anthropics/claude-code/tree/main/mods
hooks/register.tsx 544 lines1/**
2 * chess — Claude Mod
3 *
4 * Chess against Claude in a side pane while you work. `/chess` opens it; in
5 * the fullscreen layout (`/tui fullscreen`) the engine docks it beside the
6 * transcript, floor to ceiling, otherwise it sits above the prompt. Click a
7 * piece and then a square, or type a move (`e4`, `Nf3`, `O-O`, `e2e4`).
8 *
9 * Claude's move comes from `$.model.fork`: one tool-less completion over the
10 * session's own transcript, on the session's model, sharing its prompt cache.
11 * It is the one model call that reports what it cost, so every move of
12 * Claude's shows the API's own usage (input, output, cache read, cache write)
13 * and the pane sums them. Before the session's first turn there is no
14 * transcript to fork; that move falls back to `$.model.complete` on
15 * `fallbackModel`, a short completion whose usage is counted the same way.
16 *
17 * With a Jev key in the options (`typesafeApiKey` or `gatewayApiKey`, the
18 * same ones jev-model-router takes), Jev plays instead: one request to
19 * TypeSafe's decision API per move, a `choice` over the legal moves, and the
20 * pane says Jev wherever it said Claude. Tokens show when the response
21 * reports them, "not reported" otherwise.
22 *
23 * Privacy: with a Jev key set, the position and the moves so far are sent to
24 * whichever backend the key belongs to.
25 *
26 * Nothing here touches files, git or the transcript.
27 *
28 * Needs Claude Code >= 2.1.287.
29 *
30 * Options (pluginConfigs["chess@skills-dir"].options):
31 * columns: number width asked for the docked pane (default 40)
32 * pieces: string "unicode" (default) or "letters"
33 * fallbackModel: string model for a move made before the first turn (default "haiku")
34 * typesafeApiKey / gatewayApiKey: string a Jev key makes Jev the opponent
35 * provider: string "auto" (default), "typesafe", "gateway" or "claude"
36 * typesafeBaseUrl, typesafeModel, gatewayBaseUrl, gatewayModel, timeoutMs (Jev, default 15000)
37 */
38import type { Register } from 'claude-code'
39import { findMove, legalMoves, squareIndex, squareName } from './chess.ts'
40import type { Color, Move, Piece } from './chess.ts'
41import {
42 addUsage,
43 asReply,
44 claudeColor,
45 colorName,
46 fmt,
47 gameUsage,
48 isClaudeTurn,
49 isYourTurn,
50 movePrompt,
51 newGame,
52 play,
53 readReply,
54 resultText,
55 totalTokens,
56 usageLine,
57} from './game.ts'
58import type { Game, Played, Reply } from './game.ts'
59import { DEFAULT_BASE_URL, DEFAULT_MODEL, endpoint, jevMove, requestBody, requestHeaders, selectProvider } from './jev.ts'
60import type { JevResponse } from './jev.ts'
61
62const PANE = 'chess'
63const COMMAND = 'chess'
64const DEFAULT_COLUMNS = 40
65// Claude's moves listed under the board, newest last
66const HISTORY_ROWS = 8
67
68// Unicode's "white" pieces are outlines and its "black" ones solid. On a dark
69// theme the terminal draws both in a light foreground, so the solid set reads
70// as the light side: there White gets the solid glyphs and Black the outlines.
71const OUTLINE: Record<string, string> = { k: '♔', q: '♕', r: '♖', b: '♗', n: '♘', p: '♙' }
72const SOLID: Record<string, string> = { k: '♚', q: '♛', r: '♜', b: '♝', n: '♞', p: '♟' }
73const LIGHT = '#8b7355'
74const DARK = '#5c4a36'
75const PICKED = '#a08a2c'
76const LAST = '#4f6b3a'
77// where the picked piece can go, and where it captures
78const TARGET = '#3d6a8a'
79const CAPTURE = '#8a3d3d'
80
81// both resolve ModelCompleteResult-like values; read through asReply
82type Call = (prompt: string) => Promise<unknown>
83// Jev's HTTP answer for a game, or undefined when it did not come in time
84type JevCall = (g: Game) => Promise<JevResponse>
85
86let game: Game = newGame('w')
87let picked = -1
88let thinking = false
89let note: string | undefined
90let isOpen = false
91// the Claude Code theme is dark (or auto, taken as dark) unless it names light
92let darkTheme = true
93// raised by every new game, so a reply that lands after one is dropped
94let generation = 0
95// who plays the other side: Jev when a Jev key is configured, Claude otherwise
96let opponent = 'Claude'
97
98const isDarkTheme = (value: unknown) => typeof value !== 'string' || !value.startsWith('light')
99// `auto` follows the terminal, which a mod cannot read, so `board` can say which it is
100const pickDark = (board: unknown, theme: unknown) => (board === 'dark' ? true : board === 'light' ? false : isDarkTheme(theme))
101
102const paneColumns = (v: unknown) => (typeof v === 'number' && v >= 30 && v <= 100 ? Math.round(v) : DEFAULT_COLUMNS)
103
104function statusText(): string | undefined {
105 if (!isOpen) return undefined
106 const { usage, moves } = gameUsage(game)
107 const where = resultText(game, opponent) ?? (thinking ? `${opponent} is thinking` : isYourTurn(game) ? 'your move' : `${opponent} to move`)
108 return `chess: ${where} · ${opponent} ${moves} move${moves === 1 ? '' : 's'}, ${fmt(totalTokens(usage))} tokens`
109}
110
111/**
112 * Claude's move: fork the session, or before its first turn (nothing to fork)
113 * complete on `fallbackModel`; read the reply, retry once naming the miss,
114 * then a random legal move so the game never stalls. Every call's usage is
115 * summed onto the move. Never throws: a failure still ends Claude's turn.
116 */
117async function claudeMoves(fork: Call, complete: Call, fallbackModel: string): Promise<void> {
118 const gen = generation
119 let usage: Played['usage'] = null
120 let reply = ''
121 let move: Move | undefined
122 let how: string | undefined
123 const count = (r: Reply) => {
124 if (r.usage) usage = usage ? addUsage(usage, r.usage) : { ...r.usage }
125 }
126 try {
127 for (let attempt = 0; attempt < 2 && !move; attempt++) {
128 const prompt = movePrompt(game, attempt ? reply : undefined)
129 let r = asReply(await fork(prompt))
130 if (gen !== generation) return
131 if (r.reason === 'nothing-to-fork') {
132 r = asReply(await complete(prompt))
133 if (gen !== generation) return
134 how = `${fallbackModel}: no transcript to fork yet`
135 }
136 count(r)
137 if (r.reason) {
138 how = `no reply (${r.reason})`
139 continue
140 }
141 reply = r.text ?? ''
142 move = readReply(game.pos, reply)
143 if (!move && attempt === 0) how = `retried: "${reply.trim().slice(0, 16)}" was not legal`
144 }
145 } catch (err) {
146 if (gen !== generation) return
147 how = `model call failed: ${String(err).slice(0, 60)}`
148 }
149 if (!move) {
150 const legal = legalMoves(game.pos)
151 move = legal[Math.floor(Math.random() * legal.length)]
152 how = `random: ${how ?? `${opponent} named no legal move`}`
153 }
154 game = play(game, move, { by: 'claude', usage, ...(how ? { note: how } : {}) })
155 note = undefined
156}
157
158/**
159 * Jev's move: one request to the decision model, whose answer is a choice
160 * among the legal moves. A failed request, a timeout or an answer naming no
161 * legal move plays a random legal move and says why. Never throws.
162 */
163async function jevMoves(ask: JevCall, provider: string): Promise<void> {
164 const gen = generation
165 let got: ReturnType<typeof jevMove>
166 try {
167 got = jevMove(game.pos, await ask(game), provider)
168 } catch (err) {
169 got = { usage: null, why: `request failed: ${String(err).slice(0, 60)}` }
170 }
171 if (gen !== generation) return
172 let move = got.move
173 if (!move) {
174 const legal = legalMoves(game.pos)
175 move = legal[Math.floor(Math.random() * legal.length)]
176 }
177 game = play(game, move, { by: 'claude', usage: got.usage, ...(got.why ? { note: `random: ${got.why}` } : {}) })
178 note = undefined
179}
180
181function tryYourMove(text: string): boolean {
182 if (!isYourTurn(game) || thinking) return false
183 const m = findMove(game.pos, text)
184 if (!m) {
185 note = `"${text.trim().slice(0, 20)}" is not a legal move here`
186 return false
187 }
188 game = play(game, m, { by: 'you' })
189 picked = -1
190 note = undefined
191 return true
192}
193
194/** A click on a square: pick your piece, re-pick, or move the picked one there. */
195function clickSquare(sq: number): boolean {
196 if (!isYourTurn(game) || thinking) return false
197 const p = game.pos.board[sq]
198 const mine = p !== '' && (p === p.toUpperCase() ? 'w' : 'b') === game.you
199 if (picked < 0 || mine) {
200 picked = mine && picked !== sq ? sq : -1
201 note = undefined
202 return false
203 }
204 const options = legalMoves(game.pos).filter(m => m.from === picked && m.to === sq)
205 if (!options.length) {
206 picked = -1
207 return false
208 }
209 // a promotion by click is a queen; type e7e8n for another piece
210 const m = options.find(o => !o.promotion || o.promotion === 'q') ?? options[0]
211 game = play(game, m, { by: 'you' })
212 picked = -1
213 note = undefined
214 return true
215}
216
217export const register: Register = (on, options) => {
218 const columns = paneColumns(options.columns)
219 const letters = options.pieces === 'letters'
220 const text = (key: string, fallback: string) =>
221 typeof options[key] === 'string' && options[key] ? (options[key] as string) : fallback
222 const fallbackModel = text('fallbackModel', 'haiku')
223 // A Jev key (the same ones jev-model-router takes) makes Jev the opponent.
224 const typesafeKey = text('typesafeApiKey', '')
225 const gatewayKey = text('gatewayApiKey', '')
226 const forced = text('provider', 'auto')
227 const jev = selectProvider(forced, typesafeKey, gatewayKey)
228 // a backend named without its key plays Claude; say so rather than silently
229 const unusable = (forced === 'typesafe' || forced === 'gateway') && !jev
230 const jevKey = jev === 'typesafe' ? typesafeKey : gatewayKey
231 const jevModel = jev ? text(`${jev}Model`, DEFAULT_MODEL[jev]) : ''
232 const jevUrl = jev ? endpoint(jev, text(`${jev}BaseUrl`, DEFAULT_BASE_URL[jev])) : ''
233 const jevTimeout = typeof options.timeoutMs === 'number' && options.timeoutMs > 0 ? options.timeoutMs : 15_000
234 opponent = jev ? 'Jev' : 'Claude'
235
236 on('session.start', async ($, e, next) => {
237 const r = await next(e)
238 await $.command
239 .register({
240 name: COMMAND,
241 description: `Play chess against ${opponent} in a side pane; shows the tokens each of its moves costs`,
242 argumentHint: '[white|black|stop]',
243 immediate: true,
244 })
245 .catch(err => $.ui.log(`chess: /${COMMAND} not registered: ${err}`))
246 $.ui.log(`chess loaded: /${COMMAND} opens the board`, { to: 'debug' })
247 if (unusable) $.ui.log(`chess: provider "${forced}" has no key set; Claude plays`)
248 const theme = await $.config.list().then(rows => rows.find(row => row.key === 'theme')?.value).catch(() => undefined)
249 darkTheme = pickDark(options.board, theme)
250 return r
251 })
252
253 on('command.run', { command: COMMAND }, async ($, e) => {
254 const arg = e.args.trim().toLowerCase()
255 if (arg === 'stop' || arg === 'close') {
256 await $.ui.close({ id: PANE }).catch(() => undefined)
257 isOpen = false
258 $.ui.status(undefined)
259 return { text: 'chess closed; the game stays where it is' }
260 }
261 const side: Color | undefined = arg === 'white' || arg === 'w' ? 'w' : arg === 'black' || arg === 'b' ? 'b' : undefined
262 if (side || game.over || game.resigned) {
263 generation++
264 game = newGame(side ?? game.you)
265 picked = -1
266 thinking = false
267 note = undefined
268 }
269 const theme = await $.config.list().then(rows => rows.find(row => row.key === 'theme')?.value).catch(() => undefined)
270 darkTheme = pickDark(options.board, theme)
271 isOpen = true
272 await $.ui.open({ id: PANE, title: 'chess', focus: true, columns })
273 $.ui.status(statusText())
274 $.ui.invalidate('ui.render')
275 if (isClaudeTurn(game) && !thinking) {
276 const gen = generation
277 thinking = true
278 $.ui.status(statusText())
279 $.ui.invalidate('ui.render')
280 if (jev) {
281 await jevMoves(
282 async g =>
283 (await Promise.race([
284 $.http.fetch(jevUrl, { method: 'POST', headers: requestHeaders(jev, jevKey, jevModel), body: requestBody(jev, g, jevModel) }),
285 $.clock.sleep(jevTimeout),
286 ])) ?? undefined,
287 jev,
288 )
289 } else {
290 await claudeMoves(
291 prompt => $.model.fork({ prompt }),
292 prompt => $.model.complete({ model: fallbackModel, prompt, maxTokens: 64, timeoutMs: 60_000 }),
293 fallbackModel,
294 )
295 }
296 // a new game or a resignation meanwhile owns `thinking` now
297 if (gen === generation) thinking = false
298 $.ui.status(statusText())
299 $.ui.invalidate('ui.render')
300 }
301 const hint = e.presentation.isFullscreen ? 'click a piece, then a square' : 'drawn above the prompt; /tui fullscreen docks it beside the transcript'
302 return { text: `you play ${colorName(game.you)} · ${hint} · /${COMMAND} stop closes` }
303 })
304
305 on('ui.close', async ($, e, next) => {
306 if (e.id !== PANE) return next(e)
307 const r = await next(e)
308 isOpen = false
309 $.ui.status(undefined)
310 return r
311 })
312
313 on('ui.input', async ($, e, next) => {
314 if (e.plugin !== $.plugin.name || e.requestId !== PANE || e.element !== 'move') return next(e)
315 const r = await next(e)
316 if (e.kind !== 'submit') return r
317 const moved = tryYourMove(e.value)
318 $.ui.status(statusText())
319 $.ui.invalidate('ui.render')
320 if (moved && isClaudeTurn(game)) {
321 const gen = generation
322 thinking = true
323 $.ui.status(statusText())
324 $.ui.invalidate('ui.render')
325 if (jev) {
326 await jevMoves(
327 async g =>
328 (await Promise.race([
329 $.http.fetch(jevUrl, { method: 'POST', headers: requestHeaders(jev, jevKey, jevModel), body: requestBody(jev, g, jevModel) }),
330 $.clock.sleep(jevTimeout),
331 ])) ?? undefined,
332 jev,
333 )
334 } else {
335 await claudeMoves(
336 prompt => $.model.fork({ prompt }),
337 prompt => $.model.complete({ model: fallbackModel, prompt, maxTokens: 64, timeoutMs: 60_000 }),
338 fallbackModel,
339 )
340 }
341 // a new game or a resignation meanwhile owns `thinking` now
342 if (gen === generation) thinking = false
343 $.ui.status(statusText())
344 $.ui.invalidate('ui.render')
345 }
346 return r
347 })
348
349 on('ui.press', async ($, e, next) => {
350 if (e.plugin !== $.plugin.name || e.requestId !== PANE) return next(e)
351 const r = await next(e)
352 const key = e.element
353 let moved = false
354
355 if (key === 'close') {
356 await $.ui.close({ id: PANE }).catch(() => undefined)
357 return r
358 }
359 if (key === 'new-w' || key === 'new-b') {
360 generation++
361 game = newGame(key === 'new-w' ? 'w' : 'b')
362 picked = -1
363 thinking = false
364 note = undefined
365 } else if (key === 'resign') {
366 if (!game.over && !game.resigned) {
367 generation++
368 game = { ...game, resigned: game.you }
369 thinking = false
370 }
371 } else if (key.startsWith('sq:')) {
372 moved = clickSquare(squareIndex(key.slice(3)))
373 }
374 $.ui.status(statusText())
375 $.ui.invalidate('ui.render')
376 if ((moved || key.startsWith('new-')) && isClaudeTurn(game) && !thinking) {
377 const gen = generation
378 thinking = true
379 $.ui.status(statusText())
380 $.ui.invalidate('ui.render')
381 if (jev) {
382 await jevMoves(
383 async g =>
384 (await Promise.race([
385 $.http.fetch(jevUrl, { method: 'POST', headers: requestHeaders(jev, jevKey, jevModel), body: requestBody(jev, g, jevModel) }),
386 $.clock.sleep(jevTimeout),
387 ])) ?? undefined,
388 jev,
389 )
390 } else {
391 await claudeMoves(
392 prompt => $.model.fork({ prompt }),
393 prompt => $.model.complete({ model: fallbackModel, prompt, maxTokens: 64, timeoutMs: 60_000 }),
394 fallbackModel,
395 )
396 }
397 // a new game or a resignation meanwhile owns `thinking` now
398 if (gen === generation) thinking = false
399 $.ui.status(statusText())
400 $.ui.invalidate('ui.render')
401 }
402 return r
403 })
404
405 on('ui.render', { component: 'Pane' }, async ($, e, next) => {
406 if (e.requestId !== PANE) return next(e)
407 const els = $.ui.resolve(e)
408 const { Box, Text, Button } = els
409 // mobile draws no Input: there a move is clicked
410 const Input = 'Input' in els ? els.Input : undefined
411 const noop = () => {}
412 const g = game
413 const last = g.played[g.played.length - 1]
414 const lastSquares = last ? [squareIndex(last.uci.slice(0, 2)), squareIndex(last.uci.slice(2, 4))] : []
415 // destination -> whether moving there captures (en passant lands on an empty square)
416 const targets = new Map(picked >= 0 ? legalMoves(g.pos).filter(m => m.from === picked).map(m => [m.to, !!m.captured] as const) : [])
417 const ranks = g.you === 'w' ? [7, 6, 5, 4, 3, 2, 1, 0] : [0, 1, 2, 3, 4, 5, 6, 7]
418 const files = g.you === 'w' ? [0, 1, 2, 3, 4, 5, 6, 7] : [7, 6, 5, 4, 3, 2, 1, 0]
419 const isWhite = (p: Piece) => p !== '' && p === p.toUpperCase()
420 // HTML collapses runs of spaces and trims a text's ends; a no-break space keeps them
421 const pad = (text: string) => (e.surface === 'terminal' ? text : text.replace(/ /g, '\u00a0'))
422 const glyph = (p: Piece) => {
423 if (p === '' || letters) return p || ' '
424 const solid = isWhite(p) === darkTheme
425 return (solid ? SOLID : OUTLINE)[p.toLowerCase()]
426 }
427
428 const board = ranks.map(rank => (
429 <Box key={`rank:${rank}`} flexDirection="row">
430 <Box key="rank-label" width={2} flexShrink={0}>
431 <Text dimColor>{pad(`${rank + 1} `)}</Text>
432 </Box>
433 {files.map(file => {
434 const sq = rank * 8 + file
435 const p = g.pos.board[sq]
436 const target = targets.has(sq)
437 const capture = targets.get(sq) === true
438 const bg =
439 sq === picked ? PICKED
440 : target ? (capture ? CAPTURE : TARGET)
441 : lastSquares.includes(sq) ? LAST
442 : (file + rank) % 2 ? LIGHT : DARK
443 // the marker keeps a capture readable without the red
444 const label = capture ? `×${p === '' ? ' ' : glyph(p)} ` : target ? ' • ' : ` ${glyph(p)} `
445 return (
446 // Every square is a fixed 3 x 1 cell. Without it a desktop (HTML) sizes a
447 // square to its text: whitespace collapses, so an empty square came out
448 // narrower than one holding a piece and rows 3-6 drifted out of the grid.
449 // The terminal already drew exactly 3 x 1, so nothing changes there.
450 <Box key={`cell:${sq}`} backgroundColor={bg} width={3} height={1} flexShrink={0} justifyContent="center">
451 <Button
452 key={`sq:${squareName(sq)}`}
453 plain
454 dimColor={darkTheme && p !== '' && !isWhite(p)}
455 label={pad(label)}
456 onPress={noop}
457 />
458 </Box>
459 )
460 })}
461 </Box>
462 ))
463
464 const { usage, moves, unreported } = gameUsage(g)
465 const claudeLast = [...g.played].reverse().find(m => m.by === 'claude')
466 const history = g.played
467 .map((m, i) => ({ m, n: Math.floor(i / 2) + 1, i }))
468 .filter(x => x.m.by === 'claude')
469 .slice(-HISTORY_ROWS)
470 const result = resultText(g, opponent)
471 const turnLine = result ??
472 (thinking
473 ? `${opponent} is thinking…`
474 : isYourTurn(g)
475 ? `Your move (${colorName(g.you)}): ${picked >= 0 ? 'click a highlighted square' : 'click a piece'}`
476 : `${opponent} to move`)
477
478 return (
479 <Box flexDirection="column">
480 <Text bold>{`You ${colorName(g.you)} vs ${opponent} ${colorName(claudeColor(g))}`}</Text>
481 <Box key="board" flexDirection="column" marginTop={1}>
482 {board}
483 <Box key="files" flexDirection="row">
484 <Box key="files-gap" width={2} flexShrink={0} />
485 {files.map(f => (
486 <Box key={`file:${f}`} width={3} flexShrink={0} justifyContent="center">
487 <Text dimColor>{'abcdefgh'[f]}</Text>
488 </Box>
489 ))}
490 </Box>
491 </Box>
492
493 <Box key="turn" marginTop={1} flexDirection="column">
494 <Text bold color={result ? 'magenta' : thinking ? 'yellow' : 'green'}>{turnLine}</Text>
495 {note ? <Text color="red" wrap="truncate-end">{note}</Text> : null}
496 {Input && isYourTurn(g) && !thinking ? (
497 <Input key="move" label="move " placeholder="e4, Nf3, O-O, e7e8q" submitLabel="play" onSubmit={noop} />
498 ) : null}
499 </Box>
500
501 <Box key="tokens" marginTop={1} flexDirection="column" borderStyle="round" borderDimColor paddingX={1}>
502 <Text bold color="cyan">{`${opponent}'s tokens (API usage)`}</Text>
503 {claudeLast ? (
504 <Text wrap="truncate-end">
505 <Text bold>{`last ${claudeLast.san}: `}</Text>
506 {claudeLast.usage ? `${fmt(totalTokens(claudeLast.usage))}` : 'not reported'}
507 </Text>
508 ) : (
509 <Text dimColor>no move yet</Text>
510 )}
511 {claudeLast?.usage ? <Text dimColor wrap="truncate-end">{usageLine(claudeLast.usage)}</Text> : null}
512 {claudeLast?.note ? <Text color="yellow" wrap="truncate-end">{claudeLast.note}</Text> : null}
513 <Text wrap="truncate-end">
514 <Text bold>{`game: ${fmt(totalTokens(usage))}`}</Text>
515 {` over ${moves} move${moves === 1 ? '' : 's'}${unreported ? `, ${unreported} unreported` : ''}`}
516 </Text>
517 {moves ? <Text dimColor wrap="truncate-end">{usageLine(usage)}</Text> : null}
518 </Box>
519
520 {history.length ? (
521 <Box key="history" flexDirection="column" marginTop={1}>
522 {history.map(({ m, n, i }) => (
523 <Text key={`h:${i}`} wrap="truncate-end">
524 <Text dimColor>{`${n}${i % 2 ? '...' : '.'} `.padEnd(6)}</Text>
525 {m.san.padEnd(8)}
526 <Text color="cyan">{m.usage ? fmt(totalTokens(m.usage)).padStart(7) : ' -'}</Text>
527 {m.usage ? <Text dimColor>{` out ${fmt(m.usage.output_tokens)}`}</Text> : null}
528 </Text>
529 ))}
530 </Box>
531 ) : null}
532
533 <Box key="foot" marginTop={1} flexDirection="row" columnGap={1} flexWrap="wrap">
534 <Button key="new-w" label="new as white" onPress={noop} />
535 <Button key="new-b" label="new as black" onPress={noop} />
536 <Button key="resign" label="resign" onPress={noop} />
537 <Button key="close" label="close" onPress={noop} />
538 </Box>
539 <Text dimColor>{'click a piece: • move × capture'}</Text>
540 </Box>
541 )
542 })
543}
544hooks/chess.ts 317 lines1/**
2 * The rules of chess, pure and dependency-free: positions as FEN, every legal
3 * move (castling, en passant, promotion), check, mate, stalemate, the
4 * fifty-move rule, threefold repetition, insufficient material, and SAN.
5 *
6 * Squares are 0..63, a1 = 0, h1 = 7, a8 = 56. Pieces are FEN letters:
7 * uppercase white, lowercase black, '' an empty square.
8 */
9
10export type Color = 'w' | 'b'
11export type Piece = '' | 'P' | 'N' | 'B' | 'R' | 'Q' | 'K' | 'p' | 'n' | 'b' | 'r' | 'q' | 'k'
12
13export type Position = {
14 board: Piece[]
15 turn: Color
16 /** Castling rights still held, as FEN spells them ('KQkq', '' for none). */
17 castling: string
18 /** The square a pawn may capture onto en passant, or -1. */
19 ep: number
20 /** Half-moves since the last capture or pawn move (the fifty-move rule). */
21 half: number
22 /** The full-move number, from 1, raised after Black moves. */
23 full: number
24}
25
26export type Move = {
27 from: number
28 to: number
29 piece: Piece
30 captured?: Piece
31 promotion?: 'q' | 'r' | 'b' | 'n'
32 /** 'k' / 'q' king- or queen-side castling, 'e' en passant, 'd' a double pawn step. */
33 flag?: 'k' | 'q' | 'e' | 'd'
34}
35
36export const START_FEN = 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1'
37
38const FILES = 'abcdefgh'
39export const fileOf = (sq: number) => sq & 7
40export const rankOf = (sq: number) => sq >> 3
41export const squareName = (sq: number) => `${FILES[fileOf(sq)]}${rankOf(sq) + 1}`
42export function squareIndex(name: string): number {
43 const f = FILES.indexOf(name[0] ?? '')
44 const r = Number(name[1]) - 1
45 return f < 0 || !(r >= 0 && r < 8) || name.length !== 2 ? -1 : r * 8 + f
46}
47
48export const colorOf = (p: Piece): Color | undefined => (p === '' ? undefined : p === p.toUpperCase() ? 'w' : 'b')
49const other = (c: Color): Color => (c === 'w' ? 'b' : 'w')
50const kind = (p: Piece) => p.toLowerCase()
51
52export function parseFen(fen: string): Position {
53 const [placement = '', turn = 'w', castling = '-', ep = '-', half = '0', full = '1'] = fen.trim().split(/\s+/)
54 const board: Piece[] = new Array(64).fill('')
55 const rows = placement.split('/')
56 if (rows.length !== 8) throw new Error(`bad FEN: ${fen}`)
57 rows.forEach((row, i) => {
58 const rank = 7 - i
59 let file = 0
60 for (const ch of row) {
61 if (/[1-8]/.test(ch)) file += Number(ch)
62 else if (/[pnbrqkPNBRQK]/.test(ch) && file < 8) board[rank * 8 + file++] = ch as Piece
63 else throw new Error(`bad FEN: ${fen}`)
64 }
65 if (file !== 8) throw new Error(`bad FEN: ${fen}`)
66 })
67 return {
68 board,
69 turn: turn === 'b' ? 'b' : 'w',
70 castling: castling === '-' ? '' : castling,
71 ep: ep === '-' ? -1 : squareIndex(ep),
72 half: Number(half) || 0,
73 full: Number(full) || 1,
74 }
75}
76
77/** The position's placement, side, castling and en-passant fields: what repetition compares. */
78export function positionKey(pos: Position): string {
79 const rows: string[] = []
80 for (let rank = 7; rank >= 0; rank--) {
81 let row = ''
82 let empty = 0
83 for (let file = 0; file < 8; file++) {
84 const p = pos.board[rank * 8 + file]
85 if (p === '') empty++
86 else {
87 if (empty) row += empty
88 empty = 0
89 row += p
90 }
91 }
92 rows.push(empty ? row + empty : row)
93 }
94 return `${rows.join('/')} ${pos.turn} ${pos.castling || '-'} ${pos.ep < 0 ? '-' : squareName(pos.ep)}`
95}
96
97export const toFen = (pos: Position) => `${positionKey(pos)} ${pos.half} ${pos.full}`
98
99const KNIGHT = [[1, 2], [2, 1], [2, -1], [1, -2], [-1, -2], [-2, -1], [-2, 1], [-1, 2]]
100const KING = [[1, 0], [1, 1], [0, 1], [-1, 1], [-1, 0], [-1, -1], [0, -1], [1, -1]]
101const ROOK_DIRS = [[1, 0], [-1, 0], [0, 1], [0, -1]]
102const BISHOP_DIRS = [[1, 1], [1, -1], [-1, 1], [-1, -1]]
103
104function step(sq: number, df: number, dr: number): number {
105 const f = fileOf(sq) + df
106 const r = rankOf(sq) + dr
107 return f < 0 || f > 7 || r < 0 || r > 7 ? -1 : r * 8 + f
108}
109
110/** Whether `by` attacks `sq` in `board`. */
111export function attacked(board: readonly Piece[], sq: number, by: Color): boolean {
112 const own = (p: Piece, k: string) => p !== '' && colorOf(p) === by && kind(p) === k
113 const pawnDr = by === 'w' ? -1 : 1
114 for (const df of [-1, 1]) {
115 const s = step(sq, df, pawnDr)
116 if (s >= 0 && own(board[s], 'p')) return true
117 }
118 for (const [df, dr] of KNIGHT) {
119 const s = step(sq, df, dr)
120 if (s >= 0 && own(board[s], 'n')) return true
121 }
122 for (const [df, dr] of KING) {
123 const s = step(sq, df, dr)
124 if (s >= 0 && own(board[s], 'k')) return true
125 }
126 for (const [dirs, sliders] of [[ROOK_DIRS, 'rq'], [BISHOP_DIRS, 'bq']] as const) {
127 for (const [df, dr] of dirs) {
128 let s = step(sq, df, dr)
129 while (s >= 0) {
130 const p = board[s]
131 if (p !== '') {
132 if (colorOf(p) === by && sliders.includes(kind(p))) return true
133 break
134 }
135 s = step(s, df, dr)
136 }
137 }
138 }
139 return false
140}
141
142export function inCheck(pos: Position, color: Color = pos.turn): boolean {
143 const king = pos.board.indexOf(color === 'w' ? 'K' : 'k')
144 return king >= 0 && attacked(pos.board, king, other(color))
145}
146
147function pseudoMoves(pos: Position): Move[] {
148 const moves: Move[] = []
149 const { board, turn } = pos
150 const push = (from: number, to: number, extra: Partial<Move> = {}) => {
151 const captured = board[to] || undefined
152 moves.push({ from, to, piece: board[from], ...(captured ? { captured } : {}), ...extra })
153 }
154 for (let from = 0; from < 64; from++) {
155 const p = board[from]
156 if (p === '' || colorOf(p) !== turn) continue
157 const k = kind(p)
158 if (k === 'p') {
159 const dr = turn === 'w' ? 1 : -1
160 const startRank = turn === 'w' ? 1 : 6
161 const lastRank = turn === 'w' ? 7 : 0
162 const pawnTo = (to: number, extra: Partial<Move> = {}) => {
163 if (rankOf(to) === lastRank) for (const promotion of ['q', 'r', 'b', 'n'] as const) push(from, to, { ...extra, promotion })
164 else push(from, to, extra)
165 }
166 const one = step(from, 0, dr)
167 if (one >= 0 && board[one] === '') {
168 pawnTo(one)
169 const two = step(from, 0, 2 * dr)
170 if (rankOf(from) === startRank && board[two] === '') push(from, two, { flag: 'd' })
171 }
172 for (const df of [-1, 1]) {
173 const to = step(from, df, dr)
174 if (to < 0) continue
175 if (board[to] !== '' && colorOf(board[to]) !== turn) pawnTo(to)
176 else if (to === pos.ep) moves.push({ from, to, piece: p, captured: turn === 'w' ? 'p' : 'P', flag: 'e' })
177 }
178 } else if (k === 'n' || k === 'k') {
179 for (const [df, dr] of k === 'n' ? KNIGHT : KING) {
180 const to = step(from, df, dr)
181 if (to >= 0 && colorOf(board[to]) !== turn) push(from, to)
182 }
183 } else {
184 const dirs = k === 'r' ? ROOK_DIRS : k === 'b' ? BISHOP_DIRS : [...ROOK_DIRS, ...BISHOP_DIRS]
185 for (const [df, dr] of dirs) {
186 let to = step(from, df, dr)
187 while (to >= 0) {
188 if (board[to] === '') push(from, to)
189 else {
190 if (colorOf(board[to]) !== turn) push(from, to)
191 break
192 }
193 to = step(to, df, dr)
194 }
195 }
196 }
197 }
198 // castling: rights held, squares between empty, king not in, through or into check
199 const home = turn === 'w' ? 0 : 56
200 const them = other(turn)
201 const king = turn === 'w' ? 'K' : 'k'
202 const rook = turn === 'w' ? 'R' : 'r'
203 if (board[home + 4] === king && !attacked(board, home + 4, them)) {
204 const [kRight, qRight] = turn === 'w' ? ['K', 'Q'] : ['k', 'q']
205 if (
206 pos.castling.includes(kRight) &&
207 board[home + 7] === rook &&
208 board[home + 5] === '' &&
209 board[home + 6] === '' &&
210 !attacked(board, home + 5, them) &&
211 !attacked(board, home + 6, them)
212 )
213 moves.push({ from: home + 4, to: home + 6, piece: king, flag: 'k' })
214 if (
215 pos.castling.includes(qRight) &&
216 board[home] === rook &&
217 board[home + 1] === '' &&
218 board[home + 2] === '' &&
219 board[home + 3] === '' &&
220 !attacked(board, home + 3, them) &&
221 !attacked(board, home + 2, them)
222 )
223 moves.push({ from: home + 4, to: home + 2, piece: king, flag: 'q' })
224 }
225 return moves
226}
227
228/** The position after `move`, which must be one of `legalMoves(pos)`. */
229export function makeMove(pos: Position, move: Move): Position {
230 const board = pos.board.slice()
231 const { from, to, piece, flag } = move
232 board[to] = move.promotion ? ((pos.turn === 'w' ? move.promotion.toUpperCase() : move.promotion) as Piece) : piece
233 board[from] = ''
234 if (flag === 'e') board[to + (pos.turn === 'w' ? -8 : 8)] = ''
235 if (flag === 'k') {
236 board[to - 1] = board[to + 1]
237 board[to + 1] = ''
238 }
239 if (flag === 'q') {
240 board[to + 1] = board[to - 2]
241 board[to - 2] = ''
242 }
243 // a right goes when its king or rook moves, or its rook is captured at home
244 const lost: Record<number, string> = { 0: 'Q', 4: 'KQ', 7: 'K', 56: 'q', 60: 'kq', 63: 'k' }
245 let castling = pos.castling
246 for (const sq of [from, to]) for (const right of lost[sq] ?? '') castling = castling.replace(right, '')
247 return {
248 board,
249 turn: other(pos.turn),
250 castling,
251 ep: flag === 'd' ? (from + to) / 2 : -1,
252 half: kind(piece) === 'p' || move.captured ? 0 : pos.half + 1,
253 full: pos.turn === 'b' ? pos.full + 1 : pos.full,
254 }
255}
256
257export function legalMoves(pos: Position): Move[] {
258 return pseudoMoves(pos).filter(m => !inCheck(makeMove(pos, m), pos.turn))
259}
260
261export const uci = (m: Move) => `${squareName(m.from)}${squareName(m.to)}${m.promotion ?? ''}`
262
263/** Standard algebraic notation for `move` in `pos`, with + or #. */
264export function san(pos: Position, move: Move, legal: readonly Move[] = legalMoves(pos)): string {
265 let text: string
266 if (move.flag === 'k') text = 'O-O'
267 else if (move.flag === 'q') text = 'O-O-O'
268 else {
269 const k = kind(move.piece)
270 const capture = move.captured ? 'x' : ''
271 if (k === 'p') {
272 text = `${capture ? FILES[fileOf(move.from)] + 'x' : ''}${squareName(move.to)}`
273 if (move.promotion) text += `=${move.promotion.toUpperCase()}`
274 } else {
275 const rivals = legal.filter(m => m.piece === move.piece && m.to === move.to && m.from !== move.from)
276 let from = ''
277 if (rivals.length) {
278 if (!rivals.some(m => fileOf(m.from) === fileOf(move.from))) from = FILES[fileOf(move.from)]
279 else if (!rivals.some(m => rankOf(m.from) === rankOf(move.from))) from = String(rankOf(move.from) + 1)
280 else from = squareName(move.from)
281 }
282 text = `${k.toUpperCase()}${from}${capture}${squareName(move.to)}`
283 }
284 }
285 const after = makeMove(pos, move)
286 if (inCheck(after)) text += legalMoves(after).length ? '+' : '#'
287 return text
288}
289
290const bare = (s: string) => s.replace(/[+#!?\s]/g, '').replace(/0/g, 'O').replace('=', '')
291
292/**
293 * The legal move `text` names, in SAN (`Nf3`, `exd5`, `O-O`, `e8=Q`) or UCI
294 * (`g1f3`, `e7e8q`); undefined when it names none.
295 */
296export function findMove(pos: Position, text: string, legal: readonly Move[] = legalMoves(pos)): Move | undefined {
297 const want = bare(text)
298 if (!want) return undefined
299 const byUci = legal.find(m => uci(m) === want.toLowerCase())
300 if (byUci) return byUci
301 return legal.find(m => bare(san(pos, m, legal)) === want)
302}
303
304/** Why the game is over, or undefined while it goes on. */
305export type Ending = 'checkmate' | 'stalemate' | 'fifty-move rule' | 'threefold repetition' | 'insufficient material'
306
307export function ending(pos: Position, history: readonly string[] = []): Ending | undefined {
308 if (!legalMoves(pos).length) return inCheck(pos) ? 'checkmate' : 'stalemate'
309 if (pos.half >= 100) return 'fifty-move rule'
310 const key = positionKey(pos)
311 if (history.filter(k => k === key).length >= 3) return 'threefold repetition'
312 const rest = pos.board.filter(p => p !== '' && kind(p) !== 'k')
313 if (rest.length === 0) return 'insufficient material'
314 if (rest.length === 1 && 'nb'.includes(kind(rest[0]))) return 'insufficient material'
315 return undefined
316}
317hooks/game.ts 178 lines1/**
2 * The game around the rules: the moves played, who played them, what each of
3 * Claude's cost, the prompt Claude answers, and how its reply is read.
4 * Pure: no `$`, so the tests drive it directly.
5 */
6import type { ModelUsage } from 'claude-code'
7import {
8 START_FEN,
9 ending,
10 findMove,
11 legalMoves,
12 makeMove,
13 parseFen,
14 positionKey,
15 san,
16 toFen,
17 uci,
18} from './chess.ts'
19import type { Color, Ending, Move, Position } from './chess.ts'
20
21export type Played = {
22 san: string
23 uci: string
24 by: 'you' | 'claude'
25 /** What Claude's move cost as the API reported it; null when the call reported none. */
26 usage?: ModelUsage | null
27 /** How the move was got when not plainly (a retry, a fallback, a random move). */
28 note?: string
29}
30
31export type Game = {
32 pos: Position
33 you: Color
34 played: Played[]
35 /** positionKey of every position reached, the start included: threefold repetition. */
36 keys: string[]
37 /** The starting position's move number and side, for numbering the move list. */
38 start: { full: number; turn: Color }
39 over?: Ending
40 /** Set by `resign`: the side that gave up. */
41 resigned?: Color
42}
43
44export function newGame(you: Color, fen = START_FEN): Game {
45 const pos = parseFen(fen)
46 return { pos, you, played: [], keys: [positionKey(pos)], start: { full: pos.full, turn: pos.turn } }
47}
48
49export const claudeColor = (g: Game): Color => (g.you === 'w' ? 'b' : 'w')
50export const isClaudeTurn = (g: Game) => !g.over && !g.resigned && g.pos.turn === claudeColor(g)
51export const isYourTurn = (g: Game) => !g.over && !g.resigned && g.pos.turn === g.you
52
53/** Plays `move` (legal in `g.pos`) and records it; returns the new game. */
54export function play(g: Game, move: Move, entry: Omit<Played, 'san' | 'uci'>): Game {
55 const text = san(g.pos, move)
56 const pos = makeMove(g.pos, move)
57 const keys = [...g.keys, positionKey(pos)]
58 return { ...g, pos, keys, played: [...g.played, { ...entry, san: text, uci: uci(move) }], over: ending(pos, keys) }
59}
60
61/** "1. e4 e5 2. Nf3" from the moves played. */
62export function moveList(g: Game): string {
63 const out: string[] = []
64 const offset = g.start.turn === 'b' ? 1 : 0
65 g.played.forEach((m, i) => {
66 const ply = i + offset
67 if (ply % 2 === 0) out.push(`${g.start.full + ply / 2}.`)
68 else if (i === 0) out.push(`${g.start.full}...`)
69 out.push(m.san)
70 })
71 return out.join(' ')
72}
73
74export const colorName = (c: Color) => (c === 'w' ? 'White' : 'Black')
75
76/**
77 * What Claude is asked each move. It goes to `$.model.fork`, appended to the
78 * session's own transcript, so it first sets the work aside.
79 */
80export function movePrompt(g: Game, retry?: string): string {
81 const legal = legalMoves(g.pos)
82 const lines = [
83 'Side game, unrelated to the work above: you are playing chess against the user in a side panel.',
84 'Answer only this message; do not continue, mention or act on the task above.',
85 '',
86 `You play ${colorName(claudeColor(g))}. Position (FEN): ${toFen(g.pos)}`,
87 `Moves so far: ${moveList(g) || '(none, you move first)'}`,
88 `Your legal moves: ${legal.map(m => san(g.pos, m, legal)).join(' ')}`,
89 ]
90 if (retry) lines.push('', `Your last reply, "${retry.slice(0, 40)}", is not one of those moves.`)
91 lines.push('', 'Reply with exactly one move from that list, in SAN, and nothing else.')
92 return lines.join('\n')
93}
94
95/** The first legal move Claude's reply names, reading word by word. */
96export function readReply(pos: Position, reply: string): Move | undefined {
97 const legal = legalMoves(pos)
98 const whole = findMove(pos, reply.trim(), legal)
99 if (whole) return whole
100 for (const word of reply.split(/[\s,;:()"'`*]+/)) {
101 const m = findMove(pos, word.replace(/^\d+\.+/, '').replace(/\.$/, ''), legal)
102 if (m) return m
103 }
104 return undefined
105}
106
107export const zeroUsage = (): ModelUsage => ({
108 input_tokens: 0,
109 output_tokens: 0,
110 cache_read_input_tokens: 0,
111 cache_creation_input_tokens: 0,
112})
113
114export function addUsage(a: ModelUsage, b: ModelUsage): ModelUsage {
115 return {
116 input_tokens: a.input_tokens + b.input_tokens,
117 output_tokens: a.output_tokens + b.output_tokens,
118 cache_read_input_tokens: a.cache_read_input_tokens + b.cache_read_input_tokens,
119 cache_creation_input_tokens: a.cache_creation_input_tokens + b.cache_creation_input_tokens,
120 }
121}
122
123export const totalTokens = (u: ModelUsage) =>
124 u.input_tokens + u.output_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens
125
126/** Claude's moves' usage summed, and how many moves reported none. */
127export function gameUsage(g: Game): { usage: ModelUsage; moves: number; unreported: number } {
128 let usage = zeroUsage()
129 let moves = 0
130 let unreported = 0
131 for (const m of g.played) {
132 if (m.by !== 'claude') continue
133 moves++
134 if (m.usage) usage = addUsage(usage, m.usage)
135 else unreported++
136 }
137 return { usage, moves, unreported }
138}
139
140/** 950, 12.3k, 1.20M */
141export function fmt(n: number): string {
142 if (n < 1000) return String(n)
143 if (n < 1_000_000) return `${(n / 1000).toFixed(n < 10_000 ? 1 : 0)}k`
144 return `${(n / 1_000_000).toFixed(2)}M`
145}
146
147/** "in 12 · out 4 · cache r 45k w 210" */
148export const usageLine = (u: ModelUsage) =>
149 `in ${fmt(u.input_tokens)} · out ${fmt(u.output_tokens)} · cache r ${fmt(u.cache_read_input_tokens)} w ${fmt(u.cache_creation_input_tokens)}`
150
151/** `opponent` is who plays the other side in the pane's words: Claude, or Jev. */
152export function resultText(g: Game, opponent = 'Claude'): string | undefined {
153 if (g.resigned) return `${g.resigned === g.you ? 'You resigned' : `${opponent} resigned`}: ${colorName(g.resigned === 'w' ? 'b' : 'w')} wins`
154 if (!g.over) return undefined
155 if (g.over === 'checkmate') {
156 const winner = g.pos.turn === 'w' ? 'b' : 'w'
157 return `Checkmate: ${winner === g.you ? 'you win' : `${opponent} wins`}`
158 }
159 return `Draw by ${g.over}`
160}
161
162/**
163 * One model call's result, whatever Claude Code answered it with: 2.1.283 and
164 * later resolve `{ isAnswered, text, usage }` or `{ isAnswered: false, reason }`
165 * for both `$.model.fork` and `$.model.complete`; earlier releases resolved a
166 * bare string (complete) or null (fork with nothing to fork).
167 */
168export type Reply = { text?: string; usage?: ModelUsage; reason?: string }
169
170export function asReply(result: unknown): Reply {
171 if (typeof result === 'string') return { text: result }
172 if (result === null || result === undefined) return { reason: 'nothing-to-fork' }
173 const r = result as { isAnswered?: boolean; text?: unknown; usage?: ModelUsage; reason?: unknown }
174 const usage = r.usage && typeof r.usage.input_tokens === 'number' ? r.usage : undefined
175 if (r.isAnswered === false) return { reason: String(r.reason ?? 'unanswered'), ...(usage ? { usage } : {}) }
176 return { text: typeof r.text === 'string' ? r.text : '', ...(usage ? { usage } : {}) }
177}
178hooks/jev.ts 153 lines1/**
2 * Jev as the opponent: TypeSafe's System One decision model picks the move.
3 * Pure: no `$` and no I/O; the hooks module does the fetch at its call site.
4 *
5 * Jev answers typed questions, not free text, so a move is a `choice` whose
6 * options are exactly the legal moves: it cannot name an illegal one. The two
7 * backends and their wire shapes are the ones jev-model-router speaks:
8 *
9 * typesafe POST {base}/v1/systemone `{ model, state, questions }`
10 * gateway POST {base}/evaluation-model `{ state, questions }`, model in a header
11 */
12import type { ModelUsage } from 'claude-code'
13import { findMove, legalMoves, san, squareName, toFen } from './chess.ts'
14import type { Move, Position } from './chess.ts'
15import { claudeColor, colorName, moveList } from './game.ts'
16import type { Game } from './game.ts'
17
18export type Provider = 'typesafe' | 'gateway'
19
20export const DEFAULT_BASE_URL: Record<Provider, string> = {
21 typesafe: 'https://api.typesafe.ai',
22 gateway: 'https://ai-gateway.vercel.sh/v4/ai',
23}
24
25export const DEFAULT_MODEL: Record<Provider, string> = {
26 typesafe: 'jev-latest',
27 gateway: 'typesafe-ai/jev',
28}
29
30// the Gateway rejects a request that names no protocol version (see jev-model-router)
31const AI_GATEWAY_PROTOCOL_VERSION = '0.0.1'
32
33/** Which backend the keys select; null plays Claude. `auto` prefers TypeSafe. */
34export function selectProvider(forced: string, typesafeKey: string, gatewayKey: string): Provider | null {
35 if (forced === 'claude') return null
36 if (forced === 'typesafe') return typesafeKey ? 'typesafe' : null
37 if (forced === 'gateway') return gatewayKey ? 'gateway' : null
38 if (typesafeKey) return 'typesafe'
39 if (gatewayKey) return 'gateway'
40 return null
41}
42
43export function endpoint(provider: Provider, baseUrl: string): string {
44 const root = baseUrl.replace(/\/+$/, '')
45 return provider === 'typesafe' ? `${root}/v1/systemone` : `${root}/evaluation-model`
46}
47
48export function requestHeaders(provider: Provider, apiKey: string, model: string): Record<string, string> {
49 const common = { 'content-type': 'application/json', authorization: `Bearer ${apiKey}` }
50 if (provider === 'typesafe') return common
51 return {
52 ...common,
53 'ai-gateway-auth-method': 'api-key',
54 'ai-model-id': model,
55 'ai-gateway-protocol-version': AI_GATEWAY_PROTOCOL_VERSION,
56 'ai-evaluation-model-specification-version': '4',
57 }
58}
59
60const PIECE_NAME: Record<string, string> = { p: 'pawn', n: 'knight', b: 'bishop', r: 'rook', q: 'queen', k: 'king' }
61
62/** "knight g1-f3, takes pawn, check": what a choice key means, for the model. */
63export function describeMove(m: Move, text: string): string {
64 if (m.flag === 'k') return 'castle kingside'
65 if (m.flag === 'q') return 'castle queenside'
66 const parts = [`${PIECE_NAME[m.piece.toLowerCase()]} ${squareName(m.from)}-${squareName(m.to)}`]
67 if (m.captured) parts.push(`takes ${PIECE_NAME[m.captured.toLowerCase()]}${m.flag === 'e' ? ' en passant' : ''}`)
68 if (m.promotion) parts.push(`promotes to ${PIECE_NAME[m.promotion]}`)
69 if (text.endsWith('#')) parts.push('checkmate')
70 else if (text.endsWith('+')) parts.push('check')
71 return parts.join(', ')
72}
73
74/** The request body: the position as state, one choice over the legal moves. */
75export function requestBody(provider: Provider, g: Game, model: string): string {
76 const legal = legalMoves(g.pos)
77 const criteria: Record<string, string> = {}
78 for (const m of legal) {
79 const text = san(g.pos, m, legal)
80 criteria[text] = describeMove(m, text)
81 }
82 const side = colorName(claudeColor(g))
83 const state = {
84 game: 'chess',
85 you_play: side,
86 fen: toFen(g.pos),
87 moves_so_far: moveList(g) || '(none)',
88 }
89 const questions = {
90 move: {
91 type: 'choice',
92 instructions: `You play ${side} in this chess position. Which legal move is strongest?`,
93 criteria,
94 },
95 }
96 return JSON.stringify(provider === 'typesafe' ? { model, state, questions } : { state, questions })
97}
98
99export type JevAnswer = { san: string; confidence: number | null; usage: ModelUsage | null }
100
101const num = (...values: unknown[]) => values.find((v): v is number => typeof v === 'number')
102
103/**
104 * Token usage when the response carries any, in either the Anthropic or the
105 * OpenAI spelling; null when it reports none, so the pane says so rather than
106 * showing a count it never got.
107 */
108export function readUsage(parsed: unknown): ModelUsage | null {
109 const u = (parsed as { usage?: Record<string, unknown> } | null)?.usage
110 if (!u || typeof u !== 'object') return null
111 const input = num(u.input_tokens, u.prompt_tokens, u.inputTokens)
112 const output = num(u.output_tokens, u.completion_tokens, u.outputTokens)
113 if (input === undefined && output === undefined) return null
114 return {
115 input_tokens: input ?? 0,
116 output_tokens: output ?? 0,
117 cache_read_input_tokens: num(u.cache_read_input_tokens, u.cachedInputTokens) ?? 0,
118 cache_creation_input_tokens: num(u.cache_creation_input_tokens) ?? 0,
119 }
120}
121
122/** The move Jev chose, or null when the body names none. */
123export function readAnswer(responseText: string): JevAnswer | null {
124 let parsed: unknown
125 try {
126 parsed = JSON.parse(responseText)
127 } catch {
128 return null
129 }
130 const answer = (parsed as { answers?: Record<string, Record<string, unknown>> } | null)?.answers?.move
131 if (!answer || typeof answer.choice !== 'string') return null
132 const probabilities = answer.probabilities as Record<string, number> | undefined
133 const values = probabilities ? Object.values(probabilities) : []
134 const confidence = num(answer.confidence) ?? (values.length ? Math.max(...values) : null)
135 return { san: answer.choice, confidence, usage: readUsage(parsed) }
136}
137
138/** Jev's HTTP answer, or undefined when it did not come in time. */
139export type JevResponse = { ok: boolean; status: number; text: string } | undefined
140
141/**
142 * The move a response names, what it cost when reported, and, when there is
143 * no legal move in it, why: the caller then plays a random legal move.
144 */
145export function jevMove(pos: Position, res: JevResponse, provider: string): { move?: Move; usage: ModelUsage | null; why?: string } {
146 if (!res) return { usage: null, why: 'no answer in time' }
147 if (!res.ok) return { usage: null, why: `${provider} responded ${res.status}` }
148 const answer = readAnswer(res.text)
149 if (!answer) return { usage: null, why: 'unreadable answer' }
150 const move = findMove(pos, answer.san)
151 return move ? { move, usage: answer.usage } : { usage: answer.usage, why: `"${answer.san.slice(0, 16)}" was not legal` }
152}
153