Files
hermes-config/bin/kimi-loop.sh
Alexander Whitestone 94a24c801e feat: Gitea-based delegation — Kimi files own PRs, Hermes reviews
- Remove all tmux dispatch (send-keys, capture-pane, polling)
- Hermes assigns issues to kimi via Gitea API
- kimi-loop.sh polls for assignments, branches, tests, pushes, opens PRs
- Hermes reviews Kimi PRs at start of each cycle
- Zero blocking — both agents productive simultaneously
- All work auditable in Gitea PR history
2026-03-15 13:52:31 -04:00

280 lines
9.9 KiB
Bash
Executable File

#!/usr/bin/env bash
# ── Kimi Agent Loop ────────────────────────────────────────────────────
# Polls Gitea for issues assigned to kimi. For each one:
# 1. Creates worktree + branch
# 2. Runs kimi CLI with the issue as context
# 3. Tests (tox -e unit)
# 4. Pushes branch, opens PR
# 5. Unassigns self if done
#
# Communication is through Gitea. No tmux coordination with Hermes.
# ───────────────────────────────────────────────────────────────────────
set -uo pipefail
export PATH="/opt/homebrew/bin:$HOME/.local/bin:$HOME/.hermes/bin:/usr/local/bin:$PATH"
REPO="rockachopa/Timmy-time-dashboard"
CANONICAL="$HOME/Timmy-Time-dashboard"
WORKTREE_BASE="$HOME/worktrees"
KIMI_TOKEN=$(cat "$HOME/.hermes/kimi_token")
HERMES_TOKEN=$(cat "$HOME/.hermes/gitea_token")
API="http://localhost:3000/api/v1"
MAX_CYCLE_TIME=900 # 15 min per issue
POLL_INTERVAL=120 # Check every 2 min
log() {
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*"
}
# ── Git identity for kimi ──────────────────────────────────────────────
setup_git_identity() {
local workdir="$1"
cd "$workdir"
git config user.name "kimi"
git config user.email "kimi@localhost"
# Set push URL with kimi's token
git remote set-url origin "http://kimi:${KIMI_TOKEN}@localhost:3000/${REPO}.git"
}
# ── Fetch assigned issues ──────────────────────────────────────────────
get_assigned_issues() {
curl -s "${API}/repos/${REPO}/issues?assignee=kimi&state=open&type=issues&sort=priority&direction=asc&limit=5&token=${KIMI_TOKEN}"
}
# ── Create worktree for an issue ───────────────────────────────────────
create_worktree() {
local issue_number="$1"
local branch="kimi/issue-${issue_number}"
local workdir="${WORKTREE_BASE}/kimi-${issue_number}"
mkdir -p "$WORKTREE_BASE"
# Clean up stale worktree if exists
if [ -d "$workdir" ]; then
cd "$CANONICAL"
git worktree remove "$workdir" --force 2>/dev/null || rm -rf "$workdir"
fi
# Fetch latest main
cd "$CANONICAL"
git fetch origin main 2>/dev/null
# Create branch and worktree
git branch -D "$branch" 2>/dev/null || true
git worktree add -b "$branch" "$workdir" origin/main 2>/dev/null
if [ ! -d "$workdir" ]; then
log "ERROR: Failed to create worktree at $workdir"
return 1
fi
setup_git_identity "$workdir"
echo "$workdir"
}
# ── Run kimi on an issue ───────────────────────────────────────────────
work_issue() {
local issue_number="$1"
local issue_title="$2"
local issue_body="$3"
local workdir="$4"
local branch="kimi/issue-${issue_number}"
log "Working on #${issue_number}: ${issue_title}"
# Comment that kimi is starting work
curl -s -X POST "${API}/repos/${REPO}/issues/${issue_number}/comments?token=${KIMI_TOKEN}" \
-H "Content-Type: application/json" \
-d "{\"body\": \"Kimi picking this up. Working in branch \`${branch}\`.\"}" >/dev/null
# Build the prompt for kimi
local prompt="You are Kimi, an AI coding agent. Fix issue #${issue_number} in the Timmy-time-dashboard repo.
ISSUE: ${issue_title}
DESCRIPTION:
${issue_body}
WORKSPACE: ${workdir}
BRANCH: ${branch}
RULES:
- Work ONLY in the workspace directory
- Make focused, minimal changes — do not refactor unrelated code
- Run 'tox -e unit' before committing — tests MUST pass
- Run 'tox -e lint' before committing — no new lint errors
- Commit with a descriptive message referencing #${issue_number}
- Do NOT push or create PRs — the loop script handles that
- If the issue is unclear or too large, just add a comment explaining why and exit"
# Write prompt and run kimi
local prompt_file="${workdir}/.kimi-prompt.txt"
echo "$prompt" > "$prompt_file"
cd "$workdir"
# Run kimi with timeout
timeout "${MAX_CYCLE_TIME}" kimi --print-file "$prompt_file" 2>&1 | tee "${workdir}/.kimi-output.log"
local exit_code=$?
if [ $exit_code -ne 0 ]; then
log "WARN: kimi exited with code $exit_code on #${issue_number}"
fi
return $exit_code
}
# ── Test, push, and PR ─────────────────────────────────────────────────
submit_work() {
local issue_number="$1"
local issue_title="$2"
local workdir="$3"
local branch="kimi/issue-${issue_number}"
cd "$workdir"
# Check if there are any commits beyond origin/main
local new_commits
new_commits=$(git log origin/main..HEAD --oneline 2>/dev/null | wc -l | tr -d ' ')
if [ "$new_commits" -eq 0 ]; then
log "No commits on #${issue_number} — kimi produced no changes"
curl -s -X POST "${API}/repos/${REPO}/issues/${issue_number}/comments?token=${KIMI_TOKEN}" \
-H "Content-Type: application/json" \
-d '{"body": "No changes produced. May need different approach or more context."}' >/dev/null
return 1
fi
# Run tests
log "Running tests for #${issue_number}..."
if ! tox -e unit -q 2>&1 | tail -5; then
log "Tests FAILED on #${issue_number} — not submitting PR"
curl -s -X POST "${API}/repos/${REPO}/issues/${issue_number}/comments?token=${KIMI_TOKEN}" \
-H "Content-Type: application/json" \
-d '{"body": "Tests failed on my branch. Leaving for manual review or retry."}' >/dev/null
return 1
fi
# Run lint
log "Running lint for #${issue_number}..."
if ! tox -e lint -q 2>&1 | tail -5; then
log "Lint FAILED on #${issue_number} — not submitting PR"
curl -s -X POST "${API}/repos/${REPO}/issues/${issue_number}/comments?token=${KIMI_TOKEN}" \
-H "Content-Type: application/json" \
-d '{"body": "Lint check failed on my branch. Leaving for manual review or retry."}' >/dev/null
return 1
fi
# Push
log "Pushing branch ${branch}..."
if ! git push --no-verify origin "$branch" 2>&1; then
log "Push FAILED on #${issue_number}"
return 1
fi
# Create PR
log "Creating PR for #${issue_number}..."
local pr_body="Fixes #${issue_number}
$(git log origin/main..HEAD --format='- %s' 2>/dev/null)
---
*Authored by kimi-agent. Tests and lint pass.*"
local pr_response
pr_response=$(curl -s -X POST "${API}/repos/${REPO}/pulls?token=${KIMI_TOKEN}" \
-H "Content-Type: application/json" \
-d "{
\"title\": \"[kimi] ${issue_title} (#${issue_number})\",
\"body\": $(echo "$pr_body" | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read()))'),
\"head\": \"${branch}\",
\"base\": \"main\",
\"assignees\": [\"hermes\"]
}")
local pr_number
pr_number=$(echo "$pr_response" | python3 -c "import json,sys; print(json.loads(sys.stdin.read()).get('number','FAIL'))" 2>/dev/null)
if [ "$pr_number" = "FAIL" ]; then
log "PR creation FAILED on #${issue_number}: $pr_response"
return 1
fi
log "PR #${pr_number} created for issue #${issue_number}"
# Comment on the issue
curl -s -X POST "${API}/repos/${REPO}/issues/${issue_number}/comments?token=${KIMI_TOKEN}" \
-H "Content-Type: application/json" \
-d "{\"body\": \"PR #${pr_number} submitted. Tests and lint pass. Assigned to hermes for review.\"}" >/dev/null
return 0
}
# ── Cleanup worktree ───────────────────────────────────────────────────
cleanup_worktree() {
local issue_number="$1"
local workdir="${WORKTREE_BASE}/kimi-${issue_number}"
cd "$CANONICAL"
git worktree remove "$workdir" --force 2>/dev/null || rm -rf "$workdir"
}
# ── Main loop ──────────────────────────────────────────────────────────
log "=== Kimi Agent Loop started ==="
log "Polling ${API}/repos/${REPO}/issues?assignee=kimi every ${POLL_INTERVAL}s"
while true; do
# Fetch assigned issues
issues_json=$(get_assigned_issues)
# Parse issues
issue_count=$(echo "$issues_json" | python3 -c "
import json, sys
try:
issues = json.loads(sys.stdin.read())
print(len(issues) if isinstance(issues, list) else 0)
except:
print(0)
" 2>/dev/null)
if [ "$issue_count" -gt 0 ]; then
# Take the first (highest priority) issue
read -r issue_number issue_title <<< "$(echo "$issues_json" | python3 -c "
import json, sys
issues = json.loads(sys.stdin.read())
i = issues[0]
# Escape title for shell
title = i['title'].replace('\"', '').replace('\`', '')[:80]
print(f\"{i['number']} {title}\")
" 2>/dev/null)"
issue_body=$(echo "$issues_json" | python3 -c "
import json, sys
issues = json.loads(sys.stdin.read())
body = issues[0].get('body', '')[:2000]
print(body)
" 2>/dev/null)
log "Found issue #${issue_number}: ${issue_title}"
# Create worktree
workdir=$(create_worktree "$issue_number")
if [ -n "$workdir" ] && [ -d "$workdir" ]; then
# Do the work
work_issue "$issue_number" "$issue_title" "$issue_body" "$workdir"
# Submit if there's work
submit_work "$issue_number" "$issue_title" "$workdir"
# Clean up
cleanup_worktree "$issue_number"
fi
else
log "No assigned issues. Sleeping."
fi
sleep "$POLL_INTERVAL"
done