commit 53c2fca0807bc88df6bed30a5a453ef7a80cf5c0 Author: Azat Date: Mon Feb 2 23:20:33 2026 +0100 Initial agents skill - instance management with personalities diff --git a/SKILL.md b/SKILL.md new file mode 100644 index 0000000..f429f1d --- /dev/null +++ b/SKILL.md @@ -0,0 +1,135 @@ +--- +name: agents +description: Agent instance management with personalities +metadata: + version: "1.0.0" + vibestack: + main: false +--- + +# Agents Skill + +Manages Claude agent instances with different personalities. + +## Concepts + +- **Personality**: Template (CLAUDE.md + context) defining an agent's "soul" +- **Instance**: Running Claude process with a personality +- **Workspace**: Directory where an instance does its work + +## Directory Structure + +``` +/personalities/ # Soul templates +├── orchestrator/ +│ ├── CLAUDE.md # System prompt / instructions +│ ├── config.yaml # Optional: defaults +│ └── context/ # Reference docs +├── frontend-dev/ +└── backend-dev/ + +/workspaces/ # Where work happens +├── project-a/ +└── project-b/ +``` + +## Configuration + +| Variable | Description | Default | +|----------|-------------|---------| +| `AGENTS_PORT` | API port | `8800` | +| `PERSONALITIES_DIR` | Personalities location | `/personalities` | +| `WORKSPACES_DIR` | Workspaces location | `/workspaces` | +| `AGENTS_DOMAIN` | Domain for Caddy | (none) | + +## API + +### Personalities + +```bash +# List all personalities +GET /personalities + +# Get personality details +GET /personalities/{name} +``` + +### Instances + +```bash +# Spawn new instance +POST /instances +{ + "personality": "frontend-dev", + "workspace": "/workspaces/my-app", # optional + "name": "frontend-1" # optional, auto-generated +} + +# List running instances +GET /instances + +# Get instance details +GET /instances/{id} + +# Kill instance +DELETE /instances/{id} + +# Quick spawn with defaults +POST /spawn/{personality} +``` + +### Conversations + +```bash +# List conversations +GET /conversations + +# Get conversation messages +GET /conversations/{id} +``` + +## Personality Config + +Optional `config.yaml` in personality folder: + +```yaml +name: frontend-dev +description: React/TypeScript specialist +default_workspace: /workspaces/frontend +model: claude-sonnet-4-20250514 +max_turns: 20 +max_instances: 3 +lifecycle: on-demand # or long-running +tools: + - Bash + - Read + - Write + - Edit + - Glob + - Grep +``` + +## Storage + +Auto-detects available storage: +1. PostgreSQL (if postgres skill present) +2. DuckDB (if duckdb skill present) +3. SQLite (fallback) + +## Instance Discovery + +Instances can discover each other: +```bash +# From any instance +curl http://localhost:8800/instances +``` + +System prompt includes peer info at spawn time. + +## Example: Spawning from Claude + +An orchestrator agent can spawn specialists: +```bash +curl -X POST http://localhost:8800/instances \ + -d '{"personality":"frontend-dev","workspace":"/workspaces/app"}' +``` diff --git a/scripts/autorun.sh b/scripts/autorun.sh new file mode 100644 index 0000000..8089925 --- /dev/null +++ b/scripts/autorun.sh @@ -0,0 +1,218 @@ +#!/bin/bash +set -e + +SKILLS_DIR="${SKILLS_DIR:-/skills}" +PERSONALITIES_DIR="${PERSONALITIES_DIR:-/personalities}" +WORKSPACES_DIR="${WORKSPACES_DIR:-/workspaces}" +AGENTS_DATA_DIR="${AGENTS_DATA_DIR:-/data/agents}" + +# Install dependencies +install_deps() { + local needed="" + command -v socat &>/dev/null || needed="$needed socat" + command -v jq &>/dev/null || needed="$needed jq" + command -v sqlite3 &>/dev/null || needed="$needed sqlite3" + + if [ -n "$needed" ]; then + echo "Installing dependencies:$needed" + apt-get update && apt-get install -y $needed + fi +} + +# Setup directories +setup_dirs() { + mkdir -p "$PERSONALITIES_DIR" + mkdir -p "$WORKSPACES_DIR" + mkdir -p "$AGENTS_DATA_DIR" + mkdir -p /var/run/agents + + echo "Personalities: $PERSONALITIES_DIR" + echo "Workspaces: $WORKSPACES_DIR" + echo "Data: $AGENTS_DATA_DIR" +} + +# Detect storage backend +detect_storage() { + if [ -d "$SKILLS_DIR/postgres" ] && command -v psql &>/dev/null; then + echo "postgres" + elif [ -d "$SKILLS_DIR/duckdb" ] && command -v duckdb &>/dev/null; then + echo "duckdb" + else + echo "sqlite" + fi +} + +# Initialize SQLite database (fallback) +init_sqlite() { + local db="$AGENTS_DATA_DIR/agents.db" + + sqlite3 "$db" << 'SQL' +CREATE TABLE IF NOT EXISTS instances ( + id TEXT PRIMARY KEY, + name TEXT, + personality TEXT NOT NULL, + workspace TEXT, + port INTEGER, + pid INTEGER, + status TEXT DEFAULT 'starting', + started_at TEXT DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS conversations ( + id TEXT PRIMARY KEY, + instance_id TEXT, + started_at TEXT DEFAULT CURRENT_TIMESTAMP, + status TEXT DEFAULT 'active', + FOREIGN KEY (instance_id) REFERENCES instances(id) +); + +CREATE TABLE IF NOT EXISTS messages ( + id TEXT PRIMARY KEY, + conversation_id TEXT, + role TEXT, + content TEXT, + tool_calls TEXT, + timestamp TEXT DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (conversation_id) REFERENCES conversations(id) +); +SQL + + echo "SQLite initialized: $db" +} + +# Initialize DuckDB tables +init_duckdb() { + local db="$AGENTS_DATA_DIR/agents.duckdb" + + duckdb "$db" << 'SQL' +CREATE TABLE IF NOT EXISTS instances ( + id VARCHAR PRIMARY KEY, + name VARCHAR, + personality VARCHAR NOT NULL, + workspace VARCHAR, + port INTEGER, + pid INTEGER, + status VARCHAR DEFAULT 'starting', + started_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS conversations ( + id VARCHAR PRIMARY KEY, + instance_id VARCHAR, + started_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + status VARCHAR DEFAULT 'active' +); + +CREATE TABLE IF NOT EXISTS messages ( + id VARCHAR PRIMARY KEY, + conversation_id VARCHAR, + role VARCHAR, + content VARCHAR, + tool_calls JSON, + timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); +SQL + + echo "DuckDB initialized: $db" +} + +# Configure Caddy if present +configure_caddy() { + local caddy_dir="$SKILLS_DIR/caddy" + local agents_port="${AGENTS_PORT:-8800}" + local agents_domain="${AGENTS_DOMAIN:-}" + + if [ ! -d "$caddy_dir" ]; then + echo "Caddy not found - Agents API on port $agents_port" + return 0 + fi + + echo "Caddy detected - configuring reverse proxy..." + mkdir -p "$caddy_dir/snippets.d" + + local snippet="$caddy_dir/snippets.d/agents.caddy" + + if [ -n "$agents_domain" ]; then + cat > "$snippet" << EOF +# Auto-generated by agents skill +$agents_domain { + reverse_proxy localhost:$agents_port +} +EOF + echo "Caddy config: $agents_domain -> localhost:$agents_port" + fi +} + +# Create example personality if none exist +create_example_personality() { + if [ -d "$PERSONALITIES_DIR/orchestrator" ]; then + return 0 + fi + + echo "Creating example orchestrator personality..." + mkdir -p "$PERSONALITIES_DIR/orchestrator" + + cat > "$PERSONALITIES_DIR/orchestrator/CLAUDE.md" << 'EOF' +# Orchestrator Agent + +You are the orchestrator agent. Your role is to: +1. Understand high-level tasks +2. Break them into subtasks +3. Spawn specialist agents as needed +4. Coordinate their work +5. Synthesize results + +## Spawning Agents + +To spawn a specialist: +```bash +curl -X POST http://localhost:8800/instances \ + -H "Content-Type: application/json" \ + -d '{"personality": "specialist-name", "workspace": "/workspaces/project"}' +``` + +## Available Personalities + +Check what personalities are available: +```bash +curl http://localhost:8800/personalities +``` + +## Running Instances + +Check what agents are running: +```bash +curl http://localhost:8800/instances +``` +EOF + + cat > "$PERSONALITIES_DIR/orchestrator/config.yaml" << 'EOF' +name: orchestrator +description: Coordinates other agents, breaks down tasks +model: claude-sonnet-4-20250514 +max_turns: 50 +lifecycle: long-running +EOF + + echo "Created: $PERSONALITIES_DIR/orchestrator/" +} + +install_deps +setup_dirs + +STORAGE=$(detect_storage) +echo "Storage backend: $STORAGE" + +case "$STORAGE" in + sqlite) init_sqlite ;; + duckdb) init_duckdb ;; + postgres) echo "PostgreSQL - tables managed by postgres skill" ;; +esac + +# Save storage type for run.sh +echo "$STORAGE" > "$AGENTS_DATA_DIR/.storage" + +configure_caddy +create_example_personality + +echo "Agents setup complete" diff --git a/scripts/run.sh b/scripts/run.sh new file mode 100644 index 0000000..c0dc8cb --- /dev/null +++ b/scripts/run.sh @@ -0,0 +1,391 @@ +#!/bin/bash +set -e + +AGENTS_PORT="${AGENTS_PORT:-8800}" +PERSONALITIES_DIR="${PERSONALITIES_DIR:-/personalities}" +WORKSPACES_DIR="${WORKSPACES_DIR:-/workspaces}" +AGENTS_DATA_DIR="${AGENTS_DATA_DIR:-/data/agents}" +CLAUDE_MODEL="${CLAUDE_MODEL:-claude-sonnet-4-20250514}" +INSTANCES_FILE="/var/run/agents/instances.json" + +# Initialize instances file +echo '[]' > "$INSTANCES_FILE" + +# Port allocation (start from 8900) +NEXT_PORT=8900 + +# Generate short ID +gen_id() { + head -c 4 /dev/urandom | xxd -p +} + +# Create HTTP handler +create_handler() { + cat > /tmp/agents_handler.sh << 'HANDLER' +#!/bin/bash + +PERSONALITIES_DIR="${PERSONALITIES_DIR:-/personalities}" +WORKSPACES_DIR="${WORKSPACES_DIR:-/workspaces}" +AGENTS_DATA_DIR="${AGENTS_DATA_DIR:-/data/agents}" +INSTANCES_FILE="/var/run/agents/instances.json" +CLAUDE_MODEL="${CLAUDE_MODEL:-claude-sonnet-4-20250514}" + +# Read request +read -r request_line +method=$(echo "$request_line" | cut -d' ' -f1) +full_path=$(echo "$request_line" | cut -d' ' -f2) +path=$(echo "$full_path" | cut -d'?' -f1) + +content_length=0 +while read -r header; do + header=$(echo "$header" | tr -d '\r') + [ -z "$header" ] && break + if [[ "$header" =~ ^[Cc]ontent-[Ll]ength:\ *([0-9]+) ]]; then + content_length="${BASH_REMATCH[1]}" + fi +done + +body="" +if [ "$content_length" -gt 0 ]; then + body=$(head -c "$content_length") +fi + +send_response() { + local status="$1" + local content_type="$2" + local body="$3" + local body_length=${#body} + + printf "HTTP/1.1 %s\r\n" "$status" + printf "Content-Type: %s\r\n" "$content_type" + printf "Content-Length: %d\r\n" "$body_length" + printf "Connection: close\r\n" + printf "\r\n" + printf "%s" "$body" +} + +send_json() { + send_response "200 OK" "application/json" "$1" +} + +send_error() { + send_response "$1" "application/json" "{\"error\":\"$2\"}" +} + +# Generate ID +gen_id() { + head -c 4 /dev/urandom | xxd -p +} + +# Find next available port +find_port() { + local port=8900 + while [ $port -lt 9000 ]; do + if ! netstat -tln 2>/dev/null | grep -q ":$port "; then + echo $port + return + fi + port=$((port + 1)) + done + echo "0" +} + +# List personalities +list_personalities() { + local result="[]" + for dir in "$PERSONALITIES_DIR"/*/; do + [ ! -d "$dir" ] && continue + local name=$(basename "$dir") + local desc="" + local config="$dir/config.yaml" + + if [ -f "$config" ]; then + desc=$(yq -r '.description // ""' "$config" 2>/dev/null || echo "") + fi + + result=$(echo "$result" | jq --arg n "$name" --arg d "$desc" \ + '. + [{name: $n, description: $d}]') + done + echo "$result" +} + +# Get personality details +get_personality() { + local name="$1" + local dir="$PERSONALITIES_DIR/$name" + + if [ ! -d "$dir" ]; then + echo "" + return + fi + + local claude_md="" + local config="{}" + + if [ -f "$dir/CLAUDE.md" ]; then + claude_md=$(cat "$dir/CLAUDE.md" | jq -Rs '.') + fi + + if [ -f "$dir/config.yaml" ]; then + config=$(yq -o=json '.' "$dir/config.yaml" 2>/dev/null || echo "{}") + fi + + jq -n --arg name "$name" --argjson config "$config" --argjson claude_md "$claude_md" \ + '{name: $name, config: $config, claude_md: $claude_md}' +} + +# Spawn instance +spawn_instance() { + local personality=$(echo "$body" | jq -r '.personality // empty') + local workspace=$(echo "$body" | jq -r '.workspace // empty') + local name=$(echo "$body" | jq -r '.name // empty') + + if [ -z "$personality" ]; then + send_error "400 Bad Request" "personality required" + return + fi + + local personality_dir="$PERSONALITIES_DIR/$personality" + if [ ! -d "$personality_dir" ]; then + send_error "404 Not Found" "personality not found: $personality" + return + fi + + # Generate ID and name + local id=$(gen_id) + [ -z "$name" ] && name="${personality}-${id}" + + # Find port + local port=$(find_port) + if [ "$port" = "0" ]; then + send_error "503 Service Unavailable" "no ports available" + return + fi + + # Default workspace + if [ -z "$workspace" ]; then + if [ -f "$personality_dir/config.yaml" ]; then + workspace=$(yq -r '.default_workspace // empty' "$personality_dir/config.yaml" 2>/dev/null) + fi + [ -z "$workspace" ] && workspace="$WORKSPACES_DIR/$name" + fi + + mkdir -p "$workspace" + + # Get config + local model="$CLAUDE_MODEL" + local max_turns=10 + local tools="Bash,Read,Write,Edit,Glob,Grep" + + if [ -f "$personality_dir/config.yaml" ]; then + model=$(yq -r ".model // \"$CLAUDE_MODEL\"" "$personality_dir/config.yaml" 2>/dev/null) + max_turns=$(yq -r '.max_turns // 10' "$personality_dir/config.yaml" 2>/dev/null) + local config_tools=$(yq -r '.tools // [] | join(",")' "$personality_dir/config.yaml" 2>/dev/null) + [ -n "$config_tools" ] && tools="$config_tools" + fi + + # Build system prompt with peer info + local system_prompt="" + if [ -f "$personality_dir/CLAUDE.md" ]; then + system_prompt=$(cat "$personality_dir/CLAUDE.md") + fi + + # Add peer info + local instances=$(cat "$INSTANCES_FILE") + local peers=$(echo "$instances" | jq -r '.[] | "- \(.name): port \(.port), personality: \(.personality)"' 2>/dev/null || echo "") + + if [ -n "$peers" ]; then + system_prompt="$system_prompt + +## Running Peer Agents +$peers + +You can communicate with peers via their HTTP API on localhost:{port}." + fi + + # Start Claude instance + local log_file="/var/log/agents/${name}.log" + mkdir -p /var/log/agents + + cd "$workspace" + + # Create wrapper script for this instance + cat > "/tmp/agent_${id}.sh" << WRAPPER +#!/bin/bash +cd "$workspace" +exec claude --print \ + --model "$model" \ + --max-turns "$max_turns" \ + --system-prompt "\$(cat << 'SYSPROMPT' +$system_prompt +SYSPROMPT +)" \ + --allowedTools "$tools" \ + "\$@" +WRAPPER + chmod +x "/tmp/agent_${id}.sh" + + # For now, we don't start a long-running process + # The instance is registered and can be invoked via the agents API + + # Record instance + local instance=$(jq -n \ + --arg id "$id" \ + --arg name "$name" \ + --arg personality "$personality" \ + --arg workspace "$workspace" \ + --argjson port "$port" \ + --arg status "ready" \ + --arg started_at "$(date -Iseconds)" \ + '{id:$id, name:$name, personality:$personality, workspace:$workspace, port:$port, status:$status, started_at:$started_at}') + + # Update instances file + local instances=$(cat "$INSTANCES_FILE") + echo "$instances" | jq --argjson inst "$instance" '. + [$inst]' > "$INSTANCES_FILE" + + send_json "$instance" +} + +# List instances +list_instances() { + cat "$INSTANCES_FILE" +} + +# Get instance +get_instance() { + local id="$1" + local instance=$(jq -r --arg id "$id" '.[] | select(.id == $id or .name == $id)' "$INSTANCES_FILE") + + if [ -z "$instance" ] || [ "$instance" = "null" ]; then + send_error "404 Not Found" "instance not found" + return + fi + + echo "$instance" +} + +# Delete instance +delete_instance() { + local id="$1" + local instance=$(jq -r --arg id "$id" '.[] | select(.id == $id or .name == $id)' "$INSTANCES_FILE") + + if [ -z "$instance" ] || [ "$instance" = "null" ]; then + send_error "404 Not Found" "instance not found" + return + fi + + # Kill process if running + local pid=$(echo "$instance" | jq -r '.pid // empty') + if [ -n "$pid" ] && [ "$pid" != "null" ]; then + kill "$pid" 2>/dev/null || true + fi + + # Remove from instances + local instances=$(cat "$INSTANCES_FILE") + echo "$instances" | jq --arg id "$id" '[.[] | select(.id != $id and .name != $id)]' > "$INSTANCES_FILE" + + send_json '{"deleted":true}' +} + +# Invoke instance (run a prompt) +invoke_instance() { + local id="$1" + local instance=$(jq -r --arg id "$id" '.[] | select(.id == $id or .name == $id)' "$INSTANCES_FILE") + + if [ -z "$instance" ] || [ "$instance" = "null" ]; then + send_error "404 Not Found" "instance not found" + return + fi + + local prompt=$(echo "$body" | jq -r '.prompt // empty') + if [ -z "$prompt" ]; then + send_error "400 Bad Request" "prompt required" + return + fi + + local inst_id=$(echo "$instance" | jq -r '.id') + local workspace=$(echo "$instance" | jq -r '.workspace') + + cd "$workspace" + local result=$("/tmp/agent_${inst_id}.sh" "$prompt" 2>&1) || true + + local result_json=$(echo "$result" | jq -Rs '.') + send_json "{\"result\":$result_json}" +} + +# Route requests +case "$method:$path" in + GET:/health) + send_json '{"status":"ok"}' + ;; + GET:/personalities) + result=$(list_personalities) + send_json "$result" + ;; + GET:/personalities/*) + name="${path#/personalities/}" + result=$(get_personality "$name") + if [ -z "$result" ]; then + send_error "404 Not Found" "personality not found" + else + send_json "$result" + fi + ;; + POST:/instances) + spawn_instance + ;; + GET:/instances) + result=$(list_instances) + send_json "$result" + ;; + GET:/instances/*) + id="${path#/instances/}" + result=$(get_instance "$id") + [ -n "$result" ] && send_json "$result" + ;; + DELETE:/instances/*) + id="${path#/instances/}" + delete_instance "$id" + ;; + POST:/instances/*/invoke) + id=$(echo "$path" | sed 's|/instances/\([^/]*\)/invoke|\1|') + invoke_instance "$id" + ;; + POST:/spawn/*) + personality="${path#/spawn/}" + body="{\"personality\":\"$personality\"}" + spawn_instance + ;; + *) + send_error "404 Not Found" "Unknown endpoint. Try: GET /personalities, GET /instances, POST /instances" + ;; +esac +HANDLER + + chmod +x /tmp/agents_handler.sh + + # Export vars + export PERSONALITIES_DIR + export WORKSPACES_DIR + export AGENTS_DATA_DIR + export INSTANCES_FILE + export CLAUDE_MODEL +} + +# Cleanup stale instances on startup +cleanup_stale() { + echo '[]' > "$INSTANCES_FILE" +} + +# Serve +serve() { + echo "Starting Agents API on port $AGENTS_PORT..." + echo "Personalities: $PERSONALITIES_DIR" + echo "Workspaces: $WORKSPACES_DIR" + + exec socat TCP-LISTEN:$AGENTS_PORT,reuseaddr,fork EXEC:"/tmp/agents_handler.sh" +} + +cleanup_stale +create_handler +serve