Updates docs/runtime.md and README.md to match what's actually shipped:
- reflex chain priorities and what each body now dispatches
- operator chat command list (status, come, pause, resume, stop)
- automatic + manual Pi escalation paths and the no-code-change rule
- the full self-improvement loop end-to-end (detector → TUI approval
→ propose:apply → supervisor restart) with the rationale for the
manual propose:apply step
- new state files layout (proposals/, proposals/approved/, etc.)
- supervisor.js + bot:bare script flags
No code changes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
13 KiB
Runtime — hybrid script + LLM-on-demand
Status: active. This is the recommended way to run pepa-pi-bot since 2026-05-25. The pure Pi runtime (
pifrom repo root) still works and is documented as a fallback at the bottom of this file.
Why a hybrid runtime?
The original design ran every tick inside Pi — the LLM saw the world, picked one tool, executed it, looped. That gave full self-extension out of the box, but had three problems in practice:
- Slow. A "look around → defend yourself" round-trip took 20–60 seconds because the LLM was in the hot path.
- Expensive. Hostile mob at 4 m? Cost of evasion = one full reasoning pass. Hungry? Same. Idle? Same.
- Invisible. With Pi as the only frontend, you had to
tmux capture-paneto know what the bot was doing.
The hybrid runtime splits the bot into a script-driven layer that handles fast, well-understood things on its own, and a Pi (or Codex) headless escalation that's only invoked when the script gets stuck or needs to write new code for itself.
Architecture
┌────────────────────────────────────────────────────────────────────┐
│ operator │
│ ├── repo edits (.env, skills/, runtime/) │
│ ├── TUI (Ink) — see status, send chat, press [a] to escalate │
│ └── (future) Telegram bridge │
└─────────────┬────────────────────────────────────────────┬─────────┘
│ Unix socket (newline-JSON) │ git
▼ ▼
┌────────────────────────────────────────────────────────────────────┐
│ runtime/bot.js — single long-running Node process │
│ │
│ ┌──────────────────┐ ┌──────────────────────┐ ┌────────────────┐│
│ │ Mineflayer │ │ Reflex loop │ │ IPC server ││
│ │ - MC TCP │ │ - tick every N sec │ │ - Unix socket ││
│ │ - AuthMe handler │◀─│ - priority order: │─▶│ - broadcasts ││
│ │ - chat / events │ │ defend > eat │ │ status/log/ ││
│ │ │ │ > sleep > current │ │ chat events ││
│ │ │ │ > idle │ │ - accepts ││
│ │ │ │ - NO LLM in path │ │ commands ││
│ └──────────────────┘ └─────────┬────────────┘ └────────────────┘│
│ │ │
│ ▼ on stuck / new scenario │
│ ┌──────────────────────┐ │
│ │ pi-bridge.js │ │
│ │ spawn `pi -p` │ │
│ │ stream stdout to IPC │ │
│ └──────────────────────┘ │
└────────────────────────────────────────────────────────────────────┘
│
│ TCP 25565
▼
Minecraft server
The bot is one process. The TUI is a separate process you can connect and disconnect at will — the bot keeps running. Multiple TUI clients can attach to the same bot simultaneously.
Quickstart
# Once
cd ~/Projects/pepa-pi-bot
npm install
# Terminal 1 — the bot daemon
npm run bot
# Logs go to stdout AND state/<host>/logs/<YYYY-MM-DD>.log
# Terminal 2 — the dashboard
npm run tui
The TUI auto-reconnects to the bot if you restart it. Press q to leave the
TUI; the bot is unaffected.
TUI hotkeys
| Key | Effect |
|---|---|
p |
Pause / resume the reflex loop (MC connection stays). |
s |
Stop the bot process gracefully (disconnect + cleanup + exit). |
r |
Force-broadcast a status snapshot now. |
c |
Enter chat mode — type a message, Enter sends it into MC chat. |
a |
Enter ask-Pi mode — type a prompt, Enter spawns pi -p and streams output into the Pi panel. |
y |
Open the latest pending proposal. In the proposal panel: y approves, n/Esc closes. |
q |
Quit TUI only. Bot keeps running. |
Enter submits, blank submit cancels. The status bar shows [proposals N, press y] when there's something pending.
What the reflex loop does today
The chain (highest priority first), wired and dispatching real Mineflayer actions:
operatorGoalReflex— ifOPERATOR_USERNAMESissued acome/followcommand, satisfy it (walk to the operator's last known position, reply in chat on arrival or failure).defendReflex— closest hostile within 4 m →attackNearest(equips best melee). Within 12 m + low HP or ≥3 hostiles →fleeFromalong the away-vector.eatReflex— food < 16 →eatBestFood(picks from FOOD_PRIORITY list, equip + consume). 5 s cooldown.sleepReflex— night + no hostile within 8 m →sleepInBed(finds nearest placed bed within 16 blocks, paths there, sleeps). 30 s cooldown on failures.idleReflex— every 20th tick, log heartbeat (HP / food / pos).
Adding a new reflex = a function (ctx) => { action, ... } in
runtime/reflex.js, inserted at the right priority. Actions live in
runtime/actions.js. Both files trigger a supervisor hot-restart when
saved (see "Self-improvement" below).
When the bot calls Pi
Two escalation paths:
1. Manual — operator presses a in the TUI, types a question, the
bot spawns pi -p "<question>" and streams its stdout into the Pi
panel.
2. Automatic — every tick where the entire reflex chain returns
noop (no operator goal, no hostiles in reach, food fine, day or no
bed, etc.) increments a counter. When the counter hits
ESCALATE_AFTER_NOOPS = 20 (≈1 min at tick=3s), the bot fires
askPi with the current snapshot and a fixed system prompt telling Pi
to suggest one next action. 10 min cooldown so a permanently-idle bot
doesn't run the LLM dry.
The auto-escalation prompt explicitly bans code-change proposals — Pi should only suggest what to do with the existing tools. If a deeper problem is happening, the failure-tracker (see Self-improvement) will file a proposal instead.
Operator chat commands
Players listed in OPERATOR_USERNAMES can address the bot in MC chat
by prefixing the message with the bot's name:
pepa_bot status → bot replies with HP / food / pos / hostiles / busy
pepa_bot come → bot pathfinds to the operator's current position
pepa_bot pause → reflex loop stops
pepa_bot resume → reflex loop resumes
pepa_bot stop → graceful disconnect + process exit
Unrecognized commands get a polite "didn't recognize" reply. Operator identity verification is the server's job (AuthMe on cracked, online-mode on premium) — the bot trusts the nickname.
IPC protocol
Socket: state/<MC_HOST>_<MC_PORT>/bot.sock (permissions 0600, removed on
shutdown). Framing: one JSON object per line.
Server → client events (see runtime/ipc-protocol.js):
| Type | Payload |
|---|---|
hello |
{ snapshot, recentLogs } — sent on connect. |
status |
full snapshot from perceive.js. |
log |
{ ts, level, source, text, details } — every log line. |
chat |
{ from, text, kind: "player" | "system" }. |
death |
{ reason, position }. |
error |
{ source, text }. |
ask-pi-chunk |
{ stream: "stdout" | "stderr", text }. |
ask-pi-done |
{ code, durationMs }. |
Client → server commands:
| Type | Payload | Effect |
|---|---|---|
cmd:pause |
{} |
Reflex loop stops ticking. |
cmd:resume |
{} |
Reflex loop resumes. |
cmd:stop |
{} |
Graceful shutdown of the bot. |
cmd:chat |
{ text } |
Sends text into MC chat (rate-limited). |
cmd:ask-pi |
{ prompt } |
Spawns pi -p "<prompt>". |
cmd:snapshot |
{} |
Force a status event now. |
The protocol is intentionally tiny — anyone can write a second client
(a Telegram bridge, a web UI, a one-shot CLI) by reading
runtime/ipc-protocol.js.
Self-improvement loop
End-to-end and wired. The flow:
1. reflex chain dispatches an action → action returns { ok: false, detail }
2. bot.js failure tracker accumulates the failure under its label
3. same label fails 3× in a row → writeProposal() → markdown lands in
state/<host>/proposals/<ts>-<slug>.md
4. next IPC STATUS event includes pendingProposals: N
5. TUI shows [proposals N, press y] badge
6. operator presses y, reads the proposal, presses y again to approve
7. proposal moves to state/<host>/proposals/approved/
8. operator runs: npm run propose:apply <filename>
9. script verifies clean working tree, creates feat/proposal-<slug>
branch, spawns `pi -p` with the proposal + repo-conventions prompt
10. Pi commits a patch on that branch (no push, no merge)
11. operator reviews diff, runs `npm run bot` to smoke-test
12. operator pushes the branch and opens a PR by hand
13. supervisor on the running bot picks up runtime/*.js changes and
hot-restarts the child the moment they hit disk
Operator is in the loop at three guardrails: approving the proposal, reviewing Pi's diff, deciding to merge.
Triggers (today)
Only one detector is wired: "same labelled action fails 3 times in a
row" — for example, three back-to-back flee from zombie failures.
30 min cooldown so the same proposal doesn't multiply when the bot
keeps trying.
More triggers worth adding (each as a small follow-up):
- "Pi auto-escalation fired but the snapshot didn't change in the next N ticks" → bot is fundamentally stuck, propose a code change.
- "death count >K in M minutes at similar coords" → safety regression.
- "operator typed the same chat command twice and the bot couldn't act" → missing operator verb.
Why a manual propose:apply step
Approval inside the TUI is cheap — one keypress. Spawning Pi to write a patch is not (subscription tokens, multiple minutes). Splitting "I want this addressed" (TUI) from "now actually run the patcher" (CLI) means you can approve five proposals over a session and dispatch them in a batch when convenient.
File layout
runtime/
supervisor.js forks bot.js, watches runtime/*.js, restart-on-change
bot.js entrypoint — owns MC + tick + IPC + reconnect
config.js reads .env, exposes frozen config + redacted view
log.js ring buffer + stdout + daily file + IPC fan-out
perceive.js snapshot(bot) → JSON
reflex.js priority chain (operator > defend > eat > sleep > idle)
actions.js attackNearest / fleeFrom / eatBestFood / sleepInBed / goTo
state-store.js current-task / diary / proposals on disk
ipc-server.js Unix-socket server
ipc-protocol.js shared contract (event types, command types, framer)
pi-bridge.js spawn `pi -p`, stream stdout
tui/
tui.tsx Ink dashboard (React)
ipc-client.js socket client → EventEmitter
scripts/
propose-apply.js approved-proposal → feat-branch + `pi -p` patcher
Per-server state stays under state/<MC_HOST>_<MC_PORT>/, gitignored:
state/play.xmatic.team_25565/
bot.sock Unix-domain socket (perms 0600, ephemeral)
joined-before.flag AuthMe /register vs /login marker
current-task.json resume anchor — what the bot was doing
goal.md long-term ambition (operator-seeded)
diary/YYYY-MM-DD.md daily journal (one line per milestone)
proposals/ pending self-improvement proposals
proposals/approved/ approved, waiting on propose:apply
logs/YYYY-MM-DD.log full runtime log mirror
Pi-only fallback
The original Pi-driven runtime still works if you prefer the single-process
model — npm run agent from repo root loads AGENTS.md and the existing
extensions in extensions/. The two runtimes share the .env, the
mineflayer deps, and the state/ directory. They MUST NOT run
simultaneously — both will try to claim the same MC nickname and the
server will kick one of them.
If you switch between them frequently, kill one before starting the other:
# stop hybrid
# (in TUI press 's', or just kill `npm run bot`)
# start Pi
npm run agent