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。见 错误处理