• 简体中文
  • Terminals(PTY)

    终端是一个真正的 PTY shell:有 tmux 时用 tmux,没有就用原生 PTY。会话跨调用保留工作目录、环境变量和正在运行的程序,程序运行期间也能继续接收输入。

    适合 REPL、交互式程序,以及 WebTerminal 界面。

    命令 每次调用都会启动新进程,调用结束后不会保留 shell。

    需要保持连接并持续使用 shell 时,可以使用 终端

    如果只需执行一条命令并分别读取 stdout 和 stderr,请使用 命令。v1 和 v2 路由的对照见 从 1.x 迁移

    运行要求

    可以通过 GET /v1/capabilities 查看终端使用的 shell。shell 为 bash 或 sh 时,exec.ptytrue;在 PowerShell 主机上,终端仍可打开,但 exec 不可用。

    打开一个终端

    curl -X POST "$BASE_URL/v2/pty/sessions" \
      -H "Content-Type: application/json" \
      -d '{"cwd": "/workspace", "cols": 120, "rows": 40}'

    请求体示例:

    {
      "cwd": "/workspace",
      "cols": 120,
      "rows": 40
    }

    请求体包含以下字段:

    字段取值含义
    idstring用指定 id 创建会话;不传则自动生成
    cwdstringshell 的起始目录
    colsrowsinteger终端尺寸,默认 120 x 24;要么都传,要么都不传
    retentionpersistent(默认), expiring一直保留到显式删除,或空闲后回收
    userstringshell 的运行身份;会话存续期间固定;仅限 Linux
    no_change_timeoutseconds该会话中命令的静默上限,默认 120
    envobject尚未实现;带上它的请求返回 400
    curl -X POST "$BASE_URL/v1/shell/sessions/create" \
      -H "Content-Type: application/json" \
      -d '{"exec_dir": "/workspace"}'

    请求体示例:

    {
      "exec_dir": "/workspace"
    }

    请求体包含以下字段:

    字段取值含义
    idstring用指定 id 创建会话;不传则自动生成
    exec_dirstringshell 的起始目录
    userstringshell 的运行身份;会话存续期间固定;仅限 Linux
    no_change_timeoutseconds该会话中命令的静默上限,默认 120
    preserve_symlinksboolean保持传入的路径,不解析符号链接

    也可以不预先创建:不带 idPOST /v1/shell/exec 会开一个会话并返回它的 id。

    需要终端需要固定尺寸,或者没有 WebSocket 连接时也能调整尺寸时,切到 v2:

    后续调用使用 session_id 定位终端,working_dir 是 shell 实际解析出的工作目录。exec 在 shell 当前所在的目录执行,包括在终端里敲 cd 进入的目录;请求里带上 exec_dir 才会把 shell 移过去。

    使用已有 id 创建时,接口会返回现有会话,不会重复创建。此时如果 user 与首次创建时不同,会返回 400;目录不存在时也返回 400

    执行一条命令

    curl -X POST "$BASE_URL/v2/pty/sessions/SESSION_ID/exec" \
      -H "Content-Type: application/json" \
      -d '{"command": "printf shell-doc-ok"}'

    请求体示例:

    {
      "command": "printf shell-doc-ok"
    }

    请求体包含以下字段:

    字段取值含义
    commandstring, required在提示符下敲入的那行命令
    asyncboolean立即返回,status"running"
    timeoutseconds调用等待的时长;不传就一直等到命令结束
    hard_timeoutseconds命令被打断的时间点
    no_change_timeoutseconds多久没有新输出就打断
    curl -X POST "$BASE_URL/v1/shell/exec" \
      -H "Content-Type: application/json" \
      -d '{"id": "SESSION_ID", "command": "printf shell-doc-ok"}'

    请求体示例:

    {
      "id": "SESSION_ID",
      "command": "printf shell-doc-ok"
    }

    请求体包含以下字段:

    字段取值含义
    commandstring, required在提示符下敲入的那行命令
    idstring在哪个会话里执行;不传就新开一个,id 不存在返回 404
    exec_dirstring新建会话时 shell 的起始目录
    async_modeboolean立即返回,status"running"
    timeoutseconds调用等待的时长;不传就一直等到命令结束
    hard_timeoutseconds命令被打断的时间点
    no_change_timeoutseconds多久没有新输出就打断
    userstring新建会话时 shell 的运行身份;仅限 Linux
    preserve_symlinksboolean保持传入的 exec_dir,不解析符号链接
    strictbooleanexec_dir 用不了时直接失败,而不是忽略
    truncateboolean过长的响应做截断;默认开启

    两个版本的响应是一样的:

    {
      "success": true,
      "message": "Command executed",
      "data": {
        "session_id": "SESSION_ID",
        "command": "printf shell-doc-ok",
        "status": "completed",
        "output": "shell-doc-ok",
        "console": [
          {
            "ps1": "$ ",
            "command": "printf shell-doc-ok",
            "output": "shell-doc-ok"
          }
        ],
        "exit_code": 0
      }
    }

    PTY 只有一条输出流,因此 output 是合并后的内容。需要区分 stdoutstderr 时,请使用命令 API。

    console 是会话记录,每条已完成的命令占一项,最多保留最近 100 条。一个终端同时只能执行一条命令;上一条仍在运行时再次调用会返回 400 Session already has a running command

    status 是命令的生命周期,只有 completed 时才有 exit_code

    status命令会话是否结束
    running还在跑;读屏幕跟进
    completed自行退出,或被 kill
    no_change_timeout因为没有新输出被打断
    hard_timeouthard_timeout 被打断
    terminated被显式信号终止

    两个超时都会用 ^C 打断命令,并将 exit_code 保持为 null。会话仍然打开,可以继续执行下一条命令。

    no_change_timeout 不适用于 async 命令,因为没有调用在等待它。

    响应超过 30,000 字节时,只保留开头和结尾各 15,000 字节,中间替换为 [... Observation truncated due to length ...]。v1 可以使用 truncate: false 关闭截断,v2 始终截断。

    路由

    路由用途说明
    POST /v2/pty/sessions打开终端返回其余路由都要用的 id
    GET /v2/pty/sessions列出终端和池状态一个响应里同时给 sessionsstats
    GET /v2/pty/sessions/{id}查看单个终端目录、存活时长、状态、当前命令
    PATCH /v2/pty/sessions/{id}调整尺寸或静默上限colsrows 要一起传,否则 400
    POST /v2/pty/sessions/{id}/exec执行命令每个终端同时只跑一条
    GET /v2/pty/sessions/{id}/screen读屏幕读取 async 命令的输出
    POST /v2/pty/sessions/{id}/input往终端里输入这里 press_enter 默认关
    POST /v2/pty/sessions/{id}/signal终止命令向进程组发 SIGKILL,并关闭会话
    DELETE /v2/pty/sessions/{id}关闭终端id 已经没了时返回 success: false
    GET /v2/pty/sessions/{id}/ws连接终端protocoldurablerestorereplay_bytes
    GET /v2/pty/ws开一个随连接存活的 shell不返回 id,连接断开即销毁
    路由用途说明
    POST /v1/shell/sessions/create打开终端不带 id 的 exec 也会开一个
    GET /v1/shell/sessions列出终端以 session id 为键
    GET /v1/shell/sessions/stats查看池状态总数、max_sessionssession_timeout
    POST /v1/shell/sessions/update改会话的静默上限只有 no_change_timeout
    POST /v1/shell/exec执行命令每个终端同时只跑一条
    POST /v1/shell/view读屏幕和 v2 的 screen 返回一样
    POST /v1/shell/wait阻塞到命令结束seconds 默认 30,且不低于 5
    POST /v1/shell/write往终端里输入这里 press_enter 默认开
    POST /v1/shell/kill终止命令向进程组发 SIGKILL,并关闭会话
    DELETE /v1/shell/sessions/{id}关闭终端DELETE /v1/shell/sessions 关闭全部
    GET /v1/shell/terminal-url生成 WebShell 链接session_id 指向已有终端,不带则新开一个
    GET /v1/shell/ws连接终端不带 session_id 时新开一个并公布 id
    • 屏幕内容就是命令当前为止的输出:命令运行时实时更新,结束后保留最后的输出。command 是当前运行的命令,两条命令之间为 null
    • wait 在命令结束或 seconds 到期时返回,以先发生者为准;没有运行中的命令时会立即返回。
    • input 会原样发送到 PTY,包括转义序列和控制字符:\u001b 是 ESC,\u0003 是 Ctrl-C。press_enter 会追加 Enter 键对应的回车符。
    • raw 模式的 TUI 会将单独的 \n 当作 Shift+Enter。不要同时在 input 末尾放 \r 并设置 press_enter: true,否则会发送两次 Enter。
    • 终止命令也会关闭会话,之后该 id 不再出现在列表中,再次终止会返回 404。如果只想打断命令并保留终端,请使用 hard_timeoutno_change_timeout
    • 终端和 文件 共享同一文件系统。在提示符下写入的文件可以立即通过文件 API 读取,反过来也一样。

    常见用法

    工作会跨越多次调用,或者程序会反问时,终端才值得开一个会话:

    场景启动方式然后
    printf shell-doc-okexec直接从同一个响应里读 output
    构建任务exec + timeout返回 running 时读屏幕直到结束
    REPLexec,async敲一行,读屏幕
    read -p 这类提示exec,async回答它,末尾按 Enter
    终端界面连 WebSocket断线后带 durable=true 重连

    处理交互式输入

    会提问的程序用 async 启动,然后把答案敲进去:

    AioExamples 中用于解析返回结构的辅助客户端:它取出 data,并在 success 为 false 时抛错。

    Python
    TypeScript
    import time
    
    sb = Aio(BASE_URL)
    session = sb.post("/v2/pty/sessions", cwd="/workspace")
    sid = session["session_id"]
    
    # 1. Start a program that asks a question; async returns while it waits.
    body = {"command": 'read -p "Name: " name; echo Hello $name', "async": True}
    sb.call("POST", f"/v2/pty/sessions/{sid}/exec", json=body)
    
    # 2. The prompt reaches the screen a moment after exec returns.
    time.sleep(0.3)
    screen = sb.get(f"/v2/pty/sessions/{sid}/screen")
    print(repr(screen["output"]), screen["status"])
    # 'Name: ' running
    
    # 3. Answer it, then read the screen once the command has ended.
    sb.post(f"/v2/pty/sessions/{sid}/input", input="Alice", press_enter=True)
    while sb.get(f"/v2/pty/sessions/{sid}/screen")["status"] == "running":
        time.sleep(0.2)
    screen = sb.get(f"/v2/pty/sessions/{sid}/screen")
    print(repr(screen["output"]), screen["status"], screen["exit_code"])
    # 'Name: Alice\nHello Alice' completed 0
    
    # 4. Close the terminal.
    sb.delete(f"/v2/pty/sessions/{sid}")
    Python
    TypeScript
    import time
    
    from agent_sandbox import Sandbox
    
    client = Sandbox(base_url=BASE_URL)
    session = client.shell.create_session(exec_dir="/workspace").data
    sid = session.session_id
    
    # 1. Start a program that asks a question; async_mode returns while it waits.
    client.shell.exec_command(
        id=sid, command='read -p "Name: " name; echo Hello $name', async_mode=True
    )
    
    # 2. The prompt reaches the screen a moment after the call returns.
    time.sleep(0.3)
    screen = client.shell.view(id=sid).data
    print(repr(screen.output), screen.status)
    # 'Name: ' running
    
    # 3. Answer it, then wait for the command to end.
    client.shell.write_to_process(id=sid, input="Alice", press_enter=True)
    waited = client.shell.wait_for_process(id=sid, seconds=5).data
    screen = client.shell.view(id=sid).data
    print(repr(screen.output), waited.status, screen.exit_code)
    # 'Name: Alice\nHello Alice' completed 0
    
    # 4. Close the terminal.
    client.shell.cleanup_session(sid)

    async exec 刚返回就读屏幕,可能读到的还是命令行本身的回显;再读一次即可。press_enter 是两个路由默认值唯一不同的字段,显式传上它。

    WebSocket 协议

    长时间运行的任务应通过 WebSocket 连接实时查看,而不是轮询。

    REST 和 WebSocket 共享同一个 shell,因此通过 exec 创建的文件可以立即在终端中看到。

    连接地址是 GET /v2/pty/sessions/{id}/wsGET /v1/shell/ws?session_id=…。主机和端口不变,只需将协议改为 ws://

    {"type":"ready","session_id":"…","transport":"json","backend":"tmux","resumed":false}
    {"type":"input","data":"ls\n"}
    {"type":"resize","cols":120,"rows":40}
    {"type":"ping","timestamp":<t>}
    {"type":"output","data":"…"}
    {"type":"restore_output","data":"…"}
    {"type":"pong","timestamp":<t>}
    {"type":"error","data":"…"}
    方向type含义
    ← 收ready升级成功后的第一帧
    → 发input按键或命令文本
    → 发resize调整 PTY 尺寸;字段缺失时按 80 x 24 处理
    → 发ping可选的保活;守护进程不会主动 ping
    ← 收output终端输出,含 ANSI
    ← 收restore_output重连时回放的缓冲历史
    ← 收terminal_restored回放到此结束
    ← 收pongping 的回应,原样返回 timestamp
    ← 收error连接或会话失败;之后连接关闭

    input 中必须包含 \n,该行才会执行。colsrows 也可以放在 data 下。

    其他内容(结构不同的 JSON 或纯文本)都会作为原始输入发送给 shell。无法识别的控制帧因此会变成提示符下的一行乱码。

    protocol=binary 使用二进制帧传输原始 PTY 字节,不再发送 JSON output 帧;ready 帧仍是 JSON,并包含 transport

    GET /v2/pty/ws 不创建会话。它打开的 shell 与连接同生共死,不会公布 id。

    GET /v1/shell/ws 不带 session_id 时也会新开终端,但会在 ready 之前通过 session_id 帧公布 id。该会话会一直保留到关闭或空闲回收。

    断线后重连

    连接断了,命令不会停。durable=true 让终端在无人连接时也保留输出,restore=true 在下次连接时回放:

    // BASE_URL as defined above, e.g. "http://127.0.0.1:18091"
    const WS_BASE_URL = "ws://127.0.0.1:18091"; // same host and port, ws:// scheme
    const query = "durable=true&restore=true";
    
    // 1. Open a terminal over REST, attach, and start a build.
    const create = await fetch(`${BASE_URL}/v2/pty/sessions`, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: "{}",
    }).then((r) => r.json());
    const sessionId = create.data.session_id;
    
    const first = new WebSocket(
      `${WS_BASE_URL}/v2/pty/sessions/${sessionId}/ws?${query}`,
    );
    first.onopen = () =>
      first.send(
        JSON.stringify({
          type: "input",
          data: "for i in 1 2 3 4 5 6; do echo build step $i; sleep 1; done\n",
        }),
      );
    
    // 2. The client goes away mid-build; the command keeps running.
    setTimeout(() => first.close(), 3000);
    
    // 3. Reattach: what was buffered arrives first, then live output.
    setTimeout(() => {
      const again = new WebSocket(
        `${WS_BASE_URL}/v2/pty/sessions/${sessionId}/ws?${query}`,
      );
      again.onmessage = (event) => console.log(event.data);
    }, 5000);

    客户端离开期间继续跑的构建,会以三帧回来,随后恢复实时输出:

    {"type":"ready","session_id":"SESSION_ID","transport":"json","backend":"native","resumed":true}
    {"type":"restore_output","data":"~ $ for i in 1 2 3 4 5 6; do echo build step $i; sleep 1; done\r\nbuild step 1\r\nbuild step 2\r\nbuild step 3\r\nbuild step 4\r\nbuild step 5\r\n"}
    {"type":"terminal_restored","session_id":"SESSION_ID"}
    {"type":"output","data":"build step 6\r\n"}
    • resumed —— 这条连接接管了一个已经连着的终端时为 true
    • restore_output —— 无人连接期间缓冲的全部内容;再次重连时只回放上次之后新增的部分
    • terminal_restored —— 回放结束,之后是实时输出

    restore 只有在同时设置 durable 时才生效。replay_bytes 限制回放快照的大小,默认 10 MiB,取值范围为 256 KiB..10 MiB。

    保留的输出存放在 daemon 内存中,会话关闭、终止或回收时都会丢失。

    未设置 durable=true 时,连接到已有终端的第二个 WebSocket 会收到以下错误并被拒绝:

    {
      "type": "error",
      "data": "Session already has an active WebSocket connection"
    }

    设置 durable=true 后,新连接会接管终端,旧连接收到 relay_replaced 帧后关闭。

    durablerestore 都必须用于已有会话;匿名连接使用任意一个参数都会返回 400

    Human in the loop

    用户可以直接打开 agent 正在使用的终端。预置镜像在 /terminal?session_id=… 提供 WebShell 页面,通过 /v1/shell/ws 连接,因此页面和 agent 看到的是同一个终端,也都可以输入。

    GET /v1/shell/terminal-url 用来生成页面链接:传入 ?session_id= 时返回已有终端的地址,id 不存在则返回 404;不传时会新建终端。反复调用可能留下没人使用的终端。

    daemon 只提供 URL 和 WebSocket,页面本身由镜像提供。浏览器和桌面的查看方式见 Computer Use

    Windows

    Windows 上的终端使用 PowerShell 的 ConPTY,启动的进程都在 job object 中运行。

    该 plane 上的命令执行返回 501,因为完成协议需要 POSIX shell。创建、输入、读屏幕、调整尺寸、连接和终止仍然可用。见 Windows

    后端与上限

    AIO_SHELL_BACKEND=auto(默认)在找到可用的 tmux 时使用 tmux。

    只有显式创建的会话可能使用 tmux。exec 自动创建的会话、retention: "expiring" 的会话和匿名 WebSocket 打开的终端,一律使用原生 PTY。会话实际使用的后端会写在 ready 帧中。

    AIO_SHELL_BACKEND=native 固定使用原生 PTY;tmux 强制要求 tmux,找不到 tmux 二进制时返回 503

    会话状态只保存在 daemon 内存中,aiod 重启后 id 不会保留。已经运行的 tmux server 会继续存在,其 socket 会被复用,不会重新创建。

    最多同时运行 20 个终端,每个终端空闲 3600 秒后自动关闭(AIO_SHELL_MAX_SESSIONS / AIO_SHELL_SESSION_TIMEOUT_SECS)。连接着 WebSocket 的终端不会被视为空闲。

    达到上限时,daemon 会关闭最久未使用且可以回收的终端;没有可回收终端时,创建请求返回 400

    错误处理

    HTTP 成功只说明请求被接受。先看 data.status 判断命令生命周期,statuscompleted 之后再看 exit_code。该 plane 自身的失败:

    返回场景
    400终端里已经有命令在跑;创建时带 envcols/rows 只传了一个;user 和会话已有的不一致
    404没有指定 id 的终端:已被关闭、终止或空闲回收
    422请求不合法,附带 errors 列表
    501shell 不是 bash 或 sh 的机器上执行 exec

    关闭一个已经不存在的 id 不算失败:DELETE 返回 200success 为 false。见 错误处理