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();
|
||||
|
||||
58
pnpm-lock.yaml
generated
58
pnpm-lock.yaml
generated
@@ -75,12 +75,21 @@ importers:
|
||||
'@homelab/db':
|
||||
specifier: workspace:^
|
||||
version: link:../../packages/db
|
||||
'@xterm/addon-fit':
|
||||
specifier: ^0.11.0
|
||||
version: 0.11.0
|
||||
'@xterm/xterm':
|
||||
specifier: ^6.0.0
|
||||
version: 6.0.0
|
||||
drizzle-orm:
|
||||
specifier: ^0.36.0
|
||||
version: 0.36.4(@opentelemetry/api@1.9.0)(@types/pg@8.15.6)(@types/react@19.2.14)(postgres@3.4.8)(react@19.2.4)
|
||||
next:
|
||||
specifier: ^15.1.0
|
||||
version: 15.5.14(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
node-pty:
|
||||
specifier: ^1.1.0
|
||||
version: 1.1.0
|
||||
postgres:
|
||||
specifier: ^3.4.0
|
||||
version: 3.4.8
|
||||
@@ -90,6 +99,9 @@ importers:
|
||||
react-dom:
|
||||
specifier: ^19.0.0
|
||||
version: 19.2.4(react@19.2.4)
|
||||
ws:
|
||||
specifier: ^8.20.0
|
||||
version: 8.20.0
|
||||
yaml:
|
||||
specifier: ^2.7.0
|
||||
version: 2.8.2
|
||||
@@ -103,6 +115,9 @@ importers:
|
||||
'@types/react-dom':
|
||||
specifier: ^19.0.0
|
||||
version: 19.2.3(@types/react@19.2.14)
|
||||
'@types/ws':
|
||||
specifier: ^8.18.1
|
||||
version: 8.18.1
|
||||
typescript:
|
||||
specifier: ^5.7.0
|
||||
version: 5.9.3
|
||||
@@ -1755,6 +1770,9 @@ packages:
|
||||
'@types/tedious@4.0.14':
|
||||
resolution: {integrity: sha512-KHPsfX/FoVbUGbyYvk1q9MMQHLPeRZhRJZdO45Q4YjvFkv4hMNghCWTvy7rdKessBsmtz4euWCWAB6/tVpI1Iw==}
|
||||
|
||||
'@types/ws@8.18.1':
|
||||
resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==}
|
||||
|
||||
'@typescript-eslint/eslint-plugin@8.57.1':
|
||||
resolution: {integrity: sha512-Gn3aqnvNl4NGc6x3/Bqk1AOn0thyTU9bqDRhiRnUWezgvr2OnhYCWCgC8zXXRVqBsIL1pSDt7T9nJUe0oM0kDQ==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
@@ -1909,6 +1927,12 @@ packages:
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@xterm/addon-fit@0.11.0':
|
||||
resolution: {integrity: sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==}
|
||||
|
||||
'@xterm/xterm@6.0.0':
|
||||
resolution: {integrity: sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==}
|
||||
|
||||
accepts@1.3.8:
|
||||
resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==}
|
||||
engines: {node: '>= 0.6'}
|
||||
@@ -3108,6 +3132,9 @@ packages:
|
||||
sass:
|
||||
optional: true
|
||||
|
||||
node-addon-api@7.1.1:
|
||||
resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==}
|
||||
|
||||
node-domexception@1.0.0:
|
||||
resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==}
|
||||
engines: {node: '>=10.5.0'}
|
||||
@@ -3121,6 +3148,9 @@ packages:
|
||||
resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==}
|
||||
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
|
||||
|
||||
node-pty@1.1.0:
|
||||
resolution: {integrity: sha512-20JqtutY6JPXTUnL0ij1uad7Qe1baT46lyolh2sSENDd4sTzKZ4nmAFkeAARDKwmlLjPx6XKRlwRUxwjOy+lUg==}
|
||||
|
||||
object-assign@4.1.1:
|
||||
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -3761,6 +3791,18 @@ packages:
|
||||
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
ws@8.20.0:
|
||||
resolution: {integrity: sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==}
|
||||
engines: {node: '>=10.0.0'}
|
||||
peerDependencies:
|
||||
bufferutil: ^4.0.1
|
||||
utf-8-validate: '>=5.0.2'
|
||||
peerDependenciesMeta:
|
||||
bufferutil:
|
||||
optional: true
|
||||
utf-8-validate:
|
||||
optional: true
|
||||
|
||||
xtend@4.0.2:
|
||||
resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==}
|
||||
engines: {node: '>=0.4'}
|
||||
@@ -5248,6 +5290,10 @@ snapshots:
|
||||
dependencies:
|
||||
'@types/node': 22.19.15
|
||||
|
||||
'@types/ws@8.18.1':
|
||||
dependencies:
|
||||
'@types/node': 22.19.15
|
||||
|
||||
'@typescript-eslint/eslint-plugin@8.57.1(@typescript-eslint/parser@8.57.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)':
|
||||
dependencies:
|
||||
'@eslint-community/regexpp': 4.12.2
|
||||
@@ -5398,6 +5444,10 @@ snapshots:
|
||||
'@unrs/resolver-binding-win32-x64-msvc@1.11.1':
|
||||
optional: true
|
||||
|
||||
'@xterm/addon-fit@0.11.0': {}
|
||||
|
||||
'@xterm/xterm@6.0.0': {}
|
||||
|
||||
accepts@1.3.8:
|
||||
dependencies:
|
||||
mime-types: 2.1.35
|
||||
@@ -6735,6 +6785,8 @@ snapshots:
|
||||
- '@babel/core'
|
||||
- babel-plugin-macros
|
||||
|
||||
node-addon-api@7.1.1: {}
|
||||
|
||||
node-domexception@1.0.0: {}
|
||||
|
||||
node-exports-info@1.6.0:
|
||||
@@ -6750,6 +6802,10 @@ snapshots:
|
||||
fetch-blob: 3.2.0
|
||||
formdata-polyfill: 4.0.10
|
||||
|
||||
node-pty@1.1.0:
|
||||
dependencies:
|
||||
node-addon-api: 7.1.1
|
||||
|
||||
object-assign@4.1.1: {}
|
||||
|
||||
object-inspect@1.13.4: {}
|
||||
@@ -7569,6 +7625,8 @@ snapshots:
|
||||
string-width: 4.2.3
|
||||
strip-ansi: 6.0.1
|
||||
|
||||
ws@8.20.0: {}
|
||||
|
||||
xtend@4.0.2: {}
|
||||
|
||||
y18n@5.0.8: {}
|
||||
|
||||
Reference in New Issue
Block a user