Replace command-runner Terminal with a real interactive PTY

The previous Terminal ran one command at a time via subprocess.run
and returned its output - it couldn't run interactive programs
(colors, live input, TUIs like gemini's chat/auth flow), which is
what a terminal actually needs to do.

Backend: new /ws/terminal WebSocket endpoint spawns a real shell
attached to a pseudo-terminal (stdlib pty on POSIX, pywinpty/ConPTY
on Windows) and streams raw I/O bidirectionally, with resize support.
Replaces the old POST /api/terminal/run and GET /api/terminal/session
endpoints entirely.

Frontend: terminal.js now loads xterm.js + the fit addon from CDN
and renders a real terminal emulator wired to the WebSocket, instead
of a scrollback div with a single input line.

Verified on this Linux sandbox via raw WebSocket tests: shell spawns
correctly, commands execute and echo real output, resize propagates
to the PTY (confirmed via ), and closing the connection
cleanly kills the shell process with no orphans (interactive bash
ignores SIGTERM by default, so cleanup uses SIGKILL). Could not
visually verify the xterm.js browser rendering in this sandbox since
its egress policy blocks the CDN (cdn.jsdelivr.net) outright - same
CDN this app already uses for chart.js, so expected to work on a
normal machine; please confirm on Windows.
This commit is contained in:
Claude 2026-07-06 05:12:54 +00:00
parent 22f8c710dd
commit d7a1cbd292
5 changed files with 216 additions and 157 deletions

View File

@ -52,9 +52,6 @@ const api = {
discoverStandards: () => api.post('/api/standards/discover'),
chat: (agent, message, controller) => api.post('/api/chat', { agent, message }, controller),
getChatHistory: () => api.get('/api/chat/history'),
// Terminal
getTerminalSession: () => api.get('/api/terminal/session'),
runTerminalCommand: (command) => api.post('/api/terminal/run', { command }),
// Kanban
getKanbanBoard: (status) => api.get(status ? `/api/kanban/board?status=${encodeURIComponent(status)}` : '/api/kanban/board'),
getKanbanTask: (id) => api.get(`/api/kanban/tasks/${encodeURIComponent(id)}`),

View File

@ -1,114 +1,120 @@
let _termInstance = null;
let _termSocket = null;
function loadXterm() {
if (window.Terminal && window.FitAddon) return Promise.resolve();
return new Promise((resolve, reject) => {
if (!document.querySelector('link[data-xterm-css]')) {
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = 'https://cdn.jsdelivr.net/npm/xterm@5.3.0/css/xterm.css';
link.setAttribute('data-xterm-css', '1');
document.head.appendChild(link);
}
const script = document.createElement('script');
script.src = 'https://cdn.jsdelivr.net/npm/xterm@5.3.0/lib/xterm.js';
script.onload = () => {
const fitScript = document.createElement('script');
fitScript.src = 'https://cdn.jsdelivr.net/npm/xterm-addon-fit@0.8.0/lib/xterm-addon-fit.js';
fitScript.onload = () => resolve();
fitScript.onerror = () => reject(new Error('Failed to load xterm-addon-fit'));
document.body.appendChild(fitScript);
};
script.onerror = () => reject(new Error('Failed to load xterm.js'));
document.body.appendChild(script);
});
}
function setTerminalStatus(kind, text) {
const el = document.getElementById('terminalStatus');
if (!el) return;
el.textContent = text;
el.className = `badge badge-${kind === 'online' ? 'success' : kind === 'offline' ? 'danger' : 'warning'}`;
}
function closeTerminalSession() {
if (window._terminalResizeHandler) {
window.removeEventListener('resize', window._terminalResizeHandler);
window._terminalResizeHandler = null;
}
if (_termSocket) {
try { _termSocket.close(); } catch {}
_termSocket = null;
}
if (_termInstance) {
try { _termInstance.dispose(); } catch {}
_termInstance = null;
}
}
async function renderTerminal() {
const content = document.getElementById('pageContent');
content.innerHTML = `
<div class="page-header">
<div class="page-header-left">
<h1 class="page-title">Terminal</h1>
<p class="page-subtitle">Run shell commands directly on this machine</p>
<p class="page-subtitle">A real, interactive shell running on this machine</p>
</div>
<div class="btn-group">
<button class="btn" onclick="clearTerminal()">🗑 Clear</button>
<span id="terminalStatus" class="badge badge-warning">Connecting</span>
</div>
</div>
<div class="terminal-panel" onclick="focusTerminalInput()">
<div id="terminalOutput" class="terminal-output"></div>
<div class="terminal-input-row">
<span class="terminal-prompt" id="terminalPrompt">$</span>
<input id="terminalInput" class="terminal-input" type="text" autocomplete="off" spellcheck="false" onkeydown="handleTerminalKey(event)">
</div>
<div class="terminal-panel">
<div id="xtermContainer" class="terminal-xterm-container"></div>
</div>
`;
window._terminalHistory = window._terminalHistory || [];
window._terminalHistoryIndex = window._terminalHistory.length;
window._terminalBusy = false;
closeTerminalSession();
try {
const session = await api.getTerminalSession();
updateTerminalPrompt(session.cwd);
} catch {
appendTerminalLine('Could not reach the terminal backend.', 'stderr');
}
document.getElementById('terminalInput').focus();
}
function focusTerminalInput() {
const input = document.getElementById('terminalInput');
if (input) input.focus();
}
function updateTerminalPrompt(cwd) {
const prompt = document.getElementById('terminalPrompt');
if (prompt) prompt.textContent = `${cwd} $`;
}
function appendTerminalLine(text, kind = '') {
const output = document.getElementById('terminalOutput');
if (!output || !text) return;
const line = document.createElement('div');
if (kind) line.className = `terminal-line-${kind}`;
line.textContent = text;
output.appendChild(line);
output.scrollTop = output.scrollHeight;
}
function clearTerminal() {
const output = document.getElementById('terminalOutput');
if (output) output.innerHTML = '';
}
async function handleTerminalKey(e) {
const input = e.target;
if (e.key === 'ArrowUp') {
e.preventDefault();
if (window._terminalHistoryIndex > 0) {
window._terminalHistoryIndex--;
input.value = window._terminalHistory[window._terminalHistoryIndex] || '';
}
return;
}
if (e.key === 'ArrowDown') {
e.preventDefault();
if (window._terminalHistoryIndex < window._terminalHistory.length - 1) {
window._terminalHistoryIndex++;
input.value = window._terminalHistory[window._terminalHistoryIndex] || '';
} else {
window._terminalHistoryIndex = window._terminalHistory.length;
input.value = '';
}
return;
}
if (e.key !== 'Enter' || window._terminalBusy) return;
const command = input.value.trim();
input.value = '';
if (!command) return;
window._terminalHistory.push(command);
window._terminalHistoryIndex = window._terminalHistory.length;
appendTerminalLine(command, 'cmd');
if (command === 'clear' || command === 'cls') {
clearTerminal();
return;
}
window._terminalBusy = true;
input.disabled = true;
try {
const r = await api.runTerminalCommand(command);
if (r.stdout) appendTerminalLine(r.stdout.replace(/\n$/, ''));
if (r.stderr) appendTerminalLine(r.stderr.replace(/\n$/, ''), 'stderr');
if (r.timed_out) appendTerminalLine('Command timed out after 60s.', 'info');
updateTerminalPrompt(r.cwd);
await loadXterm();
} catch (err) {
appendTerminalLine(`Error: ${err.message}`, 'stderr');
} finally {
window._terminalBusy = false;
input.disabled = false;
input.focus();
document.getElementById('xtermContainer').innerHTML =
`<div class="empty-state"><div class="empty-state-icon">⚠</div><div class="empty-state-title">Failed to load terminal library</div><div class="empty-state-desc">${escapeHtml(err.message || String(err))}</div></div>`;
return;
}
const term = new window.Terminal({
cursorBlink: true,
fontFamily: "'JetBrains Mono', 'Fira Code', 'SF Mono', monospace",
fontSize: 13,
theme: { background: '#0a0e14', foreground: '#c9d1d9' },
});
const fitAddon = new window.FitAddon.FitAddon();
term.loadAddon(fitAddon);
term.open(document.getElementById('xtermContainer'));
fitAddon.fit();
_termInstance = term;
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const socket = new WebSocket(`${proto}//${window.location.host}/ws/terminal`);
_termSocket = socket;
socket.onopen = () => {
setTerminalStatus('online', 'Connected');
fitAddon.fit();
socket.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }));
term.focus();
};
socket.onmessage = (event) => {
try {
const msg = JSON.parse(event.data);
if (msg.type === 'output') term.write(msg.data);
} catch {}
};
socket.onclose = () => setTerminalStatus('offline', 'Disconnected');
socket.onerror = () => setTerminalStatus('offline', 'Connection error');
term.onData((data) => {
if (socket.readyState === WebSocket.OPEN) socket.send(JSON.stringify({ type: 'input', data }));
});
const resizeHandler = () => {
fitAddon.fit();
if (socket.readyState === WebSocket.OPEN) socket.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }));
};
window.addEventListener('resize', resizeHandler);
window._terminalResizeHandler = resizeHandler;
window.addEventListener('hashchange', closeTerminalSession, { once: true });
}

View File

@ -824,34 +824,17 @@ pre {
/* Terminal */
.terminal-panel {
flex: 1; display: flex; flex-direction: column;
min-height: 0;
min-height: 0; height: calc(100vh - 180px);
background: #0a0e14;
border: 1px solid var(--border);
border-radius: var(--radius-lg);
overflow: hidden;
box-shadow: var(--shadow);
font-family: var(--font-mono);
}
.terminal-output {
flex: 1; overflow-y: auto; padding: 16px;
font-size: 13px; line-height: 1.6;
color: #c9d1d9; white-space: pre-wrap; word-break: break-word;
}
.terminal-line-cmd { color: #7ee787; }
.terminal-line-cmd::before { content: '$ '; color: #58a6ff; }
.terminal-line-stderr { color: #ff7b72; }
.terminal-line-info { color: var(--text-muted); font-style: italic; }
.terminal-input-row {
display: flex; align-items: center; gap: 8px;
padding: 10px 16px;
background: #0d1117;
border-top: 1px solid var(--border);
}
.terminal-prompt { color: #58a6ff; font-size: 13px; white-space: nowrap; }
.terminal-input {
flex: 1; background: transparent; border: none; outline: none;
color: #c9d1d9; font-family: var(--font-mono); font-size: 13px;
.terminal-xterm-container {
flex: 1; min-height: 0; padding: 8px;
}
.terminal-xterm-container .xterm { height: 100%; }
/* ─── v0.2.0 UI Modernization ─── */

View File

@ -3,3 +3,4 @@ uvicorn[standard]>=0.32.0
apscheduler>=3.10.0
pydantic>=2.0.0
python-multipart>=0.0.12
pywinpty>=2.0.0; sys_platform == "win32"

146
server.py
View File

@ -4,10 +4,12 @@ Agentic OS — FastAPI Backend
Multi-agent orchestration server for opencode, Hermes, Gemini CLI
"""
import argparse
import asyncio
import json
import os
import re
import shutil
import signal
import subprocess
import tarfile
import threading
@ -17,7 +19,7 @@ from datetime import datetime, timezone
from pathlib import Path
from typing import Optional
from fastapi import FastAPI, HTTPException, Query
from fastapi import FastAPI, HTTPException, Query, WebSocket, WebSocketDisconnect
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import HTMLResponse, JSONResponse, Response
from fastapi.staticfiles import StaticFiles
@ -101,9 +103,6 @@ class ChatRequest(BaseModel):
agent: str
message: str
class TerminalRunRequest(BaseModel):
command: str
# ─── Helper Functions ─────────────────────────────────────────────
def read_file(path: Path):
@ -667,45 +666,118 @@ def chat(req: ChatRequest):
def get_chat_history():
return load_chat_history()
# ─── Routes: Terminal ─────────────────────────────────────────────
# ─── Routes: Terminal (real interactive PTY over WebSocket) ──────
_terminal_cwd = str(BASE_DIR)
class PtySession:
"""Wraps a real pseudo-terminal shell process, POSIX (stdlib pty) or Windows (pywinpty)."""
@app.get("/api/terminal/session")
def get_terminal_session():
return {"cwd": _terminal_cwd}
def __init__(self):
self.master_fd = None
self.pid = None
self.winpty_process = None
@app.post("/api/terminal/run")
def run_terminal_command(req: TerminalRunRequest):
global _terminal_cwd
command = req.command.strip()
if not command:
return {"cwd": _terminal_cwd, "stdout": "", "stderr": "", "returncode": 0, "timed_out": False}
def start(self, cwd: str):
if os.name == "nt":
import winpty
shell = shutil.which("powershell.exe") or "powershell.exe"
self.winpty_process = winpty.PtyProcess.spawn([shell, "-NoLogo"], cwd=cwd)
else:
import pty
pid, fd = pty.fork()
if pid == 0:
os.chdir(cwd)
shell = os.environ.get("SHELL", "/bin/bash")
os.execvp(shell, [shell])
else:
self.master_fd = fd
self.pid = pid
if command == "cd" or command.startswith("cd "):
target = command[2:].strip() or str(Path.home())
new_dir = (Path(_terminal_cwd) / target).resolve() if not Path(target).is_absolute() else Path(target).resolve()
if not new_dir.is_dir():
return {"cwd": _terminal_cwd, "stdout": "", "stderr": f"cd: no such directory: {target}", "returncode": 1, "timed_out": False}
_terminal_cwd = str(new_dir)
append_audit({"action": "terminal_command", "command": "cd", "cwd": _terminal_cwd})
return {"cwd": _terminal_cwd, "stdout": "", "stderr": "", "returncode": 0, "timed_out": False}
def read(self, size: int = 4096):
if self.winpty_process is not None:
return self.winpty_process.read(size)
return os.read(self.master_fd, size)
def write(self, data: str):
if self.winpty_process is not None:
self.winpty_process.write(data)
else:
os.write(self.master_fd, data.encode())
def resize(self, cols: int, rows: int):
if self.winpty_process is not None:
self.winpty_process.setwinsize(rows, cols)
else:
import fcntl
import struct
import termios
fcntl.ioctl(self.master_fd, termios.TIOCSWINSZ, struct.pack("HHHH", rows, cols, 0, 0))
def close(self):
if self.winpty_process is not None:
try:
self.winpty_process.close(force=True)
except Exception:
pass
else:
try:
os.close(self.master_fd)
except OSError:
pass
if self.pid:
# Interactive shells commonly ignore SIGTERM; SIGKILL can't be blocked/ignored.
try:
os.kill(self.pid, signal.SIGKILL)
except OSError:
pass
@app.websocket("/ws/terminal")
async def ws_terminal(websocket: WebSocket):
await websocket.accept()
session = PtySession()
try:
session.start(str(BASE_DIR))
except Exception as e:
await websocket.send_json({"type": "output", "data": f"Failed to start terminal: {e}\r\n"})
await websocket.close()
return
append_audit({"action": "terminal_session_started"})
loop = asyncio.get_event_loop()
stop = False
def reader():
while not stop:
try:
data = session.read(4096)
except (OSError, EOFError):
break
if not data:
break
text = data.decode(errors="replace") if isinstance(data, bytes) else data
fut = asyncio.run_coroutine_threadsafe(websocket.send_json({"type": "output", "data": text}), loop)
try:
fut.result(timeout=5)
except Exception:
break
asyncio.run_coroutine_threadsafe(websocket.close(), loop)
threading.Thread(target=reader, daemon=True).start()
try:
if os.name == "nt":
r = subprocess.run(
["powershell", "-NoProfile", "-NonInteractive", "-Command", command],
cwd=_terminal_cwd, capture_output=True, text=True, timeout=60,
)
else:
r = subprocess.run(
command, shell=True, cwd=_terminal_cwd,
capture_output=True, text=True, timeout=60,
)
append_audit({"action": "terminal_command", "command": command[:200], "cwd": _terminal_cwd})
return {"cwd": _terminal_cwd, "stdout": r.stdout, "stderr": r.stderr, "returncode": r.returncode, "timed_out": False}
except subprocess.TimeoutExpired:
return {"cwd": _terminal_cwd, "stdout": "", "stderr": "Command timed out after 60s.", "returncode": -1, "timed_out": True}
while True:
msg = await websocket.receive_json()
if msg.get("type") == "input":
session.write(msg.get("data", ""))
elif msg.get("type") == "resize":
session.resize(int(msg.get("cols", 80)), int(msg.get("rows", 24)))
except WebSocketDisconnect:
pass
except Exception:
pass
finally:
stop = True
session.close()
append_audit({"action": "terminal_session_ended"})
# ═══════════════════════════════════════════════════════════════════
# v0.2.0 — New Feature Endpoints