Executive Dysfunction

Probe whether an AI model can tell which chess positions deserve deeper thinking, given a finite budget of hand-offs to a deeper instance of itself.

---
format: typebulb/v1
name: Executive Dysfunction
---

**code.tsx**

```tsx
import React, { useEffect, useMemo, useRef, useState } from "react"
import { createRoot } from "react-dom/client"
import { Chess, type Square } from "chess.js"

// ── Types ────────────────────────────────────────────────────────

type TbModel = { provider: string; name: string; friendlyName: string; providerName: string }
type Color = "w" | "b"

type MoveRecord = {
  color: Color
  san: string
  effortFloor: number   // baseline effort the move was played at
  effortFinal: number   // baseline, or baseline+1 if handed off
  handedOff: boolean    // did this move use a hand-off (re-evaluated one level deeper)?
  forced: boolean       // was the hand-off forced by an illegal baseline move, not the model's choice?
  budgetLeft: number    // hand-offs remaining for this side after the move
  thinkMs: number       // wall-clock across every call for this move
  reasoningChars: number // streamed reasoning volume across every call — a truer "thinking spent" meter
  reasoning: string     // the played reply's streamed thinking, kept so a past move can be re-read
  evalStr: string       // the model's self-eval, its own perspective (+ = it's better)
  comment: string       // the played reply's words
  shallow?: { san: string; evalStr: string; comment: string; reasoning: string } // the pre-hand-off baseline reply, when handed off
  changed: boolean      // did the hand-off change the move?
}

type Result =
  | { type: "checkmate"; winner: Color }
  | { type: "draw"; reason: string }
  | { type: "forfeit"; winner: Color; offender: Color }
  | { type: "error"; message: string }
  | { type: "stopped" } // user stopped/reset — partial game, still recorded

// ── Constants ────────────────────────────────────────────────────

const PIECES: Record<string, string> = { p: "♟", n: "♞", b: "♝", r: "♜", q: "♛", k: "♚" }
const FILES = ["a", "b", "c", "d", "e", "f", "g", "h"]
// Effort ladder indexed by tb.ai `effort` (0–3). The baseline must leave headroom below High so a
// hand-off can escalate +1. Minimal (0) is deliberately NOT a baseline rung: on Anthropic it adds an
// "economize" instruction that suppresses the hand-off instinct outright — you end up measuring the
// API knob, not the model's judgment. Low is the weakest *calibrated* baseline.
const REASONING = ["Minimal", "Low", "Medium", "High"]
const SHORT = ["Min", "Low", "Med", "High"]
const MAX_EFFORT = 3
const MIN_EFFORT = 1
const CARRY = true // hand the shallow conclusion to the deeper instance — fixed, not surfaced in the UI

const RESULTS_FILE = "results.json"

const modelLabel = (m: TbModel) => m.friendlyName
const effortName = (i: number) => REASONING[i] ?? String(i)
const fmtK = (n: number) => (n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n))
const effortShort = (floor: number, final: number) =>
  floor === final ? SHORT[final] : `${SHORT[floor]}→${SHORT[final]}`

// Deliberately free of any budget/efficiency language — that prior is what suppresses hand-offs, and we
// measure the model's choice, not its frugality.
const SYSTEM =
  "You are a strong chess player. Reply with EXACTLY the requested labelled lines and nothing else."

// ── Prompt construction ──────────────────────────────────────────

function historyText(history: string[]) {
  return history.length
    ? history.map((m, i) => (i % 2 === 0 ? `${i / 2 + 1}. ${m}` : m)).join(" ")
    : "(none yet — you are White, make the first move)"
}

// The hand-off question is framed honestly (defer to "another instance of yourself afforded more
// reasoning" = this same model re-run one level higher) and posed as a CROSS-TEMPORAL comparison that
// stays coherent for any budget: spend one opportunity on this move now, or hold back for a more
// important move later? — forcing the model to weigh now against later, the reasoning the floods (e.g.
// Opus) skip by judging each move in isolation. The harness converts "yes" into the deeper re-evaluation.
function buildMsg(o: {
  colorName: string; fen: string; history: string[]; legal: string[]
  effort: number; budgetLeft: number; moveNo: number; canHandoff: boolean
  prevText?: string; note?: string
}) {
  const lines: string[] = []

  if (o.prevText) {
    lines.push(
      `A shallower instance of you evaluated this position and suggested:\n"${o.prevText.trim()}"\n` +
      `You have an extended reasoning budget — evaluate it fully and return the best move.\n`
    )
  }

  lines.push(
    `You are playing as ${o.colorName}. It is move ${o.moveNo}.`,
    `Position (FEN): ${o.fen}`,
    `Moves so far: ${historyText(o.history)}`,
    `Your legal moves: ${o.legal.join(", ")}`
  )
  if (o.note) lines.push(``, o.note)
  lines.push(``)

  if (o.canHandoff) {
    lines.push(
     `You have ${o.budgetLeft} opportunit${o.budgetLeft === 1 ? "y" : "ies"} left in this game to hand off the
      evaluation of this move to another instance of yourself afforded more reasoning. Do you think handing of this turn would make
      a win more likely?`
    )
  }

  lines.push(
    `Reply with these labelled lines and nothing else:`,
    `Move: <your best move now, in standard algebraic notation, e.g. Nf3, exd5, O-O>`,
    `Eval: <evaluation in pawns from YOUR perspective — + means you are better, e.g. +1.2, -0.5, 0.0; use #N for a mate you can force, #-N if you are being mated>`
  )
  if (o.canHandoff) lines.push(`HandOff: <yes|no — yes to spend an opportunity on this move now, no to hold back for a more important move later>`)
  lines.push(`Comment: <a short note on your move; in a sharp or critical position, explain the idea and the key lines you are calculating>`)

  return lines.join("\n")
}

// ── Reply parsing ────────────────────────────────────────────────

function tryMove(fen: string, cand: string): string | null {
  const base = cand.trim().replace(/0/g, "O")
  for (const v of new Set([base, base.replace(/[+#]+$/, "")])) {
    if (!v) continue
    try {
      const c = new Chess(fen)
      const mv = c.move(v)
      if (mv) return mv.san
    } catch {}
  }
  const coord = base.match(/^[KQRBNkqrbn]?([a-h][1-8])([a-h][1-8])([qrbnQRBN])?$/)
  if (coord) {
    try {
      const c = new Chess(fen)
      const mv = c.move({ from: coord[1].toLowerCase(), to: coord[2].toLowerCase(), promotion: coord[3]?.toLowerCase() as any })
      if (mv) return mv.san
    } catch {}
  }
  return null
}

function parseMove(text: string, fen: string): string | null {
  const s = (text || "").replace(/```[a-z]*/gi, "").replace(/```/g, "").trim()
  const candidates: string[] = []
  const moveLine = s.match(/(?:^|\n)\s*move\s*[:=]\s*(.+)/i)
  if (moveLine) candidates.push(moveLine[1].trim())
  const firstLine = s.split(/\r?\n/)[0].trim()
  if (firstLine) candidates.push(firstLine)
  candidates.push(s)
  const re = /([KQRBN]?[a-h][1-8][a-h][1-8][qrbn]?|O-O-O|O-O|0-0-0|0-0|[KQRBN]?[a-h]?[1-8]?x?[a-h][1-8](?:=[QRBNqrbn])?[+#]?)/g
  let m: RegExpExecArray | null
  while ((m = re.exec(s)) !== null) candidates.push(m[0])
  for (const cand of candidates) {
    const san = tryMove(fen, cand)
    if (san) return san
  }
  return null
}

function parseEval(text: string): string {
  const m = (text || "").match(/(?:^|\n)\s*eval\s*[:=]\s*([^\n]+)/i)
  if (!m) return ""
  const mm = m[1].match(/#-?\d+|[+\-]?\d+(?:\.\d+)?/)
  return mm ? mm[0] : m[1].trim().slice(0, 12)
}

function parseHandoff(text: string): boolean {
  const m = (text || "").match(/hand\s*-?\s*off\s*[:=]\s*(yes|no|true|false|y|n)/i)
  return m ? /^(yes|true|y)$/i.test(m[1]) : false
}

// Comment is requested last, so capture everything after the label; trim a stray trailing labelled line
// if the model reordered.
function parseComment(text: string): string {
  const m = (text || "").match(/(?:^|\n)\s*comment\s*[:=]\s*([\s\S]*)/i)
  if (!m) return ""
  return m[1].replace(/\n\s*(?:move|eval|hand\s*-?off)\s*[:=].*$/is, "").replace(/```/g, "").trim()
}

// ── Async helpers ────────────────────────────────────────────────

const sleep = (ms: number, signal: AbortSignal) => new Promise<void>((res, rej) => {
  const t = setTimeout(res, ms)
  signal.addEventListener("abort", () => { clearTimeout(t); rej(new Error("aborted")) }, { once: true })
})

// Streaming call. Accumulates the answer text and the reasoning stream, reporting deltas to `onDelta`
// for the live view, and returns both plus wall-clock. Reasoning length is our "thinking spent" meter:
// tb.ai discards reasoning tokens, but tb.ai.stream surfaces reasoning deltas we can count. `reasoning`
// chunks arrive at effort 1–3 with a thinking-capable model, though at Low effort some models emit a
// sparse or empty summary (reasoningChars near 0 for that call), which is itself signal. At Minimal (0)
// no trace is requested at all, so reasoningChars 0 there says nothing about whether the model thought.
async function streamModel(
  m: TbModel, content: string, effort: number, signal: AbortSignal,
  onDelta?: (reason: string, text: string) => void,
): Promise<{ text: string; reasoning: string; ms: number }> {
  let lastErr: any
  for (let attempt = 0; attempt < 3 && !signal.aborted; attempt++) {
    try {
      const t0 = performance.now()
      let text = "", reason = ""
      for await (const c of tb.ai.stream({ provider: m.provider, model: m.name, webSearch: false, effort: effort as 0 | 1 | 2 | 3, system: SYSTEM, messages: [{ role: "user", content }], signal })) {
        if (signal.aborted) throw new Error("aborted")
        if (c.kind === "reasoning") { reason += c.text; onDelta?.(reason, text) }
        else { text += c.text; onDelta?.(reason, text) }
      }
      return { text, reasoning: reason, ms: performance.now() - t0 }
    } catch (e: any) {
      lastErr = e
      if (signal.aborted) break
      await sleep(800 * (attempt + 1), signal)
    }
  }
  throw lastErr
}

// ── Transport icons (Material paths, filled with currentColor) ───

const PLAY = "M8 5v14l11-7z"
const PAUSE = "M6 19h4V5H6v14zm8-14v14h4V5h-4z"
const RESET = "M12 5V1L7 6l5 5V7c3.31 0 6 2.69 6 6s-2.69 6-6 6-6-2.69-6-6H4c0 4.42 3.58 8 8 8s8-3.58 8-8-3.58-8-8-8z"

const Icon = ({ d }: { d: string }) => (
  <svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d={d} /></svg>
)

// ── Model picker ─────────────────────────────────────────────────

function ModelSelect({ label, models, idx, setIdx, disabled }: {
  label: string; models: TbModel[]; idx: number; setIdx: (n: number) => void; disabled: boolean
}) {
  const groups = useMemo(() => {
    const g: Record<string, { m: TbModel; i: number }[]> = {}
    models.forEach((m, i) => { const k = m.providerName || m.provider || "Models"; (g[k] ??= []).push({ m, i }) })
    return Object.entries(g)
  }, [models])
  return (
    <div className="picker">
      <label>{label}</label>
      <select value={idx} onChange={e => setIdx(+e.target.value)} disabled={disabled}>
        <option value={-1}>Select…</option>
        {groups.map(([prov, arr]) => (
          <optgroup key={prov} label={prov}>
            {arr.map(({ m, i }) => <option key={i} value={i}>{m.friendlyName}</option>)}
          </optgroup>
        ))}
      </select>
    </div>
  )
}

// ── Baseline effort picker ───────────────────────────────────────
// MIN_EFFORT..(MAX_EFFORT-1): the baseline must stay below High so a hand-off can escalate +1.

function BaselineSelect({ label, value, onChange, disabled }: {
  label: string; value: number; onChange: (n: number) => void; disabled: boolean
}) {
  return (
    <div className="picker effort">
      <label>{label}</label>
      <select value={value} onChange={e => onChange(+e.target.value)} disabled={disabled}>
        {Array.from({ length: MAX_EFFORT - MIN_EFFORT }, (_, k) => MIN_EFFORT + k)
          .map(i => <option key={i} value={i}>{REASONING[i]}</option>)}
      </select>
    </div>
  )
}

// ── Results persistence (CLI only) ───────────────────────────────
// Every finished game is appended to a single growing JSON file so runs accumulate for later analysis.
// tb.fs is CLI-only; in editor/published mode saving is skipped.

type SideSummary = { id: string; name: string; provider: string; thinkMs: number; reasoningChars: number; handoffs: number; forced: number; moves: number }
type GameRecord = {
  ts: string
  outcome: "white" | "black" | "draw" | "error" | "stopped"
  result: Result
  settings: { whiteFloor: number; blackFloor: number; budget: number; carry: boolean }
  white: SideSummary; black: SideSummary
  plies: number; finalFen: string
  moves: MoveRecord[]
}

async function saveGame(game: GameRecord): Promise<{ ok: boolean; count?: number; reason?: string }> {
  if (tb.mode !== "local") return { ok: false, reason: "saving is CLI-only" }
  try {
    let store: { schema: number; games: GameRecord[] } = { schema: 1, games: [] }
    let existed = false
    try {
      const txt = await tb.fs.read(RESULTS_FILE)
      existed = true
      const parsed = JSON.parse(txt)
      if (!parsed || !Array.isArray(parsed.games)) throw new Error("unexpected shape")
      store = parsed
    } catch {
      // Missing file → start fresh. But a file that EXISTS yet won't parse must not be clobbered.
      if (existed) return { ok: false, reason: "existing results file is unreadable — not overwriting it" }
    }
    store.games.push(game)
    await tb.fs.write(RESULTS_FILE, JSON.stringify(store, null, 2))
    return { ok: true, count: store.games.length }
  } catch (e: any) {
    return { ok: false, reason: e?.message || String(e) }
  }
}

// Collapsed by default so the ledger stays scannable; expand to re-read a past move's streamed thinking.
function Thinking({ text }: { text: string }) {
  if (!text.trim()) return null
  return (
    <details className="think">
      <summary>Thinking · {fmtK(text.length)} chars</summary>
      <div className="think-body">{text}</div>
    </details>
  )
}

// ── App ──────────────────────────────────────────────────────────

function App() {
  const [models, setModels] = useState<TbModel[]>([])
  const [needsKeys, setNeedsKeys] = useState(false)
  const [whiteIdx, setWhiteIdx] = useState(-1)
  const [blackIdx, setBlackIdx] = useState(-1)
  const [whiteFloor, setWhiteFloor] = useState(1) // per-side baseline effort; defaults to Low even while Minimal is offered
  const [blackFloor, setBlackFloor] = useState(1)
  const [budget, setBudget] = useState(8)      // hand-offs available per side

  const [moves, setMoves] = useState<string[]>([])
  const [records, setRecords] = useState<MoveRecord[]>([])
  const [running, setRunning] = useState(false)
  const [paused, setPaused] = useState(false)
  const [thinking, setThinking] = useState<{ color: Color; effort: number; handoff: boolean } | null>(null)
  const [live, setLive] = useState<{ color: Color; effort: number; handoff: boolean; reasoning: string; text: string } | null>(null)
  const [result, setResult] = useState<Result | null>(null)
  const [selected, setSelected] = useState<number | null>(null)
  const [saveNote, setSaveNote] = useState("")
  const abortRef = useRef<AbortController | null>(null)
  const pausedRef = useRef(false)
  const ledgerRef = useRef<HTMLDivElement | null>(null)
  const reasonRef = useRef<HTMLDivElement | null>(null)

  const togglePause = () => { pausedRef.current = !pausedRef.current; setPaused(pausedRef.current) }

  // Stop-and-clear: aborts any game in flight, then wipes the board back to the setup state.
  const resetGame = () => {
    abortRef.current?.abort()
    pausedRef.current = false; setPaused(false)
    setMoves([]); setRecords([]); setResult(null); setSelected(null); setLive(null); setSaveNote("")
  }

  useEffect(() => { const el = ledgerRef.current; if (el) el.scrollTop = el.scrollHeight }, [records])
  useEffect(() => { const el = reasonRef.current; if (el) el.scrollTop = el.scrollHeight }, [live?.reasoning])

  useEffect(() => {
    // A full game is dozens of AI calls — far past the courtesy quota, so gate on the user's own keys.
    tb.aiAccess().then(a => setNeedsKeys(a !== "own"))
    tb.models().then(ms => {
      setModels(ms)
      if (!ms.length) return
      const find = (re: RegExp, fallback: number) => {
        const i = ms.findIndex(m => re.test(m.name.toLowerCase()))
        return i >= 0 ? i : Math.min(fallback, ms.length - 1)
      }
      setWhiteIdx(find(/(^|\/)gemini-3-flash/, 0))
      setBlackIdx(find(/(^|\/)gemini-3-flash/, 1))
    })
  }, [])

  const chess = useMemo(() => {
    const c = new Chess()
    const plies = selected == null ? moves.length : Math.min(selected + 1, moves.length)
    for (let i = 0; i < plies; i++) { try { c.move(moves[i]) } catch {} }
    return c
  }, [moves, selected])

  const lastMove = useMemo(() => {
    const h = chess.history({ verbose: true })
    const m = h[h.length - 1]
    return m ? new Set<string>([m.from, m.to]) : new Set<string>()
  }, [chess])

  const whiteModel = whiteIdx >= 0 ? models[whiteIdx] : null
  const blackModel = blackIdx >= 0 ? models[blackIdx] : null
  const labelFor = (c: Color) => (c === "w" ? whiteModel : blackModel)

  const totals = useMemo(() => {
    const t = { w: { ms: 0, used: 0, rc: 0 }, b: { ms: 0, used: 0, rc: 0 } }
    for (const r of records) { t[r.color].ms += r.thinkMs; t[r.color].used += r.handedOff ? 1 : 0; t[r.color].rc += r.reasoningChars }
    return t
  }, [records])

  async function runGame() {
    if (!whiteModel || !blackModel) return
    const ac = new AbortController()
    abortRef.current = ac
    const signal = ac.signal
    setRunning(true); setResult(null); setMoves([]); setRecords([]); setSelected(null); setSaveNote(""); setLive(null)
    pausedRef.current = false; setPaused(false)
    let finalResult: Result | undefined
    const finish = (r: Result) => { finalResult = r; setResult(r) }

    const c = new Chess()
    const localMoves: string[] = []
    const localRecords: MoveRecord[] = []
    const side: Record<Color, { budget: number; model: TbModel; floor: number }> = {
      w: { budget, model: whiteModel, floor: whiteFloor },
      b: { budget, model: blackModel, floor: blackFloor },
    }

    // Live streaming view — flush reasoning at ~60ms cadence, but always reflect new answer text promptly.
    let liveAt = 0
    const pushLive = (color: Color, effort: number, handoff: boolean) => (reasoning: string, text: string) => {
      const now = performance.now()
      if (!text && now - liveAt < 60) return
      liveAt = now
      setLive({ color, effort, handoff, reasoning, text })
    }

    // One physical model call. Streams the reply, accumulates wall-clock + reasoning volume into `accum`,
    // and surfaces live effort/handoff + the thinking stream.
    async function physical(color: Color, content: string, effort: number, handoff: boolean, accum: { ms: number; rc: number }) {
      setThinking({ color, effort, handoff })
      setLive({ color, effort, handoff, reasoning: "", text: "" })
      const { text, reasoning, ms } = await streamModel(side[color].model, content, effort, signal, pushLive(color, effort, handoff))
      accum.ms += ms
      accum.rc += reasoning.length
      return { text, reasoning }
    }

    // Resolve one move for `color`: play at the baseline, and if the model judges a hand-off would make a
    // win more likely (and budget remains), re-evaluate ONCE at one level deeper and play that move. A
    // single hand-off per move — no recursion. Returns san:null on a failure to produce a legal move.
    async function getMove(color: Color): Promise<Omit<MoveRecord, "color">> {
      const s = side[color]
      const colorName = color === "w" ? "White" : "Black"
      const fen = c.fen()
      const history = [...localMoves]
      const legal = c.moves()
      const moveNo = Math.floor(history.length / 2) + 1
      const accum = { ms: 0, rc: 0 }

      // One labelled call at `effort`, with a single same-effort feedback retry on an unparseable/illegal
      // reply (a format slip, not a thinking choice).
      const ask = async (effort: number, handoff: boolean, canHandoff: boolean, prevText?: string) => {
        let { text, reasoning } = await physical(color, buildMsg({ colorName, fen, history, legal, effort, budgetLeft: s.budget, moveNo, canHandoff, prevText }), effort, handoff, accum)
        let san = parseMove(text, fen)
        if (!san) {
          const note = `Your previous reply did not contain a legal move. Choose exactly one from: ${legal.join(", ")}.`
          const retry = await physical(color, buildMsg({ colorName, fen, history, legal, effort, budgetLeft: s.budget, moveNo, canHandoff, prevText, note }), effort, handoff, accum)
          text = retry.text; reasoning = retry.reasoning
          san = parseMove(text, fen)
        }
        const parsed = { text, reasoning, san, evalStr: parseEval(text), comment: parseComment(text), handoff: canHandoff && parseHandoff(text) }
        // TEMP diagnostic: surface each raw reply + the hand-off decision so we can see where the budget
        // gets used. Remove once the mechanism is dialed in.
        tb.log(`[reply ${colorName} eff${effort} budget${s.budget}] handoff=${parsed.handoff} san=${parsed.san} :: ${text.replace(/\s+/g, " ").slice(0, 220)}`)
        return parsed
      }

      const canHO = s.budget > 0 && s.floor < MAX_EFFORT
      const floorReply = await ask(s.floor, false, canHO)
      let cur = floorReply
      let effort = s.floor, handed = false, forced = false

      if (!cur.san) {
        // Illegal at baseline: rather than forfeit outright, spend one opportunity (if any) to re-evaluate
        // one level deeper — an illegal move is itself a sign more thinking is warranted. An illegal move
        // from the escalated instance has nothing left to escalate to, so it falls through to a forfeit.
        if (canHO) {
          s.budget -= 1; handed = true; forced = true; effort = s.floor + 1
          const deep = await ask(effort, true, false, CARRY ? floorReply.text : undefined)
          if (deep.san) cur = deep
        }
      } else if (cur.handoff && canHO) {
        // Model chose to hand off on a legal baseline move.
        s.budget -= 1; handed = true; effort = s.floor + 1 // one level deeper, single chance, no recursion
        const deep = await ask(effort, true, false, CARRY ? cur.text : undefined)
        if (deep.san) cur = deep
      }

      if (!cur.san) {
        return { san: null as any, effortFloor: s.floor, effortFinal: effort, handedOff: handed, forced, budgetLeft: s.budget, thinkMs: accum.ms, reasoningChars: accum.rc, reasoning: cur.reasoning, evalStr: "", comment: "", changed: false }
      }
      return {
        san: cur.san!, effortFloor: s.floor, effortFinal: effort, handedOff: handed, forced, budgetLeft: s.budget,
        thinkMs: accum.ms, reasoningChars: accum.rc, reasoning: cur.reasoning, evalStr: cur.evalStr, comment: cur.comment, changed: handed && cur.san !== floorReply.san,
        shallow: handed ? { san: floorReply.san ?? "(illegal)", evalStr: floorReply.evalStr, comment: floorReply.comment, reasoning: floorReply.reasoning } : undefined,
      }
    }

    try {
      while (!signal.aborted) {
        if (c.isGameOver()) {
          if (c.isCheckmate()) finish({ type: "checkmate", winner: c.turn() === "w" ? "b" : "w" })
          else finish({
            type: "draw",
            reason: c.isStalemate() ? "stalemate"
              : c.isThreefoldRepetition() ? "threefold repetition"
              : c.isInsufficientMaterial() ? "insufficient material"
              : "fifty-move rule",
          })
          break
        }
        // Pause holds between moves — native reasoning is opaque mid-call, so a paused click takes
        // effect once the current move finishes streaming, then the loop waits here until Resume.
        if (pausedRef.current) {
          setThinking(null); setLive(null)
          while (pausedRef.current && !signal.aborted) await sleep(150, signal)
          if (signal.aborted) break
        }
        const color = c.turn()
        const rec = await getMove(color)
        if (signal.aborted) break
        if (!rec.san) {
          finish({ type: "forfeit", winner: color === "w" ? "b" : "w", offender: color })
          break
        }
        c.move(rec.san)
        localMoves.push(rec.san)
        localRecords.push({ color, ...rec })
        setMoves([...localMoves])
        setRecords([...localRecords])
      }
    } catch (e: any) {
      if (!signal.aborted) finish({ type: "error", message: e?.message || String(e) })
    }

    setThinking(null)
    setLive(null)
    setRunning(false)
    pausedRef.current = false; setPaused(false)

    // Append the game to the growing results file however it ended — a stop/reset usually means "we
    // already have the data we need", so partial games persist too (outcome "stopped"). Only games with
    // no moves are skipped; non-CLI modes are skipped inside saveGame.
    if (localRecords.length) {
      const result: Result = finalResult ?? { type: "stopped" }
      const sum = (color: Color): SideSummary => {
        const rs = localRecords.filter(r => r.color === color)
        const m = color === "w" ? whiteModel : blackModel
        return {
          id: m.name, name: m.friendlyName, provider: m.provider,
          thinkMs: rs.reduce((a, r) => a + r.thinkMs, 0), reasoningChars: rs.reduce((a, r) => a + r.reasoningChars, 0),
          handoffs: rs.filter(r => r.handedOff).length, forced: rs.filter(r => r.forced).length, moves: rs.length,
        }
      }
      const outcome: GameRecord["outcome"] =
        result.type === "draw" ? "draw"
          : result.type === "error" ? "error"
          : result.type === "stopped" ? "stopped"
          : result.winner === "w" ? "white" : "black"
      const game: GameRecord = {
        ts: new Date().toISOString(), outcome, result,
        settings: { whiteFloor, blackFloor, budget, carry: CARRY }, white: sum("w"), black: sum("b"),
        plies: localMoves.length, finalFen: c.fen(), moves: localRecords,
      }
      saveGame(game).then(res => setSaveNote(res.ok ? `Saved game #${res.count} → ${RESULTS_FILE}` : `Not saved: ${res.reason}`))
    }
  }

  // ── Status / banner ──

  const status = (() => {
    if (needsKeys) return null
    if (running && paused) return "Paused — press play to resume."
    if (running && thinking) {
      const m = labelFor(thinking.color)
      const who = `${m ? modelLabel(m) : ""} (${thinking.color === "w" ? "White" : "Black"})`
      return thinking.handoff
        ? `${who} handed off — deeper instance thinking at ${effortName(thinking.effort)}…`
        : `${who} thinking at ${effortName(thinking.effort)}…`
    }
    if (!result) return moves.length ? "Game stopped." : "Pick two models and a hand-off budget, then press play."
    return null
  })()

  const banner = (() => {
    if (!result) return null
    if (result.type === "checkmate") {
      const w = labelFor(result.winner)
      return { tone: "win", text: `Checkmate — ${w ? modelLabel(w) : result.winner} (${result.winner === "w" ? "White" : "Black"}) wins.` }
    }
    if (result.type === "draw") return { tone: "draw", text: `Draw — ${result.reason}.` }
    if (result.type === "forfeit") {
      const w = labelFor(result.winner), o = labelFor(result.offender)
      return { tone: "forfeit", text: `${o ? modelLabel(o) : ""} (${result.offender === "w" ? "White" : "Black"}) failed to find a legal move — ${w ? modelLabel(w) : ""} wins.` }
    }
    return result.type === "error" ? { tone: "error", text: result.message } : null
  })()

  const board = chess.board()
  const displayIdx = selected ?? records.length - 1
  const current = displayIdx >= 0 ? records[displayIdx] : undefined
  const moveLabel = (i: number) => `${Math.floor(i / 2) + 1}${i % 2 === 0 ? "." : "…"}`

  const Meter = ({ color }: { color: Color }) => {
    const m = labelFor(color), t = totals[color]
    return (
      <div className="meter">
        <div className="meter-hd">
          <span className={`cdot ${color}`} />
          <span className="meter-name">{m ? modelLabel(m) : color === "w" ? "White" : "Black"}</span>
        </div>
        <div className="meter-stats">
          <span>hand-offs {Math.max(0, budget - t.used)} / {budget}</span>
          <span>think {(t.ms / 1000).toFixed(1)}s</span>
          <span>reason {fmtK(t.rc)}</span>
        </div>
      </div>
    )
  }

  return (
    <div className="wrap">
      <header>
        <h1>Executive Dysfunction</h1>
        <p className="subtitle">Each side gets a finite number of hand-offs to a deeper instance of itself. Where does a model choose to use them?</p>
      </header>

      {needsKeys && (
        <div className="gate">
          <p>This plays a full game of dozens of AI calls — past the free courtesy quota. To run it, use your own keys:</p>
          <ul className="gate-list">
            <li>locally with the <strong>typebulb CLI</strong>, or</li>
            <li>sign in at <strong>typebulb.com</strong> and add your keys.</li>
          </ul>
        </div>
      )}

      <div className="setup">
        <div className="matchup">
          <div className="side-setup">
            <ModelSelect label="White" models={models} idx={whiteIdx} setIdx={setWhiteIdx} disabled={running} />
            <BaselineSelect label="Baseline" value={whiteFloor} onChange={setWhiteFloor} disabled={running} />
          </div>
          <span className="vs">vs</span>
          <div className="side-setup">
            <ModelSelect label="Black" models={models} idx={blackIdx} setIdx={setBlackIdx} disabled={running} />
            <BaselineSelect label="Baseline" value={blackFloor} onChange={setBlackFloor} disabled={running} />
          </div>
        </div>
        <div className="transport">
          {!running
            ? <button className="icon-btn big" onClick={runGame} disabled={needsKeys || !whiteModel || !blackModel} title="Play"><Icon d={PLAY} /></button>
            : <button className="icon-btn big" onClick={togglePause} title={paused ? "Resume" : "Pause"}><Icon d={paused ? PLAY : PAUSE} /></button>}
          <button className="icon-btn small" onClick={resetGame} disabled={!running && !moves.length && !result} title="Reset"><Icon d={RESET} /></button>
          <div className="picker">
            <label>Hand-offs</label>
            <input type="number" min={0} max={200} value={budget} disabled={running}
              onChange={e => setBudget(Math.max(0, Math.min(200, +e.target.value || 0)))} />
          </div>
        </div>
      </div>

      {banner && <div className={`banner ${banner.tone}`}>{banner.text}</div>}
      {status && <div className="status">{status}{running && thinking && <span className="dots" />}</div>}
      {saveNote && <div className="savenote">{saveNote}</div>}

      <div className="meters">
        <Meter color="w" />
        <Meter color="b" />
      </div>

      <div className="game">
        <div className="board">
          {board.map((rank, ri) =>
            rank.map((cell, fi) => {
              const sq = (FILES[fi] + (8 - ri)) as Square
              const cls = ["sq", (ri + fi) % 2 === 1 ? "dark" : "light", lastMove.has(sq) ? "last" : ""].join(" ")
              return (
                <div key={sq} className={cls}>
                  {cell && <span className={cell.color === "w" ? "pw" : "pb"}>{PIECES[cell.type]}</span>}
                </div>
              )
            })
          )}
        </div>

        <div className="side-panel">
          {running && live && (
            <div className="live">
              <div className="live-hd">
                <span className={`cdot ${live.color}`} />
                <span className="live-who">{(() => { const m = labelFor(live.color); return m ? modelLabel(m) : live.color === "w" ? "White" : "Black" })()}</span>
                <span className="live-eff">{effortName(live.effort)}{live.handoff ? " · deeper" : ""}</span>
              </div>
              {live.reasoning
                ? <div className="live-reason" ref={reasonRef}>{live.reasoning}</div>
                : <div className="live-reason empty">thinking…</div>}
              {live.text && <div className="live-text">{live.text}</div>}
            </div>
          )}
          <div className="detail">
            {current ? (
              <>
                <div className="detail-hd">
                  <span className={`cdot ${current.color}`} />
                  <span className="detail-move">{moveLabel(displayIdx)} {current.san}</span>
                  {current.handedOff && <span className="ho-badge">{current.forced ? "auto · illegal baseline" : `handed off${current.changed ? " — move changed" : ""}`}</span>}
                  {selected != null && records.length > 1 && <button className="live-btn" onClick={() => setSelected(null)}>↩ latest</button>}
                </div>
                {current.handedOff && current.shallow ? (
                  <>
                    <div className="take">
                      <div className="take-hd"><span className="take-lbl">Baseline · {SHORT[current.effortFloor]}</span> <span className="mono">{current.shallow.san}</span>{current.shallow.evalStr && <span className="take-eval"> ({current.shallow.evalStr})</span>}</div>
                      <div className="take-body">{current.shallow.comment || <em className="nc">(no comment)</em>}</div>
                      <Thinking text={current.shallow.reasoning} />
                    </div>
                    <div className="take played">
                      <div className="take-hd"><span className="take-lbl">Deeper · {SHORT[current.effortFinal]}</span> <span className="mono">{current.san}</span>{current.evalStr && <span className="take-eval"> ({current.evalStr})</span>} <span className="played-tag">played</span></div>
                      <div className="take-body">{current.comment || <em className="nc">(no comment)</em>}</div>
                      <Thinking text={current.reasoning} />
                    </div>
                  </>
                ) : (
                  <div className="take">
                    <div className="take-hd"><span className="mono">{current.san}</span>{current.evalStr && <span className="take-eval"> ({current.evalStr})</span>}</div>
                    <div className="take-body">{current.comment || <em className="nc">(no comment)</em>}</div>
                    <Thinking text={current.reasoning} />
                  </div>
                )}
                <div className="detail-grid">
                  <span>Effort</span><span>{effortShort(current.effortFloor, current.effortFinal)}</span>
                  <span>Think time</span><span>{(current.thinkMs / 1000).toFixed(1)}s</span>
                  <span>Reasoning</span><span>{current.reasoningChars ? `${fmtK(current.reasoningChars)} chars` : "—"}</span>
                  <span>Hand-offs left</span><span>{current.budgetLeft}</span>
                </div>
              </>
            ) : (
              <div className="detail-empty">Per-move detail and the model's own commentary appear here. Click any move to revisit it.</div>
            )}
          </div>

          <div className="ledger" ref={ledgerRef}>
            <table>
              <thead>
                <tr><th>#</th><th>Move</th><th>Effort</th><th>HO</th><th>Eval</th><th>Time</th></tr>
              </thead>
              <tbody>
                {records.length === 0 && <tr><td colSpan={6} className="ledger-empty">No moves yet.</td></tr>}
                {records.map((r, i) => (
                  <tr key={i} className={i === displayIdx ? "sel" : ""} onClick={() => setSelected(i === records.length - 1 ? null : i)}>
                    <td className="dim">{moveLabel(i)}</td>
                    <td className="mono"><span className={`cdot ${r.color}`} />{r.san}</td>
                    <td className="mono">{effortShort(r.effortFloor, r.effortFinal)}</td>
                    <td className="dim">{r.handedOff ? (r.forced ? "!" : (r.changed ? "✓*" : "✓")) : ""}</td>
                    <td className="mono">{r.evalStr || "—"}</td>
                    <td className="dim">{(r.thinkMs / 1000).toFixed(1)}s</td>
                  </tr>
                ))}
              </tbody>
            </table>
          </div>
        </div>
      </div>
    </div>
  )
}

createRoot(document.getElementById("root")!).render(<App />)
```
**styles.css**

```css
:root {
  --bg: #16181d; --panel: #1e2128; --text: #e6e8ee; --text2: #9aa0ac;
  --border: rgba(255,255,255,0.1); --accent: #60a5fa; --green: #4ade80; --red: #f87171; --gold: #fbbf24;
  --board: 420px;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font: 14px system-ui, -apple-system, "Segoe UI", sans-serif; background: var(--bg); color: var(--text); padding: 20px; }
.wrap { max-width: 560px; margin: 0 auto; display: grid; gap: 14px; }
.game { display: grid; gap: 14px; }
.side-panel { display: grid; gap: 14px; min-width: 0; align-content: start; }

header h1 { font-size: 1.5rem; text-align: center; }
.subtitle { text-align: center; color: var(--text2); font-size: 0.85rem; margin-top: 4px; line-height: 1.5; }

.gate { background: var(--panel); border: 1px solid var(--gold); border-radius: 10px; padding: 16px 20px; }
.gate p { color: var(--text); font-size: 0.9rem; line-height: 1.6; }
.gate-list { color: var(--text); font-size: 0.9rem; line-height: 1.6; margin: 8px 0 0; padding-left: 22px; }

.setup { display: grid; gap: 14px; justify-items: center; }
/* Narrow: the two sides stack as full-width aligned rows (the "vs" would unbalance the wrap, so it's
   hidden). Wide (≥820px): a 1fr|auto|1fr grid — both sides get identical width and the "vs" sits in
   the middle track, so no width can wrap it out of alignment. Selects stretch to their track in both
   layouts (never content-sized), which is what keeps the two sides mirrored. */
.matchup { display: grid; gap: 12px; width: 100%; max-width: 460px; }
.side-setup { display: flex; align-items: flex-end; gap: 10px; }
.side-setup .picker:first-child { flex: 1; min-width: 0; }
.side-setup .picker:first-child select { width: 100%; min-width: 0; }
.picker.effort select { min-width: 0; width: 96px; }
.picker { display: grid; gap: 4px; }
.picker label { font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text2); }
.picker select, .picker input { padding: 7px 10px; border: 1px solid var(--border); border-radius: 6px; background: var(--panel); color: var(--text); font-size: 0.85rem; }
.picker select { min-width: 180px; }
.picker input { width: 90px; }
.vs { display: none; color: var(--text2); font-size: 0.8rem; padding-bottom: 8px; }

/* Transport: borderless icon buttons filled with currentColor — big play/pause, small reset —
   with the hand-off budget knob on the same row. */
.transport { display: flex; align-items: center; gap: 6px; }
.icon-btn { background: transparent; border: none; padding: 0; cursor: pointer; color: var(--accent); display: flex; align-items: center; justify-content: center; transition: color 0.15s; }
.icon-btn svg { width: 100%; height: 100%; }
.icon-btn:hover:not(:disabled) { color: #93c5fd; }
.icon-btn:disabled { opacity: 0.35; cursor: not-allowed; }
.icon-btn.big { width: 48px; height: 48px; }
.icon-btn.small { width: 32px; height: 32px; color: var(--text2); }
.icon-btn.small:hover:not(:disabled) { color: var(--text); }
.transport .picker { margin-left: 12px; }

.banner { padding: 10px 14px; border-radius: 8px; font-size: 0.9rem; font-weight: 600; text-align: center; }
.banner.win { background: rgba(74,222,128,0.12); border: 1px solid var(--green); color: var(--green); }
.banner.draw { background: var(--panel); border: 1px solid var(--border); color: var(--text2); }
.banner.forfeit, .banner.error { background: rgba(248,113,113,0.12); border: 1px solid var(--red); color: var(--red); }

.status { text-align: center; color: var(--text2); font-size: 0.85rem; min-height: 1.2em; }
.dots { display: inline-block; width: 1.2em; text-align: left; }
.dots::after { content: ""; animation: dots 1.2s steps(4, end) infinite; }
@keyframes dots { 0% { content: ""; } 25% { content: "."; } 50% { content: ".."; } 75% { content: "..."; } }
.savenote { text-align: center; color: var(--text2); font-size: 0.76rem; font-family: ui-monospace, monospace; opacity: 0.8; }

.meters { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.meter { background: var(--panel); border: 1px solid var(--border); border-radius: 8px; padding: 8px 12px; }
.meter-hd { display: flex; align-items: center; gap: 7px; margin-bottom: 4px; }
.meter-name { font-weight: 600; font-size: 0.9rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.meter-stats { display: flex; justify-content: space-between; font-size: 0.78rem; color: var(--text2); font-family: ui-monospace, monospace; }

.cdot { display: inline-block; width: 9px; height: 9px; border-radius: 50%; flex-shrink: 0; margin-right: 5px; }
.cdot.w { background: #e6e8ee; }
.cdot.b { background: #6b6f7a; }

.board { display: grid; grid-template-columns: repeat(8, 1fr); border: 2px solid #6b4f35; border-radius: 4px; overflow: hidden; user-select: none; }
.sq { aspect-ratio: 1; display: grid; place-items: center; position: relative; font-size: clamp(24px, 7vw, 40px); line-height: 1; }
.sq.light { background: #f0d9b5; }
.sq.dark { background: #b58863; }
.sq.last { box-shadow: inset 0 0 0 100vmax rgba(155, 199, 0, 0.35); }
.pw { color: #fafafa; text-shadow: 0 1px 1px #000, 0 0 2px #000; }
.pb { color: #1a1a1a; }

.detail { background: var(--panel); border: 1px solid var(--border); border-radius: 8px; padding: 12px 14px; min-height: 110px; }
.detail-empty { color: var(--text2); font-size: 0.85rem; font-style: italic; line-height: 1.5; }
.detail-hd { display: flex; align-items: center; gap: 8px; margin-bottom: 10px; flex-wrap: wrap; }
.detail-move { font-family: ui-monospace, monospace; font-size: 0.9rem; font-weight: 700; }
.ho-badge { font-size: 0.7rem; font-weight: 700; color: var(--accent); background: rgba(96,165,250,0.14); border-radius: 5px; padding: 2px 7px; }
.live-btn { margin-left: auto; background: transparent; border: 1px solid var(--border); color: var(--text2); border-radius: 6px; padding: 2px 9px; font-size: 0.72rem; cursor: pointer; }
.live-btn:hover { border-color: var(--accent); color: var(--text); }
.take { margin-bottom: 10px; }
.take-hd { font-size: 0.8rem; color: var(--text2); margin-bottom: 3px; display: flex; align-items: baseline; gap: 6px; flex-wrap: wrap; }
.take-lbl { font-size: 0.68rem; text-transform: uppercase; letter-spacing: 0.4px; }
.take .mono { font-family: ui-monospace, monospace; font-weight: 700; color: var(--text); }
.take-eval { font-family: ui-monospace, monospace; color: var(--text2); }
.played-tag { font-size: 0.62rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.4px; color: var(--accent); background: rgba(96,165,250,0.14); border-radius: 4px; padding: 1px 6px; }
.take-body { font-size: 0.88rem; color: var(--text); line-height: 1.5; white-space: pre-wrap; }
.take-body .nc { color: var(--text2); opacity: 0.6; }
.detail-grid { display: grid; grid-template-columns: auto 1fr; gap: 5px 14px; font-size: 0.84rem; border-top: 1px solid var(--border); padding-top: 10px; }
.detail-grid span:nth-child(odd) { color: var(--text2); }
.detail-grid span:nth-child(even) { font-family: ui-monospace, monospace; }

.live { background: var(--panel); border: 1px solid var(--accent); border-radius: 8px; padding: 10px 12px; display: grid; gap: 8px; }
.live-hd { display: flex; align-items: center; gap: 7px; font-size: 0.82rem; }
.live-who { font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.live-eff { margin-left: auto; flex-shrink: 0; font-size: 0.7rem; font-weight: 700; color: var(--accent); background: rgba(96,165,250,0.14); border-radius: 5px; padding: 2px 7px; }
.live-reason { max-height: 200px; overflow-y: auto; font-size: 0.78rem; line-height: 1.5; color: var(--text2); white-space: pre-wrap; font-family: ui-monospace, monospace; }
.live-reason.empty { font-style: italic; opacity: 0.7; font-family: inherit; }
.live-text { font-size: 0.86rem; line-height: 1.5; color: var(--text); white-space: pre-wrap; border-top: 1px solid var(--border); padding-top: 8px; }

.think { margin-top: 8px; border-top: 1px dashed var(--border); padding-top: 6px; }
.think > summary { cursor: pointer; font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.4px; color: var(--text2); list-style: none; }
.think > summary::-webkit-details-marker { display: none; }
.think > summary::before { content: "▸ "; }
.think[open] > summary::before { content: "▾ "; }
.think-body { margin-top: 6px; max-height: 220px; overflow-y: auto; font-size: 0.78rem; line-height: 1.5; color: var(--text2); white-space: pre-wrap; font-family: ui-monospace, monospace; }

.ledger { background: var(--panel); border: 1px solid var(--border); border-radius: 8px; max-height: 300px; overflow-y: auto; }
.ledger table { width: 100%; border-collapse: collapse; font-size: 0.8rem; }
.ledger th { position: sticky; top: 0; background: var(--panel); text-align: left; padding: 7px 10px; color: var(--text2); font-weight: 600; font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.4px; border-bottom: 1px solid var(--border); }
.ledger td { padding: 5px 10px; border-bottom: 1px solid rgba(255,255,255,0.04); }
.ledger tr { cursor: pointer; }
.ledger tbody tr:hover { background: rgba(255,255,255,0.04); }
.ledger tr.sel { background: rgba(96,165,250,0.18); }
.ledger .mono { font-family: ui-monospace, monospace; }
.ledger .dim { color: var(--text2); }
.ledger-empty { color: var(--text2); font-style: italic; text-align: center; padding: 14px; cursor: default; }

@media (min-width: 820px) {
  .wrap { max-width: 980px; }
  .game { grid-template-columns: minmax(0, var(--board)) 1fr; gap: 22px; align-items: start; }
  .ledger { max-height: 460px; }
  .matchup { grid-template-columns: 1fr auto 1fr; align-items: end; gap: 16px; max-width: 680px; }
  .vs { display: block; }
}
```
**index.html**

```html
<div id="root"></div>
```
**config.json**

```json
{
  "description": "Probe whether an AI model can tell which chess positions deserve deeper thinking, given a finite budget of hand-offs to a deeper instance of itself.",
  "dependencies": {
    "react": "^19.2.7",
    "react-dom": "^19.2.7",
    "chess.js": "^1.4.0"
  }
}
```

Markdown source · More bulbs by antypica · Typebulb home