Написание собственного адаптера
Адаптер оборачивает CLI или сервер одного агента и преобразует его вывод в нормализованный поток блоков — потоковый текст, рассуждения, вызовы инструментов, карточки плана, diff'ы — так что в приложении все агенты выглядят одинаково. Один экземпляр адаптера соответствует одной интерактивной сессии с одним агентом. Встроенные адаптеры находятся в terminull_bridge/adapters/ (Claude Code, Codex, Cursor, Gemini, Kilo, OpenCode, Pi, Hermes, Droid); свой собственный можно добавить, не трогая ядро.
Контракт BaseAdapter
Унаследуйтесь от BaseAdapter и реализуйте четыре корутины. Контракт проверяется при импорте — если подкласс пропускает обязательное объявление, сразу возникает TypeError, поэтому адаптер не может незаметно унаследовать пустую заглушку.
| Метод | Возвращает | Ответственность |
|---|---|---|
start(session_id) | AgentInfoBlock | Запускает/подключает агента; возвращает его возможности через make_agent_info(). |
send(text, attachments=None) | — | Передаёт сообщение пользователя в работающую сессию. |
stream() | AsyncIterator[Block] | Выдаёт нормализованные блоки по мере их производства агентом. |
stop() | — | Корректно завершает сессию. |
Каждый ход вашего stream() должен заканчиваться SessionEndBlock, а вызовы инструментов должны выдавать сначала running, а затем done/error ToolCallBlock (плюс парный ToolResultBlock). Две опциональные возможности требуют явного отказа — каждый флаг нужно установить в True или False:
| Флаг | Если True, переопределить | Для |
|---|---|---|
provides_models | list_models() | Выбор модели при создании новой сессии. |
provides_usage | get_usage() | Статистика стоимости/токенов для /status. |
Минимальный адаптер
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() считывает ваши атрибуты cap_* и автоматически добавляет универсальные слэш-команды Terminull Service — стройте информационный блок через неё, а не описывайте возможности вручную. Ваш __init__ фильтруется по сигнатуре: Terminull Service предлагает cwd, model, binary, resume_session_id, on_session_id (вызовите его с собственным id сессии агента, чтобы перезапуск Terminull Service мог возобновить этот разговор) и effort — но передаёт только те параметры, что вы реально объявили, так что минимальный def __init__(self, cwd=None) тоже работает. Объявляйте то, что вам нужно.
Возможности
Объявляются как атрибуты класса; передаются в AgentInfoBlock, чтобы приложение показывало или скрывало соответствующий интерфейс.
| Атрибут | Эффект в приложении |
|---|---|
cap_images | В composer включается выбор изображений. |
cap_documents | Принимаются вложения файлов, не являющихся изображениями. |
cap_thinking | Будут отображаться блоки рассуждений. |
cap_permissions | Будут появляться карточки запроса разрешений. |
cap_subagents | Вызовы инструментов суб-агентов отображаются как вложенные группы. |
cap_interrupt | Кнопка остановки активна посреди хода. |
cap_input_during_turn | Composer остаётся активным во время генерации (иначе сообщения ставятся в очередь). |
cap_terminal | Может открывать терминал на устройстве. |
Обнаружение и установка без пересборки
Обнаружение реализуется в адаптере как classmethod detect() → DetectedAgent | None — один и тот же путь для встроенных и внешних адаптеров. Реализация detect() по умолчанию требует agent_id и, если задан cli_binary, проверяет через shutil.which() + --version. Переопределите её для проверок на основе HTTP или конфигурации.
Внешний адаптер — это просто модуль .py, помещённый в ~/.terminull/ext_adapters/, определяющий подкласс BaseAdapter как минимум с 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
Загрузчик импортирует каждый модуль с защитой — он выполняет ту же проверку контракта, отклоняет конфликты с встроенными id, логирует и пропускает всё некорректное, а валидные модули регистрирует. Дальше они обнаруживаются и создаются точно так же, как встроенные. Не нужно менять ядро, не нужно перезапускать ничего, кроме Terminull Service.
Где находится код. Встроенные адаптеры поставляются как редактируемый исходный код в terminull_bridge/adapters/*.py — даже в опубликованном, скомпилированном в байт-код Terminull Service эта папка остаётся обычным .py, так что вы можете читать их как рабочие примеры и скопировать один как отправную точку. Полный, готовый к копированию эталонный адаптер — документирующий каждый хук и полную сигнатуру __init__ выше — это examples/ext_adapter_example.py; скопируйте его в ~/.terminull/ext_adapters/ и отредактируйте. Контракт, который вы реализуете, — terminull_bridge/adapters/base.py.
Жизненный цикл при работе на двух поверхностях
На протяжении своего жизненного цикла сессией можно управлять с двух поверхностей — из нативного CLI агента в терминале и из Terminull. Правило простое: один автор, много наблюдателей — Terminull никогда не должен становиться вторым одновременным автором в разговоре, которым активно управляет терминал. То, как адаптер в этом участвует, зависит от того, как он удерживает агента между ходами:
| Тип | Между ходами | Примеры |
|---|---|---|
| Для каждого хода / без удержания | Запускает новый процесс на каждый ход в stream(), затем завершается — так что CLI свободен возобновить работу между ходами. | Codex, Cursor, Gemini, Droid, Pi |
| Постоянный | Удерживает агента (долгоживущий подпроцесс или сервер) между ходами — требует явного освобождения, прежде чем терминал сможет перехватить управление. | Claude Code, OpenCode, Kilo |
Чтобы передача управления проходила бесшовно, адаптер может реализовать несколько опциональных методов класса (по умолчанию все они означают «не поддерживается», так что базовый адаптер просто отказывается от передачи управления):
| Хук | Роль |
|---|---|
scan_external_sessions() | Находит сессии, которые этот агент запустил вне Terminull (читает его транскрипты на диске) → они появляются в боковой панели приложения. |
is_cwd_externally_owned(cwd, sid) | Управляет ли этой директорией/сессией сейчас нативный процесс? Задействует защиту от конфликта, чтобы Terminull приостанавливался вместо двойной записи. |
make_transcript_mirror(cwd, sid) | Чтение в реальном времени транскрипта сессии, которой владеет терминал, → зеркально отображается в приложении, без записи. |
latest_sid_for_cwd(cwd) | Заново определяет актуальную вершину цепочки после возврата управления из терминала (--resume может создать новый id). |
terminal_resume_command(sid) | Команда shell, возобновляющая сессию в терминале → лежит в основе кнопки приложения «Продолжить в терминале». |
Весь процесс целиком: приложение показывает внешние CLI-сессии через scan_external_sessions(); нажатие на одну из них привязывает к ней сессию Terminull. Если директорией владеет терминал, Terminull Service приостанавливает сессию (ставя сообщения в очередь и показывая состояние «отсоединённый наблюдатель») и вместо записи стримит зеркало только для чтения. Когда терминал переходит в простой, Terminull Service подключается автоматически — заново определяя вершину цепочки, а затем разгружая очередь. В обратную сторону кнопка «Продолжить в терминале» освобождает простаивающую активную сессию и снова переводит её в режим зеркала. Весь дизайн, вместе с матрицей поддержки по адаптерам, описан в docs/session-handoff.md.
Ядро Terminull Service задумано открытым. Структура исходного кода, полный справочник блоков и дизайн передачи управления находятся рядом, в docs/ репозитория. Вопросы: hello@terminull.dev.