# Shell 执行与 PTY · 在伪终端里跑命令

> 让 agent 跑 shell 命令，远不止 spawn 一下。grok 有非流式与流式两条执行路径、跨平台的进程树 kill、边跑边写盘的日志，还有一个基于 portable-pty + alacritty 网格模拟的完整 PTY 控制器。这一章拆执行、流式、后台任务与 PTY。

- 作者：David（道雾轩）
- 专栏：Grok Build 源码解读（https://daiw.org/manual/grok-build.md）
- 最后更新：2026-09-15
- 原文：https://daiw.org/manual/grok-build/shell-and-pty
- 转载与引用：请注明出处并附原文链接（https://daiw.org/about/copyright）

# Shell 执行与 PTY · 在伪终端里跑命令

「跑一条命令」是 agent 最危险也最常用的能力。grok 在这上面做的工程量，远超一句 `spawn`。

## 两条执行路径

`TerminalRunner`(这套执行器如今独立成 `xai-grok-shell-terminal` crate，`xai-grok-shell` 以 `xai_grok_shell::terminal` 重新导出)按 `request.stream` 分流：

- **`LocalTerminalRunner`**(非流式，简单)：`bash -lc "命令"`(Unix；Windows 走平台 shell 的 argv)，stdout/stderr 管道读到底，`timeout` 到了既 `start_kill` 直接子进程、也杀掉整个进程组(免得孙进程攥着管道继续跑)，输出按 UTF-8 边界保留末尾 N 字节。
- **`StreamingLocalTerminalRunner`**(流式、可 kill，**主路径**)：这条是重点。

两条都用 setsid 把子进程从 `/dev/tty` 摘开(非流式走 **`detach_command`**，流式走 `process_wrap` 的 `ProcessSession`)，免得子进程抢终端；两条也都挂上了沙箱的子进程网络过滤(见[下一章](https://daiw.org/manual/grok-build/sandbox-permissions))。

## 流式路径的讲究

`StreamingLocalTerminalRunner` 维护一个全局进程注册表(`(session_id, terminal_id) → TerminalEntry`),spawn 时用 `process_wrap` 做**跨平台进程树 kill**(Unix setsid 新进程组、Windows JobObject)+ `KillOnDrop`。核心是一个 `tokio::select!` 循环，四个竞争分支：读 stdout / 读 stderr / 定时 tick(默认 100ms 报一次进度)/ **钉住的超时 deadline**。

两个细节值得学：

- **输出边读边写盘**：每读到一段就**立刻**写进 `output_file`——在内存缓冲被截断之后，磁盘日志仍然完整。
- **通知必须 fire-and-forget**：进度通知若阻塞，超时分支就会卡住，所以通知发送方契约上就是「发了不等」。

## kill 与后台化

kill 一个跑飞的命令要干脆：`kill_terminal` 用**有界回收**(`timeout(2s, child.wait())`)——不会为等一个僵尸进程卡死。会话取消时 `kill_and_release_all_for_session` 分四步：短锁下抽干注册表 → 同步 SIGKILL 前台 → 后台任务重新插回 → **在独立 task 上回收**，让取消路径**瞬间返回**。

模型侧的后台任务由 `BackgroundTaskRegistry` 管(每会话上限 10)。它的 `wait_for_completion` 有个经典的**无丢失唤醒**写法：**先注册 `notified()` 再检查 `completed`**——顺序反了就可能错过唤醒。

## PTY：一个完整的伪终端控制器

有些命令(vim、top、交互式脚本)非得在**真终端**里跑才正常。grok 为此有 `ptyctl`——基于 `portable-pty`(开 PTY)+ `alacritty_terminal`(终端网格模拟)。`PtySession::start` 起四个角色：

```mermaid
flowchart LR
  CMD[命令] --> PTY[PTY master/slave]
  PTY -->|专用 OS 线程读| R[reader]
  W[writer 任务] -->|send_keys| PTY
  R --> FEED[terminal feeder:<br/>broadcast + term.feed]
  FEED -->|feed 后| GEN[generation++ watch]
  WAIT[事件驱动 expect] -.只在 generation 变时复检.-> GEN
```

最漂亮的是 `wait.rs` 的**事件驱动 expect**：等某段文字出现，**不轮询**——feeder 每喂完一批就把一个 `watch` 代-数 +1，等待方只在 `generation` 变化时复检(`borrow_and_update` 先于检查 = 无丢失唤醒)；子进程退出(feeder 的 sender 被 drop)则标记 `ended`。还配了 axum 的 HTTP + WebSocket 控制面，把 PTY 的输入输出对外暴露。

shell 内部还有一套交互式 PTY(`pty_session.rs`)，它的**忙碌检测**很巧：每 500ms 用 `process_group_leader()`(Unix `tcgetpgrp`)对比子进程 pid，判断前台有没有在跑活的进程——**基于时间而非输出**，因为一个忙进程可能一声不吭。

## 和《从 LLM 到 Coding Agent》对照

对照 [tool-use](https://daiw.org/manual/llm-to-agent/tool-use) 与 [streaming-tool-execution](https://daiw.org/manual/llm-to-agent/streaming-tool-execution)：最小实现里跑命令就是 `exec` 拿 stdout。grok 的增量是——真实产品要处理：流式输出、跨平台进程树 kill、瞬时取消、后台任务、边跑边落盘、以及交互式程序必需的 PTY。下一章看跑命令这件事的另一半：怎么用 OS 沙箱把它关进笼子。

---

上一篇：[hunk 追踪](https://daiw.org/manual/grok-build/hunk-tracker) · 下一篇：[沙箱与权限](https://daiw.org/manual/grok-build/sandbox-permissions)
