Escribir un adaptador personalizado
Un adaptador envuelve una CLI o servidor de agente y traduce su salida en un flujo de bloques normalizado — texto en streaming, razonamiento, llamadas a herramientas, tarjetas de plan, diffs — para que cada agente se sienta idéntico en la app. Una instancia de adaptador equivale a una sesión interactiva con un agente. Los adaptadores integrados viven en terminull_bridge/adapters/ (Claude Code, Codex, Cursor, Gemini, Kilo, OpenCode, Pi, Hermes, Droid); el tuyo se puede añadir sin tocar el núcleo.
El contrato de BaseAdapter
Crea una subclase de BaseAdapter e implementa cuatro corrutinas. El contrato se hace cumplir en el import — una subclase que se salta una declaración obligatoria lanza TypeError de inmediato, así un adaptador no puede heredar en silencio una implementación vacía.
| Método | Devuelve | Responsabilidad |
|---|---|---|
start(session_id) | AgentInfoBlock | Inicia/conecta el agente; devuelve sus capacidades vía make_agent_info(). |
send(text, attachments=None) | — | Envía un mensaje del usuario a la sesión en curso. |
stream() | AsyncIterator[Block] | Produce bloques normalizados a medida que el agente los genera. |
stop() | — | Cierra la sesión de forma limpia. |
Cada turno de tu stream() debe terminar con un SessionEndBlock, y las llamadas a herramientas deben emitir un ToolCallBlock en estado running y luego done/error (más un ToolResultBlock emparejado). Dos superficies opcionales requieren exclusión explícita — debes fijar cada flag en True o False:
| Flag | Si es True, anula | Para |
|---|---|---|
provides_models | list_models() | El selector de modelo de la nueva sesión. |
provides_usage | get_usage() | Estadísticas de costo/tokens para /status. |
Un adaptador mínimo
import asyncio, uuid
from terminull_bridge.adapters.base import BaseAdapter
from terminull_bridge.blocks import TextBlock, SessionEndBlock
class EchoAdapter(BaseAdapter):
# ── detection identity ──
agent_id = "echo"
display_name = "Echo"
cli_binary = "echo" # default detect() probes `echo --version` on PATH
# ── capabilities (single source of truth) ──
cap_thinking = False
cap_permissions = False
cap_interrupt = False
# ── optional surfaces: explicit opt-out required ──
provides_models = False
provides_usage = False
def __init__(self, cwd=None, model=None, **kw):
self._cwd = cwd
self._queue = asyncio.Queue()
async def start(self, session_id):
self._sid = session_id
return self.make_agent_info(name="Echo", version="1.0")
async def send(self, text, attachments=None):
await self._queue.put(text)
async def stream(self):
text = await self._queue.get()
# stream the reply, then close the turn
yield TextBlock(text=f"You said: {text}", done=True, id=str(uuid.uuid4()))
yield SessionEndBlock(reason="completed")
async def stop(self):
pass
make_agent_info() lee tus atributos cap_* y antepone automáticamente los comandos de barra universales de Terminull Service — construye el bloque de información a través de él en lugar de armar las capacidades a mano. Tu __init__ se filtra por firma: Terminull Service ofrece cwd, model, binary, resume_session_id, on_session_id (llámalo con el id de sesión propio de tu agente para que un reinicio de Terminull Service pueda reanudar esta conversación) y effort — pero solo pasa los que realmente declares, así un def __init__(self, cwd=None) mínimo sigue funcionando. Declara solo lo que necesites.
Capacidades
Se declaran como atributos de clase; se exponen en el AgentInfoBlock para que la app muestre u oculte la interfaz correspondiente.
| Atributo | Efecto en la app |
|---|---|
cap_images | Selector de imágenes habilitado en el compositor. |
cap_documents | Se aceptan adjuntos de archivos que no son imágenes. |
cap_thinking | Se renderizarán los bloques de razonamiento. |
cap_permissions | Aparecerán tarjetas de permiso. |
cap_subagents | Las llamadas a herramientas de sub-agentes se renderizan como grupos anidados. |
cap_interrupt | Botón de detener activo a mitad de turno. |
cap_input_during_turn | El compositor permanece habilitado durante la generación (de lo contrario los mensajes se encolan). |
cap_terminal | Puede abrir una terminal en el dispositivo. |
Detección & instalación tipo drop-in
La detección vive en el adaptador como un classmethod detect() → DetectedAgent | None — la misma ruta para adaptadores integrados y externos. El detect() por defecto requiere agent_id y, si cli_binary está definido, prueba con shutil.which() + --version. Sobrescríbelo para sondeos basados en HTTP o configuración.
Un adaptador externo es simplemente un módulo .py colocado en ~/.terminull/ext_adapters/ que define una subclase de BaseAdapter con al menos agent_id + display_name:
# ~/.terminull/ext_adapters/echo.py
from terminull_bridge.adapters.base import BaseAdapter
class EchoAdapter(BaseAdapter):
agent_id = "echo"; display_name = "Echo"; cli_binary = "echo"
provides_models = False; provides_usage = False
... # the four coroutines
El cargador importa cada módulo de forma protegida — ejecuta la misma verificación de contrato, rechaza colisiones con los ids integrados, registra en el log y omite todo lo malformado, y registra los válidos. Luego se detectan e instancian exactamente igual que los integrados. Sin editar el núcleo, sin reiniciar nada más que Terminull Service.
Dónde vive el código. Los adaptadores integrados se distribuyen como código fuente editable en terminull_bridge/adapters/*.py — incluso en el Terminull Service publicado y compilado a bytecode, esa carpeta se mantiene en .py plano para que puedas leerlos como ejemplos trabajados y copiar uno como punto de partida. El adaptador de referencia completo, listo para copiar y pegar — que documenta cada hook y la firma completa de __init__ de arriba — es examples/ext_adapter_example.py; coloca una copia en ~/.terminull/ext_adapters/ y edítala. El contrato que estás implementando es terminull_bridge/adapters/base.py.
Ciclo de vida a través de una ejecución de dos superficies
Una sesión puede manejarse desde dos superficies — la CLI nativa del agente en una terminal y Terminull — a lo largo de su vida. La regla es un escritor, muchos observadores: Terminull nunca debe convertirse en un segundo escritor simultáneo en una conversación que la terminal está manejando activamente. Cómo participa un adaptador depende de cómo mantiene el agente entre turnos:
| Tipo | Entre turnos | Ejemplos |
|---|---|---|
| Por turno / sin arrendamiento | Inicia un proceso nuevo en cada turno dentro de stream() y luego sale — así la CLI queda libre para reanudar en el intermedio. | Codex, Cursor, Gemini, Droid, Pi |
| Persistente | Mantiene el agente (un subproceso o servidor de larga duración) entre turnos — necesita una liberación explícita antes de que una terminal pueda tomar el control. | Claude Code, OpenCode, Kilo |
Para que la transferencia sea fluida, un adaptador puede implementar unos pocos métodos de clase opcionales (todos con valor por defecto de "no soportado", así un adaptador básico simplemente se excluye de la transferencia):
| Hook | Rol |
|---|---|
scan_external_sessions() | Descubre sesiones que este agente inició fuera de Terminull (lee sus transcripciones en disco) → aparecen en la barra lateral de la app. |
is_cwd_externally_owned(cwd, sid) | ¿Hay un proceso nativo manejando actualmente este directorio/sesión? Controla la protección contra contención para que Terminull se detenga en vez de escribir por duplicado. |
make_transcript_mirror(cwd, sid) | Cola de solo lectura de la transcripción de una sesión propiedad de la terminal → se refleja en la app en vivo, sin escribir. |
latest_sid_for_cwd(cwd) | Vuelve a resolver la cabeza del linaje tras devolver el control desde una terminal (--resume puede generar un id nuevo). |
terminal_resume_command(sid) | Comando de shell que reanuda la sesión en una terminal → alimenta el botón "Continuar en terminal" de la app. |
El flujo, de principio a fin: la app muestra sesiones de CLI externas vía scan_external_sessions(); tocar una vincula una sesión de Terminull a ella. Si la terminal es dueña del directorio, Terminull Service detiene la sesión (encolando mensajes y mostrando un estado de "observador desconectado") y transmite un reflejo de solo lectura en lugar de escribir. Cuando la terminal queda inactiva, Terminull Service se reconecta automáticamente — resolviendo de nuevo la cabeza del linaje y luego vaciando la cola. A la inversa, "Continuar en terminal" libera una sesión en vivo inactiva y la vuelve a detener como reflejo. El diseño completo, con la matriz de cobertura por adaptador, está en docs/session-handoff.md.
El núcleo de Terminull Service está pensado para ser abierto. La estructura del código fuente, la referencia completa de bloques y el diseño de transferencia viven junto a esto en el docs/ del repositorio. Preguntas: hello@terminull.dev.