diff --git a/.gitignore b/.gitignore index 31168b9..bc7470b 100644 --- a/.gitignore +++ b/.gitignore @@ -65,6 +65,13 @@ bin/* !bin/tower-hermes.sh !bin/tower-timmy.sh !bin/tower-watchdog.sh +!bin/claude-loop.sh +!bin/ops-dashboard-v2.sh +!bin/ops-panel.sh +!bin/ops-helpers.sh +!bin/kimi-loop.sh +!bin/timmy-loopstat.sh +!bin/start-dashboard.sh # ── Queue (transient task queue) ───────────────────────────────────── queue/ diff --git a/bin/claude-loop.sh b/bin/claude-loop.sh new file mode 100755 index 0000000..1759c3d --- /dev/null +++ b/bin/claude-loop.sh @@ -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 < (#${issue_num})", "body": "Fixes #${issue_num}\n\n", "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. "}' + +== 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" \ + > "$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 diff --git a/bin/ops-dashboard-v2.sh b/bin/ops-dashboard-v2.sh index f18400d..d1ee52a 100755 --- a/bin/ops-dashboard-v2.sh +++ b/bin/ops-dashboard-v2.sh @@ -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" diff --git a/bin/ops-helpers.sh b/bin/ops-helpers.sh index 70c1063..811a844 100755 --- a/bin/ops-helpers.sh +++ b/bin/ops-helpers.sh @@ -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 diff --git a/bin/ops-panel.sh b/bin/ops-panel.sh index 886bcfe..1ef0741 100755 --- a/bin/ops-panel.sh +++ b/bin/ops-panel.sh @@ -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 ' ') diff --git a/bin/timmy-loopstat.sh b/bin/timmy-loopstat.sh new file mode 100644 index 0000000..275f81e --- /dev/null +++ b/bin/timmy-loopstat.sh @@ -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