Skip to content

Instantly share code, notes, and snippets.

@thanakijwanavit
Created February 8, 2026 08:17
Show Gist options
  • Select an option

  • Save thanakijwanavit/20d4d03a48371fb1480567113da695ef to your computer and use it in GitHub Desktop.

Select an option

Save thanakijwanavit/20d4d03a48371fb1480567113da695ef to your computer and use it in GitHub Desktop.
Gastown Automated Monitor + OpenClaw Bot Setup - Claude CLI health checks, hourly handoffs, upstream tracking, Discord reporting

Gastown Automated Monitor & OpenClaw Bot Setup

A fully automated monitoring system for Gas Town that uses Claude CLI to continuously check agent health, enforce hourly handoffs, track upstream updates, and report to Discord.


Overview

Component What It Does
Claude Monitor Runs every 20 min in tmux. Gathers system state, sends to Claude CLI for analysis + action
Hourly Handoffs Enforces max 1-hour session age for all agents (Mayor, Deacon, Witness, Refinery, Crews)
Upstream Checker Hits GitHub API each cycle to detect new gastown releases or code changes
Discord Reporter Posts monitor reports to Discord channels via discord-notify
OpenClaw Bot Full Discord integration for status queries, Mayor relay, and agent monitoring

1. Claude Monitor Setup

Prerequisites

# Claude CLI installed and authenticated
claude --version

# Gas Town installed
gt --version

# tmux available
tmux -V

The Monitor Script

Create scripts/claude-monitor.sh in your gastown workspace:

#!/usr/bin/env bash
# Claude Gastown Monitor - Automated health & activity monitor
# Runs every 20 minutes using Claude CLI in non-interactive mode

set -euo pipefail

# ─── Configuration ───
INTERVAL_SECONDS=1200        # 20 minutes
GT_DIR="$HOME/gt"            # Your gastown workspace
RIG_DIR="$GT_DIR/<your-rig>" # Your rig directory
LOG_DIR="$GT_DIR/daemon"
LOG_FILE="$LOG_DIR/claude-monitor.log"
STATE_FILE="$LOG_DIR/claude-monitor-state.json"
PID_FILE="$LOG_DIR/claude-monitor.pid"
MAX_LOG_SIZE=5242880          # 5MB log rotation
MODEL="opus"                  # Claude model (opus, sonnet, etc.)

# Upstream tracking
UPSTREAM_REPO="steveyegge/gastown"
UPSTREAM_API="https://api.github.com/repos/$UPSTREAM_REPO"
UPDATE_LOG="$LOG_DIR/gastown-upstream-updates.jsonl"
UPDATE_STATE="$LOG_DIR/gastown-upstream-state.json"

# Handoff enforcement
HANDOFF_STATE="$LOG_DIR/handoff-tracker.json"
HANDOFF_LOG="$LOG_DIR/handoff-history.jsonl"
HANDOFF_MAX_AGE=3600          # 1 hour in seconds

# Discord (optional - set channel IDs for your server)
DISCORD_NOTIFY="$GT_DIR/bin/discord-notify"
DISCORD_CHANNEL_STATUS=0      # Your #gt-status channel ID
DISCORD_CHANNEL_ALERTS=0      # Your #gt-alerts channel ID

mkdir -p "$LOG_DIR"
echo $$ > "$PID_FILE"

Core Functions

Logging & Discord

log() {
    local ts=$(date '+%Y-%m-%d %H:%M:%S')
    echo "[$ts] $1" | tee -a "$LOG_FILE"
}

discord_report() {
    local message="$1"
    local channel="${2:-$DISCORD_CHANNEL_STATUS}"
    local title="${3:-Claude Monitor Report}"
    local color="${4:-blue}"
    [ ${#message} -gt 1800 ] && message="${message:0:1797}..."
    if [ -x "$DISCORD_NOTIFY" ] && [ "$channel" -gt 0 ] 2>/dev/null; then
        "$DISCORD_NOTIFY" "$message" --channel "$channel" \
            --title "$title" --color "$color" --from "claude-monitor" \
            >> "$LOG_FILE" 2>&1 || true
    fi
}

rotate_log() {
    if [ -f "$LOG_FILE" ] && [ "$(stat -c%s "$LOG_FILE" 2>/dev/null || echo 0)" -gt "$MAX_LOG_SIZE" ]; then
        mv "$LOG_FILE" "${LOG_FILE}.old"
        log "Log rotated"
    fi
}

Upstream Update Checker

check_upstream_updates() {
    local ts=$(date '+%Y-%m-%dT%H:%M:%S')
    local current_version=$(gt --version 2>&1 || echo "unknown")

    log "Checking gastown upstream updates..."

    # Fetch latest release and recent commits from GitHub API
    local latest_release=$(curl -sf "$UPSTREAM_API/releases/latest" 2>/dev/null || echo '{}')
    local release_tag=$(echo "$latest_release" | python3 -c \
        "import sys,json; print(json.load(sys.stdin).get('tag_name','unknown'))" 2>/dev/null || echo "unknown")
    local release_date=$(echo "$latest_release" | python3 -c \
        "import sys,json; print(json.load(sys.stdin).get('published_at','unknown'))" 2>/dev/null || echo "unknown")

    local recent_commits=$(curl -sf "$UPSTREAM_API/commits?per_page=10" 2>/dev/null || echo '[]')
    local commits_summary=$(echo "$recent_commits" | python3 -c "
import sys, json
try:
    for c in json.load(sys.stdin):
        sha = c['sha'][:7]
        msg = c['commit']['message'].split('\n')[0][:100]
        date = c['commit']['author']['date']
        print(f'{sha} {date} {msg}')
except: print('Could not parse commits')
" 2>/dev/null || echo "Could not fetch commits")

    # Normalize and compare versions
    local norm_current=$(echo "$current_version" | sed 's/^gt version //; s/^v//; s/[[:space:]]//g')
    local norm_release=$(echo "$release_tag" | sed 's/^v//; s/[[:space:]]//g')
    local update_available="false"
    if [ "$norm_release" != "unknown" ] && [ "$norm_current" != "$norm_release" ]; then
        update_available="true"
        log "UPDATE AVAILABLE: $norm_current -> $norm_release"
    else
        log "Gastown version $norm_current is current (latest: $norm_release)"
    fi

    # Record to JSONL (append-only history)
    python3 - "$ts" "$norm_current" "$norm_release" "$release_date" \
        "$update_available" "$UPDATE_LOG" <<'PYEOF'
import json, sys
ts, cur, rel, rdate, upd, logfile = sys.argv[1:7]
entry = {"timestamp": ts, "current_version": cur, "latest_release": rel,
         "release_date": rdate, "update_available": upd == "true"}
with open(logfile, "a") as f:
    f.write(json.dumps(entry) + "\n")
PYEOF

    # Output summary for Claude prompt
    echo "=== GASTOWN UPSTREAM UPDATES ===
Installed: $current_version | Latest: $release_tag ($release_date)
$([ "$update_available" = "true" ] && echo "*** UPDATE AVAILABLE ***" || echo "Up to date.")

Recent commits (steveyegge/gastown):
$commits_summary
"
}

Hourly Handoff Enforcement

This is the key automation -- forces every agent to restart with fresh context every hour:

enforce_handoffs() {
    local now=$(date +%s)
    local ts=$(date '+%Y-%m-%dT%H:%M:%S')
    local handoff_summary="" handoffs_done=0

    log "Checking agent session ages (max ${HANDOFF_MAX_AGE}s)..."

    # Load last handoff timestamps
    local tracker="{}"
    [ -f "$HANDOFF_STATE" ] && tracker=$(cat "$HANDOFF_STATE" 2>/dev/null || echo "{}")

    # ─── Define your agents here ───
    # Format: "tmux_session_name|restart_command|display_name"
    local agents=(
        "hq-mayor|gt mayor restart|Mayor"
        "hq-deacon|gt deacon restart|Deacon"
        "gt-<rig>-witness|gt witness restart <rig>|Witness"
        "gt-<rig>-refinery|gt refinery restart <rig>|Refinery"
        # Add your crews:
        "gt-<rig>-crew-<name>|gt crew restart <name> --rig <rig>|Crew/<name>"
    )

    for entry in "${agents[@]}"; do
        IFS='|' read -r session_name handoff_cmd display_name <<< "$entry"

        # Skip if session doesn't exist
        tmux has-session -t "$session_name" 2>/dev/null || continue

        # Get session age
        local session_created=$(tmux display-message -t "$session_name" \
            -p '#{session_created}' 2>/dev/null || echo "0")
        local last_handoff=$(echo "$tracker" | python3 -c \
            "import sys,json; print(json.load(sys.stdin).get('$session_name',0))" \
            2>/dev/null || echo "0")

        local effective_start=$session_created
        [ "$last_handoff" -gt "$session_created" ] 2>/dev/null && effective_start=$last_handoff
        local age=$(( now - effective_start ))

        if [ "$age" -ge "$HANDOFF_MAX_AGE" ]; then
            log "HANDOFF: $display_name running $(( age/60 ))m. Restarting..."

            if cd "$GT_DIR" && eval "$handoff_cmd" >> "$LOG_FILE" 2>&1; then
                log "HANDOFF: $display_name restarted OK"
                tracker=$(echo "$tracker" | python3 -c \
                    "import sys,json; d=json.load(sys.stdin); d['$session_name']=$now; \
                     json.dump(d,sys.stdout)")
                echo "{\"timestamp\":\"$ts\",\"agent\":\"$display_name\",\"age\":$age,\"status\":\"success\"}" \
                    >> "$HANDOFF_LOG"
                handoffs_done=$((handoffs_done + 1))
            else
                log "HANDOFF: $display_name FAILED"
                echo "{\"timestamp\":\"$ts\",\"agent\":\"$display_name\",\"age\":$age,\"status\":\"failed\"}" \
                    >> "$HANDOFF_LOG"
            fi
        fi
    done

    # Save tracker
    echo "$tracker" | python3 -c \
        "import sys,json; json.dump(json.load(sys.stdin), open('$HANDOFF_STATE','w'), indent=2)" \
        2>/dev/null || true

    # Report to Discord if handoffs happened
    [ "$handoffs_done" -gt 0 ] && discord_report \
        "Hourly handoffs: $handoffs_done agents restarted" \
        "$DISCORD_CHANNEL_STATUS" "Agent Handoffs" "yellow"

    log "Handoffs: $handoffs_done triggered"
}

State Gathering

gather_state() {
    local state=""

    # Upstream updates
    state+="$(check_upstream_updates 2>&1)"

    # Handoff enforcement
    state+="$(enforce_handoffs 2>&1)"

    # Agent sessions
    state+="=== TMUX SESSIONS ===
$(tmux list-sessions 2>/dev/null || echo 'none')

"
    # Beads activity
    if [ -f "$RIG_DIR/.beads/issues.jsonl" ]; then
        local total=$(wc -l < "$RIG_DIR/.beads/issues.jsonl")
        local open=$(grep -c '"status":"open"' "$RIG_DIR/.beads/issues.jsonl" 2>/dev/null || echo 0)
        local closed=$(grep -c '"status":"closed"' "$RIG_DIR/.beads/issues.jsonl" 2>/dev/null || echo 0)
        state+="=== BEADS ===
Total: $total | Open: $open | Closed: $closed
"
    fi

    # Git activity
    state+="=== GIT (last 24h) ===
$(cd "$GT_DIR" && git log --oneline --since='24 hours ago' 2>/dev/null || echo 'none')

"
    # Git sync status
    cd "$GT_DIR" && git fetch origin --quiet 2>/dev/null || true
    local ahead=$(git rev-list --count origin/master..HEAD 2>/dev/null || echo "?")
    local behind=$(git rev-list --count HEAD..origin/master 2>/dev/null || echo "?")
    state+="Ahead: $ahead | Behind: $behind

"
    # System health
    state+="=== SYSTEM ===
$(free -h 2>/dev/null | head -3)
Load: $(cat /proc/loadavg) | CPUs: $(nproc)
Disk: $(df -h / | tail -1)

"
    # Daemon health
    state+="=== DAEMONS ===
"
    for pidfile in "$LOG_DIR"/*.pid; do
        [ -f "$pidfile" ] || continue
        local name=$(basename "$pidfile" .pid)
        local pid=$(cat "$pidfile" 2>/dev/null)
        if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then
            state+="$name: RUNNING (PID $pid)
"
        else
            state+="$name: DEAD (PID $pid)
"
        fi
    done

    echo "$state"
}

Claude Analysis & Main Loop

run_claude_check() {
    local state=$(gather_state 2>&1)

    local prompt="You are the Gastown Automated Monitor Agent. Analyze the system state and take action.

## Responsibilities
1. Check for upstream gastown updates (steveyegge/gastown)
2. Verify all agents are running (Mayor, Deacon, Witness, Refinery, Crews)
3. Ensure beads are being worked on and closed
4. Check git commits are happening and synced
5. Hourly handoffs are enforced automatically - report any failures
6. File new beads for improvements if needed

## System State
$state

## Actions to Take
- Restart dead agents: gt mayor start, gt deacon start, etc.
- Sync git: cd $GT_DIR && git pull --rebase && bd sync && git push
- File beads: bd new --rig <rig> --type task --title '...' --body '...'
- Nudge idle crews: gt nudge <rig>/crew/<name> 'message'
- Update gastown if new version: gt update

IMPORTANT: Fully automated. Do NOT ask for user input. Act decisively."

    log "Running Claude check (model: $MODEL)..."

    local output=$(cd "$GT_DIR" && claude \
        --print \
        --model "$MODEL" \
        --dangerously-skip-permissions \
        --max-budget-usd 5 \
        --verbose \
        "$prompt" 2>&1) || true

    log "Claude response:"
    echo "$output" | tee -a "$LOG_FILE"

    # Send to Discord
    if [ -n "$output" ]; then
        discord_report "$output" "$DISCORD_CHANNEL_STATUS" "Monitor Report" "blue"
        # Alert channel for critical issues
        if echo "$output" | grep -qi 'critical\|DEAD\|DOWN\|FAILED\|UPDATE AVAILABLE'; then
            discord_report "$output" "$DISCORD_CHANNEL_ALERTS" "Monitor ALERT" "red"
        fi
    fi

    # Save state
    cat > "$STATE_FILE" <<EOF
{"last_check":"$(date '+%Y-%m-%dT%H:%M:%S')","model":"$MODEL","pid":$$,"status":"running"}
EOF
    log "Check complete."
}

# Cleanup on exit
cleanup() {
    log "Monitor shutting down (PID $$)"
    rm -f "$PID_FILE"
}
trap cleanup EXIT INT TERM

# ─── Main Loop ───
log "Monitor started (PID $$) | Model: $MODEL | Interval: ${INTERVAL_SECONDS}s"

while true; do
    rotate_log
    run_claude_check
    log "Next check in $(( INTERVAL_SECONDS / 60 )) minutes..."
    sleep "$INTERVAL_SECONDS"
done

Control Script

Create scripts/claude-monitor-ctl.sh:

#!/usr/bin/env bash
TMUX_SESSION="claude-monitor"
SCRIPT="$HOME/gt/scripts/claude-monitor.sh"
PID_FILE="$HOME/gt/daemon/claude-monitor.pid"
LOG_FILE="$HOME/gt/daemon/claude-monitor.log"

case "${1:-status}" in
    start)
        if tmux has-session -t "$TMUX_SESSION" 2>/dev/null; then
            echo "Already running"; exit 0
        fi
        tmux new-session -d -s "$TMUX_SESSION" "bash $SCRIPT 2>&1"
        sleep 1 && echo "Started. View: tmux attach -t $TMUX_SESSION"
        ;;
    stop)
        [ -f "$PID_FILE" ] && kill "$(cat "$PID_FILE")" 2>/dev/null
        tmux kill-session -t "$TMUX_SESSION" 2>/dev/null
        rm -f "$PID_FILE"
        echo "Stopped."
        ;;
    restart) "$0" stop; sleep 2; "$0" start ;;
    status)
        tmux has-session -t "$TMUX_SESSION" 2>/dev/null && echo "RUNNING" || echo "NOT RUNNING"
        [ -f "$PID_FILE" ] && echo "PID: $(cat "$PID_FILE")"
        tail -20 "$LOG_FILE" 2>/dev/null
        ;;
    logs) tail -f "$LOG_FILE" ;;
    *) echo "Usage: $0 {start|stop|restart|status|logs}" ;;
esac

Launch

chmod +x scripts/claude-monitor.sh scripts/claude-monitor-ctl.sh
bash scripts/claude-monitor-ctl.sh start

2. What Gets Recorded

File Format Purpose
daemon/claude-monitor.log Text Full monitor output with timestamps
daemon/claude-monitor-state.json JSON Last check time, PID, status
daemon/gastown-upstream-updates.jsonl JSONL One line per cycle: version, release, update available
daemon/gastown-upstream-state.json JSON Latest snapshot: version, release notes, recent commits
daemon/handoff-tracker.json JSON Last handoff epoch per agent
daemon/handoff-history.jsonl JSONL Every handoff: agent, age, success/failure

3. OpenClaw Bot Setup

OpenClaw provides Discord integration for real-time Gas Town monitoring.

Install OpenClaw

npm install -g openclaw
# or
brew install openclaw

Configure Discord Bot

  1. Create a Discord bot at https://discord.com/developers/applications
  2. Enable Message Content Intent under Bot settings
  3. Invite bot to your server with message read/write permissions

Create Discord Channels

Set up these channels in your Discord server:

Channel Purpose
#gt-status Overall status reports (monitor posts here)
#gt-alerts Critical alerts only
#beads Bead tracking
#mayor Mayor communication relay
#deploys Deployment notifications

Discord Notify Helper

Create bin/discord-notify for sending messages from scripts:

#!/usr/bin/env python3
"""Send Discord notifications from Gas Town agents."""
import discord, asyncio, argparse, sys

TOKEN = "YOUR_BOT_TOKEN"
DEFAULT_CHANNEL_ID = 0  # Your #gt-status channel ID

async def send(args):
    intents = discord.Intents.default()
    client = discord.Client(intents=intents)

    @client.event
    async def on_ready():
        channel = client.get_channel(args.channel)
        if channel:
            colors = {'green': 0x00ff00, 'red': 0xff0000, 'yellow': 0xffff00, 'blue': 0x0099ff}
            embed = discord.Embed(
                title=args.title,
                description=args.message,
                color=colors.get(args.color, 0x0099ff)
            )
            embed.set_footer(text=f"From: {args.sender}")
            await channel.send(embed=embed)
            print(f"Sent to #{channel.name}")
        else:
            print(f"Channel {args.channel} not found", file=sys.stderr)
        await client.close()

    await client.start(TOKEN)

parser = argparse.ArgumentParser()
parser.add_argument('message')
parser.add_argument('--channel', '-c', type=int, default=DEFAULT_CHANNEL_ID)
parser.add_argument('--title', '-t', default='Gas Town Notification')
parser.add_argument('--color', default='blue', choices=['green', 'red', 'yellow', 'blue'])
parser.add_argument('--from', '-f', dest='sender', default='agent')
asyncio.run(send(parser.parse_args()))
chmod +x bin/discord-notify
pip install discord.py

OpenClaw Bot Script

Create discord_bot/gastown-openclaw-bot.py -- a comprehensive bot that:

  • Monitors Gas Town status every 5 minutes
  • Tracks convoy changes every minute
  • Watches bead changes every 5 minutes
  • Sends metrics summaries every 30 minutes
  • Relays Discord <-> Mayor messages via gt mail
  • Responds to !gt status, !gt beads, !gt agents, !gt help

OpenClaw Bot Control

# Start
scripts/openclaw-bot-ctl.sh start

# Stop
scripts/openclaw-bot-ctl.sh stop

# Status
scripts/openclaw-bot-ctl.sh status

# Logs
scripts/openclaw-bot-ctl.sh logs -f

# Quick message to Discord
bin/discord-notify "System is healthy" --channel <CHANNEL_ID> --title "Status" --color green

OpenClaw Gateway (for AI responses in Discord)

# Start the OpenClaw gateway
openclaw gateway --port 18789 &

# Configure channels
openclaw config set channels.discord.guilds.<GUILD_ID>.channels.<CHANNEL_ID>.allow true

# Set model
openclaw models set anthropic/claude-opus-4-5

# Test
openclaw message send --to discord:<CHANNEL_ID> --message "Hello from OpenClaw!"

4. What the Monitor Actually Does

Each 20-minute cycle:

  1. Checks upstream -- Curls steveyegge/gastown GitHub API for latest release + 10 recent commits. Compares against gt --version. Records to JSONL.

  2. Enforces handoffs -- Reads each agent's tmux session_created timestamp. If any exceeds 1 hour since last handoff, runs gt mayor restart, gt crew restart <name> --rig <rig>, etc. Tracks in JSON.

  3. Gathers state -- Collects: tmux sessions, beads open/closed counts, git log (24h), git sync status, memory/disk/load, daemon PID health, recent log errors, active polecats.

  4. Sends to Claude -- Pipes entire state blob to claude --print --model opus --dangerously-skip-permissions --max-budget-usd 5. Claude analyzes and takes action: restarts dead agents, syncs git, nudges idle crews, files beads, cleans stale polecats.

  5. Reports to Discord -- Posts Claude's report to #gt-status. If critical keywords detected (DEAD, FAILED, etc.), also posts to #gt-alerts.

Example Output

## Health Report — 2026-02-08 03:12 UTC

**Upstream**: v0.5.0 is current. 10 upstream commits today (dolt migration, process cleanup).

**Agents**: All healthy. Mayor (1m), Deacon (8m), Witness (1m), Refinery (1m).
6 crews freshly handed off.

**Actions Taken**:
- Synced beads (181 issues changed)
- Committed and pushed daemon state
- Nudged chat2shop crew to resume P2 work

**System**: 87% RAM available, 36% disk, load 9/16 CPUs.

5. Customization

Change Interval

INTERVAL_SECONDS=900   # 15 minutes
INTERVAL_SECONDS=1800  # 30 minutes

Change Model

MODEL="sonnet"    # Faster, cheaper
MODEL="opus"      # More thorough (default)

Change Handoff Interval

HANDOFF_MAX_AGE=1800   # 30 minutes
HANDOFF_MAX_AGE=7200   # 2 hours

Add More Agents

Add entries to the agents array in enforce_handoffs():

"gt-myrig-crew-newcrew|gt crew restart newcrew --rig myrig|Crew/newcrew"

6. Troubleshooting

# Check if monitor is running
bash scripts/claude-monitor-ctl.sh status

# Watch live
tmux attach -t claude-monitor

# Tail logs
tail -f daemon/claude-monitor.log

# Check handoff history
cat daemon/handoff-history.jsonl | python3 -m json.tool

# Check upstream update history
cat daemon/gastown-upstream-updates.jsonl

# Restart everything
bash scripts/claude-monitor-ctl.sh restart

Requirements: Gas Town, Claude CLI (claude), tmux, python3, curl, git. Optional: discord.py for Discord reporting.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment