agent-console complained about the two-step workflow: kubectl exec a script to spawn an agent, separate terminal to watch it. Root cause was hub being a separate Go sidecar with no access to the pi binary or its config, so it could only watch, never trigger. hub.js now runs inside the pi container itself (same filesystem, same PATH) and exposes POST /run, which spawns `pi -p --mode json` directly and streams it over the same /console WebSocket everyone else is already watching. agent-console gained a `run <agent> <prompt>` subcommand that POSTs then watches -- no separate exec step, no separate terminal. Drops the golang:1.25-alpine sidecar container and agent-run-configmap.yaml entirely (both superseded). ConfigMaps regenerated via local-harness/hub/sync-to-homelab.sh instead of hand-copied, so hub.js/config.json/settings.json each have exactly one source of truth.
120 lines
4.2 KiB
YAML
120 lines
4.2 KiB
YAML
apiVersion: v1
|
|
data:
|
|
hub.js: |
|
|
#!/usr/bin/env node
|
|
// agent-hub: lives inside the pi container (not a sidecar) so it can spawn
|
|
// `pi -p --mode json` directly. One persistent in-cluster service --
|
|
// POST /run to trigger an agent, GET /console (WebSocket) to watch every
|
|
// concurrent run live as it happens, relaying pi's own session protocol
|
|
// verbatim (same event shape Claude Code sessions use).
|
|
const http = require("node:http");
|
|
const crypto = require("node:crypto");
|
|
const { spawn } = require("node:child_process");
|
|
const readline = require("node:readline");
|
|
const { WebSocketServer } = require("ws");
|
|
|
|
const PORT = process.env.HUB_PORT || 9090;
|
|
|
|
const sessions = new Map(); // id -> {id, agent, status, events, startedAt, endedAt}
|
|
const viewers = new Set(); // WebSocket connections watching /console
|
|
|
|
function broadcast(type, session) {
|
|
const msg = JSON.stringify({ type, session });
|
|
for (const ws of viewers) {
|
|
if (ws.readyState === ws.OPEN) ws.send(msg);
|
|
}
|
|
}
|
|
|
|
function startSession(agent) {
|
|
const id = crypto.randomUUID();
|
|
const session = { id, agent, status: "running", events: [], startedAt: new Date().toISOString() };
|
|
sessions.set(id, session);
|
|
broadcast("start", session);
|
|
return session;
|
|
}
|
|
|
|
function addEvent(session, rawLine) {
|
|
session.events.push(JSON.parse(rawLine));
|
|
broadcast("event", session);
|
|
}
|
|
|
|
function endSession(session, status) {
|
|
session.status = status;
|
|
session.endedAt = new Date().toISOString();
|
|
broadcast("end", session);
|
|
}
|
|
|
|
// Spawns `pi -p --mode json <prompt>` and relays it live. Same shape as the
|
|
// old agent-run.js wrapper, just in-process instead of a separate exec.
|
|
function runAgent(agent, prompt, extraArgs = []) {
|
|
const session = startSession(agent);
|
|
const child = spawn("pi", ["-p", "--mode", "json", ...extraArgs, prompt], {
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
});
|
|
const rl = readline.createInterface({ input: child.stdout });
|
|
rl.on("line", (line) => {
|
|
if (!line.trim()) return;
|
|
try {
|
|
addEvent(session, line);
|
|
} catch {
|
|
// non-JSON stdout noise, ignore
|
|
}
|
|
});
|
|
child.stderr.on("data", (chunk) => process.stderr.write(chunk));
|
|
child.on("close", (code) => endSession(session, code === 0 ? "done" : "error"));
|
|
return session;
|
|
}
|
|
|
|
const server = http.createServer((req, res) => {
|
|
const url = new URL(req.url, "http://localhost");
|
|
|
|
if (url.pathname === "/healthz") {
|
|
res.writeHead(200).end();
|
|
return;
|
|
}
|
|
|
|
if (url.pathname === "/sessions" && req.method === "GET") {
|
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
res.end(JSON.stringify([...sessions.values()]));
|
|
return;
|
|
}
|
|
|
|
if (url.pathname === "/run" && req.method === "POST") {
|
|
let body = "";
|
|
req.on("data", (chunk) => (body += chunk));
|
|
req.on("end", () => {
|
|
try {
|
|
const { agent, prompt, provider, model } = JSON.parse(body);
|
|
if (!agent || !prompt) throw new Error("agent and prompt are required");
|
|
const extraArgs = [];
|
|
if (provider) extraArgs.push("--provider", provider);
|
|
if (model) extraArgs.push("--model", model);
|
|
const session = runAgent(agent, prompt, extraArgs);
|
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
res.end(JSON.stringify({ id: session.id }));
|
|
} catch (err) {
|
|
res.writeHead(400, { "Content-Type": "application/json" });
|
|
res.end(JSON.stringify({ error: err.message }));
|
|
}
|
|
});
|
|
return;
|
|
}
|
|
|
|
res.writeHead(404).end();
|
|
});
|
|
|
|
const wss = new WebSocketServer({ server, path: "/console" });
|
|
wss.on("connection", (ws) => {
|
|
for (const session of sessions.values()) {
|
|
ws.send(JSON.stringify({ type: "snapshot", session }));
|
|
}
|
|
viewers.add(ws);
|
|
ws.on("close", () => viewers.delete(ws));
|
|
});
|
|
|
|
server.listen(PORT, () => console.log(`agent-hub listening on :${PORT}`));
|
|
kind: ConfigMap
|
|
metadata:
|
|
name: hub-src
|
|
namespace: agent-pod
|