Escrevendo um adaptador personalizado
Um adaptador envolve uma CLI ou servidor de agente e traduz sua saída num fluxo normalizado de blocos — texto em streaming, raciocínio, chamadas de ferramentas, cards de plano, diffs — para que todo agente pareça igual no app. Uma instância de adaptador equivale a uma sessão interativa com um agente. Os adaptadores integrados ficam em terminull_bridge/adapters/ (Claude Code, Codex, Cursor, Gemini, Kilo, OpenCode, Pi, Hermes, Droid); o seu pode ser adicionado sem tocar no núcleo.
O contrato BaseAdapter
Crie uma subclasse de BaseAdapter e implemente quatro corrotinas. O contrato é validado na importação — uma subclasse que pula uma declaração obrigatória gera TypeError imediatamente, então um adaptador não pode herdar silenciosamente um comportamento vazio.
| Método | Retorna | Responsabilidade |
|---|---|---|
start(session_id) | AgentInfoBlock | Inicia/conecta o agente; retorna suas capacidades via make_agent_info(). |
send(text, attachments=None) | — | Envia uma mensagem do usuário para a sessão em execução. |
stream() | AsyncIterator[Block] | Emite blocos normalizados conforme o agente os produz. |
stop() | — | Encerra a sessão de forma limpa. |
A cada turno, seu stream() deve terminar com um SessionEndBlock, e chamadas de ferramentas devem emitir um ToolCallBlock running e depois um done/error (mais um ToolResultBlock correspondente). Duas superfícies opcionais exigem desativação explícita — você precisa definir cada flag como True ou False:
| Flag | Se True, sobrescreve | Para |
|---|---|---|
provides_models | list_models() | O seletor de modelo de nova sessão. |
provides_usage | get_usage() | Estatísticas de custo/tokens para /status. |
Um 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() lê seus atributos cap_* e adiciona automaticamente os comandos de barra universais do Terminull Service — monte o bloco de informações através dele em vez de montar as capacidades na mão. Seu __init__ é filtrado por assinatura: o Terminull Service oferece cwd, model, binary, resume_session_id, on_session_id (chame-o com o id de sessão do seu próprio agente para que um restart do Terminull Service consiga retomar essa conversa) e effort — mas só passa os que você realmente declarar, então um def __init__(self, cwd=None) mínimo já funciona. Declare o que você precisar.
Capacidades
Declaradas como atributos de classe; expostas no AgentInfoBlock para que o app mostre ou esconda a UI correspondente.
| Atributo | Efeito no app |
|---|---|
cap_images | Seletor de imagens habilitado no compositor. |
cap_documents | Anexos de arquivos que não são imagem são aceitos. |
cap_thinking | Blocos de raciocínio serão renderizados. |
cap_permissions | Cards de permissão vão aparecer. |
cap_subagents | Chamadas de ferramentas de subagentes são renderizadas como grupos aninhados. |
cap_interrupt | Botão de parar ativo no meio do turno. |
cap_input_during_turn | O compositor continua habilitado durante a geração (caso contrário, as mensagens entram na fila). |
cap_terminal | Pode abrir um terminal no dispositivo. |
Detecção & instalação plug-and-play
A detecção fica no adaptador como um classmethod detect() → DetectedAgent | None — o mesmo caminho para adaptadores integrados e externos. O detect() padrão exige agent_id e, se cli_binary estiver definido, testa com shutil.which() + --version. Sobrescreva-o para verificações baseadas em HTTP/config.
Um adaptador externo é só um módulo .py colocado em ~/.terminull/ext_adapters/ que define uma subclasse de BaseAdapter com pelo 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
O carregador importa cada módulo de forma protegida — ele roda a mesma verificação de contrato, rejeita colisões com ids integrados, registra em log e ignora qualquer coisa malformada, e registra os válidos. Eles são então detectados e instanciados exatamente como os integrados. Sem editar o núcleo, sem reiniciar nada além do Terminull Service.
Onde o código mora. Os adaptadores integrados vêm como código-fonte editável em terminull_bridge/adapters/*.py — mesmo no Terminull Service publicado, compilado em bytecode, essa pasta continua em .py puro para que você possa lê-los como exemplos prontos e copiar um como ponto de partida. O adaptador de referência completo, pronto para copiar e colar — documentando cada hook e a assinatura completa do __init__ acima — é examples/ext_adapter_example.py; coloque uma cópia em ~/.terminull/ext_adapters/ e edite. O contrato que você está implementando é terminull_bridge/adapters/base.py.
Ciclo de vida numa execução de duas superfícies
Uma sessão pode ser controlada a partir de duas superfícies — a CLI nativa do agente num terminal e o Terminull — ao longo da sua vida. A regra é um escritor, muitos observadores: o Terminull nunca deve se tornar um segundo escritor simultâneo numa conversa que o terminal está controlando ativamente. Como um adaptador participa depende de como ele mantém o agente entre os turnos:
| Tipo | Entre turnos | Exemplos |
|---|---|---|
| Por turno / sem locação | Inicia um processo novo a cada turno em stream() e depois encerra — deixando a CLI livre para retomar entre os turnos. | Codex, Cursor, Gemini, Droid, Pi |
| Persistente | Mantém o agente (um subprocesso ou servidor de longa duração) entre os turnos — precisa de uma liberação explícita antes que um terminal possa assumir o controle. | Claude Code, OpenCode, Kilo |
Para tornar a transferência de controle transparente, um adaptador pode implementar alguns métodos de classe opcionais (todos partem de "não suportado" por padrão, então um adaptador básico simplesmente não participa da transferência):
| Hook | Papel |
|---|---|
scan_external_sessions() | Descobre sessões que esse agente iniciou fora do Terminull (lê seus registros salvos em disco) → elas aparecem na barra lateral do app. |
is_cwd_externally_owned(cwd, sid) | Existe um processo nativo controlando esse diretório/sessão agora? Controla a proteção contra conflito para que o Terminull fique em espera em vez de escrever em duplicidade. |
make_transcript_mirror(cwd, sid) | Leitura somente do final do registro de uma sessão controlada pelo terminal → espelhada ao vivo no app, sem escrever nela. |
latest_sid_for_cwd(cwd) | Reresolve a origem da linhagem após o terminal devolver o controle (o --resume pode gerar um novo id). |
terminal_resume_command(sid) | Comando de shell que retoma a sessão num terminal → alimenta o botão "Continuar no terminal" do app. |
O fluxo, de ponta a ponta: o app mostra sessões de CLI externas via scan_external_sessions(); tocar em uma vincula uma sessão do Terminull a ela. Se o terminal for o dono do diretório, o Terminull Service estaciona a sessão (enfileirando mensagens e mostrando um estado de "observador desacoplado") e transmite um espelho somente leitura em vez de escrever. Quando o terminal fica ocioso, o Terminull Service reconecta automaticamente — reresolvendo a origem da linhagem e depois esvaziando a fila. Ao contrário, "Continuar no terminal" libera uma sessão ativa ociosa e a estaciona novamente como espelho. O design completo, com a matriz de cobertura por adaptador, está em docs/session-handoff.md.
O núcleo do Terminull Service é feito para ser aberto. A estrutura do código-fonte, a referência completa de blocos e o design de transferência de controle estão junto disso na pasta docs/ do repositório. Dúvidas: hello@terminull.dev.