← 全部文档
编写自定义适配器
一个 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_models | list_models() | 新建会话时的模型选择器。 |
provides_usage | get_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。