aiur-cli 0.0.5 → 0.0.7

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.
@@ -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,12 +480,11 @@ 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
@@ -588,31 +588,84 @@ run_asks() {
588
588
  # mode=foreground attaches the UI and tears down on exit; mode=background leaves
589
589
  # the detached tmux session running and returns.
590
590
 
591
- # Load operator/machine credentials before repo-local settings. Since each file
592
- # 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
+
593
605
  load_dotenv() {
594
- 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
595
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
596
655
  }
597
656
 
657
+ # Usage: load_dotenv_file <file> [github_credentials]
658
+ # The optional second argument skips the GitHub credential group.
598
659
  load_dotenv_file() {
599
- local file="$1" line key val
660
+ local file="$1" skip="${2:-}" line
600
661
  [ -f "$file" ] || return 0
601
662
  while IFS= read -r line || [ -n "$line" ]; do
602
- line="${line#"${line%%[![:space:]]*}"}"
603
- case "$line" in '' | '#'*) continue ;; esac
604
- [ "${line#*=}" = "$line" ] && continue
605
- key="${line%%=*}"
606
- key="${key%"${key##*[![:space:]]}"}"
607
- case "$key" in '' | *[!A-Za-z0-9_]*) continue ;; esac
608
- val="${line#*=}"
609
- val="${val#"${val%%[![:space:]]*}"}"
610
- case "$val" in
611
- \"*\") val="${val#\"}" && val="${val%\"}" ;;
612
- \'*\') val="${val#\'}" && val="${val%\'}" ;;
613
- esac
614
- [ -n "${!key+x}" ] && continue
615
- 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"
616
669
  done <"$file"
617
670
  }
618
671
 
@@ -754,6 +807,16 @@ run_session() {
754
807
  scrub_run_only_env
755
808
  export AIUR_DEFAULT_DASHBOARD_HOST="$(default_dashboard_host)"
756
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
+
757
820
  # The daemon's `Aiur.Upgrade` check uses the CLI package version (not the mix
758
821
  # version) as the "installed" version, so an npm install's notice names what
759
822
  # the user actually has. `run_version` sets the same var for `--version`.
@@ -869,8 +932,8 @@ run_session() {
869
932
  RELEASE_COOKIE ERL_AFLAGS ERL_EPMD_ADDRESS AIUR_NODE AIUR_ERLANG_COOKIE \
870
933
  AIUR_TMUX_SESSION AIUR_TMUX_SOCKET AIUR_TMUX_CONF AIUR_BIN \
871
934
  AIUR_SESSION_TMPFILE AIUR_AGENT_TMPFILE AIUR_WORKSPACE_ROOT_FILE AIUR_ALERT_LEDGER_PATH_FILE \
872
- ELIXIR_ERL_OPTIONS AIUR_LOGS_ROOT AIUR_OPENCODE_BRIDGE_PORT AIUR_DEFAULT_DASHBOARD_HOST AIUR_DEBUG \
873
- AIUR_OPERATOR_PID AIUR_NOFILE_SOFT_LIMIT ERL_CRASH_DUMP ERL_CRASH_DUMP_SECONDS \
935
+ ELIXIR_ERL_OPTIONS AIUR_LOGS_ROOT AIUR_OPENCODE_BRIDGE_PORT AIUR_DEFAULT_DASHBOARD_HOST AIUR_DEBUG AIUR_DEV_TEST_TICKET_IDS \
936
+ AIUR_OPERATOR_PID AIUR_LAUNCHER_PID AIUR_NOFILE_SOFT_LIMIT ERL_CRASH_DUMP ERL_CRASH_DUMP_SECONDS \
874
937
  AIUR_BG_STATE_DIR AIUR_CLI_VERSION; do
875
938
  if [ -n "${!v:-}" ]; then printf 'export %s=%q\n' "$v" "${!v}"; fi
876
939
  done
@@ -937,6 +1000,20 @@ run_session() {
937
1000
  exit 1
938
1001
  fi
939
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
+
940
1017
  # Title the agent-list pane (the only pane in the fresh session). The conf's
941
1018
  # `pane-border-status`/`pane-border-format` render it; PaneManager titles the
942
1019
  # chat panes it opens. Best-effort — a missing title just shows the default.
@@ -1350,7 +1427,8 @@ agent_pid_matches() {
1350
1427
  # never touching the Executor’s own default tmux. Headless agents (claude/codex
1351
1428
  # app-servers spawned via Port) are bare OS processes that reparent to init on a
1352
1429
  # BEAM crash; kill-server can't see them, so they're reaped from the pidfile by
1353
- # 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.
1354
1432
  reap_aiur_agents() {
1355
1433
  local socket="$1" pidfile="${2:-}"
1356
1434
  local tmux_bin
@@ -1482,6 +1560,7 @@ write_aiur_instance_record() {
1482
1560
  printf 'AIUR_RECORD_INSTANCE_KEY=%q\n' "$AIUR_INSTANCE_KEY"
1483
1561
  printf 'AIUR_RECORD_SESSION=%q\n' "$session"
1484
1562
  printf 'AIUR_RECORD_SOCKET=%q\n' "$socket"
1563
+ printf 'AIUR_RECORD_AGENT_TMPFILE=%q\n' "${AIUR_AGENT_TMPFILE:-}"
1485
1564
  printf 'AIUR_RECORD_WORKSPACE_ROOT_FILE=%q\n' "${AIUR_WORKSPACE_ROOT_FILE:-}"
1486
1565
  printf 'AIUR_RECORD_PROJECT_ROOT=%q\n' "$root"
1487
1566
  printf 'AIUR_RECORD_PROJECT_ROOT_SOURCE=%q\n' "${AIUR_PROJECT_ROOT_SOURCE:-}"
@@ -1721,6 +1800,31 @@ workspace_root_file_from_instance_record() {
1721
1800
  printf '%s\n' "$AIUR_RECORD_WORKSPACE_ROOT_FILE"
1722
1801
  }
1723
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
+
1724
1828
  # Background watchdog that survives the BEAM. Polls for the release BEAM by
1725
1829
  # command pattern and, once it has SEEN the BEAM and then the BEAM disappears
1726
1830
  # (orderly halt OR :emfile-style crash), reaps every agent. Polling the pattern
@@ -2037,6 +2141,7 @@ load_aiur_instance_record() {
2037
2141
  AIUR_RECORD_INSTANCE_KEY=""
2038
2142
  AIUR_RECORD_SESSION=""
2039
2143
  AIUR_RECORD_SOCKET=""
2144
+ AIUR_RECORD_AGENT_TMPFILE=""
2040
2145
  AIUR_RECORD_WORKSPACE_ROOT_FILE=""
2041
2146
  AIUR_RECORD_PROJECT_ROOT=""
2042
2147
  AIUR_RECORD_PROJECT_ROOT_SOURCE=""
@@ -2531,7 +2636,8 @@ cmd_reset_budget() {
2531
2636
  # The text is base64-encoded for the RPC hop so arbitrary content (quotes,
2532
2637
  # backslashes, `#{}`, newlines) survives without Elixir-string escaping.
2533
2638
  cmd_message() {
2534
- 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
2535
2641
 
2536
2642
  local issue="${1:-}"
2537
2643
  if [ -z "$issue" ] || [[ ! "$issue" =~ ^[0-9]+$ ]]; then
@@ -2540,6 +2646,26 @@ cmd_message() {
2540
2646
  fi
2541
2647
  shift
2542
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
+
2543
2669
  local text="$*"
2544
2670
  if [ -z "$text" ]; then
2545
2671
  echo "$usage" >&2
@@ -2548,7 +2674,11 @@ cmd_message() {
2548
2674
 
2549
2675
  local encoded
2550
2676
  encoded="$(printf '%s' "$text" | base64 | tr -d '\n')"
2551
- 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
2552
2682
  }
2553
2683
 
2554
2684
  # `aiur agents` — concise one-line-per-agent state + current activity from a
@@ -2617,7 +2747,7 @@ encode_control_value() {
2617
2747
  # These are explicit trusted-Executor mutations, deliberately separate from
2618
2748
  # the read-only `commands` catalog. Revisions remain dashboard-owned.
2619
2749
  cmd_executor_answer() {
2620
- 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
2621
2751
  if [ -z "$decision_id" ] || [[ "$decision_id" = -* ]]; then
2622
2752
  echo "aiur: executor-answer expects exactly one decision ID" >&2
2623
2753
  exit 64
@@ -2639,6 +2769,7 @@ cmd_executor_answer() {
2639
2769
  --idempotency-key=*) idempotency_key="${arg#--idempotency-key=}" ;;
2640
2770
  --executor-id) [ "$#" -gt 1 ] || { echo "aiur: executor-answer --executor-id requires a value" >&2; exit 64; }; shift; executor_id="$1" ;;
2641
2771
  --executor-id=*) executor_id="${arg#--executor-id=}" ;;
2772
+ --supersede) supersede=1 ;;
2642
2773
  -*) echo "aiur: executor-answer received an unknown option: $arg" >&2; exit 64 ;;
2643
2774
  *) echo "aiur: executor-answer expects exactly one decision ID" >&2; exit 64 ;;
2644
2775
  esac
@@ -2663,6 +2794,9 @@ cmd_executor_answer() {
2663
2794
  opts="$opts, rationale: Base.decode64!(\"$(encode_control_value "$rationale")\")"
2664
2795
  opts="$opts, idempotency_key: Base.decode64!(\"$(encode_control_value "$idempotency_key")\")"
2665
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
2666
2800
  local AIUR_CONTROL_ATTEMPT_CONTEXT="decision ID ${decision_id} with expected version ${expected_version}"
2667
2801
  run_control_rpc "Aiur.AgentControlCLI.executor_answer([$opts])"
2668
2802
  }
@@ -3050,6 +3184,33 @@ cmd_executor_roster() {
3050
3184
  run_control_rpc "Aiur.AgentControlCLI.executor_roster(json: $json_arg)"
3051
3185
  }
3052
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
+
3053
3214
  cmd_executor_claim() {
3054
3215
  local as="" arg
3055
3216
  while [ "$#" -gt 0 ]; do
@@ -3347,8 +3508,9 @@ cmd_stop() {
3347
3508
  export RELEASE_NODE ERL_EPMD_ADDRESS
3348
3509
  resolve_control_identity_from_records
3349
3510
 
3350
- local workspace_root_file
3511
+ local workspace_root_file agent_pidfile
3351
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)"
3352
3514
 
3353
3515
  local tmux_bin
3354
3516
  tmux_bin="$(command -v tmux || true)"
@@ -3407,16 +3569,13 @@ cmd_stop() {
3407
3569
  "$tmux_bin" -L "$socket" kill-server 2>/dev/null || true
3408
3570
  fi
3409
3571
 
3410
- # Belt-and-suspenders for a mid-turn stop: sweep any headless agent (this run
3411
- # or a prior crashed one) still recorded in a pidfile. comm-guarded, so a
3412
- # recycled pid is spared. Empty socket: the kill-server above already ran.
3413
- local session_root agentfile
3414
- session_root="${XDG_RUNTIME_DIR:-${TMPDIR:-/tmp}}"
3415
- for agentfile in "$session_root"/aiur-*-agents; do
3416
- [ -e "$agentfile" ] || continue
3417
- reap_aiur_agents "" "$agentfile"
3418
- rm -f "$agentfile" 2>/dev/null || true
3419
- 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
3420
3579
 
3421
3580
  reap_stale_manual_smoke 0
3422
3581
  sweep_dead_tmux_sockets
@@ -3902,10 +4061,6 @@ aiur_engine_main() {
3902
4061
  findings)
3903
4062
  run_findings "$@"
3904
4063
  ;;
3905
- guard-pr-deletions)
3906
- shift
3907
- "$engine_dir/guard-pr-deletions.sh" "$@"
3908
- ;;
3909
4064
  ask | asks)
3910
4065
  run_asks "$@"
3911
4066
  ;;
@@ -3996,6 +4151,10 @@ aiur_engine_main() {
3996
4151
  shift
3997
4152
  cmd_executor_roster "$@"
3998
4153
  ;;
4154
+ executor-fast-forward)
4155
+ shift
4156
+ cmd_executor_fast_forward "$@"
4157
+ ;;
3999
4158
  executor-claim)
4000
4159
  shift
4001
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",
3
+ "version": "0.0.7",
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",
39
- "aiur-cli-linux-arm64": "0.0.5",
40
- "aiur-cli-darwin-arm64": "0.0.5"
38
+ "aiur-cli-linux-x64": "0.0.7",
39
+ "aiur-cli-linux-arm64": "0.0.7",
40
+ "aiur-cli-darwin-arm64": "0.0.7"
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)"