Add interactive PTY Chat tab with xterm.js terminal emulator
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:
40
apps/harness/src/app/api/agents/[id]/env/route.ts
vendored
Normal file
40
apps/harness/src/app/api/agents/[id]/env/route.ts
vendored
Normal 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",
|
||||
});
|
||||
}
|
||||
304
apps/harness/src/components/chat-tab.tsx
Normal file
304
apps/harness/src/components/chat-tab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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} />}
|
||||
|
||||
55
apps/harness/src/lib/agent-env.ts
Normal file
55
apps/harness/src/lib/agent-env.ts
Normal 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 };
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user