← 全部文档

编写自定义适配器

一个 adapter 封装一个助手 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]在助手产生内容时逐步生成规范化的内容块。
stop()—干净地关闭会话。

每一轮对话中,你的 stream() 都必须以一个 SessionEndBlock 结束;工具调用必须先发出 running,再发出 done/error 状态的 ToolCallBlock(并配一个对应的 ToolResultBlock)。有两个可选能力需要显式声明是否启用 — 你必须将每个标志设为 True 或 False:

标志若为 True,覆盖用途
provides_modelslist_models()新建会话时的模型选择器。
provides_usageget_usage()/status 中的花费 / token 统计。

一个最简适配器

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(用你助手自己的会话 id 调用它,以便 Terminull Service 重启后能恢复这段对话)以及 effort — 但只会传入你实际声明的那些参数,因此一个最简单的 def __init__(self, cwd=None) 依然可用。按需声明即可。

能力声明

以类属性的形式声明;会体现在 AgentInfoBlock 中,从而让应用显示或隐藏对应的界面元素。

属性在应用中的效果
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)对终端持有的会话记录进行只读追踪 → 实时镜像到应用中,不进行写入。
latest_sid_for_cwd(cwd)在终端交还后重新解析会话链的最新节点(--resume 可能会生成新的 id)。
terminal_resume_command(sid)在终端中恢复该会话所用的 shell 命令 → 支撑应用中的“在终端中继续”按钮。

完整流程如下:应用通过 scan_external_sessions() 展示外部 CLI 会话;点击其中一个即可将 Terminull 会话与之绑定。如果终端正持有该目录,Terminull Service 会挂起该会话(消息排队,并显示“已分离观察”状态),转为流式输出只读的镜像而不进行写入。当终端进入空闲状态,Terminull Service 会自动接管 — 重新解析会话链的最新节点,再清空排队的消息。反过来,“在终端中继续”会释放一个空闲的实时会话,并将其重新挂起为镜像。完整设计以及各适配器的支持情况矩阵见 docs/session-handoff.md。

Terminull Service 的核心部分设计为开放。源码结构、完整的内容块参考文档,以及交接设计都在仓库的 docs/ 目录中。如有疑问:hello@terminull.dev。