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:
2026-04-07 13:28:29 +00:00
commit ff694d12f6
28 changed files with 5917 additions and 0 deletions

188
src/overseer.ts Normal file
View 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...");
}