SLOPSHOPPER

chess

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…

newpanecommandstatusmodelnetwork
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · chess
│ ┃ chess ✕ › fix the failing auth test and add an audit log call │ ┃ You White vs Claude Black │ ┃ ● chess: chess loaded: /chess opens the board │ ┃ 8 ♖ ♘ ♗ ♕ ♔ ♗ ♘ ♖ ⏺ Read(src/auth.ts) │ ┃ 7 ♙ ♙ ♙ ♙ ♙ ♙ ♙ ♙ ⎿ Read 6 lines │ ┃ 6 ⏺ Update(src/auth.ts) │ ┃ 5 ⎿ Added 2 lines, removed 1 line │ ┃ 4 ⏺ Bash(bun test) │ ┃ 3 ⎿ 3 pass, 1 fail │ ┃ 2 ♟ ♟ ♟ ♟ ♟ ♟ ♟ ♟ │ ┃ 1 ♜ ♞ ♝ ♛ ♚ ♝ ♞ ♜ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ a b c d e f g h │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ Your move (White): click a piece │ ┃ move : e4, Nf3, O-O, e7e8q ⏎ play › /chess │ ┃ ⎿ chess: you play White · click a piece, then a square · /chess st │ ┃ ╭──────────────────────────────────────────╮ │ ┃ │ Claude's tokens (API usage) │ │ ┃ │ no move yet │ │ ┃ │ game: 0 over 0 moves │ │ ┃ ╰──────────────────────────────────────────╯ │ ┃ │ ┃ [ new as white ] [ new as black ] [ resign ] │ ┃ click a piece: • move × capture │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ chess: chess: your move · Claude 0 moves, 0 tokens

Draws

Pane · chess
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 ⏎ play ╭──────────────────────────────────────────────────────────╮ │ Claude's tokens (API usage) │ │ no move yet │ │ game: 0 over 0 moves │ ╰──────────────────────────────────────────────────────────╯ [ new as white ] [ new as black ] [ resign ] [ close ] click a piece: • move × capture
README

chess

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.

Playing

  • Click one of your pieces, then a square. The picked square turns yellow, the squares it can move to turn blue with a •, 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.
  • Type a move in the field under the board: SAN (e4, Nf3, exd5, O-O, e8=N) or UCI (e2e4, e7e8n).
  • new as white / new as black start over; resign ends the game.
  • Your side is always at the bottom.

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.

Where it draws

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.

How Claude moves, and what the tokens mean

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:

  • Most of each move is cache read. The fork re-reads the session's system prompt, tools and conversation from the cache, so a move in a fresh session costs about 48k tokens, nearly all 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.
  • The headline is the sum of all four counts. The breakdown line under it says where they went; cache reads are billed at a fraction of normal input.
  • A move that needed a retry (Claude named a move that is not legal) makes two calls, and both are counted on that move.
  • Nothing is written to your transcript. The fork's prompt and reply stay out of the conversation's JSONL.

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".

Playing Jev instead of Claude

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.
  • Jev answers typed questions, not text, so each move is one 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.
  • Jev's move is one HTTP request, not a fork of your session: it reads none of your transcript and spends none of your Claude tokens. The pane shows the tokens the response reports; when it reports none, the move reads not reported rather than a guessed number.
  • A failed request (a wrong key, a non-2xx status), no answer within 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.

Options

  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.

Install

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.

Tests

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

Source 4 files
hooks/register.tsx 544 lines
1/**
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}
544
hooks/chess.ts 317 lines
1/**
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}
317
hooks/game.ts 178 lines
1/**
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}
178
hooks/jev.ts 153 lines
1/**
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