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:
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user