カスタムアダプターを書く
アダプターは1つのエージェントCLIまたはサーバーをラップし、その出力を正規化されたブロックストリーム — ストリーミングテキスト、思考過程、ツール呼び出し、プランカード、差分 — へと変換します。これにより、アプリ上ではどのエージェントも同じように感じられます。1つのアダプターインスタンスは、1つのエージェントとの1つの対話セッションに対応します。組み込みアダプターはterminull_bridge/adapters/にあります(Claude Code、Codex、Cursor、Gemini、Kilo、OpenCode、Pi、Hermes、Droid)。独自のものはコア部分に触れずに追加できます。
BaseAdapter契約
BaseAdapterをサブクラス化し、4つのコルーチンを実装してください。この契約はインポート時に強制されます — 必須の宣言を省略したサブクラスは即座にTypeErrorを送出するため、アダプターが黙って何もしない実装を継承することはありません。
| メソッド | 戻り値 | 責務 |
|---|---|---|
start(session_id) | AgentInfoBlock | エージェントを起動・接続し、make_agent_info()経由でその機能を返します。 |
send(text, attachments=None) | — | 実行中のセッションへユーザーメッセージを渡します。 |
stream() | AsyncIterator[Block] | エージェントが生成するブロックを正規化してyieldします。 |
stop() | — | セッションをきれいにシャットダウンします。 |
各ターンでstream()は必ずSessionEndBlockで終える必要があり、ツール呼び出しはrunningの後にdone/errorのToolCallBlockを送出しなければなりません(対になるToolResultBlockも含む)。オプションの2つの機能は明示的なオプトアウトです — 各フラグを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 スラッシュコマンドを自動的に付加します — 機能を手作りするのではなく、これを経由してinfoブロックを構築してください。__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にあります。
2つの入口にまたがるライフサイクル
1つのセッションは、その生涯にわたって2つの入口 — ターミナル上のネイティブエージェントCLIとそしてTerminull — から操作されることがあります。ルールは書き手は1つ、観察者は複数です: Terminullは、ターミナルが現に操作している会話に対して、2つ目の同時書き手になってはいけません。アダプターがどう関与するかは、ターンとターンの間にエージェントをどう保持するかによって決まります:
| 種類 | ターンの間 | 例 |
|---|---|---|
| ターンごと / リースなし | 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) | ターミナルからの引き継ぎ後に系譜の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 まで。