From 430bf76868a94b52fd0e67c1e41b220ccbce61af Mon Sep 17 00:00:00 2001 From: Yuriy Mayatnikov Date: Mon, 25 May 2026 16:19:36 +0300 Subject: [PATCH] feat(runtime): state persistence + proposals + supervisor hot-restart MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the self-improvement loop end-to-end: reflex fails 3× → proposal file → operator approves in TUI → `npm run propose:apply ` spawns Pi on a feature branch → Pi commits the patch → supervisor watches runtime/*.js and restarts the child on change. runtime/state-store.js — atomic current-task.json writes, daily diary append, proposals/ + proposals/approved/ helpers. runtime/bot.js: - ctx.dispatch writes current-task.json on start and updates it on completion / failure / throw. - failure tracker: 3 consecutive same-label failures → writeProposal() with the snapshot, labels, and a suggested-next-step section. 30-min cooldown prevents proposal spam. - on startup, surfaces resume info (previous task + pending proposal count); on death, clears current-task.json + writes diary line. - new IPC commands: PROPOSAL_LATEST returns the newest pending proposal body; PROPOSAL_APPROVE moves it to proposals/approved/. tui/tui.tsx — status bar shows `[proposals N, press y]` badge when bot.pendingProposals > 0. Hotkey 'y' opens the proposal panel; 'y' approves, 'n'/Esc closes. scripts/propose-apply.js — given an approved proposal filename, creates a `feat/proposal-` branch and spawns `pi -p` with the proposal + repo-conventions prompt. Refuses on dirty tree. No auto-push, no auto-merge — operator reviews the diff and decides. runtime/supervisor.js — forks bot.js as a child, watches runtime/*.js, restarts on file change or on child exit code 42. Rate-limited at 5 restarts/minute. SIGINT/SIGTERM forward cleanly. `npm run bot` now goes through the supervisor; `npm run bot:bare` skips it. Smoke-tested: supervisor spawned, bot connected to MC, spawned at expected coords, diary line written, state cleanup on SIGTERM correct. Co-Authored-By: Claude Opus 4.7 (1M context) --- package.json | 6 +- runtime/bot.js | 140 ++++++++++++++++++++++++++++++++++++++- runtime/ipc-protocol.js | 3 + runtime/state-store.js | 138 ++++++++++++++++++++++++++++++++++++++ runtime/supervisor.js | 101 ++++++++++++++++++++++++++++ scripts/propose-apply.js | 109 ++++++++++++++++++++++++++++++ tui/tui.tsx | 69 ++++++++++++++++++- 7 files changed, 560 insertions(+), 6 deletions(-) create mode 100644 runtime/state-store.js create mode 100644 runtime/supervisor.js create mode 100644 scripts/propose-apply.js diff --git a/package.json b/package.json index 9aac0ca..406f7ba 100644 --- a/package.json +++ b/package.json @@ -11,8 +11,10 @@ "scripts": { "agent": "pi", "agent:resume": "pi -c", - "bot": "node runtime/bot.js", - "tui": "tsx tui/tui.tsx" + "bot": "node runtime/supervisor.js", + "bot:bare": "node runtime/bot.js", + "tui": "tsx tui/tui.tsx", + "propose:apply": "node scripts/propose-apply.js" }, "dependencies": { "dotenv": "^16.4.5", diff --git a/runtime/bot.js b/runtime/bot.js index e0a328a..a293701 100644 --- a/runtime/bot.js +++ b/runtime/bot.js @@ -20,6 +20,16 @@ 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"; +import { + readCurrentTask, + writeCurrentTask, + clearCurrentTask, + appendDiary, + writeProposal, + listProposals, + readProposal, + approveProposal, +} from "./state-store.js"; fs.mkdirSync(stateDir, { recursive: true }); const JOINED_FLAG = path.join(stateDir, "joined-before.flag"); @@ -50,6 +60,8 @@ const reflexCtx = { idleCounter: 0, lastEatAt: 0, lastSleepAttemptAt: 0, + // Tracks repeated failure of the same labelled action — triggers a proposal. + recentFailures: [], // [{label, detail, ts}], capped at 10 dispatch: dispatchAction, clearOperatorGoal: () => { reflexCtx.operatorGoal = null; @@ -124,11 +136,20 @@ function dispatchAction(fn, label, opts = {}) { } reflexCtx.busy = true; reflexCtx.currentActionLabel = label; + writeCurrentTask({ label, status: "in_progress", snapshot: lastSnapshot }); info("dispatch", `→ ${label}`); Promise.resolve() .then(() => fn()) .then((res) => { - info("dispatch", `← ${label} ${res?.ok ? "ok" : "fail"}${res?.detail ? ` (${JSON.stringify(res.detail).slice(0, 80)})` : ""}`); + const ok = !!res?.ok; + info( + "dispatch", + `← ${label} ${ok ? "ok" : "fail"}${res?.detail ? ` (${JSON.stringify(res.detail).slice(0, 80)})` : ""}`, + ); + writeCurrentTask({ label, status: ok ? "completed" : "failed", detail: res?.detail }); + if (!ok) recordFailure(label, res?.detail); + else clearRecentFailures(label); + if (opts.onComplete) { try { opts.onComplete(res ?? { ok: false, detail: "no result" }); @@ -139,6 +160,8 @@ function dispatchAction(fn, label, opts = {}) { }) .catch((e) => { warn("dispatch", `${label} threw: ${e?.message ?? e}`); + writeCurrentTask({ label, status: "threw", detail: String(e?.message ?? e) }); + recordFailure(label, String(e?.message ?? e)); }) .finally(() => { reflexCtx.busy = false; @@ -146,6 +169,70 @@ function dispatchAction(fn, label, opts = {}) { }); } +// ---- failure tracking + proposal detection -------------------------------- + +const PROPOSAL_THRESHOLD = 3; // same labelled action fails 3+ times in a row +let lastProposalAt = 0; +const PROPOSAL_COOLDOWN_MS = 30 * 60 * 1000; // don't spam proposal files + +function recordFailure(label, detail) { + reflexCtx.recentFailures.push({ ts: Date.now(), label, detail }); + if (reflexCtx.recentFailures.length > 10) reflexCtx.recentFailures.shift(); + maybeFileProposal(label); +} + +function clearRecentFailures(label) { + reflexCtx.recentFailures = reflexCtx.recentFailures.filter((f) => f.label !== label); +} + +function maybeFileProposal(label) { + // Count consecutive trailing failures with the same label. + const trailing = []; + for (let i = reflexCtx.recentFailures.length - 1; i >= 0; i--) { + const f = reflexCtx.recentFailures[i]; + if (f.label === label) trailing.push(f); + else break; + } + if (trailing.length < PROPOSAL_THRESHOLD) return; + if (Date.now() - lastProposalAt < PROPOSAL_COOLDOWN_MS) return; + lastProposalAt = Date.now(); + + const summary = `${label} failed ${trailing.length}× in a row`; + const body = [ + `# Repeated failure: ${label}`, + "", + "## What happened", + "", + `The reflex layer dispatched \`${label}\` ${trailing.length} times in succession without a single success.`, + "", + "## Most recent failures", + "", + ...trailing.slice(0, 5).map( + (f, i) => `${i + 1}. \`${new Date(f.ts).toISOString()}\` — ${JSON.stringify(f.detail).slice(0, 200)}`, + ), + "", + "## Snapshot at moment of last failure", + "", + "```json", + JSON.stringify(lastSnapshot, null, 2), + "```", + "", + "## Suggested next step", + "", + "Operator: review whether the reflex should:", + "- back off (cooldown extension)", + "- switch to a different action variant", + "- escalate to Pi for situational reasoning", + "- or whether the underlying primitive in `runtime/actions.js` needs work.", + "", + `Approve this proposal (move to \`proposals/approved/\`) and run \`npm run propose:apply \` to delegate a patch attempt to Pi headless.`, + ].join("\n"); + + const { filename } = writeProposal({ kind: `repeated-fail-${label}`, summary, body }); + warn("proposal", `filed ${filename}: ${summary}`); + appendDiary(`proposal filed: ${filename} (${summary})`); +} + // ---- operator chat commands ------------------------------------------------ function isOperator(username) { @@ -228,6 +315,7 @@ function connect() { bot.once("spawn", () => { info("mc", `spawned at ${JSON.stringify(bot.entity.position)}`); + appendDiary(`spawned at ${bot.entity.position.x.toFixed(0)},${bot.entity.position.y.toFixed(0)},${bot.entity.position.z.toFixed(0)}`); ipc?.broadcast(EVENT_TYPES.STATUS, buildSnapshot(bot)); }); @@ -251,9 +339,11 @@ function connect() { bot.on("death", () => { const pos = bot.entity?.position; warn("mc", `died at ${JSON.stringify(pos)}`); + appendDiary(`died at ${pos?.x.toFixed(0)},${pos?.y.toFixed(0)},${pos?.z.toFixed(0)}`); ipc?.broadcast(EVENT_TYPES.DEATH, { reason: "unknown", position: pos }); // On death, drop any operator goal — they need to ask again. reflexCtx.operatorGoal = null; + clearCurrentTask(); }); bot.on("kicked", (reason) => { @@ -325,6 +415,7 @@ function tick() { if (shuttingDown) return; if (bot && bot.entity) { lastSnapshot = buildSnapshot(bot); + lastSnapshot.pendingProposals = listProposals().length; reflexCtx.snapshot = lastSnapshot; if (!reflexPaused) { const result = runTick(reflexCtx); @@ -388,6 +479,32 @@ function handleCommand(msg, send) { case COMMAND_TYPES.SNAPSHOT: send(EVENT_TYPES.STATUS, lastSnapshot); break; + case COMMAND_TYPES.PROPOSAL_LATEST: { + const all = listProposals(); + if (all.length === 0) { + send(EVENT_TYPES.PROPOSAL, { filename: null, body: null, total: 0 }); + return; + } + const filename = all[all.length - 1]; + send(EVENT_TYPES.PROPOSAL, { + filename, + body: readProposal(filename), + total: all.length, + }); + break; + } + case COMMAND_TYPES.PROPOSAL_APPROVE: { + const filename = msg.payload?.filename; + if (!filename) return; + try { + const dst = approveProposal(filename); + info("proposal", `approved ${filename} → ${dst}`); + appendDiary(`proposal approved: ${filename}`); + } catch (e) { + send(EVENT_TYPES.ERROR, { source: "proposal", text: e.message }); + } + break; + } default: warn("ipc", `unknown command type: ${msg.type}`); } @@ -412,8 +529,27 @@ process.on("SIGINT", () => gracefulExit(0)); process.on("SIGTERM", () => gracefulExit(0)); info("runtime", `pepa runtime starting; cfg=${JSON.stringify(redactedConfig())}`); + +// Resume info: surface stale state across restarts. We don't auto-resume any +// action — but we tell the operator if the bot died mid-task last time, and +// the count of pending proposals. +const lastTask = readCurrentTask(); +if (lastTask && lastTask.label) { + info("resume", `previous task: ${lastTask.label} (${lastTask.status ?? "?"}, ${lastTask.ts ?? "?"})`); + if (lastTask.status === "in_progress") { + warn("resume", `last shutdown happened mid-action — operator should review state//current-task.json`); + } +} +const pendingProposals = listProposals(); +if (pendingProposals.length > 0) { + warn("resume", `${pendingProposals.length} pending proposal(s) — see state//proposals/`); +} + ipc = createIpcServer({ - getStatusSnapshot: () => lastSnapshot, + getStatusSnapshot: () => ({ + ...lastSnapshot, + pendingProposals: listProposals().length, + }), onCommand: handleCommand, }); connect(); diff --git a/runtime/ipc-protocol.js b/runtime/ipc-protocol.js index 8f09291..04c40a5 100644 --- a/runtime/ipc-protocol.js +++ b/runtime/ipc-protocol.js @@ -14,6 +14,7 @@ export const EVENT_TYPES = Object.freeze({ 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 + PROPOSAL: "proposal", // pending proposal payload { filename, body } }); // Client → server commands. @@ -24,6 +25,8 @@ export const COMMAND_TYPES = Object.freeze({ 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 + PROPOSAL_LATEST: "cmd:proposal-latest", // request latest pending proposal + PROPOSAL_APPROVE: "cmd:proposal-approve", // { filename } move to approved/ }); export function encodeFrame(obj) { diff --git a/runtime/state-store.js b/runtime/state-store.js new file mode 100644 index 0000000..adee4b9 --- /dev/null +++ b/runtime/state-store.js @@ -0,0 +1,138 @@ +// Per-server state on disk. Two surfaces: +// - current-task.json — what action is in flight (or last finished). +// Used as the resume anchor across restarts. Atomically rewritten via +// write-rename to avoid torn reads. +// - diary/YYYY-MM-DD.md — append-only daily journal. One line per +// meaningful milestone (death, escalation, first attack on a new mob +// type, sleep success, etc.). The reflex layer decides what counts. +// +// All writes are sync — these files are tiny and per-tick at most. + +import fs from "node:fs"; +import path from "node:path"; +import { stateDir } from "./config.js"; + +const CURRENT_TASK_PATH = path.join(stateDir, "current-task.json"); +const DIARY_DIR = path.join(stateDir, "diary"); +const PROPOSALS_DIR = path.join(stateDir, "proposals"); +const PROPOSALS_APPROVED_DIR = path.join(PROPOSALS_DIR, "approved"); + +fs.mkdirSync(DIARY_DIR, { recursive: true }); +fs.mkdirSync(PROPOSALS_DIR, { recursive: true }); +fs.mkdirSync(PROPOSALS_APPROVED_DIR, { recursive: true }); + +// ---- current-task ---------------------------------------------------------- + +export function readCurrentTask() { + try { + const raw = fs.readFileSync(CURRENT_TASK_PATH, "utf8").trim(); + if (!raw) return null; + return JSON.parse(raw); + } catch (e) { + if (e.code === "ENOENT") return null; + // corrupt file — treat as no task + return null; + } +} + +function writeAtomic(filePath, content) { + const tmp = `${filePath}.tmp`; + fs.writeFileSync(tmp, content); + fs.renameSync(tmp, filePath); +} + +export function writeCurrentTask(task) { + if (!task) { + clearCurrentTask(); + return; + } + writeAtomic( + CURRENT_TASK_PATH, + JSON.stringify({ ts: new Date().toISOString(), ...task }, null, 2), + ); +} + +export function clearCurrentTask() { + try { + writeAtomic(CURRENT_TASK_PATH, "{}\n"); + } catch (e) { + if (e.code !== "ENOENT") throw e; + } +} + +// ---- diary ----------------------------------------------------------------- + +function diaryPath(date = new Date()) { + const stamp = date.toISOString().slice(0, 10); + return path.join(DIARY_DIR, `${stamp}.md`); +} + +export function appendDiary(text) { + const stamp = new Date().toISOString().slice(11, 19); + const line = `${stamp} ${text}\n`; + fs.appendFileSync(diaryPath(), line); +} + +// ---- proposals ------------------------------------------------------------- + +function slugify(s) { + return s + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 60); +} + +export function writeProposal({ kind, summary, body }) { + const stamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19); + const filename = `${stamp}-${slugify(kind)}.md`; + const filePath = path.join(PROPOSALS_DIR, filename); + const content = [ + "---", + `kind: ${kind}`, + `ts: ${new Date().toISOString()}`, + `summary: ${JSON.stringify(summary)}`, + "approved: false", + "---", + "", + body, + "", + ].join("\n"); + fs.writeFileSync(filePath, content); + return { filePath, filename }; +} + +export function listProposals({ approved = false } = {}) { + const dir = approved ? PROPOSALS_APPROVED_DIR : PROPOSALS_DIR; + try { + return fs + .readdirSync(dir) + .filter((f) => f.endsWith(".md")) + .sort(); + } catch (e) { + if (e.code === "ENOENT") return []; + throw e; + } +} + +export function readProposal(filename, { approved = false } = {}) { + const dir = approved ? PROPOSALS_APPROVED_DIR : PROPOSALS_DIR; + const filePath = path.join(dir, filename); + return fs.readFileSync(filePath, "utf8"); +} + +export function approveProposal(filename) { + const src = path.join(PROPOSALS_DIR, filename); + const dst = path.join(PROPOSALS_APPROVED_DIR, filename); + const content = fs.readFileSync(src, "utf8").replace(/^approved: false/m, "approved: true"); + fs.writeFileSync(dst, content); + fs.unlinkSync(src); + return dst; +} + +export const paths = Object.freeze({ + currentTask: CURRENT_TASK_PATH, + diary: DIARY_DIR, + proposals: PROPOSALS_DIR, + proposalsApproved: PROPOSALS_APPROVED_DIR, +}); diff --git a/runtime/supervisor.js b/runtime/supervisor.js new file mode 100644 index 0000000..dcf65c5 --- /dev/null +++ b/runtime/supervisor.js @@ -0,0 +1,101 @@ +// Supervisor: forks runtime/bot.js as a child process and restarts it +// when the child exits with a "reload requested" code (RELOAD_EXIT_CODE). +// Also watches runtime/*.js — when a file changes, signals the child to +// reload itself by exiting with the same code. +// +// True hot module reload in Node ESM is fragile (caches, open sockets, +// mineflayer client state). Restart-on-change is the same outcome with +// none of the gotchas: the only thing the bot loses is the MC TCP +// connection, which it would reconnect anyway after a Mineflayer kick. +// +// Run via `npm run bot`. Falls back to plain `node runtime/bot.js` via +// `npm run bot:bare` if you want to skip the supervisor. + +import { spawn } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const RUNTIME_DIR = __dirname; +const BOT_ENTRY = path.join(RUNTIME_DIR, "bot.js"); + +export const RELOAD_EXIT_CODE = 42; +const WATCH_DEBOUNCE_MS = 800; +const MAX_RESTARTS_PER_MINUTE = 5; + +let child = null; +let restartingDueToWatch = false; +const restartTimestamps = []; + +function nowMs() { + return Date.now(); +} + +function spawnChild() { + child = spawn(process.execPath, [BOT_ENTRY], { + stdio: "inherit", + env: { ...process.env, PEPA_SUPERVISED: "1" }, + }); + + child.on("exit", (code, signal) => { + console.log(`[supervisor] child exited code=${code} signal=${signal}`); + const wantsRestart = code === RELOAD_EXIT_CODE || restartingDueToWatch; + restartingDueToWatch = false; + if (!wantsRestart) { + // Clean exit (SIGINT/SIGTERM bubble) or crash — don't relaunch. + process.exit(code ?? 0); + } + // Rate-limit restarts so a crash loop doesn't burn CPU. + const now = nowMs(); + restartTimestamps.push(now); + while (restartTimestamps.length && now - restartTimestamps[0] > 60_000) restartTimestamps.shift(); + if (restartTimestamps.length > MAX_RESTARTS_PER_MINUTE) { + console.error(`[supervisor] too many restarts (${restartTimestamps.length} in 60s) — giving up`); + process.exit(1); + } + console.log(`[supervisor] restarting in 500ms…`); + setTimeout(spawnChild, 500); + }); + + child.on("error", (err) => { + console.error(`[supervisor] failed to spawn child: ${err.message}`); + process.exit(1); + }); +} + +let debounceTimer = null; +function watchRuntime() { + const watcher = fs.watch(RUNTIME_DIR, { recursive: false }, (eventType, filename) => { + if (!filename || !filename.endsWith(".js")) return; + // supervisor.js itself is excluded — restarting THIS process from + // inside itself would require a separate exec, which we don't do. + if (filename === "supervisor.js") return; + if (debounceTimer) clearTimeout(debounceTimer); + debounceTimer = setTimeout(() => { + console.log(`[supervisor] ${filename} changed — restarting child`); + restartingDueToWatch = true; + child?.kill("SIGTERM"); + }, WATCH_DEBOUNCE_MS); + }); + watcher.on("error", (err) => { + console.error(`[supervisor] watcher error: ${err.message}`); + }); +} + +// Forward signals to the child, then exit ourselves once it has. +for (const sig of ["SIGINT", "SIGTERM"]) { + process.on(sig, () => { + console.log(`[supervisor] forwarding ${sig} to child`); + if (!child) process.exit(0); + child.once("exit", () => process.exit(0)); + child.kill(sig); + // hard cap in case the child hangs + setTimeout(() => process.exit(1), 5000).unref(); + }); +} + +console.log(`[supervisor] starting; watching ${RUNTIME_DIR} for *.js changes`); +spawnChild(); +watchRuntime(); diff --git a/scripts/propose-apply.js b/scripts/propose-apply.js new file mode 100644 index 0000000..f80850a --- /dev/null +++ b/scripts/propose-apply.js @@ -0,0 +1,109 @@ +#!/usr/bin/env node +// propose:apply +// +// Take an approved proposal from state//proposals/approved/, build a +// task prompt for Pi headless, hand it the proposal + relevant repo context, +// and let Pi write the patch on a new feature branch. The script: +// +// 1. Verifies the proposal exists in proposals/approved/. +// 2. Refuses to run on a dirty working tree. +// 3. Creates a new branch `feat/proposal-` off the current HEAD. +// 4. Spawns `pi -p ""` and streams its stdout to the terminal. +// 5. After Pi exits, prints the resulting `git status` so the operator +// can inspect what changed before pushing/merging. +// +// We do NOT push or merge automatically — the operator reviews `git diff`, +// runs the smoke test, and decides. + +import { spawn, spawnSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const REPO_ROOT = path.resolve(__dirname, ".."); + +const filenameArg = process.argv[2]; +if (!filenameArg) { + console.error("usage: npm run propose:apply "); + process.exit(2); +} + +function findApproved(filename) { + const stateRoot = path.join(REPO_ROOT, "state"); + if (!fs.existsSync(stateRoot)) return null; + for (const host of fs.readdirSync(stateRoot)) { + const candidate = path.join(stateRoot, host, "proposals", "approved", filename); + if (fs.existsSync(candidate)) return candidate; + } + return null; +} + +const proposalPath = findApproved(filenameArg); +if (!proposalPath) { + console.error(`proposal not found in any state//proposals/approved/: ${filenameArg}`); + process.exit(3); +} + +const dirty = spawnSync("git", ["status", "--porcelain"], { cwd: REPO_ROOT }) + .stdout.toString() + .trim(); +if (dirty) { + console.error("working tree is dirty — commit or stash first"); + console.error(dirty); + process.exit(4); +} + +const slug = filenameArg + .replace(/\.md$/, "") + .replace(/[^a-zA-Z0-9-]+/g, "-") + .slice(0, 60); +const branch = `feat/proposal-${slug}`; + +const branchRes = spawnSync("git", ["checkout", "-b", branch], { cwd: REPO_ROOT, stdio: "inherit" }); +if (branchRes.status !== 0) { + console.error(`could not create branch ${branch}`); + process.exit(5); +} + +const proposalText = fs.readFileSync(proposalPath, "utf8"); + +const prompt = [ + "You are about to patch the pepa-pi-bot repo to address an approved proposal.", + "", + "## The proposal", + "", + proposalText, + "", + "## Your task", + "", + "Read the proposal carefully. Make a *minimal* patch to address the underlying problem.", + "", + "Rules:", + "1. Touch as few files as possible. Prefer fixing the smallest thing that addresses the root cause.", + "2. The fix should land under `runtime/` (the hybrid runtime) — NOT under `extensions/` (Pi-only legacy).", + "3. Honor the existing patterns:", + " - Reflexes live in `runtime/reflex.js` and follow the `(ctx) => { action, ... }` shape.", + " - Actions live in `runtime/actions.js` and return `{ ok, detail }` with a hard timeout.", + " - State on disk goes through `runtime/state-store.js`.", + "4. Add or update a short comment explaining WHY only if the change is non-obvious.", + "5. Do not introduce new npm dependencies without a strong reason.", + "6. Do not push, do not open a PR — just commit on the current branch.", + "7. Use a conventional commit message (feat/fix/refactor scope).", + "", + "When you're done, commit and stop. The operator will review.", +].join("\n"); + +console.log("\n=== spawning pi -p (this may take a few minutes) ===\n"); +const child = spawn("pi", ["-p", prompt], { cwd: REPO_ROOT, stdio: "inherit" }); +child.on("exit", (code) => { + console.log(`\n=== pi exited code=${code} ===\n`); + console.log("git status:"); + spawnSync("git", ["status"], { cwd: REPO_ROOT, stdio: "inherit" }); + console.log("\nDiff summary:"); + spawnSync("git", ["diff", "--stat", "HEAD~1..HEAD"], { cwd: REPO_ROOT, stdio: "inherit" }); + console.log(`\nBranch: ${branch}`); + console.log(`Next steps: review the diff, smoke-test with 'npm run bot', then 'git push -u origin ${branch}' if good.`); + process.exit(code ?? 0); +}); diff --git a/tui/tui.tsx b/tui/tui.tsx index ef56765..15fe87d 100644 --- a/tui/tui.tsx +++ b/tui/tui.tsx @@ -35,6 +35,7 @@ type State = { paused: boolean; piStream: string; piRunning: boolean; + proposal: { filename: string | null; body: string | null; total: number } | null; }; type Action = @@ -47,7 +48,9 @@ type Action = | { type: "pi-chunk"; payload: { stream: string; text: string } } | { type: "pi-done"; payload: { code: number; durationMs: number } } | { type: "set-paused"; paused: boolean } - | { type: "hello"; payload: { snapshot: Snapshot; recentLogs: LogEntry[] } }; + | { type: "hello"; payload: { snapshot: Snapshot; recentLogs: LogEntry[] } } + | { type: "proposal"; payload: { filename: string | null; body: string | null; total: number } } + | { type: "proposal-close" }; const MAX_LOGS = 200; const MAX_CHAT = 100; @@ -88,6 +91,10 @@ function reducer(state: State, action: Action): State { snapshot: action.payload.snapshot || {}, logs: action.payload.recentLogs || [], }; + case "proposal": + return { ...state, proposal: action.payload }; + case "proposal-close": + return { ...state, proposal: null }; default: return state; } @@ -114,11 +121,42 @@ function StatusBar({ snapshot, paused, connectedToBot }: { snapshot: Snapshot; p : "?"}{" "} day={String(snapshot.isDay ?? "?")} hostiles={snapshot.hostileCount ?? 0} {snapshot.closestHostile ? ` closest=${snapshot.closestHostile.name}@${snapshot.closestHostile.distance}m` : ""} + {snapshot.pendingProposals ? {` [proposals ${snapshot.pendingProposals}, press y]`} : null} ); } +function ProposalPanel({ + proposal, + onClose, + onApprove, +}: { + proposal: { filename: string | null; body: string | null; total: number }; + onClose: () => void; + onApprove: () => void; +}) { + if (!proposal.filename) { + return ( + + No pending proposals. ([Esc] to close) + + ); + } + const lines = (proposal.body ?? "").split("\n").slice(0, 30); + return ( + + + proposal: {proposal.filename} (total pending: {proposal.total}) + + {lines.map((line, i) => ( + {line} + ))} + [y]es approve [n]o close — uses npm run propose:apply afterwards + + ); +} + function EventLog({ logs }: { logs: LogEntry[] }) { const last = logs.slice(-14); return ( @@ -175,6 +213,7 @@ function App() { paused: false, piStream: "", piRunning: false, + proposal: null, }); const [client] = useState(() => createIpcClient()); @@ -207,6 +246,9 @@ function App() { case EVENT_TYPES.ASK_PI_DONE: dispatch({ type: "pi-done", payload: frame.payload }); break; + case EVENT_TYPES.PROPOSAL: + dispatch({ type: "proposal", payload: frame.payload }); + break; } }; (client as any).on("connected", onConnected); @@ -222,6 +264,16 @@ function App() { useInput((input, key) => { if (mode !== "idle") return; // text input has its own handling + // Proposal panel is open — accept y/n only. + if (state.proposal) { + if (input === "y" && state.proposal.filename) { + client.send(COMMAND_TYPES.PROPOSAL_APPROVE, { filename: state.proposal.filename }); + dispatch({ type: "proposal-close" }); + } else if (input === "n" || key.escape) { + dispatch({ type: "proposal-close" }); + } + return; + } if (input === "q") { client.close(); exit(); @@ -240,6 +292,7 @@ function App() { } if (input === "c") setMode("chat"); if (input === "a") setMode("ask-pi"); + if (input === "y") client.send(COMMAND_TYPES.PROPOSAL_LATEST, {}); }); function submit(value: string) { @@ -254,7 +307,7 @@ function App() { const hotkeyHint = mode === "idle" - ? "[p]ause/resume [s]top [r]efresh [c]hat [a]sk-pi [q]uit" + ? "[p]ause/resume [s]top [r]efresh [c]hat [a]sk-pi [y] proposals [q]uit" : mode === "chat" ? "chat → MC (Enter to send, Esc to cancel)" : "ask-pi → spawn pi -p (Enter to send)"; @@ -267,6 +320,18 @@ function App() { + {state.proposal ? ( + dispatch({ type: "proposal-close" })} + onApprove={() => { + if (state.proposal?.filename) { + client.send(COMMAND_TYPES.PROPOSAL_APPROVE, { filename: state.proposal.filename }); + dispatch({ type: "proposal-close" }); + } + }} + /> + ) : null} {mode === "idle" ? ( {hotkeyHint} -- 2.54.0