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.
| 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 |
# Claude CLI installed and authenticated
claude --version
# Gas Town installed
gt --version
# tmux available
tmux -VCreate 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"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
}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
"
}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"
}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"
}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"
doneCreate 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}" ;;
esacchmod +x scripts/claude-monitor.sh scripts/claude-monitor-ctl.sh
bash scripts/claude-monitor-ctl.sh start| 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 |
OpenClaw provides Discord integration for real-time Gas Town monitoring.
npm install -g openclaw
# or
brew install openclaw- Create a Discord bot at https://discord.com/developers/applications
- Enable Message Content Intent under Bot settings
- Invite bot to your server with message read/write permissions
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 |
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.pyCreate 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
# 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# 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!"Each 20-minute cycle:
-
Checks upstream -- Curls
steveyegge/gastownGitHub API for latest release + 10 recent commits. Compares againstgt --version. Records to JSONL. -
Enforces handoffs -- Reads each agent's tmux
session_createdtimestamp. If any exceeds 1 hour since last handoff, runsgt mayor restart,gt crew restart <name> --rig <rig>, etc. Tracks in JSON. -
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.
-
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. -
Reports to Discord -- Posts Claude's report to
#gt-status. If critical keywords detected (DEAD, FAILED, etc.), also posts to#gt-alerts.
## 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.
INTERVAL_SECONDS=900 # 15 minutes
INTERVAL_SECONDS=1800 # 30 minutesMODEL="sonnet" # Faster, cheaper
MODEL="opus" # More thorough (default)HANDOFF_MAX_AGE=1800 # 30 minutes
HANDOFF_MAX_AGE=7200 # 2 hoursAdd entries to the agents array in enforce_handoffs():
"gt-myrig-crew-newcrew|gt crew restart newcrew --rig myrig|Crew/newcrew"# 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 restartRequirements: Gas Town, Claude CLI (claude), tmux, python3, curl, git. Optional: discord.py for Discord reporting.