agent-control-plane 0.1.7 → 0.1.8

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.
@@ -7,6 +7,7 @@ source "${HOOK_SCRIPT_DIR}/../tools/bin/flow-config-lib.sh"
7
7
 
8
8
  FLOW_SKILL_DIR="$(cd "${HOOK_SCRIPT_DIR}/.." && pwd)"
9
9
  CONFIG_YAML="$(resolve_flow_config_yaml "${BASH_SOURCE[0]}")"
10
+ PROFILE_ID="$(flow_resolve_adapter_id "${CONFIG_YAML}")"
10
11
  ADAPTER_BIN_DIR="${FLOW_SKILL_DIR}/bin"
11
12
  FLOW_TOOLS_DIR="${FLOW_SKILL_DIR}/tools/bin"
12
13
  REPO_SLUG="$(flow_resolve_repo_slug "${CONFIG_YAML}")"
@@ -15,6 +16,12 @@ STATE_ROOT="$(flow_resolve_state_root "${CONFIG_YAML}")"
15
16
  RUNS_ROOT="$(flow_resolve_runs_root "${CONFIG_YAML}")"
16
17
  BLOCKED_RECOVERY_STATE_DIR="${STATE_ROOT}/blocked-recovery-issues"
17
18
 
19
+ issue_kick_scheduler() {
20
+ ACP_PROJECT_ID="${PROFILE_ID}" \
21
+ AGENT_PROJECT_ID="${PROFILE_ID}" \
22
+ "${FLOW_TOOLS_DIR}/kick-scheduler.sh" "${1:-2}" >/dev/null || true
23
+ }
24
+
18
25
  issue_clear_blocked_recovery_state() {
19
26
  rm -f "${BLOCKED_RECOVERY_STATE_DIR}/${ISSUE_ID}.env" 2>/dev/null || true
20
27
  }
@@ -194,7 +201,7 @@ issue_after_pr_created() {
194
201
  if [[ "$(jq -r '.eligibleForAutoMerge' <<<"$risk_json")" == "true" ]]; then
195
202
  bash "${FLOW_TOOLS_DIR}/agent-github-update-labels" --repo-slug "${REPO_SLUG}" --number "$pr_number" --add agent-automerge >/dev/null || true
196
203
  fi
197
- "${FLOW_TOOLS_DIR}/kick-scheduler.sh" 5 >/dev/null || true
204
+ issue_kick_scheduler 5
198
205
  }
199
206
 
200
207
  issue_after_reconciled() {
@@ -213,5 +220,5 @@ issue_after_reconciled() {
213
220
  esac
214
221
  fi
215
222
 
216
- "${FLOW_TOOLS_DIR}/kick-scheduler.sh" 2 >/dev/null || true
223
+ issue_kick_scheduler 2
217
224
  }
@@ -7,6 +7,7 @@ source "${HOOK_SCRIPT_DIR}/../tools/bin/flow-config-lib.sh"
7
7
 
8
8
  FLOW_SKILL_DIR="$(cd "${HOOK_SCRIPT_DIR}/.." && pwd)"
9
9
  CONFIG_YAML="$(resolve_flow_config_yaml "${BASH_SOURCE[0]}")"
10
+ PROFILE_ID="$(flow_resolve_adapter_id "${CONFIG_YAML}")"
10
11
  ADAPTER_BIN_DIR="${FLOW_SKILL_DIR}/bin"
11
12
  FLOW_TOOLS_DIR="${FLOW_SKILL_DIR}/tools/bin"
12
13
  RUNS_ROOT="$(flow_resolve_runs_root "${CONFIG_YAML}")"
@@ -19,6 +20,12 @@ ISSUE_SESSION_PREFIX="$(flow_resolve_issue_session_prefix "${CONFIG_YAML}")"
19
20
  PR_WORKTREE_BRANCH_PREFIX="$(flow_resolve_pr_worktree_branch_prefix "${CONFIG_YAML}")"
20
21
  PR_LANE_OVERRIDE_DIR="${STATE_ROOT}/pr-lane-overrides"
21
22
 
23
+ pr_kick_scheduler() {
24
+ ACP_PROJECT_ID="${PROFILE_ID}" \
25
+ AGENT_PROJECT_ID="${PROFILE_ID}" \
26
+ "${FLOW_TOOLS_DIR}/kick-scheduler.sh" "${1:-2}" >/dev/null || true
27
+ }
28
+
22
29
  pr_best_effort_update_labels() {
23
30
  bash "${FLOW_TOOLS_DIR}/agent-github-update-labels" "$@" >/dev/null 2>&1 || true
24
31
  }
@@ -131,14 +138,14 @@ pr_after_merged() {
131
138
  pr_clear_lane_override "$pr_number"
132
139
  pr_best_effort_update_labels --repo-slug "${REPO_SLUG}" --number "$pr_number" --remove agent-running --remove agent-automerge --remove agent-repair-queued --remove agent-fix-needed --remove agent-manual-fix-override --remove agent-ci-refresh --remove agent-ci-bypassed --remove agent-double-check-1/2 --remove agent-double-check-2/2 --remove agent-human-review --remove agent-human-approved --remove agent-blocked --remove agent-handoff --remove agent-exclusive
133
140
  pr_refresh_linked_issue_checklist "$pr_number"
134
- "${FLOW_TOOLS_DIR}/kick-scheduler.sh" 5 >/dev/null || true
141
+ pr_kick_scheduler 5
135
142
  }
136
143
 
137
144
  pr_after_closed() {
138
145
  local pr_number="${1:?pr number required}"
139
146
  pr_clear_lane_override "$pr_number"
140
147
  pr_best_effort_update_labels --repo-slug "${REPO_SLUG}" --number "$pr_number" --remove agent-running --remove agent-automerge --remove agent-repair-queued --remove agent-fix-needed --remove agent-manual-fix-override --remove agent-ci-refresh --remove agent-ci-bypassed --remove agent-double-check-1/2 --remove agent-double-check-2/2 --remove agent-human-review --remove agent-human-approved --remove agent-blocked --remove agent-handoff --remove agent-exclusive
141
- "${FLOW_TOOLS_DIR}/kick-scheduler.sh" 5 >/dev/null || true
148
+ pr_kick_scheduler 5
142
149
  }
143
150
 
144
151
  pr_automerge_allowed() {
@@ -189,7 +196,7 @@ pr_after_double_check_advanced() {
189
196
  pr_set_lane_override "$pr_number" "double-check-${next_stage}"
190
197
  pr_best_effort_update_labels --repo-slug "${REPO_SLUG}" --number "$pr_number" --remove agent-running --remove agent-automerge --remove agent-repair-queued --remove agent-fix-needed --remove agent-manual-fix-override --remove agent-ci-refresh --remove agent-human-review --remove agent-human-approved --remove agent-double-check-1/2 --remove agent-double-check-2/2 --add "$next_label"
191
198
  pr_best_effort_sync_pr_labels "$pr_number"
192
- "${FLOW_TOOLS_DIR}/kick-scheduler.sh" 5 >/dev/null || true
199
+ pr_kick_scheduler 5
193
200
  }
194
201
 
195
202
  pr_after_updated_branch() {
@@ -221,5 +228,5 @@ pr_after_failed() {
221
228
  }
222
229
 
223
230
  pr_after_reconciled() {
224
- "${FLOW_TOOLS_DIR}/kick-scheduler.sh" 2 >/dev/null || true
231
+ pr_kick_scheduler 2
225
232
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-control-plane",
3
- "version": "0.1.7",
3
+ "version": "0.1.8",
4
4
  "description": "Help a repo keep GitHub-driven coding agents running reliably without constant human babysitting",
5
5
  "homepage": "https://github.com/ducminhnguyen0319/agent-control-plane",
6
6
  "bugs": {
@@ -40,7 +40,7 @@
40
40
  "scripts": {
41
41
  "doctor": "node ./npm/bin/agent-control-plane.js doctor",
42
42
  "smoke": "node ./npm/bin/agent-control-plane.js smoke",
43
- "test": "bash tools/tests/test-agent-control-plane-npm-cli.sh && bash tools/tests/test-profile-adopt-skip-anchor-sync-creates-agent-repo-root.sh && bash tools/tests/test-vendored-codex-quota-claude-oauth-only.sh && bash tools/tests/test-package-smoke-command.sh"
43
+ "test": "bash tools/tests/test-agent-control-plane-npm-cli.sh && bash tools/tests/test-agent-project-detached-launch-stable-cwd.sh && bash tools/tests/test-agent-project-claude-session-wrapper-reaps-child-on-term.sh && bash tools/tests/test-agent-project-claude-session-wrapper-does-not-retry-provider-quota.sh && bash tools/tests/test-agent-project-reconcile-issue-provider-quota-schedules-provider-cooldown.sh && bash tools/tests/test-pr-reconcile-hooks-refreshes-recurring-issue-checklist.sh && bash tools/tests/test-issue-reconcile-hooks-kick-scheduler-uses-profile.sh && bash tools/tests/test-profile-adopt-skip-anchor-sync-creates-agent-repo-root.sh && bash tools/tests/test-vendored-codex-quota-claude-oauth-only.sh && bash tools/tests/test-package-smoke-command.sh"
44
44
  },
45
45
  "keywords": [
46
46
  "agents",
@@ -6,6 +6,8 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
6
6
  source "${SCRIPT_DIR}/flow-config-lib.sh"
7
7
  CONFIG_YAML="$(resolve_flow_config_yaml "${BASH_SOURCE[0]}")"
8
8
  STATE_ROOT="$(flow_resolve_state_root "${CONFIG_YAML}")"
9
+ AGENT_REPO_ROOT="$(flow_resolve_agent_repo_root "${CONFIG_YAML}")"
10
+ REPO_ROOT="$(flow_resolve_repo_root "${CONFIG_YAML}")"
9
11
 
10
12
  usage() {
11
13
  cat <<'EOF'
@@ -66,6 +68,21 @@ if [[ -n "${pending_key}" ]]; then
66
68
  pending_file="${pending_dir}/${pending_key}.pid"
67
69
  fi
68
70
 
71
+ launch_cwd="${ACP_LAUNCH_CWD:-${F_LOSNING_LAUNCH_CWD:-}}"
72
+ if [[ -z "${launch_cwd}" ]]; then
73
+ for candidate in "${AGENT_REPO_ROOT}" "${REPO_ROOT}" "${STATE_ROOT}" "${HOME:-}" "/"; do
74
+ if [[ -n "${candidate}" && -d "${candidate}" ]]; then
75
+ launch_cwd="${candidate}"
76
+ break
77
+ fi
78
+ done
79
+ fi
80
+
81
+ if [[ -z "${launch_cwd}" || ! -d "${launch_cwd}" ]]; then
82
+ echo "could not determine a stable working directory for detached launch" >&2
83
+ exit 1
84
+ fi
85
+
69
86
  python_bin="${PYTHON_BIN:-$(command -v python3 || true)}"
70
87
  if [[ -z "${python_bin}" ]]; then
71
88
  echo "python3 is required for detached launch" >&2
@@ -73,14 +90,15 @@ if [[ -z "${python_bin}" ]]; then
73
90
  fi
74
91
 
75
92
  launch_pid="$(
76
- "${python_bin}" - "${log_file}" "${pending_file}" "$@" <<'PY'
93
+ "${python_bin}" - "${log_file}" "${pending_file}" "${launch_cwd}" "$@" <<'PY'
77
94
  import subprocess
78
95
  import sys
79
96
  from pathlib import Path
80
97
 
81
98
  log_file = Path(sys.argv[1])
82
99
  pending_file = sys.argv[2]
83
- argv = sys.argv[3:]
100
+ launch_cwd = sys.argv[3]
101
+ argv = sys.argv[4:]
84
102
 
85
103
  with log_file.open("ab", buffering=0) as log_handle:
86
104
  proc = subprocess.Popen(
@@ -88,6 +106,7 @@ with log_file.open("ab", buffering=0) as log_handle:
88
106
  stdin=subprocess.DEVNULL,
89
107
  stdout=log_handle,
90
108
  stderr=subprocess.STDOUT,
109
+ cwd=launch_cwd,
91
110
  start_new_session=True,
92
111
  )
93
112
 
@@ -102,6 +121,7 @@ printf 'LAUNCH_MODE=detached\n'
102
121
  printf 'LAUNCH_NAME=%s\n' "${launch_name}"
103
122
  printf 'LAUNCH_PID=%s\n' "${launch_pid}"
104
123
  printf 'LAUNCH_LOG=%s\n' "${log_file}"
124
+ printf 'LAUNCH_CWD=%s\n' "${launch_cwd}"
105
125
  if [[ -n "${pending_file}" ]]; then
106
126
  printf 'LAUNCH_PENDING_FILE=%s\n' "${pending_file}"
107
127
  fi
@@ -794,6 +794,41 @@ ${publish_out}
794
794
  EOF
795
795
  }
796
796
 
797
+ build_issue_runtime_blocker_comment() {
798
+ local runtime_reason="${1:-worker-exit-failed}"
799
+ local worker_name="${CODING_WORKER:-worker}"
800
+
801
+ case "${runtime_reason}" in
802
+ provider-quota-limit)
803
+ cat <<EOF
804
+ # Blocker: Provider quota is currently exhausted
805
+
806
+ This recurring run stopped before implementation because the configured ${worker_name} account hit a provider-side rate limit.
807
+
808
+ Why it was blocked:
809
+ - the worker reached Anthropic's current request limit for this account
810
+ - ACP recorded the quota hit and will retry after the configured cooldown instead of looping indefinitely
811
+
812
+ Next step:
813
+ - wait for the current quota window to reset, or switch this profile to another available provider/account
814
+ EOF
815
+ return 0
816
+ ;;
817
+ esac
818
+
819
+ cat <<EOF
820
+ # Blocker: Worker session failed before publish
821
+
822
+ The worker exited before ACP could publish or reconcile a result for this cycle.
823
+
824
+ Failure reason:
825
+ - \`${runtime_reason}\`
826
+
827
+ Next step:
828
+ - inspect the run logs for this session and re-queue once the underlying worker issue is resolved
829
+ EOF
830
+ }
831
+
797
832
  extract_recovery_worktree_from_publish_output() {
798
833
  local publish_out="${1:-}"
799
834
  awk -F= '/^RECOVERY_WORKTREE=/{print $2}' <<<"$publish_out" | tail -n 1
@@ -1106,9 +1141,17 @@ case "$status" in
1106
1141
  failure_reason="${failure_reason:-worker-exit-failed}"
1107
1142
  schedule_provider_quota_cooldown "${failure_reason}"
1108
1143
  normalize_issue_runner_state "failed" "${LAST_EXIT_CODE:-}" "${failure_reason}"
1144
+ if [[ "${result_outcome:-}" == "blocked" && "${result_action:-}" == "host-comment-blocker" ]]; then
1145
+ if [[ ! -s "${run_dir}/issue-comment.md" ]]; then
1146
+ write_issue_comment_artifact "$(build_issue_runtime_blocker_comment "${failure_reason}")" || true
1147
+ fi
1148
+ post_issue_comment_if_present
1149
+ issue_set_reconcile_summary "$status" "$result_outcome" "$result_action" "$failure_reason"
1150
+ else
1151
+ issue_set_reconcile_summary "$status" "" "" "$failure_reason"
1152
+ fi
1109
1153
  require_transition "issue_schedule_retry" issue_schedule_retry "${failure_reason}"
1110
1154
  require_transition "issue_mark_ready" issue_mark_ready
1111
- issue_set_reconcile_summary "$status" "" "" "$failure_reason"
1112
1155
  cleanup_issue_session
1113
1156
  notify_issue_reconciled
1114
1157
  ;;
@@ -1120,6 +1163,12 @@ mark_reconciled
1120
1163
  printf 'STATUS=%s\n' "$status"
1121
1164
  printf 'ISSUE_ID=%s\n' "$issue_id"
1122
1165
  printf 'PR_NUMBER=%s\n' "$pr_number"
1166
+ if [[ -n "${issue_summary_outcome:-}" ]]; then
1167
+ printf 'OUTCOME=%s\n' "${issue_summary_outcome}"
1168
+ fi
1169
+ if [[ -n "${issue_summary_action:-}" ]]; then
1170
+ printf 'ACTION=%s\n' "${issue_summary_action}"
1171
+ fi
1123
1172
  if [[ -n "$failure_reason" ]]; then
1124
1173
  printf 'FAILURE_REASON=%s\n' "$failure_reason"
1125
1174
  fi
@@ -11,12 +11,13 @@ persist the standard run artifacts.
11
11
 
12
12
  Options:
13
13
  --claude-model <name> Claude model alias or full name
14
- --claude-permission-mode <mode> Claude permission mode (e.g. dontAsk, bypassPermissions)
14
+ --claude-permission-mode <mode> Claude permission mode (e.g. acceptEdits, bypassPermissions)
15
15
  --claude-effort <level> Claude effort level (low, medium, high, max)
16
16
  --claude-timeout-seconds <secs> Claude command timeout (default: 900)
17
17
  --claude-max-attempts <count> Retry transient failures this many times (default: 3)
18
18
  --claude-retry-backoff-seconds <s>
19
19
  Sleep between transient retries (default: 30)
20
+ --claude-allowed-tools <spec> Allowed Claude tools for headless runs
20
21
  --env-prefix <prefix> Export prefixed runtime/context env vars inside the worker
21
22
  --context <KEY=VALUE> Extra metadata written to run.env and exported to the worker
22
23
  --collect-file <name> Copy sandbox artifact file into the host run dir after execution
@@ -35,11 +36,12 @@ adapter_id=""
35
36
  task_kind=""
36
37
  task_id=""
37
38
  claude_model="${ACP_CLAUDE_MODEL:-${F_LOSNING_CLAUDE_MODEL:-sonnet}}"
38
- claude_permission_mode="${ACP_CLAUDE_PERMISSION_MODE:-${F_LOSNING_CLAUDE_PERMISSION_MODE:-dontAsk}}"
39
+ claude_permission_mode="${ACP_CLAUDE_PERMISSION_MODE:-${F_LOSNING_CLAUDE_PERMISSION_MODE:-acceptEdits}}"
39
40
  claude_effort="${ACP_CLAUDE_EFFORT:-${F_LOSNING_CLAUDE_EFFORT:-medium}}"
40
41
  claude_timeout_seconds="${ACP_CLAUDE_TIMEOUT_SECONDS:-${F_LOSNING_CLAUDE_TIMEOUT_SECONDS:-900}}"
41
42
  claude_max_attempts="${ACP_CLAUDE_MAX_ATTEMPTS:-${F_LOSNING_CLAUDE_MAX_ATTEMPTS:-3}}"
42
43
  claude_retry_backoff_seconds="${ACP_CLAUDE_RETRY_BACKOFF_SECONDS:-${F_LOSNING_CLAUDE_RETRY_BACKOFF_SECONDS:-30}}"
44
+ claude_allowed_tools="${ACP_CLAUDE_ALLOWED_TOOLS:-${F_LOSNING_CLAUDE_ALLOWED_TOOLS:-Bash(*),Read,Grep,Glob,LS,Edit,Write,MultiEdit}}"
43
45
  env_prefix=""
44
46
  sandbox_subdir=".openclaw-artifacts"
45
47
  reconcile_command=""
@@ -94,6 +96,7 @@ while [[ $# -gt 0 ]]; do
94
96
  --claude-timeout-seconds) claude_timeout_seconds="${2:-}"; shift 2 ;;
95
97
  --claude-max-attempts) claude_max_attempts="${2:-}"; shift 2 ;;
96
98
  --claude-retry-backoff-seconds) claude_retry_backoff_seconds="${2:-}"; shift 2 ;;
99
+ --claude-allowed-tools) claude_allowed_tools="${2:-}"; shift 2 ;;
97
100
  --env-prefix) env_prefix="${2:-}"; shift 2 ;;
98
101
  --context) context_items+=("${2:-}"); shift 2 ;;
99
102
  --collect-file) collect_files+=("${2:-}"); shift 2 ;;
@@ -156,11 +159,31 @@ meta_file="${artifact_dir}/run.env"
156
159
  result_file="${artifact_dir}/result.env"
157
160
  runner_state_file="${artifact_dir}/runner.env"
158
161
  sandbox_run_dir="${worktree%/}/${sandbox_subdir}/${session}"
162
+ claude_settings_file="${artifact_dir}/claude-headless-settings.json"
163
+ claude_mcp_config_file="${artifact_dir}/claude-headless-mcp.json"
164
+ claude_debug_file="${artifact_dir}/claude-debug.log"
159
165
  started_at="$(date -u +"%Y-%m-%dT%H:%M:%SZ")"
160
166
 
161
167
  mkdir -p "$artifact_dir"
162
168
  mkdir -p "$sandbox_run_dir"
163
169
 
170
+ effective_claude_permission_mode="${claude_permission_mode}"
171
+ if [[ "${effective_claude_permission_mode}" == "dontAsk" ]]; then
172
+ effective_claude_permission_mode="acceptEdits"
173
+ fi
174
+
175
+ cat >"$claude_settings_file" <<'EOF'
176
+ {
177
+ "disableAllHooks": true
178
+ }
179
+ EOF
180
+
181
+ cat >"$claude_mcp_config_file" <<'EOF'
182
+ {
183
+ "mcpServers": {}
184
+ }
185
+ EOF
186
+
164
187
  if tmux has-session -t "$session" 2>/dev/null; then
165
188
  echo "tmux session already exists: $session" >&2
166
189
  exit 1
@@ -187,10 +210,15 @@ printf -v started_at_q '%q' "$started_at"
187
210
  printf -v claude_bin_q '%q' "$claude_bin"
188
211
  printf -v claude_model_q '%q' "$claude_model"
189
212
  printf -v claude_permission_mode_q '%q' "$claude_permission_mode"
213
+ printf -v claude_effective_permission_mode_q '%q' "$effective_claude_permission_mode"
190
214
  printf -v claude_effort_q '%q' "$claude_effort"
191
215
  printf -v claude_timeout_q '%q' "$claude_timeout_seconds"
192
216
  printf -v claude_max_attempts_q '%q' "$claude_max_attempts"
193
217
  printf -v claude_retry_backoff_q '%q' "$claude_retry_backoff_seconds"
218
+ printf -v claude_allowed_tools_q '%q' "$claude_allowed_tools"
219
+ printf -v claude_settings_file_q '%q' "$claude_settings_file"
220
+ printf -v claude_mcp_config_file_q '%q' "$claude_mcp_config_file"
221
+ printf -v claude_debug_file_q '%q' "$claude_debug_file"
194
222
  printf -v python_bin_q '%q' "$python_bin"
195
223
  printf -v sandbox_subdir_q '%q' "$sandbox_subdir"
196
224
  printf -v claude_thread_id_q '%q' "claude-print-${session}"
@@ -213,10 +241,15 @@ printf -v claude_thread_id_q '%q' "claude-print-${session}"
213
241
  printf 'CLAUDE_BIN=%s\n' "$claude_bin_q"
214
242
  printf 'CLAUDE_MODEL=%s\n' "$claude_model_q"
215
243
  printf 'CLAUDE_PERMISSION_MODE=%s\n' "$claude_permission_mode_q"
244
+ printf 'CLAUDE_EFFECTIVE_PERMISSION_MODE=%s\n' "$claude_effective_permission_mode_q"
216
245
  printf 'CLAUDE_EFFORT=%s\n' "$claude_effort_q"
217
246
  printf 'CLAUDE_TIMEOUT_SECONDS=%s\n' "$claude_timeout_q"
218
247
  printf 'CLAUDE_MAX_ATTEMPTS=%s\n' "$claude_max_attempts_q"
219
248
  printf 'CLAUDE_RETRY_BACKOFF_SECONDS=%s\n' "$claude_retry_backoff_q"
249
+ printf 'CLAUDE_ALLOWED_TOOLS=%s\n' "$claude_allowed_tools_q"
250
+ printf 'CLAUDE_SETTINGS_FILE=%s\n' "$claude_settings_file_q"
251
+ printf 'CLAUDE_MCP_CONFIG_FILE=%s\n' "$claude_mcp_config_file_q"
252
+ printf 'CLAUDE_DEBUG_FILE=%s\n' "$claude_debug_file_q"
220
253
  printf 'PYTHON_BIN=%s\n' "$python_bin_q"
221
254
  } >"$meta_file"
222
255
 
@@ -334,14 +367,20 @@ host_result_file=${result_q}
334
367
  claude_bin=${claude_bin_q}
335
368
  claude_model=${claude_model_q}
336
369
  claude_permission_mode=${claude_permission_mode_q}
370
+ claude_effective_permission_mode=${claude_effective_permission_mode_q}
337
371
  claude_effort=${claude_effort_q}
338
372
  claude_timeout_seconds=${claude_timeout_q}
339
373
  claude_max_attempts=${claude_max_attempts_q}
340
374
  claude_retry_backoff_seconds=${claude_retry_backoff_q}
375
+ claude_allowed_tools=${claude_allowed_tools_q}
376
+ claude_settings_file=${claude_settings_file_q}
377
+ claude_mcp_config_file=${claude_mcp_config_file_q}
378
+ claude_debug_file=${claude_debug_file_q}
341
379
  python_bin=${python_bin_q}
342
380
  worktree_root=${worktree_q}
343
381
  sandbox_subdir=${sandbox_subdir_q}
344
382
  prompt_file=${prompt_q}
383
+ export CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1
345
384
 
346
385
  write_state() {
347
386
  local runner_state="\${1:?runner state required}"
@@ -370,48 +409,119 @@ write_state() {
370
409
 
371
410
  run_with_timeout() {
372
411
  local timeout_seconds="\${1:?timeout seconds required}"
412
+ local stdin_file="\${2:?stdin file required}"
413
+ shift
373
414
  shift
374
415
 
375
- "\${python_bin}" - "\${timeout_seconds}" "\$@" <<'PY'
416
+ "\${python_bin}" - "\${timeout_seconds}" "\${stdin_file}" "\$@" <<'PY'
417
+ import errno
418
+ import fcntl
376
419
  import os
420
+ import selectors
377
421
  import signal
378
422
  import subprocess
379
423
  import sys
424
+ import time
380
425
 
381
426
  timeout_seconds = float(sys.argv[1])
382
- argv = sys.argv[2:]
427
+ stdin_path = sys.argv[2]
428
+ argv = sys.argv[3:]
383
429
 
384
430
  if not argv:
385
431
  sys.exit(64)
386
432
 
387
- proc = subprocess.Popen(argv, start_new_session=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
433
+ stdin_handle = open(stdin_path, "rb")
434
+ proc = subprocess.Popen(
435
+ argv,
436
+ start_new_session=True,
437
+ stdin=stdin_handle,
438
+ stdout=subprocess.PIPE,
439
+ stderr=subprocess.PIPE,
440
+ )
388
441
 
389
- try:
390
- stdout, stderr = proc.communicate(timeout=timeout_seconds)
391
- except subprocess.TimeoutExpired:
442
+ for stream in (proc.stdout, proc.stderr):
443
+ if stream is None:
444
+ continue
445
+ flags = fcntl.fcntl(stream.fileno(), fcntl.F_GETFL)
446
+ fcntl.fcntl(stream.fileno(), fcntl.F_SETFL, flags | os.O_NONBLOCK)
447
+
448
+ selector = selectors.DefaultSelector()
449
+ if proc.stdout is not None:
450
+ selector.register(proc.stdout, selectors.EVENT_READ, sys.stdout.buffer)
451
+ if proc.stderr is not None:
452
+ selector.register(proc.stderr, selectors.EVENT_READ, sys.stderr.buffer)
453
+
454
+ def terminate_process_group(sig):
392
455
  try:
393
- os.killpg(proc.pid, signal.SIGTERM)
456
+ os.killpg(proc.pid, sig)
394
457
  except ProcessLookupError:
395
- pass
396
- try:
397
- stdout, stderr = proc.communicate(timeout=2)
398
- except subprocess.TimeoutExpired:
458
+ return
459
+
460
+ def drain_streams(wait_seconds):
461
+ events = selector.select(wait_seconds)
462
+ for key, _ in events:
399
463
  try:
400
- os.killpg(proc.pid, signal.SIGKILL)
401
- except ProcessLookupError:
402
- pass
403
- stdout, stderr = proc.communicate()
404
- if stdout:
405
- sys.stdout.buffer.write(stdout)
406
- if stderr:
407
- sys.stderr.buffer.write(stderr)
408
- sys.exit(124)
464
+ chunk = key.fileobj.read()
465
+ except BlockingIOError:
466
+ continue
467
+ except OSError as exc:
468
+ if exc.errno == errno.EAGAIN:
469
+ continue
470
+ raise
471
+ if not chunk:
472
+ selector.unregister(key.fileobj)
473
+ continue
474
+ key.data.write(chunk)
475
+ key.data.flush()
476
+
477
+ def handle_parent_signal(signum, _frame):
478
+ terminate_process_group(signal.SIGTERM)
479
+ deadline = time.monotonic() + 2.0
480
+ while proc.poll() is None and time.monotonic() < deadline:
481
+ drain_streams(0.1)
482
+ if proc.poll() is None:
483
+ terminate_process_group(signal.SIGKILL)
484
+ while selector.get_map():
485
+ drain_streams(0)
486
+ sys.exit(128 + signum)
487
+
488
+ for signum in (signal.SIGTERM, signal.SIGINT, signal.SIGHUP):
489
+ signal.signal(signum, handle_parent_signal)
490
+
491
+ deadline = time.monotonic() + timeout_seconds
492
+ grace_deadline = None
493
+ timed_out = False
409
494
 
410
- if stdout:
411
- sys.stdout.buffer.write(stdout)
412
- if stderr:
413
- sys.stderr.buffer.write(stderr)
414
- sys.exit(proc.returncode)
495
+ try:
496
+ while True:
497
+ now = time.monotonic()
498
+ if not timed_out and now >= deadline:
499
+ timed_out = True
500
+ grace_deadline = now + 2.0
501
+ terminate_process_group(signal.SIGTERM)
502
+ elif timed_out and grace_deadline is not None and proc.poll() is None and now >= grace_deadline:
503
+ grace_deadline = None
504
+ terminate_process_group(signal.SIGKILL)
505
+
506
+ wait_seconds = 0.1
507
+ if not timed_out:
508
+ wait_seconds = max(0.0, min(0.1, deadline - now))
509
+ elif grace_deadline is not None:
510
+ wait_seconds = max(0.0, min(0.1, grace_deadline - now))
511
+
512
+ drain_streams(wait_seconds)
513
+
514
+ if proc.poll() is not None and not selector.get_map():
515
+ break
516
+ finally:
517
+ while selector.get_map():
518
+ drain_streams(0)
519
+
520
+ if timed_out and proc.returncode is None:
521
+ sys.exit(124)
522
+ if timed_out:
523
+ sys.exit(124)
524
+ sys.exit(proc.wait())
415
525
  PY
416
526
  }
417
527
 
@@ -528,28 +638,30 @@ HOOK_EOF
528
638
  }
529
639
 
530
640
  classify_failure_reason() {
531
- local log_file="\${1:-\${output_file}}"
532
-
533
- if grep -Eiq 'authentication|unauthorized|login required|invalid api key|api key' "\${log_file}" 2>/dev/null; then
534
- printf 'auth-failure\n'
535
- return 0
536
- fi
537
- if grep -Eiq 'rate limit|quota exceeded|insufficient credits|payment required|429' "\${log_file}" 2>/dev/null; then
538
- printf 'provider-quota-limit\n'
539
- return 0
540
- fi
541
- if grep -Eiq 'model .* not available|unsupported model|invalid model|model not found' "\${log_file}" 2>/dev/null; then
542
- printf 'model-unavailable\n'
543
- return 0
544
- fi
545
- if grep -Eiq 'connection reset|connection error|network error|temporarily unavailable|ECONNRESET|ECONNREFUSED|ENOTFOUND|EAI_AGAIN' "\${log_file}" 2>/dev/null; then
546
- printf 'network-connection\n'
547
- return 0
548
- fi
549
- if grep -Eiq 'timeout|timed out|ETIMEDOUT' "\${log_file}" 2>/dev/null; then
550
- printf 'timeout\n'
551
- return 0
552
- fi
641
+ local log_file=""
642
+ for log_file in "\$@"; do
643
+ [[ -n "\${log_file}" && -f "\${log_file}" ]] || continue
644
+ if grep -Eiq 'authentication|unauthorized|login required|invalid api key|api key' "\${log_file}" 2>/dev/null; then
645
+ printf 'auth-failure\n'
646
+ return 0
647
+ fi
648
+ if grep -Eiq 'rate limit|quota exceeded|insufficient credits|payment required|429' "\${log_file}" 2>/dev/null; then
649
+ printf 'provider-quota-limit\n'
650
+ return 0
651
+ fi
652
+ if grep -Eiq 'model .* not available|unsupported model|invalid model|model not found' "\${log_file}" 2>/dev/null; then
653
+ printf 'model-unavailable\n'
654
+ return 0
655
+ fi
656
+ if grep -Eiq 'connection reset|connection error|network error|temporarily unavailable|ECONNRESET|ECONNREFUSED|ENOTFOUND|EAI_AGAIN' "\${log_file}" 2>/dev/null; then
657
+ printf 'network-connection\n'
658
+ return 0
659
+ fi
660
+ if grep -Eiq 'timeout|timed out|ETIMEDOUT' "\${log_file}" 2>/dev/null; then
661
+ printf 'timeout\n'
662
+ return 0
663
+ fi
664
+ done
553
665
  printf 'claude-exit-failed\n'
554
666
  }
555
667
 
@@ -580,17 +692,23 @@ reset_sandbox_run_dir
580
692
  ensure_workspace_excludes
581
693
  install_pre_commit_scope_hook
582
694
 
583
- prompt_payload="\$(cat "\${prompt_file}")"
584
695
  claude_args=(
585
696
  -p
586
697
  --output-format text
698
+ --verbose
699
+ --debug-file "\${claude_debug_file}"
587
700
  --no-session-persistence
588
- --permission-mode "\${claude_permission_mode}"
701
+ --permission-mode "\${claude_effective_permission_mode}"
702
+ --allowed-tools "\${claude_allowed_tools}"
703
+ --disable-slash-commands
704
+ --strict-mcp-config
705
+ --mcp-config "\${claude_mcp_config_file}"
706
+ --settings "\${claude_settings_file}"
589
707
  --model "\${claude_model}"
590
708
  --effort "\${claude_effort}"
591
709
  --add-dir ${worktree_q}
592
710
  )
593
- if [[ "\${claude_permission_mode}" == "bypassPermissions" ]]; then
711
+ if [[ "\${claude_effective_permission_mode}" == "bypassPermissions" ]]; then
594
712
  claude_args+=(--allow-dangerously-skip-permissions)
595
713
  fi
596
714
 
@@ -602,7 +720,7 @@ while (( attempt <= claude_max_attempts )); do
602
720
  attempt_log_file="\${artifact_dir}/claude-attempt-\${attempt}.log"
603
721
  write_state running '' '' "\${attempt}" "\$((attempt - 1))"
604
722
  printf '\n[claude-attempt] %s/%s\n' "\${attempt}" "\${claude_max_attempts}" | tee -a "\${output_file}" >/dev/null
605
- run_with_timeout "\${claude_timeout_seconds}" "\${claude_bin}" "\${claude_args[@]}" "\${prompt_payload}" >"\${attempt_log_file}" 2>&1
723
+ run_with_timeout "\${claude_timeout_seconds}" "\${prompt_file}" "\${claude_bin}" "\${claude_args[@]}" >"\${attempt_log_file}" 2>&1
606
724
  status=\$?
607
725
  cat "\${attempt_log_file}" >>"\${output_file}"
608
726
  if [[ "\${status}" -eq 0 ]]; then
@@ -612,7 +730,7 @@ while (( attempt <= claude_max_attempts )); do
612
730
  if [[ "\${status}" -eq 124 ]]; then
613
731
  failure_reason="timeout"
614
732
  else
615
- failure_reason="\$(classify_failure_reason "\${attempt_log_file}")"
733
+ failure_reason="\$(classify_failure_reason "\${attempt_log_file}" "\${claude_debug_file}")"
616
734
  fi
617
735
  if (( attempt >= claude_max_attempts )) || ! is_retryable_failure_reason "\${failure_reason}"; then
618
736
  break
@@ -629,6 +747,8 @@ if [[ -f "\${result_file_path}" ]]; then
629
747
  else
630
748
  if [[ "\${status}" -eq 0 ]]; then
631
749
  write_result_fallback "missing-result-contract"
750
+ elif [[ "\${status}" -ne 124 && -n "\${failure_reason}" && "\${failure_reason}" != "claude-exit-failed" ]]; then
751
+ write_result_fallback "\${failure_reason}"
632
752
  else
633
753
  write_result_fallback "worker-exit-\${status}"
634
754
  fi
@@ -41,7 +41,7 @@ CODEX_PROFILE_BYPASS="${ACP_CODEX_PROFILE_BYPASS:-${F_LOSNING_CODEX_PROFILE_BYPA
41
41
  if [[ "$MODE" == "bypass" ]]; then
42
42
  CLAUDE_PERMISSION_MODE_DEFAULT="bypassPermissions"
43
43
  else
44
- CLAUDE_PERMISSION_MODE_DEFAULT="dontAsk"
44
+ CLAUDE_PERMISSION_MODE_DEFAULT="acceptEdits"
45
45
  fi
46
46
  CLAUDE_MODEL="${ACP_CLAUDE_MODEL:-${F_LOSNING_CLAUDE_MODEL:-sonnet}}"
47
47
  CLAUDE_PERMISSION_MODE="${ACP_CLAUDE_PERMISSION_MODE:-${F_LOSNING_CLAUDE_PERMISSION_MODE:-${CLAUDE_PERMISSION_MODE_DEFAULT}}}"
@@ -25,7 +25,7 @@ Options:
25
25
  --coding-worker <codex|openclaw|claude>
26
26
  Default coding backend (default: openclaw)
27
27
  --claude-model <model> Claude model alias or full name
28
- --claude-permission-mode <mode> Claude permission mode (default: dontAsk)
28
+ --claude-permission-mode <mode> Claude permission mode (default: acceptEdits)
29
29
  --claude-effort <level> Claude effort level (default: medium)
30
30
  --claude-timeout-seconds <secs> Claude timeout (default: 900)
31
31
  --claude-max-attempts <count> Claude retry attempts (default: 3)
@@ -49,7 +49,7 @@ retained_repo_root=""
49
49
  vscode_workspace_file=""
50
50
  coding_worker="openclaw"
51
51
  claude_model="sonnet"
52
- claude_permission_mode="dontAsk"
52
+ claude_permission_mode="acceptEdits"
53
53
  claude_effort="medium"
54
54
  claude_timeout_seconds="900"
55
55
  claude_max_attempts="3"