diff --git a/nanoclaw.sh b/nanoclaw.sh index 82d445a..c17966e 100755 --- a/nanoclaw.sh +++ b/nanoclaw.sh @@ -137,6 +137,96 @@ write_header # NANOCLAW_BOOTSTRAPPED=1 and skips re-printing the wordmark. cat "$PROJECT_ROOT/assets/setup-splash.txt" +# ─── pre-flight: minimum hardware specs ──────────────────────────────── +# NanoClaw runs an agent container per session. Below these thresholds the +# host + container + agent will struggle (OOM under load, image + session +# DBs filling the disk). Soft warn — `df` only sees the partition that +# $PROJECT_ROOT lives on, which can underreport on hosts with separate +# /home or /var mounts, so the user can override. + +# RAM floor is set below 4 GB because "4 GB" VMs typically report 3700–3900 MB +# after kernel reserves (e.g. Hetzner CX21 ≈ 3814, AWS t3.medium ≈ 3800). +MIN_MEM_MB=3700 +MIN_DISK_GB=20 + +detect_mem_mb() { + case "$(uname -s)" in + Linux) + awk '/^MemTotal:/ {printf "%d", $2 / 1024}' /proc/meminfo 2>/dev/null + ;; + Darwin) + local bytes + bytes=$(sysctl -n hw.memsize 2>/dev/null || echo 0) + echo $(( bytes / 1024 / 1024 )) + ;; + esac +} + +detect_disk_gb() { + # -P: POSIX format (no line-wrapping); -k: 1024-byte blocks. Avail is col 4. + df -Pk "$PROJECT_ROOT" 2>/dev/null \ + | awk 'NR==2 { printf "%d", $4 / 1024 / 1024 }' +} + +MEM_MB=$(detect_mem_mb) +DISK_GB=$(detect_disk_gb) +: "${MEM_MB:=0}" +: "${DISK_GB:=0}" + +LOW_MEM=false; LOW_DISK=false +[ "$MEM_MB" -gt 0 ] && [ "$MEM_MB" -lt "$MIN_MEM_MB" ] && LOW_MEM=true +[ "$DISK_GB" -gt 0 ] && [ "$DISK_GB" -lt "$MIN_DISK_GB" ] && LOW_DISK=true + +if [ "$LOW_MEM" = true ] || [ "$LOW_DISK" = true ]; then + printf ' %s\n' "$(red 'Warning: this machine likely cannot run NanoClaw.')" + printf ' %s\n' "$(dim 'NanoClaw recommends a 4 GB+ machine with 20 GB+ free disk. Below this,')" + printf ' %s\n' "$(dim 'the host + agent container will run out of memory or disk under most')" + printf ' %s\n' "$(dim 'workloads. A stronger machine is strongly recommended.')" + [ "$LOW_MEM" = true ] && printf ' %s\n' "$(dim " · Detected RAM: ${MEM_MB} MB")" + [ "$LOW_DISK" = true ] && printf ' %s\n' "$(dim " · Free disk on $PROJECT_ROOT: ${DISK_GB} GB")" + printf '\n' + read -r -p " $(bold 'Try anyway?') [y/N] " SPECS_ANS /dev/null \ + || grep -qi 'Google' /sys/class/dmi/id/sys_vendor 2>/dev/null; }; then + printf ' %s\n' "$(red 'Warning: Google Cloud VM detected.')" + printf ' %s\n' "$(dim 'Google blocks sudo commands, so NanoClaw is unlikely to run successfully on this VM.')" + printf ' %s\n\n' "$(dim 'If you want to run NanoClaw successfully, switch to a different provider (Hetzner, Hostinger, exe.dev and others..).')" + read -r -p " $(bold 'Try anyway?') [y/N] " GCE_ANS { "Your assistant talks to you through a Telegram bot you create.", "Here's how:", '', - ' 1. Open Telegram and message @BotFather', + " 1. Open Telegram and message @BotFather — Telegram's official bot for creating and managing bots", ' 2. Send /newbot and follow the prompts', ' 3. Copy the token it gives you (it looks like :)', '', diff --git a/setup/lib/windowed-runner.ts b/setup/lib/windowed-runner.ts index 6f165a4..87c971e 100644 --- a/setup/lib/windowed-runner.ts +++ b/setup/lib/windowed-runner.ts @@ -23,7 +23,7 @@ import { emit as phEmit } from './diagnostics.js'; import type { StepResult, SpinnerLabels } from './runner.js'; import { dumpTranscriptOnFailure, spawnStep, writeStepEntry } from './runner.js'; import * as setupLog from '../logs.js'; -import { brandBody, fitToWidth } from './theme.js'; +import { brandBody, fitToWidth, fmtDuration } from './theme.js'; const WINDOW_SIZE = 3; const SPINNER_FRAMES = ['◒', '◐', '◓', '◑']; @@ -85,9 +85,8 @@ async function runUnderWindow( const redraw = (): void => { if (stallPromptActive) return; out.write(`\x1b[${WINDOW_SIZE + 1}A`); - const elapsed = Math.round((Date.now() - start) / 1000); const icon = SPINNER_FRAMES[frameIdx % SPINNER_FRAMES.length]; - const suffix = ` (${elapsed}s)`; + const suffix = ` (${fmtDuration(Date.now() - start)})`; const header = fitToWidth(labels.running, suffix); out.write(`\x1b[2K${k.cyan(icon)} ${header}${k.dim(suffix)}\n`); @@ -164,8 +163,7 @@ async function runUnderWindow( out.write(SHOW_CURSOR); process.off('exit', restoreCursorOnExit); - const elapsed = Math.round((Date.now() - start) / 1000); - const suffix = ` (${elapsed}s)`; + const suffix = ` (${fmtDuration(Date.now() - start)})`; if (result.ok) { const isSkipped = result.terminal?.fields.STATUS === 'skipped'; const msg = isSkipped && labels.skipped ? labels.skipped : labels.done;