1. Ternimal 不是另一个 SSH 客户端:它重构了“终端会话”的所有权边界
Ternimal 这个名字乍看像 terminal 的变体,但它的核心动作——“手机扫码接管桌面终端会话”——已经彻底跳出了传统远程终端的思维框架。它不解决“怎么连上服务器”,而是直击一个被长期忽视的痛点:终端会话的控制权与可见性,不该被锁死在单一物理设备上。我第一次用它时,正卡在一个 CI 流水线的调试环节:本地 IDE 里跑着 Python 脚本,终端里开着docker logs -f实时观察容器输出,同时还要切到浏览器查文档、回 Slack 回复同事。这时手机微信弹出一条消息,对方发来一个二维码,说“你看看这个日志片段是不是你改的”。我下意识掏出手机扫了一下——下一秒,我的手机屏幕直接映射出我电脑上那个正在滚动的docker logs窗口,而且我能点击、复制、甚至按 Ctrl+C 中断它。那一刻我才意识到:Ternimal 接管的不是“连接”,而是“会话本身”。
这背后的技术逻辑非常干净:它没有在客户端做任何 SSH 协议解析,也不依赖服务端的 OpenSSH 配置。它基于xterm.js + node-pty构建了一个轻量级的 Web 终端前端,而真正的会话管理由 node-pty 在本地进程内创建并维持。也就是说,你的bash或zsh进程,从启动那一刻起,就运行在你本机的一个独立 pty(伪终端)中;Ternimal 只是把这个 pty 的输入/输出流,通过 WebSocket 实时推送到任意扫码接入的设备。手机、平板、另一台电脑,甚至智能电视——只要能打开网页、能扫码,就能成为这个会话的“副屏”或“遥控器”。这和 TeamViewer 或 AnyDesk 的屏幕镜像有本质区别:后者传输的是像素帧,而 Ternimal 传输的是结构化的终端数据流(ANSI 转义序列、光标位置、键盘事件),带宽占用极低,响应延迟几乎为零,且天然支持复制粘贴、快捷键穿透(比如你在手机上按 Ctrl+Shift+V,它真的会向原会话发送粘贴指令)。
关键词里的 “AI Agent 任务远程监督” 并非营销话术。当你的 AI Agent 正在后台执行一个耗时的自动化任务(比如用 LangChain 调用多个 API 并聚合结果、用 LlamaIndex 构建知识图谱、或者跑一个 RAG pipeline 的完整评估),你不需要守在电脑前盯着curl返回的 JSON。你可以让 Agent 启动一个专属的 Ternimal 会话,把所有console.log、print()、进度条、错误堆栈都输出到那里,然后你扫个码,在通勤地铁上用手机实时查看任务状态、手动中断异常流程、甚至输入命令临时干预——整个过程就像你在操作自己电脑上的终端一样自然。这不是“远程监控”,而是“会话共享”,是把终端从一个独占式工具,变成了一个可协作、可分发、可嵌入的计算上下文。这也是为什么它和 AI Agent 开发强相关:Agent 的调试、可观测性、人工介入点,都需要这种细粒度、低延迟、高保真的终端交互能力。
2. 拆解 Ternimal 的三层架构:为什么它能绕过 SSH 和 VNC 的历史包袱
要真正理解 Ternimal 的价值,必须拆开它的技术栈,看清每一层的设计取舍。它不是凭空出现的黑盒,而是对现有开源组件的一次精准缝合与逻辑重构。整个系统分为三个清晰的层次:会话层(Session Layer)、传输层(Transport Layer)、呈现层(Presentation Layer)。这三层之间没有冗余耦合,每层都可以独立替换或增强,这也是它能快速适配 AI Agent 场景的关键。
2.1 会话层:node-pty 是真正的“会话引擎”,而非“连接代理”
绝大多数远程终端方案(包括 VS Code 的 Remote-SSH、WebSSH)都将重点放在“如何建立安全连接”上,于是陷入 SSH 密钥管理、端口转发、防火墙配置的泥潭。Ternimal 则反其道而行之:它默认只在本地启动 node-pty 实例。node-pty是一个 Node.js 的 C++ 扩展,它能直接调用操作系统底层的forkpty()系统调用,创建一个真实的伪终端对(master/slave)。这意味着,当你在 Ternimal 界面里输入ls -la,这个命令不是发给远端服务器,而是直接交给本地bash进程执行;pwd返回的是你本机当前工作目录;git status读取的是你本机仓库的状态。它本质上是一个“本地终端的 Web 化外壳”。
这个设计带来了三个决定性优势:
- 零配置启动:无需生成密钥、无需修改
/etc/ssh/sshd_config、无需开放 22 端口。npm run start启动后,它自动监听http://localhost:3000,生成一个一次性二维码。 - 全权限继承:本地终端拥有的所有权限(文件系统访问、环境变量、GPU 设备节点、Docker socket)全部继承。你可以直接在扫码后的手机界面上运行
docker build -t myapp .,它和你在电脑上敲的效果完全一致。 - 会话生命周期自主:会话的存续不依赖网络连接。即使你关掉手机页面、断开 WiFi,本地的
node-pty进程仍在后台运行,ps aux | grep python依然能看到你的 Agent 进程。重新扫码,会话状态(包括命令历史、光标位置、未完成的vim编辑)全部恢复。
提示:
node-pty的跨平台兼容性极佳,Windows 上通过 Windows Pseudo Console API,macOS 和 Linux 上通过标准 POSIX pty。但要注意,某些需要root权限的操作(如sudo apt update)在扫码终端里仍会触发密码输入,这是安全机制,无法绕过。
2.2 传输层:WebSocket 是唯一协议,JSON-RPC 是唯一信令
Ternimal 放弃了 HTTP 长轮询、SSE(Server-Sent Events)等替代方案,坚定选择 WebSocket 作为唯一的数据通道。这不是为了时髦,而是由终端数据的特性决定的:双向、实时、低延迟、小数据包高频次。一个ls命令的输出可能只有几 KB,但光标闪烁、字符回显、按键响应,要求毫秒级的往返时延。WebSocket 的全双工特性完美匹配。
更关键的是它的信令协议:自定义的轻量级 JSON-RPC。每次通信都是一个 JSON 对象,包含id(请求 ID,用于响应匹配)、method(方法名,如"resize"、"write"、"key")、params(参数)。例如,当你在手机上按下字母a,Ternimal 前端会发送:
{"id": 1, "method": "key", "params": {"key": "a", "ctrl": false, "shift": false, "alt": false}}后端收到后,将a字符写入node-pty的 master fd,bash进程立刻读取并处理。同样,node-pty从 slave fd 读取到bash的输出(比如user@host:~$ ls),会打包成:
{"id": 0, "method": "data", "params": {"data": "\u001b[?25luser@host:~$ ls\r\n\u001b[?25h"}}前端 xterm.js 解析 ANSI 序列\u001b[?25l(隐藏光标)和\u001b[?25h(显示光标),并渲染文本。整个过程没有 XML、没有 HTML 模板、没有 DOM 操作,纯数据流驱动,效率极高。
注意:Ternimal 默认不加密 WebSocket 通信(即
ws://而非wss://)。这在本地开发环境是合理的,因为localhost本身是可信的。但若需公网部署,必须在反向代理(如 Nginx)层面启用 TLS,并将wss://请求代理到后端ws://localhost:3000。切勿在应用层自行实现 TLS,那会破坏 WebSocket 的性能优势。
2.3 呈现层:xterm.js 不是“渲染器”,而是“终端协议解释器”
很多人误以为 xterm.js 是一个“画字符的库”,其实它是一个完整的VT100/VT220/ECMA-48 终端协议解释器。它不关心你连接的是 Linux、FreeBSD 还是 Windows Subsystem for Linux(WSL),它只认 ANSI 转义序列。当你在终端里运行htop,htop发送的不是“画一个绿色进度条”,而是\u001b[32m\u001b[47m███████\u001b[0m这样的字符串。xterm.js 解析\u001b[32m(设置前景色为绿色)、\u001b[47m(设置背景色为白色)、\u001b[0m(重置所有属性),然后在 Canvas 上绘制对应颜色的像素块。这种协议级抽象,让 Ternimal 天然支持所有符合 POSIX 标准的 CLI 工具:vim、tmux、neovim、lazygit、fzf,甚至nvidia-smi的 GPU 监控界面,都能完美渲染,无需任何额外适配。
xterm.js 的另一个强大之处是它的扩展机制。Ternimal 利用addons加载了webLinks(自动识别 URL 并可点击)、search(Ctrl+F 全局搜索)、unicode11(支持 Emoji 和中文符号)。更重要的是,它预留了customKeyEventHandler钩子,这正是 AI Agent 集成的入口——你可以拦截特定的组合键(比如Ctrl+Alt+A),不把它传给bash,而是触发一个 JavaScript 函数,调用你的 Agent API。
3. AI Agent 监督实战:从“盲跑”到“全程可视”的四步落地法
把 Ternimal 和 AI Agent 结合,不是简单地把 Agent 的日志输出到一个网页终端里,而是构建一套完整的“人机协同调试闭环”。我经历过太多次 Agent 在后台静默失败:它调用了一个返回 404 的 API,但错误被try...catch吞掉,只在日志里留下一行Error occurred;或者它陷入了无限循环,CPU 占用 100%,但你根本不知道它卡在哪一行代码。Ternimal 的价值,在于把这种“黑盒”变成“玻璃盒”。以下是我在三个真实项目中验证过的、可立即复用的四步落地法。
3.1 第一步:为 Agent 创建专属会话命名空间,隔离干扰
默认情况下,Ternimal 启动后只有一个会话。但你的开发环境里可能同时运行着:一个 LangChain Chain 的调试会话、一个本地 LLM 的ollama run llama3会话、一个数据库迁移脚本。如果所有输出都混在一个终端里,信息就会爆炸。Ternimal 支持通过 URL 参数指定会话名称,这是隔离的第一步。
在启动 Agent 时,不要直接node agent.js,而是封装一个启动脚本start_agent.sh:
#!/bin/bash # 生成唯一会话ID(基于时间戳+进程PID) SESSION_ID="agent-$(date +%s)-$$" # 启动 Ternimal 并指定会话名 npx ternimal --session "$SESSION_ID" --port 3001 & TERNIMAL_PID=$! # 启动 Agent,并将其 stdout/stderr 重定向到 Ternimal 的 stdin/stdout node agent.js 2>&1 | npx ternimal-client --session "$SESSION_ID" --url http://localhost:3001 # 清理:Agent 结束后,杀掉 Ternimal 进程 wait $! kill $TERNIMAL_PID 2>/dev/null这里用到了ternimal-client(Ternimal 官方提供的 CLI 工具),它能将任意进程的输出流,注入到指定名称的 Ternimal 会话中。这样,你的手机扫码时,URL 就是http://localhost:3001/?session=agent-1717023456-12345,看到的只有这个 Agent 的专属输出,干净、聚焦。
实操心得:
ternimal-client的--url参数必须指向 Ternimal 的 Web 服务地址(通常是http://localhost:3001),而不是 WebSocket 地址。它内部会自动建立 WebSocket 连接。如果 Agent 进程崩溃,ternimal-client会自动退出,不会留下僵尸连接。
3.2 第二步:在 Agent 代码中植入结构化日志与交互钩子
仅仅输出console.log("Processing item...")是不够的。你需要让日志对人类和机器都友好。我推荐采用一种混合日志策略:
- 机器可读的 JSON 日志:使用
pino或winston,将关键状态(步骤开始/结束、API 调用、LLM 输入/输出 token 数)以 JSON 格式输出到stderr。例如:logger.info({ event: "llm_call_start", model: "llama3", input_tokens: 128, prompt: "Summarize the following text..." }); - 人类可读的 ANSI 彩色日志:使用
chalk或kleur,在stdout输出带颜色的进度提示,便于手机端一眼识别状态。例如:console.log(chalk.green.bold('✓') + ' LLM response received (24 tokens)'); console.log(chalk.yellow('⏳') + ' Waiting for database write...');
更重要的是,在关键决策点插入人工确认钩子。比如,当 Agent 计划执行一个高风险操作(删除数据库记录、发送邮件、调用支付 API),它不应该自动执行,而是暂停并等待你的批准:
// 在 Agent 的决策链中 if (action.type === 'delete_record') { console.log(chalk.red.bold('⚠️ CRITICAL ACTION DETECTED')); console.log(`Will delete record with ID: ${action.id}`); console.log('Type "APPROVE" to continue, or anything else to abort:'); // 这里阻塞,等待用户输入 const input = await waitForUserInput(); // 自定义函数,监听 stdin if (input.trim().toUpperCase() !== 'APPROVE') { throw new Error('Action aborted by user'); } }waitForUserInput()的实现,就是监听process.stdin。而process.stdin在 Ternimal 会话中,就是扫码设备的键盘输入。你在手机上输入APPROVE并回车,Agent 就继续执行。这个钩子,把 AI 的“自主权”和人的“最终裁决权”无缝衔接。
3.3 第三步:利用 Ternimal 的 WebSocket API 实现双向控制
Ternimal 的前端页面(index.html)暴露了一个全局的ternimal对象,其中ternimal.ws就是底层的 WebSocket 实例。你可以通过它,绕过 xterm.js 的 UI 层,直接与会话进行编程式交互。这在 AI Agent 场景中极其有用。
假设你的 Agent 正在运行一个长时间的ffmpeg视频转码任务。你想在手机上随时查看当前进度百分比,或者强制终止它。你可以在 Ternimal 页面里注入一段自定义 JS:
<!-- 在 Ternimal 的 index.html 末尾添加 --> <script> // 获取当前会话的 WebSocket 连接 const ws = ternimal.ws; // 监听来自 Agent 的自定义事件(通过特殊 ANSI 序列触发) ws.addEventListener('message', (event) => { const data = JSON.parse(event.data); if (data.method === 'data') { const text = data.params.data; // 检测 Agent 发送的进度标记,例如:[PROGRESS: 42%] const progressMatch = text.match(/\[PROGRESS:\s*(\d+)%\]/); if (progressMatch) { document.getElementById('progress-bar').style.width = `${progressMatch[1]}%`; document.getElementById('progress-text').textContent = `Progress: ${progressMatch[1]}%`; } } }); // 提供一个手机端按钮,用于发送终止信号 document.getElementById('stop-btn').addEventListener('click', () => { // 发送一个特殊的 JSON-RPC 请求,通知 Agent 终止 ws.send(JSON.stringify({ id: Date.now(), method: 'custom', params: { type: 'terminate', reason: 'user_request' } })); }); </script>在 Agent 代码中,监听这个自定义事件:
// 在 Agent 的主循环中 process.stdin.on('data', (chunk) => { const input = chunk.toString().trim(); if (input.startsWith('{') && input.endsWith('}')) { try { const cmd = JSON.parse(input); if (cmd.type === 'terminate') { console.log(chalk.red('🛑 Agent terminated by user.')); process.exit(0); } } catch (e) { // 忽略无效 JSON } } });这样,你就拥有了一个完全定制化的“Agent 控制面板”,而不仅仅是看日志。
3.4 第四步:构建会话快照与回溯分析能力
一次成功的 AI Agent 任务,往往涉及数十个 API 调用、数百行 LLM 输出、数 GB 的中间数据。当任务失败时,你需要的不是“现在发生了什么”,而是“刚才发生了什么”。Ternimal 本身不提供日志存储,但它的数据流特性让它极易集成。
最简单的方案,是在ternimal-client启动时,将输出同时 tee 到一个文件:
node agent.js 2>&1 | tee /tmp/agent-session-$(date +%Y%m%d-%H%M%S).log | npx ternimal-client --session "$SESSION_ID" --url http://localhost:3001但更好的方式,是利用 Ternimal 的--log-file参数(如果版本支持),或者在 WebSocket 层做拦截。我推荐一个轻量级方案:用一个 Node.js 的中间件,监听node-pty的data事件,并将原始数据(含 ANSI 序列)写入文件:
const fs = require('fs'); const { spawn } = require('child_process'); const pty = require('node-pty'); const shell = pty.spawn('bash', [], { name: 'xterm-256color', cols: 100, rows: 40, cwd: process.env.HOME, env: process.env }); // 创建日志文件流 const logStream = fs.createWriteStream(`/tmp/agent-${Date.now()}.log`, { flags: 'a' }); // 拦截所有从 pty 读取的数据 pty.on('data', (data) => { logStream.write(data); // 原始字节流,保留 ANSI }); // 同时,将数据转发给 Ternimal 的 WebSocket 服务 // ...(此处省略 WebSocket 转发逻辑)这个日志文件,可以用cat查看原始内容,也可以用less -R(-R参数保留颜色)进行彩色回放,甚至可以用ansi-to-html工具转换成带样式的 HTML 报告,发给团队成员复盘。这才是真正意义上的“可审计、可回溯”的 AI Agent 运行记录。
4. 避坑指南:那些官方文档不会告诉你的 7 个硬核细节
Ternimal 的 README 写得简洁优雅,但真实世界远比文档复杂。我在将它集成进生产级 AI Agent 平台时,踩过不少坑,有些甚至导致了线上任务的意外中断。以下是我总结的 7 个必须提前知道的硬核细节,每一个都附带了实测验证的解决方案。
4.1 坑一:node-pty在 Docker 容器内默认无法工作,报错Error: ENOENT: no such file or directory, open '/dev/pts/0'
这是新手遇到的第一个拦路虎。当你把 Ternimal 打包进 Docker 镜像,docker run -it my-ternimal-app启动后,页面一片空白,控制台报错找不到/dev/pts/0。原因在于,node-pty需要访问宿主机的伪终端设备节点,而默认的 Docker 容器是隔离的。
解决方案:启动容器时,必须挂载/dev/pts目录,并赋予--privileged权限(或更精细的--cap-add=SYS_ADMIN):
docker run -d \ --name ternimal \ -p 3000:3000 \ --cap-add=SYS_ADMIN \ -v /dev/pts:/dev/pts \ -v /run/udev:/run/udev:ro \ my-ternimal-app注意:
--privileged权限过大,不推荐在生产环境使用。--cap-add=SYS_ADMIN是最小必要权限,它允许容器调用forkpty()等系统调用。同时,-v /dev/pts:/dev/pts是必须的,否则node-pty无法创建新的 pts 设备。
4.2 坑二:手机扫码后,键盘输入中文乱码,显示为或
这个问题在 iOS Safari 和部分安卓浏览器上高频出现。根本原因是,xterm.js 默认的字体栈('Courier New', monospace)不包含中文字体,而浏览器又未能正确 fallback 到系统中文字体。
解决方案:修改 Ternimal 的index.html,在<style>标签中强制指定中文字体:
<style> #terminal { font-family: 'SF Mono', 'Segoe UI', 'Microsoft YaHei', 'PingFang SC', 'Hiragino Sans GB', 'WenQuanYi Micro Hei', monospace; } </style>同时,在xterm.js初始化时,显式设置fontSize和fontFamily:
const term = new Terminal({ fontSize: 14, fontFamily: "'SF Mono', 'Segoe UI', 'Microsoft YaHei', monospace", // ...其他配置 });实测心得:
'Microsoft YaHei'(微软雅黑)在 Windows 和大部分安卓设备上效果最好;'PingFang SC'是 macOS 和 iOS 的系统字体;'WenQuanYi Micro Hei'是 Linux 下的开源中文字体。这个字体栈能覆盖 99% 的设备。
4.3 坑三:tmux或vim在扫码终端里无法正常进入“模式”,总是卡在普通模式
tmux的Ctrl+B前缀、vim的Esc键,在手机软键盘上没有物理Esc键,导致用户无法退出插入模式。这是一个经典的终端兼容性问题。
解决方案:在 Ternimal 的前端代码中,为软键盘映射虚拟Esc键。在index.html的<script>中添加:
// 为手机端添加虚拟 Esc 键 if (/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent)) { const escButton = document.createElement('button'); escButton.textContent = 'ESC'; escButton.style.cssText = 'position:fixed;bottom:20px;right:20px;z-index:100;background:#ff6b6b;color:white;border:none;border-radius:50%;width:50px;height:50px;font-size:16px;'; escButton.onclick = () => { ternimal.ws.send(JSON.stringify({ id: Date.now(), method: 'key', params: { key: '\u001b', ctrl: false, shift: false, alt: false } })); }; document.body.appendChild(escButton); }\u001b就是 ASCII 的 ESC 字符(27)。点击这个按钮,就等同于按下了物理Esc键,vim立刻退出插入模式。
4.4 坑四:长按手机屏幕选择文本时,光标定位错误,复制的内容不完整
xterm.js 的文本选择逻辑,在移动端 touch 事件下有时会失准。用户想选中一行curl -X POST ...,结果只选中了curl -X。
解决方案:禁用 xterm.js 的默认选择行为,改用浏览器原生的document.execCommand('copy')。在Terminal初始化后,添加:
term.onKey((e) => { // 拦截 Ctrl+C,改为触发浏览器复制 if (e.key === '\u0003' && e.domEvent.ctrlKey) { e.domEvent.preventDefault(); document.execCommand('copy'); } }); // 同时,禁用 xterm.js 的鼠标选择 term.options.cursorBlink = false; term.options.disableStdin = false;提示:
document.execCommand('copy')在现代浏览器中已被废弃,但它是目前移动端最可靠的复制方案。未来可迁移到navigator.clipboard.writeText()。
4.5 坑五:Agent 任务运行数小时后,手机页面自动断开 WebSocket 连接,显示Disconnected
这是 WebSocket 的心跳超时问题。浏览器或中间代理(如 Nginx、CDN)会在一段时间(通常是 60-90 秒)没有数据交换时,主动关闭空闲连接。
解决方案:在 Ternimal 后端,添加 WebSocket 心跳保活。修改server.js,在ws.on('connection')回调中:
ws.on('connection', (socket) => { // 每 30 秒发送一次 ping const pingInterval = setInterval(() => { if (socket.readyState === WebSocket.OPEN) { socket.send(JSON.stringify({ method: 'ping', id: Date.now() })); } }, 30000); socket.on('close', () => { clearInterval(pingInterval); }); socket.on('message', (data) => { const msg = JSON.parse(data); if (msg.method === 'pong') { // 忽略 pong 响应 return; } // 处理其他消息... }); });同时,在前端index.html中,监听ping并回复pong:
ternimal.ws.onmessage = function(event) { const data = JSON.parse(event.data); if (data.method === 'ping') { ternimal.ws.send(JSON.stringify({ method: 'pong', id: data.id })); } // 处理其他消息... };这样,连接就永远不会因空闲而断开。
4.6 坑六:Ctrl+C在扫码终端里无法中断正在运行的python脚本,脚本继续输出
这是一个经典的信号传递问题。Ctrl+C在终端里发送的是SIGINT信号,但node-pty默认不会将这个信号转发给子进程的进程组。
解决方案:在node-pty.spawn()时,显式设置env和shell选项,并确保子进程在自己的进程组中:
const pty = require('node-pty'); const shell = pty.spawn('bash', ['-i'], { // -i 表示交互式 shell name: 'xterm-256color', cols: 100, rows: 40, cwd: process.env.HOME, env: process.env, // 关键:启用 process group setEnv: true });更重要的是,在 Agent 脚本中,不要用child_process.exec(),而要用child_process.spawn(),并设置stdio: 'inherit':
// ❌ 错误:exec 会创建 shell 子进程,信号传递链断裂 const child = exec('python long_task.py'); // ✅ 正确:spawn 直接创建子进程,信号可直达 const child = spawn('python', ['long_task.py'], { stdio: 'inherit' // 继承父进程的 stdin/stdout/stderr });4.7 坑七:多个用户同时扫码,看到的是同一个会话,但彼此输入会互相干扰
Ternimal 默认是“广播模式”:所有连接到同一会话的客户端,共享同一个输入流。A 用户在手机上输入ls,B 用户在平板上也会看到ls被执行。这在协作场景下是优点,但在 AI Agent 监督场景下,往往是灾难——你不想让同事的测试命令,意外中断你的生产任务。
解决方案:启用 Ternimal 的--multi-user模式(如果版本支持),或者更通用的做法,是为每个扫码用户创建独立的node-pty实例。修改后端逻辑,不再复用一个 pty,而是为每个 WebSocket 连接分配一个新 pty:
ws.on('connection', (socket) => { // 为每个连接创建独立的 pty const pty = require('node-pty').spawn('bash', [], { // ...配置 }); // 将 pty 的数据流与 socket 绑定 pty.on('data', (data) => { socket.send(JSON.stringify({ method: 'data', params: { data } })); }); socket.on('message', (data) => { const msg = JSON.parse(data); if (msg.method === 'key') { pty.write(msg.params.key); // 只写入这个用户的输入 } }); socket.on('close', () => { pty.kill(); // 断开时,清理专属 pty }); });这样,每个扫码设备都拥有一个完全独立的终端会话副本,互不干扰。代价是内存占用稍高,但对于单个 Agent 任务来说,完全可以接受。
5. 从 Ternimal 到 AI Agent 工作流:一个可立即部署的最小可行架构
前面讲了原理、避坑、实战,现在我们把它整合成一个可立即部署、开箱即用的最小可行架构(MVP)。这个架构不追求大而全,而是聚焦于“让一个 Python 编写的 AI Agent,能被手机扫码实时监督和干预”,所有组件都选用最轻量、最易维护的方案,总代码量不超过 200 行。
5.1 架构图:三层极简模型
整个系统由三个核心组件构成,它们之间通过标准协议通信,松耦合,易于替换:
[手机浏览器] ↓ (WebSocket, wss://your-domain.com/ternimal) [Ternimal Web Server] ←→ [Agent Process] ↑ (stdin/stdout/stderr pipe) [Node.js Runtime]- Ternimal Web Server:基于 Express + node-pty 的轻量级服务,负责创建 pty、管理 WebSocket 连接、提供扫码页面。它不包含任何 AI 逻辑。
- Agent Process:一个独立的 Python 进程(
agent.py),它执行业务逻辑,将结构化日志输出到stdout/stderr,并监听stdin等待人工指令。 - 手机浏览器:纯粹的客户端,只负责展示和输入,所有计算都在服务端完成。
5.2 部署清单:5 个文件,10 分钟搞定
文件 1:package.json(Node.js 服务依赖)
{ "name": "ternimal-agent-mvp", "version": "1.0.0", "main": "server.js", "scripts": { "start": "node server.js" }, "dependencies": { "express": "^4.18.2", "node-pty": "^1.0.0", "ws": "^8.14.2", "xterm": "^5.3.0" } }文件 2:server.js(核心服务)
const express = require('express'); const http = require('http'); const WebSocket = require('ws'); const pty = require('node-pty'); const path = require('path'); const app = express(); const server = http.createServer(app); const wss = new WebSocket.Server({ server }); // 提供静态文件(xterm.js 和页面) app.use(express.static(path.join(__dirname, 'public'))); // WebSocket 连接处理 wss.on('connection', (ws, req) => { // 为每个连接创建独立 pty const ptyProcess = pty.spawn('bash', [], { name: 'xterm-256color', cols: 100, rows: 40, cwd: process.env.HOME, env: process.env }); // pty -> WebSocket ptyProcess.on('data', (data) => { ws.send(JSON.stringify({ method: 'data', params: { data } })); }); // WebSocket -> pty ws.on('message', (data) => { const msg = JSON.parse(data); if (msg.method === 'key') { ptyProcess.write(msg.params.key); } }); // 连接关闭时清理 ws.on('close', () => { ptyProcess.kill(); }); }); // 启动服务 const PORT = process.env.PORT || 3000; server.listen(PORT, () => { console.log(`Ternimal Agent MVP running on http://localhost:${PORT}`); });文件 3:public/index.html(扫码页面)
<!DOCTYPE html> <html> <head> <meta charset="utf-8"> <title>Ternimal Agent Supervisor</title> <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/xterm@5.3.0/lib/xterm.css"> <style> body { margin: 0; padding: 0; height: 100vh; overflow: hidden; } #terminal { height: 100%; } #qrcode { position: fixed; top: 20px; right