agent-control-plane 0.1.16 → 0.3.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.
Files changed (63) hide show
  1. package/README.md +93 -14
  2. package/bin/pr-risk.sh +28 -6
  3. package/hooks/heartbeat-hooks.sh +62 -22
  4. package/npm/bin/agent-control-plane.js +360 -10
  5. package/package.json +6 -3
  6. package/references/architecture.md +8 -0
  7. package/references/control-plane-map.md +6 -2
  8. package/references/release-checklist.md +0 -2
  9. package/tools/bin/agent-github-update-labels +6 -1
  10. package/tools/bin/agent-project-catch-up-issue-pr-links +118 -0
  11. package/tools/bin/agent-project-catch-up-merged-prs +78 -21
  12. package/tools/bin/agent-project-catch-up-scheduled-issue-retries +123 -0
  13. package/tools/bin/agent-project-cleanup-session +132 -4
  14. package/tools/bin/agent-project-heartbeat-loop +116 -1461
  15. package/tools/bin/agent-project-reconcile-issue-session +90 -117
  16. package/tools/bin/agent-project-reconcile-pr-session +76 -111
  17. package/tools/bin/agent-project-run-claude-session +12 -2
  18. package/tools/bin/agent-project-run-codex-resilient +86 -9
  19. package/tools/bin/agent-project-run-codex-session +16 -5
  20. package/tools/bin/agent-project-run-kilo-session +356 -14
  21. package/tools/bin/agent-project-run-ollama-session +658 -0
  22. package/tools/bin/agent-project-run-openclaw-session +37 -25
  23. package/tools/bin/agent-project-run-opencode-session +364 -14
  24. package/tools/bin/agent-project-run-pi-session +479 -0
  25. package/tools/bin/agent-project-worker-status +11 -8
  26. package/tools/bin/cleanup-worktree.sh +6 -1
  27. package/tools/bin/flow-config-lib.sh +196 -3
  28. package/tools/bin/flow-resident-worker-lib.sh +120 -2
  29. package/tools/bin/flow-shell-lib.sh +29 -2
  30. package/tools/bin/heartbeat-loop-cache-lib.sh +164 -0
  31. package/tools/bin/heartbeat-loop-counting-lib.sh +306 -0
  32. package/tools/bin/heartbeat-loop-pr-strategy-lib.sh +199 -0
  33. package/tools/bin/heartbeat-loop-scheduling-lib.sh +506 -0
  34. package/tools/bin/heartbeat-loop-worker-lib.sh +319 -0
  35. package/tools/bin/heartbeat-recovery-preflight.sh +13 -1
  36. package/tools/bin/heartbeat-safe-auto.sh +119 -20
  37. package/tools/bin/install-project-launchd.sh +19 -2
  38. package/tools/bin/prepare-worktree.sh +4 -4
  39. package/tools/bin/profile-activate.sh +2 -2
  40. package/tools/bin/profile-adopt.sh +2 -2
  41. package/tools/bin/project-init.sh +1 -1
  42. package/tools/bin/project-launchd-bootstrap.sh +11 -8
  43. package/tools/bin/project-runtimectl.sh +90 -7
  44. package/tools/bin/provider-cooldown-state.sh +14 -14
  45. package/tools/bin/reconcile-bootstrap-lib.sh +113 -0
  46. package/tools/bin/render-flow-config.sh +30 -33
  47. package/tools/bin/resident-issue-controller-lib.sh +448 -0
  48. package/tools/bin/resident-issue-queue-status.py +35 -0
  49. package/tools/bin/run-codex-task.sh +53 -4
  50. package/tools/bin/scaffold-profile.sh +18 -3
  51. package/tools/bin/start-issue-worker.sh +1 -1
  52. package/tools/bin/start-pr-fix-worker.sh +30 -0
  53. package/tools/bin/start-pr-review-worker.sh +31 -0
  54. package/tools/bin/start-resident-issue-loop.sh +27 -438
  55. package/tools/bin/sync-agent-repo.sh +2 -2
  56. package/tools/bin/sync-dependency-baseline.sh +3 -3
  57. package/tools/bin/sync-shared-agent-home.sh +4 -1
  58. package/tools/dashboard/app.js +7 -0
  59. package/tools/dashboard/dashboard_snapshot.py +13 -29
  60. package/tools/templates/pr-fix-template.md +3 -7
  61. package/tools/templates/pr-merge-repair-template.md +3 -7
  62. package/tools/templates/pr-review-template.md +2 -1
  63. package/SKILL.md +0 -149
@@ -38,6 +38,23 @@ auth_refresh_poll_seconds="${ACP_CODEX_AUTH_REFRESH_POLL_SECONDS:-${F_LOSNING_CO
38
38
  max_quota_autoswitch_attempts="${ACP_CODEX_MAX_AUTOSWITCH_ATTEMPTS:-${F_LOSNING_CODEX_MAX_AUTOSWITCH_ATTEMPTS:-1}}"
39
39
  codex_progress_heartbeat_seconds="${ACP_CODEX_PROGRESS_HEARTBEAT_SECONDS:-${F_LOSNING_CODEX_PROGRESS_HEARTBEAT_SECONDS:-30}}"
40
40
  codex_stall_seconds="${ACP_CODEX_STALL_SECONDS:-${F_LOSNING_CODEX_STALL_SECONDS:-300}}"
41
+ python_bin=""
42
+
43
+ resolve_python_bin() {
44
+ if command -v python3 >/dev/null 2>&1; then
45
+ command -v python3
46
+ return 0
47
+ fi
48
+ if [[ -x /opt/homebrew/bin/python3 ]]; then
49
+ printf '%s\n' "/opt/homebrew/bin/python3"
50
+ return 0
51
+ fi
52
+ if command -v python >/dev/null 2>&1; then
53
+ command -v python
54
+ return 0
55
+ fi
56
+ return 1
57
+ }
41
58
 
42
59
  while [[ $# -gt 0 ]]; do
43
60
  case "$1" in
@@ -92,6 +109,12 @@ case "$codex_stall_seconds" in
92
109
  ''|*[!0-9]*) echo "ACP_CODEX_STALL_SECONDS must be numeric" >&2; exit 1 ;;
93
110
  esac
94
111
 
112
+ python_bin="$(resolve_python_bin || true)"
113
+ if [[ -z "$python_bin" || ! -x "$python_bin" ]]; then
114
+ echo "unable to resolve a runnable python interpreter for codex supervision" >&2
115
+ exit 1
116
+ fi
117
+
95
118
  FLOW_SKILL_DIR="$(resolve_flow_skill_dir "${BASH_SOURCE[0]}")"
96
119
  state_file="${host_run_dir}/runner.env"
97
120
  auth_file="${HOME}/.codex/auth.json"
@@ -177,7 +200,7 @@ run_with_timeout() {
177
200
  local timeout_seconds="${1:?timeout seconds required}"
178
201
  shift
179
202
 
180
- /opt/homebrew/bin/python3 - "$timeout_seconds" "$@" <<'PY'
203
+ "$python_bin" - "$timeout_seconds" "$@" <<'PY'
181
204
  import os
182
205
  import signal
183
206
  import subprocess
@@ -220,6 +243,60 @@ sys.exit(proc.returncode)
220
243
  PY
221
244
  }
222
245
 
246
+ stat_file_size() {
247
+ local path="${1:?path required}"
248
+ local value=""
249
+
250
+ value="$(stat -f %z "$path" 2>/dev/null || true)"
251
+ if [[ "$value" =~ ^[0-9]+$ ]]; then
252
+ printf '%s\n' "$value"
253
+ return 0
254
+ fi
255
+
256
+ value="$(stat -c %s "$path" 2>/dev/null || true)"
257
+ if [[ "$value" =~ ^[0-9]+$ ]]; then
258
+ printf '%s\n' "$value"
259
+ return 0
260
+ fi
261
+
262
+ "$python_bin" - "$path" <<'PY'
263
+ import os
264
+ import sys
265
+
266
+ try:
267
+ print(os.path.getsize(sys.argv[1]))
268
+ except OSError:
269
+ print("0")
270
+ PY
271
+ }
272
+
273
+ stat_file_mtime() {
274
+ local path="${1:?path required}"
275
+ local value=""
276
+
277
+ value="$(stat -f %m "$path" 2>/dev/null || true)"
278
+ if [[ "$value" =~ ^[0-9]+$ ]]; then
279
+ printf '%s\n' "$value"
280
+ return 0
281
+ fi
282
+
283
+ value="$(stat -c %Y "$path" 2>/dev/null || true)"
284
+ if [[ "$value" =~ ^[0-9]+$ ]]; then
285
+ printf '%s\n' "$value"
286
+ return 0
287
+ fi
288
+
289
+ "$python_bin" - "$path" <<'PY'
290
+ import os
291
+ import sys
292
+
293
+ try:
294
+ print(int(os.path.getmtime(sys.argv[1])))
295
+ except OSError:
296
+ print("0")
297
+ PY
298
+ }
299
+
223
300
  auth_fingerprint() {
224
301
  if [[ ! -f "$auth_file" ]]; then
225
302
  printf 'missing\n'
@@ -227,8 +304,8 @@ auth_fingerprint() {
227
304
  fi
228
305
 
229
306
  local mtime size sha
230
- mtime="$(stat -f %m "$auth_file" 2>/dev/null || printf '0')"
231
- size="$(stat -f %z "$auth_file" 2>/dev/null || printf '0')"
307
+ mtime="$(stat_file_mtime "$auth_file" 2>/dev/null || printf '0')"
308
+ size="$(stat_file_size "$auth_file" 2>/dev/null || printf '0')"
232
309
  sha="$(shasum -a 256 "$auth_file" | awk '{print $1}')"
233
310
  printf '%s:%s:%s\n' "$mtime" "$size" "$sha"
234
311
  }
@@ -256,8 +333,8 @@ quota_switch_signature() {
256
333
  fi
257
334
 
258
335
  local mtime size sha
259
- mtime="$(stat -f %m "$quota_switch_state_file" 2>/dev/null || printf '0')"
260
- size="$(stat -f %z "$quota_switch_state_file" 2>/dev/null || printf '0')"
336
+ mtime="$(stat_file_mtime "$quota_switch_state_file" 2>/dev/null || printf '0')"
337
+ size="$(stat_file_size "$quota_switch_state_file" 2>/dev/null || printf '0')"
261
338
  sha="$(shasum -a 256 "$quota_switch_state_file" | awk '{print $1}')"
262
339
  printf '%s:%s:%s\n' "$mtime" "$size" "$sha"
263
340
  }
@@ -390,7 +467,7 @@ run_quota_autoswitch() {
390
467
  new_output_since() {
391
468
  local start_size="${1:?start size required}"
392
469
  local file_size
393
- file_size="$(stat -f %z "$output_file" 2>/dev/null || printf '0')"
470
+ file_size="$(stat_file_size "$output_file" 2>/dev/null || printf '0')"
394
471
  if (( file_size <= start_size )); then
395
472
  return 0
396
473
  fi
@@ -456,7 +533,7 @@ stream_codex_exec() {
456
533
  local progress_file=""
457
534
  local line=""
458
535
 
459
- last_attempt_start_size="$(stat -f %z "$output_file" 2>/dev/null || printf '0')"
536
+ last_attempt_start_size="$(stat_file_size "$output_file" 2>/dev/null || printf '0')"
460
537
  last_attempt_started_epoch="$(date +%s)"
461
538
  progress_file="${host_run_dir}/.codex-progress.$$"
462
539
  rm -f "$progress_file"
@@ -514,7 +591,7 @@ stream_codex_exec() {
514
591
  break
515
592
  fi
516
593
  else
517
- last_progress_epoch="$(stat -f %m "$progress_file" 2>/dev/null || printf '0')"
594
+ last_progress_epoch="$(stat_file_mtime "$progress_file" 2>/dev/null || printf '0')"
518
595
  if [[ -n "$last_progress_epoch" && "$last_progress_epoch" != "0" ]]; then
519
596
  idle_for=$((now - last_progress_epoch))
520
597
  if (( idle_for >= codex_stall_seconds )); then
@@ -556,7 +633,7 @@ stream_codex_exec() {
556
633
  }
557
634
 
558
635
  extract_thread_id() {
559
- /opt/homebrew/bin/python3 -c '
636
+ "$python_bin" -c '
560
637
  import json
561
638
  import sys
562
639
 
@@ -39,10 +39,16 @@ declare -a context_items=()
39
39
  declare -a collect_files=()
40
40
 
41
41
  resolve_codex_bin() {
42
- local configured_bin="${CODEX_BIN:-}"
42
+ local configured_bin="${CODEX_BIN:-${ACP_CODEX_BIN:-${F_LOSNING_CODEX_BIN:-}}}"
43
43
  local best_version=""
44
44
  local best_bin=""
45
45
  local candidate version_line version
46
+ local -a fallback_paths=(
47
+ "${HOME}/.local/bin/codex"
48
+ "${HOME}/.codex/local/bin/codex"
49
+ "/usr/local/bin/codex"
50
+ "/opt/homebrew/bin/codex"
51
+ )
46
52
 
47
53
  if [[ -n "$configured_bin" && -x "$configured_bin" ]]; then
48
54
  printf '%s\n' "$configured_bin"
@@ -50,11 +56,16 @@ resolve_codex_bin() {
50
56
  fi
51
57
 
52
58
  if command -v codex >/dev/null 2>&1; then
53
- candidate="$(command -v codex)"
59
+ command -v codex
60
+ return 0
61
+ fi
62
+
63
+ for candidate in "${fallback_paths[@]}"; do
54
64
  if [[ -x "$candidate" ]]; then
55
- best_bin="$candidate"
65
+ printf '%s\n' "$candidate"
66
+ return 0
56
67
  fi
57
- fi
68
+ done
58
69
 
59
70
  if [[ -d "${HOME:-}/.nvm/versions/node" ]]; then
60
71
  while IFS= read -r candidate; do
@@ -347,7 +358,7 @@ find_logged_artifact_path() {
347
358
  if [[ "\$(basename "\${candidate}")" == "\${artifact_name}" && -f "\${candidate}" ]]; then
348
359
  printf '%s\n' "\${candidate}"
349
360
  fi
350
- done < <(grep -oE '/(Users|Volumes|tmp)/[^[:space:])"]+' ${output_q} 2>/dev/null || true)
361
+ done < <(grep -oE '/[^[:space:])"]+' ${output_q} 2>/dev/null || true)
351
362
  }
352
363
  recover_logged_artifact() {
353
364
  local artifact_name="\${1:?artifact name required}"
@@ -4,24 +4,366 @@ set -euo pipefail
4
4
  usage() {
5
5
  cat <<'EOF'
6
6
  Usage:
7
- agent-project-run-kilo-session [shared session-wrapper options]
7
+ agent-project-run-kilo-session --mode safe|bypass --session <id> --worktree <path> --prompt-file <path> --runs-root <path> --adapter-id <id> --task-kind <kind> --task-id <id> [options]
8
8
 
9
- Placeholder adapter for the roadmap. The public package ships this command so
10
- ACP can expose and test worker routing for `kilo`, but live session execution
11
- is not implemented yet.
9
+ Launch a Kilo Code worker session inside tmux for a project adapter and persist
10
+ the standard run artifacts.
12
11
 
13
- Use `codex`, `claude`, or `openclaw` for real runs today. See ROADMAP.md for
14
- current backend maturity.
12
+ Kilo is a TypeScript/Bun coding agent (kilocode/cli). It executes via
13
+ `kilo run --auto --format json` in non-interactive mode.
14
+
15
+ Options:
16
+ --env-prefix <prefix> Export prefixed runtime/context env vars inside the worker
17
+ --context <KEY=VALUE> Extra metadata written to run.env and exported to the worker
18
+ --collect-file <name> Copy sandbox artifact file into the host run dir after execution
19
+ --reconcile-command <cmd> Host-side command queued after the worker exits
20
+ --sandbox-subdir <name> Subdir under the worktree for worker artifacts (default: .kilo-artifacts)
21
+ --kilo-model <id> Model in provider/name format (default: anthropic/claude-sonnet-4-20250514)
22
+ --kilo-timeout-seconds <secs> Hard timeout in seconds (default: 900)
23
+ --help Show this help
15
24
  EOF
16
25
  }
17
26
 
18
- case "${1:-}" in
19
- --help|-h)
20
- usage
21
- exit 0
22
- ;;
27
+ mode=""
28
+ session=""
29
+ worktree=""
30
+ prompt_file=""
31
+ runs_root=""
32
+ adapter_id=""
33
+ task_kind=""
34
+ task_id=""
35
+ env_prefix=""
36
+ sandbox_subdir=".kilo-artifacts"
37
+ reconcile_command=""
38
+ kilo_model="${ACP_KILO_MODEL:-${F_LOSNING_KILO_MODEL:-anthropic/claude-sonnet-4-20250514}}"
39
+ kilo_timeout_seconds="${ACP_KILO_TIMEOUT_SECONDS:-${F_LOSNING_KILO_TIMEOUT_SECONDS:-900}}"
40
+ declare -a context_items=()
41
+ declare -a collect_files=()
42
+
43
+ while [[ $# -gt 0 ]]; do
44
+ case "$1" in
45
+ --mode) mode="${2:-}"; shift 2 ;;
46
+ --session) session="${2:-}"; shift 2 ;;
47
+ --worktree) worktree="${2:-}"; shift 2 ;;
48
+ --prompt-file) prompt_file="${2:-}"; shift 2 ;;
49
+ --runs-root) runs_root="${2:-}"; shift 2 ;;
50
+ --adapter-id) adapter_id="${2:-}"; shift 2 ;;
51
+ --task-kind) task_kind="${2:-}"; shift 2 ;;
52
+ --task-id) task_id="${2:-}"; shift 2 ;;
53
+ --env-prefix) env_prefix="${2:-}"; shift 2 ;;
54
+ --context) context_items+=("${2:-}"); shift 2 ;;
55
+ --collect-file) collect_files+=("${2:-}"); shift 2 ;;
56
+ --reconcile-command) reconcile_command="${2:-}"; shift 2 ;;
57
+ --sandbox-subdir) sandbox_subdir="${2:-}"; shift 2 ;;
58
+ --kilo-model) kilo_model="${2:-}"; shift 2 ;;
59
+ --kilo-timeout-seconds) kilo_timeout_seconds="${2:-}"; shift 2 ;;
60
+ --help|-h) usage; exit 0 ;;
61
+ *) echo "Unknown argument: $1" >&2; usage >&2; exit 1 ;;
62
+ esac
63
+ done
64
+
65
+ if [[ -z "$mode" || -z "$session" || -z "$worktree" || -z "$prompt_file" || -z "$runs_root" || -z "$adapter_id" || -z "$task_kind" || -z "$task_id" ]]; then
66
+ usage >&2
67
+ exit 1
68
+ fi
69
+
70
+ case "$mode" in
71
+ safe|bypass) ;;
72
+ *) echo "--mode must be safe or bypass" >&2; exit 1 ;;
73
+ esac
74
+
75
+ case "$kilo_timeout_seconds" in
76
+ ''|*[!0-9]*|0) echo "--kilo-timeout-seconds must be a positive integer" >&2; exit 1 ;;
23
77
  esac
24
78
 
25
- echo "agent-project-run-kilo-session: kilo support is scaffolded, but execution is not implemented yet" >&2
26
- echo "Choose codex, claude, or openclaw for live runs today." >&2
27
- exit 1
79
+ resolve_kilo_bin() {
80
+ local configured_bin="${KILO_BIN:-${ACP_KILO_BIN:-}}"
81
+ if [[ -n "${configured_bin}" && -x "${configured_bin}" ]]; then
82
+ printf '%s\n' "${configured_bin}"
83
+ return 0
84
+ fi
85
+ if command -v kilo >/dev/null 2>&1; then
86
+ command -v kilo
87
+ return 0
88
+ fi
89
+ local -a fallback_paths=(
90
+ "/opt/homebrew/bin/kilo"
91
+ "/usr/local/bin/kilo"
92
+ "${HOME}/.local/bin/kilo"
93
+ "${HOME}/.bun/bin/kilo"
94
+ )
95
+ local p
96
+ for p in "${fallback_paths[@]}"; do
97
+ if [[ -x "${p}" ]]; then
98
+ printf '%s\n' "${p}"
99
+ return 0
100
+ fi
101
+ done
102
+ return 1
103
+ }
104
+
105
+ kilo_bin="$(resolve_kilo_bin || true)"
106
+ if [[ -z "${kilo_bin}" || ! -x "${kilo_bin}" ]]; then
107
+ echo "unable to resolve a runnable kilo binary — install with: npm install -g @kilocode/cli" >&2
108
+ exit 1
109
+ fi
110
+
111
+ artifact_dir="${runs_root}/${session}"
112
+ output_file="${artifact_dir}/${session}.log"
113
+ inner_script="${artifact_dir}/${session}.sh"
114
+ meta_file="${artifact_dir}/run.env"
115
+ result_file="${artifact_dir}/result.env"
116
+ runner_state_file="${artifact_dir}/runner.env"
117
+ sandbox_run_dir="${worktree%/}/${sandbox_subdir}/${session}"
118
+ started_at="$(date -u +"%Y-%m-%dT%H:%M:%SZ")"
119
+
120
+ mkdir -p "$artifact_dir" "$sandbox_run_dir"
121
+
122
+ if tmux has-session -t "$session" 2>/dev/null; then
123
+ echo "tmux session already exists: $session" >&2
124
+ exit 1
125
+ fi
126
+
127
+ branch_name="$(git -C "$worktree" branch --show-current 2>/dev/null || true)"
128
+
129
+ printf -v session_q '%q' "$session"
130
+ printf -v task_kind_q '%q' "$task_kind"
131
+ printf -v task_id_q '%q' "$task_id"
132
+ printf -v mode_q '%q' "$mode"
133
+ printf -v worktree_q '%q' "$worktree"
134
+ printf -v prompt_q '%q' "$prompt_file"
135
+ printf -v output_q '%q' "$output_file"
136
+ printf -v artifact_dir_q '%q' "$artifact_dir"
137
+ printf -v script_q '%q' "$inner_script"
138
+ printf -v result_q '%q' "$result_file"
139
+ printf -v meta_file_q '%q' "$meta_file"
140
+ printf -v runner_state_q '%q' "$runner_state_file"
141
+ printf -v branch_q '%q' "$branch_name"
142
+ printf -v sandbox_run_dir_q '%q' "$sandbox_run_dir"
143
+ printf -v adapter_id_q '%q' "$adapter_id"
144
+ printf -v started_at_q '%q' "$started_at"
145
+ printf -v kilo_bin_q '%q' "$kilo_bin"
146
+ printf -v kilo_model_q '%q' "$kilo_model"
147
+ printf -v kilo_timeout_q '%q' "$kilo_timeout_seconds"
148
+
149
+ {
150
+ printf 'TASK_KIND=%s\n' "$task_kind_q"
151
+ printf 'TASK_ID=%s\n' "$task_id_q"
152
+ printf 'SESSION=%s\n' "$session_q"
153
+ printf 'MODE=%s\n' "$mode_q"
154
+ printf 'WORKTREE=%s\n' "$worktree_q"
155
+ printf 'PROMPT_FILE=%s\n' "$prompt_q"
156
+ printf 'OUTPUT_FILE=%s\n' "$output_q"
157
+ printf 'BRANCH=%s\n' "$branch_q"
158
+ printf 'RESULT_FILE=%s\n' "$result_q"
159
+ printf 'RUNNER_STATE_FILE=%s\n' "$runner_state_q"
160
+ printf 'SANDBOX_RUN_DIR=%s\n' "$sandbox_run_dir_q"
161
+ printf 'ADAPTER_ID=%s\n' "$adapter_id_q"
162
+ printf 'STARTED_AT=%s\n' "$started_at_q"
163
+ printf 'KILO_BIN=%s\n' "$kilo_bin_q"
164
+ printf 'KILO_MODEL=%s\n' "$kilo_model_q"
165
+ printf 'KILO_TIMEOUT_SECONDS=%s\n' "$kilo_timeout_q"
166
+ } >"$meta_file"
167
+
168
+ context_exports=""
169
+ if ((${#context_items[@]} > 0)); then
170
+ for item in "${context_items[@]}"; do
171
+ if [[ "$item" != *=* ]]; then
172
+ echo "--context must use KEY=VALUE syntax: $item" >&2
173
+ exit 1
174
+ fi
175
+ key="${item%%=*}"
176
+ value="${item#*=}"
177
+ if [[ ! "$key" =~ ^[A-Z0-9_]+$ ]]; then
178
+ echo "Invalid context key: $key" >&2
179
+ exit 1
180
+ fi
181
+ printf -v value_q '%q' "$value"
182
+ printf '%s=%s\n' "$key" "$value_q" >>"$meta_file"
183
+ if [[ -n "$env_prefix" ]]; then
184
+ context_exports+="export ${env_prefix}${key}=${value_q}"$'\n'
185
+ fi
186
+ context_exports+="export ACP_${key}=${value_q}"$'\n'
187
+ if [[ "$env_prefix" != "F_LOSNING_" ]]; then
188
+ context_exports+="export F_LOSNING_${key}=${value_q}"$'\n'
189
+ fi
190
+ done
191
+ fi
192
+
193
+ runtime_exports=$(
194
+ cat <<EOF
195
+ export AGENT_PROJECT_SESSION=${session_q}
196
+ export AGENT_PROJECT_RUN_DIR=${sandbox_run_dir_q}
197
+ export AGENT_PROJECT_HOST_RUN_DIR=${artifact_dir_q}
198
+ export AGENT_PROJECT_RESULT_FILE=${sandbox_run_dir_q}/result.env
199
+ export ACP_SESSION=${session_q}
200
+ export ACP_RUN_DIR=${sandbox_run_dir_q}
201
+ export ACP_HOST_RUN_DIR=${artifact_dir_q}
202
+ export ACP_RESULT_FILE=${sandbox_run_dir_q}/result.env
203
+ export F_LOSNING_SESSION=${session_q}
204
+ export F_LOSNING_RUN_DIR=${sandbox_run_dir_q}
205
+ export F_LOSNING_HOST_RUN_DIR=${artifact_dir_q}
206
+ export F_LOSNING_RESULT_FILE=${sandbox_run_dir_q}/result.env
207
+ EOF
208
+ )
209
+
210
+ if [[ -n "$env_prefix" ]]; then
211
+ runtime_exports+=$'\n'
212
+ runtime_exports+=$(cat <<EOF
213
+ export ${env_prefix}SESSION=${session_q}
214
+ export ${env_prefix}RUN_DIR=${sandbox_run_dir_q}
215
+ export ${env_prefix}HOST_RUN_DIR=${artifact_dir_q}
216
+ export ${env_prefix}RESULT_FILE=${sandbox_run_dir_q}/result.env
217
+ EOF
218
+ )
219
+ fi
220
+
221
+ collect_copy_snippet=""
222
+ if ((${#collect_files[@]} > 0)); then
223
+ for artifact_name in "${collect_files[@]}"; do
224
+ [[ -z "$artifact_name" ]] && continue
225
+ printf -v artifact_q '%q' "$artifact_name"
226
+ collect_copy_snippet+=$(cat <<EOF
227
+ if [[ -f ${sandbox_run_dir_q}/${artifact_q} ]]; then
228
+ cp ${sandbox_run_dir_q}/${artifact_q} ${artifact_dir_q}/${artifact_q}
229
+ fi
230
+ EOF
231
+ )
232
+ collect_copy_snippet+=$'\n'
233
+ done
234
+ fi
235
+
236
+ # Always collect result.env from sandbox to artifact_dir
237
+ collect_copy_snippet+=$(
238
+ cat <<EOF
239
+ if [[ -f ${sandbox_run_dir_q}/result.env ]]; then
240
+ cp ${sandbox_run_dir_q}/result.env ${artifact_dir_q}/result.env
241
+ fi
242
+ EOF
243
+ )
244
+ collect_copy_snippet+=$'\n'
245
+
246
+ reconcile_snippet=""
247
+ if [[ -n "$reconcile_command" ]]; then
248
+ printf -v delayed_reconcile_q '%q' "export ACP_EXPECTED_RUN_STARTED_AT=${started_at_q}; export F_LOSNING_EXPECTED_RUN_STARTED_AT=${started_at_q}; while tmux has-session -t ${session_q} 2>/dev/null; do sleep 1; done; sleep 2; $reconcile_command"
249
+ reconcile_snippet="nohup bash -lc ${delayed_reconcile_q} >> ${output_q} 2>&1 </dev/null &"
250
+ fi
251
+
252
+ cat >"$inner_script" <<EOF
253
+ #!/usr/bin/env bash
254
+ set -euo pipefail
255
+ ${runtime_exports}
256
+ ${context_exports}cd ${worktree_q}
257
+
258
+ runner_state_file=${runner_state_q}
259
+ output_file=${output_q}
260
+ sandbox_run_dir=${sandbox_run_dir_q}
261
+ artifact_dir=${artifact_dir_q}
262
+ result_file_path=${sandbox_run_dir_q}/result.env
263
+ host_result_file=${result_q}
264
+ kilo_bin=${kilo_bin_q}
265
+ kilo_model=${kilo_model_q}
266
+ kilo_timeout=${kilo_timeout_q}
267
+ prompt_file=${prompt_q}
268
+ worktree=${worktree_q}
269
+
270
+ write_state() {
271
+ local runner_state="\${1:?runner state required}"
272
+ local last_exit_code="\${2:-}"
273
+ local failure_reason="\${3:-}"
274
+ local updated_at tmp_file
275
+
276
+ updated_at="\$(date -u +"%Y-%m-%dT%H:%M:%SZ")"
277
+ tmp_file="\${runner_state_file}.tmp.\$\$"
278
+ {
279
+ printf 'RUNNER_STATE=%q\n' "\${runner_state}"
280
+ printf 'ATTEMPT=1\n'
281
+ printf 'RESUME_COUNT=0\n'
282
+ printf 'LAST_EXIT_CODE=%q\n' "\${last_exit_code}"
283
+ printf 'LAST_FAILURE_REASON=%q\n' "\${failure_reason}"
284
+ printf 'UPDATED_AT=%q\n' "\${updated_at}"
285
+ } >"\${tmp_file}"
286
+ mv "\${tmp_file}" "\${runner_state_file}"
287
+ }
288
+
289
+ record_final_git_state() {
290
+ local final_head final_branch tmp_file
291
+ final_head="\$(git -C ${worktree_q} rev-parse HEAD 2>/dev/null || true)"
292
+ final_branch="\$(git -C ${worktree_q} branch --show-current 2>/dev/null || true)"
293
+ tmp_file=${meta_file_q}.tmp.final.\$\$
294
+ grep -vE '^(FINAL_HEAD|FINAL_BRANCH)=' ${meta_file_q} >"\${tmp_file}" 2>/dev/null || true
295
+ {
296
+ printf 'FINAL_HEAD=%q\n' "\${final_head}"
297
+ printf 'FINAL_BRANCH=%q\n' "\${final_branch}"
298
+ } >>"\${tmp_file}"
299
+ mv "\${tmp_file}" ${meta_file_q}
300
+ }
301
+
302
+ write_state running
303
+
304
+ mkdir -p "\${sandbox_run_dir}"
305
+
306
+ # Kilo runs via 'kilo run' in non-interactive mode.
307
+ # --auto auto-approves all tool permissions (CI mode).
308
+ # --format json emits structured JSON events (parseable for result inference).
309
+ # --model selects the provider/model, --dir sets the working directory.
310
+ # Prompt is passed as the positional argument.
311
+ prompt_content="\$(cat "\${prompt_file}")"
312
+ kilo_exit_code=0
313
+ kilo_args=(run --auto --format json --model "\${kilo_model}" --dir ${worktree_q})
314
+
315
+ if command -v timeout >/dev/null 2>&1; then
316
+ timeout "\${kilo_timeout}" "\${kilo_bin}" "\${kilo_args[@]}" "\${prompt_content}" 2>&1 | tee -a "\${output_file}" || kilo_exit_code=\$?
317
+ elif command -v gtimeout >/dev/null 2>&1; then
318
+ gtimeout "\${kilo_timeout}" "\${kilo_bin}" "\${kilo_args[@]}" "\${prompt_content}" 2>&1 | tee -a "\${output_file}" || kilo_exit_code=\$?
319
+ else
320
+ "\${kilo_bin}" "\${kilo_args[@]}" "\${prompt_content}" 2>&1 | tee -a "\${output_file}" &
321
+ _kilo_pid=\$!
322
+ ( sleep "\${kilo_timeout}" && kill "\${_kilo_pid}" 2>/dev/null ) &
323
+ _wd=\$!
324
+ wait "\${_kilo_pid}" || kilo_exit_code=\$?
325
+ kill "\${_wd}" 2>/dev/null || true; wait "\${_wd}" 2>/dev/null || true
326
+ [[ "\${kilo_exit_code}" -eq 143 ]] && kilo_exit_code=124
327
+ fi
328
+
329
+ if [[ "\${kilo_exit_code}" -eq 0 ]]; then
330
+ write_state succeeded 0
331
+ # Kilo has full tool access (bash, write, edit) — it can modify the worktree.
332
+ # Infer result from git state if result.env was not written by the agent.
333
+ if [[ ! -f "\${result_file_path}" ]]; then
334
+ if git -C ${worktree_q} diff --name-only HEAD 2>/dev/null | grep -qvE '\.md$' 2>/dev/null \\
335
+ || git -C ${worktree_q} diff --cached --name-only 2>/dev/null | grep -qvE '\.md$' 2>/dev/null \\
336
+ || git -C ${worktree_q} diff --name-only origin/main..HEAD 2>/dev/null | grep -qvE '\.md$' 2>/dev/null; then
337
+ printf 'OUTCOME=implemented\nACTION=host-publish-issue-pr\n' >"\${result_file_path}"
338
+ else
339
+ printf 'OUTCOME=blocked\nACTION=host-comment-blocker\nDETAIL=missing-result-contract\n' >"\${result_file_path}"
340
+ fi
341
+ fi
342
+ else
343
+ failure_reason="kilo-exit-\${kilo_exit_code}"
344
+ [[ "\${kilo_exit_code}" -eq 124 ]] && failure_reason="timeout"
345
+ write_state failed "\${kilo_exit_code}" "\${failure_reason}"
346
+ if [[ ! -f "\${result_file_path}" ]]; then
347
+ printf 'OUTCOME=blocked\nACTION=host-comment-blocker\nDETAIL=%s\n' "\${failure_reason}" >"\${result_file_path}"
348
+ fi
349
+ fi
350
+
351
+ record_final_git_state
352
+
353
+ if [[ -f "\${result_file_path}" ]]; then
354
+ cp "\${result_file_path}" "\${host_result_file}"
355
+ fi
356
+ ${collect_copy_snippet}
357
+ ${reconcile_snippet}
358
+ printf '\n__CODEX_EXIT__:%s\n' "\${kilo_exit_code}" | tee -a "\${output_file}"
359
+ exit "\${kilo_exit_code}"
360
+ EOF
361
+
362
+ chmod +x "$inner_script"
363
+ tmux new-session -d -s "$session" "$inner_script"
364
+
365
+ printf 'SESSION=%s\n' "$session"
366
+ printf 'TASK_KIND=%s\n' "$task_kind"
367
+ printf 'TASK_ID=%s\n' "$task_id"
368
+ printf 'WORKTREE=%s\n' "$worktree"
369
+ printf 'OUTPUT=%s\n' "$output_file"