artículos / Claude Code Dentro de Minecraft: Cómo Me...

Claude Code Dentro de Minecraft: Cómo Metí un Agente de IA Real en un Bloque de Computadora

AWONG 14 minutos de lectura 62 vistas

Un análisis técnico de cómo conectar Claude Code CLI a un ordenador CC:Tweaked mediante un servidor WebSocket PTY en Python, un emulador xterm-lite en Lua y tmux como capa de persistencia, convirtiendo cualquier bloque de Minecraft en una terminal de IA funcional.

Claude Code Dentro de Minecraft: Cómo Metí un Agente de IA Real en un Bloque de Computadora

Claude Code Dentro de Minecraft: Cómo Metí un Agente de IA Real en un Bloque de Computadora

No es un mod que habla a una API. No es una ventana de chat pegada a una GUI. Es una sesión claude CLI real, corriendo en el host, renderizada en la pantalla de un ordenador CC:Tweaked, con teclado que manejas desde dentro del juego. El mismo pipe sirve para lynx, htop, vim o cualquier otro programa de terminal. Claude Code es solo el inquilino más interesante. Este post desglosa la arquitectura, los momentos raros del proceso, y la pieza de la que estoy más orgulloso: el traductor de stream que convierte un PTY xterm real en algo que una pantalla Lua de 16 colores puede renderizar.

El Stack en un Diagrama

Son cinco partes móviles que trabajan en conjunto:

┌──────────────────┐   RCON    ┌──────────────────┐   spawn     ┌──────────────────┐
│ Minecraft Fabric │◀─────────▶│  DevChefs Panel  │────────────▶│   tmux sessions  │
│  Server 1.21.1   │           │   (Next.js :3000)│             │ dev1-5 / browser │
└──────────────────┘           └──────────────────┘             └──────────────────┘
        ▲                              │                                 ▲
        │ bloque in-game               │ sirve startup.lua               │ attach
        │                              ▼                                 │
┌──────────────────┐   WebSocket   ┌──────────────────┐                  │
│   CC:Tweaked     │──────────────▶│   ptyws.py :7680 │──────────────────┘
│    computer      │   ttyd proto  │   WebSocket PTY  │
└──────────────────┘               └──────────────────┘
  • Minecraft Fabric 1.21.1 + CC:Tweaked: provee el bloque de computadora programable y su runtime Lua con term, http y peripheral.
  • DevChefs Panel: una app Next.js como plano de control — cliente RCON, gestor de jugadores, catálogo de ítems (~1.368 ítems), consola en vivo, y un endpoint público /api/setup que sirve el programa Lua de bootstrap.
  • ptyws.py: un servidor WebSocket PTY de ~300 líneas escrito sobre el módulo socket puro (sin librería websockets). Implementa suficiente del protocolo de cable ttyd para reenviar bytes PTY y eventos de resize.
  • tmux: la capa de persistencia. Las sesiones dev1..dev5 y browser1..browser5 sobreviven a reconexiones, crashes y descargas de chunks. Tu sesión claude sigue donde la dejaste tres días después.
  • startup.lua: el lado CC:Tweaked del bridge. Se auto-descarga en cualquier computadora que bootea, obtiene el roster de sesiones, abre el WebSocket, y corre un pequeño emulador xterm en Lua.

El Flujo de Usuario

Colocas una computadora (o una tortuga, o una pared de monitores avanzados junto a una computadora). En el primer boot, el runtime Lua baja startup.lua desde http://panel:3000/api/setup. El script renderiza un selector de sesiones: dev1..5 para shells, browser1..5 para lynx preconfigurado con una paleta brillante de 16 colores afinada para monitores CC:Tweaked.

Seleccionas dev1. La computadora abre ws://panel:7680/session/dev1, ptyws.py spawnea (o re-adjunta) una sesión tmux, y bash cae al PTY. Escribes claude. El agente corre dentro de Minecraft. Si hay un Monitor Avanzado adyacente, startup.lua lo autodetecta vía peripheral.find("monitor") y espeja cada term.write en él, convirtiendo una pared de monitores 5×3 en una pantalla de ~70 columnas.

La Parte Interesante: Traducir un PTY Real a CC:Tweaked

Aquí es donde el proyecto dejó de ser "código de pegamento" y se volvió genuinamente difícil.

CC:Tweaked te da una grilla de caracteres. Puedes setCursorPos(x, y), write("texto"), clearLine(), y elegir uno de 16 colores con nombre. Eso es todo. No es una terminal. Nunca ha oído hablar de ANSI. No sabe qué significa \x1b[2J.

Del otro lado del cable, una sesión bash real adjunta a tmux está disparando:

  • Secuencias CSI: \x1b[H, \x1b[2J, \x1b[31;1m, \x1b[6;14H
  • Modos privados DEC: \x1b[?25l para ocultar cursor, \x1b[?1049h para cambiar a la pantalla alternativa
  • Secuencias OSC: \x1b]0;title\x07 para títulos de ventana
  • Códigos de color SGR en formas de 8, 16, 256 y 24 bits
  • Runs UTF-8 multibyte para cada carácter de box-drawing que Claude Code ama usar

Algo tiene que estar entre esos dos mundos. Ese algo es un parser xterm-lite en streaming escrito en Lua, corriendo dentro de la computadora de Minecraft.

Cómo Funciona el Parser

processOutput(text) recorre el chunk entrante byte a byte. El loop de nivel superior tiene dos ramas: "estamos viendo un ESC" y "estamos viendo texto plano".

Rama ESC. Al encontrar 0x1B, mira el siguiente byte:

  • [ → secuencia CSI. Escanea hacia adelante hasta encontrar un byte en el rango 0x40..0x7E. Extrae el string de parámetros, detecta y salta prefijos privados DEC (?, >, =), luego despacha en el byte final: H/f = posición del cursor, A/B/C/D = movimientos relativos, J = borrar pantalla, K = borrar línea.
  • ] → OSC. Salta hasta BEL (0x07) o ST (ESC \). Los títulos de ventana no son útiles en Minecraft.
  • (, ), *, + → selects de charset. Skip de tres bytes.

¿CSI incompleto al final del buffer? Retorna temprano y deja el resto para el siguiente frame.

Rama de texto plano. Itera carácter por carácter y despacha códigos de control contra un cursor virtual (cx, cy):

  • LFcy++; si cy > CY2 llama term.scroll(1) y clampea. Esta es la línea más importante de todo el parser.
  • CRcx = 1; BScx--; TAB → redondea al siguiente stop de 8 columnas.
  • Bytes ≥ 0x80 → emite ?, luego consume bytes de continuación para no desincronizarse.
  • Todo lo demás → agrupa ASCII imprimible en un run y emite vía pwrite().
-- Parser xterm-lite en Lua (fragmento)
local function processOutput(text)
  local i = 1
  local len = #text
  while i <= len do
    local byte = text:byte(i)
    if byte == 0x1B then  -- ESC
      i = i + 1
      local next = text:byte(i)
      if next == 0x5B then  -- CSI: ESC[
        i = i + 1
        local csi_start = i
        while i <= len and (text:byte(i) < 0x40 or text:byte(i) > 0x7E) do
          i = i + 1
        end
        if i <= len then
          local params = text:sub(csi_start, i - 1)
          dispatch_csi(params, text:byte(i))
          i = i + 1
        end
      else
        i = i + 1  -- 2-byte escape, skip
      end
    elseif byte == 0x0A then  -- LF: la linea mas importante
      cy = cy + 1
      if cy > CY2 then term.scroll(1); cy = CY2 end
      i = i + 1
    else
      local run_start = i
      while i <= len and text:byte(i) >= 0x20 and text:byte(i) < 0x7F do
        i = i + 1
      end
      if i > run_start then pwrite(text:sub(run_start, i-1))
      else i = i + 1 end
    end
  end
end

Por Qué No Stripear los Escapes en el Servidor

Lo intenté. s:gsub("\27%[[%d;]*[A-Za-z]", "") es rápido y obvio y arruina todo. Claude Code es un TUI fullscreen: si strippeas las secuencias de posicionamiento y dejas solo el texto, obtienes cada frame apilado verticalmente hasta que la pantalla hace scroll más allá de la parte útil en ~400ms. Tienes que honrar los movimientos de cursor y los borrados, o la UI es inutilizable.

La Race Condition del Handshake que Nadie Documenta

Primer bug que me costó una tarde: el cliente CC:Tweaked se conectaba, el servidor enviaba los frames de bienvenida, y el cliente nunca los veía. La causa: http.websocket() de CC:Tweaked bloquea dentro de un loop pullEvent durante su propio handshake y silenciosamente se come cualquier evento websocket_message que llegue antes de que retorne.

La solución en el lado Python: después del upgrade HTTP, no enviar el saludo. Bloquear con timeout de 10 segundos hasta que el cliente mande su primer frame de resize, luego spawnear el PTY y enviar el saludo. El resize es siempre lo primero que emite startup.lua después de que http.websocket() retorna, así que es una señal perfecta de cliente listo.

# Python: esperar el primer resize antes de spawnear el PTY
cols, rows = 80, 24
conn.settimeout(10.0)
while True:
    opcode, payload = ws_recv_frame(conn)
    if opcode in (0x1, 0x2) and payload and chr(payload[0]) == '1':
        info = json.loads(payload[1:])
        cols = int(info.get('columns', cols))
        rows = int(info.get('rows', rows))
        break

master_fd, pid = spawn_shell(cols, rows, session_name)
ws_send(conn, f'1DevChefs {session_name}', send_lock)

Por Qué tmux en el Medio

Tres razones, todas sobre persistencia. Primero, sobrevivir descargas de chunks: sin tmux, alejarte de tu base mataría tu sesión claude. Con tmux, el PTY vive en el host y se readjunta en la reconexión exactamente donde lo dejaste. Segundo, compartir sesiones entre bloques: dos computadoras pueden adjuntarse a dev1 simultáneamente. Tercero, límite de seguridad limpio: ptyws.py usa un allowlist regex (^(dev|browser)[1-5]$) y lo único que el WebSocket puede hacer es adjuntarse a una de diez sesiones tmux predeclaradas.

El Wrapper de Espejado para Monitores

startup.lua no usa term.redirect() para pushear output al monitor porque ocultaría el output de la GUI propia. En cambio, sombrea term.setCursorPos, term.write, term.clear, term.clearLine, term.scroll y los setters de color con wrappers que llaman tanto el original como el periférico monitor. Escala de texto double-density (0.5) en una pared de monitores avanzados 5×3 da ~70 columnas — suficiente para el TUI completo de Claude Code.

Límite de Seguridad

El panel está ligado a 127.0.0.1 detrás de Basic Auth. La contraseña RCON vive en .env.local (gitignoreado). ptyws.py solo acepta conexiones en 127.0.0.1 y solo spawnea sesiones cuyo nombre hace match con ^(dev|browser)[1-5]$. Las sesiones tmux corren como usuario sin privilegios. No lo corras como root.

Conclusión

Lo que construí son básicamente tres archivos: ptyws.py (~320 líneas de Python, sockets crudos, sin dependencias), public/startup.lua (~990 líneas de Lua con bootstrap, selector de sesiones, parser xterm-lite y programa de tortuga), y los routes de API de Next.js como plano de control delgado sobre RCON y tmux. Sin mods. Sin código Fabric personalizado. El truco es que una vez que tienes un PTY en un extremo y una grilla de caracteres en el otro, un emulador xterm suficientemente bueno cabe en unos cientos de líneas de Lua. Y una vez que tienes eso, todo programa de terminal se convierte en un programa de Minecraft, incluido el que más me importa: Claude Code. Coloca una computadora. Bootéala. Elige dev1. Escribe claude. Empieza a construir.

compartir_artículo

LinkedIn Facebook X

artículos_relacionados