- setup.ts now downloads Alpine Linux minirootfs instead of Ubuntu squashfs - Installs Alpine packages (openssh, python3, curl, ca-certificates) in chroot - Fixes install script failing on non-Alpine base rootfs (adduser syntax) - Clean up unused imports and lint warnings Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
252 lines
8.0 KiB
TypeScript
252 lines
8.0 KiB
TypeScript
import IRC from "irc-framework";
|
|
import {
|
|
startAgent,
|
|
stopAgent,
|
|
listAgents,
|
|
stopAllAgents,
|
|
listTemplates,
|
|
reconcileAgents,
|
|
reloadAgent,
|
|
type AgentInfo,
|
|
} from "./agent-manager.js";
|
|
|
|
interface OverseerConfig {
|
|
server: string;
|
|
port: number;
|
|
nick: string;
|
|
channel: string;
|
|
}
|
|
|
|
function log(msg: string) {
|
|
process.stderr.write(`[overseer] ${msg}\n`);
|
|
}
|
|
|
|
function formatAgentList(agents: AgentInfo[]): string[] {
|
|
if (agents.length === 0) return ["No agents running."];
|
|
return agents.map(
|
|
(a) =>
|
|
`${a.name} (${a.template}) — ${a.nick} [${a.model}] ip=${a.ip} since ${a.startedAt.slice(11, 19)}`
|
|
);
|
|
}
|
|
|
|
export async function runOverseer(config: OverseerConfig) {
|
|
// Reconcile agent state on startup
|
|
log("Reconciling agent state...");
|
|
const { adopted, cleaned } = reconcileAgents();
|
|
if (adopted.length > 0) {
|
|
log(`Adopted ${adopted.length} running agent(s): ${adopted.join(", ")}`);
|
|
}
|
|
if (cleaned.length > 0) {
|
|
log(`Cleaned ${cleaned.length} dead agent(s): ${cleaned.join(", ")}`);
|
|
}
|
|
|
|
let knownAgents = new Set(listAgents().map((a) => a.name));
|
|
const bot = new IRC.Client();
|
|
|
|
bot.connect({
|
|
host: config.server,
|
|
port: config.port,
|
|
nick: config.nick,
|
|
});
|
|
|
|
bot.on("registered", () => {
|
|
log(`Connected to ${config.server}:${config.port} as ${config.nick}`);
|
|
bot.join(config.channel);
|
|
bot.join("#agents");
|
|
log(`Joined ${config.channel} and #agents`);
|
|
});
|
|
|
|
bot.on("message", async (event: { nick: string; target: string; message: string }) => {
|
|
// Only handle channel messages
|
|
if (!event.target.startsWith("#")) return;
|
|
|
|
const text = event.message.trim();
|
|
if (!text.startsWith("!")) return;
|
|
|
|
const parts = text.split(/\s+/);
|
|
const cmd = parts[0].toLowerCase();
|
|
|
|
try {
|
|
switch (cmd) {
|
|
case "!invoke": {
|
|
const template = parts[1];
|
|
if (!template) {
|
|
bot.say(event.target, "Usage: !invoke <template> [name]");
|
|
return;
|
|
}
|
|
const name = parts[2];
|
|
bot.say(event.target, `Invoking agent "${name ?? template}" from template "${template}"...`);
|
|
const info = await startAgent(template, { name });
|
|
knownAgents.add(info.name);
|
|
bot.say(
|
|
event.target,
|
|
`Agent "${info.name}" started: ${info.nick} [${info.model}] (${info.ip})`
|
|
);
|
|
break;
|
|
}
|
|
|
|
case "!destroy": {
|
|
const name = parts[1];
|
|
if (!name) {
|
|
bot.say(event.target, "Usage: !destroy <name>");
|
|
return;
|
|
}
|
|
await stopAgent(name);
|
|
knownAgents.delete(name);
|
|
bot.say(event.target, `Agent "${name}" destroyed.`);
|
|
break;
|
|
}
|
|
|
|
case "!list": {
|
|
const agents = listAgents();
|
|
for (const line of formatAgentList(agents)) {
|
|
bot.say(event.target, line);
|
|
}
|
|
break;
|
|
}
|
|
|
|
case "!model": {
|
|
const name = parts[1];
|
|
const model = parts[2];
|
|
if (!name || !model) {
|
|
bot.say(event.target, "Usage: !model <name> <model>");
|
|
return;
|
|
}
|
|
await reloadAgent(name, { model });
|
|
bot.say(event.target, `Agent "${name}" hot-reloaded with model ${model}.`);
|
|
break;
|
|
}
|
|
|
|
case "!templates": {
|
|
const templates = listTemplates();
|
|
if (templates.length === 0) {
|
|
bot.say(event.target, "No templates found.");
|
|
} else {
|
|
bot.say(event.target, `Templates: ${templates.join(", ")}`);
|
|
}
|
|
break;
|
|
}
|
|
|
|
case "!models": {
|
|
try {
|
|
const http = await import("node:http");
|
|
const data = await new Promise<string>((resolve, reject) => {
|
|
http.get("http://localhost:11434/api/tags", (res) => {
|
|
const chunks: Buffer[] = [];
|
|
res.on("data", (c) => chunks.push(c));
|
|
res.on("end", () => resolve(Buffer.concat(chunks).toString()));
|
|
}).on("error", reject);
|
|
});
|
|
const models = JSON.parse(data).models;
|
|
if (models.length === 0) {
|
|
bot.say(event.target, "No models available.");
|
|
} else {
|
|
const lines = models.map(
|
|
(m: { name: string; size: number }) =>
|
|
`${m.name} (${(m.size / 1e9).toFixed(1)}GB)`
|
|
);
|
|
bot.say(event.target, `Models: ${lines.join(", ")}`);
|
|
}
|
|
} catch {
|
|
bot.say(event.target, "Error fetching models from Ollama.");
|
|
}
|
|
break;
|
|
}
|
|
|
|
case "!status": {
|
|
try {
|
|
const os = await import("node:os");
|
|
const { execFileSync } = await import("node:child_process");
|
|
const agents = listAgents();
|
|
const uptime = Math.floor(os.uptime() / 3600);
|
|
const totalMem = (os.totalmem() / 1e9).toFixed(0);
|
|
const freeMem = (os.freemem() / 1e9).toFixed(0);
|
|
const load = os.loadavg()[0].toFixed(2);
|
|
|
|
// Disk free
|
|
let diskFree = "?";
|
|
try {
|
|
const dfOut = execFileSync("df", ["-h", "/"], { encoding: "utf-8" });
|
|
const parts = dfOut.split("\n")[1]?.split(/\s+/);
|
|
if (parts) diskFree = `${parts[3]} free / ${parts[1]}`;
|
|
} catch {}
|
|
|
|
// Ollama model loaded
|
|
let ollamaModel = "none";
|
|
try {
|
|
const http = await import("node:http");
|
|
const psData = await new Promise<string>((resolve, reject) => {
|
|
http.get("http://localhost:11434/api/ps", (res) => {
|
|
const chunks: Buffer[] = [];
|
|
res.on("data", (c) => chunks.push(c));
|
|
res.on("end", () => resolve(Buffer.concat(chunks).toString()));
|
|
}).on("error", reject);
|
|
});
|
|
const running = JSON.parse(psData).models;
|
|
if (running?.length > 0) {
|
|
ollamaModel = running.map((m: { name: string }) => m.name).join(", ");
|
|
}
|
|
} catch {}
|
|
|
|
bot.say(event.target, `Agents: ${agents.length} running | Load: ${load} | RAM: ${freeMem}/${totalMem} GB free | Disk: ${diskFree} | Uptime: ${uptime}h | Ollama: ${ollamaModel}`);
|
|
} catch {
|
|
bot.say(event.target, "Error getting status.");
|
|
}
|
|
break;
|
|
}
|
|
|
|
case "!help": {
|
|
bot.say(event.target, "Commands: !invoke <template> [name] | !destroy <name> | !list | !model <name> <model> | !models | !templates | !status | !help");
|
|
break;
|
|
}
|
|
}
|
|
} catch (err) {
|
|
const msg = err instanceof Error ? err.message : String(err);
|
|
bot.say(event.target, `Error: ${msg}`);
|
|
log(`Error handling command "${text}": ${msg}`);
|
|
}
|
|
});
|
|
|
|
bot.on("close", () => {
|
|
log("Disconnected. Reconnecting in 5s...");
|
|
setTimeout(() => {
|
|
bot.connect({
|
|
host: config.server,
|
|
port: config.port,
|
|
nick: config.nick,
|
|
});
|
|
}, 5000);
|
|
});
|
|
|
|
// Graceful shutdown
|
|
const shutdown = async () => {
|
|
log("Shutting down, stopping all agents...");
|
|
await stopAllAgents();
|
|
bot.quit("Overseer shutting down");
|
|
process.exit(0);
|
|
};
|
|
|
|
process.on("SIGINT", shutdown);
|
|
process.on("SIGTERM", shutdown);
|
|
|
|
// Periodic health check — detect and clean up dead agents
|
|
const HEALTH_CHECK_INTERVAL = 30_000;
|
|
const healthCheck = () => {
|
|
const agents = listAgents(); // listAgents already cleans dead entries
|
|
// Check if any agents were cleaned (listAgents logs and removes dead ones)
|
|
// We just need to report the ones that disappeared
|
|
const currentNames = new Set(agents.map((a) => a.name));
|
|
for (const name of knownAgents) {
|
|
if (!currentNames.has(name)) {
|
|
log(`Agent "${name}" died, cleaned up.`);
|
|
bot.say(config.channel, `Agent "${name}" has died and been cleaned up.`);
|
|
}
|
|
}
|
|
knownAgents = currentNames;
|
|
};
|
|
|
|
setInterval(healthCheck, HEALTH_CHECK_INTERVAL);
|
|
|
|
log("Overseer started. Waiting for commands...");
|
|
}
|