aiur-cli 0.0.3 → 0.0.5-nightly.24dcc4d

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.
@@ -35,32 +35,237 @@ elif [ -n "${__aiur_hard_nofile}" ]; then
35
35
  fi
36
36
  unset __aiur_hard_nofile
37
37
 
38
+ # Export the effective soft limit after the best-effort raise. The BEAM uses
39
+ # this inherited value for FD-headroom admission on hosts without procfs,
40
+ # avoiding a runtime `ulimit` subprocess precisely when descriptors are scarce.
41
+ __aiur_soft_nofile="$(ulimit -Sn 2>/dev/null || echo)"
42
+ if [[ "$__aiur_soft_nofile" =~ ^[0-9]+$ ]]; then
43
+ export AIUR_NOFILE_SOFT_LIMIT="$__aiur_soft_nofile"
44
+ else
45
+ unset AIUR_NOFILE_SOFT_LIMIT
46
+ fi
47
+ unset __aiur_soft_nofile
48
+
49
+ # Preserve the shell that initiated the run as a best-effort Executor root.
50
+ # An explicit positive override wins (service managers may know a better root);
51
+ # otherwise the engine's parent is the nearest identity available before tmux
52
+ # hands daemon ownership to its pane launcher.
53
+ if ! [[ "${AIUR_OPERATOR_PID:-}" =~ ^[1-9][0-9]*$ ]]; then
54
+ if [[ "${PPID:-}" =~ ^[1-9][0-9]*$ ]]; then
55
+ export AIUR_OPERATOR_PID="$PPID"
56
+ else
57
+ unset AIUR_OPERATOR_PID
58
+ fi
59
+ fi
60
+
38
61
  die() {
39
62
  echo "❌ $*" >&2
40
63
  exit 1
41
64
  }
42
65
 
66
+ legacy_config_path() {
67
+ while [ "$#" -gt 0 ]; do
68
+ case "$1" in
69
+ --logs-root | --host | --port | --max-agents)
70
+ if [ "$#" -ge 2 ]; then
71
+ shift 2
72
+ else
73
+ shift
74
+ fi
75
+ ;;
76
+ --)
77
+ shift
78
+ [ "$#" -gt 0 ] || return
79
+ case "$1" in
80
+ *.aiurconfig) printf '%s' "$1" ;;
81
+ esac
82
+ return
83
+ ;;
84
+ --logs-root=* | --host=* | --port=* | --max-agents=* | --*)
85
+ shift
86
+ ;;
87
+ *.aiurconfig)
88
+ printf '%s' "$1"
89
+ return
90
+ ;;
91
+ *)
92
+ # A positional non-legacy config is authoritative. Let the Elixir CLI
93
+ # validate it instead of rejecting an unrelated ambient legacy file.
94
+ return
95
+ ;;
96
+ esac
97
+ done
98
+
99
+ local target_root="${AIUR_REPO_ROOT:-}" home_real
100
+ home_real="$(cd "${HOME:-}" 2>/dev/null && pwd -P || printf '%s' "${HOME:-}")"
101
+
102
+ if [ -n "$target_root" ]; then
103
+ if [ -f "$target_root/.aiur/config" ]; then
104
+ return
105
+ fi
106
+ if [ -f "$target_root/.aiurconfig" ]; then
107
+ printf '%s' "$target_root/.aiurconfig"
108
+ return
109
+ fi
110
+ else
111
+ local pwd_real d
112
+ pwd_real="$(pwd -P 2>/dev/null || printf '%s' "$PWD")"
113
+ d="$pwd_real"
114
+
115
+ while [ -n "$d" ] && [ "$d" != "/" ] && [ "$d" != "$home_real" ]; do
116
+ if [ -f "$d/.aiur/config" ]; then
117
+ return
118
+ fi
119
+ if [ -f "$d/.aiurconfig" ]; then
120
+ printf '%s' "$d/.aiurconfig"
121
+ return
122
+ fi
123
+ d="$(dirname "$d")"
124
+ done
125
+ fi
126
+
127
+ if [ -n "$home_real" ] && [ ! -f "$home_real/.aiur/config" ] && [ -f "$home_real/.aiurconfig" ]; then
128
+ printf '%s' "$home_real/.aiurconfig"
129
+ fi
130
+ }
131
+
132
+ reject_legacy_config() {
133
+ local legacy legacy_basename canonical
134
+ legacy="$(legacy_config_path "$@")"
135
+ [ -z "$legacy" ] && return
136
+ legacy_basename="${legacy##*/}"
137
+
138
+ if [ "$legacy_basename" = ".aiurconfig" ]; then
139
+ canonical="$(dirname "$legacy")/.aiur/config"
140
+ else
141
+ canonical="${legacy%.aiurconfig}.yaml"
142
+ fi
143
+
144
+ die "$legacy is no longer supported. Move it to $canonical. Keep relative prompt_file and hooks_file paths valid from the new config directory."
145
+ }
146
+
43
147
  engine_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
44
148
 
149
+ package_version() {
150
+ local package_file="$1"
151
+ [ -r "$package_file" ] || return 1
152
+ sed -n 's/^[[:space:]]*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$package_file" | head -n 1
153
+ }
154
+
155
+ cli_package_version() {
156
+ package_version "$engine_dir/../package.json"
157
+ }
158
+
159
+ version_is_older() {
160
+ local installed="$1" available="$2"
161
+ local installed_major installed_minor installed_patch installed_pre
162
+ local available_major available_minor available_patch available_pre
163
+
164
+ if [[ ! "$installed" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)(-([0-9A-Za-z.-]+))?(\+[0-9A-Za-z.-]+)?$ ]]; then return 1; fi
165
+ installed_major="${BASH_REMATCH[1]}"
166
+ installed_minor="${BASH_REMATCH[2]}"
167
+ installed_patch="${BASH_REMATCH[3]}"
168
+ installed_pre="${BASH_REMATCH[5]}"
169
+
170
+ if [[ ! "$available" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)(-([0-9A-Za-z.-]+))?(\+[0-9A-Za-z.-]+)?$ ]]; then return 1; fi
171
+ available_major="${BASH_REMATCH[1]}"
172
+ available_minor="${BASH_REMATCH[2]}"
173
+ available_patch="${BASH_REMATCH[3]}"
174
+ available_pre="${BASH_REMATCH[5]}"
175
+
176
+ [ "$installed_major" -lt "$available_major" ] ||
177
+ { [ "$installed_major" -eq "$available_major" ] && [ "$installed_minor" -lt "$available_minor" ]; } ||
178
+ { [ "$installed_major" -eq "$available_major" ] && [ "$installed_minor" -eq "$available_minor" ] && [ "$installed_patch" -lt "$available_patch" ]; } ||
179
+ {
180
+ [ "$installed_major" -eq "$available_major" ] &&
181
+ [ "$installed_minor" -eq "$available_minor" ] &&
182
+ [ "$installed_patch" -eq "$available_patch" ] &&
183
+ {
184
+ { [ -n "$installed_pre" ] && [ -z "$available_pre" ]; } ||
185
+ { [ -n "$installed_pre" ] && [ -n "$available_pre" ] && [[ "$installed_pre" < "$available_pre" ]]; }
186
+ }
187
+ }
188
+ }
189
+
190
+ warn_if_cli_behind_release_checkout() {
191
+ local stamp="${AIUR_RELEASE_DIR:-}/AIUR_BUILD_STAMP"
192
+ local repo_root installed_version checkout_version
193
+
194
+ [ -r "$stamp" ] || return 0
195
+ repo_root="$(sed -n 's/^repo_root=//p' "$stamp" | head -n 1)"
196
+ [ -n "$repo_root" ] || return 0
197
+
198
+ installed_version="$(cli_package_version || true)"
199
+ checkout_version="$(package_version "$repo_root/packaging/npm/aiur-cli/package.json" || true)"
200
+
201
+ if version_is_older "$installed_version" "$checkout_version"; then
202
+ echo "aiur: installed CLI $installed_version is older than checkout CLI $checkout_version; update aiur-cli before retrying" >&2
203
+ fi
204
+ }
205
+
45
206
  # --- distribution identity (per-instance: keyed by the aiur project root) -----
46
207
 
47
- # The aiur project root (dir holding the active config). AIUR_REPO_ROOT (set by
48
- # the dev shim) wins; otherwise walk up from $PWD. Empty when run outside a project.
208
+ # The aiur project root used to key this instance. AIUR_REPO_ROOT (set by the dev
209
+ # shim) wins. Otherwise walk up from $PWD to the first dir holding a REPO-LOCAL
210
+ # config — but the walk STOPS at $HOME: the global config at ~/.aiur/config is
211
+ # not a repo root, and treating it as one would collapse every
212
+ # project under $HOME onto one key (#443). When no repo-local config is found, the
213
+ # BEAM serves this run via the global config (mirroring its discovery order in
214
+ # src/lib/aiur/workflow.ex) — or via none — and in both cases the project being
215
+ # served is the cwd, so we key by realpath($PWD). That gives each global-config
216
+ # project a distinct identity instead of an empty key (legacy aiur-$USER@…) or $HOME.
217
+ #
218
+ # Caveat: a global-config run's key is cwd-derived (there is no repo root to
219
+ # converge on), so control commands (status/pause/stop) must be run from the SAME
220
+ # directory the run was launched from. A repo with a repo-local .aiur/config keeps
221
+ # the walk-up, so its control commands still resolve from any subdir.
49
222
  aiur_project_root() {
50
223
  if [ -n "${AIUR_REPO_ROOT:-}" ]; then printf '%s' "$AIUR_REPO_ROOT"; return; fi
51
- local d="$PWD"
52
- while [ -n "$d" ] && [ "$d" != "/" ]; do
53
- if [ -f "$d/.aiur/config" ] || [ -f "$d/.aiurconfig" ]; then
224
+
225
+ # Canonicalize $PWD and $HOME so the home-boundary test below holds even when
226
+ # either is reached through a symlink.
227
+ local pwd_real home_real
228
+ pwd_real="$(pwd -P 2>/dev/null || printf '%s' "$PWD")"
229
+ # ${HOME:-} (not bare $HOME) so an unset HOME under `set -u` can't abort the
230
+ # script here; an empty home_real simply disables the boundary (walk to /).
231
+ home_real="$(cd "${HOME:-}" 2>/dev/null && pwd -P || printf '%s' "${HOME:-}")"
232
+
233
+ local d="$pwd_real"
234
+ while [ -n "$d" ] && [ "$d" != "/" ] && [ "$d" != "$home_real" ]; do
235
+ if [ -f "$d/.aiur/config" ]; then
54
236
  printf '%s' "$d"
55
237
  return
56
238
  fi
57
239
  d="$(dirname "$d")"
58
240
  done
241
+
242
+ printf '%s' "$pwd_real"
243
+ }
244
+
245
+ aiur_project_root_source() {
246
+ if [ -n "${AIUR_REPO_ROOT:-}" ]; then printf 'env'; return; fi
247
+
248
+ local pwd_real home_real
249
+ pwd_real="$(pwd -P 2>/dev/null || printf '%s' "$PWD")"
250
+ home_real="$(cd "${HOME:-}" 2>/dev/null && pwd -P || printf '%s' "${HOME:-}")"
251
+
252
+ local d="$pwd_real"
253
+ while [ -n "$d" ] && [ "$d" != "/" ] && [ "$d" != "$home_real" ]; do
254
+ if [ -f "$d/.aiur/config" ]; then
255
+ printf 'repo'
256
+ return
257
+ fi
258
+ d="$(dirname "$d")"
259
+ done
260
+
261
+ printf 'cwd'
59
262
  }
60
263
 
61
264
  # Short, stable, node-name-legal (lowercase hex) key for the project root, so two
62
265
  # aiur instances for the same user get distinct node/session/socket names and can't
63
- # reap each other. Empty when no project resolves (names fall back to the legacy form).
266
+ # reap each other. Any real cwd now resolves a key (global-config runs key by
267
+ # realpath($PWD), #443); only a degenerate unreadable cwd yields empty, falling back
268
+ # to the legacy un-keyed name.
64
269
  aiur_instance_key() {
65
270
  local root
66
271
  root="$(aiur_project_root)"
@@ -74,6 +279,8 @@ aiur_instance_key() {
74
279
 
75
280
  aiur_resolve_identity() {
76
281
  local config_home="${XDG_CONFIG_HOME:-$HOME/.config}"
282
+ AIUR_PROJECT_ROOT="$(aiur_project_root)"
283
+ AIUR_PROJECT_ROOT_SOURCE="$(aiur_project_root_source)"
77
284
 
78
285
  : "${AIUR_BG_STATE_DIR:=$config_home/aiur}"
79
286
  : "${AIUR_COOKIE_FILE:=$AIUR_BG_STATE_DIR/cookie}"
@@ -87,7 +294,8 @@ aiur_resolve_identity() {
87
294
  : "${AIUR_RELEASE_NODE:=aiur-${USER}${AIUR_INSTANCE_KEY:+-$AIUR_INSTANCE_KEY}@127.0.0.1}"
88
295
 
89
296
  export AIUR_BG_STATE_DIR AIUR_COOKIE_FILE AIUR_SESSION_PREFIX \
90
- AIUR_PROFILES_FILE AIUR_RELEASE_NODE AIUR_INSTANCE_KEY
297
+ AIUR_PROFILES_FILE AIUR_RELEASE_NODE AIUR_INSTANCE_KEY \
298
+ AIUR_PROJECT_ROOT AIUR_PROJECT_ROOT_SOURCE
91
299
  }
92
300
 
93
301
  aiur_print_identity() {
@@ -98,6 +306,8 @@ aiur_print_identity() {
98
306
  printf 'AIUR_PROFILES_FILE=%s\n' "$AIUR_PROFILES_FILE"
99
307
  printf 'AIUR_RELEASE_NODE=%s\n' "$AIUR_RELEASE_NODE"
100
308
  printf 'AIUR_INSTANCE_KEY=%s\n' "$AIUR_INSTANCE_KEY"
309
+ printf 'AIUR_PROJECT_ROOT=%s\n' "$AIUR_PROJECT_ROOT"
310
+ printf 'AIUR_PROJECT_ROOT_SOURCE=%s\n' "$AIUR_PROJECT_ROOT_SOURCE"
101
311
  printf 'AIUR_COOKIE_FILE=%s\n' "$AIUR_COOKIE_FILE"
102
312
  }
103
313
 
@@ -164,16 +374,35 @@ release_dir=""
164
374
  vsn_dir=""
165
375
  release_bin=""
166
376
 
377
+ control_release_retry() {
378
+ [ -n "${AIUR_CONTROL_RELEASE_RETRY_SIGNAL:-}" ] && : >"$AIUR_CONTROL_RELEASE_RETRY_SIGNAL"
379
+ return 75
380
+ }
381
+
167
382
  resolve_release() {
168
383
  release_dir="${AIUR_RELEASE_DIR:-}"
169
384
  [ -n "$release_dir" ] || die "AIUR_RELEASE_DIR is not set; the engine must be invoked via the aiur or aiurdev wrapper"
170
- [ -d "$release_dir" ] || die "AIUR_RELEASE_DIR does not exist: $release_dir"
385
+ if [ ! -d "$release_dir" ]; then
386
+ # aiurdev uses EX_TEMPFAIL to wait out an in-place dev rebuild and retry
387
+ # exactly once. Product launches keep the existing fatal diagnostics.
388
+ if [ "${AIUR_CONTROL_RELEASE_RETRYABLE:-0}" = "1" ]; then
389
+ control_release_retry
390
+ return $?
391
+ fi
392
+ die "AIUR_RELEASE_DIR does not exist: $release_dir"
393
+ fi
171
394
 
172
395
  local release_vsn
173
- release_vsn="$(cut -d' ' -f2 "$release_dir/releases/start_erl.data")"
396
+ release_vsn="$(cut -d' ' -f2 "$release_dir/releases/start_erl.data" 2>/dev/null || true)"
174
397
  vsn_dir="$release_dir/releases/$release_vsn"
175
398
  release_bin="$release_dir/bin/aiur"
176
- [ -x "$vsn_dir/elixir" ] || die "release elixir launcher not found at $vsn_dir/elixir"
399
+ if [ -z "$release_vsn" ] || [ ! -x "$release_bin" ] || [ ! -x "$vsn_dir/elixir" ]; then
400
+ if [ "${AIUR_CONTROL_RELEASE_RETRYABLE:-0}" = "1" ]; then
401
+ control_release_retry
402
+ return $?
403
+ fi
404
+ die "release elixir launcher not found at $vsn_dir/elixir"
405
+ fi
177
406
  }
178
407
 
179
408
  # --- argv round-trip (System.argv is empty under `elixir --eval`) -------------
@@ -218,15 +447,40 @@ build_init_cmd() {
218
447
 
219
448
  usage() {
220
449
  cat <<'EOF'
221
- Usage: aiur [--interactive] [--max-agents <n>] [--logs-root <path>] [--port <port>] [--host <host>] [path-to-.aiurconfig]
222
- aiur init [--force] scaffold .aiurconfig (interactive setup wizard)
223
- aiur --bg start in a lean, headless detached tmux session
450
+ Usage: aiur [--interactive] [--no-dashboard] [--executor] [--pause] [--max-agents <n>] [--logs-root <path>] [--port <port>] [--host <host>] [config-path]
451
+ aiur run [--bg] [--no-dashboard] [--executor] [--debug] explicit launch form (foreground unless --bg)
452
+ aiur init [--force] scaffold .aiur/config (interactive setup wizard)
453
+ aiur --bg [--no-dashboard] [--executor] [--debug] start detached; dashboard on unless suppressed
224
454
  aiur stop stop the running session
455
+ aiur restart [--no-build] [run flags] stop, refresh the build, start again (detached)
225
456
  aiur status show agent status
226
457
  aiur agents show each agent's state + current activity
458
+ aiur commands [<decision-id>] [--filter all|open|blocking|resolved] [--blocking] [--ticket <id>] [--search <text>] [--cursor <cursor>] [--limit <n>] [--json]
459
+ aiur executor-answer <decision-id> --expected-version <n> (--option <id>|--custom-response <text>) --rationale <text> --idempotency-key <key> [--executor-id <id>]
460
+ aiur executor-escalate <decision-id> --expected-version <n> --reason <text> [--executor-id <id>]
461
+ aiur units [--scope live|unfinished|all|none] [--condition active|alert|paused|queued|finished]... [--format auto|table|records] [--json]
462
+ aiur build-orders [<root>] [--json] show the Build Order catalog or one root
463
+ aiur analytics [--range run|full] [--since <ISO-8601>] [--until <ISO-8601>] [--build-order <id>] [--json]
464
+ aiur alerts [--needs-attention] show structured alert feed
465
+ aiur watch [--full|--changes] [--interval <secs>] server-side status board
466
+ aiur executor-listen [--topic <pattern>] stream Executor events as JSON lines
467
+ aiur executor-wait [--timeout <seconds>] [--json] block until Executor work arrives
468
+ aiur executor-emit <topic> --payload <json> publish an Executor event
469
+ aiur executor-subscribe|executor-unsubscribe <pattern>
470
+ aiur executor-subscriptions list persistent Executor bindings
227
471
  aiur set max-agents <n> change the concurrent-agent cap at runtime
228
- aiur pause <ids|--all> | resume <ids|--all>
229
- aiur message <id> <text> send operator text to a running agent
472
+ aiur pause | resume flip the global pause switch (whole daemon)
473
+ aiur pause <ids|--all> | resume <ids|--all> per-agent pause/resume
474
+ aiur message <id> <text> send Executor text to a running agent
475
+ aiur --todo <ids...> [--only] queue tickets; optionally dequeue all other pending tickets
476
+ aiur findings [--unfiled] [--slugs] [--scope aiur|repo] inspect host-local findings
477
+ aiur findings --record <json> --repo <owner/repo> append one validated finding
478
+ aiur findings --digest [--scope aiur|repo] generate the promoted Markdown digest
479
+ aiur guard-pr-deletions [base-branch] refuse PRs with excessive untouched deletions
480
+ aiur ask <title> [--body <text>|--body-file <path>] [--urgency low|normal|high] [--blocking]
481
+ aiur ask --done <id> [--note <text>] create or resolve an operator request
482
+ aiur asks [--open|--all] [--json] inspect current-repository operator requests
483
+ aiur cleanup-stale [--dry-run] list/reap stale manual-smoke leftovers
230
484
  aiur --version
231
485
  EOF
232
486
  }
@@ -235,6 +489,8 @@ EOF
235
489
 
236
490
  run_version() {
237
491
  resolve_release
492
+ AIUR_CLI_VERSION="$(cli_package_version || true)"
493
+ export AIUR_CLI_VERSION
238
494
  # Distribution-free (like init): printing the version is a compile-time
239
495
  # constant, so never claim the node name — otherwise `aiur --version` fails
240
496
  # whenever an aiur session is already running.
@@ -256,16 +512,81 @@ run_init() {
256
512
  exec "${release_cmd[@]}"
257
513
  }
258
514
 
515
+ # --- one-shot: --todo (control RPC; requires a running daemon) ----------------
516
+
517
+ parsed_todo_only=0
518
+
519
+ parse_todo_args() {
520
+ parsed_targets=()
521
+ parsed_todo_only=0
522
+
523
+ local saw_todo=0 raw part parts
524
+ for raw in "$@"; do
525
+ case "$raw" in
526
+ --todo)
527
+ [ "$saw_todo" -eq 0 ] || return 1
528
+ saw_todo=1
529
+ ;;
530
+ --only)
531
+ parsed_todo_only=1
532
+ ;;
533
+ -*)
534
+ return 1
535
+ ;;
536
+ *)
537
+ IFS=',' read -ra parts <<<"$raw"
538
+ for part in "${parts[@]}"; do
539
+ part="$(trim "$part")"
540
+ if [ -z "$part" ] || [[ ! "$part" =~ ^[0-9]+$ ]]; then return 1; fi
541
+ while [ "${#part}" -gt 1 ] && [ "${part#0}" != "$part" ]; do
542
+ part="${part#0}"
543
+ done
544
+ parsed_targets+=("$part")
545
+ done
546
+ ;;
547
+ esac
548
+ done
549
+
550
+ [ "$saw_todo" -eq 1 ] && [ "${#parsed_targets[@]}" -gt 0 ]
551
+ }
552
+
553
+ run_todo() {
554
+ if ! parse_todo_args "$@"; then
555
+ echo "aiur: --todo expects one or more numeric issue IDs, optionally followed by --only" >&2
556
+ exit 64
557
+ fi
558
+
559
+ local only_arg="false"
560
+ [ "$parsed_todo_only" -eq 1 ] && only_arg="true"
561
+ run_control_rpc "Aiur.AgentControlCLI.todo($(elixir_list_literal "${parsed_targets[@]}"), only: $only_arg, emit_exit_marker: true)"
562
+ }
563
+
564
+ # --- one-shot: findings (distribution-free, no daemon/tmux) -------------------
565
+
566
+ run_findings() {
567
+ run_init "$@"
568
+ }
569
+
570
+ # --- one-shot: ask / asks (distribution-free, no daemon/tmux) ----------------
571
+
572
+ run_asks() {
573
+ run_init "$@"
574
+ }
575
+
259
576
  # --- interactive / background run -------------------------------------------
260
577
  #
261
578
  # mode=foreground attaches the UI and tears down on exit; mode=background leaves
262
579
  # the detached tmux session running and returns.
263
580
 
264
- # Load KEY=VALUE pairs from ./.env into the environment so the running release
265
- # (e.g. GITHUB_TOKEN, dashboard creds) sees what `aiur init` scaffolded there.
266
- # An already-exported variable always wins, so a shell export overrides the file.
581
+ # Load operator/machine credentials before repo-local settings. Since each file
582
+ # only fills unset names, shell exports win first, then ~/.aiur/.env, then ./.env.
267
583
  load_dotenv() {
268
- local file=".env" line key val
584
+ load_dotenv_file "$HOME/.aiur/.env"
585
+ load_dotenv_file ".env"
586
+ }
587
+
588
+ load_dotenv_file() {
589
+ local file="$1" line key val
269
590
  [ -f "$file" ] || return 0
270
591
  while IFS= read -r line || [ -n "$line" ]; do
271
592
  line="${line#"${line%%[![:space:]]*}"}"
@@ -285,6 +606,56 @@ load_dotenv() {
285
606
  done <"$file"
286
607
  }
287
608
 
609
+ # The readiness token grants the one-shot `aiur init` assessment access that a
610
+ # normal daemon and its child agents must never inherit. Keep dotenv loading
611
+ # generic, then remove this run-only secret before any session process starts.
612
+ scrub_run_only_env() {
613
+ unset AIUR_CI_READINESS_TOKEN
614
+ }
615
+
616
+ run_argv=()
617
+ # Default dashboard bind host. Prefer this machine's Tailscale IPv4 so the
618
+ # dashboard is reachable across the tailnet by default (when config omits it);
619
+ # fall back to loopback when Tailscale is absent, or when dashboard credentials
620
+ # are unset (a non-loopback bind requires them, so we stay on loopback rather
621
+ # than refuse to start). The BEAM applies this below an explicit `server.host`,
622
+ # while an explicit `--host` remains the highest-precedence value.
623
+ default_dashboard_host() {
624
+ local ip=""
625
+ if command -v tailscale >/dev/null 2>&1; then
626
+ ip="$(tailscale ip -4 2>/dev/null | grep -m1 -E '^100\.[0-9]+\.[0-9]+\.[0-9]+$' || true)"
627
+ fi
628
+ if [ -n "$ip" ] && [ -n "${AIUR_DASHBOARD_USERNAME:-}" ] && [ -n "${AIUR_DASHBOARD_PASSWORD:-}" ]; then
629
+ printf '%s' "$ip"
630
+ else
631
+ printf '127.0.0.1'
632
+ fi
633
+ }
634
+
635
+ build_run_argv() {
636
+ local mode="$1"
637
+ shift
638
+
639
+ local has_interactive=0 has_headless=0 has_ack=0 arg
640
+ for arg in "$@"; do
641
+ case "$arg" in
642
+ --interactive) has_interactive=1 ;;
643
+ --headless) has_headless=1 ;;
644
+ --i-understand-that-this-will-be-running-without-the-usual-guardrails) has_ack=1 ;;
645
+ esac
646
+ done
647
+
648
+ local injected=()
649
+ if [ "$mode" = "background" ] && [ "$has_interactive" -eq 0 ]; then
650
+ [ "$has_headless" -eq 1 ] || injected+=(--headless)
651
+ else
652
+ [ "$has_interactive" -eq 1 ] || injected+=(--interactive)
653
+ fi
654
+ [ "$has_ack" -eq 1 ] || injected+=(--i-understand-that-this-will-be-running-without-the-usual-guardrails)
655
+
656
+ run_argv=("${injected[@]}" "$@")
657
+ }
658
+
288
659
  run_session() {
289
660
  local mode="$1"
290
661
  shift
@@ -311,34 +682,25 @@ run_session() {
311
682
  # Pick up GITHUB_TOKEN / dashboard creds the wizard wrote to ./.env so the
312
683
  # running tracker can authenticate. Shell exports still take precedence.
313
684
  load_dotenv
685
+ scrub_run_only_env
686
+ export AIUR_DEFAULT_DASHBOARD_HOST="$(default_dashboard_host)"
314
687
 
315
688
  init_argv_file
316
689
 
317
- # Inject the flags a bare `aiur` needs: loopback bind, UI mode, and the
318
- # no-guardrails ack. Skip any the user already passed. Foreground runs are
319
- # interactive (tmux panes + dashboard); `--bg` runs lean/headless — no panes,
320
- # no dashboard bind, no chat backfill — and is driven over the control RPC
321
- # (status/agents/message/pause/set). `aiur --bg --interactive` opts back into
322
- # the full interactive stack for an attachable background session.
323
- local has_host=0 has_interactive=0 has_headless=0 has_ack=0 arg
324
- for arg in "$@"; do
325
- case "$arg" in
326
- --host | --host=*) has_host=1 ;;
327
- --interactive) has_interactive=1 ;;
328
- --headless) has_headless=1 ;;
329
- --i-understand-that-this-will-be-running-without-the-usual-guardrails) has_ack=1 ;;
330
- esac
690
+ # Supply the flags a bare `aiur` needs: UI mode and the no-guardrails ack.
691
+ # The exported dashboard host fills only a missing config value; an explicit
692
+ # server.host or --host remains authoritative. Foreground runs are
693
+ # interactive; `--bg` runs headless (no panes/chat backfill) and is driven
694
+ # over the control RPC (status/agents/message/pause/set). Dashboard binding is
695
+ # independent: it remains enabled in either mode unless `--no-dashboard` is
696
+ # supplied. `aiur --bg --interactive` opts back into the full terminal stack
697
+ # for an attachable background session.
698
+ build_run_argv "$mode" "$@"
699
+ local no_dashboard=0
700
+ for run_arg in "${run_argv[@]}"; do
701
+ [ "$run_arg" = "--no-dashboard" ] && no_dashboard=1
331
702
  done
332
- local injected=()
333
- [ "$has_host" -eq 1 ] || injected+=(--host 127.0.0.1)
334
- if [ "$mode" = "background" ] && [ "$has_interactive" -eq 0 ]; then
335
- [ "$has_headless" -eq 1 ] || injected+=(--headless)
336
- else
337
- [ "$has_interactive" -eq 1 ] || injected+=(--interactive)
338
- fi
339
- [ "$has_ack" -eq 1 ] || injected+=(--i-understand-that-this-will-be-running-without-the-usual-guardrails)
340
-
341
- write_argv "${injected[@]}" "$@"
703
+ write_argv "${run_argv[@]}"
342
704
  export AIUR_ARGV_FILE="$argv_file"
343
705
 
344
706
  prepare_distribution
@@ -355,6 +717,8 @@ run_session() {
355
717
  conf="$(resolve_tmux_conf)"
356
718
  [ -f "$conf" ] || die "tmux conf not found at $conf"
357
719
 
720
+ preflight_stale_manual_smoke
721
+
358
722
  mkdir -p "$AIUR_BG_STATE_DIR"
359
723
  printf '%s\n' "$session" >"$AIUR_BG_STATE_DIR/state"
360
724
  export AIUR_TMUX_SESSION="$session"
@@ -372,6 +736,11 @@ run_session() {
372
736
  export AIUR_AGENT_TMPFILE="${session_root}/aiur-${$}-agents"
373
737
  : >"$AIUR_AGENT_TMPFILE"
374
738
 
739
+ # Workspace-root handoff for the shell cwd-sweep backstop. The BEAM writes the
740
+ # resolved Aiur.Config.workspace_root() here after config loads.
741
+ export AIUR_WORKSPACE_ROOT_FILE="${session_root}/aiur-${$}-workspace-root"
742
+ : >"$AIUR_WORKSPACE_ROOT_FILE"
743
+
375
744
  # Background runs persist under a known run log dir so (a) the BEAM-death
376
745
  # watchdog, which outlives the BEAM, can drop a crash record next to aiur.log,
377
746
  # and (b) the post-start boot capture is not lost to /tmp when the launcher
@@ -382,6 +751,17 @@ run_session() {
382
751
  export AIUR_LOGS_ROOT
383
752
  fi
384
753
 
754
+ # Capture an erl_crash.dump on daemon BEAM death (#852) so a crash under load
755
+ # is diagnosable instead of vanishing. Written next to the run's aiur.log so it
756
+ # survives the launcher's tempfile cleanup; ERL_CRASH_DUMP_SECONDS bounds the
757
+ # write so a wedged BEAM can't hang the dump indefinitely. Nothing in the
758
+ # release boot disables dumps, and an Executor override of either var is kept.
759
+ # Requires a durable logs root (background run or agent IR sandbox).
760
+ if [ -n "${AIUR_LOGS_ROOT:-}" ]; then
761
+ export ERL_CRASH_DUMP="${ERL_CRASH_DUMP:-$AIUR_LOGS_ROOT/erl_crash.dump}"
762
+ export ERL_CRASH_DUMP_SECONDS="${ERL_CRASH_DUMP_SECONDS:-30}"
763
+ fi
764
+
385
765
  # Capture sink for BEAM startup (and, in background mode, the whole run's
386
766
  # boot stdout/stderr). Foreground uses a throwaway tempfile; background points
387
767
  # at a durable file in the run log dir. The dir/file is created lazily just
@@ -401,13 +781,15 @@ run_session() {
401
781
  {
402
782
  printf '#!/usr/bin/env bash\n'
403
783
  printf 'set -o pipefail\n'
784
+ printf 'unset AIUR_CI_READINESS_TOKEN\n'
404
785
  printf 'cd %q || exit 1\n' "$PWD"
405
786
  local v
406
787
  for v in AIUR_RELEASE_DIR AIUR_ARGV_FILE RELEASE_DISTRIBUTION RELEASE_NODE \
407
788
  RELEASE_COOKIE ERL_AFLAGS ERL_EPMD_ADDRESS AIUR_NODE AIUR_ERLANG_COOKIE \
408
789
  AIUR_TMUX_SESSION AIUR_TMUX_SOCKET AIUR_TMUX_CONF AIUR_BIN \
409
- AIUR_SESSION_TMPFILE AIUR_AGENT_TMPFILE ELIXIR_ERL_OPTIONS AIUR_LOGS_ROOT \
410
- AIUR_OPENCODE_BRIDGE_PORT AIUR_DEBUG; do
790
+ AIUR_SESSION_TMPFILE AIUR_AGENT_TMPFILE AIUR_WORKSPACE_ROOT_FILE \
791
+ ELIXIR_ERL_OPTIONS AIUR_LOGS_ROOT AIUR_OPENCODE_BRIDGE_PORT AIUR_DEFAULT_DASHBOARD_HOST AIUR_DEBUG \
792
+ AIUR_OPERATOR_PID AIUR_NOFILE_SOFT_LIMIT ERL_CRASH_DUMP ERL_CRASH_DUMP_SECONDS; do
411
793
  if [ -n "${!v:-}" ]; then printf 'export %s=%q\n' "$v" "${!v}"; fi
412
794
  done
413
795
  printf 'capture=%q\n' "$startup_capture"
@@ -424,7 +806,8 @@ run_session() {
424
806
  _session_socket="$socket" _session_name="$session" _session_conf="$conf" \
425
807
  _session_tmpfile="$AIUR_SESSION_TMPFILE" _session_capture="$startup_capture" \
426
808
  _session_argv="$argv_file" _session_release="$release_dir" _session_tmux="$tmux_bin" \
427
- _session_node="$AIUR_RELEASE_NODE" _session_pidfile="$AIUR_AGENT_TMPFILE"
809
+ _session_node="$AIUR_RELEASE_NODE" _session_pidfile="$AIUR_AGENT_TMPFILE" \
810
+ _session_workspace_root_file="$AIUR_WORKSPACE_ROOT_FILE"
428
811
  install_foreground_traps
429
812
  fi
430
813
 
@@ -433,10 +816,14 @@ run_session() {
433
816
  # session whose BEAM/control plane is gone should be reclaimed before retry.
434
817
  if [ "$mode" = "background" ] && "$tmux_bin" -L "$socket" -f "$conf" has-session -t "$session" 2>/dev/null; then
435
818
  if [ "$(probe_control_liveness)" = "up" ]; then
819
+ # Write a record only when none exists. A live session's own start already
820
+ # wrote one — and it owns the BEAM-written AIUR_RECORD_WORKSPACE_ROOT_FILE
821
+ # path, which must not be overwritten by this invocation's temp file.
822
+ [ -f "$(aiur_instance_record_path)" ] || (AIUR_WORKSPACE_ROOT_FILE="" write_aiur_instance_record "$session" "$socket")
436
823
  echo "aiur is already running in the background (tmux session ${session})." >&2
437
824
  echo "Use: aiur status # inspect agents" >&2
438
825
  echo "Use: aiur stop # stop it before starting a fresh session" >&2
439
- rm -f "$startup_capture" "$argv_file" "$launcher" "$AIUR_SESSION_TMPFILE" "$AIUR_AGENT_TMPFILE" 2>/dev/null || true
826
+ rm -f "$startup_capture" "$argv_file" "$launcher" "$AIUR_SESSION_TMPFILE" "$AIUR_AGENT_TMPFILE" "$AIUR_WORKSPACE_ROOT_FILE" 2>/dev/null || true
440
827
  return 0
441
828
  fi
442
829
 
@@ -474,7 +861,7 @@ run_session() {
474
861
  -x "${COLUMNS:-200}" -y "${LINES:-50}" "$inner_cmd"; then
475
862
  echo "❌ aiur failed to start; captured output:" >&2
476
863
  tail -n 30 "$startup_capture" 2>/dev/null | sed 's/^/ /' >&2 || true
477
- rm -f "$startup_capture" "$argv_file" "$launcher" "$AIUR_SESSION_TMPFILE" "$AIUR_AGENT_TMPFILE" 2>/dev/null || true
864
+ rm -f "$startup_capture" "$argv_file" "$launcher" "$AIUR_SESSION_TMPFILE" "$AIUR_AGENT_TMPFILE" "$AIUR_WORKSPACE_ROOT_FILE" 2>/dev/null || true
478
865
  exit 1
479
866
  fi
480
867
 
@@ -483,11 +870,11 @@ run_session() {
483
870
  # chat panes it opens. Best-effort — a missing title just shows the default.
484
871
  "$tmux_bin" -L "$socket" -f "$conf" select-pane -t "$session" -T "AIUR Agents" 2>/dev/null || true
485
872
 
486
- # Grace window: surface a boot crash instead of attaching to nothing. For
487
- # headless background runs, also prove the control plane can answer before
488
- # claiming startup succeeded; control commands depend on that RPC path.
489
- local require_control=0
490
- [ "$mode" = "background" ] && require_control=1
873
+ # Grace window: surface a boot crash instead of attaching to nothing. Prove
874
+ # the control plane can answer before declaring startup usable: background
875
+ # control commands depend on that RPC path, and foreground automation must not
876
+ # treat a pre-TUI application crash as a successful `tmux attach`.
877
+ local require_control=1
491
878
  if ! wait_for_session_startup "$tmux_bin" "$socket" "$conf" "$session" "$startup_capture" "$require_control"; then
492
879
  if [ "$mode" = "background" ]; then
493
880
  "$tmux_bin" -L "$socket" -f "$conf" kill-session -t "$session" 2>/dev/null || true
@@ -495,10 +882,17 @@ run_session() {
495
882
  kill_beams_matching "-name ${AIUR_RELEASE_NODE}"
496
883
  fi
497
884
  [ "$mode" = "foreground" ] && return 1
498
- rm -f "$startup_capture" "$argv_file" "$launcher" "$AIUR_SESSION_TMPFILE" "$AIUR_AGENT_TMPFILE" 2>/dev/null || true
885
+ rm -f "$startup_capture" "$argv_file" "$launcher" "$AIUR_SESSION_TMPFILE" "$AIUR_AGENT_TMPFILE" "$AIUR_WORKSPACE_ROOT_FILE" 2>/dev/null || true
499
886
  exit 1
500
887
  fi
501
888
 
889
+ write_aiur_instance_record "$session" "$socket"
890
+ print_dashboard_status "$no_dashboard" "$startup_capture"
891
+
892
+ if [ "$mode" = "foreground" ]; then
893
+ echo "aiur foreground tmux socket ${socket}, session ${session}" >&2
894
+ fi
895
+
502
896
  if [ "$mode" = "background" ]; then
503
897
  # Fresh run: drop any stale crash/stop state from a prior dead instance so
504
898
  # `status` doesn't report a phantom orphan and the watchdog starts clean.
@@ -508,9 +902,9 @@ run_session() {
508
902
  background_watchdog_pid="$(start_beam_death_watchdog \
509
903
  "-name ${AIUR_RELEASE_NODE}" "$socket" "$AIUR_AGENT_TMPFILE" 1 1 \
510
904
  "$AIUR_RELEASE_NODE" "${AIUR_LOGS_ROOT:-}" \
511
- "$(aiur_stop_sentinel_path)" "$(aiur_crash_marker_path)")"
905
+ "$(aiur_stop_sentinel_path)" "$(aiur_crash_marker_path)" "$AIUR_WORKSPACE_ROOT_FILE")"
512
906
  disown "$background_watchdog_pid" 2>/dev/null || true
513
- echo "aiur started in the background (tmux session ${session}). Attach with: aiur" >&2
907
+ echo "aiur started in the background (tmux socket ${socket}, session ${session}). Attach with: aiur" >&2
514
908
  # Keep $startup_capture (boot.out.log) for the run's lifetime; only the
515
909
  # transient argv file is no longer needed.
516
910
  rm -f "$argv_file" 2>/dev/null || true
@@ -524,11 +918,19 @@ run_session() {
524
918
  # the session, and unblocks the attach. It polls the node name rather than a
525
919
  # captured pid, so another Aiur instance from the same release cannot hold it open.
526
920
  _session_watchdog_pid="$(start_beam_death_watchdog \
527
- "-name ${AIUR_RELEASE_NODE}" "$socket" "$AIUR_AGENT_TMPFILE" 1)"
921
+ "-name ${AIUR_RELEASE_NODE}" "$socket" "$AIUR_AGENT_TMPFILE" 1 0 \
922
+ "" "" "" "" "$AIUR_WORKSPACE_ROOT_FILE")"
528
923
 
529
924
  # Foreground: attach the UI. Do not exec — that would drop the teardown trap.
530
- "$tmux_bin" -L "$socket" -f "$conf" attach -t "$session" 2> >(grep -v -F "[server exited]" >&2)
531
- return $?
925
+ # Avoid process substitution here: some sandboxed non-TTY launchers reject
926
+ # opening /dev/fd/* during the real manual-test wrapper path.
927
+ local attach_stderr attach_code
928
+ attach_stderr="$(mktemp "${TMPDIR:-/tmp}/aiur-attach-stderr.XXXXXX")"
929
+ "$tmux_bin" -L "$socket" -f "$conf" attach -t "$session" 2>"$attach_stderr"
930
+ attach_code=$?
931
+ grep -v -F "[server exited]" "$attach_stderr" >&2 || true
932
+ rm -f "$attach_stderr" 2>/dev/null || true
933
+ return "$attach_code"
532
934
  }
533
935
 
534
936
  resolve_tmux_conf() {
@@ -563,6 +965,237 @@ kill_beams_matching() {
563
965
  for pid in $pids; do kill -KILL "$pid" 2>/dev/null || true; done
564
966
  }
565
967
 
968
+ pid_owner() {
969
+ ps -p "$1" -o user= 2>/dev/null | awk '{print $1}' || true
970
+ }
971
+
972
+ pid_command() {
973
+ ps -p "$1" -o command= 2>/dev/null || true
974
+ }
975
+
976
+ pid_ppid() {
977
+ ps -p "$1" -o ppid= 2>/dev/null | awk '{print $1}' || true
978
+ }
979
+
980
+ pid_cwd() {
981
+ local pid="$1" cwd
982
+ if [ -e "/proc/$pid/cwd" ]; then
983
+ readlink "/proc/$pid/cwd" 2>/dev/null || true
984
+ return 0
985
+ fi
986
+ if command -v lsof >/dev/null 2>&1; then
987
+ cwd="$(lsof -a -p "$pid" -d cwd -Fn 2>/dev/null | sed -n 's/^n//p' | head -n 1 || true)"
988
+ [ -n "$cwd" ] && printf '%s\n' "$cwd"
989
+ fi
990
+ }
991
+
992
+ kill_pid_with_escalation() {
993
+ local pid="$1" waited=0
994
+ [ -n "$pid" ] || return 0
995
+ kill -TERM "$pid" 2>/dev/null || true
996
+ while [ "$waited" -lt 20 ]; do
997
+ kill -0 "$pid" 2>/dev/null || return 0
998
+ sleep 0.1
999
+ waited=$((waited + 1))
1000
+ done
1001
+ kill -KILL "$pid" 2>/dev/null || true
1002
+ }
1003
+
1004
+ aiur_node_from_command() {
1005
+ local cmd="$1" node
1006
+ node="$(printf '%s\n' "$cmd" | sed -n "s/.*--name[[:space:]]\\(aiur-${USER}[-A-Za-z0-9_]*@127\\.0\\.0\\.1\\).*/\\1/p" | head -n 1)"
1007
+ [ -n "$node" ] && printf '%s\n' "$node"
1008
+ }
1009
+
1010
+ aiur_release_root_from_command() {
1011
+ local cmd="$1"
1012
+ if [[ "$cmd" =~ --boot-var[[:space:]]+RELEASE_LIB[[:space:]]+([^[:space:]]+)/lib ]]; then
1013
+ printf '%s\n' "${BASH_REMATCH[1]}"
1014
+ return 0
1015
+ fi
1016
+ if [[ "$cmd" =~ ([^[:space:]]*/src/_build/dev/rel/aiur)/releases/ ]]; then
1017
+ printf '%s\n' "${BASH_REMATCH[1]}"
1018
+ return 0
1019
+ fi
1020
+ if [[ "$cmd" =~ ([^[:space:]]*/release)/releases/ ]]; then
1021
+ printf '%s\n' "${BASH_REMATCH[1]}"
1022
+ return 0
1023
+ fi
1024
+ return 1
1025
+ }
1026
+
1027
+ workspace_root_from_aiur_release_root() {
1028
+ local release_root="${1%/}"
1029
+ case "$release_root" in
1030
+ */src/_build/dev/rel/aiur)
1031
+ printf '%s\n' "${release_root%/src/_build/dev/rel/aiur}"
1032
+ ;;
1033
+ *)
1034
+ printf '%s\n' "$release_root"
1035
+ ;;
1036
+ esac
1037
+ }
1038
+
1039
+ aiur_node_tmux_session_alive() {
1040
+ local node="$1" short tmux_bin
1041
+ short="${node%@*}"
1042
+ tmux_bin="$(command -v tmux || true)"
1043
+ [ -n "$tmux_bin" ] || return 1
1044
+ "$tmux_bin" -L "$short" has-session -t "${short}-default" 2>/dev/null
1045
+ }
1046
+
1047
+ stale_manual_smoke_beam_inventory() {
1048
+ local pid cmd node release_root workspace_root owner
1049
+ aiur_resolve_identity
1050
+
1051
+ for pid in $(pgrep -f -- "beam\\.smp.*--name aiur-${USER}.*@127\\.0\\.0\\.1" 2>/dev/null || true); do
1052
+ [ -n "$pid" ] || continue
1053
+ owner="$(pid_owner "$pid")"
1054
+ [ "$owner" = "$USER" ] || continue
1055
+
1056
+ cmd="$(pid_command "$pid")"
1057
+ node="$(aiur_node_from_command "$cmd")"
1058
+ [ -n "$node" ] || continue
1059
+
1060
+ release_root="$(aiur_release_root_from_command "$cmd" || true)"
1061
+ [ -n "$release_root" ] || continue
1062
+ workspace_root="$(workspace_root_from_aiur_release_root "$release_root")"
1063
+
1064
+ # Scope this broad cleanup to stale issue/manual-smoke workspaces. The
1065
+ # current instance cleanup path handles the active daemon's own node.
1066
+ case "$workspace_root" in
1067
+ */aiur-workspaces/*) ;;
1068
+ *) continue ;;
1069
+ esac
1070
+
1071
+ # A matching live aiur tmux session means this node may still be in use.
1072
+ aiur_node_tmux_session_alive "$node" && continue
1073
+
1074
+ printf '%s\t%s\t%s\t%s\n' "$pid" "$node" "$workspace_root" "$release_root"
1075
+ done
1076
+ }
1077
+
1078
+ manual_smoke_wrapper_tmux_sockets() {
1079
+ local tmux_bin sockdir path name panes current pane_path start manual_context all_sleep
1080
+ tmux_bin="$(command -v tmux || true)"
1081
+ [ -n "$tmux_bin" ] || return 0
1082
+ sockdir="${TMUX_TMPDIR:-/tmp}/tmux-$(id -u)"
1083
+ [ -d "$sockdir" ] || return 0
1084
+
1085
+ for path in "$sockdir"/*; do
1086
+ [ -S "$path" ] || continue
1087
+ name="${path##*/}"
1088
+ case "$name" in
1089
+ codex-driver-* | codex-aiur* | codex-[0-9]* | claude-driver*) ;;
1090
+ *) continue ;;
1091
+ esac
1092
+ panes="$("$tmux_bin" -L "$name" list-panes -a -F '#{pane_current_command} #{pane_current_path} #{pane_start_command}' 2>/dev/null || true)"
1093
+ [ -n "$panes" ] || continue
1094
+ manual_context=0
1095
+ all_sleep=1
1096
+ while IFS=$'\t' read -r current pane_path start; do
1097
+ case "$start" in
1098
+ *aiurdev\ --test* | *scripts/aiurdev\ --test*) manual_context=1 ;;
1099
+ esac
1100
+ [ "$current" = "sleep" ] || all_sleep=0
1101
+ done <<<"$panes"
1102
+ [ "$manual_context" -eq 1 ] && [ "$all_sleep" -eq 1 ] && printf '%s\n' "$name"
1103
+ done
1104
+ }
1105
+
1106
+ orphaned_opencode_attach_inventory() {
1107
+ local pid owner ppid cmd cwd
1108
+ for pid in $(pgrep -f -- "opencode attach http://127\\.0\\.0\\.1:" 2>/dev/null || true); do
1109
+ owner="$(pid_owner "$pid")"
1110
+ [ "$owner" = "$USER" ] || continue
1111
+ ppid="$(pid_ppid "$pid")"
1112
+ [ "$ppid" = "1" ] || continue
1113
+ cwd="$(pid_cwd "$pid")"
1114
+ case "$cwd" in
1115
+ */aiur-workspaces/*) ;;
1116
+ *) continue ;;
1117
+ esac
1118
+ cmd="$(pid_command "$pid")"
1119
+ case "$cmd" in
1120
+ *"opencode attach http://127.0.0.1:"*) printf '%s\t%s\t%s\n' "$pid" "$cwd" "$cmd" ;;
1121
+ esac
1122
+ done
1123
+ }
1124
+
1125
+ report_stale_manual_smoke() {
1126
+ local found=0 pid node workspace_root release_root socket cwd cmd
1127
+
1128
+ while IFS=$'\t' read -r pid node workspace_root release_root; do
1129
+ [ -n "$pid" ] || continue
1130
+ if [ "$found" -eq 0 ]; then
1131
+ echo "aiur: stale manual-smoke leftovers detected:" >&2
1132
+ found=1
1133
+ fi
1134
+ echo " BEAM pid=${pid} node=${node} workspace=${workspace_root} release=${release_root}" >&2
1135
+ done < <(stale_manual_smoke_beam_inventory)
1136
+
1137
+ while IFS= read -r socket; do
1138
+ [ -n "$socket" ] || continue
1139
+ if [ "$found" -eq 0 ]; then
1140
+ echo "aiur: stale manual-smoke leftovers detected:" >&2
1141
+ found=1
1142
+ fi
1143
+ echo " wrapper tmux socket=${socket}" >&2
1144
+ done < <(manual_smoke_wrapper_tmux_sockets)
1145
+
1146
+ while IFS=$'\t' read -r pid cwd cmd; do
1147
+ [ -n "$pid" ] || continue
1148
+ if [ "$found" -eq 0 ]; then
1149
+ echo "aiur: stale manual-smoke leftovers detected:" >&2
1150
+ found=1
1151
+ fi
1152
+ echo " orphan opencode attach pid=${pid} workspace=${cwd}" >&2
1153
+ done < <(orphaned_opencode_attach_inventory)
1154
+
1155
+ if [ "$found" -eq 1 ]; then
1156
+ echo "aiur: run 'aiur cleanup-stale' to TERM/KILL only these same-user Aiur leftovers." >&2
1157
+ fi
1158
+
1159
+ return "$found"
1160
+ }
1161
+
1162
+ preflight_stale_manual_smoke() {
1163
+ report_stale_manual_smoke >/dev/null || true
1164
+ }
1165
+
1166
+ reap_stale_manual_smoke() {
1167
+ local include_wrappers="${1:-0}"
1168
+ local pid node workspace_root release_root socket cwd cmd tmux_bin reaped=0
1169
+
1170
+ while IFS=$'\t' read -r pid node workspace_root release_root; do
1171
+ [ -n "$pid" ] || continue
1172
+ echo "aiur: reaping stale BEAM pid=${pid} node=${node} workspace=${workspace_root}" >&2
1173
+ kill_pid_with_escalation "$pid"
1174
+ reaped=$((reaped + 1))
1175
+ done < <(stale_manual_smoke_beam_inventory)
1176
+
1177
+ tmux_bin="$(command -v tmux || true)"
1178
+ if [ "$include_wrappers" = "1" ] && [ -n "$tmux_bin" ]; then
1179
+ while IFS= read -r socket; do
1180
+ [ -n "$socket" ] || continue
1181
+ echo "aiur: reaping stale wrapper tmux socket=${socket}" >&2
1182
+ "$tmux_bin" -L "$socket" kill-server 2>/dev/null || true
1183
+ reaped=$((reaped + 1))
1184
+ done < <(manual_smoke_wrapper_tmux_sockets)
1185
+ fi
1186
+
1187
+ while IFS=$'\t' read -r pid cwd cmd; do
1188
+ [ -n "$pid" ] || continue
1189
+ echo "aiur: reaping orphan opencode attach pid=${pid}" >&2
1190
+ kill_pid_with_escalation "$pid"
1191
+ reaped=$((reaped + 1))
1192
+ done < <(orphaned_opencode_attach_inventory)
1193
+
1194
+ if [ "$reaped" -eq 0 ]; then
1195
+ echo "aiur: no stale manual-smoke leftovers found" >&2
1196
+ fi
1197
+ }
1198
+
566
1199
  # Echo $1 and every descendant pid, depth-first. Mac-safe (`pgrep -P`, no
567
1200
  # /proc), mirroring Aiur.RemoteControl.collect_descendants so the launcher reaps
568
1201
  # the same tree the BEAM-side reaper would. The recorded agent pid is a bash
@@ -596,7 +1229,7 @@ agent_pid_matches() {
596
1229
  #
597
1230
  # tmux runs on aiur's PRIVATE socket (-L aiur-$USER), so kill-server tears down
598
1231
  # every REPL/chat pane agent in one shot AND leaves no live aiur tmux server —
599
- # never touching the operator's own default tmux. Headless agents (claude/codex
1232
+ # never touching the Executor’s own default tmux. Headless agents (claude/codex
600
1233
  # app-servers spawned via Port) are bare OS processes that reparent to init on a
601
1234
  # BEAM crash; kill-server can't see them, so they're reaped from the pidfile by
602
1235
  # process tree, comm-guarded against pid reuse. Idempotent.
@@ -643,6 +1276,35 @@ aiur_state_slug() {
643
1276
  printf '%s' "${AIUR_RELEASE_NODE:-aiur}" | tr -c 'A-Za-z0-9._-' '_'
644
1277
  }
645
1278
 
1279
+ aiur_instances_dir() {
1280
+ printf '%s/instances' "${AIUR_BG_STATE_DIR:?}"
1281
+ }
1282
+
1283
+ aiur_instance_record_path() {
1284
+ printf '%s/%s.instance' "$(aiur_instances_dir)" "$(aiur_state_slug)"
1285
+ }
1286
+
1287
+ write_aiur_instance_record() {
1288
+ local session="$1" socket="$2" record_dir record tmp root
1289
+ record_dir="$(aiur_instances_dir)"
1290
+ record="$(aiur_instance_record_path)"
1291
+ root="$(canonical_workspace_root "${AIUR_PROJECT_ROOT:-}")"
1292
+ mkdir -p "$record_dir" 2>/dev/null || return 0
1293
+ tmp="$(mktemp "$record_dir/.${AIUR_INSTANCE_KEY:-aiur}.XXXXXX" 2>/dev/null)" || return 0
1294
+ {
1295
+ printf 'AIUR_RECORD_NODE=%q\n' "$AIUR_RELEASE_NODE"
1296
+ printf 'AIUR_RECORD_INSTANCE_KEY=%q\n' "$AIUR_INSTANCE_KEY"
1297
+ printf 'AIUR_RECORD_SESSION=%q\n' "$session"
1298
+ printf 'AIUR_RECORD_SOCKET=%q\n' "$socket"
1299
+ printf 'AIUR_RECORD_WORKSPACE_ROOT_FILE=%q\n' "${AIUR_WORKSPACE_ROOT_FILE:-}"
1300
+ printf 'AIUR_RECORD_PROJECT_ROOT=%q\n' "$root"
1301
+ printf 'AIUR_RECORD_PROJECT_ROOT_SOURCE=%q\n' "${AIUR_PROJECT_ROOT_SOURCE:-}"
1302
+ printf 'AIUR_RECORD_WRITTEN_AT=%q\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || true)"
1303
+ } >"$tmp" || { rm -f "$tmp" 2>/dev/null || true; return 0; }
1304
+ chmod 0600 "$tmp" 2>/dev/null || true
1305
+ mv "$tmp" "$record" 2>/dev/null || rm -f "$tmp" 2>/dev/null || true
1306
+ }
1307
+
646
1308
  # Marker `status` reads to tell "daemon crashed, agents may be orphaned" apart
647
1309
  # from "nothing was ever running". Written by the watchdog on an unexpected exit,
648
1310
  # cleared on a clean stop and on the next successful background start.
@@ -690,24 +1352,128 @@ record_beam_crash() {
690
1352
  fi
691
1353
  }
692
1354
 
693
- # Background watchdog that survives the BEAM. Polls for the release BEAM by
694
- # command pattern and, once it has SEEN the BEAM and then the BEAM disappears
695
- # (orderly halt OR :emfile-style crash), reaps every agent. Polling the pattern
696
- # rather than a captured pid avoids two failure modes: a recycled BEAM pid the
697
- # watchdog would poll forever, and an empty pid at arm time that would silently
698
- # disarm the only crash reaper. The `seen` latch prevents a startup race from
699
- # reaping before the BEAM has come up. kill-server collapses the orphaned tmux
700
- # session, which returns the foreground `tmux attach` so the EXIT trap
701
- # (session_cleanup) runs its idempotent reap too.
702
- #
703
- # $1 beam_pattern $2 socket $3 pidfile $4 interval_s $5 initial_seen
704
- # $6 node $7 run_log_dir $8 stop_sentinel $9 crash_marker
705
- # Args 6-9 arm crash recording (background mode). Omit them (foreground) and the
706
- # watchdog only reaps — a foreground BEAM death is already visible at the UI.
707
- # Prints the watchdog's own pid so the caller can kill it on a clean teardown.
708
- start_beam_death_watchdog() {
1355
+ canonical_workspace_root() {
1356
+ local root="$1"
1357
+ if [ -d "$root" ]; then
1358
+ (cd "$root" 2>/dev/null && pwd -P) || printf '%s\n' "$root"
1359
+ else
1360
+ printf '%s\n' "$root"
1361
+ fi
1362
+ }
1363
+
1364
+ workspace_root_is_shallow() {
1365
+ local root="${1%/}" trimmed
1366
+ trimmed="${root#/}"
1367
+ [ -n "$trimmed" ] || return 0
1368
+ case "$trimmed" in
1369
+ */*) return 1 ;;
1370
+ *) return 0 ;;
1371
+ esac
1372
+ }
1373
+
1374
+ workspace_cwd_pids() {
1375
+ local root="${1%/}" proc_dir="${2:-/proc}" entry pid cwd
1376
+ [ -d "$proc_dir" ] || return 0
1377
+
1378
+ for entry in "$proc_dir"/[0-9]*; do
1379
+ [ -d "$entry" ] || continue
1380
+ pid="${entry##*/}"
1381
+ cwd="$(readlink "$entry/cwd" 2>/dev/null || true)"
1382
+ [ -n "$cwd" ] || continue
1383
+ cwd="${cwd%/}"
1384
+ if [ "$cwd" != "$root" ] && [[ "$cwd/" == "$root/"* ]]; then
1385
+ printf '%s\n' "$pid"
1386
+ fi
1387
+ done
1388
+ }
1389
+
1390
+ shell_protected_pid_list() {
1391
+ local p
1392
+ printf '%s\n' "$$"
1393
+ [ "${BASHPID:-$$}" = "$$" ] || printf '%s\n' "$BASHPID"
1394
+ [ -n "${PPID:-}" ] && printf '%s\n' "$PPID"
1395
+ for p in $(agent_pid_tree "$$" 2>/dev/null || true); do
1396
+ printf '%s\n' "$p"
1397
+ done
1398
+ }
1399
+
1400
+ reap_workspace_cwd_agents() {
1401
+ local root="${1:-}"
1402
+ [ -n "$root" ] || return 0
1403
+ [ -d /proc ] || return 0
1404
+
1405
+ root="$(canonical_workspace_root "$root")"
1406
+ if workspace_root_is_shallow "$root"; then
1407
+ echo "⚠️ refusing shallow workspace cwd sweep root: $root" >&2
1408
+ return 0
1409
+ fi
1410
+
1411
+ local max_sweeps="${AIUR_WORKSPACE_REAP_SWEEPS:-6}"
1412
+ case "$max_sweeps" in '' | *[!0-9]*) max_sweeps=6 ;; esac
1413
+ [ "$max_sweeps" -gt 0 ] || return 0
1414
+
1415
+ local protected pids=() filtered=() pid p sweep waited any
1416
+ protected=" $(shell_protected_pid_list | tr '\n' ' ') "
1417
+
1418
+ sweep=0
1419
+ while [ "$sweep" -lt "$max_sweeps" ]; do
1420
+ mapfile -t pids < <(workspace_cwd_pids "$root" /proc)
1421
+ filtered=()
1422
+ for pid in "${pids[@]}"; do
1423
+ case "$protected" in *" $pid "*) continue ;; esac
1424
+ kill -0 "$pid" 2>/dev/null && filtered+=("$pid")
1425
+ done
1426
+
1427
+ [ "${#filtered[@]}" -gt 0 ] || return 0
1428
+
1429
+ for p in "${filtered[@]}"; do kill -TERM "$p" 2>/dev/null || true; done
1430
+ waited=0
1431
+ while [ "$waited" -lt 10 ]; do
1432
+ any=0
1433
+ for p in "${filtered[@]}"; do kill -0 "$p" 2>/dev/null && { any=1; break; }; done
1434
+ [ "$any" -eq 0 ] && break
1435
+ sleep 0.1
1436
+ waited=$((waited + 1))
1437
+ done
1438
+ for p in "${filtered[@]}"; do kill -KILL "$p" 2>/dev/null || true; done
1439
+
1440
+ sweep=$((sweep + 1))
1441
+ sleep 0.1
1442
+ done
1443
+ }
1444
+
1445
+ reap_workspace_cwd_from_file() {
1446
+ local root_file="${1:-}" root
1447
+ [ -n "$root_file" ] && [ -s "$root_file" ] || return 0
1448
+ IFS= read -r root <"$root_file" || root=""
1449
+ reap_workspace_cwd_agents "$root"
1450
+ }
1451
+
1452
+ workspace_root_file_from_instance_record() {
1453
+ load_aiur_instance_record "$(aiur_instance_record_path)" || return 1
1454
+ [ -n "${AIUR_RECORD_WORKSPACE_ROOT_FILE:-}" ] || return 1
1455
+ printf '%s\n' "$AIUR_RECORD_WORKSPACE_ROOT_FILE"
1456
+ }
1457
+
1458
+ # Background watchdog that survives the BEAM. Polls for the release BEAM by
1459
+ # command pattern and, once it has SEEN the BEAM and then the BEAM disappears
1460
+ # (orderly halt OR :emfile-style crash), reaps every agent. Polling the pattern
1461
+ # rather than a captured pid avoids two failure modes: a recycled BEAM pid the
1462
+ # watchdog would poll forever, and an empty pid at arm time that would silently
1463
+ # disarm the only crash reaper. The `seen` latch prevents a startup race from
1464
+ # reaping before the BEAM has come up. kill-server collapses the orphaned tmux
1465
+ # session, which returns the foreground `tmux attach` so the EXIT trap
1466
+ # (session_cleanup) runs its idempotent reap too.
1467
+ #
1468
+ # $1 beam_pattern $2 socket $3 pidfile $4 interval_s $5 initial_seen
1469
+ # $6 node $7 run_log_dir $8 stop_sentinel $9 crash_marker
1470
+ # $10 workspace_root_file
1471
+ # Args 6-9 arm crash recording (background mode). Omit them (foreground) and the
1472
+ # watchdog only reaps — a foreground BEAM death is already visible at the UI.
1473
+ # Prints the watchdog's own pid so the caller can kill it on a clean teardown.
1474
+ start_beam_death_watchdog() {
709
1475
  local beam_pattern="$1" socket="$2" pidfile="$3" interval="${4:-1}" initial_seen="${5:-0}"
710
- local node="${6:-}" run_log_dir="${7:-}" stop_sentinel="${8:-}" crash_marker="${9:-}"
1476
+ local node="${6:-}" run_log_dir="${7:-}" stop_sentinel="${8:-}" crash_marker="${9:-}" workspace_root_file="${10:-}"
711
1477
  # Redirect the subshell's stdout so a command-substitution caller
712
1478
  # (`pid=$(start_beam_death_watchdog ...)`) returns immediately instead of
713
1479
  # blocking on the still-open pipe until the watchdog finishes.
@@ -730,13 +1496,17 @@ start_beam_death_watchdog() {
730
1496
  record_beam_crash "$node" "$run_log_dir" "$crash_marker"
731
1497
  fi
732
1498
  reap_aiur_agents "$socket" "$pidfile"
1499
+ reap_workspace_cwd_from_file "$workspace_root_file"
733
1500
  ) >/dev/null 2>&1 &
734
1501
  printf '%s\n' "$!"
735
1502
  }
736
1503
 
737
1504
  probe_control_liveness() {
738
1505
  local expression output status
739
- expression='case Process.whereis(Aiur.Orchestrator) do pid when is_pid(pid) -> case Aiur.Orchestrator.status(Aiur.Orchestrator, 100) do statuses when is_list(statuses) -> IO.puts("__AIUR_CONTROL_READY__"); _ -> IO.puts("__AIUR_CONTROL_NOT_READY__") end; _ -> IO.puts("__AIUR_CONTROL_NOT_READY__") end'
1506
+ # The Orchestrator starts before the dashboard child, so an answering status
1507
+ # call alone does not prove startup (or the bind attempt) finished. Wait until
1508
+ # OTP marks :aiur started before printing the effective dashboard status.
1509
+ expression='aiur_started = Enum.any?(Application.started_applications(), fn {app, _description, _version} -> app == :aiur end); case {Process.whereis(Aiur.Orchestrator), aiur_started} do {pid, true} when is_pid(pid) -> case Aiur.Orchestrator.status(Aiur.Orchestrator, 100) do statuses when is_list(statuses) -> IO.puts("__AIUR_CONTROL_READY__"); _ -> IO.puts("__AIUR_CONTROL_NOT_READY__") end; _ -> IO.puts("__AIUR_CONTROL_NOT_READY__") end'
740
1510
 
741
1511
  set +e
742
1512
  output="$("$release_bin" rpc "$expression" 2>&1)"
@@ -750,11 +1520,51 @@ probe_control_liveness() {
750
1520
  fi
751
1521
  }
752
1522
 
1523
+ # Return the useful URL and effective bind host/port only when the running node
1524
+ # confirms that Bandit actually bound a listener. An empty result means the
1525
+ # listener was suppressed or refused; configured server.port alone is never
1526
+ # proof of service.
1527
+ probe_dashboard_status() {
1528
+ local expression output status marker="__AIUR_DASHBOARD_STATUS__:"
1529
+ expression='case {Aiur.HttpServer.base_url(), Aiur.Config.server_host(), Aiur.HttpServer.bound_port()} do {url, host, port} when is_binary(url) and is_binary(host) and is_integer(port) -> IO.puts("__AIUR_DASHBOARD_STATUS__:Dashboard: " <> url <> " (bind host=" <> host <> ", port=" <> Integer.to_string(port) <> ")"); _ -> :ok end'
1530
+
1531
+ set +e
1532
+ run_release_rpc_with_timeout "$expression"
1533
+ status=$?
1534
+ set -e
1535
+ output="$AIUR_CONTROL_RPC_OUTPUT"
1536
+
1537
+ if [ "$status" -eq 0 ] && [[ "$output" == *"$marker"* ]]; then
1538
+ output="${output#*"$marker"}"
1539
+ printf '%s' "${output%%$'\n'*}"
1540
+ fi
1541
+ }
1542
+
1543
+ print_dashboard_status() {
1544
+ local no_dashboard="$1" startup_capture="$2" dashboard_status
1545
+
1546
+ if [ "$no_dashboard" -eq 1 ]; then
1547
+ echo "Dashboard disabled by --no-dashboard." >&2
1548
+ return
1549
+ fi
1550
+
1551
+ dashboard_status="$(probe_dashboard_status)"
1552
+ if [ -n "$dashboard_status" ]; then
1553
+ echo "${dashboard_status}" >&2
1554
+ else
1555
+ echo "⚠️ dashboard listener unavailable; inspect ${startup_capture} for bind or authentication refusal." >&2
1556
+ fi
1557
+ }
1558
+
753
1559
  wait_for_session_startup() {
754
1560
  local tmux_bin="$1" socket="$2" conf="$3" session="$4" startup_capture="$5" require_control="$6"
755
1561
  local max_ticks="${AIUR_TMUX_GRACE_TICKS:-30}" tick control_state
756
1562
  if [ "$require_control" = "1" ]; then
757
- max_ticks="${AIUR_NODE_GRACE_TICKS:-100}"
1563
+ # 0.1s per tick. The old 100-tick (10s) budget was under a cold control
1564
+ # plane's real boot time, so a healthy daemon was killed mid-startup and
1565
+ # reported as a failure. Boot time grows with the module count; keep the
1566
+ # headroom generous, since the loop exits as soon as the probe says "up".
1567
+ max_ticks="${AIUR_NODE_GRACE_TICKS:-1200}"
758
1568
  fi
759
1569
 
760
1570
  for ((tick = 0; tick < max_ticks; tick++)); do
@@ -778,7 +1588,16 @@ wait_for_session_startup() {
778
1588
  done
779
1589
 
780
1590
  if [ "$require_control" = "1" ]; then
781
- echo "❌ aiur control plane did not become ready at ${RELEASE_NODE:-unknown} during startup; captured output:" >&2
1591
+ # A live session at this point means the BEAM never died -- we ran out of
1592
+ # patience, not the daemon out of health. Saying so is the difference
1593
+ # between "your release is broken" and "give it longer", and the empty
1594
+ # capture below is evidence of the latter, not of a silent crash.
1595
+ if "$tmux_bin" -L "$socket" -f "$conf" has-session -t "$session" 2>/dev/null; then
1596
+ echo "❌ aiur control plane at ${RELEASE_NODE:-unknown} was still booting after $(awk "BEGIN{printf \"%.0f\", ${max_ticks} / 10}")s; the BEAM is alive but not yet answering." >&2
1597
+ echo " Raise the budget with AIUR_NODE_GRACE_TICKS (ticks of 0.1s) if this box is just slow." >&2
1598
+ else
1599
+ echo "❌ aiur control plane did not become ready at ${RELEASE_NODE:-unknown} during startup; captured output:" >&2
1600
+ fi
782
1601
  tail -n 30 "$startup_capture" 2>/dev/null | sed 's/^/ /' >&2 || true
783
1602
  return 1
784
1603
  fi
@@ -789,7 +1608,7 @@ wait_for_session_startup() {
789
1608
  # Foreground teardown: kill the session + BEAM and reap opencode sessions on exit.
790
1609
  _session_socket="" _session_name="" _session_conf="" _session_tmpfile=""
791
1610
  _session_capture="" _session_argv="" _session_release="" _session_tmux="" _session_node=""
792
- _session_pidfile="" _session_watchdog_pid=""
1611
+ _session_pidfile="" _session_watchdog_pid="" _session_workspace_root_file=""
793
1612
  _cleanup_ran=0
794
1613
  session_cleanup() {
795
1614
  [ "$_cleanup_ran" = 1 ] && return 0
@@ -819,6 +1638,11 @@ session_cleanup() {
819
1638
  # release dir, so release-path pgrep would terminate sibling workflows.
820
1639
  [ -n "$_session_node" ] && kill_beams_matching "-name ${_session_node}"
821
1640
 
1641
+ # Final cwd-sweep backstop after the BEAM is gone: catches workspace-rooted
1642
+ # agents or test children that registered too late, reparented during the
1643
+ # BEAM-side sweep, or survived a bounded in-BEAM cleanup.
1644
+ reap_workspace_cwd_from_file "$_session_workspace_root_file"
1645
+
822
1646
  # Reap the epmd our BEAM spawned for distribution so it doesn't linger after
823
1647
  # exit. The node is dead by now, so `epmd -kill` succeeds; it safely refuses
824
1648
  # while any *other* Erlang node is alive, so a daemon shared with an unrelated
@@ -846,7 +1670,8 @@ session_cleanup() {
846
1670
  # tempfiles are spared (fresh + live pid) before the explicit rm below clears them.
847
1671
  sweep_stale_tmp_artifacts || true
848
1672
 
849
- rm -f "$_session_tmpfile" "$_session_capture" "$_session_argv" "$_session_pidfile" 2>/dev/null || true
1673
+ rm -f "$_session_tmpfile" "$_session_capture" "$_session_argv" "$_session_pidfile" "$_session_workspace_root_file" 2>/dev/null || true
1674
+ rm -f "$(aiur_instance_record_path)" 2>/dev/null || true
850
1675
  return $code
851
1676
  }
852
1677
  install_foreground_traps() {
@@ -891,21 +1716,281 @@ probe_node_liveness() {
891
1716
  printf 'unknown'
892
1717
  }
893
1718
 
1719
+ probe_named_node_liveness() {
1720
+ local node="$1" old_node="${RELEASE_NODE:-}" state
1721
+ RELEASE_NODE="$node"
1722
+ state="$(probe_node_liveness)"
1723
+ RELEASE_NODE="$old_node"
1724
+ printf '%s' "$state"
1725
+ }
1726
+
1727
+ load_aiur_instance_record() {
1728
+ local file="$1"
1729
+ AIUR_RECORD_NODE=""
1730
+ AIUR_RECORD_INSTANCE_KEY=""
1731
+ AIUR_RECORD_SESSION=""
1732
+ AIUR_RECORD_SOCKET=""
1733
+ AIUR_RECORD_WORKSPACE_ROOT_FILE=""
1734
+ AIUR_RECORD_PROJECT_ROOT=""
1735
+ AIUR_RECORD_PROJECT_ROOT_SOURCE=""
1736
+ [ -r "$file" ] || return 1
1737
+ # Records are written by this engine as KEY=%q lines under the user's state dir.
1738
+ # shellcheck disable=SC1090
1739
+ source "$file" 2>/dev/null || return 1
1740
+ [ -n "$AIUR_RECORD_NODE" ] && [ -n "$AIUR_RECORD_SESSION" ] && \
1741
+ [ -n "$AIUR_RECORD_SOCKET" ] && [ -n "$AIUR_RECORD_PROJECT_ROOT" ]
1742
+ }
1743
+
1744
+ path_is_within_root() {
1745
+ local root child
1746
+ root="$(canonical_workspace_root "$1")"
1747
+ child="$(canonical_workspace_root "$2")"
1748
+ [ "$child" = "$root" ] || [[ "$child/" == "$root/"* ]]
1749
+ }
1750
+
1751
+ AIUR_CONTROL_ADOPTED_RECORD=0
1752
+ AIUR_CONTROL_CURRENT_NODE_STATE=""
1753
+ AIUR_CONTROL_HINT_ROOTS=""
1754
+ AIUR_CONTROL_CALLER_ROOT=""
1755
+ AIUR_CONTROL_CALLER_NODE=""
1756
+ AIUR_CONTROL_CALLER_ROOT_SOURCE=""
1757
+
1758
+ resolve_control_identity_from_records() {
1759
+ AIUR_CONTROL_ADOPTED_RECORD=0
1760
+ AIUR_CONTROL_CURRENT_NODE_STATE="$(probe_node_liveness)"
1761
+ AIUR_CONTROL_HINT_ROOTS=""
1762
+ AIUR_CONTROL_CALLER_ROOT="$(canonical_workspace_root "${AIUR_PROJECT_ROOT:-}")"
1763
+ AIUR_CONTROL_CALLER_NODE="$AIUR_RELEASE_NODE"
1764
+ AIUR_CONTROL_CALLER_ROOT_SOURCE="${AIUR_PROJECT_ROOT_SOURCE:-}"
1765
+
1766
+ [ "$AIUR_CONTROL_CURRENT_NODE_STATE" = "down" ] || return 0
1767
+ [ "${AIUR_PROJECT_ROOT_SOURCE:-}" = "cwd" ] || return 0
1768
+
1769
+ local dir file state root match_count=0
1770
+ local match_node="" match_key="" match_session="" match_socket="" match_root="" match_source=""
1771
+ dir="$(aiur_instances_dir)"
1772
+ [ -d "$dir" ] || return 0
1773
+
1774
+ for file in "$dir"/*.instance; do
1775
+ [ -e "$file" ] || continue
1776
+ load_aiur_instance_record "$file" || continue
1777
+ state="$(probe_named_node_liveness "$AIUR_RECORD_NODE")"
1778
+ [ "$state" = "up" ] || continue
1779
+
1780
+ root="$(canonical_workspace_root "$AIUR_RECORD_PROJECT_ROOT")"
1781
+ case "$AIUR_CONTROL_HINT_ROOTS" in
1782
+ *"
1783
+ $root
1784
+ "*) ;;
1785
+ *) AIUR_CONTROL_HINT_ROOTS="${AIUR_CONTROL_HINT_ROOTS}${root}
1786
+ " ;;
1787
+ esac
1788
+
1789
+ path_is_within_root "$root" "$AIUR_CONTROL_CALLER_ROOT" || continue
1790
+ match_count=$((match_count + 1))
1791
+ match_node="$AIUR_RECORD_NODE"
1792
+ match_key="$AIUR_RECORD_INSTANCE_KEY"
1793
+ match_session="$AIUR_RECORD_SESSION"
1794
+ match_socket="$AIUR_RECORD_SOCKET"
1795
+ match_root="$root"
1796
+ match_source="$AIUR_RECORD_PROJECT_ROOT_SOURCE"
1797
+ done
1798
+
1799
+ [ "$match_count" -eq 1 ] || return 0
1800
+
1801
+ AIUR_RELEASE_NODE="$match_node"
1802
+ AIUR_INSTANCE_KEY="$match_key"
1803
+ AIUR_PROJECT_ROOT="$match_root"
1804
+ AIUR_PROJECT_ROOT_SOURCE="$match_source"
1805
+ AIUR_ADOPTED_TMUX_SESSION="$match_session"
1806
+ AIUR_ADOPTED_TMUX_SOCKET="$match_socket"
1807
+ export AIUR_RELEASE_NODE AIUR_INSTANCE_KEY AIUR_PROJECT_ROOT AIUR_PROJECT_ROOT_SOURCE \
1808
+ AIUR_ADOPTED_TMUX_SESSION AIUR_ADOPTED_TMUX_SOCKET
1809
+ RELEASE_NODE="$AIUR_RELEASE_NODE"
1810
+ export RELEASE_NODE
1811
+ AIUR_CONTROL_ADOPTED_RECORD=1
1812
+ }
1813
+
1814
+ print_global_config_control_hint() {
1815
+ [ -n "${AIUR_CONTROL_HINT_ROOTS:-}" ] || return 0
1816
+ [ "${AIUR_CONTROL_CALLER_ROOT_SOURCE:-}" = "cwd" ] || return 0
1817
+ [ "${AIUR_CONTROL_CURRENT_NODE_STATE:-}" = "down" ] || return 0
1818
+
1819
+ echo "aiur: global-config control identity is keyed by cwd ${AIUR_CONTROL_CALLER_ROOT:-${AIUR_PROJECT_ROOT:-unknown}}" >&2
1820
+ echo "aiur: run control commands from the launch directory, or from a subdirectory of that launch directory" >&2
1821
+ echo "aiur: live launch directory candidate(s):" >&2
1822
+ printf '%s' "$AIUR_CONTROL_HINT_ROOTS" | sed '/^$/d; s/^/ /' >&2
1823
+ }
1824
+
1825
+ control_rpc_timeout_seconds() {
1826
+ local seconds="${AIUR_CONTROL_RPC_TIMEOUT_SECONDS:-10}"
1827
+ case "$seconds" in
1828
+ '' | *[!0-9]* | 0) seconds=10 ;;
1829
+ esac
1830
+ printf '%s' "$seconds"
1831
+ }
1832
+
1833
+ print_not_running_message() {
1834
+ echo "error: aiur is not running. Start it with \`aiurdev run\` (or \`aiurdev --bg\`), then retry." >&2
1835
+ }
1836
+
1837
+ print_control_down_message() {
1838
+ local crash_marker
1839
+ crash_marker="$(aiur_crash_marker_path)"
1840
+ if [ -f "$crash_marker" ]; then
1841
+ echo "aiur: background daemon at ${RELEASE_NODE} is DOWN after an unexpected exit; agents may be orphaned" >&2
1842
+ sed 's/^/ /' "$crash_marker" >&2 2>/dev/null || true
1843
+ echo "aiur: run 'aiur stop' to reap any orphaned agents, then start aiur again" >&2
1844
+ else
1845
+ print_not_running_message
1846
+ print_global_config_control_hint
1847
+ fi
1848
+ }
1849
+
1850
+ kill_control_rpc_process() {
1851
+ local pid="$1" grouped="$2" p tree=()
1852
+ [ -n "$pid" ] || return 0
1853
+
1854
+ # Snapshot descendants before signalling the wrapper. A release launcher may
1855
+ # fork its rpc BEAM into another process group and then exit; group signalling
1856
+ # alone would orphan that child and lose the only relationship we can reap.
1857
+ while IFS= read -r p; do tree+=("$p"); done < <(agent_pid_tree "$pid")
1858
+
1859
+ if [ "$grouped" = "1" ]; then
1860
+ kill -TERM "-$pid" 2>/dev/null || kill -TERM "$pid" 2>/dev/null || true
1861
+ fi
1862
+
1863
+ for p in "${tree[@]}"; do kill -TERM "$p" 2>/dev/null || true; done
1864
+ sleep 0.2
1865
+ if [ "$grouped" = "1" ]; then
1866
+ kill -KILL "-$pid" 2>/dev/null || true
1867
+ fi
1868
+ for p in "${tree[@]}"; do kill -KILL "$p" 2>/dev/null || true; done
1869
+ }
1870
+
1871
+ run_release_rpc_with_timeout() {
1872
+ local expression="$1" timeout output_file timeout_file pid watchdog_pid status grouped=0
1873
+ timeout="$(control_rpc_timeout_seconds)"
1874
+ output_file="$(mktemp "${TMPDIR:-/tmp}/aiur-control-rpc-output.XXXXXX")"
1875
+ timeout_file="$(mktemp "${TMPDIR:-/tmp}/aiur-control-rpc-timeout.XXXXXX")"
1876
+ rm -f "$timeout_file" 2>/dev/null || true
1877
+
1878
+ if command -v setsid >/dev/null 2>&1; then
1879
+ setsid "$release_bin" rpc "$expression" >"$output_file" 2>&1 &
1880
+ grouped=1
1881
+ else
1882
+ "$release_bin" rpc "$expression" >"$output_file" 2>&1 &
1883
+ fi
1884
+ pid=$!
1885
+
1886
+ (
1887
+ sleep "$timeout"
1888
+ if kill -0 "$pid" 2>/dev/null; then
1889
+ : >"$timeout_file"
1890
+ kill_control_rpc_process "$pid" "$grouped"
1891
+ fi
1892
+ ) &
1893
+ watchdog_pid=$!
1894
+
1895
+ if wait "$pid"; then
1896
+ status=0
1897
+ else
1898
+ status=$?
1899
+ fi
1900
+ if [ -f "$timeout_file" ]; then
1901
+ # The timeout watchdog owns descendant cleanup. Once it has marked the
1902
+ # timeout, let it finish its TERM/KILL sequence; cancelling it here can
1903
+ # strand descendants that ignored TERM after the root process exits.
1904
+ :
1905
+ else
1906
+ kill "$watchdog_pid" 2>/dev/null || true
1907
+ fi
1908
+ wait "$watchdog_pid" 2>/dev/null || true
1909
+
1910
+ AIUR_CONTROL_RPC_OUTPUT="$(cat "$output_file" 2>/dev/null || true)"
1911
+ if [ -f "$timeout_file" ]; then
1912
+ AIUR_CONTROL_RPC_TIMED_OUT=1
1913
+ status=124
1914
+ else
1915
+ AIUR_CONTROL_RPC_TIMED_OUT=0
1916
+ fi
1917
+
1918
+ rm -f "$output_file" "$timeout_file" 2>/dev/null || true
1919
+ return "$status"
1920
+ }
1921
+
1922
+ control_command_label() {
1923
+ printf '%s' "${AIUR_CONTROL_COMMAND:-control rpc}"
1924
+ }
1925
+
1926
+ # Every line a control RPC surfaces to the operator routes through one of these
1927
+ # two helpers, so `run_control_rpc` can prove it never returns non-zero while
1928
+ # saying nothing (#1684): a silent failure is indistinguishable from a healthy
1929
+ # idle fleet, and nobody reads exit codes interactively.
1930
+ control_rpc_say() {
1931
+ AIUR_CONTROL_RPC_DIAGNOSED=1
1932
+ printf '%s\n' "$1" >&2
1933
+ }
1934
+
1935
+ control_rpc_echo_output() {
1936
+ [ -n "$1" ] || return 0
1937
+ AIUR_CONTROL_RPC_DIAGNOSED=1
1938
+ printf '%s\n' "$1" >&2
1939
+ }
1940
+
894
1941
  # RPC an expression into the running node. The control CLI prints a trailing
895
1942
  # `__AIUR_CONTROL_EXIT__:<code>` marker we translate into the process exit code.
1943
+ #
1944
+ # Wrapper contract: a non-zero return ALWAYS carries at least one stderr line
1945
+ # naming the command that failed. EX_TEMPFAIL (75) is exempt — it is the dev
1946
+ # shim's internal rebuild-and-retry signal, never an operator-visible outcome.
896
1947
  run_control_rpc() {
1948
+ local status=0
1949
+
1950
+ AIUR_CONTROL_RPC_DIAGNOSED=0
1951
+ run_control_rpc_dispatch "$@" || status=$?
1952
+
1953
+ if [ "$status" -ne 0 ] && [ "$status" -ne 75 ] && [ "${AIUR_CONTROL_RPC_DIAGNOSED:-0}" -ne 1 ]; then
1954
+ if [ "$status" -eq 124 ]; then
1955
+ control_rpc_say "aiur: $(control_command_label) to ${RELEASE_NODE:-the daemon} timed out after $(control_rpc_timeout_seconds)s; outcome is unknown. The daemon did not reply within the budget - commonly one blocked process inside it, not host load. 'aiur alerts' is answered by a different process and can confirm the daemon is alive."
1956
+ else
1957
+ control_rpc_say "aiur: $(control_command_label) failed (exit ${status}) and produced no diagnostic output; outcome is unknown. 'aiur alerts' is answered by a different process and can confirm the daemon is alive."
1958
+ fi
1959
+ fi
1960
+
1961
+ return "$status"
1962
+ }
1963
+
1964
+ run_control_rpc_dispatch() {
897
1965
  local expression="$1"
898
- resolve_release
1966
+ resolve_release || return $?
899
1967
  prepare_distribution || die "distribution setup failed; cannot contact aiur"
1968
+ resolve_control_identity_from_records
1969
+ if [ "${AIUR_CONTROL_ADOPTED_RECORD:-0}" -eq 1 ]; then
1970
+ prepare_distribution || die "distribution setup failed; cannot contact aiur"
1971
+ fi
900
1972
 
901
- local marker="__AIUR_CONTROL_EXIT__:"
902
- local output status exit_code=0 saw_marker=0 line
1973
+ local marker="__AIUR_CONTROL_EXIT__:" error_marker="__AIUR_CONTROL_ERROR__:"
1974
+ local output status exit_code=0 saw_marker=0 saw_error=0 saw_output=0 line partial_suffix=""
903
1975
 
904
1976
  set +e
905
- output="$("$release_bin" rpc "$expression" 2>&1)"
1977
+ run_release_rpc_with_timeout "$expression"
906
1978
  status=$?
1979
+ output="$AIUR_CONTROL_RPC_OUTPUT"
907
1980
  set -e
908
1981
 
1982
+ if [ "$status" -ne 0 ] && [ "${AIUR_CONTROL_RELEASE_RETRYABLE:-0}" = "1" ] && \
1983
+ { [ ! -x "$release_bin" ] || [ ! -x "$vsn_dir/elixir" ] || [ ! -r "$release_dir/releases/start_erl.data" ]; }; then
1984
+ control_release_retry
1985
+ return $?
1986
+ fi
1987
+
1988
+ if [ "${AIUR_CONTROL_RPC_TIMED_OUT:-0}" -eq 1 ]; then
1989
+ [ -n "$output" ] && partial_suffix="; partial output was discarded"
1990
+ control_rpc_say "aiur: $(control_command_label) to ${RELEASE_NODE} timed out after $(control_rpc_timeout_seconds)s; outcome is unknown${partial_suffix}. Commonly one blocked process inside the daemon, not host load."
1991
+ return 124
1992
+ fi
1993
+
909
1994
  if [ "$status" -ne 0 ]; then
910
1995
  # A non-zero exit here is the rpc transport failing — application-level
911
1996
  # outcomes ride the marker path below. `bin/aiur rpc` (Elixir --rpc-eval)
@@ -915,25 +2000,31 @@ run_control_rpc() {
915
2000
  # node epmd confirms is down earns the friendly "start aiur" hint; an `up`
916
2001
  # node failed for a real reason, and an `unknown` probe must not be assumed
917
2002
  # down — in both of those cases surface the actual rpc output, never mask it.
2003
+ #
2004
+ # An empty buffer is its own case: `--rpc-eval` kills itself without a word
2005
+ # when the evaluated expression exits (a GenServer call timing out against a
2006
+ # saturated daemon does exactly that), so "see the error above" would point
2007
+ # at nothing (#1684). Name the failure instead.
918
2008
  case "$(probe_node_liveness)" in
919
2009
  down)
920
- local crash_marker
921
- crash_marker="$(aiur_crash_marker_path)"
922
- if [ -f "$crash_marker" ]; then
923
- echo "aiur: background daemon at ${RELEASE_NODE} is DOWN after an unexpected exit; agents may be orphaned" >&2
924
- sed 's/^/ /' "$crash_marker" >&2 2>/dev/null || true
925
- echo "aiur: run 'aiur stop' to reap any orphaned agents, then start aiur again" >&2
926
- else
927
- echo "aiur: no running aiur node at ${RELEASE_NODE}; start aiur and try again" >&2
928
- fi
2010
+ print_control_down_message
2011
+ AIUR_CONTROL_RPC_DIAGNOSED=1
929
2012
  ;;
930
2013
  up)
931
- [ -n "$output" ] && printf '%s\n' "$output" >&2
932
- echo "aiur: rpc to ${RELEASE_NODE} failed (node is running); see the error above" >&2
2014
+ control_rpc_echo_output "$output"
2015
+ if [ -n "$output" ]; then
2016
+ control_rpc_say "aiur: $(control_command_label) failed against ${RELEASE_NODE} (node is running); see the error above"
2017
+ else
2018
+ control_rpc_say "aiur: $(control_command_label) failed against ${RELEASE_NODE} with no diagnostic output (node is running); outcome is unknown. 'aiur alerts' is answered by a different process and can confirm the daemon is alive."
2019
+ fi
933
2020
  ;;
934
2021
  *)
935
- [ -n "$output" ] && printf '%s\n' "$output" >&2
936
- echo "aiur: rpc to ${RELEASE_NODE} failed (could not query epmd to confirm node state); see the error above" >&2
2022
+ control_rpc_echo_output "$output"
2023
+ if [ -n "$output" ]; then
2024
+ control_rpc_say "aiur: $(control_command_label) failed against ${RELEASE_NODE} (could not query epmd to confirm node state); see the error above"
2025
+ else
2026
+ control_rpc_say "aiur: $(control_command_label) failed against ${RELEASE_NODE} with no output (could not query epmd to confirm node state)"
2027
+ fi
937
2028
  ;;
938
2029
  esac
939
2030
  return 1
@@ -945,15 +2036,55 @@ run_control_rpc() {
945
2036
  saw_marker=1
946
2037
  exit_code="${line#"$marker"}"
947
2038
  ;;
2039
+ "$error_marker"*)
2040
+ saw_error=1
2041
+ AIUR_CONTROL_RPC_DIAGNOSED=1
2042
+ printf '%s\n' "${line#"$error_marker"}" >&2
2043
+ ;;
948
2044
  :ok | "") ;;
949
- *) printf '%s\n' "$line" ;;
2045
+ *)
2046
+ saw_output=1
2047
+ AIUR_CONTROL_RPC_DIAGNOSED=1
2048
+ printf '%s\n' "$line"
2049
+ ;;
950
2050
  esac
951
2051
  done <<<"$output"
952
2052
 
953
- [ "$saw_marker" -eq 1 ] || return 1
2053
+ if [ "$saw_marker" -ne 1 ]; then
2054
+ control_rpc_say "aiur: $(control_command_label) to ${RELEASE_NODE} returned no exit marker; command output may be incomplete"
2055
+ return 1
2056
+ fi
2057
+
2058
+ if [ "$exit_code" -ne 0 ] && [ "$saw_error" -ne 1 ] && [ "$saw_output" -ne 1 ]; then
2059
+ control_rpc_say "aiur: $(control_command_label) failed with exit ${exit_code} and returned no diagnostic output"
2060
+ fi
2061
+
954
2062
  return "$exit_code"
955
2063
  }
956
2064
 
2065
+ # A streaming Executor listener must retain the RPC process and its stdout. It
2066
+ # intentionally bypasses the one-shot control timeout and exit-marker wrapper.
2067
+ run_control_stream() {
2068
+ local expression="$1"
2069
+ resolve_release || return $?
2070
+ prepare_distribution || die "distribution setup failed; cannot contact aiur"
2071
+ resolve_control_identity_from_records
2072
+ if [ "${AIUR_CONTROL_ADOPTED_RECORD:-0}" -eq 1 ]; then
2073
+ prepare_distribution || die "distribution setup failed; cannot contact aiur"
2074
+ fi
2075
+ if [ "$(probe_node_liveness)" = "down" ]; then
2076
+ print_control_down_message
2077
+ return 1
2078
+ fi
2079
+ if "$release_bin" rpc "$expression"; then
2080
+ return 0
2081
+ else
2082
+ local status=$?
2083
+ echo "aiur: streaming control RPC failed with exit ${status}" >&2
2084
+ return "$status"
2085
+ fi
2086
+ }
2087
+
957
2088
  elixir_list_literal() {
958
2089
  local first=1 item
959
2090
  printf '['
@@ -997,11 +2128,26 @@ cmd_status() {
997
2128
  run_control_rpc "Aiur.AgentControlCLI.status()"
998
2129
  }
999
2130
 
2131
+ # `aiur usage` — Codex/Claude limit headroom from the daemon's meter
2132
+ # projection, each value carrying the age of its observation.
2133
+ cmd_usage() {
2134
+ [ "$#" -eq 0 ] || die "usage does not accept arguments"
2135
+ run_control_rpc "Aiur.AgentControlCLI.usage()"
2136
+ }
2137
+
1000
2138
  cmd_pause_resume() {
1001
2139
  local command="$1"
1002
2140
  shift
2141
+
2142
+ # Bare `aiur pause` / `aiur resume` (no IDs, no --all) flips the single
2143
+ # global pause switch: a daemon-wide halt distinct from per-agent pause.
2144
+ if [ "$#" -eq 0 ]; then
2145
+ run_control_rpc "Aiur.AgentControlCLI.${command}_global()"
2146
+ return
2147
+ fi
2148
+
1003
2149
  if ! parse_issue_targets "$@"; then
1004
- echo "aiur: $command expects issue IDs or --all (e.g. aiur $command 44 45,46; aiur $command --all)" >&2
2150
+ echo "aiur: $command expects issue IDs or --all (e.g. aiur $command 44 45,46; aiur $command --all; or bare aiur $command for the global switch)" >&2
1005
2151
  exit 64
1006
2152
  fi
1007
2153
 
@@ -1015,7 +2161,28 @@ cmd_pause_resume() {
1015
2161
  run_control_rpc "$expression"
1016
2162
  }
1017
2163
 
1018
- # `aiur message <issue> <text>` — deliver operator text to one running agent.
2164
+ # `aiur reset-budget <id>...` — clear the lifetime dispatch latch for one or
2165
+ # more tickets (the supported exit from the #1453 latch; no JSON hand-editing).
2166
+ cmd_reset_budget() {
2167
+ if ! parse_issue_targets "$@"; then
2168
+ echo "aiur: reset-budget expects issue IDs (e.g. aiur reset-budget 44 45,46)" >&2
2169
+ exit 64
2170
+ fi
2171
+
2172
+ # --all is rejected (exit 64 with guidance) rather than silently no-opping:
2173
+ # clearing every ticket's latch at once is not a documented operation and
2174
+ # would mask which tickets are structurally stuck (#1453 review P2d).
2175
+ if [ "$parsed_all" -eq 1 ]; then
2176
+ echo "aiur: reset-budget does not accept --all; name ticket IDs explicitly (e.g. aiur reset-budget 44 45,46)" >&2
2177
+ exit 64
2178
+ fi
2179
+
2180
+ local expression
2181
+ expression="Aiur.AgentControlCLI.reset_budget($(elixir_list_literal "${parsed_targets[@]}"))"
2182
+ run_control_rpc "$expression"
2183
+ }
2184
+
2185
+ # `aiur message <issue> <text>` — deliver Executor text to one running agent.
1019
2186
  # The text is base64-encoded for the RPC hop so arbitrary content (quotes,
1020
2187
  # backslashes, `#{}`, newlines) survives without Elixir-string escaping.
1021
2188
  cmd_message() {
@@ -1046,6 +2213,431 @@ cmd_agents() {
1046
2213
  run_control_rpc "Aiur.AgentControlCLI.agents()"
1047
2214
  }
1048
2215
 
2216
+ # `aiur commands` — read the dashboard's retained Decision projection without
2217
+ # exposing any dispatch or answer mutation path.
2218
+ cmd_commands() {
2219
+ local filter="all" blocking=0 json=0 decision_id="" ticket="" search="" cursor="" limit="" arg
2220
+
2221
+ while [ "$#" -gt 0 ]; do
2222
+ arg="$1"
2223
+ case "$arg" in
2224
+ --filter) [ "$#" -gt 1 ] || { echo "aiur: commands --filter requires a value" >&2; exit 64; }; shift; filter="$1" ;;
2225
+ --filter=*) filter="${arg#--filter=}" ;;
2226
+ --blocking) blocking=1 ;;
2227
+ --ticket) [ "$#" -gt 1 ] || { echo "aiur: commands --ticket requires a value" >&2; exit 64; }; shift; ticket="$1" ;;
2228
+ --ticket=*) ticket="${arg#--ticket=}" ;;
2229
+ --search) [ "$#" -gt 1 ] || { echo "aiur: commands --search requires a value" >&2; exit 64; }; shift; search="$1" ;;
2230
+ --search=*) search="${arg#--search=}" ;;
2231
+ --cursor) [ "$#" -gt 1 ] || { echo "aiur: commands --cursor requires a value" >&2; exit 64; }; shift; cursor="$1" ;;
2232
+ --cursor=*) cursor="${arg#--cursor=}" ;;
2233
+ --limit) [ "$#" -gt 1 ] || { echo "aiur: commands --limit requires a value" >&2; exit 64; }; shift; limit="$1" ;;
2234
+ --limit=*) limit="${arg#--limit=}" ;;
2235
+ --json) json=1 ;;
2236
+ -*) echo "aiur: commands received an unknown option: $arg" >&2; exit 64 ;;
2237
+ *)
2238
+ if [ -n "$decision_id" ]; then
2239
+ echo "aiur: commands accepts at most one decision ID" >&2
2240
+ exit 64
2241
+ fi
2242
+ decision_id="$arg"
2243
+ ;;
2244
+ esac
2245
+ shift
2246
+ done
2247
+
2248
+ case "$filter" in all|open|blocking|resolved) ;; *) echo "aiur: commands --filter accepts all, open, blocking, or resolved" >&2; exit 64 ;; esac
2249
+ [ -z "$limit" ] || [[ "$limit" =~ ^[1-9][0-9]*$ ]] || { echo "aiur: commands --limit expects a positive integer" >&2; exit 64; }
2250
+ [ -z "$ticket" ] || [ "$filter" = "all" ] || { echo "aiur: commands --ticket requires --filter all" >&2; exit 64; }
2251
+ [ -z "$search" ] || [ "$filter" = "all" ] || { echo "aiur: commands --search requires --filter all" >&2; exit 64; }
2252
+
2253
+ local opts="filter: :$filter"
2254
+ [ "$blocking" -eq 1 ] && opts="$opts, blocking: true"
2255
+ [ "$json" -eq 1 ] && opts="$opts, json: true"
2256
+ [ -n "$limit" ] && opts="$opts, limit: $limit"
2257
+ local key raw encoded
2258
+ for key in decision_id ticket search cursor; do
2259
+ raw="${!key}"
2260
+ [ -n "$raw" ] || continue
2261
+ encoded="$(printf '%s' "$raw" | base64 | tr -d '\n')"
2262
+ opts="$opts, $key: Base.decode64!(\"$encoded\")"
2263
+ done
2264
+
2265
+ run_control_rpc "Aiur.AgentControlCLI.commands([$opts])"
2266
+ }
2267
+
2268
+ encode_control_value() {
2269
+ printf '%s' "$1" | base64 | tr -d '\n'
2270
+ }
2271
+
2272
+ # These are explicit trusted-Executor mutations, deliberately separate from
2273
+ # the read-only `commands` catalog. Revisions remain dashboard-owned.
2274
+ cmd_executor_answer() {
2275
+ local decision_id="${1:-}" expected_version="" option_id="" custom_response="" rationale="" idempotency_key="" executor_id="aiur-cli" arg
2276
+ if [ -z "$decision_id" ] || [[ "$decision_id" = -* ]]; then
2277
+ echo "aiur: executor-answer expects exactly one decision ID" >&2
2278
+ exit 64
2279
+ fi
2280
+ shift
2281
+
2282
+ while [ "$#" -gt 0 ]; do
2283
+ arg="$1"
2284
+ case "$arg" in
2285
+ --expected-version) [ "$#" -gt 1 ] || { echo "aiur: executor-answer --expected-version requires a value" >&2; exit 64; }; shift; expected_version="$1" ;;
2286
+ --expected-version=*) expected_version="${arg#--expected-version=}" ;;
2287
+ --option) [ "$#" -gt 1 ] || { echo "aiur: executor-answer --option requires a value" >&2; exit 64; }; shift; option_id="$1" ;;
2288
+ --option=*) option_id="${arg#--option=}" ;;
2289
+ --custom-response) [ "$#" -gt 1 ] || { echo "aiur: executor-answer --custom-response requires a value" >&2; exit 64; }; shift; custom_response="$1" ;;
2290
+ --custom-response=*) custom_response="${arg#--custom-response=}" ;;
2291
+ --rationale) [ "$#" -gt 1 ] || { echo "aiur: executor-answer --rationale requires a value" >&2; exit 64; }; shift; rationale="$1" ;;
2292
+ --rationale=*) rationale="${arg#--rationale=}" ;;
2293
+ --idempotency-key) [ "$#" -gt 1 ] || { echo "aiur: executor-answer --idempotency-key requires a value" >&2; exit 64; }; shift; idempotency_key="$1" ;;
2294
+ --idempotency-key=*) idempotency_key="${arg#--idempotency-key=}" ;;
2295
+ --executor-id) [ "$#" -gt 1 ] || { echo "aiur: executor-answer --executor-id requires a value" >&2; exit 64; }; shift; executor_id="$1" ;;
2296
+ --executor-id=*) executor_id="${arg#--executor-id=}" ;;
2297
+ -*) echo "aiur: executor-answer received an unknown option: $arg" >&2; exit 64 ;;
2298
+ *) echo "aiur: executor-answer expects exactly one decision ID" >&2; exit 64 ;;
2299
+ esac
2300
+ shift
2301
+ done
2302
+
2303
+ [[ "$expected_version" =~ ^[1-9][0-9]*$ ]] || { echo "aiur: executor-answer --expected-version expects a positive integer" >&2; exit 64; }
2304
+ if { [ -n "$option_id" ] && [ -n "$custom_response" ]; } || { [ -z "$option_id" ] && [ -z "$custom_response" ]; }; then
2305
+ echo "aiur: executor-answer requires exactly one of --option or --custom-response" >&2
2306
+ exit 64
2307
+ fi
2308
+ [ -n "$rationale" ] || { echo "aiur: executor-answer --rationale is required" >&2; exit 64; }
2309
+ [ -n "$idempotency_key" ] || { echo "aiur: executor-answer --idempotency-key is required" >&2; exit 64; }
2310
+ [ -n "$executor_id" ] || { echo "aiur: executor-answer --executor-id must not be empty" >&2; exit 64; }
2311
+
2312
+ local opts="decision_id: Base.decode64!(\"$(encode_control_value "$decision_id")\"), expected_version: $expected_version"
2313
+ if [ -n "$option_id" ]; then
2314
+ opts="$opts, option_id: Base.decode64!(\"$(encode_control_value "$option_id")\")"
2315
+ else
2316
+ opts="$opts, custom_response: Base.decode64!(\"$(encode_control_value "$custom_response")\")"
2317
+ fi
2318
+ opts="$opts, rationale: Base.decode64!(\"$(encode_control_value "$rationale")\")"
2319
+ opts="$opts, idempotency_key: Base.decode64!(\"$(encode_control_value "$idempotency_key")\")"
2320
+ opts="$opts, executor_id: Base.decode64!(\"$(encode_control_value "$executor_id")\")"
2321
+ run_control_rpc "Aiur.AgentControlCLI.executor_answer([$opts])"
2322
+ }
2323
+
2324
+ cmd_executor_escalate() {
2325
+ local decision_id="${1:-}" expected_version="" reason="" executor_id="aiur-cli" arg
2326
+ if [ -z "$decision_id" ] || [[ "$decision_id" = -* ]]; then
2327
+ echo "aiur: executor-escalate expects exactly one decision ID" >&2
2328
+ exit 64
2329
+ fi
2330
+ shift
2331
+
2332
+ while [ "$#" -gt 0 ]; do
2333
+ arg="$1"
2334
+ case "$arg" in
2335
+ --expected-version) [ "$#" -gt 1 ] || { echo "aiur: executor-escalate --expected-version requires a value" >&2; exit 64; }; shift; expected_version="$1" ;;
2336
+ --expected-version=*) expected_version="${arg#--expected-version=}" ;;
2337
+ --reason) [ "$#" -gt 1 ] || { echo "aiur: executor-escalate --reason requires a value" >&2; exit 64; }; shift; reason="$1" ;;
2338
+ --reason=*) reason="${arg#--reason=}" ;;
2339
+ --executor-id) [ "$#" -gt 1 ] || { echo "aiur: executor-escalate --executor-id requires a value" >&2; exit 64; }; shift; executor_id="$1" ;;
2340
+ --executor-id=*) executor_id="${arg#--executor-id=}" ;;
2341
+ -*) echo "aiur: executor-escalate received an unknown option: $arg" >&2; exit 64 ;;
2342
+ *) echo "aiur: executor-escalate expects exactly one decision ID" >&2; exit 64 ;;
2343
+ esac
2344
+ shift
2345
+ done
2346
+
2347
+ [[ "$expected_version" =~ ^[1-9][0-9]*$ ]] || { echo "aiur: executor-escalate --expected-version expects a positive integer" >&2; exit 64; }
2348
+ [ -n "$reason" ] || { echo "aiur: executor-escalate --reason is required" >&2; exit 64; }
2349
+ [ -n "$executor_id" ] || { echo "aiur: executor-escalate --executor-id must not be empty" >&2; exit 64; }
2350
+
2351
+ local opts="decision_id: Base.decode64!(\"$(encode_control_value "$decision_id")\"), expected_version: $expected_version"
2352
+ opts="$opts, reason: Base.decode64!(\"$(encode_control_value "$reason")\")"
2353
+ opts="$opts, executor_id: Base.decode64!(\"$(encode_control_value "$executor_id")\")"
2354
+ run_control_rpc "Aiur.AgentControlCLI.executor_escalate([$opts])"
2355
+ }
2356
+
2357
+ # `aiur units` — read the dashboard Units catalog through its own projection,
2358
+ # including non-running tickets in current-run membership.
2359
+ cmd_units() {
2360
+ local scope="live" format="" json=0 arg condition condition_value condition_encoded conditions_literal=""
2361
+ local -a conditions=() condition_values=()
2362
+ while [ "$#" -gt 0 ]; do
2363
+ arg="$1"
2364
+ case "$arg" in
2365
+ --scope) [ "$#" -gt 1 ] || { echo "aiur: units --scope requires a value" >&2; exit 64; }; shift; scope="$1" ;;
2366
+ --scope=*) scope="${arg#--scope=}" ;;
2367
+ --condition) [ "$#" -gt 1 ] || { echo "aiur: units --condition requires a value" >&2; exit 64; }; shift; conditions+=("$1") ;;
2368
+ --condition=*) conditions+=("${arg#--condition=}") ;;
2369
+ --format) [ "$#" -gt 1 ] || { echo "aiur: units --format requires a value" >&2; exit 64; }; shift; format="$1" ;;
2370
+ --format=*) format="${arg#--format=}" ;;
2371
+ --json) json=1 ;;
2372
+ -*) echo "aiur: units received an unknown option: $arg" >&2; exit 64 ;;
2373
+ *) echo "aiur: units does not accept positional arguments" >&2; exit 64 ;;
2374
+ esac
2375
+ shift
2376
+ done
2377
+
2378
+ for condition in "${conditions[@]+"${conditions[@]}"}"; do
2379
+ IFS=',' read -r -a condition_values <<< "$condition"
2380
+ for condition_value in "${condition_values[@]+"${condition_values[@]}"}"; do
2381
+ if [ -n "$conditions_literal" ]; then conditions_literal="$conditions_literal, "; fi
2382
+ condition_encoded="$(printf '%s' "$condition_value" | base64 | tr -d '\n')"
2383
+ conditions_literal="${conditions_literal}Base.decode64!(\"${condition_encoded}\")"
2384
+ done
2385
+ done
2386
+
2387
+ local scope_encoded
2388
+ scope_encoded="$(printf '%s' "$scope" | base64 | tr -d '\n')"
2389
+ local opts="scope: Base.decode64!(\"$scope_encoded\")"
2390
+ [ -z "$conditions_literal" ] || opts="$opts, conditions: [$conditions_literal]"
2391
+
2392
+ if [ -n "$format" ]; then
2393
+ local format_encoded
2394
+ format_encoded="$(printf '%s' "$format" | base64 | tr -d '\n')"
2395
+ opts="$opts, format: Base.decode64!(\"$format_encoded\")"
2396
+ fi
2397
+
2398
+ [ "$json" -eq 1 ] && opts="$opts, json: true"
2399
+ run_control_rpc "Aiur.AgentControlCLI.units([$opts])"
2400
+ }
2401
+
2402
+ # `aiur build-orders` — read the dashboard Build Order projection without a
2403
+ # second GitHub or API derivation. A root selector switches from catalog to the
2404
+ # selected-root graph view.
2405
+ cmd_build_orders() {
2406
+ local json=0 root="" arg
2407
+
2408
+ while [ "$#" -gt 0 ]; do
2409
+ arg="$1"
2410
+ case "$arg" in
2411
+ --json) json=1 ;;
2412
+ -*) echo "aiur: build-orders received an unknown option: $arg" >&2; exit 64 ;;
2413
+ *)
2414
+ if [ -n "$root" ]; then
2415
+ echo "aiur: build-orders accepts at most one root" >&2
2416
+ exit 64
2417
+ fi
2418
+ root="$arg"
2419
+ ;;
2420
+ esac
2421
+ shift
2422
+ done
2423
+
2424
+ local opts=""
2425
+ [ "$json" -eq 1 ] && opts="json: true"
2426
+
2427
+ if [ -n "$root" ]; then
2428
+ local encoded
2429
+ encoded="$(printf '%s' "$root" | base64 | tr -d '\n')"
2430
+ [ -n "$opts" ] && opts="$opts, "
2431
+ opts="${opts}root: Base.decode64!(\"$encoded\")"
2432
+ fi
2433
+
2434
+ run_control_rpc "Aiur.AgentControlCLI.build_orders([$opts])"
2435
+ }
2436
+
2437
+ # `aiur analytics` — render the dashboard analytics projection for an explicit
2438
+ # time window. This is read-only and obtains the same durable telemetry snapshot
2439
+ # the page uses through the running node.
2440
+ cmd_analytics() {
2441
+ local range="run" json=0 since="" until="" build_order="" has_build_order=0 arg
2442
+
2443
+ while [ "$#" -gt 0 ]; do
2444
+ arg="$1"
2445
+ case "$arg" in
2446
+ --range) [ "$#" -gt 1 ] || { echo "aiur: analytics --range requires a value" >&2; exit 64; }; shift; range="$1" ;;
2447
+ --range=*) range="${arg#--range=}" ;;
2448
+ --since) [ "$#" -gt 1 ] || { echo "aiur: analytics --since requires a value" >&2; exit 64; }; shift; since="$1" ;;
2449
+ --since=*) since="${arg#--since=}" ;;
2450
+ --until) [ "$#" -gt 1 ] || { echo "aiur: analytics --until requires a value" >&2; exit 64; }; shift; until="$1" ;;
2451
+ --until=*) until="${arg#--until=}" ;;
2452
+ --build-order) [ "$#" -gt 1 ] || { echo "aiur: analytics --build-order requires a value" >&2; exit 64; }; shift; build_order="$1"; has_build_order=1 ;;
2453
+ --build-order=*) build_order="${arg#--build-order=}"; has_build_order=1 ;;
2454
+ --json) json=1 ;;
2455
+ -*) echo "aiur: analytics received an unknown option: $arg" >&2; exit 64 ;;
2456
+ *) echo "aiur: analytics does not accept positional arguments" >&2; exit 64 ;;
2457
+ esac
2458
+ shift
2459
+ done
2460
+
2461
+ case "$range" in run|full) ;; *) echo "aiur: analytics --range accepts run or full" >&2; exit 64 ;; esac
2462
+ [ "$has_build_order" -eq 0 ] || [[ "$build_order" =~ ^[0-9]+$ ]] || { echo "aiur: analytics --build-order expects a numeric ticket ID" >&2; exit 64; }
2463
+
2464
+ local opts="range: :$range" key raw encoded
2465
+ [ "$json" -eq 1 ] && opts="$opts, json: true"
2466
+ for key in since until build_order; do
2467
+ raw="${!key}"
2468
+ [ -n "$raw" ] || continue
2469
+ encoded="$(printf '%s' "$raw" | base64 | tr -d '\n')"
2470
+ opts="$opts, $key: Base.decode64!(\"$encoded\")"
2471
+ done
2472
+
2473
+ run_control_rpc "Aiur.AgentControlCLI.analytics([$opts])"
2474
+ }
2475
+
2476
+ # `aiur alerts` — newline-delimited structured alert feed from persisted
2477
+ # per-agent logs. `--needs-attention` filters to Executor-actionable alerts.
2478
+ cmd_alerts() {
2479
+ local needs_attention=0 arg
2480
+ for arg in "$@"; do
2481
+ case "$arg" in
2482
+ --needs-attention) needs_attention=1 ;;
2483
+ *)
2484
+ echo "aiur: alerts only accepts --needs-attention" >&2
2485
+ exit 64
2486
+ ;;
2487
+ esac
2488
+ done
2489
+
2490
+ if [ "$needs_attention" -eq 1 ]; then
2491
+ run_control_rpc "Aiur.AgentControlCLI.alerts(needs_attention: true)"
2492
+ else
2493
+ run_control_rpc "Aiur.AgentControlCLI.alerts()"
2494
+ fi
2495
+ }
2496
+
2497
+ # `aiur watch` — the server-side status board. Compiles one row per active
2498
+ # agent (state · complexity · activity-age · doing) plus an actionable section
2499
+ # from aiur's own state, with no GitHub round-trip. `--changes` (default) prints
2500
+ # only state-level deltas since the last call; `--full` prints every row;
2501
+ # `--interval N` re-renders every N seconds as a foreground watcher (the Elixir
2502
+ # call stays one-shot — the loop lives here).
2503
+ cmd_watch() {
2504
+ local mode="changes" interval="" arg
2505
+
2506
+ while [ "$#" -gt 0 ]; do
2507
+ arg="$1"
2508
+ case "$arg" in
2509
+ --full) mode="full" ;;
2510
+ --changes) mode="changes" ;;
2511
+ --once) : ;;
2512
+ --interval)
2513
+ shift
2514
+ interval="${1:-}"
2515
+ watch_validate_interval "$interval"
2516
+ ;;
2517
+ --interval=*)
2518
+ interval="${arg#--interval=}"
2519
+ watch_validate_interval "$interval"
2520
+ ;;
2521
+ *)
2522
+ echo "aiur: watch accepts --full, --changes, --once, --interval <secs>" >&2
2523
+ exit 64
2524
+ ;;
2525
+ esac
2526
+ shift
2527
+ done
2528
+
2529
+ local expression="Aiur.AgentControlCLI.watch(mode: :${mode})"
2530
+
2531
+ if [ -n "$interval" ]; then
2532
+ while true; do
2533
+ run_control_rpc "$expression" || true
2534
+ sleep "$interval"
2535
+ done
2536
+ else
2537
+ run_control_rpc "$expression"
2538
+ fi
2539
+ }
2540
+
2541
+ cmd_executor_listen() {
2542
+ local topic="executor.#" arg
2543
+ while [ "$#" -gt 0 ]; do
2544
+ arg="$1"
2545
+ case "$arg" in
2546
+ --topic) shift; topic="${1:-}" ;;
2547
+ --topic=*) topic="${arg#--topic=}" ;;
2548
+ *) echo "aiur: executor-listen accepts --topic <pattern>" >&2; exit 64 ;;
2549
+ esac
2550
+ [ -n "$topic" ] || { echo "aiur: executor-listen requires a topic" >&2; exit 64; }
2551
+ shift
2552
+ done
2553
+ local encoded
2554
+ encoded="$(printf '%s' "$topic" | base64 | tr -d '\n')"
2555
+ run_control_stream "Aiur.AgentControlCLI.executor_listen(topic: Base.decode64!(\"$encoded\"))"
2556
+ }
2557
+
2558
+ cmd_executor_wait() {
2559
+ local timeout=300 json=0 arg
2560
+ while [ "$#" -gt 0 ]; do
2561
+ arg="$1"
2562
+ case "$arg" in
2563
+ --timeout) shift; timeout="${1:-}" ;;
2564
+ --timeout=*) timeout="${arg#--timeout=}" ;;
2565
+ --json) json=1 ;;
2566
+ *) echo "aiur: executor-wait accepts --timeout <seconds> and --json" >&2; exit 64 ;;
2567
+ esac
2568
+ shift
2569
+ done
2570
+ [[ "$timeout" =~ ^[1-9][0-9]*$ ]] || { echo "aiur: executor-wait --timeout expects a positive integer" >&2; exit 64; }
2571
+ local json_arg=false
2572
+ if [ "$json" -eq 1 ]; then json_arg=true; fi
2573
+ AIUR_CONTROL_COMMAND="executor-wait"
2574
+ AIUR_CONTROL_RPC_TIMEOUT_SECONDS=$((timeout + 10)) run_control_rpc "Aiur.AgentControlCLI.executor_wait(timeout_ms: $((timeout * 1000)), json: $json_arg)"
2575
+ }
2576
+
2577
+ cmd_executor_emit() {
2578
+ local topic="${1:-}" payload="" arg
2579
+ shift 2>/dev/null || true
2580
+ [ -n "$topic" ] || { echo "aiur: executor-emit requires a topic" >&2; exit 64; }
2581
+ while [ "$#" -gt 0 ]; do
2582
+ arg="$1"
2583
+ case "$arg" in
2584
+ --payload) shift; payload="${1:-}" ;;
2585
+ --payload=*) payload="${arg#--payload=}" ;;
2586
+ *) echo "aiur: executor-emit accepts only --payload <json>" >&2; exit 64 ;;
2587
+ esac
2588
+ shift
2589
+ done
2590
+ [ -n "$payload" ] || { echo "aiur: executor-emit requires --payload <json>" >&2; exit 64; }
2591
+ local topic_encoded payload_encoded
2592
+ topic_encoded="$(printf '%s' "$topic" | base64 | tr -d '\n')"
2593
+ payload_encoded="$(printf '%s' "$payload" | base64 | tr -d '\n')"
2594
+ run_control_rpc "Aiur.AgentControlCLI.executor_emit(Base.decode64!(\"$topic_encoded\"), Base.decode64!(\"$payload_encoded\"))"
2595
+ }
2596
+
2597
+ cmd_executor_subscription() {
2598
+ local action="$1" topic="${2:-}"
2599
+ [ -n "$topic" ] && [ "$#" -eq 2 ] || { echo "aiur: $action requires one topic pattern" >&2; exit 64; }
2600
+ local encoded
2601
+ encoded="$(printf '%s' "$topic" | base64 | tr -d '\n')"
2602
+ case "$action" in
2603
+ executor-subscribe) run_control_rpc "Aiur.AgentControlCLI.executor_subscribe(Base.decode64!(\"$encoded\"))" ;;
2604
+ executor-unsubscribe) run_control_rpc "Aiur.AgentControlCLI.executor_unsubscribe(Base.decode64!(\"$encoded\"))" ;;
2605
+ esac
2606
+ }
2607
+
2608
+ cmd_executor_subscriptions() {
2609
+ [ "$#" -eq 0 ] || { echo "aiur: executor-subscriptions does not accept arguments" >&2; exit 64; }
2610
+ run_control_rpc "Aiur.AgentControlCLI.executor_subscriptions()"
2611
+ }
2612
+
2613
+ watch_validate_interval() {
2614
+ if ! [[ "$1" =~ ^[0-9]+$ ]] || [ "$1" -le 0 ]; then
2615
+ echo "aiur: watch --interval expects a positive integer (seconds)" >&2
2616
+ exit 64
2617
+ fi
2618
+ }
2619
+
2620
+ cmd_cleanup_stale() {
2621
+ local dry_run=0 arg
2622
+ for arg in "$@"; do
2623
+ case "$arg" in
2624
+ --dry-run) dry_run=1 ;;
2625
+ *)
2626
+ echo "aiur: cleanup-stale only accepts --dry-run" >&2
2627
+ exit 64
2628
+ ;;
2629
+ esac
2630
+ done
2631
+
2632
+ aiur_resolve_identity
2633
+ if [ "$dry_run" -eq 1 ]; then
2634
+ report_stale_manual_smoke || true
2635
+ else
2636
+ report_stale_manual_smoke || true
2637
+ reap_stale_manual_smoke 1
2638
+ fi
2639
+ }
2640
+
1049
2641
  # `aiur set <key> <value>` — runtime config overrides without editing
1050
2642
  # `.aiur/config`. Currently: `aiur set max-agents N`.
1051
2643
  cmd_set() {
@@ -1098,7 +2690,7 @@ sweep_dead_tmux_sockets() {
1098
2690
  # foreground teardown (session_cleanup) and on `aiur stop`. Bounded and safe:
1099
2691
  #
1100
2692
  # * Only exact top-level Aiur artifact families under the temp roots the engine
1101
- # and BEAM write to. Arbitrary operator worktrees/checkouts like
2693
+ # and BEAM write to. Arbitrary Executor worktrees/checkouts like
1102
2694
  # `/tmp/aiur-pr490` are not candidates.
1103
2695
  # * Age-gated by AIUR_TMP_REAP_MINUTES (default 1440 = 24h). An entry is removed
1104
2696
  # only when NOTHING in its subtree was modified within the window, so a live
@@ -1192,15 +2784,72 @@ sweep_stale_tmp_artifacts() {
1192
2784
  return 0
1193
2785
  }
1194
2786
 
2787
+ # A stop only reaps the daemon whose node name matches this project root's
2788
+ # instance key (keys are sha256(project_root), so instances can't reap each
2789
+ # other). A daemon launched from a different directory therefore survives a stop
2790
+ # invoked elsewhere — the silent orphan that keeps holding the dashboard port and
2791
+ # serving stale code/credentials. Surface it loudly (we warn, not reap, to
2792
+ # respect the deliberate isolation) so the operator can stop it explicitly.
2793
+ warn_other_aiur_daemons() {
2794
+ local self_node="$1" me pids pid cmd node port found=0
2795
+ me="${USER:-$(id -un 2>/dev/null)}"
2796
+ pids="$(pgrep -u "$me" -f -- "-name aiur-${me}" 2>/dev/null || true)"
2797
+ for pid in $pids; do
2798
+ cmd="$(pid_command "$pid")"
2799
+ case "$cmd" in *beam.smp*) : ;; *) continue ;; esac
2800
+ node="$(printf '%s' "$cmd" | grep -oE -- '-name [^ ]+' | awk '{print $2}' | head -1)"
2801
+ [ -z "$node" ] && continue
2802
+ [ "$node" = "$self_node" ] && continue
2803
+ port="$(ss -tlnpH 2>/dev/null | awk -v p="pid=$pid," '$0 ~ p {print $4}' \
2804
+ | grep -oE '[0-9]+$' | head -1 || true)"
2805
+ if [ "$found" -eq 0 ]; then
2806
+ echo "aiur: heads up — another aiur daemon is still running that this stop did not touch" >&2
2807
+ echo " (launched from a different directory, so it has a different instance key):" >&2
2808
+ found=1
2809
+ fi
2810
+ echo " pid=$pid node=$node${port:+ dashboard-port=$port} — stop it with: kill $pid" >&2
2811
+ done
2812
+ }
2813
+
2814
+ # Set when cmd_stop returns nonzero only because there was nothing to stop. It
2815
+ # lets a caller (cmd_restart) tell that benign outcome apart from a stop that
2816
+ # failed for a real reason, without changing `aiur stop`'s own exit code.
2817
+ AIUR_STOP_FOUND_NOTHING=0
2818
+
1195
2819
  # Stop the running session: kill this instance's tmux + BEAM, then sweep.
1196
2820
  cmd_stop() {
2821
+ AIUR_STOP_FOUND_NOTHING=0
1197
2822
  resolve_release
1198
2823
  aiur_resolve_identity
2824
+ RELEASE_NODE="$AIUR_RELEASE_NODE"
2825
+ ERL_EPMD_ADDRESS="${ERL_EPMD_ADDRESS:-127.0.0.1}"
2826
+ export RELEASE_NODE ERL_EPMD_ADDRESS
2827
+ resolve_control_identity_from_records
2828
+
2829
+ local workspace_root_file
2830
+ workspace_root_file="$(workspace_root_file_from_instance_record 2>/dev/null || true)"
1199
2831
 
1200
2832
  local tmux_bin
1201
2833
  tmux_bin="$(command -v tmux || true)"
1202
- local session="${AIUR_SESSION_PREFIX}-${USER:-user}${AIUR_INSTANCE_KEY:+-$AIUR_INSTANCE_KEY}-default"
1203
- local socket="${AIUR_SESSION_PREFIX}-${USER:-user}${AIUR_INSTANCE_KEY:+-$AIUR_INSTANCE_KEY}"
2834
+ local session="${AIUR_ADOPTED_TMUX_SESSION:-${AIUR_SESSION_PREFIX}-${USER:-user}${AIUR_INSTANCE_KEY:+-$AIUR_INSTANCE_KEY}-default}"
2835
+ local socket="${AIUR_ADOPTED_TMUX_SOCKET:-${AIUR_SESSION_PREFIX}-${USER:-user}${AIUR_INSTANCE_KEY:+-$AIUR_INSTANCE_KEY}}"
2836
+
2837
+ local has_session=0
2838
+ if [ -n "$tmux_bin" ] && "$tmux_bin" -L "$socket" has-session -t "$session" 2>/dev/null; then
2839
+ has_session=1
2840
+ fi
2841
+
2842
+ if [ "${AIUR_CONTROL_ADOPTED_RECORD:-0}" -ne 1 ] && \
2843
+ [ "${AIUR_CONTROL_CURRENT_NODE_STATE:-}" = "down" ] && \
2844
+ [ "${AIUR_PROJECT_ROOT_SOURCE:-}" = "cwd" ] && \
2845
+ [ "$has_session" -eq 0 ] && \
2846
+ [ ! -f "$(aiur_crash_marker_path)" ]; then
2847
+ echo "aiur: no running aiur node at ${AIUR_RELEASE_NODE}; nothing stopped" >&2
2848
+ warn_other_aiur_daemons "$AIUR_RELEASE_NODE"
2849
+ print_global_config_control_hint
2850
+ AIUR_STOP_FOUND_NOTHING=1
2851
+ return 1
2852
+ fi
1204
2853
 
1205
2854
  # Tell the background BEAM-death watchdog this exit is intentional before we
1206
2855
  # kill the BEAM, so it consumes the sentinel instead of recording a crash. The
@@ -1219,6 +2868,11 @@ cmd_stop() {
1219
2868
  # it. Do not sweep by release dir: sibling instances share dev releases.
1220
2869
  kill_beams_matching "-name ${AIUR_RELEASE_NODE}"
1221
2870
 
2871
+ # Final cwd-sweep backstop after the BEAM is dead. The launcher's instance
2872
+ # record points to the BEAM-written root handoff, so degraded stop never has
2873
+ # to wait for an overloaded daemon before it starts tearing workers down.
2874
+ reap_workspace_cwd_from_file "$workspace_root_file"
2875
+
1222
2876
  # The BEAM (alive until the TERM above) reaped its own headless agents through
1223
2877
  # ProcessReaper on Application.stop. kill-server is the guarantee the earlier
1224
2878
  # kill-session can't give for mid-turn agents: every pane agent across all
@@ -1238,14 +2892,248 @@ cmd_stop() {
1238
2892
  rm -f "$agentfile" 2>/dev/null || true
1239
2893
  done
1240
2894
 
2895
+ reap_stale_manual_smoke 0
1241
2896
  sweep_dead_tmux_sockets
1242
2897
  sweep_stale_tmp_artifacts
2898
+ rm -f "$(aiur_instance_record_path)" 2>/dev/null || true
2899
+ warn_other_aiur_daemons "$AIUR_RELEASE_NODE"
2900
+ }
2901
+
2902
+ # Stop, refresh the release, start again — `aiur restart`.
2903
+ #
2904
+ # The refresh deliberately runs BETWEEN the stop and the start. A dev rebuild
2905
+ # rewrites the release directory in place, and a BEAM booted from that directory
2906
+ # is exactly what the control-retry path (EX_TEMPFAIL) exists to survive; doing
2907
+ # it while the daemon is down means the new daemon boots from a release nothing
2908
+ # else is holding half-written.
2909
+ #
2910
+ # The engine itself never knows how to build: the installed CLI runs a pinned
2911
+ # platform release with no source tree beside it, so its restart is a plain
2912
+ # bounce. The dev shim supplies its build-if-stale step through
2913
+ # AIUR_RESTART_BUILD_CMD, a shell command line run via `bash -c` so the wrapper
2914
+ # (not this parser) owns quoting.
2915
+ aiur_restart_daemon_state="untouched"
2916
+
2917
+ # Everything between the stop and a confirmed start runs with this armed, so the
2918
+ # operator hears about a fleet that is down no matter which step failed — a
2919
+ # rebuild, a missing tmux, a BEAM that would not boot, or a Ctrl-C during the
2920
+ # minutes-long rebuild. Naming only the step that failed would let "aiur failed
2921
+ # to start" read as "nothing changed", which is the silently stopped fleet.
2922
+ aiur_restart_report_state() {
2923
+ [ "$aiur_restart_daemon_state" = "stopped" ] || return 0
2924
+
2925
+ # release_dir is resolved by the stop; fall back to the caller's declared dir
2926
+ # for the paths that fail before that.
2927
+ local dir="${release_dir:-${AIUR_RELEASE_DIR:-}}"
2928
+
2929
+ echo "aiur: the daemon is STOPPED and was NOT restarted" >&2
2930
+ if [ -n "$dir" ] && [ -x "$dir/bin/aiur" ]; then
2931
+ echo "aiur: fix the cause and run 'aiur restart' again, or 'aiur restart --no-build' to start" >&2
2932
+ echo " on the release already on disk" >&2
2933
+ else
2934
+ # The dev builder removes an incomplete release, so after a failed rebuild
2935
+ # there is nothing for --no-build to start. Advertising it anyway would cost
2936
+ # the operator a second failed cycle while the fleet stays down.
2937
+ echo "aiur: no complete release is left on disk, so --no-build has nothing to start —" >&2
2938
+ echo " fix the build and run 'aiur restart' again" >&2
2939
+ fi
2940
+ }
2941
+
2942
+ # The rebuild runs in a process this command cannot see into, so "the builder
2943
+ # exited 0" is not the same claim as "the release I am about to boot is the one
2944
+ # it just built" — a wrapper pointed at another checkout satisfies the first and
2945
+ # not the second, which is exactly how a bounce ships a stale release while every
2946
+ # step reports success. The builder leaves a receipt naming the release dir it
2947
+ # vouches for and the commit it built from; this checks both against the release
2948
+ # on disk. Anything unverifiable is reported as unverifiable, never as success.
2949
+ verify_restart_build() {
2950
+ local receipt="$1"
2951
+ local built_dir built_sha stamped_sha stamp target_dir
2952
+
2953
+ if [ ! -s "$receipt" ]; then
2954
+ echo "aiur: the rebuild left no build receipt, so restart cannot confirm which" >&2
2955
+ echo " release it produced" >&2
2956
+ return 1
2957
+ fi
2958
+
2959
+ built_dir="$(sed -n 's/^release_dir=//p' "$receipt" | head -n 1)"
2960
+ built_sha="$(sed -n 's/^source_sha=//p' "$receipt" | head -n 1)"
2961
+
2962
+ # Fail on the missing field rather than on whatever an empty one happens to
2963
+ # compare equal to further down.
2964
+ if [ -z "$built_dir" ] || [ -z "$built_sha" ]; then
2965
+ echo "aiur: the build receipt is malformed; restart cannot confirm what was built" >&2
2966
+ return 1
2967
+ fi
2968
+
2969
+ # Compare canonical paths: the builder and this engine can reach the same
2970
+ # release through different symlinked parents.
2971
+ built_dir="$(cd -P "$built_dir" 2>/dev/null && pwd || printf '%s' "$built_dir")"
2972
+ target_dir="$(cd -P "${AIUR_RELEASE_DIR:-}" 2>/dev/null && pwd || printf '%s' "${AIUR_RELEASE_DIR:-}")"
2973
+
2974
+ if [ "$built_dir" != "$target_dir" ]; then
2975
+ echo "aiur: the rebuild targeted a different release than the one about to boot" >&2
2976
+ echo " rebuilt : $built_dir" >&2
2977
+ echo " booting : $target_dir" >&2
2978
+ return 1
2979
+ fi
2980
+
2981
+ stamp="$target_dir/AIUR_BUILD_STAMP"
2982
+ if [ ! -r "$stamp" ]; then
2983
+ echo "aiur: the release carries no build stamp, so restart cannot confirm it is" >&2
2984
+ echo " the one just built ($stamp)" >&2
2985
+ return 1
2986
+ fi
2987
+
2988
+ stamped_sha="$(sed -n 's/^source_sha=//p' "$stamp" | head -n 1)"
2989
+ if [ "$stamped_sha" != "$built_sha" ]; then
2990
+ echo "aiur: the release on disk was not built from the commit the rebuild reported" >&2
2991
+ echo " rebuild reported : $built_sha" >&2
2992
+ echo " release stamped : $stamped_sha" >&2
2993
+ return 1
2994
+ fi
2995
+
2996
+ # Say what was actually established. "verified ... built from unknown" would be
2997
+ # a silent-pass guard reporting success over a release it cannot identify, and
2998
+ # a dirty tree means the SHA is a floor rather than an identity.
2999
+ if [ "$stamped_sha" = "unknown" ]; then
3000
+ echo "aiur: release $target_dir matches the rebuild, but its source commit is" >&2
3001
+ echo " unknown (built outside a git work tree) — provenance unverified" >&2
3002
+ return 0
3003
+ fi
3004
+
3005
+ if [ "$(sed -n 's/^dirty=//p' "$stamp" | head -n 1)" = "yes" ]; then
3006
+ echo "aiur: release $target_dir built from $stamped_sha with uncommitted changes" >&2
3007
+ return 0
3008
+ fi
3009
+
3010
+ echo "aiur: verified release $target_dir built from $stamped_sha" >&2
3011
+ }
3012
+
3013
+ cmd_restart() {
3014
+ local skip_build=0 arg
3015
+ local run_args=()
3016
+
3017
+ # Refuse a stale config before stopping a healthy daemon. dispatch_run also
3018
+ # checks this for ordinary launches, but restart mutates state before it gets
3019
+ # that far.
3020
+ reject_legacy_config "$@"
3021
+
3022
+ for arg in "$@"; do
3023
+ case "$arg" in
3024
+ --no-build) skip_build=1 ;;
3025
+ *) run_args+=("$arg") ;;
3026
+ esac
3027
+ done
3028
+
3029
+ # Restarting something that is already down is a start, not an error: the
3030
+ # operator asked for a running daemon on current code, and that is what they
3031
+ # get. Any OTHER stop failure is fatal — building and starting on top of a
3032
+ # half-stopped instance is the in-place rewrite this ordering exists to avoid.
3033
+ local stop_status=0
3034
+ cmd_stop || stop_status=$?
3035
+ if [ "$stop_status" -ne 0 ]; then
3036
+ if [ "${AIUR_STOP_FOUND_NOTHING:-0}" -eq 1 ]; then
3037
+ echo "aiur: nothing was running; restart will start a fresh session" >&2
3038
+ else
3039
+ die "restart aborted: stop failed (exit $stop_status); the daemon may still be running"
3040
+ fi
3041
+ fi
3042
+
3043
+ # cmd_stop's tmux teardown is best-effort (`|| true`), so a stop can report
3044
+ # success while the session survives. Starting from there would rebuild the
3045
+ # release under a live BEAM and then hit run_session's idempotent "already
3046
+ # running" return — exit 0, stale release, no bounce. Refuse instead.
3047
+ # release_bin is unset only when the stop was stubbed out, i.e. in tests.
3048
+ if [ -n "${release_bin:-}" ] && [ "$(probe_control_liveness)" = "up" ]; then
3049
+ die "restart aborted: the previous session still answers after the stop; nothing was rebuilt or restarted"
3050
+ fi
3051
+
3052
+ aiur_restart_daemon_state="stopped"
3053
+ trap aiur_restart_report_state EXIT INT TERM
3054
+
3055
+ if [ -n "${AIUR_RESTART_BUILD_CMD:-}" ] && [ "$skip_build" -eq 0 ]; then
3056
+ local build_status=0 receipt
3057
+ receipt="$(mktemp "${TMPDIR:-/tmp}/aiur-restart-receipt.XXXXXX")"
3058
+ AIUR_RESTART_BUILD_RECEIPT="$receipt" bash -c "$AIUR_RESTART_BUILD_CMD" || build_status=$?
3059
+ if [ "$build_status" -ne 0 ]; then
3060
+ # Exit with the builder's own status rather than a flat 1, so a caller can
3061
+ # still tell a failed rebuild from any other abort.
3062
+ rm -f "$receipt"
3063
+ echo "❌ restart aborted before start: the rebuild failed" >&2
3064
+ aiur_restart_report_state
3065
+ trap - EXIT INT TERM
3066
+ exit "$build_status"
3067
+ fi
3068
+
3069
+ # A builder that declares AIUR_RESTART_BUILD_VERIFIES has promised a receipt,
3070
+ # so a missing or contradicted one is a real failure and an unverifiable
3071
+ # rebuild is not a lesser success — starting anyway would hand back the
3072
+ # stale-release outcome this ordering exists to prevent, with a "restarted"
3073
+ # line on top of it. A builder that makes no such promise cannot be held to
3074
+ # it; say plainly that the guarantee did not apply rather than convert an
3075
+ # unknown wrapper into a stopped fleet.
3076
+ if [ "${AIUR_RESTART_BUILD_VERIFIES:-0}" = "1" ]; then
3077
+ local verify_status=0
3078
+ verify_restart_build "$receipt" || verify_status=$?
3079
+ rm -f "$receipt"
3080
+ if [ "$verify_status" -ne 0 ]; then
3081
+ echo "❌ restart aborted before start: the rebuild could not be verified" >&2
3082
+ # Naming the builder makes an inherited AIUR_RESTART_BUILD_CMD — a dev
3083
+ # shim leaking into another shell — diagnosable from this output alone,
3084
+ # instead of an identical refusal on every retry.
3085
+ echo " rebuild command: ${AIUR_RESTART_BUILD_CMD}" >&2
3086
+ echo " Rebuild from the checkout you mean and retry, or 'aiur restart --no-build'" >&2
3087
+ echo " to start the release on disk without the guarantee." >&2
3088
+ aiur_restart_report_state
3089
+ trap - EXIT INT TERM
3090
+ exit 70
3091
+ fi
3092
+ else
3093
+ rm -f "$receipt"
3094
+ echo "aiur: UNVERIFIED rebuild — this builder reports no receipt, so restart" >&2
3095
+ echo " cannot confirm the release it is about to boot is the one just built" >&2
3096
+ fi
3097
+ elif [ "$skip_build" -eq 1 ]; then
3098
+ echo "aiur: --no-build — restarting on the release already on disk" >&2
3099
+ fi
3100
+
3101
+ # Always detached: the daemon this stopped was itself detached, and a restart
3102
+ # that captured the terminal would be a surprise. `--interactive` still opts
3103
+ # into the attachable session (`aiur --bg --interactive`), and a purely
3104
+ # foreground restart remains `aiur stop && aiur run`.
3105
+ dispatch_run --bg "${run_args[@]+"${run_args[@]}"}"
3106
+
3107
+ aiur_restart_daemon_state="running"
3108
+ trap - EXIT INT TERM
1243
3109
  }
1244
3110
 
1245
3111
  # --- dispatch ----------------------------------------------------------------
1246
3112
 
3113
+ dispatch_run() {
3114
+ local mode="foreground" arg
3115
+ local args=()
3116
+
3117
+ reject_legacy_config "$@"
3118
+
3119
+ for arg in "$@"; do
3120
+ if [ "$arg" = "--bg" ]; then
3121
+ mode="background"
3122
+ else
3123
+ args+=("$arg")
3124
+ fi
3125
+ done
3126
+
3127
+ # bash 3.2 (macOS default) errors on "${args[@]}" when args is empty under
3128
+ # `set -u` — happens for a bare `--bg` run. Guard the expansion.
3129
+ run_session "$mode" "${args[@]+"${args[@]}"}"
3130
+ }
3131
+
1247
3132
  aiur_engine_main() {
1248
3133
  local cmd="${1:-}"
3134
+ # Names the running subcommand in control-RPC diagnostics so a failure says
3135
+ # which command failed instead of a generic "control rpc" (#1684).
3136
+ AIUR_CONTROL_COMMAND="${cmd:-run}"
1249
3137
  case "$cmd" in
1250
3138
  __identity)
1251
3139
  aiur_print_identity
@@ -1256,25 +3144,97 @@ aiur_engine_main() {
1256
3144
  --version)
1257
3145
  run_version "$@"
1258
3146
  ;;
3147
+ --todo)
3148
+ run_todo "$@"
3149
+ ;;
3150
+ --only)
3151
+ echo "aiur: --only is valid only with --todo" >&2
3152
+ exit 64
3153
+ ;;
1259
3154
  init)
1260
3155
  run_init "$@"
1261
3156
  ;;
1262
- --bg)
3157
+ findings)
3158
+ run_findings "$@"
3159
+ ;;
3160
+ guard-pr-deletions)
1263
3161
  shift
1264
- run_session background "$@"
3162
+ "$engine_dir/guard-pr-deletions.sh" "$@"
3163
+ ;;
3164
+ ask | asks)
3165
+ run_asks "$@"
3166
+ ;;
3167
+ --bg)
3168
+ dispatch_run "$@"
1265
3169
  ;;
1266
3170
  run)
1267
3171
  shift
1268
- run_session foreground "$@"
3172
+ dispatch_run "$@"
1269
3173
  ;;
1270
3174
  status)
1271
3175
  shift
1272
3176
  cmd_status "$@"
1273
3177
  ;;
3178
+ usage)
3179
+ shift
3180
+ cmd_usage "$@"
3181
+ ;;
1274
3182
  agents)
1275
3183
  shift
1276
3184
  cmd_agents "$@"
1277
3185
  ;;
3186
+ commands)
3187
+ shift
3188
+ cmd_commands "$@"
3189
+ ;;
3190
+ executor-answer)
3191
+ shift
3192
+ cmd_executor_answer "$@"
3193
+ ;;
3194
+ executor-escalate)
3195
+ shift
3196
+ cmd_executor_escalate "$@"
3197
+ ;;
3198
+ units)
3199
+ shift
3200
+ cmd_units "$@"
3201
+ ;;
3202
+ build-orders)
3203
+ shift
3204
+ cmd_build_orders "$@"
3205
+ ;;
3206
+ analytics)
3207
+ shift
3208
+ cmd_analytics "$@"
3209
+ ;;
3210
+ alerts)
3211
+ shift
3212
+ cmd_alerts "$@"
3213
+ ;;
3214
+ watch)
3215
+ shift
3216
+ cmd_watch "$@"
3217
+ ;;
3218
+ executor-listen)
3219
+ shift
3220
+ cmd_executor_listen "$@"
3221
+ ;;
3222
+ executor-wait)
3223
+ shift
3224
+ cmd_executor_wait "$@"
3225
+ ;;
3226
+ executor-emit)
3227
+ shift
3228
+ cmd_executor_emit "$@"
3229
+ ;;
3230
+ executor-subscribe | executor-unsubscribe)
3231
+ shift
3232
+ cmd_executor_subscription "$cmd" "$@"
3233
+ ;;
3234
+ executor-subscriptions)
3235
+ shift
3236
+ cmd_executor_subscriptions "$@"
3237
+ ;;
1278
3238
  set)
1279
3239
  shift
1280
3240
  cmd_set "$@"
@@ -1283,26 +3243,39 @@ aiur_engine_main() {
1283
3243
  shift
1284
3244
  cmd_pause_resume "$cmd" "$@"
1285
3245
  ;;
3246
+ reset-budget)
3247
+ shift
3248
+ cmd_reset_budget "$@"
3249
+ ;;
1286
3250
  message)
1287
3251
  shift
1288
3252
  cmd_message "$@"
1289
3253
  ;;
3254
+ cleanup-stale)
3255
+ shift
3256
+ cmd_cleanup_stale "$@"
3257
+ ;;
1290
3258
  stop)
1291
3259
  cmd_stop
1292
3260
  ;;
3261
+ restart)
3262
+ shift
3263
+ cmd_restart "$@"
3264
+ ;;
1293
3265
  "")
1294
- run_session foreground
3266
+ dispatch_run
1295
3267
  ;;
1296
3268
  -*)
1297
3269
  # leading-flag forms (e.g. `aiur --interactive <config>`) are a run
1298
- run_session foreground "$@"
3270
+ dispatch_run "$@"
1299
3271
  ;;
1300
3272
  *)
1301
3273
  # a path/config argument is a run; anything else is a usage error
1302
3274
  if [ -e "$cmd" ]; then
1303
- run_session foreground "$@"
3275
+ dispatch_run "$@"
1304
3276
  else
1305
3277
  echo "aiur: unknown command: $cmd" >&2
3278
+ warn_if_cli_behind_release_checkout
1306
3279
  usage >&2
1307
3280
  exit 64
1308
3281
  fi