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:
7
.gitignore
vendored
7
.gitignore
vendored
@@ -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/
|
||||
|
||||
419
bin/claude-loop.sh
Executable file
419
bin/claude-loop.sh
Executable 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
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
210
bin/timmy-loopstat.sh
Normal 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
|
||||
Reference in New Issue
Block a user