不是所有 Agent 都是”本地 TUI 进程”。botmux 有两类非 TUI 接入:
- runner 型适配器(
mira/mir/codex-app):适配器的resolvedBin是process.execPath,worker spawn 的是一个 Node runner 脚本;输入走 stdin 控制行,输出走 stdout 里的 OSC 控制帧。对 worker 来说它仍然”长得像个 CLI 会话”,复用同一套空闲检测与卡片流(见 card-streaming)。 - Codex 混合 RPC 引擎(
codex-rpc-engine.ts):codex/traex 的 TUI 会话里,终端只当”显示器”,用户输入改走 app-server JSON-RPC,绕开 tmux 粘贴丢字节的终端层。
适配器注册面见 adapters。
Runner 协议:控制行进,OSC 帧出
输入(adapters/cli/runner-input.ts):worker 把每条消息编码成一行 ::botmux-<id>:<base64(JSON)>\n 写进 runner stdin。tmux 路径上必须分块(1KB/块)+ 节流(20ms),否则一次 send-keys 灌爆 pane pty 的 ~4KB N_TTY 缓冲,超时后无法证明字节是否落地。协议自带缓冲卫生:写前先发一个 Enter 冲刷上次失败写入遗留的半行(runner 只按换行清缓冲,半行会和下一条消息粘成坏帧);任何不确定写入返回 submissionDisposition: dirty_unknown,worker 据此围栏本代提交而不是盲目重试。
输出(adapters/cli/runner-control-channel.ts):runner 的显示文本与控制帧共享一条 PTY 字节流。控制帧格式 \x1b]777;botmux:<kind>:<base64>\x07(OSC 777),kind 有 thread(原生会话 id 绑定)、final(回合最终输出,带 startedAtMs/completedAtMs,可带 usage)、error 等。所有显示文本的 ESC 字节先替换成可见符 ␛,使控制帧边界结构性安全;RunnerControlDecoder 处理跨 PTY chunk 的半帧,超大/无终止候选直接打废而非无限缓冲。worker 侧 APP_RUNNER_OSC_CLI_IDS = {mira, mir}(worker.ts)启用该解码;codex-app 另走签名控制通道(见下文)。
三个 runner 骨架一致:读 stdin → 逐字符攒行(处理 Ctrl-C/退格)→ 控制行解码入队 → drainQueue 串行跑 turn → 每回合结束发 final 标记并重画 › 提示符(runner 的 readyPattern 就是 /›/)。
mira-runner.ts:Mira Web API(云端编排)
mira 适配器接 Mira(mira.bytedance.com)的云端搜索/编排 Agent,无本地可执行文件(registry 里 executable 为 undefined)。
- 鉴权:优先
MIRA_COOKIE_HEADER/MIRA_SESSION环境变量;否则用 sqlite3 直接读 Mira.app 的 Cookie 数据库(~/Library/Application Support/mira/Cookies),取mira_session等 cookie。 - 会话:
POST /chat/create(带 model/dataSources/topic)拿 Mira sessionId,发thread标记让 botmux 记住原生 id 供 resume(--mira-session-id)。 - 回合:
POST /chat/completion拿 SSE 流,解析title(首条打印[mira] <title>)与content事件累积 final text,finish事件收尾。模型解析三级回退:显式--model/MIRA_MODEL→ 用户设置 default_model → 模型元数据默认项。 - 输出净化(
mira-output.ts):剥掉<cis-ctrl>控制块(含 HTML 转义形态)、折叠空行;SSE 里没拿到正文时回退chat/messages/round/last历史接口(3 次退避重试)再提取最后一条 assistant 消息。
mir-runner.ts:本地 mircli Print Mode
mir 与 mira 是有意分开的两条路:mira 是云端编排(搜索/问答),mir 是本地 mircli 在本机干活(文件/bash 工具跑在用户机器上,依赖用户本地 miramcp bridge)。
选 Print Mode(mircli -p)而非驱动 TUI:没有启动确认提示、没有粘贴折叠、不需要会话状态 flush。每个 botmux turn spawn 一次:
mircli -p <content> --output-format text --session-id <sid> -y \
--append-system-prompt <local-runtime-context>
多轮连续性靠 mircli 自己的 --session-id 存储。关键配套:
- mir-local-runtime.ts:
ensureMiramcpBridgeStarted自动拉起本地 MCP bridge(探测 9801 端口、pid 文件、启动超时分级状态);ensureMiramcpSandboxAllows修补 bridge 沙盒配置把 workspace 路径加进允许清单;getMiraRuntimePaths给出真实 cwd/home 与逻辑别名。 - 运行时系统提示:告诉模型真实 cwd、本地工具(mira_local_bash 等)在独立 bridge 进程里执行、相对路径不可靠(每条 bash 命令必须
cd <cwd> &&前缀)——都是实测踩坑后的约束。 - prompt 解包(
mir-prompt.ts):normalizeMircliPrompt先拆掉 botmux 的路由信封,提取<user_message>并摘要 mention/角色信息再交给 mircli。 - stdout 捕获 → stripAnsi →
final标记。局限:投递走 stdout 而非botmux send,所以 mir 是被动参与者(能被 @ 但不能主动 @ 别人)。
codex-app-runner.ts:Codex App app-server(stdio JSON-RPC)
codex-app 把 Codex 桌面版的 app-server 协议接进飞书。runner spawn codex app-server --listen stdio://(AppServerClient,换行分隔 JSON-RPC),然后:
- 启动序列:
initialize(capabilities.experimentalApi: true)→initialized→ensureThread:有--thread-id走thread/resume,否则thread/start;90s 启动总闸,超时 exit 2。 - 回合模型:
turn/start参数由codex-app-turn.ts构造——approvalPolicy: never+sandboxPolicy: dangerFullAccess(botmux 本就 bypass 审批);支持 turn/steer:带codexAppSteerable显式授权的后续消息可在回合结束前 steer 进活跃 turn,形成”一个原生回合承载有序 accepted 组”,末位成员拥有真正的 final,前序成员结算为 superseded。身份关联用不可变的clientUserMessageId(botmux/Lark turn id),与 codex 原生 turn id 严格分离——原生完成通知只有在携带精确 client id 时才可被认领。版本门控:additionalContext≥ 0.135.0、clientUserMessageId≥ 0.136.0,老版本回落 legacy prompt。 - 服务端请求自动应答:
item/permissions/requestApproval、item/tool/requestUserInput、mcp elicitation 等一律自动批准/取消,codex 永不等人。 - token 统计:用量不走
turn/completed而骑thread/tokenUsage/updated通知,按原生 turn id 进TurnTokenUsageAccumulator(services/codex-app-token-usage.ts)累加,final 标记时 drain 附上usage;数据异常则 poison 并显式报”用量丢弃”,不塞零值。 - 签名控制通道:runner 的启动参数/私钥经
CODEX_APP_CONTROL_BOOTSTRAP_ENV一次性文件引导后即从 env 抹掉(app-server 及其工具会继承 env);运行期状态经 unix socket 签名上报 + OSC 标记双通道,防止模型输出的文本伪造控制帧。
codex-rpc-engine.ts + codex-rpc-lifecycle.ts:混合 RPC 输入
针对普通 codex/traex TUI 会话的可选增强(codexRpcInput):tmux 面板里照跑 codex --remote ws://... resume <threadId> 当纯渲染端,另起一个 codex app-server --listen ws://127.0.0.1:<port> 引擎进程(每会话一个,detached、进程组清理,孤儿标记目录 ~/.botmux/data/codex-rpc-app-servers),输入经 turn/start(带 ack 的 RPC)投递——绕开 codex 启动期/设置抖动时丢 bracketed paste 的终端层。
协作语义(实测验证):app-server 把线程事件广播给所有打开该线程的连接,所以引擎先 thread/start 并打出第一个 turn(空线程没有 rollout,TUI 无法 resume,必须先落盘),TUI 再 resume 进来实时渲染。daemon 重启时引擎 thread/resume + 面板重新 spawn 指向新端口——旧面板连着已死的上一代 app-server,reattach 会冻死 Web 终端。
生命周期全部 fail-closed(codex-rpc-lifecycle.ts):
codexRpcEligible门:仅 codex/traex + tmux 后端 + 非 adopt/沙盒/读隔离/disableCliBypass/wrapperCli/startupCommands;任何不满足退回普通粘贴路径,绝不静默变更能力或安全级别(RPC 硬编码 approvalPolicy=never,若绕过 disableCliBypass 门等于偷偷提权)。orchestrateCodexRpcInit四态状态机(accepted/ambiguous/resumed/not-engaged):fresh 首 turn 预发且绝不重排队(exactly-once);ambiguous 时明确告知用户”已发出未确认,不自动重发”。paneRunsRemoteTui:走 pane 进程树读真实 argv 判断存活面板是不是 RPC 的--remoteTUI(不信任 tmux pane_current_command,读不到就 fail-closed 当原生面板)。RpcEngagementFence单调围栏防并发 engage 互相覆盖;每请求 60s 超时防 wedged app-server 挂死 flush 队列。
共同设计
三种实现共享同一套信念:投递确认要可证明(ack/rollout/transcript 增长,而非 sleep 后假设成功)、回合归属用不可变 client id(原生 id 只做关联)、不确定就 fail-closed 并告知用户。这是 botmux 在终端字节流这种不可靠介质上做出可靠会话管的底层逻辑,会话锚定与接管的更多背景见 im-routing。