Add interactive PTY Chat tab with xterm.js terminal emulator
Some checks failed
CI / lint-and-test (push) Successful in 33s
CI / build (push) Has been cancelled
Deploy Production / deploy (push) Has been cancelled

Browser-based interactive terminal sessions with agent CLIs via
WebSocket + node-pty. Supports full TUI rendering (colors, cursor,
ctrl-c) through xterm.js in the browser.

Architecture: xterm.js ←WebSocket→ pty-server.js ←PTY→ agent CLI

- Extract shared buildAgentEnv() from executor into agent-env.ts
- Add internal /api/agents/[id]/env endpoint for PTY server
- Add pty-server.js (WebSocket + node-pty, max 3 sessions, 2hr cleanup)
- Add custom server.js wrapping Next.js with WebSocket upgrade
- Add ChatTab component with agent selector and terminal
- Wire CHAT tab into dashboard nav and render
- Configure serverExternalPackages for node-pty
- Update Dockerfile with build tools and custom server
- Bump k8s memory limit 1Gi → 2Gi for PTY sessions
This commit is contained in:
Julia McGhee
2026-03-21 20:42:58 +00:00
parent f45fa64855
commit 7bb091d4b3
12 changed files with 672 additions and 38 deletions

View File

@@ -2,7 +2,7 @@ FROM node:20-alpine AS base
RUN corepack enable && corepack prepare pnpm@latest --activate
FROM base AS deps
RUN apk add --no-cache libc6-compat
RUN apk add --no-cache libc6-compat python3 make gcc g++ linux-headers
WORKDIR /app
# Copy workspace root config + relevant package.jsons for install
@@ -40,7 +40,14 @@ RUN mkdir -p /data/harness && chown nextjs:nodejs /data/harness
COPY --from=builder /app/apps/harness/public ./apps/harness/public
COPY --from=builder --chown=nextjs:nodejs /app/apps/harness/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/apps/harness/.next/static ./apps/harness/.next/static
# PTY server dependencies + custom server
COPY --from=builder /app/node_modules/.pnpm/node-pty*/node_modules/node-pty ./node_modules/node-pty
COPY --from=builder /app/node_modules/.pnpm/ws*/node_modules/ws ./node_modules/ws
COPY apps/harness/server.js ./server.js
COPY apps/harness/pty-server.js ./pty-server.js
USER nextjs
EXPOSE 3100
ENV PORT=3100
CMD ["node", "apps/harness/server.js"]
CMD ["node", "server.js"]

View File

@@ -47,7 +47,7 @@ spec:
memory: 256Mi
cpu: 100m
limits:
memory: 1Gi
memory: 2Gi
readinessProbe:
httpGet:
path: /api/health

View File

@@ -2,6 +2,7 @@
const nextConfig = {
output: "standalone",
instrumentationHook: true,
serverExternalPackages: ["node-pty"],
};
module.exports = nextConfig;

View File

@@ -11,17 +11,22 @@
},
"dependencies": {
"@homelab/db": "workspace:^",
"@xterm/addon-fit": "^0.11.0",
"@xterm/xterm": "^6.0.0",
"drizzle-orm": "^0.36.0",
"next": "^15.1.0",
"node-pty": "^1.1.0",
"postgres": "^3.4.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"ws": "^8.20.0",
"yaml": "^2.7.0"
},
"devDependencies": {
"@types/node": "^22.10.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@types/ws": "^8.18.1",
"typescript": "^5.7.0"
}
}

169
apps/harness/pty-server.js Normal file
View File

@@ -0,0 +1,169 @@
// @ts-check
const pty = require("node-pty");
const { WebSocketServer } = require("ws");
const MAX_SESSIONS = 3;
const MAX_SESSION_AGE_MS = 2 * 60 * 60 * 1000; // 2 hours
const CLEANUP_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes
/** @type {Map<string, { pty: import("node-pty").IPty, ws: import("ws").WebSocket, createdAt: number }>} */
const sessions = new Map();
/**
* @param {import("http").Server} server
*/
function attachPtyWebSocket(server) {
const wss = new WebSocketServer({ noServer: true });
server.on("upgrade", (req, socket, head) => {
const url = new URL(req.url || "/", `http://${req.headers.host}`);
if (url.pathname !== "/ws/pty") return;
wss.handleUpgrade(req, socket, head, (ws) => {
wss.emit("connection", ws, req);
});
});
wss.on("connection", async (ws, req) => {
const url = new URL(req.url || "/", `http://${req.headers.host}`);
const agentId = url.searchParams.get("agentId");
const cols = parseInt(url.searchParams.get("cols") || "80", 10);
const rows = parseInt(url.searchParams.get("rows") || "24", 10);
if (!agentId) {
ws.send(JSON.stringify({ type: "error", message: "agentId required" }));
ws.close();
return;
}
if (sessions.size >= MAX_SESSIONS) {
ws.send(
JSON.stringify({
type: "error",
message: `max ${MAX_SESSIONS} concurrent sessions`,
}),
);
ws.close();
return;
}
// Fetch agent config + env from the Next.js API
const port = process.env.PORT || 3100;
let config;
try {
const res = await fetch(
`http://127.0.0.1:${port}/api/agents/${encodeURIComponent(agentId)}/env`,
);
if (!res.ok) {
const body = await res.json().catch(() => ({}));
ws.send(
JSON.stringify({
type: "error",
message: body.error || `agent lookup failed: ${res.status}`,
}),
);
ws.close();
return;
}
config = await res.json();
} catch (err) {
ws.send(
JSON.stringify({
type: "error",
message: `failed to fetch agent config: ${err.message}`,
}),
);
ws.close();
return;
}
const sessionId = `${agentId}-${Date.now()}`;
let term;
try {
term = pty.spawn(config.command, config.args, {
name: "xterm-256color",
cols,
rows,
cwd: config.workDir,
env: config.env,
});
} catch (err) {
ws.send(
JSON.stringify({
type: "error",
message: `failed to spawn PTY: ${err.message}`,
}),
);
ws.close();
return;
}
sessions.set(sessionId, { pty: term, ws, createdAt: Date.now() });
ws.send(JSON.stringify({ type: "connected", sessionId }));
term.onData((data) => {
if (ws.readyState === ws.OPEN) {
ws.send(data);
}
});
term.onExit(({ exitCode }) => {
if (ws.readyState === ws.OPEN) {
ws.send(JSON.stringify({ type: "exit", code: exitCode }));
ws.close();
}
sessions.delete(sessionId);
});
ws.on("message", (data) => {
const msg = data.toString();
// Try to parse as JSON control message
if (msg.startsWith("{")) {
try {
const ctrl = JSON.parse(msg);
if (ctrl.type === "resize" && ctrl.cols && ctrl.rows) {
term.resize(ctrl.cols, ctrl.rows);
return;
}
} catch {
// Not JSON, treat as terminal input
}
}
term.write(msg);
});
ws.on("close", () => {
try {
term.kill();
} catch {
// Already dead
}
sessions.delete(sessionId);
});
});
// Periodic cleanup of stale sessions
setInterval(() => {
const now = Date.now();
for (const [id, session] of sessions) {
if (now - session.createdAt > MAX_SESSION_AGE_MS) {
try {
session.pty.kill();
} catch {
// ignore
}
try {
session.ws.close();
} catch {
// ignore
}
sessions.delete(id);
}
}
}, CLEANUP_INTERVAL_MS);
return wss;
}
module.exports = { attachPtyWebSocket };

24
apps/harness/server.js Normal file
View File

@@ -0,0 +1,24 @@
const { createServer } = require("http");
const { parse } = require("url");
const next = require("next");
const { attachPtyWebSocket } = require("./pty-server");
const dev = process.env.NODE_ENV !== "production";
const hostname = process.env.HOSTNAME || "0.0.0.0";
const port = parseInt(process.env.PORT || "3100", 10);
const app = next({ dev, hostname, port });
const handle = app.getRequestHandler();
app.prepare().then(() => {
const server = createServer((req, res) => {
const parsedUrl = parse(req.url || "/", true);
handle(req, res, parsedUrl);
});
attachPtyWebSocket(server);
server.listen(port, hostname, () => {
console.log(`> Harness ready on http://${hostname}:${port}`);
});
});

View File

@@ -0,0 +1,40 @@
import { NextRequest, NextResponse } from "next/server";
import { getAgentConfig } from "@/lib/agents";
import { buildAgentEnv, buildInteractiveCommand } from "@/lib/agent-env";
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
// Only allow localhost access
const forwarded = _request.headers.get("x-forwarded-for");
const host = _request.headers.get("host") ?? "";
const isLocal =
!forwarded &&
(host.startsWith("localhost") || host.startsWith("127.0.0.1"));
if (!isLocal) {
return NextResponse.json({ error: "forbidden" }, { status: 403 });
}
const { id } = await params;
const config = await getAgentConfig(id);
if (!config) {
return NextResponse.json({ error: "agent not found" }, { status: 404 });
}
const env = await buildAgentEnv(config);
const { command, args } = buildInteractiveCommand(config);
// Strip process.env inherited values — only send what we explicitly set
const cleanEnv: Record<string, string> = {};
for (const [k, v] of Object.entries(env)) {
if (v !== undefined) cleanEnv[k] = v;
}
return NextResponse.json({
command,
args,
env: cleanEnv,
workDir: process.env.HARNESS_WORK_DIR || "/data/harness",
});
}

View File

@@ -0,0 +1,304 @@
"use client";
import { useState, useEffect, useRef, useCallback } from "react";
import {
tokens,
Label,
Mono,
Btn,
Panel,
PanelHeader,
SearchableDropdown,
DropdownOption,
} from "./harness-design-system";
type SessionState = "idle" | "connecting" | "connected" | "disconnected";
interface Agent {
id: string;
name: string;
runtime: string;
modelId: string;
}
export default function ChatTab({ mobile }: { mobile: boolean }) {
const [agents, setAgents] = useState<Agent[]>([]);
const [selectedAgentId, setSelectedAgentId] = useState<string>("");
const [state, setState] = useState<SessionState>("idle");
const termRef = useRef<HTMLDivElement>(null);
const xtermRef = useRef<any>(null);
const fitRef = useRef<any>(null);
const wsRef = useRef<WebSocket | null>(null);
// Fetch agents list
useEffect(() => {
fetch("/api/agents")
.then((r) => r.json())
.then((data) => {
setAgents(data.configs || []);
})
.catch(() => {});
}, []);
const disconnect = useCallback(() => {
if (wsRef.current) {
wsRef.current.close();
wsRef.current = null;
}
setState("disconnected");
}, []);
const connect = useCallback(async () => {
if (!selectedAgentId || !termRef.current) return;
setState("connecting");
// Dynamic import xterm (SSR-safe)
const { Terminal } = await import("@xterm/xterm");
const { FitAddon } = await import("@xterm/addon-fit");
// CSS is imported at module level below
await import("@xterm/xterm/css/xterm.css" as any);
// Clean up previous terminal
if (xtermRef.current) {
xtermRef.current.dispose();
}
termRef.current.innerHTML = "";
const term = new Terminal({
cursorBlink: true,
fontFamily: tokens.font.mono,
fontSize: 14,
theme: {
background: tokens.color.bg1,
foreground: tokens.color.text0,
cursor: tokens.color.accent,
selectionBackground: "#374151",
},
});
const fit = new FitAddon();
term.loadAddon(fit);
term.open(termRef.current);
fit.fit();
xtermRef.current = term;
fitRef.current = fit;
const cols = term.cols;
const rows = term.rows;
const proto = location.protocol === "https:" ? "wss:" : "ws:";
const ws = new WebSocket(
`${proto}//${location.host}/ws/pty?agentId=${encodeURIComponent(selectedAgentId)}&cols=${cols}&rows=${rows}`,
);
wsRef.current = ws;
ws.onopen = () => {
setState("connected");
};
ws.onmessage = (e) => {
const data = e.data;
// Check for JSON control messages
if (typeof data === "string" && data.startsWith("{")) {
try {
const msg = JSON.parse(data);
if (msg.type === "exit") {
term.writeln(`\r\n\x1b[90m[session exited with code ${msg.code}]\x1b[0m`);
setState("disconnected");
return;
}
if (msg.type === "error") {
term.writeln(`\r\n\x1b[31m[error: ${msg.message}]\x1b[0m`);
setState("disconnected");
return;
}
if (msg.type === "connected") {
return;
}
} catch {
// Not JSON, render as terminal output
}
}
term.write(data);
};
ws.onclose = () => {
setState("disconnected");
};
ws.onerror = () => {
setState("disconnected");
};
// Terminal → WebSocket
term.onData((data: string) => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(data);
}
});
// Resize handling
term.onResize(({ cols, rows }: { cols: number; rows: number }) => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: "resize", cols, rows }));
}
});
}, [selectedAgentId]);
// ResizeObserver for terminal container
useEffect(() => {
const el = termRef.current;
if (!el) return;
const observer = new ResizeObserver(() => {
if (fitRef.current) {
try {
fitRef.current.fit();
} catch {
// Terminal may not be ready
}
}
});
observer.observe(el);
return () => observer.disconnect();
}, []);
// Cleanup on unmount
useEffect(() => {
return () => {
if (wsRef.current) wsRef.current.close();
if (xtermRef.current) xtermRef.current.dispose();
};
}, []);
const agentOptions: DropdownOption[] = agents.map((a) => ({
value: a.id,
label: `${a.name}`,
sub: `${a.runtime} · ${a.modelId}`,
}));
const statusColor =
state === "connected"
? tokens.color.pass
: state === "connecting"
? tokens.color.warn
: state === "disconnected"
? tokens.color.fail
: tokens.color.muted;
const statusLabel =
state === "connected"
? "CONNECTED"
: state === "connecting"
? "CONNECTING"
: state === "disconnected"
? "DISCONNECTED"
: "IDLE";
return (
<div
style={{
flex: 1,
display: "flex",
flexDirection: "column",
padding: mobile ? tokens.space[3] : tokens.space[5],
gap: tokens.space[4],
overflow: "hidden",
}}
>
{/* Header bar */}
<div
style={{
display: "flex",
alignItems: "center",
gap: tokens.space[4],
flexWrap: "wrap",
}}
>
<div style={{ minWidth: 240, flex: mobile ? 1 : undefined }}>
<SearchableDropdown
options={agentOptions}
value={selectedAgentId}
onChange={(v) => setSelectedAgentId(Array.isArray(v) ? v[0] : v)}
placeholder="Select agent..."
/>
</div>
{state === "connected" ? (
<Btn onClick={disconnect} style={{ background: tokens.color.failDim, borderColor: tokens.color.fail }}>
DISCONNECT
</Btn>
) : (
<Btn
onClick={connect}
disabled={!selectedAgentId || state === "connecting"}
>
{state === "connecting" ? "CONNECTING..." : "CONNECT"}
</Btn>
)}
<div
style={{
display: "flex",
alignItems: "center",
gap: tokens.space[2],
marginLeft: "auto",
}}
>
<div
style={{
width: 7,
height: 7,
borderRadius: "50%",
background: statusColor,
boxShadow: state === "connected" ? tokens.color.accentGlow : undefined,
}}
/>
<Label color={statusColor} style={{ fontSize: tokens.size.xs }}>
{statusLabel}
</Label>
</div>
</div>
{/* Terminal area */}
<div
style={{
flex: 1,
background: tokens.color.bg1,
border: `1px solid ${tokens.color.border0}`,
borderRadius: 6,
overflow: "hidden",
position: "relative",
minHeight: 0,
}}
>
<div
ref={termRef}
style={{
width: "100%",
height: "100%",
padding: 4,
}}
/>
{state === "idle" && (
<div
style={{
position: "absolute",
inset: 0,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<Mono size={tokens.size.sm} color={tokens.color.text3}>
Select an agent and click CONNECT to start an interactive session
</Mono>
</div>
)}
</div>
</div>
);
}

View File

@@ -6,6 +6,7 @@ import {
Btn, Input, Textarea, EvalPip, IterDot, PathCrumb, BackBtn,
SearchableDropdown, DropdownOption,
} from "./harness-design-system";
import ChatTab from "./chat-tab";
// ─── TYPES ──────────────────────────────────────────────────────────────────────
@@ -1639,6 +1640,7 @@ function ModelsTab({ mobile }: { mobile: boolean }) {
const NAV_ITEMS = [
{ id: "LOOPS", icon: "⟳", label: "LOOPS" },
{ id: "CHAT", icon: "▸", label: "CHAT" },
{ id: "PROJECTS", icon: "◫", label: "PROJECTS" },
{ id: "MODELS", icon: "◈", label: "MODELS" },
{ id: "KNOWLEDGE", icon: "≡", label: "DOCS" },
@@ -1672,7 +1674,7 @@ function BottomNav({ activeTab, setActiveTab, tasks }: { activeTab: string; setA
function TopBar({ activeTab, setActiveTab, tasks, mobile }: { activeTab: string; setActiveTab: (t: string) => void; tasks: Task[]; mobile: boolean }) {
const running = tasks.filter(t => t.status === "running").length;
const pending = tasks.filter(t => t.status === "pending").length;
const tabs = ["LOOPS", "PROJECTS", "MODELS", "KNOWLEDGE", "HISTORY", "NEW TASK"];
const tabs = ["LOOPS", "CHAT", "PROJECTS", "MODELS", "KNOWLEDGE", "HISTORY", "NEW TASK"];
return (
<div style={{ borderBottom: `1px solid ${tokens.color.border0}`, display: "flex", alignItems: "stretch", height: 48, flexShrink: 0, background: tokens.color.bg0 }}>
@@ -1760,6 +1762,7 @@ export default function HarnessDashboard() {
<div style={{ flex: 1, display: "flex", overflow: "hidden" }}>
{activeTab === "LOOPS" && <LoopsTab tasks={tasks} setTasks={setTasks} mobile={mobile} />}
{activeTab === "CHAT" && <ChatTab mobile={mobile} />}
{activeTab === "PROJECTS" && <ProjectsTab projects={projects} setProjects={setProjects} mobile={mobile} />}
{activeTab === "MODELS" && <ModelsTab mobile={mobile} />}
{activeTab === "KNOWLEDGE" && <KnowledgeTab docs={knowledgeDocs} mobile={mobile} />}

View File

@@ -0,0 +1,55 @@
import { AgentConfig, AGENT_RUNTIMES } from "./agents";
import { getRawCredentialsByProvider, Provider } from "./credentials";
const PROVIDER_ENV_VARS: Record<string, string> = {
anthropic: "ANTHROPIC_API_KEY",
openai: "OPENAI_API_KEY",
google: "GOOGLE_API_KEY",
openrouter: "OPENROUTER_API_KEY",
"opencode-zen": "OPENCODE_ZEN_API_KEY",
};
export async function buildAgentEnv(
config: AgentConfig,
): Promise<NodeJS.ProcessEnv> {
const env: NodeJS.ProcessEnv = { ...process.env, TERM: "xterm-256color" };
const providersToInject =
config.runtime === "opencode"
? Object.keys(PROVIDER_ENV_VARS)
: [config.provider];
for (const provider of providersToInject) {
const envVar = PROVIDER_ENV_VARS[provider];
if (!envVar) continue;
const creds = await getRawCredentialsByProvider(provider as Provider);
if (creds.length > 0) {
env[envVar] = creds[0].token;
}
}
const ghCreds = await getRawCredentialsByProvider("github" as Provider);
if (ghCreds.length > 0) {
env.GITHUB_TOKEN = ghCreds[0].token;
env.GH_TOKEN = ghCreds[0].token;
}
if (config.env) {
Object.assign(env, config.env);
}
return env;
}
export function buildInteractiveCommand(
config: AgentConfig,
): { command: string; args: string[] } {
const runtime = AGENT_RUNTIMES[config.runtime];
const args: string[] = [];
if (runtime.modelFlag && config.modelId) {
args.push(runtime.modelFlag, config.modelId);
}
return { command: runtime.cliCommand, args };
}

View File

@@ -1,18 +1,10 @@
import { spawn, ChildProcess } from "node:child_process";
import { getAgentConfig, buildAgentCommand, AGENT_RUNTIMES } from "./agents";
import { getRawCredentialsByProvider, Provider } from "./credentials";
import { buildAgentEnv } from "./agent-env";
import { ExecutionResult } from "./types";
const DEFAULT_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes
const PROVIDER_ENV_VARS: Record<string, string> = {
anthropic: "ANTHROPIC_API_KEY",
openai: "OPENAI_API_KEY",
google: "GOOGLE_API_KEY",
openrouter: "OPENROUTER_API_KEY",
"opencode-zen": "OPENCODE_ZEN_API_KEY",
};
const TOKEN_PATTERNS: Record<string, { input: RegExp; output: RegExp }> = {
"claude-code": {
input: /input[_\s]tokens?[:\s]+(\d[\d,]*)/i,
@@ -50,31 +42,7 @@ export async function executeAgent(opts: {
const command = args[0];
const commandArgs = args.slice(1);
const env: NodeJS.ProcessEnv = { ...process.env };
const providersToInject =
config.runtime === "opencode"
? Object.keys(PROVIDER_ENV_VARS)
: [config.provider];
for (const provider of providersToInject) {
const envVar = PROVIDER_ENV_VARS[provider];
if (!envVar) continue;
const creds = await getRawCredentialsByProvider(provider as Provider);
if (creds.length > 0) {
env[envVar] = creds[0].token;
}
}
const ghCreds = await getRawCredentialsByProvider("github" as Provider);
if (ghCreds.length > 0) {
env.GITHUB_TOKEN = ghCreds[0].token;
env.GH_TOKEN = ghCreds[0].token;
}
if (config.env) {
Object.assign(env, config.env);
}
const env = await buildAgentEnv(config);
const timeout = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
const startTime = Date.now();