← 모든 문서
커스텀 어댑터 작성하기
어댑터는 하나의 에이전트 CLI 또는 서버를 감싸 그 출력을 정규화된 블록 스트림 — 스트리밍 텍스트, 사고 과정, 도구 호출, 플랜 카드, 변경 사항 — 으로 변환하여, 모든 에이전트가 앱에서 동일하게 느껴지도록 합니다. 어댑터 인스턴스 하나는 한 에이전트와의 대화형 세션 하나와 같습니다. 내장 어댑터는 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] | 에이전트가 생성하는 대로 정규화된 블록을 yield합니다. |
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(Terminull Service 재시작 시 이 대화를 재개할 수 있도록 에이전트 자체 세션 id로 호출), effort를 제공하지만 실제로 선언한 것만 전달하므로, 최소한의 def __init__(self, cwd=None)도 잘 작동합니다. 필요한 것만 선언하세요.
기능(Capabilities)
클래스 속성으로 선언되며, AgentInfoBlock에 노출되어 앱이 해당 UI를 표시하거나 숨깁니다.
| 속성 | 앱에서의 효과 |
|---|---|
cap_images | 컴포저에서 이미지 선택기가 활성화됩니다. |
cap_documents | 이미지가 아닌 파일 첨부가 허용됩니다. |
cap_thinking | 사고 블록이 렌더링됩니다. |
cap_permissions | 권한 카드가 표시됩니다. |
cap_subagents | 서브 에이전트 도구 호출이 중첩된 그룹으로 렌더링됩니다. |
cap_interrupt | 턴 진행 중 정지 버튼이 활성화됩니다. |
cap_input_during_turn | 생성 중에도 컴포저가 계속 활성화됩니다(그렇지 않으면 메시지가 대기열에 쌓입니다). |
cap_terminal | 기기에서 터미널을 열 수 있습니다. |
감지 & 드롭인 설치
감지는 어댑터에 classmethod detect() → DetectedAgent | None로 구현됩니다 — 내장 어댑터와 외부 어댑터에 동일하게 적용되는 경로입니다. 기본 detect()는 agent_id를 요구하며, cli_binary가 설정되어 있으면 shutil.which() + --version으로 확인합니다. HTTP/설정 기반 확인이 필요하면 재정의하세요.
외부 어댑터는 ~/.terminull/ext_adapters/에 넣는 .py 모듈일 뿐이며, 최소 agent_id + display_name을 가진 BaseAdapter 서브클래스를 정의합니다.
# ~/.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) | 터미널이 소유한 세션의 트랜스크립트를 읽기 전용으로 tail합니다 → 쓰기 없이 앱에 실시간으로 반영됩니다. |
latest_sid_for_cwd(cwd) | 터미널이 넘겨준 뒤 계보의 head를 다시 확인합니다(--resume이 새 id를 만들 수 있습니다). |
terminal_resume_command(sid) | 터미널에서 세션을 재개하는 셸 명령입니다 → 앱의 "터미널에서 계속하기" 버튼을 지원합니다. |
전체 흐름은 다음과 같습니다: 앱은 scan_external_sessions()을 통해 외부 CLI 세션을 보여주고, 하나를 탭하면 Terminull 세션이 여기에 연결됩니다. 터미널이 해당 디렉터리를 소유하고 있다면 Terminull Service는 세션을 대기 상태로 두고(메시지를 대기열에 쌓고 "분리된 관찰자" 상태를 표시) 쓰는 대신 읽기 전용 미러를 스트리밍합니다. 터미널이 유휴 상태가 되면 Terminull Service는 자동으로 다시 연결합니다 — 계보의 head를 다시 확인한 뒤 대기열을 비웁니다. 반대로 "터미널에서 계속하기"는 활성 상태의 실시간 세션을 해제하고 다시 미러로 대기시킵니다. 어댑터별 지원 매트릭스를 포함한 전체 설계는 docs/session-handoff.md에 있습니다.
Terminull Service 코어는 열려 있도록 설계되었습니다. 소스 구조, 전체 블록 레퍼런스, 핸드오프 설계는 이 저장소의 docs/에 함께 있습니다. 문의: hello@terminull.dev.