liutaio 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,567 @@
1
+ #!/bin/bash
2
+ set -euo pipefail
3
+
4
+ AGENTS_FILE="${1:-}"
5
+ ITERATIONS="${2:-}"
6
+ BASE_BRANCH="${3:-}"
7
+ LOGDIR="${4:-/tmp/liutaio}"
8
+
9
+ if [ -z "$AGENTS_FILE" ] || [ -z "$ITERATIONS" ] || [ -z "$BASE_BRANCH" ]; then
10
+ echo "Usage: liutaio <agents-file> <iterations> <base-branch> [log-dir]"
11
+ echo ""
12
+ echo " agents-file Path to agents.md relative to repo root"
13
+ echo " iterations Number of ralph loop iterations"
14
+ echo " base-branch Name of the base branch to create from main"
15
+ echo " log-dir Log directory (default: /tmp/liutaio)"
16
+ exit 1
17
+ fi
18
+
19
+ # ─── Authenticate ───────────────────────────────────────────────────
20
+ # Priority: 1) mounted/cached credentials 2) API key 3) interactive OAuth
21
+ CLAUDE_DIR="$HOME/.claude"
22
+ mkdir -p "$CLAUDE_DIR"
23
+
24
+ NEED_LOGIN=true
25
+
26
+ if [ -f /credentials/credentials.json ]; then
27
+ cp /credentials/credentials.json "$CLAUDE_DIR/.credentials.json"
28
+ chmod 600 "$CLAUDE_DIR/.credentials.json"
29
+ echo "Auth: credentials loaded from mount/cache"
30
+ NEED_LOGIN=false
31
+ elif [ -f "$CLAUDE_DIR/.credentials.json" ]; then
32
+ if jq -e '.claudeAiOauth.accessToken' "$CLAUDE_DIR/.credentials.json" >/dev/null 2>&1; then
33
+ echo "Auth: existing credentials found"
34
+ NEED_LOGIN=false
35
+ fi
36
+ elif [ -n "${ANTHROPIC_API_KEY:-}" ]; then
37
+ echo "Auth: using ANTHROPIC_API_KEY"
38
+ NEED_LOGIN=false
39
+ fi
40
+
41
+ # ─── Interactive OAuth PKCE flow (fallback) ─────────────────────────
42
+ if $NEED_LOGIN; then
43
+ echo ""
44
+ echo "============================================"
45
+ echo " Claude OAuth Login"
46
+ echo "============================================"
47
+ echo ""
48
+
49
+ OAUTH_CLIENT_ID="${LIUTAIO_OAUTH_CLIENT_ID:-9d1c250a-e61b-44d9-88ed-5944d1962f5e}"
50
+ OAUTH_AUTHORIZE="https://claude.com/cai/oauth/authorize"
51
+ OAUTH_TOKEN="https://platform.claude.com/v1/oauth/token"
52
+ OAUTH_REDIRECT="https://platform.claude.com/oauth/code/callback"
53
+ OAUTH_SCOPE="org:create_api_key user:profile user:inference user:sessions:claude_code user:mcp_servers user:file_upload"
54
+
55
+ # Generate PKCE code_verifier (43-128 chars from [A-Za-z0-9])
56
+ # tr -d '\n' removes newlines that base64 inserts every 76 chars
57
+ CODE_VERIFIER=$(head -c 96 /dev/urandom | base64 | tr -d '\n=+/' | head -c 128)
58
+
59
+ # code_challenge = base64url(sha256(code_verifier))
60
+ CODE_CHALLENGE=$(printf '%s' "$CODE_VERIFIER" \
61
+ | openssl dgst -sha256 -binary \
62
+ | base64 \
63
+ | tr -d '\n' \
64
+ | tr '+/' '-_' \
65
+ | tr -d '=')
66
+
67
+ STATE=$(head -c 32 /dev/urandom | base64 | tr -d '\n=+/' | head -c 43)
68
+
69
+ ENCODED_SCOPE=$(printf '%s' "$OAUTH_SCOPE" | sed 's/ /%20/g')
70
+ ENCODED_REDIRECT=$(printf '%s' "$OAUTH_REDIRECT" | jq -sRr @uri)
71
+ AUTH_URL="${OAUTH_AUTHORIZE}?code=true&client_id=${OAUTH_CLIENT_ID}&response_type=code&redirect_uri=${ENCODED_REDIRECT}&scope=${ENCODED_SCOPE}&code_challenge=${CODE_CHALLENGE}&code_challenge_method=S256&state=${STATE}"
72
+
73
+ echo " Open this URL in your browser and authorise:"
74
+ echo ""
75
+ echo " $AUTH_URL"
76
+ echo ""
77
+ echo " After authorising, you'll see a page with a code."
78
+ echo " Copy the ENTIRE code (including any # in the middle)"
79
+ echo " and paste it below."
80
+ echo ""
81
+ printf " Code: "
82
+ read -r AUTH_CODE </dev/tty
83
+
84
+ if [ -z "$AUTH_CODE" ]; then
85
+ echo "Error: No code entered."
86
+ exit 1
87
+ fi
88
+
89
+ # Pasted code format: "{authorization_code}#{state}"
90
+ OAUTH_CODE="${AUTH_CODE%%#*}"
91
+ OAUTH_STATE="${AUTH_CODE#*#}"
92
+
93
+ if [ -z "$OAUTH_CODE" ] || [ "$OAUTH_CODE" = "$AUTH_CODE" ]; then
94
+ echo "Error: Invalid code format. Expected {code}#{state}."
95
+ exit 1
96
+ fi
97
+
98
+ echo ""
99
+ echo " Exchanging code for tokens..."
100
+
101
+ TOKEN_BODY=$(jq -n \
102
+ --arg grant_type "authorization_code" \
103
+ --arg code "$OAUTH_CODE" \
104
+ --arg redirect_uri "$OAUTH_REDIRECT" \
105
+ --arg client_id "$OAUTH_CLIENT_ID" \
106
+ --arg code_verifier "$CODE_VERIFIER" \
107
+ --arg state "$OAUTH_STATE" \
108
+ '{grant_type: $grant_type, code: $code, redirect_uri: $redirect_uri, client_id: $client_id, code_verifier: $code_verifier, state: $state}')
109
+
110
+ TOKEN_RESPONSE=$(curl -s -w "\n%{http_code}" -X POST "$OAUTH_TOKEN" \
111
+ -H "Content-Type: application/json" \
112
+ -H "User-Agent: claude-code" \
113
+ -d "$TOKEN_BODY" \
114
+ 2>&1)
115
+
116
+ HTTP_CODE=$(echo "$TOKEN_RESPONSE" | tail -1)
117
+ RESPONSE_BODY=$(echo "$TOKEN_RESPONSE" | sed '$d')
118
+
119
+ ACCESS_TOKEN=$(echo "$RESPONSE_BODY" | jq -r '.access_token // empty')
120
+ REFRESH_TOKEN=$(echo "$RESPONSE_BODY" | jq -r '.refresh_token // empty')
121
+ EXPIRES_IN=$(echo "$RESPONSE_BODY" | jq -r '.expires_in // empty')
122
+
123
+ if [ -z "$ACCESS_TOKEN" ]; then
124
+ echo " Error: Token exchange failed (HTTP $HTTP_CODE):"
125
+ echo " $RESPONSE_BODY"
126
+ exit 1
127
+ fi
128
+
129
+ # expiresAt must be Unix epoch in MILLISECONDS
130
+ EXPIRES_AT_MS=0
131
+ if [ -n "$EXPIRES_IN" ] && [ "$EXPIRES_IN" != "null" ]; then
132
+ EXPIRES_AT_MS=$(( $(date +%s) * 1000 + EXPIRES_IN * 1000 ))
133
+ fi
134
+
135
+ # Build credentials.json in the exact format Claude Code expects
136
+ CREDS_JSON=$(jq -n \
137
+ --arg at "$ACCESS_TOKEN" \
138
+ --arg rt "${REFRESH_TOKEN:-}" \
139
+ --argjson ea "$EXPIRES_AT_MS" \
140
+ '{
141
+ claudeAiOauth: {
142
+ accessToken: $at,
143
+ refreshToken: $rt,
144
+ expiresAt: $ea,
145
+ scopes: [
146
+ "org:create_api_key",
147
+ "user:file_upload",
148
+ "user:inference",
149
+ "user:mcp_servers",
150
+ "user:profile",
151
+ "user:sessions:claude_code"
152
+ ]
153
+ }
154
+ }')
155
+
156
+ echo "$CREDS_JSON" > "$CLAUDE_DIR/.credentials.json"
157
+ chmod 600 "$CLAUDE_DIR/.credentials.json"
158
+
159
+ echo " Login successful."
160
+
161
+ # Persist credentials to mounted volume so they survive container restarts
162
+ if [ -d /credentials ] && [ -w /credentials ]; then
163
+ cp "$CLAUDE_DIR/.credentials.json" /credentials/credentials.json
164
+ chmod 600 /credentials/credentials.json
165
+ echo " Credentials cached (will persist across restarts)."
166
+ fi
167
+
168
+ echo ""
169
+ fi
170
+
171
+ AGENTS_DIR=$(dirname "$AGENTS_FILE")
172
+
173
+ echo ""
174
+ echo "============================================"
175
+ echo " Liutaio"
176
+ echo "============================================"
177
+ echo " Agents file : $AGENTS_FILE"
178
+ echo " Iterations : $ITERATIONS"
179
+ echo " Base branch : $BASE_BRANCH"
180
+ echo " Log dir : $LOGDIR"
181
+ echo "============================================"
182
+
183
+ # ─── Clone from mounted source repo ──────────────────────────────────
184
+ echo ""
185
+ echo "[1/7] Cloning repository from /repo..."
186
+ git clone /repo /workspace 2>&1 | tail -1
187
+ cd /workspace
188
+
189
+ # Repoint origin from local /repo mount to the real remote
190
+ REAL_REMOTE=$(git -C /repo remote get-url origin 2>/dev/null || echo "")
191
+ if [ -n "$REAL_REMOTE" ]; then
192
+ git remote set-url origin "$REAL_REMOTE"
193
+ echo " Remote set to: $REAL_REMOTE"
194
+ fi
195
+
196
+ # Copy agents file (and its directory) from host if not on main
197
+ if [ ! -f "/workspace/$AGENTS_FILE" ] && [ -f "/repo/$AGENTS_FILE" ]; then
198
+ echo " Agents file not on main — copying from host repo..."
199
+ mkdir -p "/workspace/$AGENTS_DIR"
200
+ cp -r "/repo/$AGENTS_DIR/." "/workspace/$AGENTS_DIR/"
201
+ echo " Copied: $AGENTS_DIR/"
202
+ fi
203
+
204
+ # Copy liutaio.setup.sh from host if not in git
205
+ if [ ! -f "/workspace/liutaio.setup.sh" ] && [ -f "/repo/liutaio.setup.sh" ]; then
206
+ cp "/repo/liutaio.setup.sh" "/workspace/liutaio.setup.sh"
207
+ chmod +x "/workspace/liutaio.setup.sh"
208
+ echo " Copied: liutaio.setup.sh"
209
+ fi
210
+
211
+ # ─── Checkout main and create base branch ────────────────────────────
212
+ echo "[2/7] Creating base branch '$BASE_BRANCH' from main..."
213
+ git checkout main --quiet
214
+ git checkout -b "$BASE_BRANCH" --quiet
215
+
216
+ # ─── Git config ──────────────────────────────────────────────────────
217
+ git config user.name "${GIT_USER_NAME:-Liutaio Agent}"
218
+ git config user.email "${GIT_USER_EMAIL:-liutaio@users.noreply.github.com}"
219
+
220
+ # ─── SSH setup ───────────────────────────────────────────────────────
221
+ echo "[3/7] Setting up SSH..."
222
+ SSH_DIR="$HOME/.ssh"
223
+ mkdir -p "$SSH_DIR"
224
+ chmod 700 "$SSH_DIR"
225
+
226
+ if [ -d /ssh-keys ]; then
227
+ cp /ssh-keys/id_* "$SSH_DIR/" 2>/dev/null || true
228
+ chmod 600 "$SSH_DIR"/id_* 2>/dev/null || true
229
+ if [ -f /ssh-keys/config ]; then
230
+ cp /ssh-keys/config "$SSH_DIR/config"
231
+ chmod 600 "$SSH_DIR/config"
232
+ fi
233
+ fi
234
+
235
+ if [ -n "${LIUTAIO_SSH_HOSTS:-}" ]; then
236
+ IFS=',' read -ra HOSTS <<< "$LIUTAIO_SSH_HOSTS"
237
+ for host in "${HOSTS[@]}"; do
238
+ timeout 5 ssh-keyscan "$(echo "$host" | xargs)" >> "$SSH_DIR/known_hosts" 2>/dev/null || true
239
+ done
240
+ elif [ -n "$REAL_REMOTE" ]; then
241
+ SSH_HOST=$(echo "$REAL_REMOTE" | sed -n 's|.*@\([^:]*\):.*|\1|p; s|.*://\([^/]*\)/.*|\1|p' | head -1)
242
+ if [ -n "$SSH_HOST" ]; then
243
+ if [ -f "$SSH_DIR/config" ]; then
244
+ REAL_HOST=$(awk -v host="$SSH_HOST" '
245
+ tolower($1) == "host" && $2 == host { found=1; next }
246
+ tolower($1) == "host" { found=0 }
247
+ found && tolower($1) == "hostname" { print $2; exit }
248
+ ' "$SSH_DIR/config")
249
+ if [ -n "$REAL_HOST" ]; then
250
+ echo " Resolved SSH alias '$SSH_HOST' -> '$REAL_HOST'"
251
+ SSH_HOST="$REAL_HOST"
252
+ fi
253
+ fi
254
+ timeout 5 ssh-keyscan "$SSH_HOST" >> "$SSH_DIR/known_hosts" 2>/dev/null || true
255
+ echo " SSH host: $SSH_HOST"
256
+ fi
257
+ else
258
+ timeout 5 ssh-keyscan github.com >> "$SSH_DIR/known_hosts" 2>/dev/null || true
259
+ echo " SSH host: github.com (default)"
260
+ fi
261
+
262
+ # ─── Install dependencies ────────────────────────────────────────────
263
+ echo "[4/7] Installing dependencies..."
264
+
265
+ install_deps() {
266
+ if [ -x "./liutaio.setup.sh" ]; then
267
+ echo " Running liutaio.setup.sh..."
268
+ bash ./liutaio.setup.sh
269
+ return
270
+ fi
271
+
272
+ if [ -f "pnpm-lock.yaml" ]; then
273
+ echo " Detected pnpm project"
274
+ npm install -g pnpm 2>&1 | tail -1
275
+ pnpm install 2>&1 | tail -3
276
+ elif [ -f "bun.lock" ] || [ -f "bun.lockb" ]; then
277
+ echo " Detected Bun project"
278
+ npm install -g bun 2>&1 | tail -1
279
+ bun install 2>&1 | tail -3
280
+ elif [ -f "yarn.lock" ]; then
281
+ echo " Detected Yarn project"
282
+ yarn install 2>&1 | tail -3
283
+ elif [ -f "package-lock.json" ] || [ -f "package.json" ]; then
284
+ echo " Detected npm project"
285
+ npm install 2>&1 | tail -3
286
+ elif [ -f "requirements.txt" ]; then
287
+ echo " Detected Python project (requirements.txt)"
288
+ pip install -r requirements.txt 2>&1 | tail -3
289
+ elif [ -f "pyproject.toml" ]; then
290
+ echo " Detected Python project (pyproject.toml)"
291
+ pip install -e . 2>&1 | tail -3
292
+ elif [ -f "Pipfile" ]; then
293
+ echo " Detected Pipenv project"
294
+ pip install pipenv 2>&1 | tail -1
295
+ pipenv install 2>&1 | tail -3
296
+ elif [ -f "go.sum" ]; then
297
+ echo " Detected Go project"
298
+ go mod download 2>&1 | tail -3
299
+ elif [ -f "Cargo.lock" ] || [ -f "Cargo.toml" ]; then
300
+ echo " Detected Rust project"
301
+ cargo fetch 2>&1 | tail -3
302
+ elif [ -f "Gemfile.lock" ]; then
303
+ echo " Detected Ruby project"
304
+ bundle install 2>&1 | tail -3
305
+ elif [ -f "composer.json" ]; then
306
+ echo " Detected PHP project"
307
+ composer install 2>&1 | tail -3
308
+ else
309
+ echo " No recognised project type — skipping dependency install"
310
+ echo " Create a liutaio.setup.sh for custom setup"
311
+ fi
312
+ }
313
+
314
+ install_deps
315
+
316
+ # ─── Post-checkout hook (auto reinstall on dependency file changes) ───
317
+ echo "[5/7] Setting up git hooks..."
318
+ mkdir -p .git/hooks
319
+ cat > .git/hooks/post-checkout << 'HOOK'
320
+ #!/bin/bash
321
+ OLD_HEAD="$1"
322
+ NEW_HEAD="$2"
323
+ IS_BRANCH_CHECKOUT="$3"
324
+
325
+ [ "$IS_BRANCH_CHECKOUT" = "1" ] || exit 0
326
+
327
+ CHANGED=$(git diff --name-only "$OLD_HEAD" "$NEW_HEAD" -- \
328
+ '**/package.json' '**/package-lock.json' \
329
+ '**/yarn.lock' '**/pnpm-lock.yaml' '**/bun.lock' \
330
+ '**/requirements.txt' '**/Pipfile.lock' '**/pyproject.toml' \
331
+ '**/go.sum' '**/Cargo.lock' \
332
+ '**/Gemfile.lock' '**/composer.lock' \
333
+ 2>/dev/null | head -1)
334
+
335
+ if [ -n "$CHANGED" ]; then
336
+ echo "post-checkout: dependency files changed, reinstalling..."
337
+ REPO_ROOT="$(git rev-parse --show-toplevel)"
338
+ cd "$REPO_ROOT"
339
+ if [ -x "./liutaio.setup.sh" ]; then
340
+ bash ./liutaio.setup.sh
341
+ elif [ -f "pnpm-lock.yaml" ]; then pnpm install
342
+ elif [ -f "bun.lock" ] || [ -f "bun.lockb" ]; then bun install
343
+ elif [ -f "yarn.lock" ]; then yarn install
344
+ elif [ -f "package-lock.json" ] || [ -f "package.json" ]; then npm install
345
+ elif [ -f "requirements.txt" ]; then pip install -r requirements.txt
346
+ elif [ -f "pyproject.toml" ]; then pip install -e .
347
+ elif [ -f "go.sum" ]; then go mod download
348
+ elif [ -f "Cargo.lock" ]; then cargo fetch
349
+ elif [ -f "Gemfile.lock" ]; then bundle install
350
+ elif [ -f "composer.json" ]; then composer install
351
+ fi
352
+ fi
353
+ HOOK
354
+ chmod +x .git/hooks/post-checkout
355
+
356
+ git config core.hooksPath .git/hooks
357
+
358
+ # ─── Output directory ─────────────────────────────────────────────────
359
+ echo "[6/7] Checking output directory..."
360
+ if [ -d /output ]; then
361
+ echo " /output mounted — progress.md will be copied after each iteration"
362
+ else
363
+ echo " Warning: /output not mounted — progress.md will only exist inside container"
364
+ fi
365
+
366
+ echo "[7/7] Ready."
367
+
368
+ echo ""
369
+ echo "============================================"
370
+ echo " Liutaio ready. Starting loop..."
371
+ echo "============================================"
372
+ echo ""
373
+
374
+ # ─── Token refresh helper ─────────────────────────────────────────────
375
+ OAUTH_CLIENT_ID="${LIUTAIO_OAUTH_CLIENT_ID:-9d1c250a-e61b-44d9-88ed-5944d1962f5e}"
376
+ TOKEN_ENDPOINT="https://platform.claude.com/v1/oauth/token"
377
+
378
+ refresh_oauth_token() {
379
+ # Skip refresh for host credentials — refreshing invalidates the host's token
380
+ local auth_method="${LIUTAIO_AUTH_METHOD:-}"
381
+ if [ "$auth_method" = "keychain" ] || [ "$auth_method" = "credentials-file" ]; then
382
+ return 0
383
+ fi
384
+
385
+ local creds_file="$HOME/.claude/.credentials.json"
386
+ [ -f "$creds_file" ] || return 0
387
+
388
+ local refresh_token
389
+ refresh_token=$(jq -r '.claudeAiOauth.refreshToken // empty' "$creds_file" 2>/dev/null)
390
+ [ -n "$refresh_token" ] || return 0
391
+
392
+ echo " Refreshing OAuth token..."
393
+ local response
394
+ local refresh_scope="user:profile user:inference user:sessions:claude_code user:mcp_servers user:file_upload"
395
+ local refresh_body
396
+ refresh_body=$(jq -n \
397
+ --arg grant_type "refresh_token" \
398
+ --arg refresh_token "$refresh_token" \
399
+ --arg client_id "$OAUTH_CLIENT_ID" \
400
+ --arg scope "$refresh_scope" \
401
+ '{grant_type: $grant_type, refresh_token: $refresh_token, client_id: $client_id, scope: $scope}')
402
+ response=$(curl -s -X POST "$TOKEN_ENDPOINT" \
403
+ -H "Content-Type: application/json" \
404
+ -H "User-Agent: claude-code" \
405
+ -d "$refresh_body" \
406
+ 2>/dev/null)
407
+
408
+ local new_access_token new_refresh_token
409
+ new_access_token=$(echo "$response" | jq -r '.access_token // empty' 2>/dev/null)
410
+ new_refresh_token=$(echo "$response" | jq -r '.refresh_token // empty' 2>/dev/null)
411
+
412
+ if [ -n "$new_access_token" ]; then
413
+ local new_expires_in
414
+ new_expires_in=$(echo "$response" | jq -r '.expires_in // empty' 2>/dev/null)
415
+ local new_expires_at=0
416
+ if [ -n "$new_expires_in" ] && [ "$new_expires_in" != "null" ]; then
417
+ new_expires_at=$(( $(date +%s) * 1000 + new_expires_in * 1000 ))
418
+ fi
419
+
420
+ local updated
421
+ updated=$(jq \
422
+ --arg at "$new_access_token" \
423
+ --arg rt "${new_refresh_token:-$refresh_token}" \
424
+ --argjson ea "$new_expires_at" \
425
+ '.claudeAiOauth.accessToken = $at | .claudeAiOauth.refreshToken = $rt | .claudeAiOauth.expiresAt = $ea' "$creds_file")
426
+ echo "$updated" > "$creds_file"
427
+ chmod 600 "$creds_file"
428
+ echo " Token refreshed successfully."
429
+
430
+ # Persist refreshed credentials to mounted volume
431
+ if [ -d /credentials ] && [ -w /credentials ]; then
432
+ cp "$creds_file" /credentials/credentials.json
433
+ chmod 600 /credentials/credentials.json
434
+ fi
435
+ else
436
+ echo " Warning: token refresh failed (will retry next iteration)"
437
+ echo " Response: $(echo "$response" | head -c 200)"
438
+ fi
439
+ }
440
+
441
+ # ─── Push helper ─────────────────────────────────────────────────────
442
+ push_base_branch() {
443
+ local current_branch
444
+ current_branch=$(git branch --show-current)
445
+
446
+ if [ "$current_branch" != "$BASE_BRANCH" ]; then
447
+ git stash --quiet 2>/dev/null || true
448
+ git checkout "$BASE_BRANCH" --quiet 2>/dev/null || true
449
+ fi
450
+
451
+ local commit_count
452
+ commit_count=$(git rev-list --count main.."$BASE_BRANCH" 2>/dev/null || echo "0")
453
+ if [ "$commit_count" -gt 0 ]; then
454
+ echo "Pushing $commit_count commit(s) on '$BASE_BRANCH' to origin..."
455
+ git push origin "$BASE_BRANCH" 2>&1 || echo "Warning: push failed"
456
+ fi
457
+
458
+ if [ "$current_branch" != "$BASE_BRANCH" ]; then
459
+ git checkout "$current_branch" --quiet 2>/dev/null || true
460
+ git stash pop --quiet 2>/dev/null || true
461
+ fi
462
+ }
463
+
464
+ # ─── Trap: push on exit (safety net for crashes/stops) ───────────────
465
+ trap 'echo ""; echo "Container stopping — pushing base branch..."; push_base_branch' EXIT
466
+
467
+ # ─── Run the loop ────────────────────────────────────────────────────
468
+ mkdir -p "$LOGDIR"
469
+
470
+ START_TIME=$SECONDS
471
+
472
+ elapsed() {
473
+ local total=$(( SECONDS - START_TIME ))
474
+ local h=$(( total / 3600 ))
475
+ local m=$(( (total % 3600) / 60 ))
476
+ local s=$(( total % 60 ))
477
+ printf "%dh %dm %ds" "$h" "$m" "$s"
478
+ }
479
+
480
+ for ((i=1; i<=$ITERATIONS; i++)); do
481
+ LOGFILE="$LOGDIR/iteration-${i}.log"
482
+
483
+ # Copy progress.md to host before each iteration (safety net for crashes)
484
+ if [ -d /output ] && [ -f "/workspace/$AGENTS_DIR/progress.md" ]; then
485
+ cp "/workspace/$AGENTS_DIR/progress.md" /output/progress.md 2>/dev/null || true
486
+ fi
487
+
488
+ # Refresh OAuth token before each iteration
489
+ refresh_oauth_token
490
+
491
+ echo "============================================"
492
+ echo "Iteration $i / $ITERATIONS — $(date)"
493
+ echo "Logging to: $LOGFILE"
494
+ echo "============================================"
495
+
496
+ # Show which ticket is next
497
+ NEXT_TICKET=""
498
+ PROGRESS_FILE="/workspace/$AGENTS_DIR/progress.md"
499
+ TICKETS_DIR="/workspace/$AGENTS_DIR/tickets"
500
+
501
+ if [ -f "$PROGRESS_FILE" ]; then
502
+ # Try "## Next Steps" section first
503
+ NEXT_TICKET=$(awk '/^## Next Steps/{found=1; next} /^## /{found=0} found && /[^ \t]/{print; exit}' "$PROGRESS_FILE" | sed 's/^[[:space:]-]*//' || true)
504
+
505
+ # Fallback: first ticket in the table NOT marked DONE
506
+ if [ -z "$NEXT_TICKET" ]; then
507
+ NEXT_ID=$(awk -F'|' '/\|.*\|.*\|/ && !/DONE/ && !/Status/' '{gsub(/[ \t]/, "", $2); if ($2 != "" && $2 != "---") print $2; }' "$PROGRESS_FILE" | head -1 || true)
508
+ if [ -n "$NEXT_ID" ] && [ -d "$TICKETS_DIR" ]; then
509
+ # Read the title (first line) from the ticket file
510
+ TICKET_FILE=$(ls "$TICKETS_DIR"/${NEXT_ID}* 2>/dev/null | head -1)
511
+ if [ -n "$TICKET_FILE" ]; then
512
+ NEXT_TICKET=$(head -1 "$TICKET_FILE" | sed 's/^#* *//')
513
+ else
514
+ NEXT_TICKET="$NEXT_ID"
515
+ fi
516
+ elif [ -n "$NEXT_ID" ]; then
517
+ NEXT_TICKET="$NEXT_ID"
518
+ fi
519
+ fi
520
+ fi
521
+
522
+ if [ -n "$NEXT_TICKET" ]; then
523
+ echo "Current Ticket: $NEXT_TICKET"
524
+ echo "============================================"
525
+ fi
526
+
527
+ claude --dangerously-skip-permissions --output-format stream-json --verbose \
528
+ -p "You are running in AFK (unattended) mode inside a Docker container (Liutaio). Follow the instructions in @${AGENTS_FILE}. IMPORTANT: Since no human is present, whenever a step says to ask the human for confirmation (e.g. merge confirmation, manual steps), auto-approve and proceed automatically. Answer 'yes' to your own merge prompts. For human-assisted tickets that require manual operations, skip them, note it in progress.md, and continue to the next ticket. IMPORTANT: Do NOT commit progress.md — it is tracked outside of git. IMPORTANT: Work on exactly ONE ticket per session. After completing one ticket (code committed, progress.md updated, branch merged and verified), STOP. Do not start the next ticket — the next iteration will handle it." \
529
+ 2>"$LOGFILE.stderr" \
530
+ | grep --line-buffered '^{' \
531
+ | tee "$LOGFILE" \
532
+ | jq --unbuffered -rj '
533
+ if .type == "assistant" then
534
+ .message.content[]? |
535
+ if .type == "tool_use" then
536
+ " " + .name + ": " + (.input | tostring | .[0:200]) + "\n"
537
+ elif .type == "text" then
538
+ .text // empty
539
+ else empty end
540
+ elif .type == "result" then
541
+ "\nSession complete (" + (.total_cost_usd // 0 | tostring) + " USD)\n"
542
+ else empty end'
543
+
544
+ echo ""
545
+
546
+ # Push base branch after each iteration
547
+ echo "Pushing after iteration $i..."
548
+ push_base_branch
549
+
550
+ # Copy progress.md back to host
551
+ if [ -d /output ]; then
552
+ cp "/workspace/$AGENTS_DIR/progress.md" /output/progress.md 2>/dev/null || true
553
+ fi
554
+
555
+ # Check for completion token
556
+ if grep -q '<promise>.*COMPLETE.*</promise>' "$LOGFILE"; then
557
+ echo ""
558
+ echo "All tickets complete after $i iterations. Total time: $(elapsed)"
559
+ exit 0
560
+ fi
561
+
562
+ echo "Iteration $i finished. Completion token not found, continuing..."
563
+ echo ""
564
+ done
565
+
566
+ echo "Reached max iterations ($ITERATIONS) without completion token. Total time: $(elapsed)"
567
+ exit 1