From 4e818b9b7d22d2f320b4ab1bc55e0441c4d477fe Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Sun, 1 Mar 2026 21:16:00 -0500 Subject: [PATCH 1/5] agents: add manifest-driven subagent packages and lifecycle tooling --- bin/baudbot | 2 + bin/deploy.sh | 22 +- bin/doctor.sh | 11 + bin/harden-permissions.sh | 2 + bin/scan-extensions.mjs | 8 +- bin/security-audit.sh | 8 + bin/subagents.sh | 438 +++++++++++++ bin/subagents.test.sh | 301 +++++++++ bin/test.sh | 1 + docs/agents.md | 4 + docs/architecture.md | 4 +- docs/operations.md | 24 + package.json | 2 +- pi/extensions/heartbeat.ts | 20 + pi/extensions/memory.test.mjs | 6 +- pi/extensions/subagent-manager.test.mjs | 210 ++++++ pi/extensions/subagent-manager.ts | 609 ++++++++++++++++++ pi/extensions/subagent-registry.ts | 362 +++++++++++ pi/extensions/subagent-util.test.mjs | 180 ++++++ pi/extensions/subagent-util.ts | 215 +++++++ pi/skills/control-agent/SKILL.md | 45 +- pi/subagents/README.md | 14 + pi/subagents/sentry-agent/SKILL.md | 113 ++++ pi/subagents/sentry-agent/subagent.json | 24 + .../utilities/extract-issue-id.mjs | 51 ++ test/shell-scripts.test.mjs | 4 + vitest.config.mjs | 2 + 27 files changed, 2645 insertions(+), 37 deletions(-) create mode 100755 bin/subagents.sh create mode 100755 bin/subagents.test.sh create mode 100644 pi/extensions/subagent-manager.test.mjs create mode 100644 pi/extensions/subagent-manager.ts create mode 100644 pi/extensions/subagent-registry.ts create mode 100644 pi/extensions/subagent-util.test.mjs create mode 100644 pi/extensions/subagent-util.ts create mode 100644 pi/subagents/README.md create mode 100644 pi/subagents/sentry-agent/SKILL.md create mode 100644 pi/subagents/sentry-agent/subagent.json create mode 100755 pi/subagents/sentry-agent/utilities/extract-issue-id.mjs diff --git a/bin/baudbot b/bin/baudbot index 79c11ef..e59bcfd 100755 --- a/bin/baudbot +++ b/bin/baudbot @@ -140,6 +140,7 @@ usage() { echo " logs Tail agent logs" echo " debug Launch debug agent with live dashboard for system observability" echo " sessions List agent tmux and pi sessions (name → id)" + echo " subagents Manage subagent packages (list/enable/disable/start/stop/reconcile)" echo "" echo -e "${BOLD}Setup:${RESET}" echo " install Bootstrap install from GitHub (download script, then escalate)" @@ -422,6 +423,7 @@ register_command "update" "exec" "$BAUDBOT_ROOT/bin/update-release.sh" "1" "0" " register_command "rollback" "exec" "$BAUDBOT_ROOT/bin/rollback-release.sh" "1" "0" "" register_command "uninstall" "exec" "$BAUDBOT_ROOT/bin/uninstall.sh" "1" "0" "" register_command "doctor" "exec" "$BAUDBOT_ROOT/bin/doctor.sh" "0" "0" "" +register_command "subagents" "exec" "$BAUDBOT_ROOT/bin/subagents.sh" "0" "0" "" COMMAND_NAME="${1:-}" if [ -n "$COMMAND_NAME" ]; then diff --git a/bin/deploy.sh b/bin/deploy.sh index 21212e7..f511f8f 100755 --- a/bin/deploy.sh +++ b/bin/deploy.sh @@ -83,6 +83,7 @@ trap 'rm -rf "$STAGE_DIR"' EXIT STAGE_MANIFEST=( "dir|pi/extensions|extensions|required|always" "dir|pi/skills|skills|required|always" + "dir|pi/subagents|subagents|optional|always" "file|start.sh|start.sh|required|always" "file|bin/harden-permissions.sh|bin/harden-permissions.sh|optional|always" "file|bin/redact-logs.sh|bin/redact-logs.sh|optional|always" @@ -131,6 +132,7 @@ fi if [ "$DRY_RUN" -eq 0 ]; then as_agent chmod -R u+rwX "$BAUDBOT_HOME/.pi/agent/extensions" 2>/dev/null || true as_agent chmod -R u+rwX "$BAUDBOT_HOME/.pi/agent/skills" 2>/dev/null || true + as_agent chmod -R u+rwX "$BAUDBOT_HOME/.pi/agent/subagents" 2>/dev/null || true as_agent chmod -R u+rwX "$BAUDBOT_HOME/runtime" 2>/dev/null || true as_agent chmod u+w "$BAUDBOT_HOME/.pi/agent/settings.json" 2>/dev/null || true as_agent chmod u+w "$BAUDBOT_HOME/.pi/agent/baudbot-version.json" 2>/dev/null || true @@ -262,6 +264,24 @@ else log "would copy: skills/" fi +# ── Subagents ─────────────────────────────────────────────────────────────── + +echo "Deploying subagents..." + +SUBAGENTS_SRC="$STAGE_DIR/subagents" +SUBAGENTS_DEST="$BAUDBOT_HOME/.pi/agent/subagents" + +if [ -d "$SUBAGENTS_SRC" ]; then + if [ "$DRY_RUN" -eq 0 ]; then + as_agent bash -c "mkdir -p '$SUBAGENTS_DEST' && cp -r '$SUBAGENTS_SRC/.' '$SUBAGENTS_DEST/'" + log "✓ subagents/" + else + log "would copy: subagents/" + fi +else + log "- subagents/: not present" +fi + # ── Runtime assets (manifest-driven) ──────────────────────────────────────── echo "Deploying heartbeat checklist..." @@ -441,7 +461,7 @@ VEOF echo ' \"source_sha\": \"$GIT_SHA\",' echo ' \"files\": {' first=1 - for dir in '$BAUDBOT_HOME/.pi/agent/extensions' '$BAUDBOT_HOME/.pi/agent/skills' '/opt/baudbot/current/gateway-bridge' '$BAUDBOT_HOME/runtime/bin'; do + for dir in '$BAUDBOT_HOME/.pi/agent/extensions' '$BAUDBOT_HOME/.pi/agent/skills' '$BAUDBOT_HOME/.pi/agent/subagents' '/opt/baudbot/current/gateway-bridge' '$BAUDBOT_HOME/runtime/bin'; do if [ -d \"\$dir\" ]; then while IFS= read -r f; do hash=\$(sha256sum \"\$f\" | cut -d' ' -f1) diff --git a/bin/doctor.sh b/bin/doctor.sh index 77d6168..af66e85 100755 --- a/bin/doctor.sh +++ b/bin/doctor.sh @@ -307,6 +307,17 @@ else fi fi +if [ -d "$BAUDBOT_HOME/.pi/agent/subagents" ]; then + SUBAGENT_COUNT=$(find "$BAUDBOT_HOME/.pi/agent/subagents" -mindepth 2 -maxdepth 2 -name 'subagent.json' 2>/dev/null | wc -l) + pass "subagents deployed ($SUBAGENT_COUNT packages)" +else + if [ "$IS_ROOT" -ne 1 ] && [ -d "$BAUDBOT_HOME" ]; then + warn "cannot verify subagents as non-root (run: sudo baudbot doctor)" + else + warn "subagents not deployed (run: baudbot deploy)" + fi +fi + BRIDGE_DIR="$BAUDBOT_CURRENT_LINK/gateway-bridge" BRIDGE_DIR_LEGACY="$BAUDBOT_CURRENT_LINK/slack-bridge" if [ -d "$BRIDGE_DIR" ] && [ -f "$BRIDGE_DIR/bridge.mjs" ]; then diff --git a/bin/harden-permissions.sh b/bin/harden-permissions.sh index 7a0d3fe..7f9eecf 100755 --- a/bin/harden-permissions.sh +++ b/bin/harden-permissions.sh @@ -41,6 +41,7 @@ echo "🔒 Hardening baudbot_agent permissions..." # Pi state directories — restrict to owner only fix_dir "$HOME/.pi" "700" fix_dir "$HOME/.pi/agent" "700" +fix_dir "$HOME/.pi/agent/subagents" "700" fix_dir "$HOME/.pi/session-control" "700" # Pi session directories @@ -59,6 +60,7 @@ fi # Pi settings fix_file "$HOME/.pi/agent/settings.json" "600" +fix_file "$HOME/.pi/agent/subagents-state.json" "600" # Secrets fix_file "$HOME/.config/.env" "600" diff --git a/bin/scan-extensions.mjs b/bin/scan-extensions.mjs index a1e3063..0a7f2a5 100755 --- a/bin/scan-extensions.mjs +++ b/bin/scan-extensions.mjs @@ -16,7 +16,7 @@ * Ported from OpenClaw's skill-scanner.ts. * * Usage: node scan-extensions.mjs [dir1] [dir2] ... - * Defaults to ~/baudbot/pi/extensions ~/baudbot/pi/skills + * Defaults to ~/baudbot/pi/extensions ~/baudbot/pi/skills ~/baudbot/pi/subagents */ import { readdir, readFile, stat } from "node:fs/promises"; @@ -277,7 +277,11 @@ async function main() { const home = homedir(); const dirs = process.argv.slice(2); if (dirs.length === 0) { - dirs.push(join(home, "baudbot/pi/extensions"), join(home, "baudbot/pi/skills")); + dirs.push( + join(home, "baudbot/pi/extensions"), + join(home, "baudbot/pi/skills"), + join(home, "baudbot/pi/subagents"), + ); } let totalScanned = 0; diff --git a/bin/security-audit.sh b/bin/security-audit.sh index a7a33e8..8393926 100755 --- a/bin/security-audit.sh +++ b/bin/security-audit.sh @@ -220,6 +220,14 @@ else ok "~/.pi/agent/skills/ is a real directory" fi +# shellcheck disable=SC2088 +if [ -L "$BAUDBOT_HOME/.pi/agent/subagents" ]; then + finding "CRITICAL" "~/.pi/agent/subagents is a symlink (should be a real dir)" \ + "Run: rm ~/.pi/agent/subagents && mkdir ~/.pi/agent/subagents && deploy.sh" +else + ok "~/.pi/agent/subagents/ is a real directory (if deployed)" +fi + BRIDGE_DIR="$BAUDBOT_CURRENT_LINK/gateway-bridge" BRIDGE_DIR_LEGACY="$BAUDBOT_CURRENT_LINK/slack-bridge" # shellcheck disable=SC2088 diff --git a/bin/subagents.sh b/bin/subagents.sh new file mode 100755 index 0000000..7a5e9ab --- /dev/null +++ b/bin/subagents.sh @@ -0,0 +1,438 @@ +#!/bin/bash +# Manage deployed subagent packages. + +set -euo pipefail + +AGENT_USER="${BAUDBOT_AGENT_USER:-baudbot_agent}" +AGENT_HOME="${BAUDBOT_AGENT_HOME:-/home/$AGENT_USER}" +SUBAGENT_DIR="${BAUDBOT_SUBAGENT_DIR:-$AGENT_HOME/.pi/agent/subagents}" +STATE_FILE="${BAUDBOT_SUBAGENT_STATE_FILE:-$AGENT_HOME/.pi/agent/subagents-state.json}" +CONTROL_DIR="${BAUDBOT_SUBAGENT_CONTROL_DIR:-$AGENT_HOME/.pi/session-control}" +ENV_FILE="${BAUDBOT_SUBAGENT_ENV_FILE:-$AGENT_HOME/.config/.env}" + +usage() { + cat < [args] + +Commands: + list + status [id] + install + uninstall + enable + disable + autostart-on + autostart-off + start + stop + reconcile +USAGE +} + +require_root() { + if [ "$(id -u)" -ne 0 ]; then + echo "❌ subagents commands require root. Run: sudo baudbot subagents ..." + exit 1 + fi +} + +require_jq() { + if ! command -v jq >/dev/null 2>&1; then + echo "❌ jq is required for subagent management" + exit 1 + fi +} + +ensure_state_file() { + if [ -f "$STATE_FILE" ]; then + return + fi + install -d -m 700 -o "$AGENT_USER" -g "$AGENT_USER" "$(dirname "$STATE_FILE")" + printf '{\n "version": 1,\n "agents": {}\n}\n' > "$STATE_FILE" + chown "$AGENT_USER:$AGENT_USER" "$STATE_FILE" + chmod 600 "$STATE_FILE" +} + +write_state_with_jq() { + local jq_expr="$1" + local id="${2:-}" + ensure_state_file + local tmp + tmp=$(mktemp) + if [ -n "$id" ]; then + jq --arg id "$id" "$jq_expr" "$STATE_FILE" > "$tmp" + else + jq "$jq_expr" "$STATE_FILE" > "$tmp" + fi + install -o "$AGENT_USER" -g "$AGENT_USER" -m 600 "$tmp" "$STATE_FILE" + rm -f "$tmp" +} + +manifest_path_for_id() { + local id="$1" + local manifest="$SUBAGENT_DIR/$id/subagent.json" + if [ ! -f "$manifest" ]; then + echo "" + return 1 + fi + echo "$manifest" +} + +manifest_field() { + local manifest="$1" + local field="$2" + jq -er "$field" "$manifest" 2>/dev/null || true +} + +state_override_bool() { + local id="$1" + local key="$2" + if [ ! -f "$STATE_FILE" ]; then + echo "" + return + fi + jq -r --arg id "$id" --arg key "$key" '.agents[$id][$key] // empty' "$STATE_FILE" 2>/dev/null || true +} + +effective_bool() { + local id="$1" + local key="$2" + local default_value="$3" + local override + override="$(state_override_bool "$id" "$key")" + if [ "$override" = "true" ] || [ "$override" = "false" ]; then + echo "$override" + return + fi + echo "$default_value" +} + +is_true() { + [ "$1" = "true" ] +} + +resolve_home_path() { + local value="$1" + if [ "$value" = "~" ]; then + echo "$AGENT_HOME" + return + fi + if [[ "$value" == ~/* ]]; then + echo "$AGENT_HOME/${value#~/}" + return + fi + echo "$value" +} + +resolve_model() { + local profile="$1" + local explicit_model="${2:-}" + + has_key() { + local key="$1" + grep -Eq "^${key}=[^[:space:]].*$" "$ENV_FILE" 2>/dev/null + } + + if [ "$profile" = "explicit" ]; then + if [ -n "$explicit_model" ]; then + echo "$explicit_model" + return 0 + fi + return 1 + fi + + if [ "$profile" = "top_tier" ]; then + if has_key "ANTHROPIC_API_KEY"; then echo "anthropic/claude-opus-4-6"; return 0; fi + if has_key "OPENAI_API_KEY"; then echo "openai/gpt-5.2-codex"; return 0; fi + if has_key "GEMINI_API_KEY"; then echo "google/gemini-3-pro-preview"; return 0; fi + if has_key "OPENCODE_ZEN_API_KEY"; then echo "opencode-zen/claude-opus-4-6"; return 0; fi + return 1 + fi + + if has_key "ANTHROPIC_API_KEY"; then echo "anthropic/claude-haiku-4-5"; return 0; fi + if has_key "OPENAI_API_KEY"; then echo "openai/gpt-5-mini"; return 0; fi + if has_key "GEMINI_API_KEY"; then echo "google/gemini-3-flash-preview"; return 0; fi + if has_key "OPENCODE_ZEN_API_KEY"; then echo "opencode-zen/claude-haiku-4-5"; return 0; fi + return 1 +} + +session_running() { + local session_name="$1" + sudo -u "$AGENT_USER" tmux has-session -t "$session_name" >/dev/null 2>&1 +} + +spawn_one() { + local id="$1" + local manifest + manifest="$(manifest_path_for_id "$id")" || true + if [ -z "$manifest" ]; then + echo "❌ Unknown subagent id: $id" + return 1 + fi + + local installed_default enabled_default autostart_default + installed_default="$(manifest_field "$manifest" '.installed_by_default // true')" + enabled_default="$(manifest_field "$manifest" '.enabled_by_default // true')" + autostart_default="$(manifest_field "$manifest" '.autostart // false')" + + local installed enabled + installed="$(effective_bool "$id" "installed" "$installed_default")" + enabled="$(effective_bool "$id" "enabled" "$enabled_default")" + + if ! is_true "$installed" || ! is_true "$enabled"; then + echo "❌ $id is not installed/enabled (installed=$installed enabled=$enabled)" + return 1 + fi + + local session_name ready_alias skill_path cwd profile explicit_model ready_timeout + session_name="$(manifest_field "$manifest" '.session_name')" + ready_alias="$(manifest_field "$manifest" '.ready_alias // .session_name')" + skill_path="$(manifest_field "$manifest" '.skill_path // "SKILL.md"')" + cwd="$(manifest_field "$manifest" '.cwd // "~"')" + profile="$(manifest_field "$manifest" '.model_profile')" + explicit_model="$(manifest_field "$manifest" '.model // empty')" + ready_timeout="$(manifest_field "$manifest" '.ready_timeout_sec // 10')" + + local package_dir + package_dir="$(dirname "$manifest")" + + if [[ "$skill_path" != /* ]] && [[ "$skill_path" != ~* ]]; then + skill_path="$package_dir/$skill_path" + fi + skill_path="$(resolve_home_path "$skill_path")" + + if [[ "$cwd" != /* ]] && [[ "$cwd" != ~* ]]; then + cwd="$package_dir/$cwd" + fi + cwd="$(resolve_home_path "$cwd")" + + local model + if ! model="$(resolve_model "$profile" "$explicit_model")"; then + echo "❌ could not resolve model for $id" + return 1 + fi + + if session_running "$session_name"; then + echo "✓ $id already running" + return 0 + fi + + local log_path + log_path="$AGENT_HOME/.pi/agent/logs/spawn-$session_name.log" + + sudo -u "$AGENT_USER" bash -lc "mkdir -p '$AGENT_HOME/.pi/agent/logs'" + sudo -u "$AGENT_USER" bash -lc "tmux new-session -d -s '$session_name' \"cd '$cwd' && export PATH=\\\"\\\$HOME/.varlock/bin:\\\$HOME/opt/node/bin:\\\$PATH\\\" && export PI_SESSION_NAME='$session_name' && exec varlock run --path \\\"\\\$HOME/.config/\\\" -- pi --session-control --skill '$skill_path' --model '$model' > '$log_path' 2>&1\"" + + local alias_path="$CONTROL_DIR/$ready_alias.alias" + local wait_ticks=$((ready_timeout * 5)) + local tick=0 + while [ "$tick" -lt "$wait_ticks" ]; do + if [ -L "$alias_path" ]; then + local target + target="$(readlink "$alias_path" 2>/dev/null || true)" + if [ -n "$target" ] && [ -S "$CONTROL_DIR/$target" ]; then + echo "✓ started $id ($session_name)" + return 0 + fi + fi + sleep 0.2 + tick=$((tick + 1)) + done + + echo "⚠️ started $id but readiness alias was not observed before timeout" + return 1 +} + +stop_one() { + local id="$1" + local manifest + manifest="$(manifest_path_for_id "$id")" || true + if [ -z "$manifest" ]; then + echo "❌ Unknown subagent id: $id" + return 1 + fi + local session_name + session_name="$(manifest_field "$manifest" '.session_name')" + if sudo -u "$AGENT_USER" tmux kill-session -t "$session_name" >/dev/null 2>&1; then + echo "✓ stopped $id ($session_name)" + else + echo "⚠️ $id ($session_name) was not running" + fi +} + +list_packages() { + printf "%-20s %-8s %-8s %-10s %-14s %s\n" "ID" "INST" "ENBL" "AUTOSTART" "SESSION" "MODEL" + printf "%-20s %-8s %-8s %-10s %-14s %s\n" "--------------------" "--------" "--------" "----------" "--------------" "-----" + + shopt -s nullglob + local manifest + for manifest in "$SUBAGENT_DIR"/*/subagent.json; do + local id installed_default enabled_default autostart_default installed enabled autostart session profile + id="$(manifest_field "$manifest" '.id')" + installed_default="$(manifest_field "$manifest" '.installed_by_default // true')" + enabled_default="$(manifest_field "$manifest" '.enabled_by_default // true')" + autostart_default="$(manifest_field "$manifest" '.autostart // false')" + installed="$(effective_bool "$id" "installed" "$installed_default")" + enabled="$(effective_bool "$id" "enabled" "$enabled_default")" + autostart="$(effective_bool "$id" "autostart" "$autostart_default")" + session="$(manifest_field "$manifest" '.session_name')" + profile="$(manifest_field "$manifest" '.model_profile')" + printf "%-20s %-8s %-8s %-10s %-14s %s\n" "$id" "$installed" "$enabled" "$autostart" "$session" "$profile" + done + shopt -u nullglob +} + +status_packages() { + local maybe_id="${1:-}" + if [ -n "$maybe_id" ]; then + local manifest + manifest="$(manifest_path_for_id "$maybe_id")" || true + if [ -z "$manifest" ]; then + echo "❌ Unknown subagent id: $maybe_id" + exit 1 + fi + set -- "$manifest" + else + set -- "$SUBAGENT_DIR"/*/subagent.json + fi + + shopt -s nullglob + local manifest + for manifest in "$@"; do + [ -f "$manifest" ] || continue + local id session ready_alias alias_path running + id="$(manifest_field "$manifest" '.id')" + session="$(manifest_field "$manifest" '.session_name')" + ready_alias="$(manifest_field "$manifest" '.ready_alias // .session_name')" + alias_path="$CONTROL_DIR/$ready_alias.alias" + + if session_running "$session"; then running="running"; else running="stopped"; fi + echo "$id" + echo " session: $session ($running)" + echo " alias: $alias_path" + if [ -L "$alias_path" ]; then + echo " socket: $(readlink "$alias_path" 2>/dev/null || true)" + else + echo " socket: (missing alias)" + fi + done + shopt -u nullglob +} + +set_install_state() { + local id="$1" + local installed="$2" + local enabled="$3" + local autostart="$4" + write_state_with_jq '.version = 1 | .agents[$id] = (.agents[$id] // {}) | .agents[$id].installed = ('"$installed"') | .agents[$id].enabled = ('"$enabled"') | .agents[$id].autostart = ('"$autostart"')' "$id" +} + +set_enabled_state() { + local id="$1" + local enabled="$2" + write_state_with_jq '.version = 1 | .agents[$id] = (.agents[$id] // {}) | .agents[$id].installed = true | .agents[$id].enabled = ('"$enabled"')' "$id" +} + +set_autostart_state() { + local id="$1" + local autostart="$2" + write_state_with_jq '.version = 1 | .agents[$id] = (.agents[$id] // {}) | .agents[$id].installed = true | .agents[$id].enabled = true | .agents[$id].autostart = ('"$autostart"')' "$id" +} + +reconcile_subagents() { + shopt -s nullglob + local manifest + local failures=0 + for manifest in "$SUBAGENT_DIR"/*/subagent.json; do + local id installed_default enabled_default autostart_default installed enabled autostart + id="$(manifest_field "$manifest" '.id')" + installed_default="$(manifest_field "$manifest" '.installed_by_default // true')" + enabled_default="$(manifest_field "$manifest" '.enabled_by_default // true')" + autostart_default="$(manifest_field "$manifest" '.autostart // false')" + + installed="$(effective_bool "$id" "installed" "$installed_default")" + enabled="$(effective_bool "$id" "enabled" "$enabled_default")" + autostart="$(effective_bool "$id" "autostart" "$autostart_default")" + + if is_true "$installed" && is_true "$enabled" && is_true "$autostart"; then + if ! spawn_one "$id"; then + failures=$((failures + 1)) + fi + fi + done + shopt -u nullglob + + if [ "$failures" -gt 0 ]; then + return 1 + fi +} + +main() { + require_root + require_jq + + local command="${1:-}" + shift || true + + case "$command" in + list) + list_packages + ;; + status) + status_packages "${1:-}" + ;; + install) + [ -n "${1:-}" ] || { echo "❌ install requires id"; exit 1; } + set_install_state "$1" true true false + echo "✓ installed $1" + ;; + uninstall) + [ -n "${1:-}" ] || { echo "❌ uninstall requires id"; exit 1; } + set_install_state "$1" false false false + stop_one "$1" || true + echo "✓ uninstalled $1" + ;; + enable) + [ -n "${1:-}" ] || { echo "❌ enable requires id"; exit 1; } + set_enabled_state "$1" true + echo "✓ enabled $1" + ;; + disable) + [ -n "${1:-}" ] || { echo "❌ disable requires id"; exit 1; } + set_enabled_state "$1" false + stop_one "$1" || true + echo "✓ disabled $1" + ;; + autostart-on) + [ -n "${1:-}" ] || { echo "❌ autostart-on requires id"; exit 1; } + set_autostart_state "$1" true + echo "✓ autostart enabled for $1" + ;; + autostart-off) + [ -n "${1:-}" ] || { echo "❌ autostart-off requires id"; exit 1; } + write_state_with_jq '.version = 1 | .agents[$id] = (.agents[$id] // {}) | .agents[$id].autostart = false' "$1" + echo "✓ autostart disabled for $1" + ;; + start) + [ -n "${1:-}" ] || { echo "❌ start requires id"; exit 1; } + spawn_one "$1" + ;; + stop) + [ -n "${1:-}" ] || { echo "❌ stop requires id"; exit 1; } + stop_one "$1" + ;; + reconcile) + reconcile_subagents + ;; + --help|-h|"") + usage + ;; + *) + echo "❌ unknown command: $command" + usage + exit 1 + ;; + esac +} + +main "$@" diff --git a/bin/subagents.test.sh b/bin/subagents.test.sh new file mode 100755 index 0000000..bfce5e1 --- /dev/null +++ b/bin/subagents.test.sh @@ -0,0 +1,301 @@ +#!/bin/bash +# Tests for bin/subagents.sh lifecycle commands. + +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +SCRIPT="$REPO_ROOT/bin/subagents.sh" + +TOTAL=0 +PASSED=0 +FAILED=0 + +run_test() { + local name="$1" + shift + local out + + TOTAL=$((TOTAL + 1)) + printf " %-45s " "$name" + + out="$(mktemp /tmp/baudbot-subagents-test-output.XXXXXX)" + if "$@" >"$out" 2>&1; then + echo "✓" + PASSED=$((PASSED + 1)) + else + echo "✗ FAILED" + tail -40 "$out" | sed 's/^/ /' + FAILED=$((FAILED + 1)) + fi + rm -f "$out" +} + +setup_fixture() { + local tmp="$1" + + local agent_home="$tmp/agent-home" + local fakebin="$tmp/fakebin" + local subagent_dir="$agent_home/.pi/agent/subagents/sentry-agent" + local control_dir="$agent_home/.pi/session-control" + + mkdir -p "$subagent_dir" "$control_dir" "$agent_home/.config" "$fakebin" + + cat > "$subagent_dir/subagent.json" <<'JSON' +{ + "id": "sentry-agent", + "name": "Sentry Agent", + "description": "Incident triage agent", + "session_name": "sentry-agent", + "cwd": "~", + "skill_path": "SKILL.md", + "model_profile": "cheap_tier", + "ready_alias": "sentry-agent", + "ready_timeout_sec": 2, + "installed_by_default": true, + "enabled_by_default": true, + "autostart": false +} +JSON + + printf '# test skill\n' > "$subagent_dir/SKILL.md" + printf 'OPENAI_API_KEY=test-key\n' > "$agent_home/.config/.env" + + cat > "$fakebin/id" <<'EOF_ID' +#!/bin/bash +if [ "${1:-}" = "-u" ]; then + echo "${BAUDBOT_TEST_ID_U:-0}" + exit 0 +fi +if [ "${1:-}" = "-un" ]; then + echo "${BAUDBOT_TEST_ID_UN:-tester}" + exit 0 +fi +exec /usr/bin/id "$@" +EOF_ID + + cat > "$fakebin/sudo" <<'EOF_SUDO' +#!/bin/bash +set -euo pipefail +if [ "${1:-}" = "-u" ]; then + shift 2 +fi +exec "$@" +EOF_SUDO + + cat > "$fakebin/tmux" <<'EOF_TMUX' +#!/bin/bash +set -euo pipefail +STATE_FILE="${BAUDBOT_TEST_TMUX_FILE:?missing BAUDBOT_TEST_TMUX_FILE}" +mkdir -p "$(dirname "$STATE_FILE")" +touch "$STATE_FILE" + +extract_flag_value() { + local flag="$1" + shift + local args=("$@") + local i + for ((i = 0; i < ${#args[@]}; i++)); do + if [ "${args[$i]}" = "$flag" ] && [ $((i + 1)) -lt ${#args[@]} ]; then + echo "${args[$((i + 1))]}" + return 0 + fi + done + return 1 +} + +cmd="${1:-}" +shift || true + +case "$cmd" in + has-session) + session="$(extract_flag_value "-t" "$@" || true)" + if [ -n "$session" ] && grep -Fxq "$session" "$STATE_FILE"; then + exit 0 + fi + exit 1 + ;; + new-session) + session="$(extract_flag_value "-s" "$@" || true)" + [ -n "$session" ] || exit 1 + if ! grep -Fxq "$session" "$STATE_FILE"; then + echo "$session" >> "$STATE_FILE" + fi + exit 0 + ;; + kill-session) + session="$(extract_flag_value "-t" "$@" || true)" + [ -n "$session" ] || exit 1 + if ! grep -Fxq "$session" "$STATE_FILE"; then + exit 1 + fi + grep -Fxv "$session" "$STATE_FILE" > "$STATE_FILE.tmp" || true + mv "$STATE_FILE.tmp" "$STATE_FILE" + exit 0 + ;; + *) + exit 0 + ;; +esac +EOF_TMUX + + chmod +x "$fakebin/id" "$fakebin/sudo" "$fakebin/tmux" + + echo "$agent_home" +} + +start_unix_socket() { + local socket_path="$1" + python3 - "$socket_path" <<'PY' & +import os +import socket +import sys +import time + +sock_path = sys.argv[1] +try: + os.unlink(sock_path) +except FileNotFoundError: + pass + +server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) +server.bind(sock_path) +server.listen(1) + +end = time.time() + 60 +while time.time() < end: + server.settimeout(1) + try: + client, _ = server.accept() + client.close() + except Exception: + pass + +server.close() +try: + os.unlink(sock_path) +except FileNotFoundError: + pass +PY + echo $! +} + +test_requires_root() { + ( + set -euo pipefail + local tmp agent_home fakebin real_user + tmp="$(mktemp -d /tmp/baudbot-subagents-test.XXXXXX)" + trap 'rm -rf "$tmp"' EXIT + + agent_home="$(setup_fixture "$tmp")" + fakebin="$tmp/fakebin" + real_user="$(/usr/bin/id -un)" + + export PATH="$fakebin:$PATH" + export BAUDBOT_TEST_ID_U="1000" + export BAUDBOT_AGENT_USER="$real_user" + export BAUDBOT_AGENT_HOME="$agent_home" + export BAUDBOT_TEST_TMUX_FILE="$tmp/tmux-sessions" + + if bash "$SCRIPT" list >/tmp/baudbot-subagents-root.out 2>&1; then + rm -f /tmp/baudbot-subagents-root.out + return 1 + fi + + grep -q "requires root" /tmp/baudbot-subagents-root.out + rm -f /tmp/baudbot-subagents-root.out + ) +} + +test_list_and_state_toggles() { + ( + set -euo pipefail + local tmp agent_home fakebin real_user + tmp="$(mktemp -d /tmp/baudbot-subagents-test.XXXXXX)" + trap 'rm -rf "$tmp"' EXIT + + agent_home="$(setup_fixture "$tmp")" + fakebin="$tmp/fakebin" + real_user="$(/usr/bin/id -un)" + + export PATH="$fakebin:$PATH" + export BAUDBOT_TEST_ID_U="0" + export BAUDBOT_AGENT_USER="$real_user" + export BAUDBOT_AGENT_HOME="$agent_home" + export BAUDBOT_TEST_TMUX_FILE="$tmp/tmux-sessions" + + local list_out + list_out="$(bash "$SCRIPT" list)" + echo "$list_out" | grep -q "sentry-agent" + + bash "$SCRIPT" install sentry-agent >/dev/null + bash "$SCRIPT" enable sentry-agent >/dev/null + bash "$SCRIPT" autostart-on sentry-agent >/dev/null + + jq -e '.agents["sentry-agent"].installed == true' "$agent_home/.pi/agent/subagents-state.json" >/dev/null + jq -e '.agents["sentry-agent"].enabled == true' "$agent_home/.pi/agent/subagents-state.json" >/dev/null + jq -e '.agents["sentry-agent"].autostart == true' "$agent_home/.pi/agent/subagents-state.json" >/dev/null + + bash "$SCRIPT" autostart-off sentry-agent >/dev/null + jq -e '.agents["sentry-agent"].autostart == false' "$agent_home/.pi/agent/subagents-state.json" >/dev/null + ) +} + +test_reconcile_status_stop() { + ( + set -euo pipefail + local tmp agent_home fakebin control_dir socket_path alias_path sock_pid real_user + tmp="$(mktemp -d /tmp/baudbot-subagents-test.XXXXXX)" + trap 'kill "$sock_pid" 2>/dev/null || true; rm -rf "$tmp"' EXIT + + agent_home="$(setup_fixture "$tmp")" + fakebin="$tmp/fakebin" + real_user="$(/usr/bin/id -un)" + control_dir="$agent_home/.pi/session-control" + socket_path="$control_dir/sentry-agent.sock" + alias_path="$control_dir/sentry-agent.alias" + + export PATH="$fakebin:$PATH" + export BAUDBOT_TEST_ID_U="0" + export BAUDBOT_AGENT_USER="$real_user" + export BAUDBOT_AGENT_HOME="$agent_home" + export BAUDBOT_TEST_TMUX_FILE="$tmp/tmux-sessions" + + sock_pid="$(start_unix_socket "$socket_path")" + for _i in $(seq 1 20); do + [ -S "$socket_path" ] && break + sleep 0.1 + done + ln -sf "$(basename "$socket_path")" "$alias_path" + + bash "$SCRIPT" autostart-on sentry-agent >/dev/null + + local reconcile_out + reconcile_out="$(bash "$SCRIPT" reconcile)" + echo "$reconcile_out" | grep -q "started sentry-agent" + + local status_out + status_out="$(bash "$SCRIPT" status sentry-agent)" + echo "$status_out" | grep -q "running" + echo "$status_out" | grep -q "sentry-agent.alias" + + bash "$SCRIPT" stop sentry-agent >/dev/null + + if grep -Fxq "sentry-agent" "$tmp/tmux-sessions"; then + return 1 + fi + ) +} + +echo "=== subagents cli tests ===" +echo "" + +run_test "requires root guard" test_requires_root +run_test "list/install/enable/autostart state" test_list_and_state_toggles +run_test "reconcile/status/stop lifecycle" test_reconcile_status_stop + +echo "" +echo "=== $PASSED/$TOTAL passed, $FAILED failed ===" + +if [ "$FAILED" -gt 0 ]; then + exit 1 +fi diff --git a/bin/test.sh b/bin/test.sh index bbb7f78..f670513 100755 --- a/bin/test.sh +++ b/bin/test.sh @@ -80,6 +80,7 @@ run_shell_tests() { run "log pruning" bash bin/prune-session-logs.test.sh run "manifest integrity" bash bin/verify-manifest.test.sh run "config flow" bash bin/config.test.sh + run "subagents cli" bash bin/subagents.test.sh run "deploy lib helpers" bash bin/lib/deploy-common.test.sh run "doctor lib helpers" bash bin/lib/doctor-common.test.sh run "update release flow" bash bin/update-release.test.sh diff --git a/docs/agents.md b/docs/agents.md index 6077ade..b4bf3ee 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -51,6 +51,10 @@ Responsibilities: - provide actionable recommendations for fixes - hand off coding tasks to dev-agent through control-agent +Sentry-agent and future role agents (SRE/QA/etc.) are managed as subagent packages +under `pi/subagents//` and deployed to `~/.pi/agent/subagents/`. +Use the `subagent_manage` tool (or `baudbot subagents ...`) to install/enable/start/stop/reconcile. + ## Session model - Control and sentry sessions are long-lived. diff --git a/docs/architecture.md b/docs/architecture.md index 24c524a..5356098 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -13,7 +13,7 @@ root-managed releases baudbot_agent user ├── ~/runtime/ # deployed runtime used by live agent -├── ~/.pi/agent/ # skills/extensions/memory/manifests +├── ~/.pi/agent/ # skills/extensions/memory/manifests/subagents └── ~/workspace/ # project repos + task worktrees ``` @@ -35,7 +35,7 @@ This allows reproducible releases and fast rollback. ```text control-agent (persistent) -├── sentry-agent (persistent/on-demand) +├── subagent packages (persistent/on-demand; e.g. sentry-agent) └── dev-agent-* (ephemeral task workers) ``` diff --git a/docs/operations.md b/docs/operations.md index 82fd5cf..1db80e2 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -17,6 +17,30 @@ sudo baudbot logs # Attach / inspect active sessions sudo baudbot attach sudo baudbot sessions +sudo baudbot subagents list +sudo baudbot subagents reconcile +``` + +## Subagent management + +```bash +# List package state +sudo baudbot subagents list + +# Status for all or one package +sudo baudbot subagents status +sudo baudbot subagents status sentry-agent + +# Lifecycle toggles +sudo baudbot subagents install sentry-agent +sudo baudbot subagents enable sentry-agent +sudo baudbot subagents autostart-on sentry-agent +sudo baudbot subagents start sentry-agent + +# Disable and remove +sudo baudbot subagents stop sentry-agent +sudo baudbot subagents disable sentry-agent +sudo baudbot subagents uninstall sentry-agent ``` ## Deployment and upgrades diff --git a/package.json b/package.json index a62a4e0..ba72b02 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "private": true, "scripts": { "test": "vitest run --config vitest.config.mjs", - "test:js": "vitest run --config vitest.config.mjs pi/extensions/heartbeat.test.mjs pi/extensions/agent-spawn.test.mjs pi/extensions/memory.test.mjs test/legacy-node-tests.test.mjs test/broker-bridge.integration.test.mjs test/integrity-status-check.test.mjs", + "test:js": "vitest run --config vitest.config.mjs pi/extensions/heartbeat.test.mjs pi/extensions/agent-spawn.test.mjs pi/extensions/subagent-manager.test.mjs pi/extensions/subagent-util.test.mjs pi/extensions/memory.test.mjs test/legacy-node-tests.test.mjs test/broker-bridge.integration.test.mjs test/integrity-status-check.test.mjs", "test:shell": "vitest run --config vitest.config.mjs test/shell-scripts.test.mjs test/security-audit.test.mjs", "test:coverage": "vitest run --config vitest.config.mjs --coverage pi/extensions/heartbeat.test.mjs pi/extensions/memory.test.mjs test/legacy-node-tests.test.mjs", "lint": "npm run lint:js && npm run lint:shell", diff --git a/pi/extensions/heartbeat.ts b/pi/extensions/heartbeat.ts index 4c53177..13af49e 100644 --- a/pi/extensions/heartbeat.ts +++ b/pi/extensions/heartbeat.ts @@ -28,6 +28,7 @@ import { StringEnum } from "@mariozechner/pi-ai"; import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; +import { discoverSubagentPackages, readSubagentState, resolveEffectiveState } from "./subagent-registry.ts"; const DEFAULT_INTERVAL_MS = 10 * 60 * 1000; // 10 minutes const MIN_INTERVAL_MS = 2 * 60 * 1000; // 2 minutes @@ -75,6 +76,25 @@ function clampInt(value: string | undefined, min: number, max: number, fallback: function getExpectedSessions(): string[] { const env = process.env.HEARTBEAT_EXPECTED_SESSIONS?.trim(); if (env) return env.split(",").map((s) => s.trim()).filter(Boolean); + try { + const discovery = discoverSubagentPackages(); + const state = readSubagentState(); + const fromSubagents = discovery.packages + .map((pkg) => ({ + effective: resolveEffectiveState(pkg, state), + alias: pkg.manifest.ready_alias, + })) + .filter((entry) => entry.effective.installed && entry.effective.enabled && entry.effective.autostart) + .map((entry) => entry.alias) + .filter(Boolean); + + if (fromSubagents.length > 0) { + return fromSubagents; + } + } catch { + // fall back to historical default + } + return ["sentry-agent"]; } diff --git a/pi/extensions/memory.test.mjs b/pi/extensions/memory.test.mjs index 17ab07d..9322e18 100644 --- a/pi/extensions/memory.test.mjs +++ b/pi/extensions/memory.test.mjs @@ -321,10 +321,10 @@ describe("memory: skill file integration", () => { ); }); - it("control-agent SKILL.md uses agent_spawn for sentry-agent startup", () => { + it("control-agent SKILL.md uses subagent_manage for sentry-agent startup", () => { assert.ok( - controlSkill.includes("session_name: sentry-agent"), - "control-agent runbook should define sentry-agent startup via agent_spawn arguments" + controlSkill.includes("subagent_manage"), + "control-agent runbook should define sentry-agent startup via subagent_manage" ); }); diff --git a/pi/extensions/subagent-manager.test.mjs b/pi/extensions/subagent-manager.test.mjs new file mode 100644 index 0000000..bc081e8 --- /dev/null +++ b/pi/extensions/subagent-manager.test.mjs @@ -0,0 +1,210 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import net from "node:net"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import subagentManagerExtension from "./subagent-manager.ts"; + +const SUBAGENTS_DIR_ENV = "BAUDBOT_SUBAGENTS_DIR"; +const SUBAGENTS_STATE_FILE_ENV = "BAUDBOT_SUBAGENTS_STATE_FILE"; +const SESSION_CONTROL_DIR_ENV = "PI_SESSION_CONTROL_DIR"; + +const ORIGINAL_SUBAGENTS_DIR = process.env[SUBAGENTS_DIR_ENV]; +const ORIGINAL_SUBAGENTS_STATE = process.env[SUBAGENTS_STATE_FILE_ENV]; +const ORIGINAL_CONTROL_DIR = process.env[SESSION_CONTROL_DIR_ENV]; +const ORIGINAL_OPENAI_KEY = process.env.OPENAI_API_KEY; + +function createHarness(execImpl) { + let registered = null; + const pi = { + registerTool(tool) { + registered = tool; + }, + exec: execImpl, + }; + subagentManagerExtension(pi); + if (!registered) throw new Error("subagent_manage tool not registered"); + return registered; +} + +function writeManifest(rootDir, manifest) { + const packageDir = path.join(rootDir, manifest.id); + mkdirSync(packageDir, { recursive: true }); + writeFileSync(path.join(packageDir, "subagent.json"), `${JSON.stringify(manifest, null, 2)}\n`, "utf-8"); + writeFileSync(path.join(packageDir, "SKILL.md"), "# Test Skill\n", "utf-8"); +} + +function startUnixSocketServer(socketPath) { + return new Promise((resolve, reject) => { + const server = net.createServer((client) => { + client.end(); + }); + + const onError = (err) => { + server.close(); + reject(err); + }; + + server.once("error", onError); + server.listen(socketPath, () => { + server.off("error", onError); + resolve(server); + }); + }); +} + +describe("subagent_manage extension tool", () => { + const tempDirs = []; + const servers = []; + + afterEach(async () => { + for (const server of servers) { + await new Promise((resolve) => server.close(() => resolve(undefined))); + } + servers.length = 0; + + for (const dir of tempDirs) { + rmSync(dir, { recursive: true, force: true }); + } + tempDirs.length = 0; + + if (ORIGINAL_SUBAGENTS_DIR === undefined) delete process.env[SUBAGENTS_DIR_ENV]; + else process.env[SUBAGENTS_DIR_ENV] = ORIGINAL_SUBAGENTS_DIR; + + if (ORIGINAL_SUBAGENTS_STATE === undefined) delete process.env[SUBAGENTS_STATE_FILE_ENV]; + else process.env[SUBAGENTS_STATE_FILE_ENV] = ORIGINAL_SUBAGENTS_STATE; + + if (ORIGINAL_CONTROL_DIR === undefined) delete process.env[SESSION_CONTROL_DIR_ENV]; + else process.env[SESSION_CONTROL_DIR_ENV] = ORIGINAL_CONTROL_DIR; + + if (ORIGINAL_OPENAI_KEY === undefined) delete process.env.OPENAI_API_KEY; + else process.env.OPENAI_API_KEY = ORIGINAL_OPENAI_KEY; + }); + + it("lists discovered subagent packages", async () => { + const root = mkdtempSync(path.join(tmpdir(), "subagent-manager-test-")); + tempDirs.push(root); + + const subagentsDir = path.join(root, "subagents"); + const statePath = path.join(root, "subagents-state.json"); + mkdirSync(subagentsDir, { recursive: true }); + + writeManifest(subagentsDir, { + id: "sentry-agent", + name: "Sentry Agent", + description: "Incident triage agent", + session_name: "sentry-agent", + model_profile: "cheap_tier", + autostart: true, + }); + + process.env[SUBAGENTS_DIR_ENV] = subagentsDir; + process.env[SUBAGENTS_STATE_FILE_ENV] = statePath; + + const execSpy = vi.fn(async (command, args) => { + if (command === "tmux" && args[0] === "has-session") { + return { stdout: "", stderr: "", code: 1, killed: false }; + } + return { stdout: "", stderr: "", code: 0, killed: false }; + }); + + const tool = createHarness(execSpy); + const result = await tool.execute("tool-call", { action: "list" }, undefined, undefined, {}); + + expect(result.isError).not.toBe(true); + expect(result.details.packages).toHaveLength(1); + expect(result.details.packages[0].id).toBe("sentry-agent"); + expect(result.details.packages[0].autostart).toBe(true); + }); + + it("enable action writes state overrides", async () => { + const root = mkdtempSync(path.join(tmpdir(), "subagent-manager-test-")); + tempDirs.push(root); + + const subagentsDir = path.join(root, "subagents"); + const statePath = path.join(root, "subagents-state.json"); + mkdirSync(subagentsDir, { recursive: true }); + + writeManifest(subagentsDir, { + id: "sentry-agent", + name: "Sentry Agent", + description: "Incident triage agent", + session_name: "sentry-agent", + model_profile: "cheap_tier", + }); + + process.env[SUBAGENTS_DIR_ENV] = subagentsDir; + process.env[SUBAGENTS_STATE_FILE_ENV] = statePath; + + const tool = createHarness(async () => ({ stdout: "", stderr: "", code: 0, killed: false })); + const result = await tool.execute( + "tool-call", + { action: "enable", id: "sentry-agent" }, + undefined, + undefined, + {}, + ); + + expect(result.isError).not.toBe(true); + const state = JSON.parse(readFileSync(statePath, "utf-8")); + expect(state.agents["sentry-agent"].installed).toBe(true); + expect(state.agents["sentry-agent"].enabled).toBe(true); + }); + + it("reconcile starts missing autostart-enabled subagent", async () => { + const root = mkdtempSync(path.join(tmpdir(), "subagent-manager-test-")); + tempDirs.push(root); + + const subagentsDir = path.join(root, "subagents"); + const statePath = path.join(root, "subagents-state.json"); + const controlDir = path.join(root, "session-control"); + mkdirSync(subagentsDir, { recursive: true }); + mkdirSync(controlDir, { recursive: true }); + + writeManifest(subagentsDir, { + id: "sentry-agent", + name: "Sentry Agent", + description: "Incident triage agent", + session_name: "sentry-agent", + model_profile: "cheap_tier", + autostart: true, + ready_timeout_sec: 3, + }); + + process.env[SUBAGENTS_DIR_ENV] = subagentsDir; + process.env[SUBAGENTS_STATE_FILE_ENV] = statePath; + process.env[SESSION_CONTROL_DIR_ENV] = controlDir; + process.env.OPENAI_API_KEY = "test-openai-key"; + + const socketPath = path.join(controlDir, "sentry-agent.sock"); + const aliasPath = path.join(controlDir, "sentry-agent.alias"); + + const execSpy = vi.fn(async (command, args) => { + if (command === "tmux" && args[0] === "has-session") { + return { stdout: "", stderr: "", code: 1, killed: false }; + } + + if (command === "tmux" && args[0] === "new-session") { + const server = await startUnixSocketServer(socketPath); + servers.push(server); + symlinkSync(path.basename(socketPath), aliasPath); + return { stdout: "", stderr: "", code: 0, killed: false }; + } + + return { stdout: "", stderr: "", code: 0, killed: false }; + }); + + const tool = createHarness(execSpy); + const result = await tool.execute( + "tool-call", + { action: "reconcile" }, + undefined, + undefined, + {}, + ); + + expect(result.isError).not.toBe(true); + expect(result.details.started).toHaveLength(1); + expect(result.details.started[0].id).toBe("sentry-agent"); + }); +}); diff --git a/pi/extensions/subagent-manager.ts b/pi/extensions/subagent-manager.ts new file mode 100644 index 0000000..e958cca --- /dev/null +++ b/pi/extensions/subagent-manager.ts @@ -0,0 +1,609 @@ +import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; +import { StringEnum } from "@mariozechner/pi-ai"; +import { Type } from "@sinclair/typebox"; +import { existsSync, mkdirSync, readlinkSync, statSync } from "node:fs"; +import net from "node:net"; +import { homedir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { + absolutePath, + discoverSubagentPackages, + ensureSubagentStateEntry, + expandHomePath, + readSubagentState, + resolveEffectiveState, + resolveModelForProfile, + resolvePathInPackage, + writeSubagentState, + type SubagentPackage, +} from "./subagent-registry.ts"; + +const SESSION_CONTROL_DIR_ENV = "PI_SESSION_CONTROL_DIR"; +const TMUX_SPAWN_TIMEOUT_MS = 15_000; +const TMUX_QUERY_TIMEOUT_MS = 4_000; +const SOCKET_PROBE_TIMEOUT_MS = 300; +const READINESS_POLL_MS = 200; + +type SpawnStage = "spawn" | "wait_alias" | "wait_socket" | "probe" | "aborted"; + +type ReadinessResult = { + ready: boolean; + aborted: boolean; + stage: SpawnStage; + aliasPath: string; + socketPath: string | null; + readyAfterMs: number; +}; + +type RuntimeStatus = { + session_name: string; + alias_exists: boolean; + socket_exists: boolean; + socket_alive: boolean; + tmux_running: boolean; +}; + +const ACTIONS = [ + "list", + "status", + "install", + "uninstall", + "enable", + "disable", + "autostart_on", + "autostart_off", + "start", + "stop", + "reconcile", +] as const; + +type Action = (typeof ACTIONS)[number]; + +function controlDir(): string { + const configured = process.env[SESSION_CONTROL_DIR_ENV]?.trim(); + if (configured) return resolve(expandHomePath(configured)); + return join(homedir(), ".pi", "session-control"); +} + +function shellQuote(value: string): string { + return `'${value.replace(/'/g, `"'"'`)}'`; +} + +function resolveSocketPathFromAlias(aliasPath: string, socketDir: string): string | null { + try { + const target = readlinkSync(aliasPath); + const resolved = resolve(socketDir, target); + if (!resolved.endsWith(".sock")) return null; + return resolved; + } catch { + return null; + } +} + +function sleep(ms: number, signal?: AbortSignal): Promise { + if (ms <= 0) return Promise.resolve(); + return new Promise((resolveSleep) => { + let settled = false; + const finish = () => { + if (settled) return; + settled = true; + clearTimeout(timer); + if (signal) signal.removeEventListener("abort", onAbort); + resolveSleep(); + }; + const onAbort = () => finish(); + const timer = setTimeout(finish, ms); + if (signal) { + if (signal.aborted) { + finish(); + return; + } + signal.addEventListener("abort", onAbort, { once: true }); + } + }); +} + +async function isSocketAlive(socketPath: string, timeoutMs: number, signal?: AbortSignal): Promise { + if (signal?.aborted) return false; + return await new Promise((resolveAlive) => { + let settled = false; + const client = net.createConnection(socketPath); + + const finish = (value: boolean) => { + if (settled) return; + settled = true; + clearTimeout(timeout); + if (signal) signal.removeEventListener("abort", onAbort); + client.removeAllListeners(); + client.destroy(); + resolveAlive(value); + }; + + const timeout = setTimeout(() => finish(false), timeoutMs); + client.once("connect", () => finish(true)); + client.once("error", () => finish(false)); + + const onAbort = () => finish(false); + if (signal) signal.addEventListener("abort", onAbort, { once: true }); + }); +} + +async function waitForSessionReadiness( + readyAlias: string, + timeoutSec: number, + signal?: AbortSignal, +): Promise { + const socketDir = controlDir(); + const aliasPath = join(socketDir, `${readyAlias}.alias`); + const startedAt = Date.now(); + const deadline = startedAt + timeoutSec * 1000; + let stage: SpawnStage = "wait_alias"; + let socketPath: string | null = null; + + while (true) { + if (signal?.aborted) { + return { + ready: false, + aborted: true, + stage: "aborted", + aliasPath, + socketPath, + readyAfterMs: Date.now() - startedAt, + }; + } + + if (Date.now() > deadline) break; + + if (existsSync(aliasPath)) { + socketPath = resolveSocketPathFromAlias(aliasPath, socketDir); + if (socketPath && existsSync(socketPath)) { + stage = "probe"; + const remainingProbeMs = deadline - Date.now(); + if (remainingProbeMs <= 0) break; + const probeTimeoutMs = Math.min(SOCKET_PROBE_TIMEOUT_MS, remainingProbeMs); + if (await isSocketAlive(socketPath, probeTimeoutMs, signal)) { + return { + ready: true, + aborted: false, + stage, + aliasPath, + socketPath, + readyAfterMs: Date.now() - startedAt, + }; + } + } else { + stage = "wait_socket"; + } + } else { + stage = "wait_alias"; + } + + const remainingPollMs = deadline - Date.now(); + if (remainingPollMs <= 0) break; + await sleep(Math.min(READINESS_POLL_MS, remainingPollMs), signal); + } + + return { + ready: false, + aborted: false, + stage, + aliasPath, + socketPath, + readyAfterMs: Date.now() - startedAt, + }; +} + +function resolveSkillPath(pkg: SubagentPackage): string | null { + const skillPathRaw = pkg.manifest.skill_path.trim(); + if (skillPathRaw.startsWith("/") || skillPathRaw.startsWith("~")) { + const skillPath = absolutePath(skillPathRaw); + if (!existsSync(skillPath)) return null; + return skillPath; + } + + const resolved = resolvePathInPackage(pkg.root_dir, skillPathRaw); + if (!resolved || !existsSync(resolved)) return null; + return resolved; +} + +function resolveCwdPath(pkg: SubagentPackage): string { + const cwdRaw = pkg.manifest.cwd.trim(); + if (!cwdRaw) return absolutePath("~"); + + if (cwdRaw.startsWith("/") || cwdRaw.startsWith("~")) { + return absolutePath(cwdRaw); + } + + const resolved = resolvePathInPackage(pkg.root_dir, cwdRaw); + if (!resolved) return absolutePath("~"); + return resolved; +} + +async function resolveRuntimeStatus(pi: ExtensionAPI, pkg: SubagentPackage): Promise { + const socketDir = controlDir(); + const aliasPath = join(socketDir, `${pkg.manifest.ready_alias}.alias`); + const aliasExists = existsSync(aliasPath); + const socketPath = aliasExists ? resolveSocketPathFromAlias(aliasPath, socketDir) : null; + const socketExists = !!socketPath && existsSync(socketPath); + const socketAlive = socketExists && socketPath ? await isSocketAlive(socketPath, SOCKET_PROBE_TIMEOUT_MS) : false; + + let tmuxRunning = false; + const tmuxResult = await pi.exec("tmux", ["has-session", "-t", pkg.manifest.session_name], { + timeout: TMUX_QUERY_TIMEOUT_MS, + }); + tmuxRunning = tmuxResult.code === 0; + + return { + session_name: pkg.manifest.session_name, + alias_exists: aliasExists, + socket_exists: socketExists, + socket_alive: socketAlive, + tmux_running: tmuxRunning, + }; +} + +function packageSummary(piPackages: SubagentPackage[], state: ReturnType) { + return piPackages.map((pkg) => { + const effective = resolveEffectiveState(pkg, state); + return { + id: pkg.id, + name: pkg.manifest.name, + description: pkg.manifest.description, + session_name: pkg.manifest.session_name, + model_profile: pkg.manifest.model_profile, + installed: effective.installed, + enabled: effective.enabled, + autostart: effective.autostart, + package_root: pkg.root_dir, + }; + }); +} + +async function startPackage(pi: ExtensionAPI, pkg: SubagentPackage, signal?: AbortSignal) { + const modelResult = resolveModelForProfile(pkg.manifest); + if (!modelResult.model) { + return { + ok: false, + error: modelResult.error ?? `unable to resolve model for ${pkg.id}`, + }; + } + + const cwdPath = resolveCwdPath(pkg); + if (!existsSync(cwdPath) || !statSync(cwdPath).isDirectory()) { + return { + ok: false, + error: `cwd does not exist: ${cwdPath}`, + }; + } + + const skillPath = resolveSkillPath(pkg); + if (!skillPath) { + return { + ok: false, + error: `skill_path does not exist for ${pkg.id}`, + }; + } + + const logPath = join(homedir(), ".pi", "agent", "logs", `spawn-${pkg.manifest.session_name}.log`); + mkdirSync(dirname(logPath), { recursive: true }); + + const tmuxCommand = [ + `cd ${shellQuote(cwdPath)}`, + 'export PATH="$HOME/.varlock/bin:$HOME/opt/node/bin:$PATH"', + `export PI_SESSION_NAME=${shellQuote(pkg.manifest.session_name)}`, + `exec varlock run --path "$HOME/.config/" -- pi --session-control --skill ${shellQuote(skillPath)} --model ${shellQuote(modelResult.model)} > ${shellQuote(logPath)} 2>&1`, + ].join(" && "); + + const spawnResult = await pi.exec("tmux", ["new-session", "-d", "-s", pkg.manifest.session_name, tmuxCommand], { + timeout: TMUX_SPAWN_TIMEOUT_MS, + signal, + }); + + if (spawnResult.code !== 0) { + return { + ok: false, + error: `tmux spawn failed for ${pkg.id}`, + details: { + stdout: spawnResult.stdout, + stderr: spawnResult.stderr, + exit_code: spawnResult.code, + }, + }; + } + + const readiness = await waitForSessionReadiness(pkg.manifest.ready_alias, pkg.manifest.ready_timeout_sec, signal); + if (!readiness.ready) { + return { + ok: false, + error: readiness.aborted + ? `readiness aborted for ${pkg.id}` + : `readiness timeout for ${pkg.id} after ${pkg.manifest.ready_timeout_sec}s`, + details: { + stage: readiness.stage, + alias_path: readiness.aliasPath, + socket_path: readiness.socketPath, + ready_after_ms: readiness.readyAfterMs, + log_path: logPath, + }, + }; + } + + return { + ok: true, + details: { + session_name: pkg.manifest.session_name, + ready_alias: pkg.manifest.ready_alias, + model: modelResult.model, + cwd: cwdPath, + skill_path: skillPath, + log_path: logPath, + ready_after_ms: readiness.readyAfterMs, + }, + }; +} + +async function stopPackage(pi: ExtensionAPI, pkg: SubagentPackage) { + const result = await pi.exec("tmux", ["kill-session", "-t", pkg.manifest.session_name], { + timeout: TMUX_QUERY_TIMEOUT_MS, + }); + + if (result.code !== 0) { + return { + ok: false, + error: `tmux session ${pkg.manifest.session_name} not running`, + }; + } + + return { + ok: true, + details: { + session_name: pkg.manifest.session_name, + }, + }; +} + +function requireId(action: Action, id: string | undefined): string | null { + const needsId = action !== "list" && action !== "reconcile"; + if (!needsId) return null; + const trimmed = id?.trim() ?? ""; + if (trimmed) return null; + return `action ${action} requires id`; +} + +export default function subagentManagerExtension(pi: ExtensionAPI): void { + pi.registerTool({ + name: "subagent_manage", + label: "Subagent Manager", + description: + "Manage built-in subagent packages (list/status/install/uninstall/enable/disable/autostart/start/stop/reconcile).", + parameters: Type.Object({ + action: StringEnum(ACTIONS), + id: Type.Optional(Type.String({ description: "Subagent id for non-list actions" })), + ready_timeout_sec: Type.Optional(Type.Number({ minimum: 1, maximum: 60 })), + }), + async execute(_toolCallId, params, signal) { + const action = params.action as Action; + const id = typeof params.id === "string" ? params.id.trim() : undefined; + + const missingIdError = requireId(action, id); + if (missingIdError) { + return { + content: [{ type: "text", text: missingIdError }], + isError: true, + details: { error: "missing_id", action }, + }; + } + + const discovery = discoverSubagentPackages(); + const state = readSubagentState(); + const pkgById = new Map(discovery.packages.map((pkg) => [pkg.id, pkg])); + + if (action === "list") { + return { + content: [{ type: "text", text: `Found ${discovery.packages.length} subagent package(s).` }], + details: { + packages: packageSummary(discovery.packages, state), + diagnostics: discovery.diagnostics, + }, + }; + } + + if (action === "reconcile") { + const started: Array> = []; + const skipped: Array> = []; + const errors: Array> = []; + + for (const pkg of discovery.packages) { + const effective = resolveEffectiveState(pkg, state); + if (!effective.installed || !effective.enabled || !effective.autostart) { + skipped.push({ id: pkg.id, reason: "not_autostart_enabled" }); + continue; + } + + const runtime = await resolveRuntimeStatus(pi, pkg); + if (runtime.socket_alive && runtime.tmux_running) { + skipped.push({ id: pkg.id, reason: "already_running", runtime }); + continue; + } + + const result = await startPackage(pi, pkg, signal); + if (result.ok) { + started.push({ id: pkg.id, ...result.details }); + } else { + errors.push({ id: pkg.id, error: result.error, details: result.details ?? null }); + } + } + + return { + content: [{ type: "text", text: `Reconcile complete: started=${started.length}, skipped=${skipped.length}, errors=${errors.length}.` }], + isError: errors.length > 0, + details: { + started, + skipped, + errors, + diagnostics: discovery.diagnostics, + }, + }; + } + + const pkg = id ? pkgById.get(id) : undefined; + if (!pkg) { + return { + content: [{ type: "text", text: `Unknown subagent id: ${id}` }], + isError: true, + details: { + error: "unknown_id", + id, + available: discovery.packages.map((entry) => entry.id), + }, + }; + } + + const stateEntry = ensureSubagentStateEntry(state, pkg.id); + const setTimeoutOverride = typeof params.ready_timeout_sec === "number" && Number.isFinite(params.ready_timeout_sec) + ? Math.min(60, Math.max(1, Math.round(params.ready_timeout_sec))) + : undefined; + + if (setTimeoutOverride !== undefined) { + pkg.manifest.ready_timeout_sec = setTimeoutOverride; + } + + switch (action) { + case "status": { + const runtime = await resolveRuntimeStatus(pi, pkg); + const effective = resolveEffectiveState(pkg, state); + return { + content: [{ type: "text", text: `Status for ${pkg.id}: enabled=${effective.enabled}, running=${runtime.socket_alive && runtime.tmux_running}` }], + details: { + id: pkg.id, + effective, + runtime, + diagnostics: discovery.diagnostics, + }, + }; + } + + case "install": { + stateEntry.installed = true; + if (stateEntry.enabled === undefined) stateEntry.enabled = true; + writeSubagentState(state); + return { + content: [{ type: "text", text: `Installed ${pkg.id}.` }], + details: { id: pkg.id, state: stateEntry }, + }; + } + + case "uninstall": { + stateEntry.installed = false; + stateEntry.enabled = false; + stateEntry.autostart = false; + writeSubagentState(state); + + const stopResult = await stopPackage(pi, pkg); + return { + content: [{ type: "text", text: stopResult.ok ? `Uninstalled ${pkg.id} and stopped session.` : `Uninstalled ${pkg.id}. Session was not running.` }], + details: { + id: pkg.id, + state: stateEntry, + stop: stopResult, + }, + }; + } + + case "enable": { + stateEntry.installed = true; + stateEntry.enabled = true; + writeSubagentState(state); + return { + content: [{ type: "text", text: `Enabled ${pkg.id}.` }], + details: { id: pkg.id, state: stateEntry }, + }; + } + + case "disable": { + stateEntry.enabled = false; + stateEntry.autostart = false; + writeSubagentState(state); + const stopResult = await stopPackage(pi, pkg); + return { + content: [{ type: "text", text: stopResult.ok ? `Disabled ${pkg.id} and stopped session.` : `Disabled ${pkg.id}. Session was not running.` }], + details: { + id: pkg.id, + state: stateEntry, + stop: stopResult, + }, + }; + } + + case "autostart_on": { + stateEntry.installed = true; + stateEntry.enabled = true; + stateEntry.autostart = true; + writeSubagentState(state); + return { + content: [{ type: "text", text: `Autostart enabled for ${pkg.id}.` }], + details: { id: pkg.id, state: stateEntry }, + }; + } + + case "autostart_off": { + stateEntry.autostart = false; + writeSubagentState(state); + return { + content: [{ type: "text", text: `Autostart disabled for ${pkg.id}.` }], + details: { id: pkg.id, state: stateEntry }, + }; + } + + case "start": { + const effective = resolveEffectiveState(pkg, state); + if (!effective.installed || !effective.enabled) { + return { + content: [{ type: "text", text: `Cannot start ${pkg.id}: installed=${effective.installed}, enabled=${effective.enabled}.` }], + isError: true, + details: { id: pkg.id, effective }, + }; + } + + const runtime = await resolveRuntimeStatus(pi, pkg); + if (runtime.socket_alive && runtime.tmux_running) { + return { + content: [{ type: "text", text: `${pkg.id} is already running.` }], + details: { id: pkg.id, runtime }, + }; + } + + const startResult = await startPackage(pi, pkg, signal); + return { + content: [{ type: "text", text: startResult.ok ? `Started ${pkg.id}.` : `Failed to start ${pkg.id}: ${startResult.error}` }], + isError: !startResult.ok, + details: { + id: pkg.id, + result: startResult, + }, + }; + } + + case "stop": { + const stopResult = await stopPackage(pi, pkg); + return { + content: [{ type: "text", text: stopResult.ok ? `Stopped ${pkg.id}.` : `Failed to stop ${pkg.id}: ${stopResult.error}` }], + isError: !stopResult.ok, + details: { + id: pkg.id, + result: stopResult, + }, + }; + } + + default: { + return { + content: [{ type: "text", text: `Unsupported action: ${action}` }], + isError: true, + details: { action }, + }; + } + } + }, + }); +} diff --git a/pi/extensions/subagent-registry.ts b/pi/extensions/subagent-registry.ts new file mode 100644 index 0000000..4aacf10 --- /dev/null +++ b/pi/extensions/subagent-registry.ts @@ -0,0 +1,362 @@ +import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { dirname, isAbsolute, join, relative, resolve } from "node:path"; + +export const SUBAGENTS_DIR_ENV = "BAUDBOT_SUBAGENTS_DIR"; +export const SUBAGENTS_STATE_FILE_ENV = "BAUDBOT_SUBAGENTS_STATE_FILE"; + +const SUBAGENT_ID_RE = /^[a-z0-9][a-z0-9-]{1,63}$/; +const SAFE_NAME_RE = /^[a-zA-Z0-9._-]+$/; + +const MIN_READY_TIMEOUT_SEC = 1; +const MAX_READY_TIMEOUT_SEC = 60; +const DEFAULT_READY_TIMEOUT_SEC = 10; + +export type SubagentModelProfile = "top_tier" | "cheap_tier" | "explicit"; + +export type SubagentUtility = { + name: string; + description: string; + entrypoint: string; + timeout_sec: number; + max_output_bytes: number; +}; + +export type SubagentManifest = { + id: string; + name: string; + description: string; + version?: string; + session_name: string; + cwd: string; + skill_path: string; + model_profile: SubagentModelProfile; + model?: string; + ready_alias: string; + ready_timeout_sec: number; + installed_by_default: boolean; + enabled_by_default: boolean; + autostart: boolean; + startup_message?: string; + utilities: SubagentUtility[]; +}; + +export type SubagentPackage = { + id: string; + root_dir: string; + manifest_path: string; + manifest: SubagentManifest; +}; + +export type SubagentStateEntry = { + installed?: boolean; + enabled?: boolean; + autostart?: boolean; +}; + +export type SubagentState = { + version: number; + agents: Record; +}; + +export type SubagentEffectiveState = { + id: string; + installed: boolean; + enabled: boolean; + autostart: boolean; +}; + +export type SubagentDiscoveryResult = { + packages: SubagentPackage[]; + diagnostics: string[]; +}; + +function isRecord(value: unknown): value is Record { + return !!value && typeof value === "object" && !Array.isArray(value); +} + +function isSafeSessionName(value: string): boolean { + return SAFE_NAME_RE.test(value); +} + +function clampReadyTimeout(value: unknown): number { + if (typeof value !== "number" || !Number.isFinite(value)) return DEFAULT_READY_TIMEOUT_SEC; + return Math.min(MAX_READY_TIMEOUT_SEC, Math.max(MIN_READY_TIMEOUT_SEC, Math.round(value))); +} + +function boolOrDefault(value: unknown, fallback: boolean): boolean { + return typeof value === "boolean" ? value : fallback; +} + +function numberOrDefault(value: unknown, fallback: number): number { + return typeof value === "number" && Number.isFinite(value) ? value : fallback; +} + +function parseUtilityList(value: unknown): SubagentUtility[] { + if (!Array.isArray(value)) return []; + const utilities: SubagentUtility[] = []; + + for (const entry of value) { + if (!isRecord(entry)) continue; + const name = typeof entry.name === "string" ? entry.name.trim() : ""; + const description = typeof entry.description === "string" ? entry.description.trim() : ""; + const entrypoint = typeof entry.entrypoint === "string" ? entry.entrypoint.trim() : ""; + + if (!name || !description || !entrypoint) continue; + + utilities.push({ + name, + description, + entrypoint, + timeout_sec: Math.max(1, Math.round(numberOrDefault(entry.timeout_sec, 30))), + max_output_bytes: Math.max(1024, Math.round(numberOrDefault(entry.max_output_bytes, 32_768))), + }); + } + + return utilities; +} + +function parseManifest(raw: unknown, manifestPath: string): { manifest?: SubagentManifest; error?: string } { + if (!isRecord(raw)) { + return { error: `manifest must be an object (${manifestPath})` }; + } + + const id = typeof raw.id === "string" ? raw.id.trim() : ""; + if (!id || !SUBAGENT_ID_RE.test(id)) { + return { error: `invalid id in ${manifestPath}` }; + } + + const name = typeof raw.name === "string" ? raw.name.trim() : ""; + if (!name) { + return { error: `missing name in ${manifestPath}` }; + } + + const description = typeof raw.description === "string" ? raw.description.trim() : ""; + if (!description) { + return { error: `missing description in ${manifestPath}` }; + } + + const sessionName = typeof raw.session_name === "string" ? raw.session_name.trim() : ""; + if (!sessionName || !isSafeSessionName(sessionName)) { + return { error: `invalid session_name in ${manifestPath}` }; + } + + const modelProfileRaw = typeof raw.model_profile === "string" ? raw.model_profile.trim() : ""; + const modelProfile = + modelProfileRaw === "top_tier" || modelProfileRaw === "cheap_tier" || modelProfileRaw === "explicit" + ? (modelProfileRaw as SubagentModelProfile) + : null; + + if (!modelProfile) { + return { error: `invalid model_profile in ${manifestPath}` }; + } + + const explicitModel = typeof raw.model === "string" ? raw.model.trim() : undefined; + if (modelProfile === "explicit" && !explicitModel) { + return { error: `model_profile=explicit requires model in ${manifestPath}` }; + } + + const readyAliasRaw = typeof raw.ready_alias === "string" ? raw.ready_alias.trim() : ""; + const readyAlias = readyAliasRaw || sessionName; + if (!isSafeSessionName(readyAlias)) { + return { error: `invalid ready_alias in ${manifestPath}` }; + } + + const manifest: SubagentManifest = { + id, + name, + description, + version: typeof raw.version === "string" ? raw.version.trim() || undefined : undefined, + session_name: sessionName, + cwd: typeof raw.cwd === "string" && raw.cwd.trim() ? raw.cwd.trim() : "~", + skill_path: typeof raw.skill_path === "string" && raw.skill_path.trim() ? raw.skill_path.trim() : "SKILL.md", + model_profile: modelProfile, + model: explicitModel, + ready_alias: readyAlias, + ready_timeout_sec: clampReadyTimeout(raw.ready_timeout_sec), + installed_by_default: boolOrDefault(raw.installed_by_default, true), + enabled_by_default: boolOrDefault(raw.enabled_by_default, true), + autostart: boolOrDefault(raw.autostart, false), + startup_message: typeof raw.startup_message === "string" ? raw.startup_message.trim() || undefined : undefined, + utilities: parseUtilityList(raw.utilities), + }; + + return { manifest }; +} + +export function expandHomePath(value: string): string { + const trimmed = value.trim(); + if (trimmed === "~") return homedir(); + if (trimmed.startsWith("~/")) return join(homedir(), trimmed.slice(2)); + return trimmed; +} + +export function absolutePath(value: string): string { + return resolve(expandHomePath(value)); +} + +export function resolveSubagentsDir(): string { + const configured = process.env[SUBAGENTS_DIR_ENV]?.trim(); + if (configured) return absolutePath(configured); + return join(homedir(), ".pi", "agent", "subagents"); +} + +export function resolveSubagentsStateFilePath(): string { + const configured = process.env[SUBAGENTS_STATE_FILE_ENV]?.trim(); + if (configured) return absolutePath(configured); + return join(homedir(), ".pi", "agent", "subagents-state.json"); +} + +export function resolvePathInPackage(rootDir: string, relativePath: string): string | null { + const trimmed = relativePath.trim(); + if (!trimmed) return null; + const resolved = resolve(rootDir, trimmed); + const rel = relative(rootDir, resolved); + if (rel.startsWith("..") || isAbsolute(rel)) return null; + return resolved; +} + +export function discoverSubagentPackages(): SubagentDiscoveryResult { + const rootDir = resolveSubagentsDir(); + const diagnostics: string[] = []; + const packages: SubagentPackage[] = []; + + if (!existsSync(rootDir)) { + return { packages, diagnostics }; + } + + let entries: string[] = []; + try { + entries = readdirSync(rootDir); + } catch (error) { + diagnostics.push(`failed to read subagents dir ${rootDir}: ${error instanceof Error ? error.message : String(error)}`); + return { packages, diagnostics }; + } + + const seen = new Set(); + for (const entry of entries.sort()) { + const packageDir = join(rootDir, entry); + let isDirectory = false; + try { + isDirectory = statSync(packageDir).isDirectory(); + } catch { + continue; + } + if (!isDirectory) continue; + + const manifestPath = join(packageDir, "subagent.json"); + if (!existsSync(manifestPath)) continue; + + let raw: unknown; + try { + raw = JSON.parse(readFileSync(manifestPath, "utf-8")) as unknown; + } catch (error) { + diagnostics.push(`failed parsing ${manifestPath}: ${error instanceof Error ? error.message : String(error)}`); + continue; + } + + const parsed = parseManifest(raw, manifestPath); + if (!parsed.manifest) { + diagnostics.push(parsed.error ?? `invalid manifest at ${manifestPath}`); + continue; + } + + if (seen.has(parsed.manifest.id)) { + diagnostics.push(`duplicate subagent id ${parsed.manifest.id} at ${manifestPath}`); + continue; + } + seen.add(parsed.manifest.id); + + packages.push({ + id: parsed.manifest.id, + root_dir: packageDir, + manifest_path: manifestPath, + manifest: parsed.manifest, + }); + } + + return { packages, diagnostics }; +} + +function emptyState(): SubagentState { + return { version: 1, agents: {} }; +} + +export function readSubagentState(): SubagentState { + const statePath = resolveSubagentsStateFilePath(); + if (!existsSync(statePath)) return emptyState(); + + try { + const parsed = JSON.parse(readFileSync(statePath, "utf-8")) as unknown; + if (!isRecord(parsed)) return emptyState(); + + const version = typeof parsed.version === "number" && Number.isFinite(parsed.version) ? parsed.version : 1; + const agentsRaw = isRecord(parsed.agents) ? parsed.agents : {}; + const agents: Record = {}; + + for (const [id, value] of Object.entries(agentsRaw)) { + if (!SUBAGENT_ID_RE.test(id) || !isRecord(value)) continue; + const entry: SubagentStateEntry = {}; + if (typeof value.installed === "boolean") entry.installed = value.installed; + if (typeof value.enabled === "boolean") entry.enabled = value.enabled; + if (typeof value.autostart === "boolean") entry.autostart = value.autostart; + agents[id] = entry; + } + + return { version, agents }; + } catch { + return emptyState(); + } +} + +export function writeSubagentState(state: SubagentState): void { + const statePath = resolveSubagentsStateFilePath(); + mkdirSync(dirname(statePath), { recursive: true }); + writeFileSync(statePath, `${JSON.stringify(state, null, 2)}\n`, "utf-8"); +} + +export function ensureSubagentStateEntry(state: SubagentState, id: string): SubagentStateEntry { + const existing = state.agents[id]; + if (existing) return existing; + const created: SubagentStateEntry = {}; + state.agents[id] = created; + return created; +} + +export function resolveEffectiveState(pkg: SubagentPackage, state: SubagentState): SubagentEffectiveState { + const override = state.agents[pkg.id] ?? {}; + + const installed = override.installed ?? pkg.manifest.installed_by_default; + const enabled = installed && (override.enabled ?? pkg.manifest.enabled_by_default); + const autostart = enabled && (override.autostart ?? pkg.manifest.autostart); + + return { + id: pkg.id, + installed, + enabled, + autostart, + }; +} + +export function resolveModelForProfile(manifest: SubagentManifest): { model?: string; error?: string } { + if (manifest.model_profile === "explicit") { + if (!manifest.model) { + return { error: `subagent ${manifest.id} is explicit model profile but model is missing` }; + } + return { model: manifest.model }; + } + + if (manifest.model_profile === "top_tier") { + if (process.env.ANTHROPIC_API_KEY) return { model: "anthropic/claude-opus-4-6" }; + if (process.env.OPENAI_API_KEY) return { model: "openai/gpt-5.2-codex" }; + if (process.env.GEMINI_API_KEY) return { model: "google/gemini-3-pro-preview" }; + if (process.env.OPENCODE_ZEN_API_KEY) return { model: "opencode-zen/claude-opus-4-6" }; + return { error: "no API key available for top_tier model profile" }; + } + + if (process.env.ANTHROPIC_API_KEY) return { model: "anthropic/claude-haiku-4-5" }; + if (process.env.OPENAI_API_KEY) return { model: "openai/gpt-5-mini" }; + if (process.env.GEMINI_API_KEY) return { model: "google/gemini-3-flash-preview" }; + if (process.env.OPENCODE_ZEN_API_KEY) return { model: "opencode-zen/claude-haiku-4-5" }; + return { error: "no API key available for cheap_tier model profile" }; +} diff --git a/pi/extensions/subagent-util.test.mjs b/pi/extensions/subagent-util.test.mjs new file mode 100644 index 0000000..4182833 --- /dev/null +++ b/pi/extensions/subagent-util.test.mjs @@ -0,0 +1,180 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import subagentUtilExtension from "./subagent-util.ts"; + +const SUBAGENTS_DIR_ENV = "BAUDBOT_SUBAGENTS_DIR"; +const SUBAGENTS_STATE_FILE_ENV = "BAUDBOT_SUBAGENTS_STATE_FILE"; + +const ORIGINAL_SUBAGENTS_DIR = process.env[SUBAGENTS_DIR_ENV]; +const ORIGINAL_SUBAGENTS_STATE = process.env[SUBAGENTS_STATE_FILE_ENV]; + +function createHarness(execImpl) { + let registered = null; + const pi = { + registerTool(tool) { + registered = tool; + }, + exec: execImpl, + }; + subagentUtilExtension(pi); + if (!registered) throw new Error("subagent_util tool not registered"); + return registered; +} + +function writeManifest(rootDir, manifest) { + const packageDir = path.join(rootDir, manifest.id); + const utilDir = path.join(packageDir, "utilities"); + mkdirSync(utilDir, { recursive: true }); + + writeFileSync(path.join(packageDir, "subagent.json"), `${JSON.stringify(manifest, null, 2)}\n`, "utf-8"); + writeFileSync(path.join(packageDir, "SKILL.md"), "# Test Skill\n", "utf-8"); + writeFileSync( + path.join(utilDir, "echo-args.sh"), + [ + "#!/usr/bin/env bash", + "set -euo pipefail", + "echo \"utility ok\"", + "echo \"$SUBAGENT_UTIL_ARGS_B64\"", + ].join("\n") + "\n", + "utf-8", + ); +} + +describe("subagent_util extension tool", () => { + const tempDirs = []; + + afterEach(() => { + for (const dir of tempDirs) { + rmSync(dir, { recursive: true, force: true }); + } + tempDirs.length = 0; + + if (ORIGINAL_SUBAGENTS_DIR === undefined) delete process.env[SUBAGENTS_DIR_ENV]; + else process.env[SUBAGENTS_DIR_ENV] = ORIGINAL_SUBAGENTS_DIR; + + if (ORIGINAL_SUBAGENTS_STATE === undefined) delete process.env[SUBAGENTS_STATE_FILE_ENV]; + else process.env[SUBAGENTS_STATE_FILE_ENV] = ORIGINAL_SUBAGENTS_STATE; + }); + + it("lists package utilities", async () => { + const root = mkdtempSync(path.join(tmpdir(), "subagent-util-test-")); + tempDirs.push(root); + + const subagentsDir = path.join(root, "subagents"); + const statePath = path.join(root, "subagents-state.json"); + mkdirSync(subagentsDir, { recursive: true }); + + writeManifest(subagentsDir, { + id: "sentry-agent", + name: "Sentry Agent", + description: "Incident triage agent", + session_name: "sentry-agent", + model_profile: "cheap_tier", + utilities: [ + { + name: "echo_args", + description: "Echo encoded args", + entrypoint: "utilities/echo-args.sh", + timeout_sec: 5, + max_output_bytes: 2048, + }, + ], + }); + + process.env[SUBAGENTS_DIR_ENV] = subagentsDir; + process.env[SUBAGENTS_STATE_FILE_ENV] = statePath; + + const tool = createHarness(async () => ({ stdout: "", stderr: "", code: 0, killed: false })); + const result = await tool.execute( + "tool-call", + { action: "list", id: "sentry-agent" }, + undefined, + undefined, + {}, + ); + + expect(result.isError).not.toBe(true); + expect(result.details.utilities).toHaveLength(1); + expect(result.details.utilities[0].name).toBe("echo_args"); + }); + + it("runs declared utility", async () => { + const root = mkdtempSync(path.join(tmpdir(), "subagent-util-test-")); + tempDirs.push(root); + + const subagentsDir = path.join(root, "subagents"); + const statePath = path.join(root, "subagents-state.json"); + mkdirSync(subagentsDir, { recursive: true }); + + writeManifest(subagentsDir, { + id: "sentry-agent", + name: "Sentry Agent", + description: "Incident triage agent", + session_name: "sentry-agent", + model_profile: "cheap_tier", + utilities: [ + { + name: "echo_args", + description: "Echo encoded args", + entrypoint: "utilities/echo-args.sh", + timeout_sec: 5, + max_output_bytes: 2048, + }, + ], + }); + + writeFileSync( + statePath, + JSON.stringify( + { + version: 1, + agents: { + "sentry-agent": { + installed: true, + enabled: true, + }, + }, + }, + null, + 2, + ) + "\n", + "utf-8", + ); + + process.env[SUBAGENTS_DIR_ENV] = subagentsDir; + process.env[SUBAGENTS_STATE_FILE_ENV] = statePath; + + const execSpy = vi.fn(async (command, args) => { + expect(command).toBe("bash"); + expect(args[0]).toBe("-lc"); + expect(args[1]).toContain("SUBAGENT_UTIL_ARGS_B64"); + return { + stdout: "utility ok\nZXhhbXBsZQ==\n", + stderr: "", + code: 0, + killed: false, + }; + }); + + const tool = createHarness(execSpy); + const result = await tool.execute( + "tool-call", + { + action: "run", + id: "sentry-agent", + utility: "echo_args", + args: { issue_id: "123" }, + }, + undefined, + undefined, + {}, + ); + + expect(result.isError).not.toBe(true); + expect(result.details.exit_code).toBe(0); + expect(result.details.stdout).toContain("utility ok"); + expect(execSpy).toHaveBeenCalledTimes(1); + }); +}); diff --git a/pi/extensions/subagent-util.ts b/pi/extensions/subagent-util.ts new file mode 100644 index 0000000..18d4a88 --- /dev/null +++ b/pi/extensions/subagent-util.ts @@ -0,0 +1,215 @@ +import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; +import { StringEnum } from "@mariozechner/pi-ai"; +import { Type } from "@sinclair/typebox"; +import { existsSync } from "node:fs"; +import { + discoverSubagentPackages, + readSubagentState, + resolveEffectiveState, + resolvePathInPackage, + type SubagentPackage, + type SubagentUtility, +} from "./subagent-registry.ts"; + +const ACTIONS = ["list", "run"] as const; +type Action = (typeof ACTIONS)[number]; + +function shellQuote(value: string): string { + return `'${value.replace(/'/g, `"'"'`)}'`; +} + +function truncateText(value: string, maxBytes: number): string { + const buffer = Buffer.from(value, "utf-8"); + if (buffer.length <= maxBytes) return value; + const prefix = buffer.subarray(0, maxBytes).toString("utf-8"); + return `${prefix}\n… [truncated to ${maxBytes} bytes]`; +} + +function summarizeUtilities(pkg: SubagentPackage) { + return pkg.manifest.utilities.map((utility) => ({ + name: utility.name, + description: utility.description, + timeout_sec: utility.timeout_sec, + max_output_bytes: utility.max_output_bytes, + })); +} + +function resolvePackageFromSessionName(packages: SubagentPackage[], sessionName: string | null): SubagentPackage | null { + if (!sessionName) return null; + const matched = packages.find((pkg) => pkg.manifest.session_name === sessionName.trim()); + return matched ?? null; +} + +function resolveUtility(pkg: SubagentPackage, utilityName: string): SubagentUtility | null { + const normalized = utilityName.trim().toLowerCase(); + if (!normalized) return null; + const match = pkg.manifest.utilities.find((utility) => utility.name.toLowerCase() === normalized); + return match ?? null; +} + +export default function subagentUtilExtension(pi: ExtensionAPI): void { + pi.registerTool({ + name: "subagent_util", + label: "Subagent Utility", + description: "List or run manifest-declared utilities for a subagent package.", + parameters: Type.Object({ + action: StringEnum(ACTIONS), + id: Type.Optional(Type.String({ description: "Subagent id (optional when called from that subagent session)" })), + utility: Type.Optional(Type.String({ description: "Utility name for action=run" })), + args: Type.Optional(Type.Record(Type.String(), Type.Unknown(), { description: "Utility arguments JSON object" })), + }), + async execute(_toolCallId, params, _signal, _onUpdate, ctx) { + const action = params.action as Action; + const requestedId = typeof params.id === "string" ? params.id.trim() : ""; + const utilityName = typeof params.utility === "string" ? params.utility.trim() : ""; + const utilityArgs = params.args && typeof params.args === "object" ? params.args : {}; + + const discovery = discoverSubagentPackages(); + const state = readSubagentState(); + const packages = discovery.packages; + + if (action === "list" && !requestedId) { + const items = packages.map((pkg) => { + const effective = resolveEffectiveState(pkg, state); + return { + id: pkg.id, + enabled: effective.enabled, + installed: effective.installed, + utilities: summarizeUtilities(pkg), + }; + }); + return { + content: [{ type: "text", text: `Listed utilities for ${items.length} package(s).` }], + details: { + packages: items, + diagnostics: discovery.diagnostics, + }, + }; + } + + let pkg: SubagentPackage | null = null; + if (requestedId) { + pkg = packages.find((entry) => entry.id === requestedId) ?? null; + } else { + const sessionName = ctx?.sessionManager?.getSessionName?.() ?? null; + pkg = resolvePackageFromSessionName(packages, sessionName); + } + + if (!pkg) { + return { + content: [{ type: "text", text: requestedId ? `Unknown subagent id: ${requestedId}` : "Could not infer subagent id from this session. Pass id explicitly." }], + isError: true, + details: { + error: "unknown_subagent", + requested_id: requestedId || null, + available: packages.map((entry) => entry.id), + }, + }; + } + + const effective = resolveEffectiveState(pkg, state); + + if (action === "list") { + return { + content: [{ type: "text", text: `Listed ${pkg.manifest.utilities.length} utilit${pkg.manifest.utilities.length === 1 ? "y" : "ies"} for ${pkg.id}.` }], + details: { + id: pkg.id, + enabled: effective.enabled, + installed: effective.installed, + utilities: summarizeUtilities(pkg), + diagnostics: discovery.diagnostics, + }, + }; + } + + if (!utilityName) { + return { + content: [{ type: "text", text: "action=run requires utility." }], + isError: true, + details: { error: "missing_utility", id: pkg.id }, + }; + } + + if (!effective.installed || !effective.enabled) { + return { + content: [{ type: "text", text: `Subagent ${pkg.id} is not enabled.` }], + isError: true, + details: { + error: "subagent_not_enabled", + id: pkg.id, + installed: effective.installed, + enabled: effective.enabled, + }, + }; + } + + const utility = resolveUtility(pkg, utilityName); + if (!utility) { + return { + content: [{ type: "text", text: `Unknown utility ${utilityName} for ${pkg.id}.` }], + isError: true, + details: { + error: "unknown_utility", + id: pkg.id, + utility: utilityName, + available: pkg.manifest.utilities.map((entry) => entry.name), + }, + }; + } + + const utilityPath = resolvePathInPackage(pkg.root_dir, utility.entrypoint); + if (!utilityPath || !existsSync(utilityPath)) { + return { + content: [{ type: "text", text: `Utility entrypoint not found: ${utility.entrypoint}` }], + isError: true, + details: { + error: "utility_not_found", + id: pkg.id, + utility: utility.name, + entrypoint: utility.entrypoint, + }, + }; + } + + const argsJson = JSON.stringify(utilityArgs ?? {}); + const argsB64 = Buffer.from(argsJson, "utf-8").toString("base64"); + + const command = [ + "set -euo pipefail", + `cd ${shellQuote(pkg.root_dir)}`, + `export SUBAGENT_UTIL_ARGS_B64=${shellQuote(argsB64)}`, + `if [ ! -x ${shellQuote(utilityPath)} ]; then chmod u+x ${shellQuote(utilityPath)} 2>/dev/null || true; fi`, + `${shellQuote(utilityPath)}`, + ].join(" && "); + + const execResult = await pi.exec("bash", ["-lc", command], { + timeout: utility.timeout_sec * 1000, + }); + + const maxBytes = utility.max_output_bytes; + const stdout = truncateText(execResult.stdout ?? "", maxBytes); + const stderr = truncateText(execResult.stderr ?? "", maxBytes); + + const ok = execResult.code === 0; + return { + content: [ + { + type: "text", + text: ok + ? `Utility ${utility.name} completed for ${pkg.id}.` + : `Utility ${utility.name} failed for ${pkg.id} (exit ${execResult.code}).`, + }, + ], + isError: !ok, + details: { + id: pkg.id, + utility: utility.name, + entrypoint: utility.entrypoint, + exit_code: execResult.code, + stdout, + stderr, + }, + }; + }, + }); +} diff --git a/pi/skills/control-agent/SKILL.md b/pi/skills/control-agent/SKILL.md index 9532619..6d7f3ff 100644 --- a/pi/skills/control-agent/SKILL.md +++ b/pi/skills/control-agent/SKILL.md @@ -305,42 +305,31 @@ This removes stale `.sock` files, cleans dead aliases, and restarts the Gateway - [ ] **Read memory files** — `ls ~/.pi/agent/memory/` then read each `.md` file to restore context from previous sessions - [ ] If `BAUDBOT_EXPERIMENTAL=1`: verify `BAUDBOT_SECRET`, create/verify `BAUDBOT_EMAIL` inbox, and start email monitor (inline mode, **300s / 5 min**) - [ ] Verify heartbeat is active (`heartbeat status` — should show enabled) -- [ ] Find or create sentry-agent: - 1. Use `list_sessions` to look for a session named `sentry-agent` - 2. If found, use that session - 3. If not found, launch with `agent_spawn` (see Sentry Agent section) - 4. If launched, continue after `agent_spawn` reports readiness +- [ ] Reconcile autostart subagents with `subagent_manage`: + 1. Call `subagent_manage` with `action: reconcile` + 2. Call `subagent_manage` with `action: status`, `id: sentry-agent` + 3. If `sentry-agent` is not running, call `subagent_manage` with `action: start`, `id: sentry-agent` - [ ] Send role assignment to the `sentry-agent` session - [ ] Clean up any stale dev-agent worktrees/tmux sessions from previous runs **Note**: Dev agents are NOT started at startup. They are spawned on-demand when tasks arrive. -### Spawning sentry-agent +### Subagent package manager -The sentry-agent triages Sentry alerts and investigates critical issues via the Sentry API. It runs on a cheap model to save tokens. +Subagents are managed declaratively via package manifests in `~/.pi/agent/subagents/`. +Use the `subagent_manage` tool for lifecycle operations instead of manual spawn commands. -**Triage (cheap):** +Common actions: -| API key | Model | -|---------|-------| -| `ANTHROPIC_API_KEY` | `anthropic/claude-haiku-4-5` | -| `OPENAI_API_KEY` | `openai/gpt-5-mini` | -| `GEMINI_API_KEY` | `google/gemini-3-flash-preview` | -| `OPENCODE_ZEN_API_KEY` | `opencode-zen/claude-haiku-4-5` | - -```bash -# Spawn via agent_spawn tool -# Call the tool with: -# session_name: sentry-agent -# cwd: ~ -# skill_path: ~/.pi/agent/skills/sentry-agent -# model: -# ready_alias: sentry-agent -# ready_timeout_sec: 10 -``` +- `list` — show installed packages and effective state +- `status` — report runtime status for a package (`id` required) +- `install` / `uninstall` — mark package installed/uninstalled in state +- `enable` / `disable` — toggle package runtime availability +- `autostart_on` / `autostart_off` — control startup reconciliation policy +- `start` / `stop` — start or stop a package session by `id` +- `reconcile` — ensure all installed+enabled+autostart packages are running -**Model note**: `github-copilot/*` models reject Personal Access Tokens and will fail in non-interactive sessions. -**Spawn note**: Do not use raw `tmux new-session` shell commands for sentry-agent startup; use `agent_spawn`. +Use `subagent_manage` for `sentry-agent` startup and recovery. Do not use raw `tmux new-session` shell commands. The sentry-agent operates in **on-demand mode** — it does NOT poll. Sentry alerts arrive via the Gateway bridge in real-time and are forwarded by you. The sentry-agent uses `sentry_monitor get ` to investigate when asked. @@ -371,7 +360,7 @@ Health checks run automatically every ~10 minutes via the `heartbeat.ts` extensi If you need to check manually, use `heartbeat trigger` to run all checks immediately. When the heartbeat reports a failure, take the appropriate action: -1. **Missing sentry-agent**: Respawn with `agent_spawn` and re-send role assignment. +1. **Missing sentry-agent**: Call `subagent_manage` with `action: start`, `id: sentry-agent`, then re-send role assignment. 2. **Orphaned dev-agents**: Kill tmux session and remove worktree. 3. **Bridge down**: Restart via `startup-pi.sh`, then check `~/.pi/agent/logs/gateway-bridge.log` (fallback: `~/.pi/agent/logs/slack-bridge.log`). 4. **Stale worktrees**: `git worktree remove --force` + `rmdir` empty parents. diff --git a/pi/subagents/README.md b/pi/subagents/README.md new file mode 100644 index 0000000..46fd422 --- /dev/null +++ b/pi/subagents/README.md @@ -0,0 +1,14 @@ +# Subagent Packages + +Subagents are packaged under `pi/subagents//`. + +Each package includes: + +- `subagent.json` — manifest (id, lifecycle defaults, model profile, session name) +- `SKILL.md` — prompt/persona instructions for the subagent session +- `utilities/` — optional scripts callable via the `subagent_util` tool + +These packages are deployed to `~/.pi/agent/subagents/` and managed by: + +- Extension tool: `subagent_manage` +- CLI: `baudbot subagents ...` diff --git a/pi/subagents/sentry-agent/SKILL.md b/pi/subagents/sentry-agent/SKILL.md new file mode 100644 index 0000000..cefe6c0 --- /dev/null +++ b/pi/subagents/sentry-agent/SKILL.md @@ -0,0 +1,113 @@ +--- +name: sentry-agent +description: Sentry monitoring agent — watches #bots-sentry Slack channel for new alerts, investigates via Sentry API, and reports triaged findings to control-agent. +--- + +# Sentry Agent + +You are a **Sentry monitoring agent** managed by Baudbot (the control-agent). + +## Role + +Triage and investigate Sentry alerts on demand. You receive alerts forwarded by the control-agent (Baudbot) and use the Sentry API to investigate them. + +## How It Works + +1. **Trigger**: The Gateway bridge receives real-time events from `#bots-sentry` via Socket Mode and delivers them to the control-agent. The control-agent forwards relevant alerts to you via `send_to_session`. +2. **Investigation**: Use `sentry_monitor get ` to fetch full issue details + stack traces from the Sentry API. +3. **Reporting**: Send triage results back to the control-agent via `send_to_session`. + +You do **NOT** poll — you are idle until the control-agent sends you an alert. This saves tokens. + +## Memory + +On startup, check for past incident history: +```bash +cat ~/.pi/agent/memory/incidents.md 2>/dev/null || true +``` + +This file contains records of past incidents — what broke, root cause, and how it was fixed. Use this to: +- Recognize recurring patterns (e.g. "this same null access error happened before in PR #142") +- Avoid re-investigating known issues +- Provide richer triage context to the control-agent + +When you investigate a new incident and find a root cause, append it to `~/.pi/agent/memory/incidents.md` with the date, issue title, root cause, fix, and what to watch for. + +**Never store secrets, API keys, or tokens in memory files.** + +## Startup + +When this skill is loaded: + +1. **Read incident history** — `cat ~/.pi/agent/memory/incidents.md 2>/dev/null || true` +2. Verify `SENTRY_AUTH_TOKEN` is set (needed for `sentry_monitor get`) +3. The `#bots-sentry` channel ID is configured via `SENTRY_CHANNEL_ID` env var +4. Acknowledge readiness to the control-agent +5. Stand by for incoming alerts + +## Triage Guidelines + +Sentry alerts in Slack include: issue title, project name, event count, and a link. The extension parses these automatically. + +**🔴 Report immediately** (send to control-agent): +- Unhandled exceptions / crashes +- Issues marked NEW or REGRESSION +- High-frequency alerts (event count spikes, 🔥) +- Errors in critical services: `ingest`, `dashboard`, `slack`, `workflows` +- Any alert Sentry marks as "critical" + +Before reporting critical issues, use `sentry_monitor get ` to fetch the stack trace. Include it in your report. + +**🟡 Batch into periodic summary** (every 30 min): +- Moderate-frequency errors in non-critical services +- Warnings +- Issues that are increasing but not yet critical + +**⚪ Track silently**: +- Low-frequency warnings +- Known/recurring issues you've already reported +- Resolved/auto-resolved alerts + +## Reporting + +Send reports to the control-agent via `send_to_session`: + +For critical issues: +``` +🚨 Sentry Alert: [count] new issue(s) + +🔴 [project] — [issue title] + [event count] events | [link] + Stack trace: [summary from sentry_monitor get] + Assessment: [your one-line triage] + +Recommendation: [what to do] +``` + +For low-priority batches (every 30 min): +``` +📊 Sentry Summary (last 30 min): [count] new alerts, [count] critical + +[brief list] + +No action needed unless you disagree. +``` + +Keep it concise. The control-agent will decide whether to notify via Slack, create a todo, or delegate to dev-agent. + +## Tool Reference + +``` +sentry_monitor get issue_id= — Fetch issue details + stack trace from Sentry API +sentry_monitor list — Show recent channel messages +sentry_monitor list count=50 — Show more messages +sentry_monitor status — Check config and state +``` + +## Environment + +Required env vars (injected via `varlock` at launch): +- `SENTRY_AUTH_TOKEN` — Sentry API bearer token +- `SENTRY_CHANNEL_ID` — Slack channel ID for Sentry alerts +- `SENTRY_ORG` — Sentry organization slug +- `GATEWAY_BOT_TOKEN` (preferred) or `SLACK_BOT_TOKEN` (legacy) — Slack bot OAuth token (required by `list` action) diff --git a/pi/subagents/sentry-agent/subagent.json b/pi/subagents/sentry-agent/subagent.json new file mode 100644 index 0000000..d5143cd --- /dev/null +++ b/pi/subagents/sentry-agent/subagent.json @@ -0,0 +1,24 @@ +{ + "id": "sentry-agent", + "name": "Sentry Agent", + "description": "Incident triage and Sentry issue investigation support.", + "version": "1.0.0", + "session_name": "sentry-agent", + "cwd": "~", + "skill_path": "SKILL.md", + "model_profile": "cheap_tier", + "ready_alias": "sentry-agent", + "ready_timeout_sec": 10, + "installed_by_default": true, + "enabled_by_default": true, + "autostart": true, + "utilities": [ + { + "name": "extract_issue_id", + "description": "Extract a Sentry issue ID from URLs or text payloads.", + "entrypoint": "utilities/extract-issue-id.mjs", + "timeout_sec": 10, + "max_output_bytes": 4096 + } + ] +} diff --git a/pi/subagents/sentry-agent/utilities/extract-issue-id.mjs b/pi/subagents/sentry-agent/utilities/extract-issue-id.mjs new file mode 100755 index 0000000..2c31552 --- /dev/null +++ b/pi/subagents/sentry-agent/utilities/extract-issue-id.mjs @@ -0,0 +1,51 @@ +#!/usr/bin/env node + +import process from "node:process"; + +function readInput() { + const encoded = process.env.SUBAGENT_UTIL_ARGS_B64?.trim(); + if (!encoded) return {}; + + try { + const raw = Buffer.from(encoded, "base64").toString("utf-8"); + const parsed = JSON.parse(raw); + return parsed && typeof parsed === "object" ? parsed : {}; + } catch { + return {}; + } +} + +function extractIssueId(text) { + if (!text) return null; + const match = text.match(/issues\/(\d+)/i); + return match?.[1] ?? null; +} + +const input = readInput(); +const candidates = []; + +if (typeof input.issue_id === "string") candidates.push(input.issue_id); +if (typeof input.url === "string") candidates.push(input.url); +if (typeof input.text === "string") candidates.push(input.text); + +let resolved = null; +for (const value of candidates) { + if (/^\d+$/.test(value.trim())) { + resolved = value.trim(); + break; + } + const extracted = extractIssueId(value); + if (extracted) { + resolved = extracted; + break; + } +} + +if (!resolved) { + process.stdout.write( + `${JSON.stringify({ ok: false, error: "Could not extract Sentry issue id", input_keys: Object.keys(input) })}\n`, + ); + process.exit(1); +} + +process.stdout.write(`${JSON.stringify({ ok: true, issue_id: resolved })}\n`); diff --git a/test/shell-scripts.test.mjs b/test/shell-scripts.test.mjs index dc969e5..c37d88b 100644 --- a/test/shell-scripts.test.mjs +++ b/test/shell-scripts.test.mjs @@ -67,4 +67,8 @@ describe("shell script test suites", () => { expect(() => runScript("bin/runtime-node-paths.test.sh")).not.toThrow(); }); + it("subagents cli", () => { + expect(() => runScript("bin/subagents.test.sh")).not.toThrow(); + }); + }); diff --git a/vitest.config.mjs b/vitest.config.mjs index e018c38..b8e5ccc 100644 --- a/vitest.config.mjs +++ b/vitest.config.mjs @@ -5,6 +5,8 @@ export default defineConfig({ include: [ "pi/extensions/heartbeat.test.mjs", "pi/extensions/agent-spawn.test.mjs", + "pi/extensions/subagent-manager.test.mjs", + "pi/extensions/subagent-util.test.mjs", "pi/extensions/memory.test.mjs", "test/legacy-node-tests.test.mjs", "test/broker-bridge.integration.test.mjs", From 347902f1fa624b81a1158333b6c9360bc82d8d3d Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Sun, 1 Mar 2026 21:24:45 -0500 Subject: [PATCH 2/5] security: harden subagent spawn command construction --- bin/subagents.sh | 76 +++++++++++++++++++++++++++++++++++++++---- bin/subagents.test.sh | 33 +++++++++++++++++++ 2 files changed, 103 insertions(+), 6 deletions(-) diff --git a/bin/subagents.sh b/bin/subagents.sh index 7a5e9ab..4f6816d 100755 --- a/bin/subagents.sh +++ b/bin/subagents.sh @@ -43,6 +43,13 @@ require_jq() { fi } +require_realpath() { + if ! command -v realpath >/dev/null 2>&1; then + echo "❌ realpath is required for subagent management" + exit 1 + fi +} + ensure_state_file() { if [ -f "$STATE_FILE" ]; then return @@ -124,6 +131,33 @@ resolve_home_path() { echo "$value" } +shell_quote() { + local value="${1:-}" + printf "'%s'" "${value//\'/\'\"\'\"\'}" +} + +is_safe_token() { + [[ "$1" =~ ^[a-zA-Z0-9._-]+$ ]] +} + +resolve_path_in_package() { + local package_dir="$1" + local relative_path="$2" + local package_root resolved + + package_root="$(realpath -m -- "$package_dir")" + resolved="$(realpath -m -- "$package_dir/$relative_path")" + + case "$resolved" in + "$package_root"|"$package_root"/*) + echo "$resolved" + return 0 + ;; + esac + + return 1 +} + resolve_model() { local profile="$1" local explicit_model="${2:-}" @@ -196,15 +230,41 @@ spawn_one() { local package_dir package_dir="$(dirname "$manifest")" + if ! is_safe_token "$session_name"; then + echo "❌ invalid session_name for $id" + return 1 + fi + if ! is_safe_token "$ready_alias"; then + echo "❌ invalid ready_alias for $id" + return 1 + fi + if [[ "$skill_path" != /* ]] && [[ "$skill_path" != ~* ]]; then - skill_path="$package_dir/$skill_path" + if ! skill_path="$(resolve_path_in_package "$package_dir" "$skill_path")"; then + echo "❌ invalid skill_path for $id: $skill_path" + return 1 + fi + else + skill_path="$(realpath -m -- "$(resolve_home_path "$skill_path")")" fi - skill_path="$(resolve_home_path "$skill_path")" if [[ "$cwd" != /* ]] && [[ "$cwd" != ~* ]]; then - cwd="$package_dir/$cwd" + if ! cwd="$(resolve_path_in_package "$package_dir" "$cwd")"; then + echo "❌ invalid cwd for $id: $cwd" + return 1 + fi + else + cwd="$(realpath -m -- "$(resolve_home_path "$cwd")")" + fi + + if [ ! -d "$cwd" ]; then + echo "❌ cwd does not exist: $cwd" + return 1 + fi + if [ ! -f "$skill_path" ]; then + echo "❌ skill_path does not exist for $id: $skill_path" + return 1 fi - cwd="$(resolve_home_path "$cwd")" local model if ! model="$(resolve_model "$profile" "$explicit_model")"; then @@ -220,8 +280,11 @@ spawn_one() { local log_path log_path="$AGENT_HOME/.pi/agent/logs/spawn-$session_name.log" - sudo -u "$AGENT_USER" bash -lc "mkdir -p '$AGENT_HOME/.pi/agent/logs'" - sudo -u "$AGENT_USER" bash -lc "tmux new-session -d -s '$session_name' \"cd '$cwd' && export PATH=\\\"\\\$HOME/.varlock/bin:\\\$HOME/opt/node/bin:\\\$PATH\\\" && export PI_SESSION_NAME='$session_name' && exec varlock run --path \\\"\\\$HOME/.config/\\\" -- pi --session-control --skill '$skill_path' --model '$model' > '$log_path' 2>&1\"" + sudo -u "$AGENT_USER" mkdir -p "$AGENT_HOME/.pi/agent/logs" + + local tmux_cmd + tmux_cmd="cd $(shell_quote "$cwd") && export PATH=\"\$HOME/.varlock/bin:\$HOME/opt/node/bin:\$PATH\" && export PI_SESSION_NAME=$(shell_quote "$session_name") && exec varlock run --path \"\$HOME/.config/\" -- pi --session-control --skill $(shell_quote "$skill_path") --model $(shell_quote "$model") > $(shell_quote "$log_path") 2>&1" + sudo -u "$AGENT_USER" tmux new-session -d -s "$session_name" "$tmux_cmd" local alias_path="$CONTROL_DIR/$ready_alias.alias" local wait_ticks=$((ready_timeout * 5)) @@ -370,6 +433,7 @@ reconcile_subagents() { main() { require_root require_jq + require_realpath local command="${1:-}" shift || true diff --git a/bin/subagents.test.sh b/bin/subagents.test.sh index bfce5e1..8e2345f 100755 --- a/bin/subagents.test.sh +++ b/bin/subagents.test.sh @@ -286,12 +286,45 @@ test_reconcile_status_stop() { ) } +test_start_rejects_injected_cwd() { + ( + set -euo pipefail + local tmp agent_home fakebin real_user marker manifest output_file + tmp="$(mktemp -d /tmp/baudbot-subagents-test.XXXXXX)" + trap 'rm -rf "$tmp"' EXIT + + agent_home="$(setup_fixture "$tmp")" + fakebin="$tmp/fakebin" + real_user="$(/usr/bin/id -un)" + marker="$tmp/injected-marker" + manifest="$agent_home/.pi/agent/subagents/sentry-agent/subagent.json" + output_file="$tmp/start.out" + + jq --arg cwd "~'; touch $marker; echo '" '.cwd = $cwd' "$manifest" > "$manifest.tmp" + mv "$manifest.tmp" "$manifest" + + export PATH="$fakebin:$PATH" + export BAUDBOT_TEST_ID_U="0" + export BAUDBOT_AGENT_USER="$real_user" + export BAUDBOT_AGENT_HOME="$agent_home" + export BAUDBOT_TEST_TMUX_FILE="$tmp/tmux-sessions" + + if bash "$SCRIPT" start sentry-agent >"$output_file" 2>&1; then + return 1 + fi + + grep -q "cwd does not exist" "$output_file" + [ ! -f "$marker" ] + ) +} + echo "=== subagents cli tests ===" echo "" run_test "requires root guard" test_requires_root run_test "list/install/enable/autostart state" test_list_and_state_toggles run_test "reconcile/status/stop lifecycle" test_reconcile_status_stop +run_test "start rejects injected cwd payload" test_start_rejects_injected_cwd echo "" echo "=== $PASSED/$TOTAL passed, $FAILED failed ===" From 36f3493861f7bd9f34026026d00e4cacd927b6ef Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Sun, 1 Mar 2026 21:43:50 -0500 Subject: [PATCH 3/5] extensions: align subagent install semantics and add regressions --- bin/subagents.test.sh | 42 +++++++++++++++++++++- pi/extensions/subagent-manager.test.mjs | 46 +++++++++++++++++++++++++ pi/extensions/subagent-manager.ts | 3 +- 3 files changed, 89 insertions(+), 2 deletions(-) diff --git a/bin/subagents.test.sh b/bin/subagents.test.sh index 8e2345f..3fef962 100755 --- a/bin/subagents.test.sh +++ b/bin/subagents.test.sh @@ -161,7 +161,7 @@ server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) server.bind(sock_path) server.listen(1) -end = time.time() + 60 +end = time.time() + 10 while time.time() < end: server.settimeout(1) try: @@ -318,6 +318,45 @@ test_start_rejects_injected_cwd() { ) } +test_start_handles_single_quote_path() { + ( + set -euo pipefail + local tmp agent_home fakebin control_dir socket_path alias_path sock_pid real_user manifest quoted_cwd output_file + tmp="$(mktemp -d /tmp/baudbot-subagents-test.XXXXXX)" + trap 'kill "$sock_pid" 2>/dev/null || true; rm -rf "$tmp"' EXIT + + agent_home="$(setup_fixture "$tmp")" + fakebin="$tmp/fakebin" + real_user="$(/usr/bin/id -un)" + control_dir="$agent_home/.pi/session-control" + socket_path="$control_dir/sentry-agent.sock" + alias_path="$control_dir/sentry-agent.alias" + manifest="$agent_home/.pi/agent/subagents/sentry-agent/subagent.json" + quoted_cwd="$tmp/cwd-with-quote's" + output_file="$tmp/start.out" + + mkdir -p "$quoted_cwd" + jq --arg cwd "$quoted_cwd" '.cwd = $cwd' "$manifest" > "$manifest.tmp" + mv "$manifest.tmp" "$manifest" + + sock_pid="$(start_unix_socket "$socket_path")" + for _i in $(seq 1 20); do + [ -S "$socket_path" ] && break + sleep 0.1 + done + ln -sf "$(basename "$socket_path")" "$alias_path" + + export PATH="$fakebin:$PATH" + export BAUDBOT_TEST_ID_U="0" + export BAUDBOT_AGENT_USER="$real_user" + export BAUDBOT_AGENT_HOME="$agent_home" + export BAUDBOT_TEST_TMUX_FILE="$tmp/tmux-sessions" + + bash "$SCRIPT" start sentry-agent >"$output_file" 2>&1 + grep -q "started sentry-agent" "$output_file" + ) +} + echo "=== subagents cli tests ===" echo "" @@ -325,6 +364,7 @@ run_test "requires root guard" test_requires_root run_test "list/install/enable/autostart state" test_list_and_state_toggles run_test "reconcile/status/stop lifecycle" test_reconcile_status_stop run_test "start rejects injected cwd payload" test_start_rejects_injected_cwd +run_test "start handles single-quote cwd path" test_start_handles_single_quote_path echo "" echo "=== $PASSED/$TOTAL passed, $FAILED failed ===" diff --git a/pi/extensions/subagent-manager.test.mjs b/pi/extensions/subagent-manager.test.mjs index bc081e8..c25361d 100644 --- a/pi/extensions/subagent-manager.test.mjs +++ b/pi/extensions/subagent-manager.test.mjs @@ -151,6 +151,52 @@ describe("subagent_manage extension tool", () => { expect(state.agents["sentry-agent"].enabled).toBe(true); }); + it("install action reenables and clears autostart to match shell behavior", async () => { + const root = mkdtempSync(path.join(tmpdir(), "subagent-manager-test-")); + tempDirs.push(root); + + const subagentsDir = path.join(root, "subagents"); + const statePath = path.join(root, "subagents-state.json"); + mkdirSync(subagentsDir, { recursive: true }); + + writeManifest(subagentsDir, { + id: "sentry-agent", + name: "Sentry Agent", + description: "Incident triage agent", + session_name: "sentry-agent", + model_profile: "cheap_tier", + }); + + writeFileSync(statePath, JSON.stringify({ + version: 1, + agents: { + "sentry-agent": { + installed: false, + enabled: false, + autostart: true, + }, + }, + }, null, 2), "utf-8"); + + process.env[SUBAGENTS_DIR_ENV] = subagentsDir; + process.env[SUBAGENTS_STATE_FILE_ENV] = statePath; + + const tool = createHarness(async () => ({ stdout: "", stderr: "", code: 0, killed: false })); + const result = await tool.execute( + "tool-call", + { action: "install", id: "sentry-agent" }, + undefined, + undefined, + {}, + ); + + expect(result.isError).not.toBe(true); + const state = JSON.parse(readFileSync(statePath, "utf-8")); + expect(state.agents["sentry-agent"].installed).toBe(true); + expect(state.agents["sentry-agent"].enabled).toBe(true); + expect(state.agents["sentry-agent"].autostart).toBe(false); + }); + it("reconcile starts missing autostart-enabled subagent", async () => { const root = mkdtempSync(path.join(tmpdir(), "subagent-manager-test-")); tempDirs.push(root); diff --git a/pi/extensions/subagent-manager.ts b/pi/extensions/subagent-manager.ts index e958cca..8401f25 100644 --- a/pi/extensions/subagent-manager.ts +++ b/pi/extensions/subagent-manager.ts @@ -485,7 +485,8 @@ export default function subagentManagerExtension(pi: ExtensionAPI): void { case "install": { stateEntry.installed = true; - if (stateEntry.enabled === undefined) stateEntry.enabled = true; + stateEntry.enabled = true; + stateEntry.autostart = false; writeSubagentState(state); return { content: [{ type: "text", text: `Installed ${pkg.id}.` }], From 9bc73b0f6be2857939c29ebd9587121a2ecf4a50 Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Sun, 1 Mar 2026 22:01:55 -0500 Subject: [PATCH 4/5] heartbeat: avoid false sentry expectation when autostart is off --- pi/extensions/heartbeat.test.mjs | 34 +++++++++++++++++++++++++++++++- pi/extensions/heartbeat.ts | 20 +++++++++---------- 2 files changed, 42 insertions(+), 12 deletions(-) diff --git a/pi/extensions/heartbeat.test.mjs b/pi/extensions/heartbeat.test.mjs index 1aea812..91bab1e 100644 --- a/pi/extensions/heartbeat.test.mjs +++ b/pi/extensions/heartbeat.test.mjs @@ -29,8 +29,13 @@ function clampInt(value, min, max, fallback) { return Math.min(max, Math.max(min, parsed)); } -function getExpectedSessions(envValue) { +function getExpectedSessions(envValue, discovered = null) { if (envValue) return envValue.split(",").map((s) => s.trim()).filter(Boolean); + if (discovered && discovered.success === true) { + if (discovered.packagesCount > 0) { + return discovered.autostartAliases; + } + } return ["sentry-agent"]; } @@ -248,6 +253,33 @@ describe("heartbeat v2: getExpectedSessions", () => { const result = getExpectedSessions("sentry-agent,,monitor,"); assert.deepEqual(result, ["sentry-agent", "monitor"]); }); + + it("returns discovered autostart aliases when discovery succeeds", () => { + const result = getExpectedSessions(undefined, { + success: true, + packagesCount: 2, + autostartAliases: ["sentry-agent"], + }); + assert.deepEqual(result, ["sentry-agent"]); + }); + + it("returns empty when discovery succeeds and all subagents are non-autostart", () => { + const result = getExpectedSessions(undefined, { + success: true, + packagesCount: 2, + autostartAliases: [], + }); + assert.deepEqual(result, []); + }); + + it("falls back to default when discovery succeeds but no packages exist", () => { + const result = getExpectedSessions(undefined, { + success: true, + packagesCount: 0, + autostartAliases: [], + }); + assert.deepEqual(result, ["sentry-agent"]); + }); }); describe("heartbeat v2: computeBackoffMs", () => { diff --git a/pi/extensions/heartbeat.ts b/pi/extensions/heartbeat.ts index 13af49e..92ab9c9 100644 --- a/pi/extensions/heartbeat.ts +++ b/pi/extensions/heartbeat.ts @@ -79,17 +79,15 @@ function getExpectedSessions(): string[] { try { const discovery = discoverSubagentPackages(); const state = readSubagentState(); - const fromSubagents = discovery.packages - .map((pkg) => ({ - effective: resolveEffectiveState(pkg, state), - alias: pkg.manifest.ready_alias, - })) - .filter((entry) => entry.effective.installed && entry.effective.enabled && entry.effective.autostart) - .map((entry) => entry.alias) - .filter(Boolean); - - if (fromSubagents.length > 0) { - return fromSubagents; + if (discovery.packages.length > 0) { + return discovery.packages + .map((pkg) => ({ + effective: resolveEffectiveState(pkg, state), + alias: pkg.manifest.ready_alias, + })) + .filter((entry) => entry.effective.installed && entry.effective.enabled && entry.effective.autostart) + .map((entry) => entry.alias) + .filter(Boolean); } } catch { // fall back to historical default From df1c4795d68dc5d97396e7da3518d8b64b6daa37 Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Sun, 1 Mar 2026 22:10:29 -0500 Subject: [PATCH 5/5] ci: align integration installer input with auth-tier prompt --- bin/ci/setup-arch.sh | 8 ++++---- bin/ci/setup-ubuntu.sh | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/bin/ci/setup-arch.sh b/bin/ci/setup-arch.sh index 3d128c2..4f86b7d 100755 --- a/bin/ci/setup-arch.sh +++ b/bin/ci/setup-arch.sh @@ -41,14 +41,14 @@ echo "=== Running bootstrap + baudbot install ===" BAUDBOT_CLI_URL="file:///home/baudbot_admin/baudbot/bin/baudbot" \ BAUDBOT_BOOTSTRAP_TARGET="/usr/local/bin/baudbot" \ bash /home/baudbot_admin/baudbot/bootstrap.sh -# Simulate interactive input: admin user, provider + Slack mode selections, -# required secrets, skip optional integrations, decline launch. -# Prompts: admin user, LLM choice(1=Anthropic), Anthropic key, +# Simulate interactive input: admin user, auth tier + provider + Slack mode +# selections, required secrets, skip optional integrations, decline launch. +# Prompts: admin user, LLM auth tier(1=API key), LLM choice(1=Anthropic), Anthropic key, # Slack mode(2=advanced), Slack bot, Slack app, Slack users, # Browser?(n), Sentry?(n), launch(n) # Arch CI droplets frequently lack netfilter modules required by setup-firewall; # skip firewall bootstrap here to keep install/integration coverage stable. -printf 'baudbot_admin\n1\nsk-ant-testkey\n2\nxoxb-test\nxapp-test\nU01TEST\nn\nn\nn\n' \ +printf 'baudbot_admin\n1\n1\nsk-ant-testkey\n2\nxoxb-test\nxapp-test\nU01TEST\nn\nn\nn\n' \ | BAUDBOT_SKIP_FIREWALL=1 BAUDBOT_INSTALL_SCRIPT_URL="file:///home/baudbot_admin/baudbot/install.sh" baudbot install echo "=== Verifying install ===" diff --git a/bin/ci/setup-ubuntu.sh b/bin/ci/setup-ubuntu.sh index 9f01557..d688fff 100755 --- a/bin/ci/setup-ubuntu.sh +++ b/bin/ci/setup-ubuntu.sh @@ -62,12 +62,12 @@ echo "=== Running bootstrap + baudbot install ===" BAUDBOT_CLI_URL="file:///home/baudbot_admin/baudbot/bin/baudbot" \ BAUDBOT_BOOTSTRAP_TARGET="/usr/local/bin/baudbot" \ bash /home/baudbot_admin/baudbot/bootstrap.sh -# Simulate interactive input: admin user, provider + Slack mode selections, -# required secrets, skip optional integrations, decline launch. -# Prompts: admin user, LLM choice(1=Anthropic), Anthropic key, +# Simulate interactive input: admin user, auth tier + provider + Slack mode +# selections, required secrets, skip optional integrations, decline launch. +# Prompts: admin user, LLM auth tier(1=API key), LLM choice(1=Anthropic), Anthropic key, # Slack mode(2=advanced), Slack bot, Slack app, Slack users, # Browser?(n), Sentry?(n), launch(n) -printf 'baudbot_admin\n1\nsk-ant-testkey\n2\nxoxb-test\nxapp-test\nU01TEST\nn\nn\nn\n' \ +printf 'baudbot_admin\n1\n1\nsk-ant-testkey\n2\nxoxb-test\nxapp-test\nU01TEST\nn\nn\nn\n' \ | BAUDBOT_INSTALL_SCRIPT_URL="file:///home/baudbot_admin/baudbot/install.sh" baudbot install echo "=== Verifying install ==="