Files
pebrel/docs/runtime-control-api.md
T
kuddev b67ee8fd62 docs: describe terminal conventions without naming other products
Replace references to specific terminal products in comments and the runtime
API document with neutral descriptions of the shared convention. The technical
facts stay; only the product names go.
2026-08-28 22:17:09 +08:00

24 KiB
Raw Blame History

Nebula Runtime Control API v1

Nebula 的运行时控制面把 GUI、CLI、Agent 与未来插件统一到同一个状态权威。CLI 不会直接 读取窗口内部结构,也不会从终端标题猜测 Pane 状态;它和其他客户端一样,通过本机回环 连接发送带版本的 JSON Lines 请求,所有窗口、Tab 与 PTY 写操作再进入当前 UI 壳的 owner 线程执行;Git worktree 准备留在 Runtime 客户端工作线程,不阻塞 GPUI。

协议 Schema:runtime-api-v1.schema.json。

为什么先做这个

窗口、标签、Pane、AI 任务状态已经存在,但此前对外只有 Unix 配置 IPC 和 Windows PING/ATTACH 单实例协议。继续分别添加 CLI、Agent、插件入口会复制状态读取、目标选择、 错误处理和权限边界。统一控制面后,新的消费者只需复用协议,不再进入 GUI 或 PTY 私有结构。

它直接带来四类价值:

  • 自动化可以可靠读取 Window/Tab/Pane/TaskState,不再 OCR 屏幕或解析标题。
  • Agent 可以聚焦、开标签、分屏、发送一行纯文本 Prompt,并等待语义状态。
  • GUI 与外部客户端看到同一份 snapshot 和单调 revision,问题可以复现和审计。
  • 后续插件、MCP、远程运行时可以在版本、权限和兼容错误已经明确的边界上继续建设。

环境契约

Nebula 打开的每个本地 pane 都带上下面这些变量。pane 里的进程据此回答"我在哪、控制面在哪", 不必扫进程树、读端口文件或 grep 源码:

变量 含义
TERM_PROGRAM=nebula 外面这层终端是 Nebula。沿用终端生态的事实标准入口,第三方工具的既有识别逻辑不必改代码
TERM_PROGRAM_VERSION Nebula 版本
NEBULA_PANE_ID 调用者所在 pane。AI hook 用同一个值给回合状态定位 tab 圆点
NEBULA_CLI 提供控制面的那个可执行文件的绝对路径。便携版不一定在 PATH 上,所以给精确路径
NEBULA_BIN_DIR 上者所在目录,同时被前置到 PATH,因此裸 nebula 也可用
NEBULA_PANE_REMOTE=1 这是 SSH pane。见下

实现在 nebula_app/src/agent_env.rs,两个壳共用同一个 apply()。三条设计约束:

  1. 只进本地 PTY。 SSH pane 注入的是 NEBULA_PANE_REMOTE=1,不给 NEBULA_CLI/NEBULA_BIN_DIR ——远端主机上没有这个二进制,把本机绝对路径送过去只会造出一个"变量有了但永远执行不了"的伪能力, 还会绕开远端 pane 不得取用本地上下文的护栏。
  2. 幂等。 在 Nebula 的 pane 里再开一个 Nebula 是常规操作(嵌套 shell、wsl.exe、隔离实例)。 PATH 前置与 WSLENV 追加都按值判重,套多少层环境块都不增长。
  3. WSL 透传。 WSLENV 里带 NEBULA_CLI/p、NEBULA_BIN_DIR/p——/p 让 WSL 把 Windows 路径翻成 /mnt/...,否则来宾 shell 拿到的是一个执行不了的 D:\… 字面量。合并以环境表里的 现值为基准,因此与 shell_detect::wsl_cwd_report_env 的 cwd 上报条目互不覆盖。

runtime.describe 的 env 段回报这份契约(变量名不必由客户端硬编码),并带 features: ["env.pane_identity", "cli.resource_verbs"] 供旧版本探测后回落。

环境契约解决的是可达性,不是该不该调。后者属于 Skill (docs/skills/nebula-runtime/SKILL.md)。两者不能互相替代:没有契约,Skill 里写的命令是空头 承诺;把发现层全押在 Skill 上(用户得手工安装一段提示词)则是不可靠的单点——所以 nebula env 本身也是发现入口,见下节。

CLI

资源 + 动词

面向"模型在 pane 里临时要派个活儿"这一种用法。命名用 CLI 界的通用惯例(资源在前、动词在后, 同 kubectl / docker / gh)而不是自造词:别人看一眼就知道在做什么,比"短"更重要。

nebula env --pretty                              # 我是谁、控制面在哪、有哪些命令

nebula pane list                                 # 所有 pane 压平成一维行
nebula pane read 17 --lines 80                   # 读某个 pane 的 Grid 尾部
nebula pane send 17 "cargo test" --wait          # 写一行并回车,然后等它跑完
nebula pane paste 17 --from-file task.txt --wait # 以 bracketed paste 发送有界多行文本
nebula pane wait 17 --after-seq 41               # 等某个 pane 静下来
nebula pane exec 17 -- cargo test                # 独立非 TTY 子进程,不改交互 shell 状态
nebula pane zoom 17 --zoomed true                # 显式缩放,不用 toggle
nebula pane resize 17 0.60                       # 修改直接父分屏中的目标占比

nebula agent list                                # 只有 AI CLI 的 pane
nebula agent send codex "修复登录回归" --wait    # 派任务并提交,然后等这一轮结束
nebula agent paste codex --from-file task.txt    # generation 绑定的多行任务输入
nebula agent read codex --lines 80               # 读它最近打印了什么
nebula agent wait codex --after-seq 41           # 等它这一轮结束

nebula window close 3                            # 关闭空闲窗口
nebula tab rename 2 tests --window 3             # 按窗口内零基索引重命名 tab
nebula tab move 2 0 --window 3                   # 同一窗口内移动 tab

它们是完整协议的薄别名:同一个请求函数、同一套响应信封、同样的 generation 与 after_seq 竞态保护,短的只是命令行,不是语义。

pane 与 agent 分成两个资源不只是为了好读——它们走的是不同的协议方法。Agent 路径带 generation 绑定(Codex 退出重开后不会把任务投给新会话),pane 路径没有这层保护。用两个资源名把 这条边界摆在命令行上,比塞进一个参数再靠前缀区分要难错得多:pane 用 nebula pane list 给出的 数字 id,Agent 用 nebula agent list 给出的名字或稳定 id。

nebula env 是发现层的实体:环境那半段不依赖 runtime,即使控制面没起来、端口文件过期、 或这根本不是 Nebula 的 pane,命令仍然成功返回并如实说明缺什么。一个探测命令若在"没连上"时整体 失败,调用方唯一能学到的就是"不知道",只好去猜。它还随响应回报完整命令清单,每条给可直接 复制执行的样例而不是抽象签名——模型照抄一条完整命令的成功率远高于自己按参数表拼装。

--wait 的基线取自提交后的那张快照。用它当 after_seq 才让随后的等待意味着"又静下来了"而 不是"本来就是静的",这正是把提交前的 idle 误判成"已完成"的那个经典竞态。--no-submit 与 --wait 是逻辑矛盾(没提交等什么),由参数解析直接拒绝,不会执行一半再静默停下。

完整协议

nebula ctl describe --pretty
nebula ctl snapshot --pretty
nebula ctl orchestrate --file workflow.json --timeout-ms 30000 --pretty
nebula ctl agents --pretty
nebula ctl agent-fork --window <WINDOW_ID> --source-pane <PANE_ID> --name login-fixer --kind codex --pretty
nebula ctl agent-get --agent login-fixer --pretty
nebula ctl agent-prompt --agent login-fixer --generation 1 --text "修复登录回归" --pretty
nebula ctl agent-wait --agent login-fixer --generation 1 --state settled --timeout-ms 300000 --pretty
nebula ctl agent-read --agent login-fixer --generation 1 --lines 120 --pretty
nebula ctl read --window <WINDOW_ID> --pane <PANE_ID> --lines 120 --pretty
nebula ctl procs --window <WINDOW_ID> --pane <PANE_ID> --pretty
nebula ctl send-key --window <WINDOW_ID> --pane <PANE_ID> --key c --control --pretty
nebula ctl run --window <WINDOW_ID> --pane <PANE_ID> --command "cargo test" --pretty
nebula ctl exec-pane --window <WINDOW_ID> --pane <PANE_ID> -- cargo test --workspace
nebula ctl focus --window <WINDOW_ID> --pane <PANE_ID>
nebula ctl new-tab --window <WINDOW_ID>
nebula ctl split --window <WINDOW_ID> --direction right
nebula ctl prompt --window <WINDOW_ID> --pane <PANE_ID> --text "检查当前构建" --wait settled
nebula ctl wait --window <WINDOW_ID> --pane <PANE_ID> --state attention --timeout-ms 300000
nebula ctl subscribe --since <REVISION>

一次性命令输出一个完整响应 Envelope。subscribe 输出 JSON Lines:第一行是订阅确认,随后 每行是一个 runtime.snapshot 事件。语义内容未变化时不增加 revision,也不发送重复事件。

Pane ID 当前在 Window 内稳定,而不是进程内全局唯一。存在多个 Window 时,应同时传 window_id 与 pane_id;省略 Window 且目标不唯一会得到 ambiguous_target,不会猜测。

方法

方法 用途 关键参数
runtime.describe 读取应用版本、协议版本和能力列表 无
runtime.snapshot 读取完整运行时投影 无
runtime.orchestrate 一次提交强类型布局、PTY、命令与 Agent 首任务工作流 steps, on_error?
events.subscribe 从 revision 开始订阅状态变化 since_revision?
agents.list 只列出 Nebula 已识别的 Agent Pane、会话身份、状态和证据来源 window_id?
agent.start 在新 Tab 或指定既有 Pane 中启动命名 Agent window_id?, pane_id?, name, kind, cwd?, resume_session_id?
agent.fork 事务化创建独立 Git branch/worktree,再启动命名 Agent source_pane_id?/source_cwd?, name, kind, branch?, base?, path?, allow_dirty_source?
agent.get 按稳定 id 或名称解析 Agent generation 与 worktree provenance agent, generation?
agent.prompt 向同一 Agent generation 发送纯文本 Prompt agent, generation?, text, submit?
agent.paste 向同一 Agent generation 发送受控 bracketed-paste 文本 agent, generation?, text, submit?
agent.read 读取命名 Agent 所在 Pane 的真实 Grid 尾部 agent, generation?, lines?
agent.wait 等待同一 Agent generation 的状态跃迁;被替换/退出即明确失败 agent, generation, state, timeout_ms, after_seq?
window.create 创建新窗口 无
window.close 关闭空闲窗口;忙碌 Pane 返回显式确认错误 window_id?
window.focus 聚焦窗口或 Pane window_id?, pane_id?
tab.new 创建默认 Shell 标签 window_id?
tab.close 按窗口内零基索引关闭空闲 Tab window_id?, tab_index
tab.rename 设置或清除 Tab 自定义名称 window_id?, tab_index, name
tab.move 在同一窗口内移动 Tab window_id?, tab_index, to_index
pane.split 从当前或指定 Pane 向右/向下分屏 window_id?, pane_id?, direction
pane.close 关闭空闲 Pane;忙碌 Pane 返回显式确认错误 window_id?, pane_id
pane.zoom 幂等设置 Pane 所在 Tab 的显式缩放状态 window_id?, pane_id, zoomed
pane.resize 设置目标 Pane 在直接父分屏中的占比 window_id?, pane_id, ratio
pane.prompt 写入一行纯文本,可追加 Enter window_id?, pane_id, text, submit
pane.paste 通过 bracketed paste 写入有界 UTF-8 文本 window_id?, pane_id, text, submit
pane.read 从真实终端 Grid 尾部读取最近逻辑行 window_id?, pane_id, lines
pane.procs 读取本地 PTY shell 为根的真实进程树 window_id?, pane_id
pane.send_key 按当前终端模式编码受限的命名控制键 window_id?, pane_id, key, modifiers?, repeat?
pane.run 运行单行命令,并以 OSC 133 返回真实 exit code window_id?, pane_id, command, wait?, timeout_ms?
pane.exec 在 Pane 的本地/WSL cwd 启动独立非 TTY argv 并捕获双流 window_id?, pane_id, argv, timeout_ms?, max_output_bytes?
pane.wait 等待 Pane 到达语义状态 window_id?, pane_id, state, timeout_ms, after_seq?

pane.prompt 有意拒绝换行、ESC 和其他控制字符,并限制为 32 KiB。它是 Prompt 接口,不是 任意终端字节注入接口。控制键走 pane.send_key:只开放命名键,字母必须配 control=true,repeat 上限 64;API 不接受任意 bytes 或 ANSI 字符串。

pane.paste 专用于确实需要保留换行的输入:只接受 UTF-8,限制 32 KiB,拒绝 ESC、NUL 与危险控制字符,并要求目标终端已启用 bracketed-paste。SSH Pane 明确拒绝本地文件/文本 转发。资源 CLI 可从字面文本、--stdin 或 --from-file 读取,但协议边界始终只接收验证后的 文本,不接收本地路径或句柄。

单请求编排

runtime.orchestrate 把模型已经确定的终端操作留在 Runtime 内执行,避免模型在每个步骤之间 重复读取 snapshot、搬运 Pane ID 和再次推理。它不是通用脚本语言:每个步骤都是 Schema 中 列出的强类型 op,步骤引用也是 { "step": "right", "field": "pane_id" },不执行字符串 模板、任意方法名或嵌套 Runtime 请求。

{
  "steps": [
    { "id": "right", "op": "split", "direction": "left_right" },
    {
      "id": "weather", "op": "agent_launch",
      "target": { "step": "right", "field": "pane_id" },
      "name": "weather", "kind": "claude",
      "initial_prompt": "查询并简要回答今天的天气"
    },
    {
      "id": "bottom", "op": "split",
      "target": { "step": "right", "field": "pane_id" },
      "direction": "top_bottom"
    },
    {
      "id": "formula", "op": "agent_launch",
      "target": { "step": "bottom", "field": "pane_id" },
      "name": "formula", "kind": "codex",
      "initial_prompt": "输出几组复杂数学公式供终端渲染测试"
    }
  ],
  "on_error": "stop"
}

首个无 target 的 split 相对当前聚焦 Pane 执行,因此常见自然语言布局不需要先请求 snapshot。后续步骤只引用前面成功步骤的 receipt;未来引用、不存在的步骤、重复 ID、未知字段 都在任何 UI 动作发生前被拒绝。普通 Pane 不做隐式回滚:中途失败时已完成动作保留,receipt 返回 failed_step、逐步 error 和 partial,调用者可从失败点续作。

agent_launch 是“绑定既有 Pane 启动 Agent + ready 握手 + 投递首任务”的原子产品语义。 Runtime 先启动 workflow 中的所有 Agent,再并行等待各自被 registry 观察到且离开启动中的 running 状态,随后投递 initial_prompt。这让多个 Claude/Codex 的冷启动互相重叠,同时 避免启动命令尚在 submit barrier 中时第二次输入触发 input_in_progress。成功 receipt 只返回 window_id、pane_id、agent_id、generation、ready 状态和 submitted 等必要字段,不附带 每一步的完整 snapshot。

命名 Agent 与隔离 worktree

agent.start 提供稳定的 agent_id + generation + name。冷启动命令只开放经过各 Provider 官方 CLI 文档核实的 claude、codex、opencode、cursor、pi、omp 与 kimi;其中 Cursor 的实际可执行文件名是 agent。恢复/会话分叉只在对应 Provider 有已核实语法时出现, 不会由显示名或检测 slug 猜命令。agent.fork 在此基础上增加 Git 隔离,完整顺序是:

  1. 由 source_pane_id 从 RuntimeHub 权威 snapshot 解析 cwd;没有 Pane 时必须提供绝对 source_cwd。SSH Pane 明确返回 remote_worktree_unsupported。
  2. 校验 Git、source dirty 状态、base commit、branch 与目标路径。默认拒绝 dirty source; 只有调用者明确传 allow_dirty_source: true 才跳过该检查。
  3. 在 Runtime 工作线程创建新 branch 与 worktree。默认 branch 为 nebula/<agent-slug>,默认 目录为主仓库同级的 <repo>-worktrees/<agent-slug>;既存 branch/path 都返回冲突,不覆盖。
  4. 把新 worktree 作为 cwd 交给真实 Tab/PTY 创建链,注册 Agent,再发送经过验证的启动命令。

成功响应和之后的 agent.get 都包含同一份 worktree:repo_root、source_root、path、 branch、base_commit、created。创建 Tab 或启动 Agent 明确失败时,Nebula 只删除本次事务 确认创建成功的 worktree 与 branch;已有目录、已有分支和用户其他 worktree 不在回滚范围。 若 UI dispatch 超时,结果处于未知态,服务端返回 runtime_timeout、 details.cleanup_deferred=true 和 worktree provenance,并保留 checkout,避免晚到的 Tab 使用 一个刚被删除的 cwd。

agent.fork 只创建隔离环境并启动 Agent,不抢跑派发任务。调用者取得响应中的 generation 后, 再用 agent.prompt 派活、agent.wait 等状态、agent.read 取结果;这样 CC 或另一个 Agent 能把 多个独立 worktree 作为并行工位调度,而不会让它们同时修改同一个工作树。

进程、控制键与真实命令结果

pane.procs 在 Windows 通过 Toolhelp 从 PTY shell pid 遍历真实后代进程。原生 SSH 只看得到 本地传输进程,因而明确返回 remote_process_unavailable,不会冒充远端进程树。

pane.run 依赖 Shell integration 的 OSC 133 CommandStart/CommandDone。只有观察到 CommandDone 携带的真实 exit code 才返回 finished/failed;没有集成或没有 exit code 时 返回 exit_code_unavailable、run_start_timeout 或 run_aborted,绝不把未知结果伪造成 0。

pane.exec 与 pane.run 是两种刻意分开的执行语义:它直接接收 argv,不经过 shell 展开, 在 Pane 当前上报的本地 cwd 中启动独立 non-TTY child,不写入 Grid、history 或交互 shell 环境。WSL Pane 通过对应 distribution 和 guest cwd 执行;SSH Pane 返回 remote_exec_unsupported。stdout/stderr 始终并行排水,每条默认最多保留 1 MiB、可配置上限 16 MiB;响应的 stdout/stderr 是直接字符串,capture 分别报告 encoding、总字节数、保留 字节数和截断状态。超时会回收整个子进程树,并保留已捕获输出与 timed_out: true。

Agent 状态与终端读取

RuntimePane.agent 只在 Nebula 能把当前程序归一为已知 AI CLI 时出现;普通长命令不会被 误列为 Agent。Agent 对象包含规范化 kind、显示名、hook 上报的可选 session_id,以及 state_source:

  • hook:CLI hook 的生命周期边沿,是最高置信度事实。
  • screen:声明式屏幕规则补偿漏失 hook;state_rule 给出命中的规则 id。
  • process:只确认进程身份,不能把它提升成“已完成”或“需要批准”的强结论。

侧栏、托盘、runtime.snapshot、agents.list、pane.wait 共用同一个 RuntimeTaskState 投影。优先级为失败、attention、等待输入、运行、完成、空闲; RuntimeHub 再对真正的状态跃迁盖 state_change_seq,因此外部客户端不需要自行解析标题或 终端文本来猜生命周期。

pane.read 直接锁定 pane 的 Term,通过 bounds_to_string 读取 Grid/scrollback;它不使用 截图或渲染快照,也不会改变用户的滚动位置、选区和光标。范围固定锚定 buffer 底部,所以用户 正翻看历史时结果仍稳定。lines 默认 120,范围 1..=2000,响应最多 1 MiB,并返回:

  • requested_lines / returned_lines:请求与实际返回的终端逻辑行数。
  • history_available:当前仍保留的 scrollback 行数。
  • truncated:更早的 buffer 内容或超出响应字节上限的内容未返回。
  • task_state、exited、exit_reason?:读取时的生命周期边界。

SSH pane 只有进入 Ready 后允许读取或写入。解析、连接、认证、打开 Shell 或 Failed 阶段会 返回 ssh_not_ready,避免把密码提示和连接错误屏误当成远端 Agent 的正常输出。

默认 GPUI 产品当前只有一个 workspace window,稳定 window_id 为 1。该窗口上的 snapshot/focus/tab.new/pane.split/pane.prompt/pane.read 都操作真实 workspace;GPUI 尚未建立 第二个拥有独立 hook/runtime 接收器的 workspace,因此 window.create 明确返回 runtime_unavailable,不会伪造一个新窗口 id。旧 winit 壳仍支持真实多窗口创建。

等待语义与 state_change_seq

每个 Pane 都带一个单调递增的 state_change_seq,只在 task_state 真正发生跃迁时 +1。它 存在的原因是「等待空闲」本身有竞态:命令提交后 Shell 需要几十毫秒才会转入 running,若 此时直接查询状态,Pane 仍是提交前的 idle,等待会立刻返回,调用方误以为命令已经跑完。 默认 GPUI 壳在 pane.prompt 成功提交 Enter 的同步动作里先建立 running 边沿,再由真实 Shell/hook 结束事件归位;因此即使命令在 120ms Runtime pump 的两拍之间完成,--wait 也能 观察到提交后的新 state_change_seq,不会把提交前的 idle 当成结果。

正确用法是先取基线、再等待跃迁:

  1. 发 pane.prompt,从响应的 snapshot 中读出该 Pane 的 state_change_seq。
  2. 把它作为 after_seq 传给 pane.wait,服务端就只承认 state_change_seq > after_seq 的观察结果。

nebula ctl prompt --wait 已经在内部串好这两步。独立调用 nebula ctl wait 时需自己传 --after-seq; 省略则退回「立即匹配当前状态」的旧语义,仅适合观察一个已在运行的 Pane。

计数器按 (窗口, Pane) 记账,因为 Pane ID 只在窗口内唯一;序号从 1 开始,0 不是合法基线。 runtime.describe 的 features 含 pane.wait.after_seq 时表示服务端支持该参数——旧版本会 静默忽略未知参数并保留竞态,所以有严格要求的客户端应当检查这个特性串而非仅看 capabilities。

兼容与错误

请求必须声明 protocol: "nebula.runtime" 和 version: 1。协议名或版本不匹配时,服务端 返回 protocol_version_mismatch,并在 details.supported_versions 中列出可用版本。

常见机器可读错误码包括:

  • invalid_request:Envelope 或 JSON 无效。
  • invalid_params:参数类型、Prompt 内容或超时范围无效。
  • invalid_reference:编排步骤引用了未来、失败或不含目标字段的步骤。
  • method_not_found:方法不存在。
  • target_not_found:Window 或 Pane 已消失。
  • ambiguous_target:Pane ID 在多个窗口中重复,必须补 Window ID。
  • invalid_state:当前标签类型不允许该动作,例如设置页不能分屏。
  • action_failed:窗口、Shell 或 Pane 创建失败。
  • agent_name_conflict / agent_identity_mismatch:名称已被活跃 Agent 使用,或 generation 已变化。
  • agent_exited / agent_replaced:等待的稳定 Agent 身份已退出或被同 Pane 中的新会话替换。
  • agent_ready_timeout:Agent 未在期限内完成进程身份观察与启动就绪握手。
  • dirty_source:agent.fork 的源工作树有未提交变更,且调用者未显式允许。
  • branch_conflict / worktree_path_conflict:目标分支或目录已存在;Nebula 不覆盖。
  • git_unavailable / invalid_base / invalid_branch:Git 能力或 revision/ref 校验失败。
  • remote_worktree_unsupported:本地 Runtime 不能替 SSH Pane 创建远端 Git worktree。
  • remote_process_unavailable:本地 Runtime 不能从 SSH 传输进程推导远端进程树。
  • exit_code_unavailable:当前 Shell 没有提供可信的 OSC 133 exit code。
  • ssh_not_ready:SSH Pane 尚未进入可安全读写的 Ready 阶段,或连接已经失败。
  • runtime_unavailable:当前壳/生命周期没有该动作所需的真实 owner;不会伪造成功。
  • timeout:pane.wait 未在期限内观察到目标状态。details 会带上 after_seq 与最后 观察到的 observed_state_change_seq,用于区分「Pane 一直没动」和「跃迁了但没到目标态」。

本机边界

服务端只监听 127.0.0.1,发现文件位于 Nebula 数据目录下的 runtime.port,其中包含随机 token。无 token 的连接会被静默丢弃。该边界用于阻止其他本机用户误调用,不承诺抵御已经 能读取当前用户文件或注入当前用户进程的攻击者。

旧版 PING/ATTACH 文本请求仍由同一服务端接受,保证单实例与会话重新挂载不因协议升级 失效;新功能只通过版本化 JSON 协议开放。