Files
fireclaw/src/overseer.ts
ansible b613c2db6f Switch setup.ts to Alpine rootfs, fix remote deployment
- 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>
2026-04-07 15:21:06 +00:00

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...");
}