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

@@ -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();