Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions bin/baudbot
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ json_get_string_stdin_or_empty() {
}

# Colors (disabled if not a terminal)
# shellcheck disable=SC2034 # YELLOW used by sourced scripts (config.sh)
if [ -t 1 ]; then
BOLD='\033[1m'
DIM='\033[2m'
Expand Down Expand Up @@ -137,7 +138,7 @@ usage() {
echo " restart Restart the agent"
echo " status Show agent status + deployed version + broker connection"
echo " logs Tail agent logs"
echo " attach Attach to control-agent by default; supports --pi/--tmux"
echo " debug Launch debug agent with live dashboard for system observability"
echo " sessions List agent tmux and pi sessions (name → id)"
echo ""
echo -e "${BOLD}Setup:${RESET}"
Expand Down Expand Up @@ -324,7 +325,7 @@ else
cmd_status() { echo "❌ Missing CLI runtime helper. Run: sudo baudbot deploy"; exit 1; }
cmd_logs() { echo "❌ Missing CLI runtime helper. Run: sudo baudbot deploy"; exit 1; }
cmd_sessions() { echo "❌ Missing CLI runtime helper. Run: sudo baudbot deploy"; exit 1; }
cmd_attach() { echo "❌ Missing CLI runtime helper. Run: sudo baudbot deploy"; exit 1; }
cmd_debug() { echo "❌ Missing CLI runtime helper. Run: sudo baudbot deploy"; exit 1; }
fi

require_systemd_runtime() {
Expand Down Expand Up @@ -411,7 +412,7 @@ register_command "restart" "function" "cmd_systemctl_restart" "1" "0" ""
register_command "status" "function" "cmd_status" "0" "0" ""
register_command "logs" "function" "cmd_logs" "0" "0" ""
register_command "sessions" "function" "cmd_sessions" "0" "0" ""
register_command "attach" "function" "cmd_attach" "0" "0" ""
register_command "debug" "function" "cmd_debug" "1" "0" ""
register_command "config" "exec" "$BAUDBOT_ROOT/bin/config.sh" "0" "0" ""
register_command "env" "exec" "$BAUDBOT_ROOT/bin/env.sh" "0" "0" ""
register_command "deploy" "exec" "$BAUDBOT_ROOT/bin/deploy.sh" "1" "0" ""
Expand Down
14 changes: 7 additions & 7 deletions bin/baudbot.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ test_status_dispatches_via_runtime_module() {
cmd_status() { echo "status-dispatch-ok"; }
cmd_logs() { echo "logs-dispatch-ok"; }
cmd_sessions() { echo "sessions-dispatch-ok"; }
cmd_attach() { echo "attach-dispatch-ok"; }
cmd_debug() { echo "debug-dispatch-ok"; }
has_systemd() { return 1; }
EOF

Expand All @@ -68,7 +68,7 @@ EOF
)
}

test_attach_requires_root() {
test_debug_requires_root() {
(
set -euo pipefail
local tmp fakebin out
Expand All @@ -89,12 +89,12 @@ fi
EOF
chmod +x "$fakebin/id"

if PATH="$fakebin:$PATH" BAUDBOT_ROOT="$REPO_ROOT" bash "$CLI" attach >/tmp/baudbot-attach.out 2>&1; then
if PATH="$fakebin:$PATH" BAUDBOT_ROOT="$REPO_ROOT" bash "$CLI" debug >/tmp/baudbot-debug.out 2>&1; then
return 1
fi

out="$(cat /tmp/baudbot-attach.out)"
rm -f /tmp/baudbot-attach.out
out="$(cat /tmp/baudbot-debug.out)"
rm -f /tmp/baudbot-debug.out
printf '%s\n' "$out" | grep -q "requires root"
)
}
Expand Down Expand Up @@ -148,7 +148,7 @@ has_systemd() { return 0; }
cmd_status() { :; }
cmd_logs() { :; }
cmd_sessions() { :; }
cmd_attach() { :; }
cmd_debug() { :; }
EOF

cat > "$fakebin/id" <<'EOF'
Expand Down Expand Up @@ -189,7 +189,7 @@ echo ""

run_test "version reads package.json" test_version_uses_package_json
run_test "status dispatches via runtime module" test_status_dispatches_via_runtime_module
run_test "attach requires root" test_attach_requires_root
run_test "debug requires root" test_debug_requires_root
run_test "broker register requires root" test_broker_register_requires_root
run_test "restart restarts systemd" test_restart_restarts_systemd

Expand Down
185 changes: 76 additions & 109 deletions bin/lib/baudbot-runtime.sh
Original file line number Diff line number Diff line change
Expand Up @@ -324,20 +324,6 @@ resolve_pi_session_id() {
return 1
}

pause_before_attach() {
if [ "${BAUDBOT_ATTACH_NO_PAUSE:-0}" = "1" ]; then
return 0
fi

if [ -t 0 ] && [ -t 1 ]; then
echo -e "${DIM}Press Enter to attach (Ctrl+C to cancel)...${RESET}"
# shellcheck disable=SC2162
read _
else
sleep 2
fi
}

cmd_status() {
if has_systemd && systemctl is-enabled baudbot &>/dev/null; then
local status_rc=0
Expand Down Expand Up @@ -433,128 +419,109 @@ cmd_sessions() {
fi
}

cmd_attach() {
require_root "attach"
cmd_debug() {
require_root "debug"

local AGENT_USER="baudbot_agent"
local AGENT_HOME="/home/$AGENT_USER"
local ATTACH_MODE="auto"
local TARGET=""
local tmux_target pi_target
local MODEL=""

while [ "$#" -gt 0 ]; do
case "$1" in
--pi)
ATTACH_MODE="pi"
shift
;;
--tmux)
ATTACH_MODE="tmux"
shift
--model)
MODEL="$2"
shift 2
;;
-h|--help)
echo "Usage: sudo baudbot attach [--pi|--tmux] [session-name|session-id]"
echo "Usage: sudo baudbot debug [--model <model>]"
echo ""
echo "Launch a debug agent with a live dashboard showing control-agent"
echo "activity, health metrics, and system state. Use send_to_session"
echo "to communicate with running agents."
echo ""
echo "Examples:"
echo " sudo baudbot attach # defaults to control-agent"
echo " sudo baudbot attach --pi control-agent"
echo " sudo baudbot attach --pi <uuid>"
echo " sudo baudbot attach --tmux sentry-agent"
echo "Options:"
echo " --model <model> LLM model to use (default: auto-detect from API keys)"
echo ""
echo "Exit: Ctrl+C (does NOT affect the running control-agent)"
exit 0
;;
*)
if [ -n "$TARGET" ]; then
echo "❌ Too many arguments for attach"
exit 1
fi
TARGET="$1"
shift
echo "❌ Unknown option: $1"
echo "Usage: sudo baudbot debug [--model <model>]"
exit 1
;;
esac
done

if [ -z "$TARGET" ]; then
TARGET="control-agent"
fi

attach_tmux_session() {
local tmux_target="$1"
echo -e "${BOLD}${CYAN}Attaching to tmux session:${RESET} $tmux_target"
echo -e "${GREEN}Safe detach:${RESET} Ctrl+b, d ${DIM}(keeps agent running)${RESET}"
echo ""
pause_before_attach
exec sudo -u "$AGENT_USER" tmux attach-session -t "$tmux_target"
}

attach_pi_session() {
local pi_target="$1"
echo -e "${BOLD}${CYAN}Attaching to pi session:${RESET} $pi_target"
echo -e "${BOLD}${YELLOW}Safe detach (does NOT stop the agent):${RESET}"
echo -e " ${YELLOW}1)${RESET} Press Ctrl+C once to clear input/cancel local prompt"
echo -e " ${YELLOW}2)${RESET} Press Ctrl+C again to exit this client"
echo -e " ${GREEN}Agent keeps running under systemd in the background.${RESET}"
echo ""
pause_before_attach
local node_bin_dir=""
node_bin_dir="$(bb_resolve_runtime_node_bin_dir "$AGENT_HOME")"
exec sudo -u "$AGENT_USER" bash -lc "export PATH='$AGENT_HOME/.varlock/bin:$node_bin_dir':\$PATH; cd ~; varlock run --path ~/.config/ -- pi --session '$pi_target'"
}

choose_tmux_target() {
local requested="${1:-}"
local first

if [ -n "$requested" ]; then
if sudo -u "$AGENT_USER" tmux has-session -t "$requested" 2>/dev/null; then
echo "$requested"
return 0
# Auto-detect model from env if not specified
if [ -z "$MODEL" ]; then
# Load env vars to check API keys
local env_file="$AGENT_HOME/.config/.env"
if [ -r "$env_file" ]; then
local env_val=""
env_val="$(grep -E '^BAUDBOT_MODEL=' "$env_file" 2>/dev/null | tail -1 | cut -d= -f2- || true)"
if [ -n "$env_val" ]; then
MODEL="$env_val"
else
env_val="$(grep -E '^ANTHROPIC_API_KEY=' "$env_file" 2>/dev/null | tail -1 | cut -d= -f2- || true)"
if [ -n "$env_val" ]; then
MODEL="anthropic/claude-sonnet-4-5"
else
env_val="$(grep -E '^OPENAI_API_KEY=' "$env_file" 2>/dev/null | tail -1 | cut -d= -f2- || true)"
if [ -n "$env_val" ]; then
MODEL="openai/gpt-4.1"
else
env_val="$(grep -E '^GEMINI_API_KEY=' "$env_file" 2>/dev/null | tail -1 | cut -d= -f2- || true)"
if [ -n "$env_val" ]; then
MODEL="google/gemini-3-flash-preview"
fi
fi
fi
fi
return 1
fi

first=$(sudo -u "$AGENT_USER" tmux ls -F '#{session_name}' 2>/dev/null | head -1)
[ -n "$first" ] || return 1
echo "$first"
return 0
}

choose_pi_target() {
local requested="${1:-}"
local resolved

if ! resolved=$(resolve_pi_session_id "$AGENT_USER" "$requested"); then
return 1
fi

[ -n "$resolved" ] || return 1
echo "$resolved"
return 0
}

if [ "$ATTACH_MODE" = "tmux" ]; then
if tmux_target=$(choose_tmux_target "$TARGET"); then
attach_tmux_session "$tmux_target"
if [ -z "$MODEL" ]; then
echo "❌ No LLM API key found. Set --model or configure API keys in $AGENT_HOME/.config/.env"
exit 1
fi
echo "❌ tmux session not found. See: sudo baudbot sessions"
exit 1
fi

if [ "$ATTACH_MODE" = "pi" ]; then
if pi_target=$(choose_pi_target "$TARGET"); then
attach_pi_session "$pi_target"
fi
echo "❌ pi session not found. See: sudo baudbot sessions"
# Validate MODEL — must be a safe provider/model string (alphanumeric, hyphens, dots, slashes)
if [[ ! "$MODEL" =~ ^[a-zA-Z0-9._/-]+$ ]]; then
echo "❌ Invalid model name: $MODEL"
exit 1
fi

if pi_target=$(choose_pi_target "$TARGET"); then
attach_pi_session "$pi_target"
local SKILL_DIR="$AGENT_HOME/.pi/agent/skills/debug-agent"
if [ ! -f "$SKILL_DIR/SKILL.md" ]; then
# Fall back to deployed location
SKILL_DIR="/opt/baudbot/current/pi/skills/debug-agent"
fi

if tmux_target=$(choose_tmux_target "$TARGET"); then
attach_tmux_session "$tmux_target"
if [ ! -f "$SKILL_DIR/SKILL.md" ]; then
echo "❌ Debug agent skill not found. Run: sudo baudbot deploy"
exit 1
fi

echo "❌ No matching tmux/pi session found. See: sudo baudbot sessions"
exit 1
echo -e "${BOLD}${CYAN}Launching debug agent${RESET}"
echo -e "${DIM}Model: $MODEL${RESET}"
echo -e "${DIM}Skill: $SKILL_DIR${RESET}"
echo -e "${GREEN}Exit: Ctrl+C (does NOT affect the running control-agent)${RESET}"
echo ""

local node_bin_dir=""
node_bin_dir="$(bb_resolve_runtime_node_bin_dir "$AGENT_HOME")"

exec sudo -u "$AGENT_USER" bash -lc "
unset PKG_EXECPATH
export PATH='$AGENT_HOME/.varlock/bin:$node_bin_dir':\$PATH
export VARLOCK_TELEMETRY_DISABLED=1
cd ~
varlock run --path ~/.config/ -- pi \
--session-control \
--model '$MODEL' \
--skill '$SKILL_DIR' \
-e '$SKILL_DIR/debug-dashboard.ts' \
'/skill:debug-agent'
"
Comment thread
sentry[bot] marked this conversation as resolved.
}
24 changes: 1 addition & 23 deletions pi/extensions/heartbeat.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,11 @@
* Run: npx vitest run pi/extensions/heartbeat.test.mjs
*/

import { describe, it, beforeEach, afterEach } from "vitest";
import { describe, it } from "vitest";
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
import os from "node:os";

// ── Replicate pure functions from heartbeat.ts v2 ───────────────────────────

const DEFAULT_INTERVAL_MS = 10 * 60 * 1000; // 10 min
const MIN_INTERVAL_MS = 2 * 60 * 1000; // 2 min
const BACKOFF_MULTIPLIER = 2;
const MAX_BACKOFF_MS = 60 * 60 * 1000; // 1 hour
const STUCK_TODO_THRESHOLD_MS = 2 * 60 * 60 * 1000; // 2 hours
Expand Down Expand Up @@ -70,23 +65,6 @@ function parseTodo(content) {

// ── Test helpers ────────────────────────────────────────────────────────────

let tmpDir;

function setup() {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "heartbeat-test-"));
}

function teardown() {
fs.rmSync(tmpDir, { recursive: true, force: true });
}

function writeFile(name, content) {
const p = path.join(tmpDir, name);
fs.mkdirSync(path.dirname(p), { recursive: true });
fs.writeFileSync(p, content, "utf-8");
return p;
}

// ── Tests ───────────────────────────────────────────────────────────────────

describe("heartbeat v2: isDisabledByEnv", () => {
Expand Down
8 changes: 4 additions & 4 deletions pi/extensions/heartbeat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ import { Type } from "@sinclair/typebox";
import { StringEnum } from "@mariozechner/pi-ai";
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
import { homedir } from "node:os";
import { join, resolve } from "node:path";
import { join } from "node:path";

const DEFAULT_INTERVAL_MS = 10 * 60 * 1000; // 10 minutes
const MIN_INTERVAL_MS = 2 * 60 * 1000; // 2 minutes
Expand Down Expand Up @@ -270,7 +270,7 @@ function checkStuckTodos(): CheckResult[] {
if (!createdAt) continue;

const createdTime = new Date(createdAt).getTime();
if (isNaN(createdTime)) continue;
if (Number.isNaN(createdTime)) continue;

const age = now - createdTime;
if (age < STUCK_TODO_THRESHOLD_MS) continue;
Expand Down Expand Up @@ -341,7 +341,7 @@ function hasMatchingInProgressTodo(worktreeName: string): boolean {
if (content.includes(pathPattern) || boundaryPattern.test(content)) return true;
}
} catch {
continue;
// skip unreadable todo files
}
}
} catch {
Expand Down Expand Up @@ -460,7 +460,7 @@ export default function heartbeatExtension(pi: ExtensionAPI): void {

state.consecutiveErrors = 0;
saveState();
} catch (err) {
} catch {
state.consecutiveErrors += 1;
try {
saveState();
Expand Down