A rustfmt run was cut short by a full D: drive and left gpui_shell/terminal/view.rs at 0 bytes. Restored from the editor history snapshot taken at 18:32 - the selection auto-scroll work and the suggest_env wiring are both back in. Emergency baseline snapshot, committed with --no-verify: the tree does not compile yet (missing SuggestEnv import, one stale update_selection_scroll call site) and the pre-commit size budgets are pre-existing failures on this branch.
16 KiB
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、远程运行时可以在版本、权限和兼容错误已经明确的边界上继续建设。
CLI
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 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.read |
读取命名 Agent 所在 Pane 的真实 Grid 尾部 | agent, generation?, lines? |
agent.wait |
等待同一 Agent generation 的状态跃迁;被替换/退出即明确失败 | agent, generation, state, timeout_ms, after_seq? |
window.create |
创建新窗口 | 无 |
window.focus |
聚焦窗口或 Pane | window_id?, pane_id? |
tab.new |
创建默认 Shell 标签 | window_id? |
pane.split |
从当前或指定 Pane 向右/向下分屏 | window_id?, pane_id?, direction |
pane.prompt |
写入一行纯文本,可追加 Enter | 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.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 字符串。
单请求编排
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,目前冷启动只开放经过验证的 Codex
与 Claude 命令。agent.fork 在此基础上增加 Git 隔离,完整顺序是:
- 由
source_pane_id从 RuntimeHub 权威 snapshot 解析 cwd;没有 Pane 时必须提供绝对source_cwd。SSH Pane 明确返回remote_worktree_unsupported。 - 校验 Git、source dirty 状态、base commit、branch 与目标路径。默认拒绝 dirty source;
只有调用者明确传
allow_dirty_source: true才跳过该检查。 - 在 Runtime 工作线程创建新 branch 与 worktree。默认 branch 为
nebula/<agent-slug>,默认 目录为主仓库同级的<repo>-worktrees/<agent-slug>;既存 branch/path 都返回冲突,不覆盖。 - 把新 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。
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 当成结果。
正确用法是先取基线、再等待跃迁:
- 发
pane.prompt,从响应的snapshot中读出该 Pane 的state_change_seq。 - 把它作为
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 协议开放。