feat: add Claude Code parallel worker loop + upgrade ops dashboard

- claude-loop.sh: parallel dispatch loop (N workers, default 3) with
  file-based locking, rate-limit detection + backoff, priority-sorted
  issue picking across all Gitea repos, auto-merge on success
- ops-panel.sh: add Claude worker status, stats, and queue sections
- ops-helpers.sh: add ops-wake-claude, ops-kill-claude,
  ops-assign-claude, ops-claude-queue commands
- ops-dashboard-v2.sh: 4-pane layout with dual Kimi + Claude feeds
- .gitignore: whitelist ops scripts for source control
- timmy-loopstat.sh: add to source control

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Alexander Whitestone
2026-03-22 18:06:38 -04:00
parent 539969c45d
commit 1eb10f72bb
6 changed files with 802 additions and 14 deletions

419
bin/claude-loop.sh Executable file
View File

@@ -0,0 +1,419 @@
#!/usr/bin/env bash
# claude-loop.sh — Parallel Claude Code agent dispatch loop
# Runs N workers concurrently against the Gitea backlog.
# Gracefully handles rate limits with backoff.
#
# Usage: claude-loop.sh [NUM_WORKERS] (default: 3)
set -euo pipefail
# === CONFIG ===
NUM_WORKERS="${1:-3}"
WORKTREE_BASE="$HOME/worktrees"
GITEA_URL="http://143.198.27.163:3000"
GITEA_TOKEN=$(cat "$HOME/.hermes/claude_token")
CLAUDE_TIMEOUT=900 # 15 min per issue
COOLDOWN=15 # seconds between launching workers
RATE_LIMIT_SLEEP=60 # initial sleep on rate limit
MAX_RATE_SLEEP=300 # max backoff on rate limit
LOG_DIR="$HOME/.hermes/logs"
SKIP_FILE="$LOG_DIR/claude-skip-list.json"
LOCK_DIR="$LOG_DIR/claude-locks"
ACTIVE_FILE="$LOG_DIR/claude-active.json"
mkdir -p "$LOG_DIR" "$WORKTREE_BASE" "$LOCK_DIR"
# Initialize files
[ -f "$SKIP_FILE" ] || echo '{}' > "$SKIP_FILE"
echo '{}' > "$ACTIVE_FILE"
# === SHARED FUNCTIONS ===
log() {
local msg="[$(date '+%Y-%m-%d %H:%M:%S')] $*"
echo "$msg" >> "$LOG_DIR/claude-loop.log"
}
lock_issue() {
local issue_key="$1"
local lockfile="$LOCK_DIR/$issue_key.lock"
if mkdir "$lockfile" 2>/dev/null; then
echo $$ > "$lockfile/pid"
return 0
fi
return 1
}
unlock_issue() {
local issue_key="$1"
rm -rf "$LOCK_DIR/$issue_key.lock" 2>/dev/null
}
mark_skip() {
local issue_num="$1"
local reason="$2"
local skip_hours="${3:-1}"
python3 -c "
import json, time, fcntl
with open('$SKIP_FILE', 'r+') as f:
fcntl.flock(f, fcntl.LOCK_EX)
try: skips = json.load(f)
except: skips = {}
skips[str($issue_num)] = {
'until': time.time() + ($skip_hours * 3600),
'reason': '$reason',
'failures': skips.get(str($issue_num), {}).get('failures', 0) + 1
}
if skips[str($issue_num)]['failures'] >= 3:
skips[str($issue_num)]['until'] = time.time() + (6 * 3600)
f.seek(0)
f.truncate()
json.dump(skips, f, indent=2)
" 2>/dev/null
log "SKIP: #${issue_num}${reason}"
}
update_active() {
local worker="$1" issue="$2" repo="$3" status="$4"
python3 -c "
import json, fcntl
with open('$ACTIVE_FILE', 'r+') as f:
fcntl.flock(f, fcntl.LOCK_EX)
try: active = json.load(f)
except: active = {}
if '$status' == 'done':
active.pop('$worker', None)
else:
active['$worker'] = {'issue': '$issue', 'repo': '$repo', 'status': '$status'}
f.seek(0)
f.truncate()
json.dump(active, f, indent=2)
" 2>/dev/null
}
cleanup_worktree() {
local wt="$1"
local branch="$2"
if [ -d "$wt" ]; then
local parent
parent=$(git -C "$wt" rev-parse --git-common-dir 2>/dev/null | sed 's|/.git$||' || true)
if [ -n "$parent" ] && [ -d "$parent" ]; then
cd "$parent"
fi
git worktree remove --force "$wt" 2>/dev/null || rm -rf "$wt"
git worktree prune 2>/dev/null
git branch -D "$branch" 2>/dev/null || true
fi
}
get_next_issue() {
python3 -c "
import json, sys, time, urllib.request, os
token = '${GITEA_TOKEN}'
base = '${GITEA_URL}'
repos = [
'rockachopa/Timmy-time-dashboard',
'rockachopa/alexanderwhitestone.com',
'rockachopa/hermes-agent',
'replit/timmy-tower',
'replit/token-gated-economy',
]
# Load skip list
try:
with open('${SKIP_FILE}') as f: skips = json.load(f)
except: skips = {}
# Load active issues (to avoid double-picking)
try:
with open('${ACTIVE_FILE}') as f:
active = json.load(f)
active_issues = {v['issue'] for v in active.values()}
except:
active_issues = set()
all_issues = []
for repo in repos:
url = f'{base}/api/v1/repos/{repo}/issues?state=open&type=issues&limit=50&sort=created'
req = urllib.request.Request(url, headers={'Authorization': f'token {token}'})
try:
resp = urllib.request.urlopen(req, timeout=10)
issues = json.loads(resp.read())
for i in issues:
i['_repo'] = repo
all_issues.extend(issues)
except:
continue
# Sort by priority: URGENT > P0 > P1 > bugs > LHF > rest
def priority(i):
t = i['title'].lower()
if '[urgent]' in t or 'urgent:' in t: return 0
if '[p0]' in t: return 1
if '[p1]' in t: return 2
if '[bug]' in t: return 3
if 'lhf:' in t or 'lhf ' in t.lower(): return 4
if '[p2]' in t: return 5
return 6
all_issues.sort(key=priority)
for i in all_issues:
# Must be assigned to claude
assignees = [a['login'] for a in (i.get('assignees') or [])]
if 'claude' not in assignees:
continue
title = i['title'].lower()
if '[philosophy]' in title: continue
if '[epic]' in title or 'epic:' in title: continue
if '[showcase]' in title: continue
num_str = str(i['number'])
# Skip if already being worked on by another worker
if num_str in active_issues:
continue
# Check skip list
entry = skips.get(num_str, {})
if entry and entry.get('until', 0) > time.time():
continue
# Check lock dir
lock = '${LOCK_DIR}/' + i['_repo'].replace('/', '-') + '-' + num_str + '.lock'
if os.path.isdir(lock):
continue
repo = i['_repo']
owner, name = repo.split('/')
print(json.dumps({
'number': i['number'],
'title': i['title'],
'repo_owner': owner,
'repo_name': name,
'repo': repo,
}))
sys.exit(0)
print('null')
" 2>/dev/null
}
build_prompt() {
local issue_num="$1"
local issue_title="$2"
local worktree="$3"
local repo_owner="$4"
local repo_name="$5"
cat <<PROMPT
You are Claude, an autonomous code agent on the ${repo_name} project.
YOUR ISSUE: #${issue_num} — "${issue_title}"
GITEA API: ${GITEA_URL}/api/v1
GITEA TOKEN: ${GITEA_TOKEN}
REPO: ${repo_owner}/${repo_name}
WORKING DIRECTORY: ${worktree}
== YOUR POWERS ==
You can do ANYTHING a developer can do.
1. READ the issue and any comments for context:
curl -s -H "Authorization: token ${GITEA_TOKEN}" "${GITEA_URL}/api/v1/repos/${repo_owner}/${repo_name}/issues/${issue_num}"
curl -s -H "Authorization: token ${GITEA_TOKEN}" "${GITEA_URL}/api/v1/repos/${repo_owner}/${repo_name}/issues/${issue_num}/comments"
2. DO THE WORK. Code, test, fix, refactor — whatever the issue needs.
- Check for tox.ini / Makefile / package.json for test/lint commands
- Run tests if the project has them
- Follow existing code conventions
3. COMMIT with conventional commits: fix: / feat: / refactor: / test: / chore:
Include "Fixes #${issue_num}" or "Refs #${issue_num}" in the message.
4. PUSH to your branch (claude/issue-${issue_num}) and CREATE A PR:
git push origin claude/issue-${issue_num}
curl -s -X POST "${GITEA_URL}/api/v1/repos/${repo_owner}/${repo_name}/pulls" \\
-H "Authorization: token ${GITEA_TOKEN}" \\
-H "Content-Type: application/json" \\
-d '{"title": "[claude] <description> (#${issue_num})", "body": "Fixes #${issue_num}\n\n<describe what you did>", "head": "claude/issue-${issue_num}", "base": "main"}'
5. COMMENT on the issue when done:
curl -s -X POST "${GITEA_URL}/api/v1/repos/${repo_owner}/${repo_name}/issues/${issue_num}/comments" \\
-H "Authorization: token ${GITEA_TOKEN}" \\
-H "Content-Type: application/json" \\
-d '{"body": "PR created. <summary of changes>"}'
== RULES ==
- Read CLAUDE.md or project README first for conventions
- If the project has tox, use tox. If npm, use npm. Follow the project.
- Never use --no-verify on git commands.
- If tests fail after 2 attempts, STOP and comment on the issue explaining why.
- Be thorough but focused. Fix the issue, don't refactor the world.
PROMPT
}
# === WORKER FUNCTION ===
run_worker() {
local worker_id="$1"
local consecutive_failures=0
log "WORKER-${worker_id}: Started"
while true; do
# Backoff on repeated failures
if [ "$consecutive_failures" -ge 5 ]; then
local backoff=$((RATE_LIMIT_SLEEP * (consecutive_failures / 5)))
[ "$backoff" -gt "$MAX_RATE_SLEEP" ] && backoff=$MAX_RATE_SLEEP
log "WORKER-${worker_id}: BACKOFF ${backoff}s (${consecutive_failures} failures)"
sleep "$backoff"
consecutive_failures=0
fi
# Get next issue
issue_json=$(get_next_issue)
if [ "$issue_json" = "null" ] || [ -z "$issue_json" ]; then
update_active "$worker_id" "" "" "idle"
sleep 60
continue
fi
issue_num=$(echo "$issue_json" | python3 -c "import sys,json; print(json.load(sys.stdin)['number'])")
issue_title=$(echo "$issue_json" | python3 -c "import sys,json; print(json.load(sys.stdin)['title'])")
repo_owner=$(echo "$issue_json" | python3 -c "import sys,json; print(json.load(sys.stdin)['repo_owner'])")
repo_name=$(echo "$issue_json" | python3 -c "import sys,json; print(json.load(sys.stdin)['repo_name'])")
issue_key="${repo_owner}-${repo_name}-${issue_num}"
branch="claude/issue-${issue_num}"
worktree="${WORKTREE_BASE}/claude-w${worker_id}-${issue_num}"
# Try to lock
if ! lock_issue "$issue_key"; then
sleep 5
continue
fi
log "WORKER-${worker_id}: === ISSUE #${issue_num}: ${issue_title} (${repo_owner}/${repo_name}) ==="
update_active "$worker_id" "$issue_num" "${repo_owner}/${repo_name}" "working"
# Ensure local clone
local_repo="${WORKTREE_BASE}/claude-base-${repo_owner}-${repo_name}"
if [ ! -d "$local_repo" ]; then
log "WORKER-${worker_id}: Cloning ${repo_owner}/${repo_name}..."
git clone --depth=1 "http://claude:${GITEA_TOKEN}@143.198.27.163:3000/${repo_owner}/${repo_name}.git" "$local_repo" 2>&1 || {
log "WORKER-${worker_id}: ERROR cloning"
unlock_issue "$issue_key"
consecutive_failures=$((consecutive_failures + 1))
sleep "$COOLDOWN"
continue
}
cd "$local_repo"
git fetch --unshallow origin main 2>/dev/null || true
fi
# Fetch latest
cd "$local_repo"
timeout 60 git fetch origin main 2>/dev/null || true
git checkout main 2>/dev/null || true
git reset --hard origin/main 2>/dev/null || true
# Create worktree
[ -d "$worktree" ] && cleanup_worktree "$worktree" "$branch"
cd "$local_repo"
if ! git worktree add "$worktree" -b "$branch" origin/main 2>&1; then
log "WORKER-${worker_id}: ERROR creating worktree"
unlock_issue "$issue_key"
consecutive_failures=$((consecutive_failures + 1))
sleep "$COOLDOWN"
continue
fi
cd "$worktree"
git remote set-url origin "http://claude:${GITEA_TOKEN}@143.198.27.163:3000/${repo_owner}/${repo_name}.git"
# Build prompt and run
prompt=$(build_prompt "$issue_num" "$issue_title" "$worktree" "$repo_owner" "$repo_name")
log "WORKER-${worker_id}: Launching Claude Code for #${issue_num}..."
set +e
cd "$worktree"
gtimeout "$CLAUDE_TIMEOUT" claude \
--print \
--dangerously-skip-permissions \
-p "$prompt" \
</dev/null >> "$LOG_DIR/claude-${issue_num}.log" 2>&1
exit_code=$?
set -e
if [ "$exit_code" -eq 0 ]; then
log "WORKER-${worker_id}: SUCCESS #${issue_num}"
# Auto-merge
pr_num=$(curl -sf "${GITEA_URL}/api/v1/repos/${repo_owner}/${repo_name}/pulls?state=open&head=${repo_owner}:${branch}&limit=1" \
-H "Authorization: token ${GITEA_TOKEN}" | python3 -c "
import sys,json
prs = json.load(sys.stdin)
if prs: print(prs[0]['number'])
else: print('')
" 2>/dev/null)
if [ -n "$pr_num" ]; then
curl -sf -X POST "${GITEA_URL}/api/v1/repos/${repo_owner}/${repo_name}/pulls/${pr_num}/merge" \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/json" \
-d '{"Do": "squash"}' >/dev/null 2>&1 || true
curl -sf -X PATCH "${GITEA_URL}/api/v1/repos/${repo_owner}/${repo_name}/issues/${issue_num}" \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/json" \
-d '{"state": "closed"}' >/dev/null 2>&1 || true
log "WORKER-${worker_id}: PR #${pr_num} merged, issue #${issue_num} closed"
fi
consecutive_failures=0
elif [ "$exit_code" -eq 124 ]; then
log "WORKER-${worker_id}: TIMEOUT #${issue_num}"
mark_skip "$issue_num" "timeout" 1
consecutive_failures=$((consecutive_failures + 1))
else
# Check for rate limit (exit code from claude CLI)
if grep -q "rate_limit\|rate limit\|429\|overloaded" "$LOG_DIR/claude-${issue_num}.log" 2>/dev/null; then
log "WORKER-${worker_id}: RATE LIMITED on #${issue_num} — backing off"
mark_skip "$issue_num" "rate_limit" 0.25 # 15 min skip
consecutive_failures=$((consecutive_failures + 3)) # faster backoff
else
log "WORKER-${worker_id}: FAILED #${issue_num} (exit ${exit_code})"
mark_skip "$issue_num" "exit_code_${exit_code}" 1
consecutive_failures=$((consecutive_failures + 1))
fi
fi
# Cleanup
cleanup_worktree "$worktree" "$branch"
unlock_issue "$issue_key"
update_active "$worker_id" "" "" "done"
sleep "$COOLDOWN"
done
}
# === MAIN ===
log "=== Claude Loop Started — ${NUM_WORKERS} workers ==="
log "Worktrees: ${WORKTREE_BASE}"
# Clean stale locks
rm -rf "$LOCK_DIR"/*.lock 2>/dev/null
# Launch workers
for i in $(seq 1 "$NUM_WORKERS"); do
run_worker "$i" &
log "Launched worker $i (PID $!)"
sleep 5 # stagger starts
done
# Wait for all workers
wait

View File

@@ -1,12 +1,12 @@
#!/usr/bin/env bash
# ── Hermes Ops Dashboard v2 ────────────────────────────────────────────
# Clean 3-pane layout:
# 4-pane layout with dual agent feeds:
# ┌───────────────────────────────────┬────────────────────────────────┐
# │ │
# │ STATUS + GITEA + QUEUE │ KIMI LIVE FEED
# │ (auto-refresh 20s) │ (tail log, colored) │
# │ │
# │ │
# │ │ KIMI LIVE FEED
# │ STATUS + GITEA + QUEUE │ (tail log, colored)
# │ (auto-refresh 20s) ├────────────────────────────────┤
# │ │ CLAUDE LIVE FEED
# │ │ (tail log, colored)
# ├───────────────────────────────────┴────────────────────────────────┤
# │ CONTROLS (bash prompt with helpers loaded) │
# └────────────────────────────────────────────────────────────────────┘
@@ -17,22 +17,44 @@ tmux kill-session -t "$SESSION" 2>/dev/null
tmux new-session -d -s "$SESSION"
# Split: left status (60%) | right kimi log (40%)
tmux split-window -h -p 40 -t "$SESSION"
# Split: left status (55%) | right feeds (45%)
tmux split-window -h -p 45 -t "$SESSION"
# Split left pane: top status (80%) | bottom controls (20%)
tmux split-window -v -p 20 -t "$SESSION:1.1"
# Split right pane: top kimi (50%) | bottom claude (50%)
tmux split-window -v -p 50 -t "$SESSION:1.2"
# Initialize log files if they don't exist
touch ~/.hermes/logs/kimi-loop.log 2>/dev/null
touch ~/.hermes/logs/claude-loop.log 2>/dev/null
# Pane 1 (top-left): consolidated status, auto-refresh
tmux send-keys -t "$SESSION:1.1" "watch -n 20 -t -c 'bash ~/.hermes/bin/ops-panel.sh'" Enter
# Pane 2 (right): kimi live feed with color
tmux send-keys -t "$SESSION:1.2" "tail -f ~/.hermes/logs/kimi-loop.log | GREP_COLOR='1;32' grep --color=always -E 'SUCCESS|$' | GREP_COLOR='1;31' grep --color=always -E 'FAILED|BACKOFF|$' | GREP_COLOR='1;36' grep --color=always -E 'ISSUE #[0-9]+|$'" Enter
# Pane 2 (top-right): kimi live feed with color
tmux send-keys -t "$SESSION:1.2" "echo -e '\\033[1m\\033[33m KIMI FEED\\033[0m' && tail -f ~/.hermes/logs/kimi-loop.log | GREP_COLOR='1;32' grep --color=always -E 'SUCCESS|$' | GREP_COLOR='1;31' grep --color=always -E 'FAILED|BACKOFF|$' | GREP_COLOR='1;36' grep --color=always -E 'ISSUE #[0-9]+|$'" Enter
# Pane 3 (bottom-left): controls with helpers sourced
tmux send-keys -t "$SESSION:1.3" "source ~/.hermes/bin/ops-helpers.sh && ops-help" Enter
# Pane 3 (bottom-right): claude live feed with color
tmux send-keys -t "$SESSION:1.3" "echo -e '\\033[1m\\033[35m CLAUDE FEED\\033[0m' && tail -f ~/.hermes/logs/claude-loop.log | GREP_COLOR='1;32' grep --color=always -E 'SUCCESS|$' | GREP_COLOR='1;31' grep --color=always -E 'FAILED|BACKOFF|$' | GREP_COLOR='1;36' grep --color=always -E 'ISSUE #[0-9]+|$'" Enter
# Focus on status pane
tmux select-pane -t "$SESSION:1.1"
# Pane 4 (bottom-left): controls with helpers sourced
tmux send-keys -t "$SESSION:1.4" "source ~/.hermes/bin/ops-helpers.sh && ops-help" Enter
# Set pane titles
tmux select-pane -t "$SESSION:1.1" -T "Status"
tmux select-pane -t "$SESSION:1.2" -T "Kimi Feed"
tmux select-pane -t "$SESSION:1.3" -T "Claude Feed"
tmux select-pane -t "$SESSION:1.4" -T "Controls"
# Border styling
tmux set-option -t "$SESSION" pane-border-status top
tmux set-option -t "$SESSION" pane-border-format " #{pane_title} "
tmux set-option -t "$SESSION" pane-border-style "fg=colour240"
tmux set-option -t "$SESSION" pane-active-border-style "fg=cyan"
# Focus on controls pane
tmux select-pane -t "$SESSION:1.4"
tmux attach -t "$SESSION"

View File

@@ -14,18 +14,22 @@ ops-help() {
echo ""
echo -e " \033[1mWake Up\033[0m"
echo " ops-wake-kimi Restart Kimi loop"
echo " ops-wake-claude Restart Claude loop"
echo " ops-wake-gateway Restart gateway"
echo " ops-wake-all Restart everything"
echo ""
echo -e " \033[1mManage\033[0m"
echo " ops-merge PR_NUM Squash-merge a PR"
echo " ops-assign ISSUE Assign issue to Kimi"
echo " ops-assign-claude ISSUE [REPO] Assign to Claude"
echo " ops-audit Run efficiency audit now"
echo " ops-prs List open PRs"
echo " ops-queue Show Kimi's queue"
echo " ops-claude-queue Show Claude's queue"
echo ""
echo -e " \033[1mEmergency\033[0m"
echo " ops-kill-kimi Stop Kimi loop"
echo " ops-kill-claude Stop Claude loop"
echo " ops-kill-zombies Kill stuck git/pytest"
echo ""
echo -e " \033[2m Type ops-help to see this again\033[0m"
@@ -43,10 +47,20 @@ ops-wake-gateway() {
hermes gateway start 2>&1
}
ops-wake-claude() {
local workers="${1:-3}"
pkill -f "claude-loop.sh" 2>/dev/null
sleep 1
nohup bash ~/.hermes/bin/claude-loop.sh "$workers" >> ~/.hermes/logs/claude-loop.log 2>&1 &
echo " Claude loop started — $workers workers (PID $!)"
}
ops-wake-all() {
ops-wake-gateway
sleep 1
ops-wake-kimi
sleep 1
ops-wake-claude
echo " All services started"
}
@@ -99,6 +113,42 @@ ops-kill-kimi() {
echo " Kimi stopped"
}
ops-kill-claude() {
pkill -f "claude-loop.sh" 2>/dev/null
pkill -f "claude.*--print.*--dangerously" 2>/dev/null
rm -rf ~/.hermes/logs/claude-locks/*.lock 2>/dev/null
echo '{}' > ~/.hermes/logs/claude-active.json 2>/dev/null
echo " Claude stopped (all workers)"
}
ops-assign-claude() {
local issue=$1
local repo="${2:-rockachopa/Timmy-time-dashboard}"
[ -z "$issue" ] && { echo "Usage: ops-assign-claude ISSUE_NUMBER [owner/repo]"; return 1; }
curl -s -X PATCH -H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
"$GITEA/api/v1/repos/$repo/issues/$issue" -d '{"assignees":["claude"]}' | python3 -c "
import json,sys; d=json.loads(sys.stdin.read()); print(f' ✓ #{$issue} assigned to claude')
" 2>/dev/null
}
ops-claude-queue() {
python3 -c "
import json, urllib.request
token = '$(cat ~/.hermes/claude_token 2>/dev/null)'
base = 'http://143.198.27.163:3000'
repos = ['rockachopa/Timmy-time-dashboard','rockachopa/alexanderwhitestone.com','replit/timmy-tower','replit/token-gated-economy','rockachopa/hermes-agent']
for repo in repos:
url = f'{base}/api/v1/repos/{repo}/issues?state=open&assignee=claude&limit=20&type=issues'
try:
req = urllib.request.Request(url, headers={'Authorization': f'token {token}'})
resp = urllib.request.urlopen(req, timeout=5)
issues = json.loads(resp.read())
for i in issues:
print(f' #{i[\"number\"]:4d} {repo.split(\"/\")[1]:20s} {i[\"title\"][:50]}')
except: continue
" 2>/dev/null || echo " (error)"
}
ops-kill-zombies() {
local killed=0
for pid in $(ps aux | grep "pytest tests/" | grep -v grep | awk '{print $2}'); do

View File

@@ -40,6 +40,15 @@ else
echo -e " ${OFF} Kimi Code ${D}not running${R}"
fi
# Claude Code loop (parallel workers)
CLAUDE_PID=$(pgrep -f "claude-loop.sh" 2>/dev/null | head -1)
CLAUDE_WORKERS=$(pgrep -f "claude.*--print.*--dangerously" 2>/dev/null | wc -l | tr -d ' ')
if [ -n "$CLAUDE_PID" ]; then
echo -e " ${OK} Claude Loop ${D}pid $CLAUDE_PID ${G}${CLAUDE_WORKERS} workers active${R}"
else
echo -e " ${FAIL} Claude Loop ${RD}DOWN — run: ops-wake-claude${R}"
fi
# Gitea VPS
if curl -s --max-time 3 "http://143.198.27.163:3000/api/v1/version" >/dev/null 2>&1; then
echo -e " ${OK} Gitea VPS ${D}143.198.27.163:3000${R}"
@@ -84,6 +93,44 @@ if [ -f "$KIMI_LOG" ]; then
fi
echo ""
# ── CLAUDE STATS ──────────────────────────────────────────────────
echo -e " ${B}${U}CLAUDE${R}"
echo ""
CLAUDE_LOG="$HOME/.hermes/logs/claude-loop.log"
if [ -f "$CLAUDE_LOG" ]; then
CL_COMPLETED=$(grep -c "SUCCESS" "$CLAUDE_LOG" 2>/dev/null || echo 0)
CL_FAILED=$(grep -c "FAILED" "$CLAUDE_LOG" 2>/dev/null || echo 0)
CL_RATE_LIM=$(grep -c "RATE LIMITED" "$CLAUDE_LOG" 2>/dev/null || echo 0)
CL_RATE=""
if [ "$CL_COMPLETED" -gt 0 ] || [ "$CL_FAILED" -gt 0 ]; then
CL_TOTAL=$((CL_COMPLETED + CL_FAILED))
[ "$CL_TOTAL" -gt 0 ] && CL_PCT=$((CL_COMPLETED * 100 / CL_TOTAL)) && CL_RATE=" (${CL_PCT}%)"
fi
echo -e " ${G}${B}$CL_COMPLETED${R} done ${RD}$CL_FAILED${R} fail ${Y}$CL_RATE_LIM${R} rate-limited${D}$CL_RATE${R}"
# Show active workers
ACTIVE="$HOME/.hermes/logs/claude-active.json"
if [ -f "$ACTIVE" ]; then
python3 -c "
import json
try:
with open('$ACTIVE') as f: active = json.load(f)
for wid, info in sorted(active.items()):
iss = info.get('issue','')
repo = info.get('repo','').split('/')[-1] if info.get('repo') else ''
st = info.get('status','')
if st == 'working':
print(f' \033[36mW{wid}\033[0m \033[33m#{iss}\033[0m \033[2m{repo}\033[0m')
elif st == 'idle':
print(f' \033[2mW{wid} idle\033[0m')
except: pass
" 2>/dev/null
fi
else
echo -e " ${D}(no log yet — start with ops-wake-claude)${R}"
fi
echo ""
# ── OPEN PRS ───────────────────────────────────────────────────────────
echo -e " ${B}${U}PULL REQUESTS${R}"
echo ""
@@ -137,6 +184,39 @@ except: print(' \033[31m(error)\033[0m')
" 2>/dev/null
echo ""
# ── CLAUDE QUEUE ──────────────────────────────────────────────────
echo -e " ${B}${U}CLAUDE QUEUE${R}"
echo ""
# Claude works across multiple repos
python3 -c "
import json, sys, urllib.request
token = '$(cat ~/.hermes/claude_token 2>/dev/null)'
base = 'http://143.198.27.163:3000'
repos = ['rockachopa/Timmy-time-dashboard','rockachopa/alexanderwhitestone.com','replit/timmy-tower','replit/token-gated-economy','rockachopa/hermes-agent']
all_issues = []
for repo in repos:
url = f'{base}/api/v1/repos/{repo}/issues?state=open&assignee=claude&limit=10&type=issues'
try:
req = urllib.request.Request(url, headers={'Authorization': f'token {token}'})
resp = urllib.request.urlopen(req, timeout=5)
issues = json.loads(resp.read())
for i in issues:
i['_repo'] = repo.split('/')[1]
all_issues.extend(issues)
except: continue
if not all_issues:
print(' \033[33m\u26a0 Queue empty \u2014 assign issues to claude\033[0m')
else:
for i in all_issues[:6]:
n = i['number']
t = i['title'][:45]
r = i['_repo'][:12]
print(f' #{n:<4d} \033[2m{r:12s}\033[0m {t}')
if len(all_issues) > 6:
print(f' \033[2m... +{len(all_issues)-6} more\033[0m')
" 2>/dev/null
echo ""
# ── WARNINGS ───────────────────────────────────────────────────────────
HERMES_PROCS=$(ps aux | grep -E "hermes.*python" | grep -v grep | wc -l | tr -d ' ')
STUCK_GIT=$(ps aux | grep "git.*push\|git-remote-http" | grep -v grep | wc -l | tr -d ' ')

210
bin/timmy-loopstat.sh Normal file
View File

@@ -0,0 +1,210 @@
#!/usr/bin/env bash
# ── LOOPSTAT Panel ──────────────────────
# Strategic view: queue, perf, triage,
# recent cycles. 40-col × 50-row pane.
# ────────────────────────────────────────
REPO="$HOME/Timmy-Time-dashboard"
QUEUE="$REPO/.loop/queue.json"
RETRO="$REPO/.loop/retro/cycles.jsonl"
TRIAGE_R="$REPO/.loop/retro/triage.jsonl"
DEEP_R="$REPO/.loop/retro/deep-triage.jsonl"
SUMMARY="$REPO/.loop/retro/summary.json"
QUARANTINE="$REPO/.loop/quarantine.json"
STATE="$REPO/.loop/state.json"
B='\033[1m' ; D='\033[2m' ; R='\033[0m'
G='\033[32m' ; Y='\033[33m' ; RD='\033[31m'
C='\033[36m' ; M='\033[35m'
W=$(tput cols 2>/dev/null || echo 40)
hr() { printf "${D}"; printf '─%.0s' $(seq 1 "$W"); printf "${R}\n"; }
while true; do
clear
echo -e "${B}${M} ◈ LOOPSTAT${R} ${D}$(date '+%H:%M')${R}"
hr
# ── PERFORMANCE ──────────────────────
python3 -c "
import json, os
f = '$SUMMARY'
if not os.path.exists(f):
print(' \033[2m(no perf data yet)\033[0m')
raise SystemExit
s = json.load(open(f))
rate = s.get('success_rate', 0)
avg = s.get('avg_duration_seconds', 0)
total = s.get('total_cycles', 0)
merged = s.get('total_prs_merged', 0)
added = s.get('total_lines_added', 0)
removed = s.get('total_lines_removed', 0)
rc = '\033[32m' if rate >= .8 else '\033[33m' if rate >= .5 else '\033[31m'
am, asec = divmod(avg, 60)
print(f' {rc}{rate*100:.0f}%\033[0m ok \033[1m{am:.0f}m{asec:02.0f}s\033[0m avg {total} cyc')
print(f' \033[32m{merged}\033[0m PRs \033[32m+{added}\033[0m/\033[31m-{removed}\033[0m lines')
bt = s.get('by_type', {})
parts = []
for t in ['bug','feature','refactor']:
i = bt.get(t, {})
if i.get('count', 0):
sr = i.get('success_rate', 0)
parts.append(f'{t[:3]}:{sr*100:.0f}%')
if parts:
print(f' \033[2m{\" \".join(parts)}\033[0m')
" 2>/dev/null
hr
# ── QUEUE ────────────────────────────
echo -e "${B}${Y} QUEUE${R}"
python3 -c "
import json, os
f = '$QUEUE'
if not os.path.exists(f):
print(' \033[2m(no queue yet)\033[0m')
raise SystemExit
q = json.load(open(f))
if not q:
print(' \033[2m(empty — needs triage)\033[0m')
raise SystemExit
types = {}
for item in q:
t = item.get('type','?')
types[t] = types.get(t, 0) + 1
ts = ' '.join(f'{t[0].upper()}:{n}' for t,n in sorted(types.items()) if t != 'philosophy')
print(f' \033[1m{len(q)}\033[0m ready \033[2m{ts}\033[0m')
print()
for i, item in enumerate(q[:8]):
n = item['issue']
s = item.get('score', 0)
title = item.get('title', '?')
t = item.get('type', '?')
ic = {'bug':'\033[31m●','feature':'\033[32m◆','refactor':'\033[36m○'}.get(t, '\033[2m·')
bar = '█' * s + '░' * (9 - s)
ptr = '\033[1m→' if i == 0 else f'\033[2m{i+1}'
# Truncate title to fit: 40 - 2(pad) - 2(ptr) - 2(ic) - 5(#num) - 1 = 28
tit = title[:24]
print(f' {ptr}\033[0m {ic}\033[0m \033[33m#{n}\033[0m {tit}')
if len(q) > 8:
print(f' \033[2m +{len(q)-8} more\033[0m')
" 2>/dev/null
hr
# ── TRIAGE ───────────────────────────
echo -e "${B}${G} TRIAGE${R}"
python3 -c "
import json, os
from datetime import datetime, timezone
cycle = '?'
if os.path.exists('$STATE'):
try: cycle = json.load(open('$STATE')).get('cycle','?')
except: pass
def ago(ts):
if not ts: return 'never'
try:
dt = datetime.fromisoformat(ts)
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
m = int((datetime.now(timezone.utc) - dt).total_seconds() / 60)
if m < 60: return f'{m}m ago'
if m < 1440: return f'{m//60}h{m%60}m ago'
return f'{m//1440}d ago'
except: return '?'
# Fast
fast_ago = 'never'
if os.path.exists('$TRIAGE_R'):
lines = open('$TRIAGE_R').read().strip().splitlines()
if lines:
try:
last = json.loads(lines[-1])
fast_ago = ago(last.get('timestamp',''))
except: pass
# Deep
deep_ago = 'never'
timmy = ''
if os.path.exists('$DEEP_R'):
lines = open('$DEEP_R').read().strip().splitlines()
if lines:
try:
last = json.loads(lines[-1])
deep_ago = ago(last.get('timestamp',''))
timmy = last.get('timmy_feedback','')[:60]
except: pass
# Next
try:
c = int(cycle)
nf = 5 - (c % 5)
nd = 20 - (c % 20)
except:
nf = nd = '?'
print(f' Fast {fast_ago:<12s} \033[2mnext:{nf}c\033[0m')
print(f' Deep {deep_ago:<12s} \033[2mnext:{nd}c\033[0m')
if timmy:
# wrap at ~36 chars
print(f' \033[35mTimmy:\033[0m')
t = timmy
while t:
print(f' \033[2m{t[:36]}\033[0m')
t = t[36:]
# Quarantine
if os.path.exists('$QUARANTINE'):
try:
qd = json.load(open('$QUARANTINE'))
if qd:
qs = ','.join(f'#{k}' for k in list(qd.keys())[:4])
print(f' \033[31mQuarantined:{len(qd)}\033[0m {qs}')
except: pass
" 2>/dev/null
hr
# ── RECENT CYCLES ────────────────────
echo -e "${B}${D} CYCLES${R}"
python3 -c "
import json, os
f = '$RETRO'
if not os.path.exists(f):
print(' \033[2m(none yet)\033[0m')
raise SystemExit
lines = open(f).read().strip().splitlines()
recent = []
for l in lines[-12:]:
try: recent.append(json.loads(l))
except: continue
if not recent:
print(' \033[2m(none yet)\033[0m')
raise SystemExit
for e in reversed(recent):
cy = e.get('cycle','?')
ok = e.get('success', False)
iss = e.get('issue','')
dur = e.get('duration', 0)
pr = e.get('pr','')
reason = e.get('reason','')[:18]
ic = '\033[32m✓\033[0m' if ok else '\033[31m✗\033[0m'
ds = f'{dur//60}m' if dur else '-'
ix = f'#{iss}' if iss else ' — '
if ok:
det = f'PR#{pr}' if pr else ''
else:
det = reason
print(f' {ic} {cy:<3} {ix:<5s} {ds:>4s} \033[2m{det}\033[0m')
" 2>/dev/null
hr
echo -e "${D} ↻ 10s${R}"
sleep 10
done