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:
@@ -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"]
|
||||
|
||||
@@ -47,7 +47,7 @@ spec:
|
||||
memory: 256Mi
|
||||
cpu: 100m
|
||||
limits:
|
||||
memory: 1Gi
|
||||
memory: 2Gi
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /api/health
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
const nextConfig = {
|
||||
output: "standalone",
|
||||
instrumentationHook: true,
|
||||
serverExternalPackages: ["node-pty"],
|
||||
};
|
||||
|
||||
module.exports = nextConfig;
|
||||
|
||||
@@ -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
169
apps/harness/pty-server.js
Normal 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
24
apps/harness/server.js
Normal 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}`);
|
||||
});
|
||||
});
|
||||
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