Initial commit — fireclaw multi-agent system
Firecracker microVM-based multi-agent system with IRC orchestration and local LLMs. Features: - Ephemeral command runner with VM snapshots (~1.1s) - Multi-agent orchestration via overseer IRC bot - 5 agent templates (worker, coder, researcher, quick, creative) - Tool access (shell + podman containers inside VMs) - Persistent workspace + memory system (MEMORY.md pattern) - Agent hot-reload (model/persona swap via SSH + SIGHUP) - Non-root agents, graceful shutdown, crash recovery - Agent-to-agent communication via IRC - DM support, /invite support - Systemd service, 20 regression tests Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
188
src/overseer.ts
Normal file
188
src/overseer.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
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(", ")}`);
|
||||
}
|
||||
|
||||
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 });
|
||||
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);
|
||||
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 (e) {
|
||||
bot.say(event.target, "Error fetching models from Ollama.");
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "!help": {
|
||||
bot.say(event.target, "Commands: !invoke <template> [name] | !destroy <name> | !list | !model <name> <model> | !models | !templates | !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);
|
||||
|
||||
log("Overseer started. Waiting for commands...");
|
||||
}
|
||||
Reference in New Issue
Block a user