feat(runtime): script-driven reflex daemon + Ink TUI dashboard
Pure-Pi runtime had three failure modes in practice: - slow: 20-60s per decision because LLM was in the hot path - expensive: every tick (defend, eat, idle) paid for a reasoning pass - invisible: required tmux capture-pane to know what the bot was doing New runtime/ layer is a long-running Node daemon that owns the MC connection, ticks a priority-ordered reflex chain (defend > eat > sleep > idle) with NO LLM in the hot path, and exposes status + commands over a Unix-socket IPC. tui/ is an Ink dashboard that attaches over IPC and can detach freely — multiple TUI clients can connect at once. Pi/Codex are still available, but as on-demand escalation: TUI hotkey 'a' spawns `pi -p "<prompt>"` as a subprocess and streams stdout into the dashboard. The self-improvement loop (proposals → operator approval → Pi-driven patch → hot reload) is documented in docs/runtime.md but not yet wired. Reflex bodies are stubs today — they log decisions but don't drive Mineflayer actions yet. The priority chain, IPC contract, and TUI are fully working; subsequent commits will fill in defend/eat/sleep bodies and wire automatic escalation. Run with `npm run bot` + `npm run tui`. Pi-only fallback stays at `npm run agent`. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+222
@@ -0,0 +1,222 @@
|
||||
// Long-running Mineflayer process. Owns:
|
||||
// - the MC TCP connection + reconnect policy
|
||||
// - the reflex tick loop (no LLM in hot path)
|
||||
// - the IPC server for TUI clients
|
||||
// - on-demand Pi-headless escalation
|
||||
//
|
||||
// Lifecycle: started by `npm run bot`. Connects to MC, spawns IPC server,
|
||||
// ticks every TICK_INTERVAL_SECONDS, broadcasts STATUS to clients each tick.
|
||||
// SIGINT / SIGTERM: graceful disconnect + socket cleanup + exit.
|
||||
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import mineflayer from "mineflayer";
|
||||
|
||||
import { config, stateDir, redactedConfig } from "./config.js";
|
||||
import { info, warn, error } from "./log.js";
|
||||
import { snapshot as buildSnapshot } from "./perceive.js";
|
||||
import { runTick } from "./reflex.js";
|
||||
import { createIpcServer } from "./ipc-server.js";
|
||||
import { askPi } from "./pi-bridge.js";
|
||||
import { COMMAND_TYPES, EVENT_TYPES } from "./ipc-protocol.js";
|
||||
|
||||
fs.mkdirSync(stateDir, { recursive: true });
|
||||
const JOINED_FLAG = path.join(stateDir, "joined-before.flag");
|
||||
|
||||
let bot = null;
|
||||
let reflexPaused = false;
|
||||
let tickTimer = null;
|
||||
let reconnectTimer = null;
|
||||
let shuttingDown = false;
|
||||
let lastSnapshot = { connected: false };
|
||||
|
||||
const reflexCtx = { snapshot: lastSnapshot, idleCounter: 0 };
|
||||
|
||||
let chatTimestamps = [];
|
||||
const CHAT_WINDOW_MS = 60_000;
|
||||
|
||||
let ipc;
|
||||
|
||||
function chatRateAllowed() {
|
||||
const now = Date.now();
|
||||
chatTimestamps = chatTimestamps.filter((t) => now - t < CHAT_WINDOW_MS);
|
||||
if (chatTimestamps.length >= config.chatRateLimitPerMin) return false;
|
||||
chatTimestamps.push(now);
|
||||
return true;
|
||||
}
|
||||
|
||||
function hasJoinedBefore() {
|
||||
return fs.existsSync(JOINED_FLAG);
|
||||
}
|
||||
|
||||
function markJoinedBefore() {
|
||||
try {
|
||||
fs.writeFileSync(JOINED_FLAG, new Date().toISOString());
|
||||
} catch (e) {
|
||||
warn("auth", `could not write joined flag: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function maybeHandleAuthPrompt(text) {
|
||||
if (!bot || !config.authmePassword) return;
|
||||
const lower = text.toLowerCase();
|
||||
const sawRegister = lower.includes("/register");
|
||||
const sawLogin = lower.includes("/login");
|
||||
if (!sawRegister && !sawLogin) return;
|
||||
|
||||
const cmd = sawLogin ? "login" : hasJoinedBefore() ? "login" : "register";
|
||||
if (cmd === "register") {
|
||||
bot.chat(`/register ${config.authmePassword} ${config.authmePassword}`);
|
||||
info("auth", "sent /register (password redacted)");
|
||||
} else {
|
||||
bot.chat(`/login ${config.authmePassword}`);
|
||||
info("auth", "sent /login (password redacted)");
|
||||
}
|
||||
markJoinedBefore();
|
||||
}
|
||||
|
||||
function connect() {
|
||||
if (bot) return;
|
||||
info("mc", `connecting as ${config.username} → ${config.host}:${config.port} (v${config.version})`);
|
||||
bot = mineflayer.createBot({
|
||||
host: config.host,
|
||||
port: config.port,
|
||||
username: config.username,
|
||||
auth: config.authMode === "microsoft" ? "microsoft" : "offline",
|
||||
version: config.version,
|
||||
hideErrors: false,
|
||||
});
|
||||
|
||||
bot.once("spawn", () => {
|
||||
info("mc", `spawned at ${JSON.stringify(bot.entity.position)}`);
|
||||
ipc?.broadcast(EVENT_TYPES.STATUS, buildSnapshot(bot));
|
||||
});
|
||||
|
||||
bot.on("messagestr", (text, _position, _jsonMsg) => {
|
||||
ipc?.broadcast(EVENT_TYPES.CHAT, { from: "server", text, kind: "system" });
|
||||
maybeHandleAuthPrompt(text);
|
||||
});
|
||||
|
||||
bot.on("chat", (username, message) => {
|
||||
if (username === bot.username) return;
|
||||
ipc?.broadcast(EVENT_TYPES.CHAT, { from: username, text: message, kind: "player" });
|
||||
});
|
||||
|
||||
bot.on("death", () => {
|
||||
const pos = bot.entity?.position;
|
||||
warn("mc", `died at ${JSON.stringify(pos)}`);
|
||||
ipc?.broadcast(EVENT_TYPES.DEATH, { reason: "unknown", position: pos });
|
||||
});
|
||||
|
||||
bot.on("kicked", (reason) => {
|
||||
warn("mc", `kicked: ${reason}`);
|
||||
});
|
||||
|
||||
bot.on("error", (err) => {
|
||||
error("mc", `bot error: ${err?.message ?? err}`);
|
||||
});
|
||||
|
||||
bot.on("end", (reason) => {
|
||||
warn("mc", `connection ended: ${reason}`);
|
||||
bot = null;
|
||||
lastSnapshot = { connected: false };
|
||||
if (!shuttingDown) scheduleReconnect();
|
||||
});
|
||||
}
|
||||
|
||||
function scheduleReconnect() {
|
||||
if (reconnectTimer || shuttingDown) return;
|
||||
const delay = 5000;
|
||||
info("mc", `reconnecting in ${delay}ms`);
|
||||
reconnectTimer = setTimeout(() => {
|
||||
reconnectTimer = null;
|
||||
connect();
|
||||
}, delay);
|
||||
}
|
||||
|
||||
function tick() {
|
||||
if (shuttingDown) return;
|
||||
if (bot && bot.entity) {
|
||||
lastSnapshot = buildSnapshot(bot);
|
||||
reflexCtx.snapshot = lastSnapshot;
|
||||
if (!reflexPaused) {
|
||||
runTick(reflexCtx);
|
||||
}
|
||||
ipc?.broadcast(EVENT_TYPES.STATUS, lastSnapshot);
|
||||
} else {
|
||||
lastSnapshot = { connected: false };
|
||||
ipc?.broadcast(EVENT_TYPES.STATUS, lastSnapshot);
|
||||
}
|
||||
}
|
||||
|
||||
function startTickLoop() {
|
||||
if (tickTimer) clearInterval(tickTimer);
|
||||
tickTimer = setInterval(tick, config.tickIntervalMs);
|
||||
}
|
||||
|
||||
function handleCommand(msg, send) {
|
||||
switch (msg.type) {
|
||||
case COMMAND_TYPES.PAUSE:
|
||||
reflexPaused = true;
|
||||
info("ipc", "reflex paused by client");
|
||||
send(EVENT_TYPES.LOG, { ts: new Date().toISOString(), level: "info", source: "ipc", text: "reflex paused" });
|
||||
break;
|
||||
case COMMAND_TYPES.RESUME:
|
||||
reflexPaused = false;
|
||||
info("ipc", "reflex resumed by client");
|
||||
break;
|
||||
case COMMAND_TYPES.STOP:
|
||||
info("ipc", "stop requested by client");
|
||||
gracefulExit(0);
|
||||
break;
|
||||
case COMMAND_TYPES.CHAT: {
|
||||
const text = (msg.payload?.text || "").trim();
|
||||
if (!text || !bot) return;
|
||||
if (!chatRateAllowed()) {
|
||||
send(EVENT_TYPES.ERROR, { source: "chat", text: "rate-limited" });
|
||||
return;
|
||||
}
|
||||
bot.chat(text);
|
||||
break;
|
||||
}
|
||||
case COMMAND_TYPES.ASK_PI: {
|
||||
const prompt = msg.payload?.prompt;
|
||||
if (!prompt) return;
|
||||
askPi({
|
||||
prompt,
|
||||
onChunk: (chunk) => ipc?.broadcast(EVENT_TYPES.ASK_PI_CHUNK, chunk),
|
||||
onDone: (result) => ipc?.broadcast(EVENT_TYPES.ASK_PI_DONE, result),
|
||||
});
|
||||
break;
|
||||
}
|
||||
case COMMAND_TYPES.SNAPSHOT:
|
||||
send(EVENT_TYPES.STATUS, lastSnapshot);
|
||||
break;
|
||||
default:
|
||||
warn("ipc", `unknown command type: ${msg.type}`);
|
||||
}
|
||||
}
|
||||
|
||||
function gracefulExit(code) {
|
||||
if (shuttingDown) return;
|
||||
shuttingDown = true;
|
||||
info("runtime", "shutting down");
|
||||
if (tickTimer) clearInterval(tickTimer);
|
||||
if (reconnectTimer) clearTimeout(reconnectTimer);
|
||||
try {
|
||||
bot?.quit("shutdown");
|
||||
} catch {}
|
||||
ipc?.close();
|
||||
setTimeout(() => process.exit(code), 500);
|
||||
}
|
||||
|
||||
process.on("SIGINT", () => gracefulExit(0));
|
||||
process.on("SIGTERM", () => gracefulExit(0));
|
||||
|
||||
info("runtime", `pepa runtime starting; cfg=${JSON.stringify(redactedConfig())}`);
|
||||
ipc = createIpcServer({
|
||||
getStatusSnapshot: () => lastSnapshot,
|
||||
onCommand: handleCommand,
|
||||
});
|
||||
connect();
|
||||
startTickLoop();
|
||||
Reference in New Issue
Block a user