feat(runtime): hybrid script reflex + Ink TUI + Pi-on-demand escalation (#4)

* fix(mindcraft-skills): hard timeout on every skill call

mc_avoid_enemies (and 7 other tools) wrapped only in safeCall without a
withTimeout. When mindcraft's underlying pathfinder/pvp goal couldn't be
satisfied, the call never resolved — the Pi tick loop blocked forever.
Observed live: mc_avoid_enemies pending >10 minutes after one mc_observe.

safeCall now takes timeoutMs (default 30s) and wraps withTimeout itself,
so every tool gets a hard ceiling. Per-tool overrides:
  - goToPosition / goToNearestBlock: 120s / 90s (unchanged from before)
  - defendSelf / avoidEnemies: 45s
  - stay: secs*1000 + 10s
  - craft / consume / pickup / place: 30s
  - equip: 15s
collectBlock still uses its bespoke per-iter 75s loop.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 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>

---------

Co-authored-by: Yuriy Mayatnikov <mayatnikov@me.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit was merged in pull request #4.
This commit is contained in:
Yuriy Mayatnikov
2026-05-25 16:04:48 +03:00
committed by GitHub
co-authored by Claude Opus 4.7 mayatnikov
parent 989ddf335e
commit 1e3b36a9a1
16 changed files with 2464 additions and 75 deletions
+222
View File
@@ -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();
+48
View File
@@ -0,0 +1,48 @@
import { config as loadDotenv } from "dotenv";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
export const REPO_ROOT = path.resolve(__dirname, "..");
loadDotenv({ path: path.join(REPO_ROOT, ".env") });
function req(name) {
const v = process.env[name]?.trim();
if (!v) throw new Error(`Missing required env var: ${name}`);
return v;
}
function opt(name, fallback = "") {
return process.env[name]?.trim() || fallback;
}
const host = req("MC_HOST");
const port = Number.parseInt(opt("MC_PORT", "25565"), 10);
const username = req("MC_USERNAME");
export const config = Object.freeze({
host,
port,
username,
version: opt("MC_VERSION", "1.21.5"),
authMode: opt("MC_AUTH_MODE", "offline"),
authmePassword: opt("MC_AUTHME_PASSWORD", ""),
operators: opt("OPERATOR_USERNAMES", "")
.split(",")
.map((s) => s.trim().toLowerCase())
.filter(Boolean),
tickIntervalMs: Math.max(1, Number.parseInt(opt("TICK_INTERVAL_SECONDS", "3"), 10)) * 1000,
chatRateLimitPerMin: Number.parseInt(opt("CHAT_RATE_LIMIT_PER_MIN", "15"), 10),
});
export const serverKey = `${host}_${port}`;
export const stateDir = path.join(REPO_ROOT, "state", serverKey);
export const socketPath = path.join(stateDir, "bot.sock");
// Redacted env view for logs — never include the AuthMe password.
export function redactedConfig() {
const { authmePassword, ...rest } = config;
return { ...rest, authmePassword: authmePassword ? "***" : "(unset)" };
}
+50
View File
@@ -0,0 +1,50 @@
// Shared IPC contract between runtime/bot.js (server) and tui/tui.tsx (client).
// Frame format: one JSON object per line over a Unix-domain socket.
// Socket path: state/<server-key>/bot.sock (created by server, removed on shutdown).
export const SOCKET_BASENAME = "bot.sock";
// Server → client messages.
export const EVENT_TYPES = Object.freeze({
STATUS: "status", // periodic snapshot (HP/food/pos/task/connection)
LOG: "log", // free-form log line { level, source, text }
CHAT: "chat", // MC chat { from, text, kind: "player" | "system" }
DEATH: "death", // death event { reason, position }
ERROR: "error", // recoverable runtime error { source, text }
ASK_PI_CHUNK: "ask-pi-chunk", // streamed stdout chunk from Pi subprocess
ASK_PI_DONE: "ask-pi-done", // Pi subprocess exited { code, durationMs }
HELLO: "hello", // sent on client connect with current snapshot
});
// Client → server commands.
export const COMMAND_TYPES = Object.freeze({
PAUSE: "cmd:pause", // reflex loop stops ticking; connection stays
RESUME: "cmd:resume", // reflex loop resumes
STOP: "cmd:stop", // graceful disconnect + process exit
CHAT: "cmd:chat", // { text } sent into MC as bot
ASK_PI: "cmd:ask-pi", // { prompt } spawn `pi -p` and stream output
SNAPSHOT: "cmd:snapshot", // request immediate STATUS event
});
export function encodeFrame(obj) {
return JSON.stringify(obj) + "\n";
}
// Stateful line splitter — instance per socket.
export function createLineParser(onObject) {
let buf = "";
return (chunk) => {
buf += chunk.toString("utf8");
let idx;
while ((idx = buf.indexOf("\n")) !== -1) {
const line = buf.slice(0, idx).trim();
buf = buf.slice(idx + 1);
if (!line) continue;
try {
onObject(JSON.parse(line));
} catch (e) {
onObject({ __parseError: true, raw: line, err: String(e) });
}
}
};
}
+88
View File
@@ -0,0 +1,88 @@
// Unix-socket server that exposes the bot to local TUI clients.
// One server can hold N clients; events are broadcast to all of them.
// All frames are JSON + newline. See ipc-protocol.js for the contract.
import fs from "node:fs";
import net from "node:net";
import { createLineParser, encodeFrame, EVENT_TYPES } from "./ipc-protocol.js";
import { socketPath } from "./config.js";
import { info, warn, recentLogs, onLog } from "./log.js";
export function createIpcServer({ getStatusSnapshot, onCommand }) {
// Clean stale socket if a previous run crashed before cleanup.
try {
fs.unlinkSync(socketPath);
} catch (e) {
if (e.code !== "ENOENT") warn("ipc", `could not unlink stale socket: ${e.message}`);
}
const clients = new Set();
const server = net.createServer((socket) => {
clients.add(socket);
info("ipc", `client connected (total=${clients.size})`);
const send = (type, payload) => {
try {
socket.write(encodeFrame({ type, ts: new Date().toISOString(), payload }));
} catch {}
};
// On hello: send current snapshot + recent logs so the TUI can render
// immediately without waiting for the next tick.
send(EVENT_TYPES.HELLO, {
snapshot: getStatusSnapshot(),
recentLogs: recentLogs(50),
});
const parser = createLineParser((obj) => {
if (obj.__parseError) {
warn("ipc", `bad frame from client: ${obj.err}`);
return;
}
onCommand?.(obj, send);
});
socket.on("data", parser);
socket.on("close", () => {
clients.delete(socket);
info("ipc", `client disconnected (total=${clients.size})`);
});
socket.on("error", (err) => {
warn("ipc", `client error: ${err?.message ?? err}`);
});
});
server.on("error", (err) => {
warn("ipc", `server error: ${err?.message ?? err}`);
});
server.listen(socketPath, () => {
fs.chmodSync(socketPath, 0o600);
info("ipc", `listening on ${socketPath}`);
});
// Forward every log entry to all subscribed clients.
const unsubLog = onLog((entry) => broadcast(EVENT_TYPES.LOG, entry));
function broadcast(type, payload) {
const frame = encodeFrame({ type, ts: new Date().toISOString(), payload });
for (const c of clients) {
try {
c.write(frame);
} catch {}
}
}
function close() {
unsubLog();
for (const c of clients) c.destroy();
clients.clear();
server.close();
try {
fs.unlinkSync(socketPath);
} catch {}
}
return { broadcast, close };
}
+70
View File
@@ -0,0 +1,70 @@
// Ring-buffered log with fan-out: stdout + IPC broadcast + on-disk daily file.
import fs from "node:fs";
import path from "node:path";
import { stateDir } from "./config.js";
const LOGS_DIR = path.join(stateDir, "logs");
fs.mkdirSync(LOGS_DIR, { recursive: true });
const RING_SIZE = 500;
const ring = []; // newest at end
const subscribers = new Set(); // fn(entry)
function todayStamp() {
return new Date().toISOString().slice(0, 10);
}
function dailyLogPath() {
return path.join(LOGS_DIR, `${todayStamp()}.log`);
}
function appendDisk(entry) {
const line = `${entry.ts} [${entry.level}] ${entry.source}: ${entry.text}\n`;
try {
fs.appendFileSync(dailyLogPath(), line);
} catch {
// disk full or read-only — give up silently to avoid log-of-log loops
}
}
export function log(level, source, text, details) {
const entry = {
ts: new Date().toISOString(),
level,
source,
text: typeof text === "string" ? text : JSON.stringify(text),
details,
};
ring.push(entry);
if (ring.length > RING_SIZE) ring.shift();
// stdout mirror
const prefix = `[${entry.ts}] [${level}] ${source}:`;
const line = `${prefix} ${entry.text}`;
if (level === "error") console.error(line);
else console.log(line);
appendDisk(entry);
for (const sub of subscribers) {
try {
sub(entry);
} catch {}
}
return entry;
}
export const info = (src, msg, d) => log("info", src, msg, d);
export const warn = (src, msg, d) => log("warn", src, msg, d);
export const error = (src, msg, d) => log("error", src, msg, d);
export const debug = (src, msg, d) => log("debug", src, msg, d);
export function recentLogs(n = 50) {
return ring.slice(-n);
}
export function onLog(fn) {
subscribers.add(fn);
return () => subscribers.delete(fn);
}
+69
View File
@@ -0,0 +1,69 @@
// Build a compact, JSON-safe snapshot of the world around the bot. Used both
// for reflex decisions and for periodic IPC STATUS events.
function vec3ToObj(v) {
if (!v) return null;
return { x: Math.round(v.x * 100) / 100, y: Math.round(v.y * 100) / 100, z: Math.round(v.z * 100) / 100 };
}
const HOSTILE = new Set([
"zombie",
"skeleton",
"creeper",
"spider",
"witch",
"pillager",
"vindicator",
"husk",
"stray",
"drowned",
"phantom",
"enderman",
"slime",
"magma_cube",
"hoglin",
"piglin_brute",
"ravager",
"warden",
"breeze",
"bogged",
]);
export function snapshot(bot) {
if (!bot || !bot.entity) {
return { connected: false };
}
const pos = bot.entity.position;
const entities = Object.values(bot.entities || {});
const players = entities.filter((e) => e.type === "player" && e.username && e.username !== bot.username);
const hostiles = entities.filter((e) => HOSTILE.has((e.name || "").toLowerCase()));
const closestHostile = hostiles.reduce((best, e) => {
const d = e.position.distanceTo(pos);
return !best || d < best.d ? { d, e } : best;
}, null);
const inventory = (bot.inventory?.items?.() ?? []).reduce((acc, item) => {
acc[item.name] = (acc[item.name] ?? 0) + item.count;
return acc;
}, {});
return {
connected: true,
username: bot.username,
position: vec3ToObj(pos),
health: bot.health,
food: bot.food,
saturation: bot.foodSaturation,
experience: bot.experience?.level,
time: bot.time?.timeOfDay,
isDay: bot.time?.isDay,
weather: { rain: bot.isRaining, thunder: bot.thundering },
dimension: bot.game?.dimension,
inventory,
players: players.map((p) => ({ name: p.username, distance: Math.round(p.position.distanceTo(pos)) })),
hostileCount: hostiles.length,
closestHostile: closestHostile
? { name: closestHostile.e.name, distance: Math.round(closestHostile.d * 10) / 10 }
: null,
};
}
+44
View File
@@ -0,0 +1,44 @@
// Spawn `pi -p "<prompt>"` as a one-shot subprocess and stream stdout/stderr
// to the caller. Used for headless escalation: the bot's reflex / TUI can ask
// Pi a single question without keeping a long-lived TUI session open.
import { spawn } from "node:child_process";
import { info, warn } from "./log.js";
// Resolve `pi` lazily — user has it on PATH via vite-plus shim.
const PI_BIN = process.env.PI_BIN || "pi";
export function askPi({ prompt, onChunk, onDone, cwd, signal }) {
const startedAt = Date.now();
info("pi-bridge", `spawning pi -p (${prompt.length} chars)`);
const child = spawn(PI_BIN, ["-p", prompt], {
cwd: cwd || process.cwd(),
env: { ...process.env, CI: "1" },
stdio: ["ignore", "pipe", "pipe"],
signal,
});
child.stdout.setEncoding("utf8");
child.stderr.setEncoding("utf8");
child.stdout.on("data", (chunk) => {
onChunk?.({ stream: "stdout", text: chunk });
});
child.stderr.on("data", (chunk) => {
onChunk?.({ stream: "stderr", text: chunk });
});
child.on("error", (err) => {
warn("pi-bridge", `pi subprocess failed to start: ${err?.message ?? err}`);
onDone?.({ code: -1, durationMs: Date.now() - startedAt, error: String(err) });
});
child.on("exit", (code) => {
const durationMs = Date.now() - startedAt;
info("pi-bridge", `pi exited code=${code} after ${durationMs}ms`);
onDone?.({ code: code ?? -1, durationMs });
});
return child;
}
+76
View File
@@ -0,0 +1,76 @@
// Reflex layer: priority-ordered list of pure-script behaviors. Each reflex
// inspects the latest snapshot and either returns a no-op or starts an action.
// LLM is NOT called here. If every reflex declines, the tick yields and we try
// again next interval. Escalation to Pi happens elsewhere, only when the bot
// has been idle/stuck for an extended window.
import { info, warn } from "./log.js";
const REFLEX_LOG = "reflex";
// A reflex returns one of:
// { action: "noop" } — nothing to do
// { action: "starting", kind, detail } — kicked off async work
// { action: "completed", kind, detail } — fully sync, already done
// Reflexes must NEVER throw — they should log and return noop on failure.
function defendReflex(ctx) {
const s = ctx.snapshot;
if (!s.connected) return { action: "noop" };
if (!s.closestHostile) return { action: "noop" };
if (s.closestHostile.distance > 6) return { action: "noop" };
// Stub: just log. Real attack/flee logic comes in a follow-up commit.
info(REFLEX_LOG, `defend: hostile ${s.closestHostile.name} at ${s.closestHostile.distance}m (stub, no action yet)`);
return { action: "completed", kind: "defend-stub", detail: s.closestHostile };
}
function eatReflex(ctx) {
const s = ctx.snapshot;
if (!s.connected) return { action: "noop" };
if (s.food === undefined || s.food >= 16) return { action: "noop" };
// Stub: log only. consume() integration arrives with the actions module.
info(REFLEX_LOG, `eat: hunger ${s.food}/20 (stub, no action yet)`);
return { action: "completed", kind: "eat-stub", detail: { food: s.food } };
}
function sleepReflex(ctx) {
const s = ctx.snapshot;
if (!s.connected) return { action: "noop" };
if (s.isDay) return { action: "noop" };
if (!s.inventory?.["red_bed"] && !s.inventory?.["white_bed"]) return { action: "noop" };
info(REFLEX_LOG, `sleep: night detected, bed in inventory (stub)`);
return { action: "completed", kind: "sleep-stub" };
}
function idleReflex(ctx) {
const s = ctx.snapshot;
if (!s.connected) return { action: "noop" };
// Once every ~10 ticks, log a heartbeat with HP/food/pos. Tunable later.
ctx.idleCounter = (ctx.idleCounter ?? 0) + 1;
if (ctx.idleCounter % 10 !== 0) return { action: "noop" };
info(REFLEX_LOG, `idle: hp=${s.health} food=${s.food} pos=${s.position?.x},${s.position?.y},${s.position?.z}`);
return { action: "completed", kind: "idle-heartbeat" };
}
const REFLEXES = [
{ name: "defend", fn: defendReflex },
{ name: "eat", fn: eatReflex },
{ name: "sleep", fn: sleepReflex },
{ name: "idle", fn: idleReflex },
];
export function runTick(ctx) {
for (const reflex of REFLEXES) {
let outcome;
try {
outcome = reflex.fn(ctx);
} catch (e) {
warn(REFLEX_LOG, `reflex ${reflex.name} threw: ${e?.message ?? e}`);
continue;
}
if (!outcome || outcome.action === "noop") continue;
// First non-noop wins — stop the chain so we don't double-act per tick.
return { reflex: reflex.name, ...outcome };
}
return null;
}