2026-03-30 20:15:36 +00:00
#!/bin/bash
2026-03-30 17:59:43 -04:00
# kimi-heartbeat.sh — Polls Gitea for assigned-kimi issues, dispatches to KimiClaw via OpenClaw
# Zero LLM cost for polling — only calls kimi/kimi-code for actual work.
#
# Run manually: bash ~/.timmy/uniwizard/kimi-heartbeat.sh
2026-04-04 20:17:40 +00:00
# Runs via launchd every 2 minutes: ai.timmy.kimi-heartbeat.plist
2026-03-30 17:59:43 -04:00
#
# Workflow for humans:
# 1. Create or open a Gitea issue in any tracked repo
# 2. Add the "assigned-kimi" label
# 3. This script picks it up, dispatches to KimiClaw, posts results back
# 4. Label transitions: assigned-kimi → kimi-in-progress → kimi-done
2026-03-30 18:28:38 -04:00
#
# PLANNING: If the issue body is >500 chars or contains "##" headers,
# KimiClaw first runs a 2-minute planning pass to decompose the task.
# If it needs subtasks, it creates child issues and labels them assigned-kimi
# for the next heartbeat cycle. This prevents 10-minute timeouts on complex work.
2026-03-30 20:15:36 +00:00
set -euo pipefail
2026-03-30 17:59:43 -04:00
# --- Config ---
TOKEN = $( cat " $HOME /.timmy/kimi_gitea_token " | tr -d '[:space:]' )
TIMMY_TOKEN = $( cat " $HOME /.config/gitea/timmy-token " | tr -d '[:space:]' )
2026-04-05 19:33:37 +00:00
BASE = " ${ GITEA_API_BASE :- https : //forge.alexanderwhitestone.com/api/v1 } "
2026-03-30 20:15:36 +00:00
LOG = "/tmp/kimi-heartbeat.log"
2026-03-30 17:59:43 -04:00
LOCKFILE = "/tmp/kimi-heartbeat.lock"
2026-04-04 20:17:40 +00:00
MAX_DISPATCH = 10 # Increased max dispatch to 10
2026-03-30 18:28:38 -04:00
PLAN_TIMEOUT = 120 # 2 minutes for planning pass
EXEC_TIMEOUT = 480 # 8 minutes for execution pass
BODY_COMPLEXITY_THRESHOLD = 500 # chars — above this triggers planning
2026-04-04 20:17:40 +00:00
STALE_PROGRESS_SECONDS = 3600 # reclaim kimi-in-progress after 1 hour of silence
2026-03-30 20:15:36 +00:00
2026-03-30 17:59:43 -04:00
REPOS = (
"Timmy_Foundation/timmy-home"
"Timmy_Foundation/timmy-config"
"Timmy_Foundation/the-nexus"
"Timmy_Foundation/hermes-agent"
)
2026-03-30 20:15:36 +00:00
2026-03-30 17:59:43 -04:00
# --- Helpers ---
log( ) { echo " [ $( date '+%Y-%m-%d %H:%M:%S' ) ] $* " | tee -a " $LOG " ; }
2026-04-05 18:27:22 +00:00
needs_pr_proof( ) {
local haystack = " ${ 1 ,, } "
[ [ " $haystack " = ~ implement| fix| refactor| feature| perf| performance| rebase| deploy| integration| module| script| pipeline| benchmark| cache| test| bug| build| port ] ]
}
has_pr_proof( ) {
local haystack = " ${ 1 ,, } "
[ [ " $haystack " = = *"proof:" * || " $haystack " = = *"pr:" * || " $haystack " = = *"/pulls/" * || " $haystack " = = *"commit:" * ] ]
}
post_issue_comment_json( ) {
local repo = " $1 "
local issue_num = " $2 "
local token = " $3 "
local body = " $4 "
local payload
payload = $( python3 - " $body " <<'PY'
import json, sys
print( json.dumps( { "body" : sys.argv[ 1] } ) )
PY
)
curl -sf -X POST -H " Authorization: token $token " -H "Content-Type: application/json" \
-d " $payload " " $BASE /repos/ $repo /issues/ $issue_num /comments " > /dev/null 2>& 1 || true
}
2026-03-30 17:59:43 -04:00
# Prevent overlapping runs
if [ -f " $LOCKFILE " ] ; then
lock_age = $(( $( date +%s) - $( stat -f %m " $LOCKFILE " 2>/dev/null || echo 0) ))
if [ " $lock_age " -lt 600 ] ; then
log " SKIP: previous run still active (lock age: ${ lock_age } s) "
exit 0
else
log " WARN: stale lock ( ${ lock_age } s), removing "
rm -f " $LOCKFILE "
fi
fi
trap 'rm -f "$LOCKFILE"' EXIT
touch " $LOCKFILE "
dispatched = 0
2026-03-30 20:15:36 +00:00
for repo in " ${ REPOS [@] } " ; do
2026-03-30 17:59:43 -04:00
# Fetch open issues with assigned-kimi label
response = $( curl -sf -H " Authorization: token $TIMMY_TOKEN " \
" $BASE /repos/ $repo /issues?state=open&labels=assigned-kimi&limit=20 " 2>/dev/null || echo "[]" )
2026-04-04 20:17:40 +00:00
# Filter: skip done tasks, but reclaim stale kimi-in-progress work automatically
2026-03-30 17:59:43 -04:00
issues = $( echo " $response " | python3 -c "
2026-04-04 20:17:40 +00:00
import json, sys, datetime
STALE = int( ${ STALE_PROGRESS_SECONDS } )
def parse_ts( value) :
if not value:
return None
try:
return datetime.datetime.fromisoformat( value.replace( 'Z' , '+00:00' ) )
except Exception:
return None
2026-03-30 17:59:43 -04:00
try:
data = json.loads( sys.stdin.buffer.read( ) )
except:
sys.exit( 0)
2026-04-04 20:17:40 +00:00
now = datetime.datetime.now( datetime.timezone.utc)
2026-03-30 17:59:43 -04:00
for i in data:
labels = [ l[ 'name' ] for l in i.get( 'labels' , [ ] ) ]
2026-04-04 20:17:40 +00:00
if 'kimi-done' in labels:
2026-03-30 20:15:36 +00:00
continue
2026-04-04 20:17:40 +00:00
reclaim = False
updated_at = i.get( 'updated_at' , '' ) or ''
if 'kimi-in-progress' in labels:
ts = parse_ts( updated_at)
age = ( now - ts) .total_seconds( ) if ts else ( STALE + 1)
if age < STALE:
continue
reclaim = True
2026-03-30 18:28:38 -04:00
body = ( i.get( 'body' , '' ) or '' )
body_len = len( body)
body_clean = body[ :1500] .replace( '\n' , ' ' ) .replace( '|' , ' ' )
2026-03-30 17:59:43 -04:00
title = i[ 'title' ] .replace( '|' , ' ' )
2026-04-04 20:17:40 +00:00
updated_clean = updated_at.replace( '|' , ' ' )
reclaim_flag = 'reclaim' if reclaim else 'fresh'
print( f\" { i[ 'number' ] } | { title} | { body_len} | { reclaim_flag} | { updated_clean} | { body_clean} \" )
2026-03-30 20:15:36 +00:00
" 2>/dev/null)
2026-03-30 17:59:43 -04:00
[ -z " $issues " ] && continue
2026-03-30 20:15:36 +00:00
2026-04-04 20:17:40 +00:00
while IFS = '|' read -r issue_num title body_len reclaim_flag updated_at body; do
2026-03-30 20:15:36 +00:00
[ -z " $issue_num " ] && continue
2026-04-04 20:17:40 +00:00
log " FOUND: $repo # $issue_num — $title (body: ${ body_len } chars, mode: ${ reclaim_flag } , updated: ${ updated_at } ) "
2026-03-30 20:15:36 +00:00
2026-03-30 17:59:43 -04:00
# --- Get label IDs for this repo ---
label_json = $( curl -sf -H " Authorization: token $TIMMY_TOKEN " \
" $BASE /repos/ $repo /labels " 2>/dev/null || echo "[]" )
2026-03-30 20:15:36 +00:00
2026-03-30 17:59:43 -04:00
progress_id = $( echo " $label_json " | python3 -c "import json,sys; [print(l['id']) for l in json.load(sys.stdin) if l['name']=='kimi-in-progress']" 2>/dev/null)
done_id = $( echo " $label_json " | python3 -c "import json,sys; [print(l['id']) for l in json.load(sys.stdin) if l['name']=='kimi-done']" 2>/dev/null)
2026-03-30 18:28:38 -04:00
kimi_id = $( echo " $label_json " | python3 -c "import json,sys; [print(l['id']) for l in json.load(sys.stdin) if l['name']=='assigned-kimi']" 2>/dev/null)
2026-03-30 17:59:43 -04:00
2026-04-04 20:17:40 +00:00
if [ " $reclaim_flag " = "reclaim" ] ; then
log " RECLAIM: $repo # $issue_num — stale kimi-in-progress since $updated_at "
[ -n " $progress_id " ] && curl -sf -X DELETE -H " Authorization: token $TIMMY_TOKEN " \
" $BASE /repos/ $repo /issues/ $issue_num /labels/ $progress_id " > /dev/null 2>& 1 || true
curl -sf -X POST -H " Authorization: token $TOKEN " -H "Content-Type: application/json" \
-d " {\"body\":\"🟡 **KimiClaw reclaiming stale task.**\\nPrevious kimi-in-progress state exceeded ${ STALE_PROGRESS_SECONDS } s without resolution.\\nLast update: $updated_at \\nTimestamp: $( date -u '+%Y-%m-%dT%H:%M:%SZ' ) \"} " \
" $BASE /repos/ $repo /issues/ $issue_num /comments " > /dev/null 2>& 1 || true
fi
2026-03-30 17:59:43 -04:00
# --- Add kimi-in-progress label ---
if [ -n " $progress_id " ] ; then
curl -sf -X POST -H " Authorization: token $TIMMY_TOKEN " -H "Content-Type: application/json" \
-d " {\"labels\":[ $progress_id ]} " \
" $BASE /repos/ $repo /issues/ $issue_num /labels " > /dev/null 2>& 1 || true
2026-03-30 20:15:36 +00:00
fi
2026-03-30 18:28:38 -04:00
# --- Decide: plan first or execute directly ---
needs_planning = false
if [ " $body_len " -gt " $BODY_COMPLEXITY_THRESHOLD " ] ; then
needs_planning = true
fi
2026-03-30 20:15:36 +00:00
2026-03-30 18:28:38 -04:00
if [ " $needs_planning " = true ] ; then
# =============================================
# PHASE 1: PLANNING PASS (2 min timeout)
# =============================================
log " PLAN: $repo # $issue_num — complex task, running planning pass "
2026-03-30 17:59:43 -04:00
2026-03-30 18:28:38 -04:00
curl -sf -X POST -H " Authorization: token $TOKEN " -H "Content-Type: application/json" \
-d " {\"body\":\"🟠 **KimiClaw picking up this task** via heartbeat.\\nBackend: kimi/kimi-code (Moonshot AI)\\nMode: **Planning first** (task is complex)\\nTimestamp: $( date -u '+%Y-%m-%dT%H:%M:%SZ' ) \"} " \
" $BASE /repos/ $repo /issues/ $issue_num /comments " > /dev/null 2>& 1 || true
2026-03-30 17:59:43 -04:00
2026-04-04 20:17:40 +00:00
plan_prompt = " You are KimiClaw, a planning agent. You have 2 MINUTES.\n\nTASK: Analyze this Gitea issue and decide if you can complete it in under 8 minutes, or if it needs to be broken into subtasks.\n\nISSUE # $issue_num in $repo : $title \n\nBODY:\n $body \n\nRULES:\n- If you CAN complete this in one pass (research, write analysis, answer a question): respond with EXECUTE followed by a one-line plan.\n- If the task is TOO BIG (needs git operations, multiple repos, >2000 words of output, or multi-step implementation): respond with DECOMPOSE followed by a numbered list of 2-5 smaller subtasks. Each subtask must be completable in under 8 minutes by itself.\n- Each subtask line format: SUBTASK: <title> | <one-line description>\n- Be realistic about what fits in 8 minutes with no terminal access.\n- You CANNOT clone repos, run git, or execute code. You CAN research, analyze, write specs, review code via API, and produce documents.\n\nRespond with ONLY your decision. No preamble. "
2026-03-30 20:15:36 +00:00
2026-04-04 20:17:40 +00:00
plan_result = $( openclaw agent --agent main --message " $plan_prompt " --timeout $PLAN_TIMEOUT --json 2>/dev/null || echo '{\"status\":\"error\"}' )
2026-03-30 18:28:38 -04:00
plan_status = $( echo " $plan_result " | python3 -c "import json,sys; print(json.load(sys.stdin).get('status','error'))" 2>/dev/null || echo "error" )
2026-04-04 20:17:40 +00:00
plan_text = $( echo " $plan_result " | python3 -c "\nimport json,sys\nd = json.load(sys.stdin)\npayloads = d.get('result',{}).get('payloads',[])\nprint(payloads[0]['text'] if payloads else '')\n" 2>/dev/null || echo "" )
2026-03-30 17:59:43 -04:00
2026-03-30 18:28:38 -04:00
if echo " $plan_text " | grep -qi "^DECOMPOSE" ; then
# --- Create subtask issues ---
log " DECOMPOSE: $repo # $issue_num — creating subtasks "
# Post the plan as a comment
escaped_plan = $( echo " $plan_text " | python3 -c "import sys,json; print(json.dumps(sys.stdin.read()))" 2>/dev/null)
curl -sf -X POST -H " Authorization: token $TOKEN " -H "Content-Type: application/json" \
2026-04-04 20:17:40 +00:00
-d " {\"body\":\"📝 **Planning complete — decomposing into subtasks:**\\n\\n $plan_text \"} " \
2026-03-30 18:28:38 -04:00
" $BASE /repos/ $repo /issues/ $issue_num /comments " > /dev/null 2>& 1 || true
# Extract SUBTASK lines and create child issues
echo " $plan_text " | grep -i "^SUBTASK:" | head -5 | while IFS = '|' read -r sub_title sub_desc; do
sub_title = $( echo " $sub_title " | sed 's/^SUBTASK: *//' )
sub_desc = $( echo " ${ sub_desc :- $sub_title } " | sed 's/^ *//' )
if [ -n " $sub_title " ] ; then
sub_body = " ## Parent Issue\\nChild of # $issue_num : $title \\n\\n## Task\\n $sub_desc \\n\\n## Constraints\\n- Must complete in under 8 minutes\\n- No git/terminal operations\\n- Post results as analysis/documentation\\n\\n## Assignee\\n@KimiClaw "
curl -sf -X POST -H " Authorization: token $TIMMY_TOKEN " -H "Content-Type: application/json" \
-d " {\"title\":\"[SUB] $sub_title \",\"body\":\" $sub_body \"} " \
" $BASE /repos/ $repo /issues " > /dev/null 2>& 1
# Get the issue number of what we just created and label it
new_num = $( curl -sf -H " Authorization: token $TIMMY_TOKEN " \
" $BASE /repos/ $repo /issues?state=open&limit=1&type=issues " | \
python3 -c "import json,sys; d=json.load(sys.stdin); print(d[0]['number'] if d else '')" 2>/dev/null)
if [ -n " $new_num " ] && [ -n " $kimi_id " ] ; then
curl -sf -X POST -H " Authorization: token $TIMMY_TOKEN " -H "Content-Type: application/json" \
-d " {\"labels\":[ $kimi_id ]} " \
" $BASE /repos/ $repo /issues/ $new_num /labels " > /dev/null 2>& 1 || true
log " SUBTASK: $repo # $new_num — $sub_title "
fi
fi
done
# Mark parent as kimi-done (subtasks will be picked up next cycle)
2026-03-30 17:59:43 -04:00
[ -n " $progress_id " ] && curl -sf -X DELETE -H " Authorization: token $TIMMY_TOKEN " \
" $BASE /repos/ $repo /issues/ $issue_num /labels/ $progress_id " > /dev/null 2>& 1 || true
[ -n " $done_id " ] && curl -sf -X POST -H " Authorization: token $TIMMY_TOKEN " -H "Content-Type: application/json" \
2026-03-30 20:15:36 +00:00
-d " {\"labels\":[ $done_id ]} " \
2026-03-30 17:59:43 -04:00
" $BASE /repos/ $repo /issues/ $issue_num /labels " > /dev/null 2>& 1 || true
2026-03-30 18:28:38 -04:00
dispatched = $(( dispatched + 1 ))
log " PLANNED: $repo # $issue_num — subtasks created, parent marked done "
2026-03-30 20:15:36 +00:00
else
2026-03-30 18:28:38 -04:00
# --- Plan says EXECUTE — proceed to execution ---
log " EXECUTE: $repo # $issue_num — planning pass says single-pass OK "
# Fall through to execution below
needs_planning = false
fi
fi
if [ " $needs_planning " = false ] ; then
# =============================================
# PHASE 2: EXECUTION PASS (8 min timeout)
# =============================================
# Post pickup comment if we didn't already (simple tasks skip planning)
if [ " $body_len " -le " $BODY_COMPLEXITY_THRESHOLD " ] ; then
2026-03-30 17:59:43 -04:00
curl -sf -X POST -H " Authorization: token $TOKEN " -H "Content-Type: application/json" \
2026-03-30 18:28:38 -04:00
-d " {\"body\":\"🟠 **KimiClaw picking up this task** via heartbeat.\\nBackend: kimi/kimi-code (Moonshot AI)\\nMode: **Direct execution** (task fits in one pass)\\nTimestamp: $( date -u '+%Y-%m-%dT%H:%M:%SZ' ) \"} " \
2026-03-30 17:59:43 -04:00
" $BASE /repos/ $repo /issues/ $issue_num /comments " > /dev/null 2>& 1 || true
2026-03-30 20:15:36 +00:00
fi
2026-03-30 17:59:43 -04:00
2026-03-30 18:28:38 -04:00
log " DISPATCH: $repo # $issue_num to openclaw (timeout: ${ EXEC_TIMEOUT } s) "
exec_prompt = " You are KimiClaw, an AI agent powered by Kimi K2.5 (Moonshot AI).
You are working on Gitea issue #$issue_num in repo $repo.
You have 8 MINUTES maximum. Be concise and focused.
ISSUE TITLE: $title
ISSUE BODY:
$body
YOUR TASK:
1. Read the issue carefully and do the work described
2. Stay focused — deliver the core ask, skip nice-to-haves
3. Provide your COMPLETE results as your response ( use markdown)
4. If you realize mid-task this will take longer than 8 minutes, STOP and summarize what you' ve done so far plus what remains"
# --- Dispatch to OpenClaw (background) ---
(
result = $( openclaw agent --agent main --message " $exec_prompt " --timeout $EXEC_TIMEOUT --json 2>/dev/null || echo '{"status":"error"}' )
status = $( echo " $result " | python3 -c "import json,sys; print(json.load(sys.stdin).get('status','error'))" 2>/dev/null || echo "error" )
# Extract response text
response_text = $( echo " $result " | python3 -c "
import json,sys
d = json.load( sys.stdin)
payloads = d.get( 'result' ,{ } ) .get( 'payloads' ,[ ] )
print( payloads[ 0] [ 'text' ] [ :3000] if payloads else 'No response' )
" 2>/dev/null || echo " No response" )
if [ " $status " = "ok" ] && [ " $response_text " != "No response" ] ; then
escaped = $( echo " $response_text " | python3 -c "import sys,json; print(json.dumps(sys.stdin.read())[1:-1])" 2>/dev/null)
2026-04-05 18:27:22 +00:00
if needs_pr_proof " $title $body " && ! has_pr_proof " $response_text " ; then
log " BLOCKED: $repo # $issue_num — response lacked PR/proof for code task "
post_issue_comment_json " $repo " " $issue_num " " $TOKEN " " 🟡 **KimiClaw produced analysis only — no PR/proof detected.**
This issue looks like implementation work, so it is NOT being marked kimi-done.
Kimi response excerpt:
$escaped
Action: removing Kimi queue labels so a code-capable agent can pick it up."
[ -n " $progress_id " ] && curl -sf -X DELETE -H " Authorization: token $TIMMY_TOKEN " \
" $BASE /repos/ $repo /issues/ $issue_num /labels/ $progress_id " > /dev/null 2>& 1 || true
[ -n " $kimi_id " ] && curl -sf -X DELETE -H " Authorization: token $TIMMY_TOKEN " \
" $BASE /repos/ $repo /issues/ $issue_num /labels/ $kimi_id " > /dev/null 2>& 1 || true
else
log " COMPLETED: $repo # $issue_num "
post_issue_comment_json " $repo " " $issue_num " " $TOKEN " " 🟢 **KimiClaw result:**
$escaped "
[ -n " $progress_id " ] && curl -sf -X DELETE -H " Authorization: token $TIMMY_TOKEN " \
" $BASE /repos/ $repo /issues/ $issue_num /labels/ $progress_id " > /dev/null 2>& 1 || true
[ -n " $kimi_id " ] && curl -sf -X DELETE -H " Authorization: token $TIMMY_TOKEN " \
" $BASE /repos/ $repo /issues/ $issue_num /labels/ $kimi_id " > /dev/null 2>& 1 || true
[ -n " $done_id " ] && curl -sf -X POST -H " Authorization: token $TIMMY_TOKEN " -H "Content-Type: application/json" \
-d " {\"labels\":[ $done_id ]} " \
" $BASE /repos/ $repo /issues/ $issue_num /labels " > /dev/null 2>& 1 || true
fi
2026-03-30 18:28:38 -04:00
else
log " FAILED: $repo # $issue_num — status= $status "
curl -sf -X POST -H " Authorization: token $TOKEN " -H "Content-Type: application/json" \
2026-04-04 20:17:40 +00:00
-d " {\"body\":\"\ud83d\udd34 **KimiClaw failed/timed out.**\\nStatus: $status \\nTimestamp: $( date -u '+%Y-%m-%dT%H:%M:%SZ' ) \\n\\nTask may be too complex for single-pass execution. Consider breaking into smaller subtasks.\"} " \
2026-03-30 18:28:38 -04:00
" $BASE /repos/ $repo /issues/ $issue_num /comments " > /dev/null 2>& 1 || true
# Remove kimi-in-progress on failure
[ -n " $progress_id " ] && curl -sf -X DELETE -H " Authorization: token $TIMMY_TOKEN " \
" $BASE /repos/ $repo /issues/ $issue_num /labels/ $progress_id " > /dev/null 2>& 1 || true
fi
) &
dispatched = $(( dispatched + 1 ))
log " DISPATCHED: $repo # $issue_num (background PID $! ) "
fi
2026-03-30 17:59:43 -04:00
# Enforce dispatch cap
if [ " $dispatched " -ge " $MAX_DISPATCH " ] ; then
log " CAPPED: reached $MAX_DISPATCH dispatches, remaining issues deferred to next heartbeat "
break 2 # Break out of both loops
fi
# Stagger dispatches to avoid overwhelming kimi
sleep 3
2026-03-30 20:15:36 +00:00
done <<< " $issues "
done
2026-03-30 17:59:43 -04:00
if [ " $dispatched " -eq 0 ] ; then
log "Heartbeat: no pending tasks"
else
log " Heartbeat: dispatched $dispatched task(s) "
fi