aiur-cli 0.0.5-nightly.e30c636 → 0.0.6

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).
@@ -456,7 +456,7 @@ Usage: aiur [--interactive] [--no-dashboard] [--executor] [--pause] [--max-agent
456
456
  aiur status show agent status
457
457
  aiur agents show each agent's state + current activity
458
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>]
459
+ aiur executor-answer <decision-id> --expected-version <n> (--option <id>|--custom-response <text>) --rationale <text> --idempotency-key <key> [--supersede] [--executor-id <id>]
460
460
  aiur executor-escalate <decision-id> --expected-version <n> --reason <text> [--executor-id <id>]
461
461
  aiur executor-moot <decision-id> --expected-version <n> --reason-class <class> [--reason <text>] [--executor-id <id>]
462
462
  aiur units [--scope live|unfinished|all|none] [--condition active|alert|paused|queued|finished]... [--format auto|table|records] [--json]
@@ -468,6 +468,7 @@ Usage: aiur [--interactive] [--no-dashboard] [--executor] [--pause] [--max-agent
468
468
  aiur watch [--full|--changes] [--interval <secs>] server-side status board
469
469
  aiur executor-listen [--topic <pattern>] stream Executor events as JSON lines
470
470
  aiur executor-wait [--timeout <seconds>] [--json] block until Executor work arrives
471
+ aiur executor-fast-forward <wake-id> [--as <id>] acknowledge an externally covered wake prefix
471
472
  aiur executor-emit <topic> --payload <json> publish an Executor event
472
473
  aiur executor-subscribe|executor-unsubscribe <pattern>
473
474
  aiur executor-subscriptions list persistent Executor bindings
@@ -479,17 +480,18 @@ Usage: aiur [--interactive] [--no-dashboard] [--executor] [--pause] [--max-agent
479
480
  aiur upgrade [--force] install the newer aiur-cli on your channel
480
481
  aiur pause | resume flip the global pause switch (whole daemon)
481
482
  aiur pause <ids|--all> | resume <ids|--all> per-agent pause/resume
482
- aiur message <id> <text> send Executor text to a running agent
483
+ aiur message <id> [--message-id ID] <text> send Executor text to a running agent
483
484
  aiur --todo <ids...> [--only] queue tickets; optionally dequeue all other pending tickets
484
485
  aiur findings [--unfiled] [--slugs] [--scope aiur|repo] inspect host-local findings
485
486
  aiur findings --record <json> --repo <owner/repo> append one validated finding
486
487
  aiur findings --digest [--scope aiur|repo] generate the promoted Markdown digest
487
- aiur guard-pr-deletions [base-branch] refuse PRs with excessive untouched deletions
488
488
  aiur ask <title> [--body <text>|--body-file <path>] [--urgency low|normal|high] [--blocking]
489
489
  aiur ask --done <id> [--note <text>] create or resolve an operator request
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
 
@@ -586,31 +588,84 @@ run_asks() {
586
588
  # mode=foreground attaches the UI and tears down on exit; mode=background leaves
587
589
  # the detached tmux session running and returns.
588
590
 
589
- # Load operator/machine credentials before repo-local settings. Since each file
590
- # only fills unset names, shell exports win first, then ~/.aiur/.env, then ./.env.
591
+ # Dotenv precedence: shell exports win, then ./.env, then ~/.aiur/.env. Each
592
+ # file only fills names that are still unset, so the repo-local file (the more
593
+ # specific scope) is read first and the machine-wide file only fills its gaps.
594
+ # A blank value is a placeholder, not a setting: `aiur init` scaffolds
595
+ # `GITHUB_TOKEN=` and `.env.example` renders every name blank, so a blank line
596
+ # neither exports nor shadows anything.
597
+ #
598
+ # GitHub credentials are one group. A repo `.env` that sets any member has
599
+ # chosen that repository's auth mode, so none of the global file's members may
600
+ # leak in beside it: a global GITHUB_APP_* triple filling the gaps around a
601
+ # repo-local GITHUB_TOKEN would outrank that token and force the daemon onto an
602
+ # App that is not installed on the repo (#2638).
603
+ GITHUB_CREDENTIAL_ENV_NAMES="GITHUB_TOKEN GITHUB_APP_ID GITHUB_APP_INSTALLATION_ID GITHUB_APP_PRIVATE_KEY_PATH GITHUB_APP_PRIVATE_KEY"
604
+
591
605
  load_dotenv() {
592
- load_dotenv_file "$HOME/.aiur/.env"
606
+ local global_skip=""
607
+ if dotenv_file_sets_github_credential ".env"; then
608
+ global_skip="github_credentials"
609
+ fi
593
610
  load_dotenv_file ".env"
611
+ load_dotenv_file "$HOME/.aiur/.env" "$global_skip"
612
+ }
613
+
614
+ github_credential_env_name() {
615
+ case " $GITHUB_CREDENTIAL_ENV_NAMES " in
616
+ *" $1 "*) return 0 ;;
617
+ esac
618
+ return 1
619
+ }
620
+
621
+ # Parses one dotenv line into DOTENV_KEY / DOTENV_VAL. Returns 1 for blank,
622
+ # comment, malformed, and blank-valued lines so callers skip them. Shared by
623
+ # the loader and the credential-group probe so both agree on what counts.
624
+ parse_dotenv_line() {
625
+ local line="$1" key val
626
+ DOTENV_KEY="" DOTENV_VAL=""
627
+ line="${line#"${line%%[![:space:]]*}"}"
628
+ case "$line" in '' | '#'*) return 1 ;; esac
629
+ [ "${line#*=}" = "$line" ] && return 1
630
+ key="${line%%=*}"
631
+ key="${key%"${key##*[![:space:]]}"}"
632
+ case "$key" in '' | *[!A-Za-z0-9_]*) return 1 ;; esac
633
+ val="${line#*=}"
634
+ val="${val#"${val%%[![:space:]]*}"}"
635
+ case "$val" in
636
+ \"*\") val="${val#\"}" && val="${val%\"}" ;;
637
+ \'*\') val="${val#\'}" && val="${val%\'}" ;;
638
+ esac
639
+ [ -n "$val" ] || return 1
640
+ DOTENV_KEY="$key"
641
+ DOTENV_VAL="$val"
642
+ }
643
+
644
+ # True when the file sets any GitHub credential name to a non-blank value.
645
+ dotenv_file_sets_github_credential() {
646
+ local file="$1" line
647
+ [ -f "$file" ] || return 1
648
+ while IFS= read -r line || [ -n "$line" ]; do
649
+ parse_dotenv_line "$line" || continue
650
+ if github_credential_env_name "$DOTENV_KEY"; then
651
+ return 0
652
+ fi
653
+ done <"$file"
654
+ return 1
594
655
  }
595
656
 
657
+ # Usage: load_dotenv_file <file> [github_credentials]
658
+ # The optional second argument skips the GitHub credential group.
596
659
  load_dotenv_file() {
597
- local file="$1" line key val
660
+ local file="$1" skip="${2:-}" line
598
661
  [ -f "$file" ] || return 0
599
662
  while IFS= read -r line || [ -n "$line" ]; do
600
- line="${line#"${line%%[![:space:]]*}"}"
601
- case "$line" in '' | '#'*) continue ;; esac
602
- [ "${line#*=}" = "$line" ] && continue
603
- key="${line%%=*}"
604
- key="${key%"${key##*[![:space:]]}"}"
605
- case "$key" in '' | *[!A-Za-z0-9_]*) continue ;; esac
606
- val="${line#*=}"
607
- val="${val#"${val%%[![:space:]]*}"}"
608
- case "$val" in
609
- \"*\") val="${val#\"}" && val="${val%\"}" ;;
610
- \'*\') val="${val#\'}" && val="${val%\'}" ;;
611
- esac
612
- [ -n "${!key+x}" ] && continue
613
- export "$key=$val"
663
+ parse_dotenv_line "$line" || continue
664
+ if [ "$skip" = "github_credentials" ] && github_credential_env_name "$DOTENV_KEY"; then
665
+ continue
666
+ fi
667
+ [ -n "${!DOTENV_KEY+x}" ] && continue
668
+ export "$DOTENV_KEY=$DOTENV_VAL"
614
669
  done <"$file"
615
670
  }
616
671
 
@@ -686,6 +741,65 @@ run_session() {
686
741
  [ -n "$tmux_bin" ] || die "tmux is required to run aiur; install tmux and retry"
687
742
 
688
743
  aiur_resolve_identity
744
+ prepare_distribution
745
+
746
+ local session="${AIUR_SESSION_PREFIX}-${USER:-user}${AIUR_INSTANCE_KEY:+-$AIUR_INSTANCE_KEY}-default"
747
+ local socket="${AIUR_SESSION_PREFIX}-${USER:-user}${AIUR_INSTANCE_KEY:+-$AIUR_INSTANCE_KEY}"
748
+ local conf launch_lock
749
+ conf="$(resolve_tmux_conf)"
750
+ [ -f "$conf" ] || die "tmux conf not found at $conf"
751
+ launch_lock="$(aiur_launch_lock_path)"
752
+ acquire_aiur_launch_lock "$launch_lock" || return 1
753
+
754
+ # An invocation targeting an existing directory session resolves here before
755
+ # fresh-start credentials, argv files, run logs, watchdogs, or teardown traps.
756
+ # Foreground attaches; background exits with explicit attach guidance.
757
+ if "$tmux_bin" -L "$socket" -f "$conf" has-session -t "$session" 2>/dev/null; then
758
+ if [ "$(probe_control_liveness)" = "up" ]; then
759
+ # Preserve the live launch's record and workspace-root handoff. Only
760
+ # backfill old/missing records, without pointing them at this attach
761
+ # invocation's transient state.
762
+ AIUR_WORKSPACE_ROOT_FILE="" write_aiur_instance_record "$session" "$socket" if-absent
763
+
764
+ if [ "$mode" = "background" ]; then
765
+ echo "aiur is already running in the background (tmux session ${session})." >&2
766
+ echo "Attach with: aiur" >&2
767
+ echo "Use: aiur status # inspect agents" >&2
768
+ echo "Use: aiur stop # stop it before starting a fresh session" >&2
769
+ release_aiur_launch_lock "$launch_lock"
770
+ return 0
771
+ fi
772
+
773
+ echo "aiur is already running; attaching to the running session ${session}." >&2
774
+ release_aiur_launch_lock "$launch_lock"
775
+ attach_tmux_session "$tmux_bin" "$socket" "$conf" "$session"
776
+ return $?
777
+ fi
778
+
779
+ # A tmux holder can exist while a healthy BEAM is still booting or while
780
+ # epmd itself is temporarily unreachable. Never turn one failed control
781
+ # probe into destructive cleanup unless distribution confirms the keyed
782
+ # node is absent.
783
+ case "$(probe_node_liveness)" in
784
+ down)
785
+ if ! confirm_stale_tmux_session "$tmux_bin" "$socket" "$conf" "$session"; then
786
+ echo "aiur found session ${session}, but it became live while checking stale state; leaving it intact." >&2
787
+ echo "Retry: aiur" >&2
788
+ release_aiur_launch_lock "$launch_lock"
789
+ return 1
790
+ fi
791
+ echo "aiur found stale tmux session ${session}; cleaning it up before restart" >&2
792
+ reap_aiur_agents "$socket" ""
793
+ kill_beams_matching "-name ${AIUR_RELEASE_NODE}"
794
+ ;;
795
+ up | unknown)
796
+ echo "aiur found session ${session}, but its control plane is not ready; leaving the running session intact." >&2
797
+ echo "Retry: aiur" >&2
798
+ release_aiur_launch_lock "$launch_lock"
799
+ return 1
800
+ ;;
801
+ esac
802
+ fi
689
803
 
690
804
  # Pick up GITHUB_TOKEN / dashboard creds the wizard wrote to ./.env so the
691
805
  # running tracker can authenticate. Shell exports still take precedence.
@@ -693,6 +807,16 @@ run_session() {
693
807
  scrub_run_only_env
694
808
  export AIUR_DEFAULT_DASHBOARD_HOST="$(default_dashboard_host)"
695
809
 
810
+ # The BEAM lives in tmux, but a fresh foreground run belongs to this shell:
811
+ # it waits on the UI attach and owns the teardown trap. Hand its pid to the
812
+ # pane watchdog so a hard-killed launcher cannot leave agents running.
813
+ # A detached run has no such owner; discard any inherited stale value.
814
+ if [ "$mode" = "foreground" ]; then
815
+ export AIUR_LAUNCHER_PID="$$"
816
+ else
817
+ unset AIUR_LAUNCHER_PID
818
+ fi
819
+
696
820
  # The daemon's `Aiur.Upgrade` check uses the CLI package version (not the mix
697
821
  # version) as the "installed" version, so an npm install's notice names what
698
822
  # the user actually has. `run_version` sets the same var for `--version`.
@@ -717,7 +841,6 @@ run_session() {
717
841
  write_argv "${run_argv[@]}"
718
842
  export AIUR_ARGV_FILE="$argv_file"
719
843
 
720
- prepare_distribution
721
844
  build_release_cmd
722
845
 
723
846
  # Force +fnu when no locale is set so the BEAM does not mangle non-ASCII paths.
@@ -725,12 +848,6 @@ run_session() {
725
848
  export ELIXIR_ERL_OPTIONS="${ELIXIR_ERL_OPTIONS:-} +fnu"
726
849
  fi
727
850
 
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
851
  preflight_stale_manual_smoke
735
852
 
736
853
  mkdir -p "$AIUR_BG_STATE_DIR"
@@ -816,7 +933,7 @@ run_session() {
816
933
  AIUR_TMUX_SESSION AIUR_TMUX_SOCKET AIUR_TMUX_CONF AIUR_BIN \
817
934
  AIUR_SESSION_TMPFILE AIUR_AGENT_TMPFILE AIUR_WORKSPACE_ROOT_FILE AIUR_ALERT_LEDGER_PATH_FILE \
818
935
  ELIXIR_ERL_OPTIONS AIUR_LOGS_ROOT AIUR_OPENCODE_BRIDGE_PORT AIUR_DEFAULT_DASHBOARD_HOST AIUR_DEBUG \
819
- AIUR_OPERATOR_PID AIUR_NOFILE_SOFT_LIMIT ERL_CRASH_DUMP ERL_CRASH_DUMP_SECONDS \
936
+ AIUR_OPERATOR_PID AIUR_LAUNCHER_PID AIUR_NOFILE_SOFT_LIMIT ERL_CRASH_DUMP ERL_CRASH_DUMP_SECONDS \
820
937
  AIUR_BG_STATE_DIR AIUR_CLI_VERSION; do
821
938
  if [ -n "${!v:-}" ]; then printf 'export %s=%q\n' "$v" "${!v}"; fi
822
939
  done
@@ -844,31 +961,11 @@ run_session() {
844
961
  _session_node="$AIUR_RELEASE_NODE" _session_pidfile="$AIUR_AGENT_TMPFILE" \
845
962
  _session_workspace_root_file="$AIUR_WORKSPACE_ROOT_FILE" \
846
963
  _session_alert_ledger_path_file="$AIUR_ALERT_LEDGER_PATH_FILE" \
847
- _session_crash_dump_baseline_file="$crash_dump_baseline_file"
964
+ _session_crash_dump_baseline_file="$crash_dump_baseline_file" \
965
+ _session_launch_lock="$launch_lock"
848
966
  install_foreground_traps
849
967
  fi
850
968
 
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
969
  # An orphaned BEAM (its tmux session gone) can still hold THIS instance's node
873
970
  # name. With no live session on our (instance-keyed) socket, reap the name-holder
874
971
  # so the launch isn't blocked by "name seems to be in use". The keyed name means
@@ -899,9 +996,24 @@ run_session() {
899
996
  echo "❌ aiur failed to start; captured output:" >&2
900
997
  tail -n 30 "$startup_capture" 2>/dev/null | sed 's/^/ /' >&2 || true
901
998
  rm -f "${launch_tempfiles[@]}" 2>/dev/null || true
999
+ release_aiur_launch_lock "$launch_lock"
902
1000
  exit 1
903
1001
  fi
904
1002
 
1003
+ # Each instance has its own tmux socket. Publish the executable shipped next
1004
+ # to this engine before the TUI opens a chat pane; tmux receives the path as
1005
+ # one argument, including when the npm install directory contains spaces.
1006
+ if [ "$mode" = "foreground" ]; then
1007
+ local ctrlc_helper="$engine_dir/aiur-pane-ctrlc"
1008
+ if [ ! -x "$ctrlc_helper" ] ||
1009
+ ! "$tmux_bin" -L "$socket" -f "$conf" set-option -g @aiur_ctrlc "$ctrlc_helper"; then
1010
+ "$tmux_bin" -L "$socket" -f "$conf" kill-session -t "$session" 2>/dev/null || true
1011
+ rm -f "${launch_tempfiles[@]}" 2>/dev/null || true
1012
+ release_aiur_launch_lock "$launch_lock"
1013
+ die "aiur pane control helper is unavailable at $ctrlc_helper"
1014
+ fi
1015
+ fi
1016
+
905
1017
  # Title the agent-list pane (the only pane in the fresh session). The conf's
906
1018
  # `pane-border-status`/`pane-border-format` render it; PaneManager titles the
907
1019
  # chat panes it opens. Best-effort — a missing title just shows the default.
@@ -924,12 +1036,16 @@ run_session() {
924
1036
  reap_aiur_agents "$socket" "$AIUR_AGENT_TMPFILE"
925
1037
  kill_beams_matching "-name ${AIUR_RELEASE_NODE}"
926
1038
  fi
1039
+ release_aiur_launch_lock "$launch_lock"
1040
+ _session_launch_lock=""
927
1041
  [ "$mode" = "foreground" ] && return 1
928
1042
  rm -f "${launch_tempfiles[@]}" 2>/dev/null || true
929
1043
  exit 1
930
1044
  fi
931
1045
 
932
1046
  write_aiur_instance_record "$session" "$socket"
1047
+ release_aiur_launch_lock "$launch_lock"
1048
+ _session_launch_lock=""
933
1049
  print_config_status "$startup_capture"
934
1050
  print_dashboard_status "$no_dashboard" "$startup_capture"
935
1051
  # The `aiur run` version notice: a cheap local state read (the daemon's
@@ -976,10 +1092,18 @@ run_session() {
976
1092
  # Foreground: attach the UI. Do not exec — that would drop the teardown trap.
977
1093
  # Avoid process substitution here: some sandboxed non-TTY launchers reject
978
1094
  # opening /dev/fd/* during the real manual-test wrapper path.
1095
+ attach_tmux_session "$tmux_bin" "$socket" "$conf" "$session"
1096
+ }
1097
+
1098
+ attach_tmux_session() {
1099
+ local tmux_bin="$1" socket="$2" conf="$3" session="$4"
979
1100
  local attach_stderr attach_code
980
1101
  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=$?
1102
+ if "$tmux_bin" -L "$socket" -f "$conf" attach -t "$session" 2>"$attach_stderr"; then
1103
+ attach_code=0
1104
+ else
1105
+ attach_code=$?
1106
+ fi
983
1107
  grep -v -F "[server exited]" "$attach_stderr" >&2 || true
984
1108
  rm -f "$attach_stderr" 2>/dev/null || true
985
1109
  return "$attach_code"
@@ -1002,12 +1126,31 @@ resolve_tmux_conf() {
1002
1126
  # name (covers orphans from any release dir): under the unified identity a dev
1003
1127
  # `_build` BEAM and an installed BEAM both claim the same node name, so a
1004
1128
  # release-path pgrep misses whichever one this run didn't launch.
1129
+ # TERM the node(s) matching $pattern, give them $grace_ticks (0.1s) to exit
1130
+ # gracefully, then SIGKILL whatever remains. The default grace is short (3s)
1131
+ # because startup reclaim wants to clear a stale BEAM quickly; the stop paths
1132
+ # pass a longer grace so the BEAM can finish its own agent reap + session
1133
+ # deletion instead of being SIGKILLed mid-cleanup (which is what orphaned agent
1134
+ # processes on `aiur stop`). Callers may override the grace internally; invalid
1135
+ # or excessive values fall back to the 3s startup-reclaim default instead of
1136
+ # skipping the wait and jumping straight to SIGKILL.
1005
1137
  kill_beams_matching() {
1006
- local pattern="$1" pids pid waited=0
1138
+ local pattern="$1" grace_ticks="${2:-30}" pids pid waited=0
1139
+ case "$grace_ticks" in
1140
+ "" | *[!0-9]*) grace_ticks=30 ;;
1141
+ *)
1142
+ # Reject overlong digit strings before numeric comparison so shell integer
1143
+ # overflow cannot turn an excessive grace into an immediate SIGKILL.
1144
+ if [ "${#grace_ticks}" -gt 5 ] ||
1145
+ { [ "${#grace_ticks}" -eq 5 ] && [ "$grace_ticks" -gt 36000 ]; }; then
1146
+ grace_ticks=30
1147
+ fi
1148
+ ;;
1149
+ esac
1007
1150
  pids="$(pgrep -f -- "$pattern" 2>/dev/null || true)"
1008
1151
  [ -n "$pids" ] || return 0
1009
1152
  for pid in $pids; do kill -TERM "$pid" 2>/dev/null || true; done
1010
- while [ "$waited" -lt 30 ]; do
1153
+ while [ "$waited" -lt "$grace_ticks" ]; do
1011
1154
  pids="$(pgrep -f -- "$pattern" 2>/dev/null || true)"
1012
1155
  [ -z "$pids" ] && break
1013
1156
  sleep 0.1
@@ -1284,7 +1427,8 @@ agent_pid_matches() {
1284
1427
  # never touching the Executor’s own default tmux. Headless agents (claude/codex
1285
1428
  # app-servers spawned via Port) are bare OS processes that reparent to init on a
1286
1429
  # BEAM crash; kill-server can't see them, so they're reaped from the pidfile by
1287
- # process tree, comm-guarded against pid reuse. Idempotent.
1430
+ # process tree, checking the recorded command. A recycled pid with a matching
1431
+ # command is still possible (#2844). Idempotent.
1288
1432
  reap_aiur_agents() {
1289
1433
  local socket="$1" pidfile="${2:-}"
1290
1434
  local tmux_bin
@@ -1336,8 +1480,76 @@ aiur_instance_record_path() {
1336
1480
  printf '%s/%s.instance' "$(aiur_instances_dir)" "$(aiur_state_slug)"
1337
1481
  }
1338
1482
 
1483
+ aiur_launch_lock_path() {
1484
+ printf '%s/locks/%s.launch' "${AIUR_BG_STATE_DIR:?}" "$(aiur_state_slug)"
1485
+ }
1486
+
1487
+ acquire_aiur_launch_lock() {
1488
+ local lock="$1" owner_file="$1/owner" recovery_lock="$1.recovery"
1489
+ local owner tick lock_age
1490
+ local max_ticks="${AIUR_LAUNCH_LOCK_TICKS:-${AIUR_NODE_GRACE_TICKS:-1200}}"
1491
+ local ownerless_stale_seconds="${AIUR_OWNERLESS_LOCK_STALE_SECONDS:-120}"
1492
+ mkdir -p "$(dirname "$lock")" 2>/dev/null || return 1
1493
+
1494
+ for ((tick = 0; tick < max_ticks; tick++)); do
1495
+ if mkdir "$lock" 2>/dev/null; then
1496
+ printf '%s\n' "$$" >"$owner_file" || { rmdir "$lock" 2>/dev/null || true; return 1; }
1497
+ return 0
1498
+ fi
1499
+
1500
+ owner="$(sed -n '1p' "$owner_file" 2>/dev/null || true)"
1501
+ if [[ "$owner" =~ ^[0-9]+$ ]] && kill -0 "$owner" 2>/dev/null; then
1502
+ sleep 0.1
1503
+ continue
1504
+ fi
1505
+
1506
+ lock_age="$(aiur_lock_age_seconds "$lock")"
1507
+ if [ -z "$owner" ] && { [ -z "$lock_age" ] || [ "$lock_age" -lt "$ownerless_stale_seconds" ]; }; then
1508
+ sleep 0.1
1509
+ continue
1510
+ fi
1511
+
1512
+ # Only one contender may reclaim a dead lock. Re-read ownership while the
1513
+ # recovery mutex is held so another contender cannot delete a newly claimed
1514
+ # launch directory after losing the original stale-lock race.
1515
+ if mkdir "$recovery_lock" 2>/dev/null; then
1516
+ owner="$(sed -n '1p' "$owner_file" 2>/dev/null || true)"
1517
+ lock_age="$(aiur_lock_age_seconds "$lock")"
1518
+ if { [ -z "$owner" ] && [ -n "$lock_age" ] && [ "$lock_age" -ge "$ownerless_stale_seconds" ]; } || \
1519
+ { [[ "$owner" =~ ^[0-9]+$ ]] && ! kill -0 "$owner" 2>/dev/null; }; then
1520
+ rm -f "$owner_file" 2>/dev/null || true
1521
+ rmdir "$lock" 2>/dev/null || true
1522
+ fi
1523
+ rmdir "$recovery_lock" 2>/dev/null || true
1524
+ fi
1525
+ sleep 0.1
1526
+ done
1527
+
1528
+ echo "aiur: another launch for this directory is still in progress; retry aiur" >&2
1529
+ return 1
1530
+ }
1531
+
1532
+ aiur_lock_age_seconds() {
1533
+ local path="$1" now modified
1534
+ now="$(date +%s 2>/dev/null || true)"
1535
+ modified="$(stat -c %Y "$path" 2>/dev/null || stat -f %m "$path" 2>/dev/null || true)"
1536
+ [[ "$now" =~ ^[0-9]+$ ]] && [[ "$modified" =~ ^[0-9]+$ ]] || return 0
1537
+ [ "$now" -ge "$modified" ] || return 0
1538
+ printf '%s' "$((now - modified))"
1539
+ }
1540
+
1541
+ release_aiur_launch_lock() {
1542
+ local lock="${1:-}" owner_file owner
1543
+ [ -n "$lock" ] || return 0
1544
+ owner_file="$lock/owner"
1545
+ owner="$(sed -n '1p' "$owner_file" 2>/dev/null || true)"
1546
+ [ "$owner" = "$$" ] || return 0
1547
+ rm -f "$owner_file" 2>/dev/null || true
1548
+ rmdir "$lock" 2>/dev/null || true
1549
+ }
1550
+
1339
1551
  write_aiur_instance_record() {
1340
- local session="$1" socket="$2" record_dir record tmp root
1552
+ local session="$1" socket="$2" write_mode="${3:-replace}" record_dir record tmp root
1341
1553
  record_dir="$(aiur_instances_dir)"
1342
1554
  record="$(aiur_instance_record_path)"
1343
1555
  root="$(canonical_workspace_root "${AIUR_PROJECT_ROOT:-}")"
@@ -1348,13 +1560,21 @@ write_aiur_instance_record() {
1348
1560
  printf 'AIUR_RECORD_INSTANCE_KEY=%q\n' "$AIUR_INSTANCE_KEY"
1349
1561
  printf 'AIUR_RECORD_SESSION=%q\n' "$session"
1350
1562
  printf 'AIUR_RECORD_SOCKET=%q\n' "$socket"
1563
+ printf 'AIUR_RECORD_AGENT_TMPFILE=%q\n' "${AIUR_AGENT_TMPFILE:-}"
1351
1564
  printf 'AIUR_RECORD_WORKSPACE_ROOT_FILE=%q\n' "${AIUR_WORKSPACE_ROOT_FILE:-}"
1352
1565
  printf 'AIUR_RECORD_PROJECT_ROOT=%q\n' "$root"
1353
1566
  printf 'AIUR_RECORD_PROJECT_ROOT_SOURCE=%q\n' "${AIUR_PROJECT_ROOT_SOURCE:-}"
1354
1567
  printf 'AIUR_RECORD_WRITTEN_AT=%q\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || true)"
1355
1568
  } >"$tmp" || { rm -f "$tmp" 2>/dev/null || true; return 0; }
1356
1569
  chmod 0600 "$tmp" 2>/dev/null || true
1357
- mv "$tmp" "$record" 2>/dev/null || rm -f "$tmp" 2>/dev/null || true
1570
+ if [ "$write_mode" = "if-absent" ]; then
1571
+ # Same-directory hard-link creation is atomic: a concurrent live launcher
1572
+ # can win without an attaching shell overwriting its workspace handoff.
1573
+ ln "$tmp" "$record" 2>/dev/null || true
1574
+ rm -f "$tmp" 2>/dev/null || true
1575
+ else
1576
+ mv "$tmp" "$record" 2>/dev/null || rm -f "$tmp" 2>/dev/null || true
1577
+ fi
1358
1578
  }
1359
1579
 
1360
1580
  # Marker `status` reads to tell "daemon crashed, agents may be orphaned" apart
@@ -1469,6 +1689,16 @@ record_beam_crash() {
1469
1689
 
1470
1690
  canonical_workspace_root() {
1471
1691
  local root="$1"
1692
+ # The BEAM hands off Config.workspace_root() verbatim, which may be a `~/...`
1693
+ # path (e.g. this repo's `workspace.root: ~/code/aiur-workspaces`). `cd` does
1694
+ # not expand `~` inside a quoted variable, so without this a `~`-rooted cwd
1695
+ # sweep compared against the literal `~/...` string, matched no /proc cwd, and
1696
+ # silently reaped nothing — the exact backstop that must catch workspace-rooted
1697
+ # agents on stop. Expand a leading `~` before canonicalizing so the sweep and
1698
+ # the /proc cwd values (always absolute) agree.
1699
+ case "$root" in
1700
+ "~" | "~/"*) root="${HOME:-}${root#\~}" ;;
1701
+ esac
1472
1702
  if [ -d "$root" ]; then
1473
1703
  (cd "$root" 2>/dev/null && pwd -P) || printf '%s\n' "$root"
1474
1704
  else
@@ -1570,6 +1800,31 @@ workspace_root_file_from_instance_record() {
1570
1800
  printf '%s\n' "$AIUR_RECORD_WORKSPACE_ROOT_FILE"
1571
1801
  }
1572
1802
 
1803
+ agent_pidfile_from_instance_record() {
1804
+ load_aiur_instance_record "$(aiur_instance_record_path)" || return 1
1805
+ if [ -n "${AIUR_RECORD_AGENT_TMPFILE:-}" ]; then
1806
+ printf '%s\n' "$AIUR_RECORD_AGENT_TMPFILE"
1807
+ return 0
1808
+ fi
1809
+
1810
+ # Records written before the pidfile field still carry the handoff created
1811
+ # by the same launcher: aiur-PID-workspace-root and aiur-PID-agents share a
1812
+ # runtime directory. Derive that one file only when the old path has the
1813
+ # exact launch shape under this runtime root; never scan other instances.
1814
+ local session_root="${XDG_RUNTIME_DIR:-${TMPDIR:-/tmp}}" handoff pid
1815
+ session_root="${session_root%/}"
1816
+ [ -n "$session_root" ] || session_root=/
1817
+ handoff="${AIUR_RECORD_WORKSPACE_ROOT_FILE:-}"
1818
+ case "$handoff" in
1819
+ "$session_root"/aiur-*-workspace-root) ;;
1820
+ *) return 1 ;;
1821
+ esac
1822
+ pid="${handoff#"$session_root"/aiur-}"
1823
+ pid="${pid%-workspace-root}"
1824
+ [[ "$pid" =~ ^[0-9]+$ ]] || return 1
1825
+ printf '%s/aiur-%s-agents\n' "$session_root" "$pid"
1826
+ }
1827
+
1573
1828
  # Background watchdog that survives the BEAM. Polls for the release BEAM by
1574
1829
  # command pattern and, once it has SEEN the BEAM and then the BEAM disappears
1575
1830
  # (orderly halt OR :emfile-style crash), reaps every agent. Polling the pattern
@@ -1742,7 +1997,7 @@ wait_for_session_startup() {
1742
1997
  _session_socket="" _session_name="" _session_conf="" _session_tmpfile=""
1743
1998
  _session_capture="" _session_argv="" _session_release="" _session_tmux="" _session_node=""
1744
1999
  _session_pidfile="" _session_watchdog_pid="" _session_workspace_root_file=""
1745
- _session_alert_ledger_path_file="" _session_crash_dump_baseline_file=""
2000
+ _session_alert_ledger_path_file="" _session_crash_dump_baseline_file="" _session_launch_lock=""
1746
2001
  _cleanup_ran=0
1747
2002
  session_cleanup() {
1748
2003
  [ "$_cleanup_ran" = 1 ] && return 0
@@ -1769,8 +2024,11 @@ session_cleanup() {
1769
2024
  reap_aiur_agents "$_session_socket" "$_session_pidfile"
1770
2025
 
1771
2026
  # 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}"
2027
+ # release dir, so release-path pgrep would terminate sibling workflows. Give
2028
+ # the BEAM a generous TERM grace (the stop grace, not the 3s startup reclaim
2029
+ # default) so its own graceful shutdown — ProcessReaper reaping the agent tree
2030
+ # plus the BEAM-side workspace sweep — completes before any SIGKILL lands.
2031
+ [ -n "$_session_node" ] && kill_beams_matching "-name ${_session_node}" 300
1774
2032
 
1775
2033
  # Final cwd-sweep backstop after the BEAM is gone: catches workspace-rooted
1776
2034
  # agents or test children that registered too late, reparented during the
@@ -1808,6 +2066,8 @@ session_cleanup() {
1808
2066
  "$_session_workspace_root_file" "$_session_alert_ledger_path_file" \
1809
2067
  "$_session_crash_dump_baseline_file" 2>/dev/null || true
1810
2068
  rm -f "$(aiur_instance_record_path)" 2>/dev/null || true
2069
+ release_aiur_launch_lock "$_session_launch_lock"
2070
+ _session_launch_lock=""
1811
2071
  return $code
1812
2072
  }
1813
2073
  install_foreground_traps() {
@@ -1852,6 +2112,21 @@ probe_node_liveness() {
1852
2112
  printf 'unknown'
1853
2113
  }
1854
2114
 
2115
+ confirm_stale_tmux_session() {
2116
+ local tmux_bin="$1" socket="$2" conf="$3" session="$4"
2117
+ local max_ticks="${AIUR_STALE_SESSION_GRACE_TICKS:-30}" tick
2118
+
2119
+ for ((tick = 0; tick < max_ticks; tick++)); do
2120
+ "$tmux_bin" -L "$socket" -f "$conf" has-session -t "$session" 2>/dev/null || return 0
2121
+ [ "$(probe_control_liveness)" = "up" ] && return 1
2122
+ [ "$(probe_node_liveness)" = "down" ] || return 1
2123
+ sleep 0.1
2124
+ done
2125
+
2126
+ "$tmux_bin" -L "$socket" -f "$conf" has-session -t "$session" 2>/dev/null || return 0
2127
+ [ "$(probe_control_liveness)" != "up" ] && [ "$(probe_node_liveness)" = "down" ]
2128
+ }
2129
+
1855
2130
  probe_named_node_liveness() {
1856
2131
  local node="$1" old_node="${RELEASE_NODE:-}" state
1857
2132
  RELEASE_NODE="$node"
@@ -1866,6 +2141,7 @@ load_aiur_instance_record() {
1866
2141
  AIUR_RECORD_INSTANCE_KEY=""
1867
2142
  AIUR_RECORD_SESSION=""
1868
2143
  AIUR_RECORD_SOCKET=""
2144
+ AIUR_RECORD_AGENT_TMPFILE=""
1869
2145
  AIUR_RECORD_WORKSPACE_ROOT_FILE=""
1870
2146
  AIUR_RECORD_PROJECT_ROOT=""
1871
2147
  AIUR_RECORD_PROJECT_ROOT_SOURCE=""
@@ -2004,6 +2280,20 @@ kill_control_rpc_process() {
2004
2280
  for p in "${tree[@]}"; do kill -KILL "$p" 2>/dev/null || true; done
2005
2281
  }
2006
2282
 
2283
+ # Signalling the watchdog subshell alone leaves its `sleep` child running and
2284
+ # orphaned: bash forks the subshell, and the sleep is a separate process the
2285
+ # subshell's death does not reap. Kill the descendants too so a fast reply
2286
+ # leaves nothing behind that outlives the command.
2287
+ # Deepest-first: a bash subshell that is TERMed while waiting on a foreground
2288
+ # child defers the signal until that child exits, so signalling the wrapper
2289
+ # before the sleep it is waiting on can leave the sleep running.
2290
+ cancel_timeout_watchdog() {
2291
+ local pid="$1" p tree=() i
2292
+ [ -n "$pid" ] || return 0
2293
+ while IFS= read -r p; do tree+=("$p"); done < <(agent_pid_tree "$pid")
2294
+ for ((i = ${#tree[@]} - 1; i >= 0; i--)); do kill -TERM "${tree[i]}" 2>/dev/null || true; done
2295
+ }
2296
+
2007
2297
  run_release_rpc_with_timeout() {
2008
2298
  local expression="$1" timeout output_file timeout_file pid watchdog_pid status grouped=0
2009
2299
  timeout="$(control_rpc_timeout_seconds)"
@@ -2019,13 +2309,18 @@ run_release_rpc_with_timeout() {
2019
2309
  fi
2020
2310
  pid=$!
2021
2311
 
2312
+ # The watchdog must not hold the caller's stdout/stderr. A caller that captures
2313
+ # our output (command substitution, a pipe, a language runtime's subprocess
2314
+ # capture) reads until EOF, and EOF arrives only when every process holding the
2315
+ # write end exits - including a watchdog that is merely sleeping. Closing those
2316
+ # descriptors here is what keeps the timeout a ceiling instead of a floor.
2022
2317
  (
2023
2318
  sleep "$timeout"
2024
2319
  if kill -0 "$pid" 2>/dev/null; then
2025
2320
  : >"$timeout_file"
2026
2321
  kill_control_rpc_process "$pid" "$grouped"
2027
2322
  fi
2028
- ) &
2323
+ ) >/dev/null 2>&1 &
2029
2324
  watchdog_pid=$!
2030
2325
 
2031
2326
  if wait "$pid"; then
@@ -2039,7 +2334,7 @@ run_release_rpc_with_timeout() {
2039
2334
  # strand descendants that ignored TERM after the root process exits.
2040
2335
  :
2041
2336
  else
2042
- kill "$watchdog_pid" 2>/dev/null || true
2337
+ cancel_timeout_watchdog "$watchdog_pid"
2043
2338
  fi
2044
2339
  wait "$watchdog_pid" 2>/dev/null || true
2045
2340
 
@@ -2341,7 +2636,8 @@ cmd_reset_budget() {
2341
2636
  # The text is base64-encoded for the RPC hop so arbitrary content (quotes,
2342
2637
  # backslashes, `#{}`, newlines) survives without Elixir-string escaping.
2343
2638
  cmd_message() {
2344
- local usage="aiur: message expects an issue ID and text (e.g. aiur message 44 \"ship it\")"
2639
+ local usage="aiur: message expects an issue ID and text (e.g. aiur message 44 \"ship it\" or aiur message 44 --message-id ID \"ship it\")"
2640
+ local message_id="" message_id_given=0
2345
2641
 
2346
2642
  local issue="${1:-}"
2347
2643
  if [ -z "$issue" ] || [[ ! "$issue" =~ ^[0-9]+$ ]]; then
@@ -2350,6 +2646,26 @@ cmd_message() {
2350
2646
  fi
2351
2647
  shift
2352
2648
 
2649
+ # `--message-id ID` names this send, so a retry after an unknown outcome
2650
+ # returns the first copy instead of queueing a second one (#2717).
2651
+ case "${1:-}" in
2652
+ --message-id)
2653
+ [ "$#" -gt 1 ] || { echo "aiur: message --message-id requires a value" >&2; exit 64; }
2654
+ message_id="$2"
2655
+ message_id_given=1
2656
+ shift 2
2657
+ ;;
2658
+ --message-id=*)
2659
+ message_id="${1#--message-id=}"
2660
+ message_id_given=1
2661
+ shift
2662
+ ;;
2663
+ esac
2664
+ if [ "$message_id_given" = 1 ] && [[ ! "$message_id" =~ ^[A-Za-z0-9._:-]{1,128}$ ]]; then
2665
+ echo "aiur: message --message-id must be 1-128 letters, digits, '.', '_', ':' or '-' (it cannot be empty)" >&2
2666
+ exit 64
2667
+ fi
2668
+
2353
2669
  local text="$*"
2354
2670
  if [ -z "$text" ]; then
2355
2671
  echo "$usage" >&2
@@ -2358,7 +2674,11 @@ cmd_message() {
2358
2674
 
2359
2675
  local encoded
2360
2676
  encoded="$(printf '%s' "$text" | base64 | tr -d '\n')"
2361
- run_control_rpc "Aiur.AgentControlCLI.message(\"$issue\", Base.decode64!(\"$encoded\"))"
2677
+ if [ -n "$message_id" ]; then
2678
+ run_control_rpc "Aiur.AgentControlCLI.message(\"$issue\", Base.decode64!(\"$encoded\"), \"$message_id\")"
2679
+ else
2680
+ run_control_rpc "Aiur.AgentControlCLI.message(\"$issue\", Base.decode64!(\"$encoded\"))"
2681
+ fi
2362
2682
  }
2363
2683
 
2364
2684
  # `aiur agents` — concise one-line-per-agent state + current activity from a
@@ -2427,7 +2747,7 @@ encode_control_value() {
2427
2747
  # These are explicit trusted-Executor mutations, deliberately separate from
2428
2748
  # the read-only `commands` catalog. Revisions remain dashboard-owned.
2429
2749
  cmd_executor_answer() {
2430
- local decision_id="${1:-}" expected_version="" option_id="" custom_response="" rationale="" idempotency_key="" executor_id="aiur-cli" arg
2750
+ local decision_id="${1:-}" expected_version="" option_id="" custom_response="" rationale="" idempotency_key="" executor_id="aiur-cli" supersede=0 arg
2431
2751
  if [ -z "$decision_id" ] || [[ "$decision_id" = -* ]]; then
2432
2752
  echo "aiur: executor-answer expects exactly one decision ID" >&2
2433
2753
  exit 64
@@ -2449,6 +2769,7 @@ cmd_executor_answer() {
2449
2769
  --idempotency-key=*) idempotency_key="${arg#--idempotency-key=}" ;;
2450
2770
  --executor-id) [ "$#" -gt 1 ] || { echo "aiur: executor-answer --executor-id requires a value" >&2; exit 64; }; shift; executor_id="$1" ;;
2451
2771
  --executor-id=*) executor_id="${arg#--executor-id=}" ;;
2772
+ --supersede) supersede=1 ;;
2452
2773
  -*) echo "aiur: executor-answer received an unknown option: $arg" >&2; exit 64 ;;
2453
2774
  *) echo "aiur: executor-answer expects exactly one decision ID" >&2; exit 64 ;;
2454
2775
  esac
@@ -2473,6 +2794,9 @@ cmd_executor_answer() {
2473
2794
  opts="$opts, rationale: Base.decode64!(\"$(encode_control_value "$rationale")\")"
2474
2795
  opts="$opts, idempotency_key: Base.decode64!(\"$(encode_control_value "$idempotency_key")\")"
2475
2796
  opts="$opts, executor_id: Base.decode64!(\"$(encode_control_value "$executor_id")\")"
2797
+ if [ "$supersede" -eq 1 ]; then
2798
+ opts="$opts, supersede: true"
2799
+ fi
2476
2800
  local AIUR_CONTROL_ATTEMPT_CONTEXT="decision ID ${decision_id} with expected version ${expected_version}"
2477
2801
  run_control_rpc "Aiur.AgentControlCLI.executor_answer([$opts])"
2478
2802
  }
@@ -2860,6 +3184,33 @@ cmd_executor_roster() {
2860
3184
  run_control_rpc "Aiur.AgentControlCLI.executor_roster(json: $json_arg)"
2861
3185
  }
2862
3186
 
3187
+ cmd_executor_fast_forward() {
3188
+ local wake_id="${1:-}" as="" arg
3189
+ [ -n "$wake_id" ] || { echo "aiur: executor-fast-forward expects a positive wake id" >&2; exit 64; }
3190
+ shift
3191
+ [[ "$wake_id" =~ ^[1-9][0-9]*$ ]] || { echo "aiur: executor-fast-forward expects a positive wake id" >&2; exit 64; }
3192
+
3193
+ while [ "$#" -gt 0 ]; do
3194
+ arg="$1"
3195
+ case "$arg" in
3196
+ --as)
3197
+ shift
3198
+ [ "$#" -gt 0 ] && [ -n "${1:-}" ] || { echo "aiur: executor-fast-forward --as requires a consumer id" >&2; exit 64; }
3199
+ as="$1"
3200
+ ;;
3201
+ --as=*)
3202
+ as="${arg#--as=}"
3203
+ [ -n "$as" ] || { echo "aiur: executor-fast-forward --as requires a consumer id" >&2; exit 64; }
3204
+ ;;
3205
+ *) echo "aiur: executor-fast-forward accepts <wake-id> and --as <id>" >&2; exit 64 ;;
3206
+ esac
3207
+ shift
3208
+ done
3209
+
3210
+ executor_validate_consumer_id "$as" "executor-fast-forward"
3211
+ run_control_rpc "Aiur.AgentControlCLI.executor_fast_forward($wake_id, [$(executor_as_keyword "$as")])"
3212
+ }
3213
+
2863
3214
  cmd_executor_claim() {
2864
3215
  local as="" arg
2865
3216
  while [ "$#" -gt 0 ]; do
@@ -3157,8 +3508,9 @@ cmd_stop() {
3157
3508
  export RELEASE_NODE ERL_EPMD_ADDRESS
3158
3509
  resolve_control_identity_from_records
3159
3510
 
3160
- local workspace_root_file
3511
+ local workspace_root_file agent_pidfile
3161
3512
  workspace_root_file="$(workspace_root_file_from_instance_record 2>/dev/null || true)"
3513
+ agent_pidfile="$(agent_pidfile_from_instance_record 2>/dev/null || true)"
3162
3514
 
3163
3515
  local tmux_bin
3164
3516
  tmux_bin="$(command -v tmux || true)"
@@ -3196,8 +3548,13 @@ cmd_stop() {
3196
3548
  fi
3197
3549
 
3198
3550
  # 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}"
3551
+ # it. Do not sweep by release dir: sibling instances share dev releases. Use
3552
+ # the generous stop TERM grace: the BEAM, once TERM'd, runs its own graceful
3553
+ # shutdown (ProcessReaper reaping the agent tree, opencode session deletes,
3554
+ # then the BEAM-side workspace sweep) and the short 3s startup default would
3555
+ # SIGKILL it mid-cleanup, orphaning exactly the agents this stop must reap.
3556
+ # The loop exits the moment the BEAM is gone, so a fast stop costs nothing.
3557
+ kill_beams_matching "-name ${AIUR_RELEASE_NODE}" 300
3201
3558
 
3202
3559
  # Final cwd-sweep backstop after the BEAM is dead. The launcher's instance
3203
3560
  # record points to the BEAM-written root handoff, so degraded stop never has
@@ -3212,16 +3569,13 @@ cmd_stop() {
3212
3569
  "$tmux_bin" -L "$socket" kill-server 2>/dev/null || true
3213
3570
  fi
3214
3571
 
3215
- # Belt-and-suspenders for a mid-turn stop: sweep any headless agent (this run
3216
- # or a prior crashed one) still recorded in a pidfile. comm-guarded, so a
3217
- # recycled pid is spared. Empty socket: the kill-server above already ran.
3218
- local session_root agentfile
3219
- session_root="${XDG_RUNTIME_DIR:-${TMPDIR:-/tmp}}"
3220
- for agentfile in "$session_root"/aiur-*-agents; do
3221
- [ -e "$agentfile" ] || continue
3222
- reap_aiur_agents "" "$agentfile"
3223
- rm -f "$agentfile" 2>/dev/null || true
3224
- done
3572
+ # Belt-and-suspenders for this run's headless agents after the BEAM exits.
3573
+ # The instance record owns its pidfile; a global sweep can kill live agents
3574
+ # belonging to another instance that shares the runtime directory.
3575
+ if [ -n "$agent_pidfile" ] && [ -e "$agent_pidfile" ]; then
3576
+ reap_aiur_agents "" "$agent_pidfile"
3577
+ rm -f "$agent_pidfile" 2>/dev/null || true
3578
+ fi
3225
3579
 
3226
3580
  reap_stale_manual_smoke 0
3227
3581
  sweep_dead_tmux_sockets
@@ -3707,10 +4061,6 @@ aiur_engine_main() {
3707
4061
  findings)
3708
4062
  run_findings "$@"
3709
4063
  ;;
3710
- guard-pr-deletions)
3711
- shift
3712
- "$engine_dir/guard-pr-deletions.sh" "$@"
3713
- ;;
3714
4064
  ask | asks)
3715
4065
  run_asks "$@"
3716
4066
  ;;
@@ -3801,6 +4151,10 @@ aiur_engine_main() {
3801
4151
  shift
3802
4152
  cmd_executor_roster "$@"
3803
4153
  ;;
4154
+ executor-fast-forward)
4155
+ shift
4156
+ cmd_executor_fast_forward "$@"
4157
+ ;;
3804
4158
  executor-claim)
3805
4159
  shift
3806
4160
  cmd_executor_claim "$@"
@@ -0,0 +1,135 @@
1
+ #!/usr/bin/env sh
2
+ # Opencode pane Ctrl+C bridge. Bound to `C-c` for non-agent-list panes in
3
+ # aiur.tmux.conf. Asks Aiur what a Ctrl+C should do for the agent painted
4
+ # into this pane (drain a queued message, pause an idle agent, or close
5
+ # the pane when already paused) and acts on the answer.
6
+ #
7
+ # The whole point is safe degradation: if Aiur is unreachable, the URL is
8
+ # unset, or anything else goes wrong, we fall back to plain `kill-pane`,
9
+ # which is exactly the pre-bridge behaviour. The 3-state UX is a pure
10
+ # enhancement layered on top — a failure here never strands the Executor
11
+ # with a pane they cannot close.
12
+ #
13
+ # Args:
14
+ # $1 tmux pane id (e.g. "%9")
15
+ # $2 control base URL (e.g. "http://127.0.0.1:4000"), may be empty
16
+ #
17
+ # Runs inside tmux `run-shell`, so `$TMUX` is set and a bare `tmux`
18
+ # command targets the same server/socket.
19
+ set -u
20
+
21
+ pane_id="${1:-}"
22
+ control_url="${2:-}"
23
+ # Optional action: "hide" skips the 3-state interrupt query and goes
24
+ # straight to hide-with-kill-fallback (the Ctrl+Q binding's path).
25
+ mode="${3:-interrupt}"
26
+
27
+ # Always-on breadcrumb so a Ctrl+C that closes a pane leaves evidence of which
28
+ # branch ran — the tmux binding and this helper are otherwise invisible to
29
+ # aiur.log. One line per press; pane id and outcome only, never the URL.
30
+ log_ctrlc() {
31
+ printf '%s ctrlc pane_id=%s %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$pane_id" "$1" \
32
+ >> "${TMPDIR:-/tmp}/aiur-ctrlc.log" 2>/dev/null || true
33
+ }
34
+
35
+ close_pane() {
36
+ log_ctrlc "action=close_pane reason=${1:-degraded}"
37
+ [ -n "$pane_id" ] && tmux kill-pane -t "$pane_id" 2>/dev/null
38
+ exit 0
39
+ }
40
+
41
+ # Closing a chat pane must never kill the opencode-attach process — ask
42
+ # Aiur to HIDE the pane (move it to the warm window, slot binding kept)
43
+ # so reopening swaps the same pane back instantly. Only when the hide
44
+ # call fails (Aiur down, unknown pane) does this degrade to kill-pane.
45
+ hide_pane() {
46
+ if [ -n "$control_url" ]; then
47
+ # shellcheck disable=SC2086
48
+ if curl -fsS -m 2 \
49
+ -H 'X-Aiur-Request: 1' \
50
+ -H 'Origin: http://127.0.0.1' \
51
+ --data-urlencode "pane_id=${pane_id}" \
52
+ $auth_args \
53
+ "${control_url}/api/v1/pane/hide" >/dev/null 2>&1; then
54
+ log_ctrlc "action=hide_pane reason=${1:-requested}"
55
+ exit 0
56
+ fi
57
+ fi
58
+
59
+ close_pane "hide_failed_${1:-requested}"
60
+ }
61
+
62
+ # No pane to act on, or no idea where Aiur is listening: degrade to close.
63
+ [ -z "$pane_id" ] && exit 0
64
+
65
+ # Loopback dashboards bind without auth; only attach credentials when the
66
+ # Executor has explicitly configured them for a non-loopback bind.
67
+ auth_args=""
68
+ if [ -n "${AIUR_DASHBOARD_USERNAME:-}" ] && [ -n "${AIUR_DASHBOARD_PASSWORD:-}" ]; then
69
+ auth_args="--user ${AIUR_DASHBOARD_USERNAME}:${AIUR_DASHBOARD_PASSWORD}"
70
+ fi
71
+
72
+ # Ctrl+Q path: unconditional hide (kill only as fallback).
73
+ [ "$mode" = "hide" ] && hide_pane "ctrl_q"
74
+
75
+ [ -z "$control_url" ] && close_pane "no_control_url"
76
+
77
+ # The interrupt timeout MUST exceed the orchestrator's own 5s
78
+ # GenServer.call timeout: that call serializes behind the single
79
+ # Orchestrator mailbox, so under load the reply can take seconds. A
80
+ # shorter curl timeout abandons a call that would have succeeded and then
81
+ # (below) degraded to a destructive kill — exactly the race that reset a
82
+ # live conversation when Ctrl+C was pressed on a busy agent.
83
+ # shellcheck disable=SC2086
84
+ response="$(
85
+ curl -fsS -m 8 \
86
+ -H 'X-Aiur-Request: 1' \
87
+ -H 'Origin: http://127.0.0.1' \
88
+ --data-urlencode "pane_id=${pane_id}" \
89
+ $auth_args \
90
+ "${control_url}/api/v1/pane/interrupt" 2>/dev/null
91
+ )"
92
+ curl_status=$?
93
+
94
+ # Curl failed: distinguish "aiur busy" from "aiur unreachable" so a slow
95
+ # call never costs the Executor their live pane. Exit 28 is a timeout —
96
+ # the dashboard is alive and the interrupt is almost certainly still
97
+ # being processed server-side, so KEEP THE PANE OPEN (killing it here is
98
+ # what broke the opencode pipe, tripped :repl_gone, and re-dispatched a
99
+ # fresh session). Any other non-zero (connection refused, DNS, HTTP
100
+ # error) means aiur is unreachable or erroring: degrade to a
101
+ # non-destructive hide (session + attach survive); hide_pane itself falls
102
+ # back to kill only if the hide call also fails.
103
+ if [ "$curl_status" -ne 0 ]; then
104
+ if [ "$curl_status" -eq 28 ]; then
105
+ log_ctrlc "action=keep_open reason=curl_timeout"
106
+ exit 0
107
+ fi
108
+ hide_pane "curl_failed_${curl_status}"
109
+ fi
110
+
111
+ # opencode owns the interrupt for codex/opencode agents: forward its native
112
+ # interrupt key (Esc) to the pane so it drains its queued Executor message and
113
+ # keeps working. Aiur mutates no state on this reply; the keystroke is the
114
+ # whole effect. Pane stays open.
115
+ case "$response" in
116
+ *send_interrupt*)
117
+ tmux send-keys -t "$pane_id" Escape 2>/dev/null
118
+ log_ctrlc "action=send_interrupt response=${response}"
119
+ exit 0
120
+ ;;
121
+ esac
122
+
123
+ # The agent handled the keypress (interrupted its REPL turn or paused). Leave
124
+ # the pane open so the Executor stays in the conversation.
125
+ case "$response" in
126
+ *interrupted* | *pause_requested* | *paused*)
127
+ log_ctrlc "action=keep_open response=${response}"
128
+ exit 0
129
+ ;;
130
+ esac
131
+
132
+ # close_pane, an error, or any unexpected body: hide the pane (the
133
+ # paused agent's session and attach process stay alive; reopen swaps the
134
+ # same pane back). Kill only when the hide call itself fails.
135
+ hide_pane "response=${response}"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aiur-cli",
3
- "version": "0.0.5-nightly.e30c636",
3
+ "version": "0.0.6",
4
4
  "description": "AI Unit Runtime for Executors.",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://github.com/aiur-team/aiur",
@@ -18,7 +18,7 @@
18
18
  "files": [
19
19
  "bin/aiur.js",
20
20
  "libexec/aiur-engine.sh",
21
- "libexec/guard-pr-deletions.sh",
21
+ "libexec/aiur-pane-ctrlc",
22
22
  "scripts/postinstall.mjs",
23
23
  "share/aiur.tmux.conf"
24
24
  ],
@@ -35,8 +35,8 @@
35
35
  ],
36
36
  "opencodeVersion": "1.17.10",
37
37
  "optionalDependencies": {
38
- "aiur-cli-linux-x64": "0.0.5-nightly.e30c636",
39
- "aiur-cli-linux-arm64": "0.0.5-nightly.e30c636",
40
- "aiur-cli-darwin-arm64": "0.0.5-nightly.e30c636"
38
+ "aiur-cli-linux-x64": "0.0.6",
39
+ "aiur-cli-linux-arm64": "0.0.6",
40
+ "aiur-cli-darwin-arm64": "0.0.6"
41
41
  }
42
42
  }
@@ -19,6 +19,15 @@ set -g mouse on
19
19
  # for the agent list pane's Shift+Enter "open in new pane" keybind.
20
20
  set -s extended-keys on
21
21
  set -as terminal-features "*:extkeys"
22
+ # Advertise synchronized-output (DEC private mode 2026) support to tmux so
23
+ # it presents each agent-list frame atomically to the attached client. The
24
+ # renderer wraps every frame in `\e[?2026h … \e[?2026l`; without this
25
+ # feature tmux applies the app's sync internally but still streams partial
26
+ # cell updates to the outer terminal, so a multi-byte glyph (emoji, box
27
+ # drawing, the 🔄 title / events `💬`) caught mid-repaint flashes as `?`
28
+ # replacement chars just above the "oldest" divider. Modern terminals
29
+ # (iTerm2, kitty, wezterm, Ghostty, recent xterm/VTE) all support 2026.
30
+ set -as terminal-features "*:sync"
22
31
  set -g status off
23
32
 
24
33
  # Show each pane's title in a one-line top border. PaneManager sets every
@@ -58,9 +67,24 @@ bind-key -n M-Tab select-pane -t :.0
58
67
  # tmux/tmux#882).
59
68
  set -gq @aiur_ctrlc ''
60
69
  set -gq @aiur_control_url ''
70
+ # NB: reference `#{q:@aiur_ctrlc}` inline rather than via a `h=...; $h` shell
71
+ # variable. tmux re-parses this double-quoted run-shell argument when if-shell
72
+ # dispatches it and expands `$h` itself (to empty), so a shell-variable form
73
+ # silently falls through to the `else` branch and raw-kills the pane. `q:`
74
+ # shell-escapes installed paths, including quotes and whitespace.
61
75
  bind-key -n C-c if-shell -F '#{==:#{pane_index},0}' \
62
76
  'kill-session' \
63
- 'run-shell "h=\"#{@aiur_ctrlc}\"; if [ -x \"$h\" ]; then \"$h\" \"#{pane_id}\" \"#{@aiur_control_url}\"; else tmux kill-pane -t \"#{pane_id}\"; fi"'
77
+ 'run-shell "if [ -x #{q:@aiur_ctrlc} ]; then #{q:@aiur_ctrlc} \"#{pane_id}\" \"#{@aiur_control_url}\"; else tmux kill-pane -t \"#{pane_id}\"; fi"'
78
+
79
+ # Ctrl+Q closes a chat pane WITHOUT touching agent state — no pause, and
80
+ # the opencode-attach process survives: the helper asks Aiur to HIDE the
81
+ # pane (move to the warm window), so reopening from the agent list swaps
82
+ # the same pane back instantly. Degrades to kill-pane when the helper or
83
+ # Aiur is unreachable. On the agent list itself (pane 0) it is a
84
+ # deliberate no-op; do NOT mirror the C-c binding's pane-0 kill-session.
85
+ bind-key -n C-q if-shell -F '#{==:#{pane_index},0}' \
86
+ '' \
87
+ 'run-shell "if [ -x #{q:@aiur_ctrlc} ]; then #{q:@aiur_ctrlc} \"#{pane_id}\" \"#{@aiur_control_url}\" hide; else tmux kill-pane -t \"#{pane_id}\"; fi"'
64
88
 
65
89
  # Pane layout is owned exclusively by `Aiur.PaneManager`, which applies an
66
90
  # explicit `select-layout <string>` after every slot change. No
@@ -1,70 +0,0 @@
1
- #!/usr/bin/env bash
2
- set -euo pipefail
3
-
4
- readonly deletion_threshold=50
5
- base_branch="${1:-${AIUR_BASE_BRANCH:-}}"
6
-
7
- if [[ -z "$base_branch" ]]; then
8
- echo "guard-pr-deletions: base branch is required (argument or AIUR_BASE_BRANCH)" >&2
9
- exit 2
10
- fi
11
-
12
- if ! git diff --quiet || ! git diff --cached --quiet; then
13
- echo "guard-pr-deletions: tracked changes must be committed before checking PR deletions" >&2
14
- exit 2
15
- fi
16
-
17
- if ! git fetch --quiet --no-tags origin "refs/heads/$base_branch"; then
18
- echo "guard-pr-deletions: could not fetch origin/$base_branch; refusing to use a stale base" >&2
19
- exit 2
20
- fi
21
-
22
- if ! base_sha="$(git rev-parse --verify 'FETCH_HEAD^{commit}')"; then
23
- echo "guard-pr-deletions: fetched base commit is unavailable" >&2
24
- exit 2
25
- fi
26
-
27
- scratch_dir="$(mktemp -d)"
28
- trap 'rm -rf "$scratch_dir"' EXIT
29
-
30
- git diff --diff-filter=D --name-only "$base_sha"..HEAD | LC_ALL=C sort -u >"$scratch_dir/deleted"
31
- deleted_count="$(wc -l <"$scratch_dir/deleted" | tr -d ' ')"
32
-
33
- if ((deleted_count <= deletion_threshold)); then
34
- echo "guard-pr-deletions: $deleted_count untouched file deletions (limit: $deletion_threshold)"
35
- exit 0
36
- fi
37
-
38
- branch_start="${AIUR_BRANCH_START_SHA:-}"
39
-
40
- if [[ -z "$branch_start" ]] && git rev-parse --verify --quiet 'refs/aiur/branch-start^{commit}' >/dev/null; then
41
- branch_start="$(git rev-parse --verify 'refs/aiur/branch-start^{commit}')"
42
- fi
43
-
44
- if [[ -z "$branch_start" ]]; then
45
- echo "guard-pr-deletions: refusing $deleted_count deletions because the workspace branch-start ref is unavailable" >&2
46
- exit 1
47
- fi
48
-
49
- if ! git merge-base --is-ancestor "$branch_start" HEAD; then
50
- echo "guard-pr-deletions: workspace branch-start $branch_start is not an ancestor of HEAD" >&2
51
- exit 2
52
- fi
53
-
54
- git log --format= --name-only "$branch_start"..HEAD | sed '/^$/d' | LC_ALL=C sort -u >"$scratch_dir/touched"
55
- # Both inputs are sorted with LC_ALL=C, so comm must compare with the same
56
- # collation. Under a UTF-8 locale comm reads C-sorted input as unsorted (any
57
- # mix of upper- and lower-case initial paths, e.g. README.md beside src/...)
58
- # and its output on unsorted input is undefined -- it can drop rows, which
59
- # would make this guard under-count untouched deletions and fail open.
60
- LC_ALL=C comm -23 "$scratch_dir/deleted" "$scratch_dir/touched" >"$scratch_dir/untouched-deletions"
61
-
62
- untouched_count="$(wc -l <"$scratch_dir/untouched-deletions" | tr -d ' ')"
63
-
64
- if ((untouched_count > deletion_threshold)); then
65
- echo "guard-pr-deletions: refusing PR with $untouched_count untouched file deletions (limit: $deletion_threshold)" >&2
66
- sed -n '1,20p' "$scratch_dir/untouched-deletions" >&2
67
- exit 1
68
- fi
69
-
70
- echo "guard-pr-deletions: $untouched_count untouched file deletions (limit: $deletion_threshold)"