Einen eigenen Adapter schreiben
Ein Adapter kapselt eine Agenten-CLI oder einen Server und übersetzt dessen Ausgabe in einen normalisierten Block-Stream — gestreamter Text, Denkprozesse, Tool-Aufrufe, Plan-Karten, Diffs —, damit sich jeder Agent in der App gleich anfühlt. Eine Adapter-Instanz entspricht einer interaktiven Session mit einem Agenten. Eingebaute Adapter liegen in terminull_bridge/adapters/ (Claude Code, Codex, Cursor, Gemini, Kilo, OpenCode, Pi, Hermes, Droid); dein eigener lässt sich einbinden, ohne den Kern anzufassen.
Der BaseAdapter-Vertrag
Leite von BaseAdapter ab und implementiere vier Coroutinen. Der Vertrag wird beim Import erzwungen — eine Unterklasse, die eine erforderliche Deklaration auslässt, wirft sofort TypeError, sodass ein Adapter keine No-op-Implementierung stillschweigend erben kann.
| Methode | Rückgabe | Verantwortung |
|---|---|---|
start(session_id) | AgentInfoBlock | Startet/verbindet den Agenten; liefert seine Capabilities über make_agent_info(). |
send(text, attachments=None) | — | Speist eine Nutzernachricht in die laufende Session ein. |
stream() | AsyncIterator[Block] | Liefert normalisierte Blöcke, sobald der Agent sie erzeugt. |
stop() | — | Fährt die Session sauber herunter. |
Jeder Turn deines stream() muss mit einem SessionEndBlock enden, und Tool-Aufrufe müssen einen running- gefolgt von einem done-/error-ToolCallBlock ausgeben (plus einem passenden ToolResultBlock). Zwei optionale Oberflächen sind explizites Opt-out — du musst jedes Flag auf True oder False setzen:
| Flag | Wenn True, überschreibt | Für |
|---|---|---|
provides_models | list_models() | Der Modell-Picker für neue Sessions. |
provides_usage | get_usage() | Kosten-/Token-Statistik für /status. |
Ein minimaler Adapter
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() liest deine cap_*-Attribute und stellt automatisch die universellen Slash-Commands von Terminull Service voran — bau den Info-Block darüber, statt Capabilities selbst zusammenzustricken. Dein __init__ wird nach Signatur gefiltert: Terminull Service bietet cwd, model, binary, resume_session_id, on_session_id (ruf es mit der eigenen Session-ID deines Agenten auf, damit ein Neustart von Terminull Service dieses Gespräch fortsetzen kann) und effort an — übergibt aber nur die, die du tatsächlich deklarierst, sodass ein minimales def __init__(self, cwd=None) trotzdem funktioniert. Deklariere, was du brauchst.
Capabilities
Als Klassenattribute deklariert; im AgentInfoBlock sichtbar gemacht, damit die App die passende UI zeigt oder versteckt.
| Attribut | Effekt in der App |
|---|---|
cap_images | Bildauswahl im Composer aktiviert. |
cap_documents | Anhänge außer Bildern werden akzeptiert. |
cap_thinking | Denk-Blöcke werden gerendert. |
cap_permissions | Berechtigungskarten erscheinen. |
cap_subagents | Sub-Agent-Tool-Aufrufe werden als verschachtelte Gruppen gerendert. |
cap_interrupt | Stopp-Button mitten im Turn aktiv. |
cap_input_during_turn | Composer bleibt während der Generierung aktiv (sonst werden Nachrichten in eine Warteschlange gestellt). |
cap_terminal | Kann ein Terminal auf dem Gerät öffnen. |
Erkennung & Drop-in-Installation
Erkennung sitzt am Adapter als classmethod detect() → DetectedAgent | None — derselbe Weg für eingebaute und externe Adapter. Das Standard-detect() erfordert agent_id und prüft, falls cli_binary gesetzt ist, per shutil.which() + --version. Überschreibe es für HTTP-/Config-basierte Prüfungen.
Ein externer Adapter ist einfach ein .py-Modul, das nach ~/.terminull/ext_adapters/ gelegt wird und eine BaseAdapter-Unterklasse mit mindestens agent_id + display_name definiert:
# ~/.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
Der Loader importiert jedes Modul abgesichert — er führt denselben Vertragscheck aus, weist Kollisionen mit eingebauten IDs ab, loggt und überspringt alles Fehlerhafte und registriert die gültigen. Sie werden dann genau wie eingebaute Adapter erkannt und instanziiert. Keine Kern-Änderung, kein Neustart von irgendwas außer Terminull Service.
Wo der Code liegt. Die eingebauten Adapter werden als editierbarer Quellcode unter terminull_bridge/adapters/*.py ausgeliefert — selbst im veröffentlichten, bytecode-kompilierten Terminull Service bleibt dieser Ordner reines .py, damit du sie als durchgearbeitete Beispiele lesen und einen als Ausgangspunkt kopieren kannst. Die vollständige, copy-paste-fertige Referenzimplementierung — die jeden Hook und die komplette obige __init__-Signatur dokumentiert — ist examples/ext_adapter_example.py; leg eine Kopie in ~/.terminull/ext_adapters/ und passe sie an. Der Vertrag, den du implementierst, ist terminull_bridge/adapters/base.py.
Lebenszyklus über einen Zwei-Oberflächen-Run
Eine Session kann über ihre Lebensdauer von zwei Oberflächen gesteuert werden — der nativen Agenten-CLI im Terminal und Terminull. Die Regel lautet ein Schreiber, viele Beobachter: Terminull darf nie zu einem zweiten gleichzeitigen Schreiber in einem Gespräch werden, das das Terminal gerade aktiv steuert. Wie ein Adapter dabei mitspielt, hängt davon ab, wie er den Agenten zwischen den Turns hält:
| Art | Zwischen Turns | Beispiele |
|---|---|---|
| Pro Turn / ohne Lease | Startet in stream() jeden Turn einen frischen Prozess und beendet sich dann — sodass die CLI dazwischen frei ist, fortzusetzen. | Codex, Cursor, Gemini, Droid, Pi |
| Persistent | Hält den Agenten (einen langlebigen Subprozess oder Server) über Turns hinweg — braucht eine explizite Freigabe, bevor ein Terminal übernehmen kann. | Claude Code, OpenCode, Kilo |
Für eine nahtlose Übergabe kann ein Adapter ein paar optionale Klassenmethoden implementieren (alle standardmäßig „nicht unterstützt“, sodass ein einfacher Adapter die Übergabe einfach abwählt):
| Hook | Rolle |
|---|---|
scan_external_sessions() | Entdeckt Sessions, die dieser Agent außerhalb von Terminull gestartet hat (liest seine Transkripte auf der Platte) → sie erscheinen in der Seitenleiste der App. |
is_cwd_externally_owned(cwd, sid) | Steuert gerade ein nativer Prozess dieses Verzeichnis/diese Session? Steuert den Kontentionsschutz, sodass Terminull pausiert statt doppelt zu schreiben. |
make_transcript_mirror(cwd, sid) | Nur-Lese-Ende des Transkripts einer vom Terminal gehaltenen Session → live in die App gespiegelt, ohne zu schreiben. |
latest_sid_for_cwd(cwd) | Löst den Lineage-Kopf nach einer Terminal-Rückgabe neu auf (--resume kann eine neue ID prägen). |
terminal_resume_command(sid) | Shell-Befehl, der die Session in einem Terminal fortsetzt → treibt den „Im Terminal fortsetzen“-Button der App an. |
Der Ablauf, Ende zu Ende: Die App zeigt externe CLI-Sessions über scan_external_sessions(); tippst du eine an, wird eine Terminull-Session daran gebunden. Gehört das Verzeichnis dem Terminal, pausiert Terminull Service die Session (stellt Nachrichten in eine Warteschlange und zeigt einen „Losgelöster Beobachter“-Zustand) und streamt stattdessen eine nur lesbare Spiegelung, statt zu schreiben. Wird das Terminal untätig, hängt sich Terminull Service automatisch wieder an — löst den Lineage-Kopf neu auf und leert dann die Warteschlange. Umgekehrt gibt „Im Terminal fortsetzen“ eine aktive Live-Session frei und pausiert sie wieder als Spiegelung. Das gesamte Design, mit der Coverage-Matrix pro Adapter, steht in docs/session-handoff.md.
Der Kern von Terminull Service soll offen sein. Quell-Layout, die vollständige Block-Referenz und das Übergabe-Design liegen daneben im docs/-Ordner des Repos. Fragen: hello@terminull.dev.