aiur-cli 0.0.3 → 0.0.4

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.
package/README.md CHANGED
@@ -58,6 +58,7 @@ to the issues you want worked and run `aiur`.
58
58
  | `aiur` | Start the workflow in the foreground (local-only bind) |
59
59
  | `aiur --bg` | Start a headless BEAM in one detached tmux lifetime session |
60
60
  | `aiur status` | Show active agents and their state |
61
+ | `aiur alerts [--needs-attention]` | Show structured alert feed JSON lines |
61
62
  | `aiur pause <id…>` / `resume <id…>` | Pause or resume agents by issue ID |
62
63
  | `aiur stop` | Stop the running session |
63
64
 
package/bin/aiur.js CHANGED
@@ -162,8 +162,9 @@ function ensureTmux() {
162
162
  }
163
163
  }
164
164
 
165
- // True only when the pinned opencode is on PATH; a wrong version (e.g. 1.17.x,
166
- // which broke the panes) must be replaced, so presence alone isn't enough.
165
+ // True only when the pinned opencode is on PATH; a wrong (e.g. newer,
166
+ // unverified) version must be replaced with the pin, so presence alone
167
+ // isn't enough.
167
168
  function opencodeMatchesPin() {
168
169
  const result = safeSpawn("opencode", ["--version"], { encoding: "utf8" });
169
170
  return !result.error && result.status === 0 && (result.stdout || "").trim() === opencodeVersion();
@@ -44,23 +44,67 @@ engine_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
44
44
 
45
45
  # --- distribution identity (per-instance: keyed by the aiur project root) -----
46
46
 
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.
47
+ # The aiur project root used to key this instance. AIUR_REPO_ROOT (set by the dev
48
+ # shim) wins. Otherwise walk up from $PWD to the first dir holding a REPO-LOCAL
49
+ # config — but the walk STOPS at $HOME: the global config at ~/.aiur/config (legacy
50
+ # ~/.aiurconfig) is not a repo root, and treating it as one would collapse every
51
+ # project under $HOME onto one key (#443). When no repo-local config is found, the
52
+ # BEAM serves this run via the global config (mirroring its discovery order in
53
+ # src/lib/aiur/workflow.ex) — or via none — and in both cases the project being
54
+ # served is the cwd, so we key by realpath($PWD). That gives each global-config
55
+ # project a distinct identity instead of an empty key (legacy aiur-$USER@…) or $HOME.
56
+ #
57
+ # Caveat: a global-config run's key is cwd-derived (there is no repo root to
58
+ # converge on), so control commands (status/pause/stop) must be run from the SAME
59
+ # directory the run was launched from. A repo with a repo-local .aiur/config keeps
60
+ # the walk-up, so its control commands still resolve from any subdir.
49
61
  aiur_project_root() {
50
62
  if [ -n "${AIUR_REPO_ROOT:-}" ]; then printf '%s' "$AIUR_REPO_ROOT"; return; fi
51
- local d="$PWD"
52
- while [ -n "$d" ] && [ "$d" != "/" ]; do
63
+
64
+ # Canonicalize $PWD and $HOME so the home-boundary test below holds even when
65
+ # either is reached through a symlink.
66
+ local pwd_real home_real
67
+ pwd_real="$(pwd -P 2>/dev/null || printf '%s' "$PWD")"
68
+ # ${HOME:-} (not bare $HOME) so an unset HOME under `set -u` can't abort the
69
+ # script here; an empty home_real simply disables the boundary (walk to /).
70
+ home_real="$(cd "${HOME:-}" 2>/dev/null && pwd -P || printf '%s' "${HOME:-}")"
71
+
72
+ local d="$pwd_real"
73
+ while [ -n "$d" ] && [ "$d" != "/" ] && [ "$d" != "$home_real" ]; do
53
74
  if [ -f "$d/.aiur/config" ] || [ -f "$d/.aiurconfig" ]; then
54
75
  printf '%s' "$d"
55
76
  return
56
77
  fi
57
78
  d="$(dirname "$d")"
58
79
  done
80
+
81
+ printf '%s' "$pwd_real"
82
+ }
83
+
84
+ aiur_project_root_source() {
85
+ if [ -n "${AIUR_REPO_ROOT:-}" ]; then printf 'env'; return; fi
86
+
87
+ local pwd_real home_real
88
+ pwd_real="$(pwd -P 2>/dev/null || printf '%s' "$PWD")"
89
+ home_real="$(cd "${HOME:-}" 2>/dev/null && pwd -P || printf '%s' "${HOME:-}")"
90
+
91
+ local d="$pwd_real"
92
+ while [ -n "$d" ] && [ "$d" != "/" ] && [ "$d" != "$home_real" ]; do
93
+ if [ -f "$d/.aiur/config" ] || [ -f "$d/.aiurconfig" ]; then
94
+ printf 'repo'
95
+ return
96
+ fi
97
+ d="$(dirname "$d")"
98
+ done
99
+
100
+ printf 'cwd'
59
101
  }
60
102
 
61
103
  # Short, stable, node-name-legal (lowercase hex) key for the project root, so two
62
104
  # 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).
105
+ # reap each other. Any real cwd now resolves a key (global-config runs key by
106
+ # realpath($PWD), #443); only a degenerate unreadable cwd yields empty, falling back
107
+ # to the legacy un-keyed name.
64
108
  aiur_instance_key() {
65
109
  local root
66
110
  root="$(aiur_project_root)"
@@ -74,6 +118,8 @@ aiur_instance_key() {
74
118
 
75
119
  aiur_resolve_identity() {
76
120
  local config_home="${XDG_CONFIG_HOME:-$HOME/.config}"
121
+ AIUR_PROJECT_ROOT="$(aiur_project_root)"
122
+ AIUR_PROJECT_ROOT_SOURCE="$(aiur_project_root_source)"
77
123
 
78
124
  : "${AIUR_BG_STATE_DIR:=$config_home/aiur}"
79
125
  : "${AIUR_COOKIE_FILE:=$AIUR_BG_STATE_DIR/cookie}"
@@ -87,7 +133,8 @@ aiur_resolve_identity() {
87
133
  : "${AIUR_RELEASE_NODE:=aiur-${USER}${AIUR_INSTANCE_KEY:+-$AIUR_INSTANCE_KEY}@127.0.0.1}"
88
134
 
89
135
  export AIUR_BG_STATE_DIR AIUR_COOKIE_FILE AIUR_SESSION_PREFIX \
90
- AIUR_PROFILES_FILE AIUR_RELEASE_NODE AIUR_INSTANCE_KEY
136
+ AIUR_PROFILES_FILE AIUR_RELEASE_NODE AIUR_INSTANCE_KEY \
137
+ AIUR_PROJECT_ROOT AIUR_PROJECT_ROOT_SOURCE
91
138
  }
92
139
 
93
140
  aiur_print_identity() {
@@ -98,6 +145,8 @@ aiur_print_identity() {
98
145
  printf 'AIUR_PROFILES_FILE=%s\n' "$AIUR_PROFILES_FILE"
99
146
  printf 'AIUR_RELEASE_NODE=%s\n' "$AIUR_RELEASE_NODE"
100
147
  printf 'AIUR_INSTANCE_KEY=%s\n' "$AIUR_INSTANCE_KEY"
148
+ printf 'AIUR_PROJECT_ROOT=%s\n' "$AIUR_PROJECT_ROOT"
149
+ printf 'AIUR_PROJECT_ROOT_SOURCE=%s\n' "$AIUR_PROJECT_ROOT_SOURCE"
101
150
  printf 'AIUR_COOKIE_FILE=%s\n' "$AIUR_COOKIE_FILE"
102
151
  }
103
152
 
@@ -224,9 +273,12 @@ Usage: aiur [--interactive] [--max-agents <n>] [--logs-root <path>] [--port <por
224
273
  aiur stop stop the running session
225
274
  aiur status show agent status
226
275
  aiur agents show each agent's state + current activity
276
+ aiur alerts [--needs-attention] show structured alert feed
277
+ aiur watch [--full|--changes] [--interval <secs>] server-side status board
227
278
  aiur set max-agents <n> change the concurrent-agent cap at runtime
228
279
  aiur pause <ids|--all> | resume <ids|--all>
229
280
  aiur message <id> <text> send operator text to a running agent
281
+ aiur cleanup-stale [--dry-run] list/reap stale manual-smoke leftovers
230
282
  aiur --version
231
283
  EOF
232
284
  }
@@ -355,6 +407,8 @@ run_session() {
355
407
  conf="$(resolve_tmux_conf)"
356
408
  [ -f "$conf" ] || die "tmux conf not found at $conf"
357
409
 
410
+ preflight_stale_manual_smoke
411
+
358
412
  mkdir -p "$AIUR_BG_STATE_DIR"
359
413
  printf '%s\n' "$session" >"$AIUR_BG_STATE_DIR/state"
360
414
  export AIUR_TMUX_SESSION="$session"
@@ -372,6 +426,11 @@ run_session() {
372
426
  export AIUR_AGENT_TMPFILE="${session_root}/aiur-${$}-agents"
373
427
  : >"$AIUR_AGENT_TMPFILE"
374
428
 
429
+ # Workspace-root handoff for the shell cwd-sweep backstop. The BEAM writes the
430
+ # resolved Aiur.Config.workspace_root() here after config loads.
431
+ export AIUR_WORKSPACE_ROOT_FILE="${session_root}/aiur-${$}-workspace-root"
432
+ : >"$AIUR_WORKSPACE_ROOT_FILE"
433
+
375
434
  # Background runs persist under a known run log dir so (a) the BEAM-death
376
435
  # watchdog, which outlives the BEAM, can drop a crash record next to aiur.log,
377
436
  # and (b) the post-start boot capture is not lost to /tmp when the launcher
@@ -406,8 +465,8 @@ run_session() {
406
465
  for v in AIUR_RELEASE_DIR AIUR_ARGV_FILE RELEASE_DISTRIBUTION RELEASE_NODE \
407
466
  RELEASE_COOKIE ERL_AFLAGS ERL_EPMD_ADDRESS AIUR_NODE AIUR_ERLANG_COOKIE \
408
467
  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
468
+ AIUR_SESSION_TMPFILE AIUR_AGENT_TMPFILE AIUR_WORKSPACE_ROOT_FILE \
469
+ ELIXIR_ERL_OPTIONS AIUR_LOGS_ROOT AIUR_OPENCODE_BRIDGE_PORT AIUR_DEBUG; do
411
470
  if [ -n "${!v:-}" ]; then printf 'export %s=%q\n' "$v" "${!v}"; fi
412
471
  done
413
472
  printf 'capture=%q\n' "$startup_capture"
@@ -424,7 +483,8 @@ run_session() {
424
483
  _session_socket="$socket" _session_name="$session" _session_conf="$conf" \
425
484
  _session_tmpfile="$AIUR_SESSION_TMPFILE" _session_capture="$startup_capture" \
426
485
  _session_argv="$argv_file" _session_release="$release_dir" _session_tmux="$tmux_bin" \
427
- _session_node="$AIUR_RELEASE_NODE" _session_pidfile="$AIUR_AGENT_TMPFILE"
486
+ _session_node="$AIUR_RELEASE_NODE" _session_pidfile="$AIUR_AGENT_TMPFILE" \
487
+ _session_workspace_root_file="$AIUR_WORKSPACE_ROOT_FILE"
428
488
  install_foreground_traps
429
489
  fi
430
490
 
@@ -433,10 +493,11 @@ run_session() {
433
493
  # session whose BEAM/control plane is gone should be reclaimed before retry.
434
494
  if [ "$mode" = "background" ] && "$tmux_bin" -L "$socket" -f "$conf" has-session -t "$session" 2>/dev/null; then
435
495
  if [ "$(probe_control_liveness)" = "up" ]; then
496
+ write_aiur_instance_record "$session" "$socket"
436
497
  echo "aiur is already running in the background (tmux session ${session})." >&2
437
498
  echo "Use: aiur status # inspect agents" >&2
438
499
  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
500
+ rm -f "$startup_capture" "$argv_file" "$launcher" "$AIUR_SESSION_TMPFILE" "$AIUR_AGENT_TMPFILE" "$AIUR_WORKSPACE_ROOT_FILE" 2>/dev/null || true
440
501
  return 0
441
502
  fi
442
503
 
@@ -474,7 +535,7 @@ run_session() {
474
535
  -x "${COLUMNS:-200}" -y "${LINES:-50}" "$inner_cmd"; then
475
536
  echo "❌ aiur failed to start; captured output:" >&2
476
537
  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
538
+ rm -f "$startup_capture" "$argv_file" "$launcher" "$AIUR_SESSION_TMPFILE" "$AIUR_AGENT_TMPFILE" "$AIUR_WORKSPACE_ROOT_FILE" 2>/dev/null || true
478
539
  exit 1
479
540
  fi
480
541
 
@@ -483,11 +544,11 @@ run_session() {
483
544
  # chat panes it opens. Best-effort — a missing title just shows the default.
484
545
  "$tmux_bin" -L "$socket" -f "$conf" select-pane -t "$session" -T "AIUR Agents" 2>/dev/null || true
485
546
 
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
547
+ # Grace window: surface a boot crash instead of attaching to nothing. Prove
548
+ # the control plane can answer before declaring startup usable: background
549
+ # control commands depend on that RPC path, and foreground automation must not
550
+ # treat a pre-TUI application crash as a successful `tmux attach`.
551
+ local require_control=1
491
552
  if ! wait_for_session_startup "$tmux_bin" "$socket" "$conf" "$session" "$startup_capture" "$require_control"; then
492
553
  if [ "$mode" = "background" ]; then
493
554
  "$tmux_bin" -L "$socket" -f "$conf" kill-session -t "$session" 2>/dev/null || true
@@ -495,10 +556,16 @@ run_session() {
495
556
  kill_beams_matching "-name ${AIUR_RELEASE_NODE}"
496
557
  fi
497
558
  [ "$mode" = "foreground" ] && return 1
498
- rm -f "$startup_capture" "$argv_file" "$launcher" "$AIUR_SESSION_TMPFILE" "$AIUR_AGENT_TMPFILE" 2>/dev/null || true
559
+ rm -f "$startup_capture" "$argv_file" "$launcher" "$AIUR_SESSION_TMPFILE" "$AIUR_AGENT_TMPFILE" "$AIUR_WORKSPACE_ROOT_FILE" 2>/dev/null || true
499
560
  exit 1
500
561
  fi
501
562
 
563
+ write_aiur_instance_record "$session" "$socket"
564
+
565
+ if [ "$mode" = "foreground" ]; then
566
+ echo "aiur foreground tmux socket ${socket}, session ${session}" >&2
567
+ fi
568
+
502
569
  if [ "$mode" = "background" ]; then
503
570
  # Fresh run: drop any stale crash/stop state from a prior dead instance so
504
571
  # `status` doesn't report a phantom orphan and the watchdog starts clean.
@@ -508,9 +575,9 @@ run_session() {
508
575
  background_watchdog_pid="$(start_beam_death_watchdog \
509
576
  "-name ${AIUR_RELEASE_NODE}" "$socket" "$AIUR_AGENT_TMPFILE" 1 1 \
510
577
  "$AIUR_RELEASE_NODE" "${AIUR_LOGS_ROOT:-}" \
511
- "$(aiur_stop_sentinel_path)" "$(aiur_crash_marker_path)")"
578
+ "$(aiur_stop_sentinel_path)" "$(aiur_crash_marker_path)" "$AIUR_WORKSPACE_ROOT_FILE")"
512
579
  disown "$background_watchdog_pid" 2>/dev/null || true
513
- echo "aiur started in the background (tmux session ${session}). Attach with: aiur" >&2
580
+ echo "aiur started in the background (tmux socket ${socket}, session ${session}). Attach with: aiur" >&2
514
581
  # Keep $startup_capture (boot.out.log) for the run's lifetime; only the
515
582
  # transient argv file is no longer needed.
516
583
  rm -f "$argv_file" 2>/dev/null || true
@@ -524,11 +591,19 @@ run_session() {
524
591
  # the session, and unblocks the attach. It polls the node name rather than a
525
592
  # captured pid, so another Aiur instance from the same release cannot hold it open.
526
593
  _session_watchdog_pid="$(start_beam_death_watchdog \
527
- "-name ${AIUR_RELEASE_NODE}" "$socket" "$AIUR_AGENT_TMPFILE" 1)"
594
+ "-name ${AIUR_RELEASE_NODE}" "$socket" "$AIUR_AGENT_TMPFILE" 1 0 \
595
+ "" "" "" "" "$AIUR_WORKSPACE_ROOT_FILE")"
528
596
 
529
597
  # 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 $?
598
+ # Avoid process substitution here: some sandboxed non-TTY launchers reject
599
+ # opening /dev/fd/* during the real manual-test wrapper path.
600
+ local attach_stderr attach_code
601
+ attach_stderr="$(mktemp "${TMPDIR:-/tmp}/aiur-attach-stderr.XXXXXX")"
602
+ "$tmux_bin" -L "$socket" -f "$conf" attach -t "$session" 2>"$attach_stderr"
603
+ attach_code=$?
604
+ grep -v -F "[server exited]" "$attach_stderr" >&2 || true
605
+ rm -f "$attach_stderr" 2>/dev/null || true
606
+ return "$attach_code"
532
607
  }
533
608
 
534
609
  resolve_tmux_conf() {
@@ -563,6 +638,237 @@ kill_beams_matching() {
563
638
  for pid in $pids; do kill -KILL "$pid" 2>/dev/null || true; done
564
639
  }
565
640
 
641
+ pid_owner() {
642
+ ps -p "$1" -o user= 2>/dev/null | awk '{print $1}' || true
643
+ }
644
+
645
+ pid_command() {
646
+ ps -p "$1" -o command= 2>/dev/null || true
647
+ }
648
+
649
+ pid_ppid() {
650
+ ps -p "$1" -o ppid= 2>/dev/null | awk '{print $1}' || true
651
+ }
652
+
653
+ pid_cwd() {
654
+ local pid="$1" cwd
655
+ if [ -e "/proc/$pid/cwd" ]; then
656
+ readlink "/proc/$pid/cwd" 2>/dev/null || true
657
+ return 0
658
+ fi
659
+ if command -v lsof >/dev/null 2>&1; then
660
+ cwd="$(lsof -a -p "$pid" -d cwd -Fn 2>/dev/null | sed -n 's/^n//p' | head -n 1 || true)"
661
+ [ -n "$cwd" ] && printf '%s\n' "$cwd"
662
+ fi
663
+ }
664
+
665
+ kill_pid_with_escalation() {
666
+ local pid="$1" waited=0
667
+ [ -n "$pid" ] || return 0
668
+ kill -TERM "$pid" 2>/dev/null || true
669
+ while [ "$waited" -lt 20 ]; do
670
+ kill -0 "$pid" 2>/dev/null || return 0
671
+ sleep 0.1
672
+ waited=$((waited + 1))
673
+ done
674
+ kill -KILL "$pid" 2>/dev/null || true
675
+ }
676
+
677
+ aiur_node_from_command() {
678
+ local cmd="$1" node
679
+ 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)"
680
+ [ -n "$node" ] && printf '%s\n' "$node"
681
+ }
682
+
683
+ aiur_release_root_from_command() {
684
+ local cmd="$1"
685
+ if [[ "$cmd" =~ --boot-var[[:space:]]+RELEASE_LIB[[:space:]]+([^[:space:]]+)/lib ]]; then
686
+ printf '%s\n' "${BASH_REMATCH[1]}"
687
+ return 0
688
+ fi
689
+ if [[ "$cmd" =~ ([^[:space:]]*/src/_build/dev/rel/aiur)/releases/ ]]; then
690
+ printf '%s\n' "${BASH_REMATCH[1]}"
691
+ return 0
692
+ fi
693
+ if [[ "$cmd" =~ ([^[:space:]]*/release)/releases/ ]]; then
694
+ printf '%s\n' "${BASH_REMATCH[1]}"
695
+ return 0
696
+ fi
697
+ return 1
698
+ }
699
+
700
+ workspace_root_from_aiur_release_root() {
701
+ local release_root="${1%/}"
702
+ case "$release_root" in
703
+ */src/_build/dev/rel/aiur)
704
+ printf '%s\n' "${release_root%/src/_build/dev/rel/aiur}"
705
+ ;;
706
+ *)
707
+ printf '%s\n' "$release_root"
708
+ ;;
709
+ esac
710
+ }
711
+
712
+ aiur_node_tmux_session_alive() {
713
+ local node="$1" short tmux_bin
714
+ short="${node%@*}"
715
+ tmux_bin="$(command -v tmux || true)"
716
+ [ -n "$tmux_bin" ] || return 1
717
+ "$tmux_bin" -L "$short" has-session -t "${short}-default" 2>/dev/null
718
+ }
719
+
720
+ stale_manual_smoke_beam_inventory() {
721
+ local pid cmd node release_root workspace_root owner
722
+ aiur_resolve_identity
723
+
724
+ for pid in $(pgrep -f -- "beam\\.smp.*--name aiur-${USER}.*@127\\.0\\.0\\.1" 2>/dev/null || true); do
725
+ [ -n "$pid" ] || continue
726
+ owner="$(pid_owner "$pid")"
727
+ [ "$owner" = "$USER" ] || continue
728
+
729
+ cmd="$(pid_command "$pid")"
730
+ node="$(aiur_node_from_command "$cmd")"
731
+ [ -n "$node" ] || continue
732
+
733
+ release_root="$(aiur_release_root_from_command "$cmd" || true)"
734
+ [ -n "$release_root" ] || continue
735
+ workspace_root="$(workspace_root_from_aiur_release_root "$release_root")"
736
+
737
+ # Scope this broad cleanup to stale issue/manual-smoke workspaces. The
738
+ # current instance cleanup path handles the active daemon's own node.
739
+ case "$workspace_root" in
740
+ */aiur-workspaces/*) ;;
741
+ *) continue ;;
742
+ esac
743
+
744
+ # A matching live aiur tmux session means this node may still be in use.
745
+ aiur_node_tmux_session_alive "$node" && continue
746
+
747
+ printf '%s\t%s\t%s\t%s\n' "$pid" "$node" "$workspace_root" "$release_root"
748
+ done
749
+ }
750
+
751
+ manual_smoke_wrapper_tmux_sockets() {
752
+ local tmux_bin sockdir path name panes current pane_path start manual_context all_sleep
753
+ tmux_bin="$(command -v tmux || true)"
754
+ [ -n "$tmux_bin" ] || return 0
755
+ sockdir="${TMUX_TMPDIR:-/tmp}/tmux-$(id -u)"
756
+ [ -d "$sockdir" ] || return 0
757
+
758
+ for path in "$sockdir"/*; do
759
+ [ -S "$path" ] || continue
760
+ name="${path##*/}"
761
+ case "$name" in
762
+ codex-driver-* | codex-aiur* | codex-[0-9]* | claude-driver*) ;;
763
+ *) continue ;;
764
+ esac
765
+ panes="$("$tmux_bin" -L "$name" list-panes -a -F '#{pane_current_command} #{pane_current_path} #{pane_start_command}' 2>/dev/null || true)"
766
+ [ -n "$panes" ] || continue
767
+ manual_context=0
768
+ all_sleep=1
769
+ while IFS=$'\t' read -r current pane_path start; do
770
+ case "$start" in
771
+ *aiurdev\ --test* | *scripts/aiurdev\ --test*) manual_context=1 ;;
772
+ esac
773
+ [ "$current" = "sleep" ] || all_sleep=0
774
+ done <<<"$panes"
775
+ [ "$manual_context" -eq 1 ] && [ "$all_sleep" -eq 1 ] && printf '%s\n' "$name"
776
+ done
777
+ }
778
+
779
+ orphaned_opencode_attach_inventory() {
780
+ local pid owner ppid cmd cwd
781
+ for pid in $(pgrep -f -- "opencode attach http://127\\.0\\.0\\.1:" 2>/dev/null || true); do
782
+ owner="$(pid_owner "$pid")"
783
+ [ "$owner" = "$USER" ] || continue
784
+ ppid="$(pid_ppid "$pid")"
785
+ [ "$ppid" = "1" ] || continue
786
+ cwd="$(pid_cwd "$pid")"
787
+ case "$cwd" in
788
+ */aiur-workspaces/*) ;;
789
+ *) continue ;;
790
+ esac
791
+ cmd="$(pid_command "$pid")"
792
+ case "$cmd" in
793
+ *"opencode attach http://127.0.0.1:"*) printf '%s\t%s\t%s\n' "$pid" "$cwd" "$cmd" ;;
794
+ esac
795
+ done
796
+ }
797
+
798
+ report_stale_manual_smoke() {
799
+ local found=0 pid node workspace_root release_root socket cwd cmd
800
+
801
+ while IFS=$'\t' read -r pid node workspace_root release_root; do
802
+ [ -n "$pid" ] || continue
803
+ if [ "$found" -eq 0 ]; then
804
+ echo "aiur: stale manual-smoke leftovers detected:" >&2
805
+ found=1
806
+ fi
807
+ echo " BEAM pid=${pid} node=${node} workspace=${workspace_root} release=${release_root}" >&2
808
+ done < <(stale_manual_smoke_beam_inventory)
809
+
810
+ while IFS= read -r socket; do
811
+ [ -n "$socket" ] || continue
812
+ if [ "$found" -eq 0 ]; then
813
+ echo "aiur: stale manual-smoke leftovers detected:" >&2
814
+ found=1
815
+ fi
816
+ echo " wrapper tmux socket=${socket}" >&2
817
+ done < <(manual_smoke_wrapper_tmux_sockets)
818
+
819
+ while IFS=$'\t' read -r pid cwd cmd; do
820
+ [ -n "$pid" ] || continue
821
+ if [ "$found" -eq 0 ]; then
822
+ echo "aiur: stale manual-smoke leftovers detected:" >&2
823
+ found=1
824
+ fi
825
+ echo " orphan opencode attach pid=${pid} workspace=${cwd}" >&2
826
+ done < <(orphaned_opencode_attach_inventory)
827
+
828
+ if [ "$found" -eq 1 ]; then
829
+ echo "aiur: run 'aiur cleanup-stale' to TERM/KILL only these same-user Aiur leftovers." >&2
830
+ fi
831
+
832
+ return "$found"
833
+ }
834
+
835
+ preflight_stale_manual_smoke() {
836
+ report_stale_manual_smoke >/dev/null || true
837
+ }
838
+
839
+ reap_stale_manual_smoke() {
840
+ local include_wrappers="${1:-0}"
841
+ local pid node workspace_root release_root socket cwd cmd tmux_bin reaped=0
842
+
843
+ while IFS=$'\t' read -r pid node workspace_root release_root; do
844
+ [ -n "$pid" ] || continue
845
+ echo "aiur: reaping stale BEAM pid=${pid} node=${node} workspace=${workspace_root}" >&2
846
+ kill_pid_with_escalation "$pid"
847
+ reaped=$((reaped + 1))
848
+ done < <(stale_manual_smoke_beam_inventory)
849
+
850
+ tmux_bin="$(command -v tmux || true)"
851
+ if [ "$include_wrappers" = "1" ] && [ -n "$tmux_bin" ]; then
852
+ while IFS= read -r socket; do
853
+ [ -n "$socket" ] || continue
854
+ echo "aiur: reaping stale wrapper tmux socket=${socket}" >&2
855
+ "$tmux_bin" -L "$socket" kill-server 2>/dev/null || true
856
+ reaped=$((reaped + 1))
857
+ done < <(manual_smoke_wrapper_tmux_sockets)
858
+ fi
859
+
860
+ while IFS=$'\t' read -r pid cwd cmd; do
861
+ [ -n "$pid" ] || continue
862
+ echo "aiur: reaping orphan opencode attach pid=${pid}" >&2
863
+ kill_pid_with_escalation "$pid"
864
+ reaped=$((reaped + 1))
865
+ done < <(orphaned_opencode_attach_inventory)
866
+
867
+ if [ "$reaped" -eq 0 ]; then
868
+ echo "aiur: no stale manual-smoke leftovers found" >&2
869
+ fi
870
+ }
871
+
566
872
  # Echo $1 and every descendant pid, depth-first. Mac-safe (`pgrep -P`, no
567
873
  # /proc), mirroring Aiur.RemoteControl.collect_descendants so the launcher reaps
568
874
  # the same tree the BEAM-side reaper would. The recorded agent pid is a bash
@@ -643,6 +949,34 @@ aiur_state_slug() {
643
949
  printf '%s' "${AIUR_RELEASE_NODE:-aiur}" | tr -c 'A-Za-z0-9._-' '_'
644
950
  }
645
951
 
952
+ aiur_instances_dir() {
953
+ printf '%s/instances' "${AIUR_BG_STATE_DIR:?}"
954
+ }
955
+
956
+ aiur_instance_record_path() {
957
+ printf '%s/%s.instance' "$(aiur_instances_dir)" "$(aiur_state_slug)"
958
+ }
959
+
960
+ write_aiur_instance_record() {
961
+ local session="$1" socket="$2" record_dir record tmp root
962
+ record_dir="$(aiur_instances_dir)"
963
+ record="$(aiur_instance_record_path)"
964
+ root="$(canonical_workspace_root "${AIUR_PROJECT_ROOT:-}")"
965
+ mkdir -p "$record_dir" 2>/dev/null || return 0
966
+ tmp="$(mktemp "$record_dir/.${AIUR_INSTANCE_KEY:-aiur}.XXXXXX" 2>/dev/null)" || return 0
967
+ {
968
+ printf 'AIUR_RECORD_NODE=%q\n' "$AIUR_RELEASE_NODE"
969
+ printf 'AIUR_RECORD_INSTANCE_KEY=%q\n' "$AIUR_INSTANCE_KEY"
970
+ printf 'AIUR_RECORD_SESSION=%q\n' "$session"
971
+ printf 'AIUR_RECORD_SOCKET=%q\n' "$socket"
972
+ printf 'AIUR_RECORD_PROJECT_ROOT=%q\n' "$root"
973
+ printf 'AIUR_RECORD_PROJECT_ROOT_SOURCE=%q\n' "${AIUR_PROJECT_ROOT_SOURCE:-}"
974
+ printf 'AIUR_RECORD_WRITTEN_AT=%q\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || true)"
975
+ } >"$tmp" || { rm -f "$tmp" 2>/dev/null || true; return 0; }
976
+ chmod 0600 "$tmp" 2>/dev/null || true
977
+ mv "$tmp" "$record" 2>/dev/null || rm -f "$tmp" 2>/dev/null || true
978
+ }
979
+
646
980
  # Marker `status` reads to tell "daemon crashed, agents may be orphaned" apart
647
981
  # from "nothing was ever running". Written by the watchdog on an unexpected exit,
648
982
  # cleared on a clean stop and on the next successful background start.
@@ -690,6 +1024,123 @@ record_beam_crash() {
690
1024
  fi
691
1025
  }
692
1026
 
1027
+ canonical_workspace_root() {
1028
+ local root="$1"
1029
+ if [ -d "$root" ]; then
1030
+ (cd "$root" 2>/dev/null && pwd -P) || printf '%s\n' "$root"
1031
+ else
1032
+ printf '%s\n' "$root"
1033
+ fi
1034
+ }
1035
+
1036
+ workspace_root_is_shallow() {
1037
+ local root="${1%/}" trimmed
1038
+ trimmed="${root#/}"
1039
+ [ -n "$trimmed" ] || return 0
1040
+ case "$trimmed" in
1041
+ */*) return 1 ;;
1042
+ *) return 0 ;;
1043
+ esac
1044
+ }
1045
+
1046
+ workspace_cwd_pids() {
1047
+ local root="${1%/}" proc_dir="${2:-/proc}" entry pid cwd
1048
+ [ -d "$proc_dir" ] || return 0
1049
+
1050
+ for entry in "$proc_dir"/[0-9]*; do
1051
+ [ -d "$entry" ] || continue
1052
+ pid="${entry##*/}"
1053
+ cwd="$(readlink "$entry/cwd" 2>/dev/null || true)"
1054
+ [ -n "$cwd" ] || continue
1055
+ cwd="${cwd%/}"
1056
+ if [ "$cwd" != "$root" ] && [[ "$cwd/" == "$root/"* ]]; then
1057
+ printf '%s\n' "$pid"
1058
+ fi
1059
+ done
1060
+ }
1061
+
1062
+ shell_protected_pid_list() {
1063
+ local p
1064
+ printf '%s\n' "$$"
1065
+ [ "${BASHPID:-$$}" = "$$" ] || printf '%s\n' "$BASHPID"
1066
+ [ -n "${PPID:-}" ] && printf '%s\n' "$PPID"
1067
+ for p in $(agent_pid_tree "$$" 2>/dev/null || true); do
1068
+ printf '%s\n' "$p"
1069
+ done
1070
+ }
1071
+
1072
+ reap_workspace_cwd_agents() {
1073
+ local root="${1:-}"
1074
+ [ -n "$root" ] || return 0
1075
+ [ -d /proc ] || return 0
1076
+
1077
+ root="$(canonical_workspace_root "$root")"
1078
+ if workspace_root_is_shallow "$root"; then
1079
+ echo "⚠️ refusing shallow workspace cwd sweep root: $root" >&2
1080
+ return 0
1081
+ fi
1082
+
1083
+ local max_sweeps="${AIUR_WORKSPACE_REAP_SWEEPS:-6}"
1084
+ case "$max_sweeps" in '' | *[!0-9]*) max_sweeps=6 ;; esac
1085
+ [ "$max_sweeps" -gt 0 ] || return 0
1086
+
1087
+ local protected pids=() filtered=() pid p sweep waited any
1088
+ protected=" $(shell_protected_pid_list | tr '\n' ' ') "
1089
+
1090
+ sweep=0
1091
+ while [ "$sweep" -lt "$max_sweeps" ]; do
1092
+ mapfile -t pids < <(workspace_cwd_pids "$root" /proc)
1093
+ filtered=()
1094
+ for pid in "${pids[@]}"; do
1095
+ case "$protected" in *" $pid "*) continue ;; esac
1096
+ kill -0 "$pid" 2>/dev/null && filtered+=("$pid")
1097
+ done
1098
+
1099
+ [ "${#filtered[@]}" -gt 0 ] || return 0
1100
+
1101
+ for p in "${filtered[@]}"; do kill -TERM "$p" 2>/dev/null || true; done
1102
+ waited=0
1103
+ while [ "$waited" -lt 10 ]; do
1104
+ any=0
1105
+ for p in "${filtered[@]}"; do kill -0 "$p" 2>/dev/null && { any=1; break; }; done
1106
+ [ "$any" -eq 0 ] && break
1107
+ sleep 0.1
1108
+ waited=$((waited + 1))
1109
+ done
1110
+ for p in "${filtered[@]}"; do kill -KILL "$p" 2>/dev/null || true; done
1111
+
1112
+ sweep=$((sweep + 1))
1113
+ sleep 0.1
1114
+ done
1115
+ }
1116
+
1117
+ reap_workspace_cwd_from_file() {
1118
+ local root_file="${1:-}" root
1119
+ [ -n "$root_file" ] && [ -s "$root_file" ] || return 0
1120
+ IFS= read -r root <"$root_file" || root=""
1121
+ reap_workspace_cwd_agents "$root"
1122
+ }
1123
+
1124
+ current_workspace_root() {
1125
+ resolve_release
1126
+ prepare_distribution >/dev/null 2>&1 || return 1
1127
+
1128
+ local output status line
1129
+ set +e
1130
+ output="$("$release_bin" rpc "IO.puts(Aiur.Config.workspace_root())" 2>/dev/null)"
1131
+ status=$?
1132
+ set -e
1133
+ [ "$status" -eq 0 ] || return 1
1134
+
1135
+ while IFS= read -r line; do
1136
+ case "$line" in
1137
+ "" | :ok) continue ;;
1138
+ *) printf '%s\n' "$line"; return 0 ;;
1139
+ esac
1140
+ done <<<"$output"
1141
+ return 1
1142
+ }
1143
+
693
1144
  # Background watchdog that survives the BEAM. Polls for the release BEAM by
694
1145
  # command pattern and, once it has SEEN the BEAM and then the BEAM disappears
695
1146
  # (orderly halt OR :emfile-style crash), reaps every agent. Polling the pattern
@@ -702,12 +1153,13 @@ record_beam_crash() {
702
1153
  #
703
1154
  # $1 beam_pattern $2 socket $3 pidfile $4 interval_s $5 initial_seen
704
1155
  # $6 node $7 run_log_dir $8 stop_sentinel $9 crash_marker
1156
+ # $10 workspace_root_file
705
1157
  # Args 6-9 arm crash recording (background mode). Omit them (foreground) and the
706
1158
  # watchdog only reaps — a foreground BEAM death is already visible at the UI.
707
1159
  # Prints the watchdog's own pid so the caller can kill it on a clean teardown.
708
1160
  start_beam_death_watchdog() {
709
1161
  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:-}"
1162
+ local node="${6:-}" run_log_dir="${7:-}" stop_sentinel="${8:-}" crash_marker="${9:-}" workspace_root_file="${10:-}"
711
1163
  # Redirect the subshell's stdout so a command-substitution caller
712
1164
  # (`pid=$(start_beam_death_watchdog ...)`) returns immediately instead of
713
1165
  # blocking on the still-open pipe until the watchdog finishes.
@@ -730,6 +1182,7 @@ start_beam_death_watchdog() {
730
1182
  record_beam_crash "$node" "$run_log_dir" "$crash_marker"
731
1183
  fi
732
1184
  reap_aiur_agents "$socket" "$pidfile"
1185
+ reap_workspace_cwd_from_file "$workspace_root_file"
733
1186
  ) >/dev/null 2>&1 &
734
1187
  printf '%s\n' "$!"
735
1188
  }
@@ -789,7 +1242,7 @@ wait_for_session_startup() {
789
1242
  # Foreground teardown: kill the session + BEAM and reap opencode sessions on exit.
790
1243
  _session_socket="" _session_name="" _session_conf="" _session_tmpfile=""
791
1244
  _session_capture="" _session_argv="" _session_release="" _session_tmux="" _session_node=""
792
- _session_pidfile="" _session_watchdog_pid=""
1245
+ _session_pidfile="" _session_watchdog_pid="" _session_workspace_root_file=""
793
1246
  _cleanup_ran=0
794
1247
  session_cleanup() {
795
1248
  [ "$_cleanup_ran" = 1 ] && return 0
@@ -819,6 +1272,11 @@ session_cleanup() {
819
1272
  # release dir, so release-path pgrep would terminate sibling workflows.
820
1273
  [ -n "$_session_node" ] && kill_beams_matching "-name ${_session_node}"
821
1274
 
1275
+ # Final cwd-sweep backstop after the BEAM is gone: catches workspace-rooted
1276
+ # agents or test children that registered too late, reparented during the
1277
+ # BEAM-side sweep, or survived a bounded in-BEAM cleanup.
1278
+ reap_workspace_cwd_from_file "$_session_workspace_root_file"
1279
+
822
1280
  # Reap the epmd our BEAM spawned for distribution so it doesn't linger after
823
1281
  # exit. The node is dead by now, so `epmd -kill` succeeds; it safely refuses
824
1282
  # while any *other* Erlang node is alive, so a daemon shared with an unrelated
@@ -846,7 +1304,8 @@ session_cleanup() {
846
1304
  # tempfiles are spared (fresh + live pid) before the explicit rm below clears them.
847
1305
  sweep_stale_tmp_artifacts || true
848
1306
 
849
- rm -f "$_session_tmpfile" "$_session_capture" "$_session_argv" "$_session_pidfile" 2>/dev/null || true
1307
+ rm -f "$_session_tmpfile" "$_session_capture" "$_session_argv" "$_session_pidfile" "$_session_workspace_root_file" 2>/dev/null || true
1308
+ rm -f "$(aiur_instance_record_path)" 2>/dev/null || true
850
1309
  return $code
851
1310
  }
852
1311
  install_foreground_traps() {
@@ -891,21 +1350,207 @@ probe_node_liveness() {
891
1350
  printf 'unknown'
892
1351
  }
893
1352
 
1353
+ probe_named_node_liveness() {
1354
+ local node="$1" old_node="${RELEASE_NODE:-}" state
1355
+ RELEASE_NODE="$node"
1356
+ state="$(probe_node_liveness)"
1357
+ RELEASE_NODE="$old_node"
1358
+ printf '%s' "$state"
1359
+ }
1360
+
1361
+ load_aiur_instance_record() {
1362
+ local file="$1"
1363
+ AIUR_RECORD_NODE=""
1364
+ AIUR_RECORD_INSTANCE_KEY=""
1365
+ AIUR_RECORD_SESSION=""
1366
+ AIUR_RECORD_SOCKET=""
1367
+ AIUR_RECORD_PROJECT_ROOT=""
1368
+ AIUR_RECORD_PROJECT_ROOT_SOURCE=""
1369
+ [ -r "$file" ] || return 1
1370
+ # Records are written by this engine as KEY=%q lines under the user's state dir.
1371
+ # shellcheck disable=SC1090
1372
+ source "$file" 2>/dev/null || return 1
1373
+ [ -n "$AIUR_RECORD_NODE" ] && [ -n "$AIUR_RECORD_SESSION" ] && \
1374
+ [ -n "$AIUR_RECORD_SOCKET" ] && [ -n "$AIUR_RECORD_PROJECT_ROOT" ]
1375
+ }
1376
+
1377
+ path_is_within_root() {
1378
+ local root child
1379
+ root="$(canonical_workspace_root "$1")"
1380
+ child="$(canonical_workspace_root "$2")"
1381
+ [ "$child" = "$root" ] || [[ "$child/" == "$root/"* ]]
1382
+ }
1383
+
1384
+ AIUR_CONTROL_ADOPTED_RECORD=0
1385
+ AIUR_CONTROL_CURRENT_NODE_STATE=""
1386
+ AIUR_CONTROL_HINT_ROOTS=""
1387
+ AIUR_CONTROL_CALLER_ROOT=""
1388
+ AIUR_CONTROL_CALLER_NODE=""
1389
+ AIUR_CONTROL_CALLER_ROOT_SOURCE=""
1390
+
1391
+ resolve_control_identity_from_records() {
1392
+ AIUR_CONTROL_ADOPTED_RECORD=0
1393
+ AIUR_CONTROL_CURRENT_NODE_STATE="$(probe_node_liveness)"
1394
+ AIUR_CONTROL_HINT_ROOTS=""
1395
+ AIUR_CONTROL_CALLER_ROOT="$(canonical_workspace_root "${AIUR_PROJECT_ROOT:-}")"
1396
+ AIUR_CONTROL_CALLER_NODE="$AIUR_RELEASE_NODE"
1397
+ AIUR_CONTROL_CALLER_ROOT_SOURCE="${AIUR_PROJECT_ROOT_SOURCE:-}"
1398
+
1399
+ [ "$AIUR_CONTROL_CURRENT_NODE_STATE" = "down" ] || return 0
1400
+ [ "${AIUR_PROJECT_ROOT_SOURCE:-}" = "cwd" ] || return 0
1401
+
1402
+ local dir file state root match_count=0
1403
+ local match_node="" match_key="" match_session="" match_socket="" match_root="" match_source=""
1404
+ dir="$(aiur_instances_dir)"
1405
+ [ -d "$dir" ] || return 0
1406
+
1407
+ for file in "$dir"/*.instance; do
1408
+ [ -e "$file" ] || continue
1409
+ load_aiur_instance_record "$file" || continue
1410
+ state="$(probe_named_node_liveness "$AIUR_RECORD_NODE")"
1411
+ [ "$state" = "up" ] || continue
1412
+
1413
+ root="$(canonical_workspace_root "$AIUR_RECORD_PROJECT_ROOT")"
1414
+ case "$AIUR_CONTROL_HINT_ROOTS" in
1415
+ *"
1416
+ $root
1417
+ "*) ;;
1418
+ *) AIUR_CONTROL_HINT_ROOTS="${AIUR_CONTROL_HINT_ROOTS}${root}
1419
+ " ;;
1420
+ esac
1421
+
1422
+ path_is_within_root "$root" "$AIUR_CONTROL_CALLER_ROOT" || continue
1423
+ match_count=$((match_count + 1))
1424
+ match_node="$AIUR_RECORD_NODE"
1425
+ match_key="$AIUR_RECORD_INSTANCE_KEY"
1426
+ match_session="$AIUR_RECORD_SESSION"
1427
+ match_socket="$AIUR_RECORD_SOCKET"
1428
+ match_root="$root"
1429
+ match_source="$AIUR_RECORD_PROJECT_ROOT_SOURCE"
1430
+ done
1431
+
1432
+ [ "$match_count" -eq 1 ] || return 0
1433
+
1434
+ AIUR_RELEASE_NODE="$match_node"
1435
+ AIUR_INSTANCE_KEY="$match_key"
1436
+ AIUR_PROJECT_ROOT="$match_root"
1437
+ AIUR_PROJECT_ROOT_SOURCE="$match_source"
1438
+ AIUR_ADOPTED_TMUX_SESSION="$match_session"
1439
+ AIUR_ADOPTED_TMUX_SOCKET="$match_socket"
1440
+ export AIUR_RELEASE_NODE AIUR_INSTANCE_KEY AIUR_PROJECT_ROOT AIUR_PROJECT_ROOT_SOURCE \
1441
+ AIUR_ADOPTED_TMUX_SESSION AIUR_ADOPTED_TMUX_SOCKET
1442
+ RELEASE_NODE="$AIUR_RELEASE_NODE"
1443
+ export RELEASE_NODE
1444
+ AIUR_CONTROL_ADOPTED_RECORD=1
1445
+ }
1446
+
1447
+ print_global_config_control_hint() {
1448
+ [ "${AIUR_CONTROL_CALLER_ROOT_SOURCE:-}" = "cwd" ] || return 0
1449
+ [ "${AIUR_CONTROL_CURRENT_NODE_STATE:-}" = "down" ] || return 0
1450
+
1451
+ echo "aiur: global-config control identity is keyed by cwd ${AIUR_CONTROL_CALLER_ROOT:-${AIUR_PROJECT_ROOT:-unknown}}" >&2
1452
+ echo "aiur: run control commands from the launch directory, or from a subdirectory of that launch directory" >&2
1453
+ if [ -n "${AIUR_CONTROL_HINT_ROOTS:-}" ]; then
1454
+ echo "aiur: live launch directory candidate(s):" >&2
1455
+ printf '%s' "$AIUR_CONTROL_HINT_ROOTS" | sed '/^$/d; s/^/ /' >&2
1456
+ fi
1457
+ }
1458
+
1459
+ control_rpc_timeout_seconds() {
1460
+ local seconds="${AIUR_CONTROL_RPC_TIMEOUT_SECONDS:-10}"
1461
+ case "$seconds" in
1462
+ '' | *[!0-9]* | 0) seconds=10 ;;
1463
+ esac
1464
+ printf '%s' "$seconds"
1465
+ }
1466
+
1467
+ kill_control_rpc_process() {
1468
+ local pid="$1" grouped="$2" p tree=()
1469
+ [ -n "$pid" ] || return 0
1470
+
1471
+ if [ "$grouped" = "1" ]; then
1472
+ kill -TERM "-$pid" 2>/dev/null || kill -TERM "$pid" 2>/dev/null || true
1473
+ sleep 0.2
1474
+ kill -KILL "-$pid" 2>/dev/null || kill -KILL "$pid" 2>/dev/null || true
1475
+ return 0
1476
+ fi
1477
+
1478
+ while IFS= read -r p; do tree+=("$p"); done < <(agent_pid_tree "$pid")
1479
+ for p in "${tree[@]}"; do kill -TERM "$p" 2>/dev/null || true; done
1480
+ sleep 0.2
1481
+ for p in "${tree[@]}"; do kill -KILL "$p" 2>/dev/null || true; done
1482
+ }
1483
+
1484
+ run_release_rpc_with_timeout() {
1485
+ local expression="$1" timeout output_file timeout_file pid watchdog_pid status grouped=0
1486
+ timeout="$(control_rpc_timeout_seconds)"
1487
+ output_file="$(mktemp "${TMPDIR:-/tmp}/aiur-control-rpc-output.XXXXXX")"
1488
+ timeout_file="$(mktemp "${TMPDIR:-/tmp}/aiur-control-rpc-timeout.XXXXXX")"
1489
+ rm -f "$timeout_file" 2>/dev/null || true
1490
+
1491
+ if command -v setsid >/dev/null 2>&1; then
1492
+ setsid "$release_bin" rpc "$expression" >"$output_file" 2>&1 &
1493
+ grouped=1
1494
+ else
1495
+ "$release_bin" rpc "$expression" >"$output_file" 2>&1 &
1496
+ fi
1497
+ pid=$!
1498
+
1499
+ (
1500
+ sleep "$timeout"
1501
+ if kill -0 "$pid" 2>/dev/null; then
1502
+ : >"$timeout_file"
1503
+ kill_control_rpc_process "$pid" "$grouped"
1504
+ fi
1505
+ ) &
1506
+ watchdog_pid=$!
1507
+
1508
+ if wait "$pid"; then
1509
+ status=0
1510
+ else
1511
+ status=$?
1512
+ fi
1513
+ kill "$watchdog_pid" 2>/dev/null || true
1514
+ wait "$watchdog_pid" 2>/dev/null || true
1515
+
1516
+ AIUR_CONTROL_RPC_OUTPUT="$(cat "$output_file" 2>/dev/null || true)"
1517
+ if [ -f "$timeout_file" ]; then
1518
+ AIUR_CONTROL_RPC_TIMED_OUT=1
1519
+ status=124
1520
+ else
1521
+ AIUR_CONTROL_RPC_TIMED_OUT=0
1522
+ fi
1523
+
1524
+ rm -f "$output_file" "$timeout_file" 2>/dev/null || true
1525
+ return "$status"
1526
+ }
1527
+
894
1528
  # RPC an expression into the running node. The control CLI prints a trailing
895
1529
  # `__AIUR_CONTROL_EXIT__:<code>` marker we translate into the process exit code.
896
1530
  run_control_rpc() {
897
1531
  local expression="$1"
898
1532
  resolve_release
899
1533
  prepare_distribution || die "distribution setup failed; cannot contact aiur"
1534
+ resolve_control_identity_from_records
1535
+ if [ "${AIUR_CONTROL_ADOPTED_RECORD:-0}" -eq 1 ]; then
1536
+ prepare_distribution || die "distribution setup failed; cannot contact aiur"
1537
+ fi
900
1538
 
901
1539
  local marker="__AIUR_CONTROL_EXIT__:"
902
1540
  local output status exit_code=0 saw_marker=0 line
903
1541
 
904
1542
  set +e
905
- output="$("$release_bin" rpc "$expression" 2>&1)"
1543
+ run_release_rpc_with_timeout "$expression"
906
1544
  status=$?
1545
+ output="$AIUR_CONTROL_RPC_OUTPUT"
907
1546
  set -e
908
1547
 
1548
+ if [ "${AIUR_CONTROL_RPC_TIMED_OUT:-0}" -eq 1 ]; then
1549
+ [ -n "$output" ] && printf '%s\n' "$output" >&2
1550
+ echo "aiur: control rpc to ${RELEASE_NODE} timed out after $(control_rpc_timeout_seconds)s; helper process was terminated" >&2
1551
+ return 124
1552
+ fi
1553
+
909
1554
  if [ "$status" -ne 0 ]; then
910
1555
  # A non-zero exit here is the rpc transport failing — application-level
911
1556
  # outcomes ride the marker path below. `bin/aiur rpc` (Elixir --rpc-eval)
@@ -925,6 +1570,7 @@ run_control_rpc() {
925
1570
  echo "aiur: run 'aiur stop' to reap any orphaned agents, then start aiur again" >&2
926
1571
  else
927
1572
  echo "aiur: no running aiur node at ${RELEASE_NODE}; start aiur and try again" >&2
1573
+ print_global_config_control_hint
928
1574
  fi
929
1575
  ;;
930
1576
  up)
@@ -950,7 +1596,11 @@ run_control_rpc() {
950
1596
  esac
951
1597
  done <<<"$output"
952
1598
 
953
- [ "$saw_marker" -eq 1 ] || return 1
1599
+ if [ "$saw_marker" -ne 1 ]; then
1600
+ echo "aiur: control rpc to ${RELEASE_NODE} returned no exit marker; command output may be incomplete" >&2
1601
+ return 1
1602
+ fi
1603
+
954
1604
  return "$exit_code"
955
1605
  }
956
1606
 
@@ -1046,6 +1696,99 @@ cmd_agents() {
1046
1696
  run_control_rpc "Aiur.AgentControlCLI.agents()"
1047
1697
  }
1048
1698
 
1699
+ # `aiur alerts` — newline-delimited structured alert feed from persisted
1700
+ # per-agent logs. `--needs-attention` filters to operator-actionable alerts.
1701
+ cmd_alerts() {
1702
+ local needs_attention=0 arg
1703
+ for arg in "$@"; do
1704
+ case "$arg" in
1705
+ --needs-attention) needs_attention=1 ;;
1706
+ *)
1707
+ echo "aiur: alerts only accepts --needs-attention" >&2
1708
+ exit 64
1709
+ ;;
1710
+ esac
1711
+ done
1712
+
1713
+ if [ "$needs_attention" -eq 1 ]; then
1714
+ run_control_rpc "Aiur.AgentControlCLI.alerts(needs_attention: true)"
1715
+ else
1716
+ run_control_rpc "Aiur.AgentControlCLI.alerts()"
1717
+ fi
1718
+ }
1719
+
1720
+ # `aiur watch` — the server-side status board. Compiles one row per active
1721
+ # agent (state · complexity · activity-age · doing) plus an actionable section
1722
+ # from aiur's own state, with no GitHub round-trip. `--changes` (default) prints
1723
+ # only state-level deltas since the last call; `--full` prints every row;
1724
+ # `--interval N` re-renders every N seconds as a foreground watcher (the Elixir
1725
+ # call stays one-shot — the loop lives here).
1726
+ cmd_watch() {
1727
+ local mode="changes" interval="" arg
1728
+
1729
+ while [ "$#" -gt 0 ]; do
1730
+ arg="$1"
1731
+ case "$arg" in
1732
+ --full) mode="full" ;;
1733
+ --changes) mode="changes" ;;
1734
+ --once) : ;;
1735
+ --interval)
1736
+ shift
1737
+ interval="${1:-}"
1738
+ watch_validate_interval "$interval"
1739
+ ;;
1740
+ --interval=*)
1741
+ interval="${arg#--interval=}"
1742
+ watch_validate_interval "$interval"
1743
+ ;;
1744
+ *)
1745
+ echo "aiur: watch accepts --full, --changes, --once, --interval <secs>" >&2
1746
+ exit 64
1747
+ ;;
1748
+ esac
1749
+ shift
1750
+ done
1751
+
1752
+ local expression="Aiur.AgentControlCLI.watch(mode: :${mode})"
1753
+
1754
+ if [ -n "$interval" ]; then
1755
+ while true; do
1756
+ run_control_rpc "$expression" || true
1757
+ sleep "$interval"
1758
+ done
1759
+ else
1760
+ run_control_rpc "$expression"
1761
+ fi
1762
+ }
1763
+
1764
+ watch_validate_interval() {
1765
+ if ! [[ "$1" =~ ^[0-9]+$ ]] || [ "$1" -le 0 ]; then
1766
+ echo "aiur: watch --interval expects a positive integer (seconds)" >&2
1767
+ exit 64
1768
+ fi
1769
+ }
1770
+
1771
+ cmd_cleanup_stale() {
1772
+ local dry_run=0 arg
1773
+ for arg in "$@"; do
1774
+ case "$arg" in
1775
+ --dry-run) dry_run=1 ;;
1776
+ *)
1777
+ echo "aiur: cleanup-stale only accepts --dry-run" >&2
1778
+ exit 64
1779
+ ;;
1780
+ esac
1781
+ done
1782
+
1783
+ aiur_resolve_identity
1784
+ if [ "$dry_run" -eq 1 ]; then
1785
+ report_stale_manual_smoke || true
1786
+ else
1787
+ report_stale_manual_smoke || true
1788
+ reap_stale_manual_smoke 1
1789
+ fi
1790
+ }
1791
+
1049
1792
  # `aiur set <key> <value>` — runtime config overrides without editing
1050
1793
  # `.aiur/config`. Currently: `aiur set max-agents N`.
1051
1794
  cmd_set() {
@@ -1196,11 +1939,33 @@ sweep_stale_tmp_artifacts() {
1196
1939
  cmd_stop() {
1197
1940
  resolve_release
1198
1941
  aiur_resolve_identity
1942
+ RELEASE_NODE="$AIUR_RELEASE_NODE"
1943
+ ERL_EPMD_ADDRESS="${ERL_EPMD_ADDRESS:-127.0.0.1}"
1944
+ export RELEASE_NODE ERL_EPMD_ADDRESS
1945
+ resolve_control_identity_from_records
1946
+
1947
+ local workspace_root
1948
+ workspace_root="$(current_workspace_root 2>/dev/null || true)"
1199
1949
 
1200
1950
  local tmux_bin
1201
1951
  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}"
1952
+ local session="${AIUR_ADOPTED_TMUX_SESSION:-${AIUR_SESSION_PREFIX}-${USER:-user}${AIUR_INSTANCE_KEY:+-$AIUR_INSTANCE_KEY}-default}"
1953
+ local socket="${AIUR_ADOPTED_TMUX_SOCKET:-${AIUR_SESSION_PREFIX}-${USER:-user}${AIUR_INSTANCE_KEY:+-$AIUR_INSTANCE_KEY}}"
1954
+
1955
+ local has_session=0
1956
+ if [ -n "$tmux_bin" ] && "$tmux_bin" -L "$socket" has-session -t "$session" 2>/dev/null; then
1957
+ has_session=1
1958
+ fi
1959
+
1960
+ if [ "${AIUR_CONTROL_ADOPTED_RECORD:-0}" -ne 1 ] && \
1961
+ [ "${AIUR_CONTROL_CURRENT_NODE_STATE:-}" = "down" ] && \
1962
+ [ "${AIUR_PROJECT_ROOT_SOURCE:-}" = "cwd" ] && \
1963
+ [ "$has_session" -eq 0 ] && \
1964
+ [ ! -f "$(aiur_crash_marker_path)" ]; then
1965
+ echo "aiur: no running aiur node at ${AIUR_RELEASE_NODE}; nothing stopped" >&2
1966
+ print_global_config_control_hint
1967
+ return 1
1968
+ fi
1204
1969
 
1205
1970
  # Tell the background BEAM-death watchdog this exit is intentional before we
1206
1971
  # kill the BEAM, so it consumes the sentinel instead of recording a crash. The
@@ -1219,6 +1984,10 @@ cmd_stop() {
1219
1984
  # it. Do not sweep by release dir: sibling instances share dev releases.
1220
1985
  kill_beams_matching "-name ${AIUR_RELEASE_NODE}"
1221
1986
 
1987
+ # Final cwd-sweep backstop after the BEAM is dead. `cmd_stop` is a fresh shell,
1988
+ # so it captures the root from the live control node before the kill above.
1989
+ reap_workspace_cwd_agents "$workspace_root"
1990
+
1222
1991
  # The BEAM (alive until the TERM above) reaped its own headless agents through
1223
1992
  # ProcessReaper on Application.stop. kill-server is the guarantee the earlier
1224
1993
  # kill-session can't give for mid-turn agents: every pane agent across all
@@ -1238,8 +2007,10 @@ cmd_stop() {
1238
2007
  rm -f "$agentfile" 2>/dev/null || true
1239
2008
  done
1240
2009
 
2010
+ reap_stale_manual_smoke 0
1241
2011
  sweep_dead_tmux_sockets
1242
2012
  sweep_stale_tmp_artifacts
2013
+ rm -f "$(aiur_instance_record_path)" 2>/dev/null || true
1243
2014
  }
1244
2015
 
1245
2016
  # --- dispatch ----------------------------------------------------------------
@@ -1275,6 +2046,14 @@ aiur_engine_main() {
1275
2046
  shift
1276
2047
  cmd_agents "$@"
1277
2048
  ;;
2049
+ alerts)
2050
+ shift
2051
+ cmd_alerts "$@"
2052
+ ;;
2053
+ watch)
2054
+ shift
2055
+ cmd_watch "$@"
2056
+ ;;
1278
2057
  set)
1279
2058
  shift
1280
2059
  cmd_set "$@"
@@ -1287,6 +2066,10 @@ aiur_engine_main() {
1287
2066
  shift
1288
2067
  cmd_message "$@"
1289
2068
  ;;
2069
+ cleanup-stale)
2070
+ shift
2071
+ cmd_cleanup_stale "$@"
2072
+ ;;
1290
2073
  stop)
1291
2074
  cmd_stop
1292
2075
  ;;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aiur-cli",
3
- "version": "0.0.3",
3
+ "version": "0.0.4",
4
4
  "description": "AI Unit Runtime for Executors.",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://github.com/its-everdred/aiur",
@@ -24,11 +24,11 @@
24
24
  "engines": {
25
25
  "node": ">=18"
26
26
  },
27
- "opencodeVersion": "1.15.6",
27
+ "opencodeVersion": "1.17.10",
28
28
  "optionalDependencies": {
29
- "aiur-cli-linux-x64": "0.0.3",
30
- "aiur-cli-linux-arm64": "0.0.3",
31
- "aiur-cli-darwin-x64": "0.0.3",
32
- "aiur-cli-darwin-arm64": "0.0.3"
29
+ "aiur-cli-linux-x64": "0.0.4",
30
+ "aiur-cli-linux-arm64": "0.0.4",
31
+ "aiur-cli-darwin-x64": "0.0.4",
32
+ "aiur-cli-darwin-arm64": "0.0.4"
33
33
  }
34
34
  }
@@ -23,14 +23,15 @@ import path from "node:path";
23
23
 
24
24
  // Pinned opencode version, read from package.json `opencodeVersion` (single
25
25
  // source of truth — keep in sync with mise.toml). aiur's opencode pane
26
- // integration is validated against this version; 1.17.x broke custom-provider
27
- // model resolution (ProviderModelNotFoundError), blanking the chat panes.
26
+ // integration is validated end-to-end against this exact version; floating to
27
+ // `latest` would adopt an unverified opencode that could regress custom-provider
28
+ // model resolution or the chat panes, so the spec must carry a fixed version.
28
29
  const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
29
30
  export const OPENCODE_PACKAGE = `opencode-ai@${pkg.opencodeVersion}`;
30
31
 
31
32
  // True only when the *pinned* opencode version is on PATH. A wrong (e.g.
32
- // newer) version must be reinstalled — leaving 1.17.x in place keeps the
33
- // panes broken — so a mere presence check isn't enough.
33
+ // newer, unverified) version must be reinstalled to the pin — so a mere
34
+ // presence check isn't enough.
34
35
  export function opencodeMatchesPin() {
35
36
  const result = spawnSync("opencode", ["--version"], { encoding: "utf8" });
36
37
  return !result.error && result.status === 0 && result.stdout.trim() === pkg.opencodeVersion;