← Toutes les docs

Écrire un adaptateur personnalisé

Un adaptateur encapsule un CLI ou un serveur d'agent et traduit sa sortie en un flux de blocs normalisé — texte en streaming, réflexion, appels d'outils, cartes de plan, diffs — pour que chaque agent paraisse identique dans l'appli. Une instance d'adaptateur équivaut à une session interactive avec un agent. Les adaptateurs intégrés se trouvent dans terminull_bridge/adapters/ (Claude Code, Codex, Cursor, Gemini, Kilo, OpenCode, Pi, Hermes, Droid) ; le vôtre peut être déposé sans toucher au cœur du système.

Le contrat BaseAdapter

Sous-classez BaseAdapter et implémentez quatre coroutines. Le contrat est vérifié à l'import — une sous-classe qui omet une déclaration requise lève immédiatement une TypeError, si bien qu'un adaptateur ne peut pas hériter silencieusement d'un no-op.

MéthodeRetourResponsabilité
start(session_id)AgentInfoBlockDémarre/connecte l'agent ; renvoie ses capacités via make_agent_info().
send(text, attachments=None)—Transmet un message utilisateur à la session en cours.
stream()AsyncIterator[Block]Produit des blocs normalisés au fur et à mesure que l'agent les génère.
stop()—Arrête proprement la session.

À chaque tour, votre stream() doit se terminer par un SessionEndBlock, et les appels d'outils doivent émettre un ToolCallBlock running puis done/error (plus un ToolResultBlock associé). Deux surfaces optionnelles nécessitent un opt-out explicite — vous devez fixer chaque indicateur à True ou False :

IndicateurSi True, remplacePour
provides_modelslist_models()Le sélecteur de modèle à la création de session.
provides_usageget_usage()Les statistiques de coût/tokens pour /status.

Un adaptateur minimal

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() lit vos attributs cap_* et ajoute automatiquement les commandes slash universelles de Terminull Service — construisez le bloc d'info via cette méthode plutôt qu'en fabriquant les capacités à la main. Votre __init__ est filtré par signature : Terminull Service propose cwd, model, binary, resume_session_id, on_session_id (à appeler avec l'id de session propre à votre agent, pour qu'un redémarrage de Terminull Service puisse reprendre cette conversation), et effort — mais ne transmet que ceux que vous déclarez réellement, si bien qu'un simple def __init__(self, cwd=None) fonctionne toujours. Déclarez ce dont vous avez besoin.

Capacités

Déclarées comme attributs de classe ; exposées dans l'AgentInfoBlock pour que l'appli affiche ou masque l'interface correspondante.

AttributEffet dans l'appli
cap_imagesSélecteur d'image activé dans le composeur.
cap_documentsPièces jointes non-image acceptées.
cap_thinkingLes blocs de réflexion s'affichent.
cap_permissionsLes cartes de permission s'affichent.
cap_subagentsLes appels d'outils des sous-agents s'affichent en groupes imbriqués.
cap_interruptBouton stop actif en plein tour.
cap_input_during_turnLe composeur reste actif pendant la génération (sinon les messages sont mis en file d'attente).
cap_terminalPeut ouvrir un terminal sur l'appareil.

Détection & installation par dépôt de fichier

La détection réside dans l'adaptateur sous la forme d'une classmethod detect() → DetectedAgent | None — le même chemin pour les adaptateurs intégrés et externes. Le detect() par défaut nécessite agent_id et, si cli_binary est défini, sonde via shutil.which() + --version. Redéfinissez-le pour des sondes basées sur HTTP ou une config.

Un adaptateur externe n'est qu'un module .py déposé dans ~/.terminull/ext_adapters/, définissant une sous-classe de BaseAdapter avec au moins 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

Le chargeur importe chaque module de manière protégée — il applique la même vérification de contrat, rejette les collisions avec les ids intégrés, journalise et ignore tout ce qui est malformé, et enregistre ceux qui sont valides. Ils sont ensuite détectés et instanciés exactement comme les adaptateurs intégrés. Aucune modification du cœur, aucun redémarrage à part celui de Terminull Service.

Où se trouve le code. Les adaptateurs intégrés sont livrés en source modifiable dans terminull_bridge/adapters/*.py — même dans le Terminull Service publié et compilé en bytecode, ce dossier reste en .py brut, pour que vous puissiez les lire comme des exemples concrets et en copier un comme point de départ. L'adaptateur de référence complet, prêt à copier-coller — documentant chaque hook et la signature complète du __init__ ci-dessus — est examples/ext_adapter_example.py ; déposez-en une copie dans ~/.terminull/ext_adapters/ et modifiez-la. Le contrat que vous implémentez est terminull_bridge/adapters/base.py.

Cycle de vie sur une exécution à deux surfaces

Une session peut être pilotée depuis deux surfaces — le CLI natif de l'agent dans un terminal et Terminull — au cours de sa vie. La règle est un seul rédacteur, plusieurs observateurs : Terminull ne doit jamais devenir un second rédacteur concurrent dans une conversation activement pilotée par le terminal. La façon dont un adaptateur participe dépend de la manière dont il retient l'agent entre les tours :

TypeEntre les toursExemples
Par tour / sans bailDémarre un nouveau processus à chaque tour dans stream(), puis se termine — le CLI est ainsi libre de reprendre la main entre-temps.Codex, Cursor, Gemini, Droid, Pi
PersistantRetient l'agent (un sous-processus ou serveur longue durée) entre les tours — nécessite une libération explicite avant qu'un terminal ne puisse reprendre la main.Claude Code, OpenCode, Kilo

Pour rendre le transfert de main transparent, un adaptateur peut implémenter quelques méthodes de classe optionnelles (toutes par défaut sur « non supporté », si bien qu'un adaptateur basique se contente de ne pas gérer le transfert) :

HookRôle
scan_external_sessions()Découvre les sessions que cet agent a démarrées hors de Terminull (lit ses transcriptions sur disque) → elles apparaissent dans la barre latérale de l'appli.
is_cwd_externally_owned(cwd, sid)Un processus natif pilote-t-il actuellement ce dossier/cette session ? Verrouille le garde-fou de contention pour que Terminull se mette en pause au lieu d'écrire en double.
make_transcript_mirror(cwd, sid)Lecture seule en continu de la transcription d'une session détenue par le terminal → reflétée en direct dans l'appli, sans écriture.
latest_sid_for_cwd(cwd)Recalcule la tête de lignage après une restitution par le terminal (--resume peut générer un nouvel id).
terminal_resume_command(sid)Commande shell qui reprend la session dans un terminal → alimente le bouton « Continuer dans le terminal » de l'appli.

Le flux, de bout en bout : l'appli affiche les sessions CLI externes via scan_external_sessions() ; en toucher une lie une session Terminull à celle-ci. Si le terminal détient le dossier, Terminull Service met en pause la session (met les messages en file d'attente et affiche un état « observateur détaché ») et diffuse un miroir en lecture seule au lieu d'écrire. Quand le terminal devient inactif, Terminull Service se rattache automatiquement — recalcule la tête de lignage, puis vide la file. À l'inverse, « Continuer dans le terminal » libère une session live inactive et la remet en pause en tant que miroir. La conception complète, avec la matrice de couverture par adaptateur, se trouve dans docs/session-handoff.md.

Le cœur de Terminull Service est destiné à rester ouvert. L'organisation des sources, la référence complète des blocs, et la conception du transfert de main se trouvent à côté, dans le docs/ du repo. Des questions : hello@terminull.dev.