aiur-cli 0.0.5-nightly.df55dc3 → 0.0.5

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
@@ -1,61 +1,77 @@
1
1
  # AIUR
2
2
 
3
- **AI Unit Runtime for Executors**
3
+ Oversee a fleet of coding agents that coordinate via events.
4
4
 
5
- Oversee a fleet of coding agents that **coordinate via events**.
5
+ ## Install
6
6
 
7
- Aiur watches your issue tracker, dispatches one agent per ticket into its own
8
- workspace and checkout, and gives you four ways to supervise the fleet instead
9
- of babysitting individual coding sessions.
10
-
11
- > [!WARNING]
12
- > **Use at your own risk.** Aiur is an unstable, vibecoded engineering preview for trusted
13
- > environments only. It **bypasses all agent permission prompts** and has very few efficiency
14
- > optimizations. Suggested for simple tasks under supervision.
15
- >
16
- > Provided "as is", without warranty of any kind. You assume all risk for any cost, token
17
- > spend, data loss, or damage from running it.
7
+ ```bash
8
+ npm install --global aiur-cli
9
+ ```
18
10
 
19
- ## Get started
11
+ ## Usage
20
12
 
21
13
  ```bash
22
- npm install -g aiur-cli # Node.js 18+
23
-
24
14
  cd your-repo
25
- aiur init # interactive setup wizard
26
- aiur # start the run
15
+ aiur init # interactive setup wizard
16
+ aiur
27
17
  ```
28
18
 
29
19
  `aiur init` scaffolds `.aiur/config` and provisions the repo: tracker and
30
20
  repository, agent backends and routing, concurrency and turn limits, the GitHub
31
21
  token, and the `agent:*` lifecycle labels. Re-running resumes from your saved
32
- answers.
22
+ answers. When setup finishes, label the issues you want worked `agent:todo` and
23
+ run `aiur`.
33
24
 
34
- When setup finishes, label the issues you want worked `agent:todo` and run
35
- `aiur`.
25
+ To have a coding agent operate the run as its Executor, install the repository's
26
+ [`aiur-intro`, `aiur-run`, and `aiur-monitor` skills](https://aiur.team/docs/skills#executor-skills)
27
+ through the agent's supported skill mechanism, then ask it to "run aiur." The npm
28
+ package installs the CLI, not those skills. [Learn about the Executor role](https://aiur.team/docs/concepts/executor).
36
29
 
37
- | Requirement | Why |
38
- | --- | --- |
39
- | [tmux](https://github.com/tmux/tmux) | Runtime for the TUI and agent panes |
40
- | [opencode](https://opencode.ai) | In-pane agent chat (provisioned on install) |
41
- | Claude Code or Codex | The agents themselves |
30
+ ## Features
31
+
32
+ - **Issue Monitoring** — Dispatch agents automatically via GitHub or Linear tickets.
33
+ - **Shared Bus** — Agents auto-pub/sub to dependency events, unblocking work early.
34
+ - **Optimize Effort** — Story points guide model sizes and skills across Claude and Codex.
35
+ - **Take the Wheel** — Drive at any moment via pre-warmed [Opencode](https://opencode.ai) sessions in configurable tmux panes.
36
+ - **Smart Alerts** — Customizable notifications and sounds when agents finish, stall, or need a decision.
37
+ - **Live Dashboard** — Shareable web view of every agent, event, and progress bar.
38
+ - **Build Orders** — Plan a large feature into typed members, lanes, phases, and dependencies, dispatched in dependency-safe parallel batches.
39
+ - **Analytics** — Live-run telemetry: lifecycle time, concurrency, complexity tiers, and cost over a chosen window.
40
+ - **Stream Deck** — Elgato Stream Deck + keys ranked by urgency, with pause/resume, a live log surface, and hold-to-dictate voice messages.
41
+ - **Hyper Optimized** — Monitors API and model token usage, routes on peak pricing and other factors, and supports Cloudflare and more.
42
42
 
43
- ## The four surfaces
43
+ The TUI and GUI are both optional: `aiur --bg` runs headless with the GUI, and
44
+ `--no-dashboard` drops the web listener. Every usable GUI requires
45
+ `AIUR_DASHBOARD_USERNAME` and `AIUR_DASHBOARD_PASSWORD` — without them the loopback
46
+ listener binds but refuses every request until both are set. The GUI is writable
47
+ by default, and writable mode requires those same two credentials even on loopback.
44
48
 
45
- | Surface | What it gives you | How to open it |
46
- | --- | --- | --- |
47
- | **CLI** | Scriptable control of a run from any shell | `aiur status`, `aiur pause 142` |
48
- | **TUI** | Live agent board plus chat panes, in your terminal | `aiur` (foreground) |
49
- | **Dashboard** | Browser view of the fleet, decisions, and analytics | `http://127.0.0.1:4000` |
50
- | **Stream Deck** | Physical keys and dictation for the fleet | Separate sidecar, see below |
49
+ ## Interfaces
50
+
51
+ ### GUI
52
+
53
+ ![The Units page of the GUI](https://aiur.team/images/dashboard/units-dark.png)
54
+
55
+ The GUI is the browser interface for supervising a run: the Units fleet table
56
+ and meters, the Commands decision inbox, Build Order graphs, and Analytics.
57
+
58
+ ### TUI
59
+
60
+ ![The Aiur TUI: the agent-list board and a chat pane](https://aiur.team/images/tui/aiur-tui.gif)
61
+
62
+ The TUI is a live agent board in your terminal with chat panes opened into
63
+ individual agent sessions. Press `enter` on a row to open its conversation,
64
+ `space` to pause or resume, `←` / `→` to change the concurrency cap, and `?`
65
+ for the full keymap. A later bare `aiur` from the same project attaches to that
66
+ live directory-scoped session; detaching leaves the run healthy.
51
67
 
52
68
  ### CLI
53
69
 
54
70
  | Command | What it does |
55
71
  | --- | --- |
56
72
  | `aiur init` | Interactive setup wizard |
57
- | `aiur` | Start the run in the foreground with the TUI |
58
- | `aiur --bg` | Start a detached headless run, dashboard still served |
73
+ | `aiur` | Start a foreground TUI, or attach to this directory's live session |
74
+ | `aiur --bg` | Start a detached headless run, GUI still served |
59
75
  | `aiur status` | Daemon state, active agents, and the current concurrency cap |
60
76
  | `aiur alerts --needs-attention` | Unresolved items waiting on you |
61
77
  | `aiur pause <id…>` / `aiur resume <id…>` | Pause or resume agents by issue id |
@@ -64,56 +80,15 @@ When setup finishes, label the issues you want worked `agent:todo` and run
64
80
  | `aiur --todo <id…> [--only]` | Queue tickets; `--only` dequeues the rest |
65
81
  | `aiur stop` / `aiur restart` | Stop the session, or refresh the release and restart |
66
82
 
67
- Add `--no-dashboard` to either mode to skip the web listener.
68
-
69
- ### TUI
70
-
71
- The foreground run opens a tmux board with one row per ticket, showing runtime,
72
- turn count, backend, pinned model, and a state glyph. Press `enter` on a row to
73
- open that agent's chat pane beside the board, `space` to pause or resume, `←`
74
- and `→` to change the concurrency cap, and `?` for the full keymap.
75
-
76
- ### Dashboard
77
-
78
- Served on foreground and `--bg` runs at the host and port in your config,
79
- `http://127.0.0.1:4000` by default. The launch output prints the real URL.
80
-
81
- | Page | What it shows |
82
- | --- | --- |
83
- | Units | Fleet table, ticket backlog, provider rate meters |
84
- | Commands | Durable decision inbox: answer, revise, retry delivery |
85
- | Build Order | Ticket dependency graph and phases |
86
- | Analytics | Throughput, complexity tiers, and spend over a chosen window |
87
-
88
- Writable mode is the default and requires `AIUR_DASHBOARD_USERNAME` and
89
- `AIUR_DASHBOARD_PASSWORD`, even on loopback. Set
90
- `observability.dashboard_writable: false` for an unauthenticated read-only
91
- loopback dashboard. Basic Auth is unencrypted, so put a TLS proxy in front of any
92
- non-loopback bind.
93
-
94
- ### Stream Deck
83
+ ## License
95
84
 
96
- An Elgato Stream Deck + can drive the fleet: agent keys ranked by urgency, pause
97
- and resume, a live log surface, and hold-to-dictate voice messages. A browser
98
- emulator of the same layout is at `/streamdeck` on the dashboard.
99
-
100
- > The physical sidecar is **experimental** and ships separately from this
101
- > package, as a Linux x64 archive (glibc 2.28+, systemd user session). It is not
102
- > installed by `npm install aiur-cli`. See the
103
- > [Stream Deck guide](https://aiur.team/docs/guide/stream-deck).
104
-
105
- ## Update notices
106
-
107
- When a newer `aiur-cli` is published, a one-line notice is printed to stderr,
108
- checked in the background at most once a day and never blocking your command.
109
- Set `AIUR_NO_UPDATE_NOTIFIER=1` to silence it; it is skipped under CI.
110
-
111
- Prerelease builds are published under the `nightly` dist-tag and never reach
112
- `latest`, so a plain `npm install -g aiur-cli` always gives you a stable release.
113
- To try one: `npm install -g aiur-cli@nightly`.
114
-
115
- ---
116
-
117
- _Command macro, delegate micro, maximize APM._
85
+ > [!WARNING]
86
+ > **Use at your own risk.** Aiur is an unstable, vibecoded engineering preview
87
+ > for trusted environments only. It **bypasses all agent permission prompts**
88
+ > and has very few efficiency optimizations. Suggested for simple tasks under
89
+ > supervision.
90
+ >
91
+ > Provided "as is", without warranty of any kind. You assume all risk for any
92
+ > cost, token spend, data loss, or damage from running it.
118
93
 
119
- [github.com/aiur-team/aiur](https://github.com/aiur-team/aiur)
94
+ This project is licensed under the [Apache License 2.0](https://github.com/aiur-team/aiur/blob/main/LICENSE).
@@ -490,6 +490,8 @@ Usage: aiur [--interactive] [--no-dashboard] [--executor] [--pause] [--max-agent
490
490
  aiur asks [--open|--all] [--json] inspect current-repository operator requests
491
491
  aiur cleanup-stale [--dry-run] list/reap stale manual-smoke leftovers
492
492
  aiur --version
493
+
494
+ Bare aiur: start or attach to this directory's interactive session.
493
495
  EOF
494
496
  }
495
497
 
@@ -686,6 +688,65 @@ run_session() {
686
688
  [ -n "$tmux_bin" ] || die "tmux is required to run aiur; install tmux and retry"
687
689
 
688
690
  aiur_resolve_identity
691
+ prepare_distribution
692
+
693
+ local session="${AIUR_SESSION_PREFIX}-${USER:-user}${AIUR_INSTANCE_KEY:+-$AIUR_INSTANCE_KEY}-default"
694
+ local socket="${AIUR_SESSION_PREFIX}-${USER:-user}${AIUR_INSTANCE_KEY:+-$AIUR_INSTANCE_KEY}"
695
+ local conf launch_lock
696
+ conf="$(resolve_tmux_conf)"
697
+ [ -f "$conf" ] || die "tmux conf not found at $conf"
698
+ launch_lock="$(aiur_launch_lock_path)"
699
+ acquire_aiur_launch_lock "$launch_lock" || return 1
700
+
701
+ # An invocation targeting an existing directory session resolves here before
702
+ # fresh-start credentials, argv files, run logs, watchdogs, or teardown traps.
703
+ # Foreground attaches; background exits with explicit attach guidance.
704
+ if "$tmux_bin" -L "$socket" -f "$conf" has-session -t "$session" 2>/dev/null; then
705
+ if [ "$(probe_control_liveness)" = "up" ]; then
706
+ # Preserve the live launch's record and workspace-root handoff. Only
707
+ # backfill old/missing records, without pointing them at this attach
708
+ # invocation's transient state.
709
+ AIUR_WORKSPACE_ROOT_FILE="" write_aiur_instance_record "$session" "$socket" if-absent
710
+
711
+ if [ "$mode" = "background" ]; then
712
+ echo "aiur is already running in the background (tmux session ${session})." >&2
713
+ echo "Attach with: aiur" >&2
714
+ echo "Use: aiur status # inspect agents" >&2
715
+ echo "Use: aiur stop # stop it before starting a fresh session" >&2
716
+ release_aiur_launch_lock "$launch_lock"
717
+ return 0
718
+ fi
719
+
720
+ echo "aiur is already running; attaching to the running session ${session}." >&2
721
+ release_aiur_launch_lock "$launch_lock"
722
+ attach_tmux_session "$tmux_bin" "$socket" "$conf" "$session"
723
+ return $?
724
+ fi
725
+
726
+ # A tmux holder can exist while a healthy BEAM is still booting or while
727
+ # epmd itself is temporarily unreachable. Never turn one failed control
728
+ # probe into destructive cleanup unless distribution confirms the keyed
729
+ # node is absent.
730
+ case "$(probe_node_liveness)" in
731
+ down)
732
+ if ! confirm_stale_tmux_session "$tmux_bin" "$socket" "$conf" "$session"; then
733
+ echo "aiur found session ${session}, but it became live while checking stale state; leaving it intact." >&2
734
+ echo "Retry: aiur" >&2
735
+ release_aiur_launch_lock "$launch_lock"
736
+ return 1
737
+ fi
738
+ echo "aiur found stale tmux session ${session}; cleaning it up before restart" >&2
739
+ reap_aiur_agents "$socket" ""
740
+ kill_beams_matching "-name ${AIUR_RELEASE_NODE}"
741
+ ;;
742
+ up | unknown)
743
+ echo "aiur found session ${session}, but its control plane is not ready; leaving the running session intact." >&2
744
+ echo "Retry: aiur" >&2
745
+ release_aiur_launch_lock "$launch_lock"
746
+ return 1
747
+ ;;
748
+ esac
749
+ fi
689
750
 
690
751
  # Pick up GITHUB_TOKEN / dashboard creds the wizard wrote to ./.env so the
691
752
  # running tracker can authenticate. Shell exports still take precedence.
@@ -717,7 +778,6 @@ run_session() {
717
778
  write_argv "${run_argv[@]}"
718
779
  export AIUR_ARGV_FILE="$argv_file"
719
780
 
720
- prepare_distribution
721
781
  build_release_cmd
722
782
 
723
783
  # Force +fnu when no locale is set so the BEAM does not mangle non-ASCII paths.
@@ -725,12 +785,6 @@ run_session() {
725
785
  export ELIXIR_ERL_OPTIONS="${ELIXIR_ERL_OPTIONS:-} +fnu"
726
786
  fi
727
787
 
728
- local session="${AIUR_SESSION_PREFIX}-${USER:-user}${AIUR_INSTANCE_KEY:+-$AIUR_INSTANCE_KEY}-default"
729
- local socket="${AIUR_SESSION_PREFIX}-${USER:-user}${AIUR_INSTANCE_KEY:+-$AIUR_INSTANCE_KEY}"
730
- local conf
731
- conf="$(resolve_tmux_conf)"
732
- [ -f "$conf" ] || die "tmux conf not found at $conf"
733
-
734
788
  preflight_stale_manual_smoke
735
789
 
736
790
  mkdir -p "$AIUR_BG_STATE_DIR"
@@ -844,31 +898,11 @@ run_session() {
844
898
  _session_node="$AIUR_RELEASE_NODE" _session_pidfile="$AIUR_AGENT_TMPFILE" \
845
899
  _session_workspace_root_file="$AIUR_WORKSPACE_ROOT_FILE" \
846
900
  _session_alert_ledger_path_file="$AIUR_ALERT_LEDGER_PATH_FILE" \
847
- _session_crash_dump_baseline_file="$crash_dump_baseline_file"
901
+ _session_crash_dump_baseline_file="$crash_dump_baseline_file" \
902
+ _session_launch_lock="$launch_lock"
848
903
  install_foreground_traps
849
904
  fi
850
905
 
851
- # Background starts are intentionally idempotent. A prior live node should not
852
- # fall through to tmux's opaque "duplicate session" failure, and a stale tmux
853
- # session whose BEAM/control plane is gone should be reclaimed before retry.
854
- if [ "$mode" = "background" ] && "$tmux_bin" -L "$socket" -f "$conf" has-session -t "$session" 2>/dev/null; then
855
- if [ "$(probe_control_liveness)" = "up" ]; then
856
- # Write a record only when none exists. A live session's own start already
857
- # wrote one — and it owns the BEAM-written AIUR_RECORD_WORKSPACE_ROOT_FILE
858
- # path, which must not be overwritten by this invocation's temp file.
859
- [ -f "$(aiur_instance_record_path)" ] || (AIUR_WORKSPACE_ROOT_FILE="" write_aiur_instance_record "$session" "$socket")
860
- echo "aiur is already running in the background (tmux session ${session})." >&2
861
- echo "Use: aiur status # inspect agents" >&2
862
- echo "Use: aiur stop # stop it before starting a fresh session" >&2
863
- rm -f "${launch_tempfiles[@]}" 2>/dev/null || true
864
- return 0
865
- fi
866
-
867
- echo "aiur found stale tmux session ${session}; cleaning it up before restart" >&2
868
- reap_aiur_agents "$socket" "$AIUR_AGENT_TMPFILE"
869
- kill_beams_matching "-name ${AIUR_RELEASE_NODE}"
870
- fi
871
-
872
906
  # An orphaned BEAM (its tmux session gone) can still hold THIS instance's node
873
907
  # name. With no live session on our (instance-keyed) socket, reap the name-holder
874
908
  # so the launch isn't blocked by "name seems to be in use". The keyed name means
@@ -899,6 +933,7 @@ run_session() {
899
933
  echo "❌ aiur failed to start; captured output:" >&2
900
934
  tail -n 30 "$startup_capture" 2>/dev/null | sed 's/^/ /' >&2 || true
901
935
  rm -f "${launch_tempfiles[@]}" 2>/dev/null || true
936
+ release_aiur_launch_lock "$launch_lock"
902
937
  exit 1
903
938
  fi
904
939
 
@@ -924,12 +959,16 @@ run_session() {
924
959
  reap_aiur_agents "$socket" "$AIUR_AGENT_TMPFILE"
925
960
  kill_beams_matching "-name ${AIUR_RELEASE_NODE}"
926
961
  fi
962
+ release_aiur_launch_lock "$launch_lock"
963
+ _session_launch_lock=""
927
964
  [ "$mode" = "foreground" ] && return 1
928
965
  rm -f "${launch_tempfiles[@]}" 2>/dev/null || true
929
966
  exit 1
930
967
  fi
931
968
 
932
969
  write_aiur_instance_record "$session" "$socket"
970
+ release_aiur_launch_lock "$launch_lock"
971
+ _session_launch_lock=""
933
972
  print_config_status "$startup_capture"
934
973
  print_dashboard_status "$no_dashboard" "$startup_capture"
935
974
  # The `aiur run` version notice: a cheap local state read (the daemon's
@@ -976,10 +1015,18 @@ run_session() {
976
1015
  # Foreground: attach the UI. Do not exec — that would drop the teardown trap.
977
1016
  # Avoid process substitution here: some sandboxed non-TTY launchers reject
978
1017
  # opening /dev/fd/* during the real manual-test wrapper path.
1018
+ attach_tmux_session "$tmux_bin" "$socket" "$conf" "$session"
1019
+ }
1020
+
1021
+ attach_tmux_session() {
1022
+ local tmux_bin="$1" socket="$2" conf="$3" session="$4"
979
1023
  local attach_stderr attach_code
980
1024
  attach_stderr="$(mktemp "${TMPDIR:-/tmp}/aiur-attach-stderr.XXXXXX")"
981
- "$tmux_bin" -L "$socket" -f "$conf" attach -t "$session" 2>"$attach_stderr"
982
- attach_code=$?
1025
+ if "$tmux_bin" -L "$socket" -f "$conf" attach -t "$session" 2>"$attach_stderr"; then
1026
+ attach_code=0
1027
+ else
1028
+ attach_code=$?
1029
+ fi
983
1030
  grep -v -F "[server exited]" "$attach_stderr" >&2 || true
984
1031
  rm -f "$attach_stderr" 2>/dev/null || true
985
1032
  return "$attach_code"
@@ -1002,12 +1049,31 @@ resolve_tmux_conf() {
1002
1049
  # name (covers orphans from any release dir): under the unified identity a dev
1003
1050
  # `_build` BEAM and an installed BEAM both claim the same node name, so a
1004
1051
  # release-path pgrep misses whichever one this run didn't launch.
1052
+ # TERM the node(s) matching $pattern, give them $grace_ticks (0.1s) to exit
1053
+ # gracefully, then SIGKILL whatever remains. The default grace is short (3s)
1054
+ # because startup reclaim wants to clear a stale BEAM quickly; the stop paths
1055
+ # pass a longer grace so the BEAM can finish its own agent reap + session
1056
+ # deletion instead of being SIGKILLed mid-cleanup (which is what orphaned agent
1057
+ # processes on `aiur stop`). Callers may override the grace internally; invalid
1058
+ # or excessive values fall back to the 3s startup-reclaim default instead of
1059
+ # skipping the wait and jumping straight to SIGKILL.
1005
1060
  kill_beams_matching() {
1006
- local pattern="$1" pids pid waited=0
1061
+ local pattern="$1" grace_ticks="${2:-30}" pids pid waited=0
1062
+ case "$grace_ticks" in
1063
+ "" | *[!0-9]*) grace_ticks=30 ;;
1064
+ *)
1065
+ # Reject overlong digit strings before numeric comparison so shell integer
1066
+ # overflow cannot turn an excessive grace into an immediate SIGKILL.
1067
+ if [ "${#grace_ticks}" -gt 5 ] ||
1068
+ { [ "${#grace_ticks}" -eq 5 ] && [ "$grace_ticks" -gt 36000 ]; }; then
1069
+ grace_ticks=30
1070
+ fi
1071
+ ;;
1072
+ esac
1007
1073
  pids="$(pgrep -f -- "$pattern" 2>/dev/null || true)"
1008
1074
  [ -n "$pids" ] || return 0
1009
1075
  for pid in $pids; do kill -TERM "$pid" 2>/dev/null || true; done
1010
- while [ "$waited" -lt 30 ]; do
1076
+ while [ "$waited" -lt "$grace_ticks" ]; do
1011
1077
  pids="$(pgrep -f -- "$pattern" 2>/dev/null || true)"
1012
1078
  [ -z "$pids" ] && break
1013
1079
  sleep 0.1
@@ -1336,8 +1402,76 @@ aiur_instance_record_path() {
1336
1402
  printf '%s/%s.instance' "$(aiur_instances_dir)" "$(aiur_state_slug)"
1337
1403
  }
1338
1404
 
1405
+ aiur_launch_lock_path() {
1406
+ printf '%s/locks/%s.launch' "${AIUR_BG_STATE_DIR:?}" "$(aiur_state_slug)"
1407
+ }
1408
+
1409
+ acquire_aiur_launch_lock() {
1410
+ local lock="$1" owner_file="$1/owner" recovery_lock="$1.recovery"
1411
+ local owner tick lock_age
1412
+ local max_ticks="${AIUR_LAUNCH_LOCK_TICKS:-${AIUR_NODE_GRACE_TICKS:-1200}}"
1413
+ local ownerless_stale_seconds="${AIUR_OWNERLESS_LOCK_STALE_SECONDS:-120}"
1414
+ mkdir -p "$(dirname "$lock")" 2>/dev/null || return 1
1415
+
1416
+ for ((tick = 0; tick < max_ticks; tick++)); do
1417
+ if mkdir "$lock" 2>/dev/null; then
1418
+ printf '%s\n' "$$" >"$owner_file" || { rmdir "$lock" 2>/dev/null || true; return 1; }
1419
+ return 0
1420
+ fi
1421
+
1422
+ owner="$(sed -n '1p' "$owner_file" 2>/dev/null || true)"
1423
+ if [[ "$owner" =~ ^[0-9]+$ ]] && kill -0 "$owner" 2>/dev/null; then
1424
+ sleep 0.1
1425
+ continue
1426
+ fi
1427
+
1428
+ lock_age="$(aiur_lock_age_seconds "$lock")"
1429
+ if [ -z "$owner" ] && { [ -z "$lock_age" ] || [ "$lock_age" -lt "$ownerless_stale_seconds" ]; }; then
1430
+ sleep 0.1
1431
+ continue
1432
+ fi
1433
+
1434
+ # Only one contender may reclaim a dead lock. Re-read ownership while the
1435
+ # recovery mutex is held so another contender cannot delete a newly claimed
1436
+ # launch directory after losing the original stale-lock race.
1437
+ if mkdir "$recovery_lock" 2>/dev/null; then
1438
+ owner="$(sed -n '1p' "$owner_file" 2>/dev/null || true)"
1439
+ lock_age="$(aiur_lock_age_seconds "$lock")"
1440
+ if { [ -z "$owner" ] && [ -n "$lock_age" ] && [ "$lock_age" -ge "$ownerless_stale_seconds" ]; } || \
1441
+ { [[ "$owner" =~ ^[0-9]+$ ]] && ! kill -0 "$owner" 2>/dev/null; }; then
1442
+ rm -f "$owner_file" 2>/dev/null || true
1443
+ rmdir "$lock" 2>/dev/null || true
1444
+ fi
1445
+ rmdir "$recovery_lock" 2>/dev/null || true
1446
+ fi
1447
+ sleep 0.1
1448
+ done
1449
+
1450
+ echo "aiur: another launch for this directory is still in progress; retry aiur" >&2
1451
+ return 1
1452
+ }
1453
+
1454
+ aiur_lock_age_seconds() {
1455
+ local path="$1" now modified
1456
+ now="$(date +%s 2>/dev/null || true)"
1457
+ modified="$(stat -c %Y "$path" 2>/dev/null || stat -f %m "$path" 2>/dev/null || true)"
1458
+ [[ "$now" =~ ^[0-9]+$ ]] && [[ "$modified" =~ ^[0-9]+$ ]] || return 0
1459
+ [ "$now" -ge "$modified" ] || return 0
1460
+ printf '%s' "$((now - modified))"
1461
+ }
1462
+
1463
+ release_aiur_launch_lock() {
1464
+ local lock="${1:-}" owner_file owner
1465
+ [ -n "$lock" ] || return 0
1466
+ owner_file="$lock/owner"
1467
+ owner="$(sed -n '1p' "$owner_file" 2>/dev/null || true)"
1468
+ [ "$owner" = "$$" ] || return 0
1469
+ rm -f "$owner_file" 2>/dev/null || true
1470
+ rmdir "$lock" 2>/dev/null || true
1471
+ }
1472
+
1339
1473
  write_aiur_instance_record() {
1340
- local session="$1" socket="$2" record_dir record tmp root
1474
+ local session="$1" socket="$2" write_mode="${3:-replace}" record_dir record tmp root
1341
1475
  record_dir="$(aiur_instances_dir)"
1342
1476
  record="$(aiur_instance_record_path)"
1343
1477
  root="$(canonical_workspace_root "${AIUR_PROJECT_ROOT:-}")"
@@ -1354,7 +1488,14 @@ write_aiur_instance_record() {
1354
1488
  printf 'AIUR_RECORD_WRITTEN_AT=%q\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || true)"
1355
1489
  } >"$tmp" || { rm -f "$tmp" 2>/dev/null || true; return 0; }
1356
1490
  chmod 0600 "$tmp" 2>/dev/null || true
1357
- mv "$tmp" "$record" 2>/dev/null || rm -f "$tmp" 2>/dev/null || true
1491
+ if [ "$write_mode" = "if-absent" ]; then
1492
+ # Same-directory hard-link creation is atomic: a concurrent live launcher
1493
+ # can win without an attaching shell overwriting its workspace handoff.
1494
+ ln "$tmp" "$record" 2>/dev/null || true
1495
+ rm -f "$tmp" 2>/dev/null || true
1496
+ else
1497
+ mv "$tmp" "$record" 2>/dev/null || rm -f "$tmp" 2>/dev/null || true
1498
+ fi
1358
1499
  }
1359
1500
 
1360
1501
  # Marker `status` reads to tell "daemon crashed, agents may be orphaned" apart
@@ -1469,6 +1610,16 @@ record_beam_crash() {
1469
1610
 
1470
1611
  canonical_workspace_root() {
1471
1612
  local root="$1"
1613
+ # The BEAM hands off Config.workspace_root() verbatim, which may be a `~/...`
1614
+ # path (e.g. this repo's `workspace.root: ~/code/aiur-workspaces`). `cd` does
1615
+ # not expand `~` inside a quoted variable, so without this a `~`-rooted cwd
1616
+ # sweep compared against the literal `~/...` string, matched no /proc cwd, and
1617
+ # silently reaped nothing — the exact backstop that must catch workspace-rooted
1618
+ # agents on stop. Expand a leading `~` before canonicalizing so the sweep and
1619
+ # the /proc cwd values (always absolute) agree.
1620
+ case "$root" in
1621
+ "~" | "~/"*) root="${HOME:-}${root#\~}" ;;
1622
+ esac
1472
1623
  if [ -d "$root" ]; then
1473
1624
  (cd "$root" 2>/dev/null && pwd -P) || printf '%s\n' "$root"
1474
1625
  else
@@ -1742,7 +1893,7 @@ wait_for_session_startup() {
1742
1893
  _session_socket="" _session_name="" _session_conf="" _session_tmpfile=""
1743
1894
  _session_capture="" _session_argv="" _session_release="" _session_tmux="" _session_node=""
1744
1895
  _session_pidfile="" _session_watchdog_pid="" _session_workspace_root_file=""
1745
- _session_alert_ledger_path_file="" _session_crash_dump_baseline_file=""
1896
+ _session_alert_ledger_path_file="" _session_crash_dump_baseline_file="" _session_launch_lock=""
1746
1897
  _cleanup_ran=0
1747
1898
  session_cleanup() {
1748
1899
  [ "$_cleanup_ran" = 1 ] && return 0
@@ -1769,8 +1920,11 @@ session_cleanup() {
1769
1920
  reap_aiur_agents "$_session_socket" "$_session_pidfile"
1770
1921
 
1771
1922
  # Reap only this instance's BEAM. Multiple keyed instances can share the same
1772
- # release dir, so release-path pgrep would terminate sibling workflows.
1773
- [ -n "$_session_node" ] && kill_beams_matching "-name ${_session_node}"
1923
+ # release dir, so release-path pgrep would terminate sibling workflows. Give
1924
+ # the BEAM a generous TERM grace (the stop grace, not the 3s startup reclaim
1925
+ # default) so its own graceful shutdown — ProcessReaper reaping the agent tree
1926
+ # plus the BEAM-side workspace sweep — completes before any SIGKILL lands.
1927
+ [ -n "$_session_node" ] && kill_beams_matching "-name ${_session_node}" 300
1774
1928
 
1775
1929
  # Final cwd-sweep backstop after the BEAM is gone: catches workspace-rooted
1776
1930
  # agents or test children that registered too late, reparented during the
@@ -1808,6 +1962,8 @@ session_cleanup() {
1808
1962
  "$_session_workspace_root_file" "$_session_alert_ledger_path_file" \
1809
1963
  "$_session_crash_dump_baseline_file" 2>/dev/null || true
1810
1964
  rm -f "$(aiur_instance_record_path)" 2>/dev/null || true
1965
+ release_aiur_launch_lock "$_session_launch_lock"
1966
+ _session_launch_lock=""
1811
1967
  return $code
1812
1968
  }
1813
1969
  install_foreground_traps() {
@@ -1852,6 +2008,21 @@ probe_node_liveness() {
1852
2008
  printf 'unknown'
1853
2009
  }
1854
2010
 
2011
+ confirm_stale_tmux_session() {
2012
+ local tmux_bin="$1" socket="$2" conf="$3" session="$4"
2013
+ local max_ticks="${AIUR_STALE_SESSION_GRACE_TICKS:-30}" tick
2014
+
2015
+ for ((tick = 0; tick < max_ticks; tick++)); do
2016
+ "$tmux_bin" -L "$socket" -f "$conf" has-session -t "$session" 2>/dev/null || return 0
2017
+ [ "$(probe_control_liveness)" = "up" ] && return 1
2018
+ [ "$(probe_node_liveness)" = "down" ] || return 1
2019
+ sleep 0.1
2020
+ done
2021
+
2022
+ "$tmux_bin" -L "$socket" -f "$conf" has-session -t "$session" 2>/dev/null || return 0
2023
+ [ "$(probe_control_liveness)" != "up" ] && [ "$(probe_node_liveness)" = "down" ]
2024
+ }
2025
+
1855
2026
  probe_named_node_liveness() {
1856
2027
  local node="$1" old_node="${RELEASE_NODE:-}" state
1857
2028
  RELEASE_NODE="$node"
@@ -2004,6 +2175,20 @@ kill_control_rpc_process() {
2004
2175
  for p in "${tree[@]}"; do kill -KILL "$p" 2>/dev/null || true; done
2005
2176
  }
2006
2177
 
2178
+ # Signalling the watchdog subshell alone leaves its `sleep` child running and
2179
+ # orphaned: bash forks the subshell, and the sleep is a separate process the
2180
+ # subshell's death does not reap. Kill the descendants too so a fast reply
2181
+ # leaves nothing behind that outlives the command.
2182
+ # Deepest-first: a bash subshell that is TERMed while waiting on a foreground
2183
+ # child defers the signal until that child exits, so signalling the wrapper
2184
+ # before the sleep it is waiting on can leave the sleep running.
2185
+ cancel_timeout_watchdog() {
2186
+ local pid="$1" p tree=() i
2187
+ [ -n "$pid" ] || return 0
2188
+ while IFS= read -r p; do tree+=("$p"); done < <(agent_pid_tree "$pid")
2189
+ for ((i = ${#tree[@]} - 1; i >= 0; i--)); do kill -TERM "${tree[i]}" 2>/dev/null || true; done
2190
+ }
2191
+
2007
2192
  run_release_rpc_with_timeout() {
2008
2193
  local expression="$1" timeout output_file timeout_file pid watchdog_pid status grouped=0
2009
2194
  timeout="$(control_rpc_timeout_seconds)"
@@ -2019,13 +2204,18 @@ run_release_rpc_with_timeout() {
2019
2204
  fi
2020
2205
  pid=$!
2021
2206
 
2207
+ # The watchdog must not hold the caller's stdout/stderr. A caller that captures
2208
+ # our output (command substitution, a pipe, a language runtime's subprocess
2209
+ # capture) reads until EOF, and EOF arrives only when every process holding the
2210
+ # write end exits - including a watchdog that is merely sleeping. Closing those
2211
+ # descriptors here is what keeps the timeout a ceiling instead of a floor.
2022
2212
  (
2023
2213
  sleep "$timeout"
2024
2214
  if kill -0 "$pid" 2>/dev/null; then
2025
2215
  : >"$timeout_file"
2026
2216
  kill_control_rpc_process "$pid" "$grouped"
2027
2217
  fi
2028
- ) &
2218
+ ) >/dev/null 2>&1 &
2029
2219
  watchdog_pid=$!
2030
2220
 
2031
2221
  if wait "$pid"; then
@@ -2039,7 +2229,7 @@ run_release_rpc_with_timeout() {
2039
2229
  # strand descendants that ignored TERM after the root process exits.
2040
2230
  :
2041
2231
  else
2042
- kill "$watchdog_pid" 2>/dev/null || true
2232
+ cancel_timeout_watchdog "$watchdog_pid"
2043
2233
  fi
2044
2234
  wait "$watchdog_pid" 2>/dev/null || true
2045
2235
 
@@ -3196,8 +3386,13 @@ cmd_stop() {
3196
3386
  fi
3197
3387
 
3198
3388
  # Reap any BEAM holding our node name regardless of which release dir launched
3199
- # it. Do not sweep by release dir: sibling instances share dev releases.
3200
- kill_beams_matching "-name ${AIUR_RELEASE_NODE}"
3389
+ # it. Do not sweep by release dir: sibling instances share dev releases. Use
3390
+ # the generous stop TERM grace: the BEAM, once TERM'd, runs its own graceful
3391
+ # shutdown (ProcessReaper reaping the agent tree, opencode session deletes,
3392
+ # then the BEAM-side workspace sweep) and the short 3s startup default would
3393
+ # SIGKILL it mid-cleanup, orphaning exactly the agents this stop must reap.
3394
+ # The loop exits the moment the BEAM is gone, so a fast stop costs nothing.
3395
+ kill_beams_matching "-name ${AIUR_RELEASE_NODE}" 300
3201
3396
 
3202
3397
  # Final cwd-sweep backstop after the BEAM is dead. The launcher's instance
3203
3398
  # record points to the BEAM-written root handoff, so degraded stop never has
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aiur-cli",
3
- "version": "0.0.5-nightly.df55dc3",
3
+ "version": "0.0.5",
4
4
  "description": "AI Unit Runtime for Executors.",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://github.com/aiur-team/aiur",
@@ -35,8 +35,8 @@
35
35
  ],
36
36
  "opencodeVersion": "1.17.10",
37
37
  "optionalDependencies": {
38
- "aiur-cli-linux-x64": "0.0.5-nightly.df55dc3",
39
- "aiur-cli-linux-arm64": "0.0.5-nightly.df55dc3",
40
- "aiur-cli-darwin-arm64": "0.0.5-nightly.df55dc3"
38
+ "aiur-cli-linux-x64": "0.0.5",
39
+ "aiur-cli-linux-arm64": "0.0.5",
40
+ "aiur-cli-darwin-arm64": "0.0.5"
41
41
  }
42
42
  }