aiur-cli 0.0.5 → 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/libexec/aiur-engine.sh +200 -41
- package/libexec/aiur-pane-ctrlc +135 -0
- package/package.json +5 -5
- package/share/aiur.tmux.conf +25 -1
- package/libexec/guard-pr-deletions.sh +0 -70
package/libexec/aiur-engine.sh
CHANGED
|
@@ -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
|
-
#
|
|
592
|
-
# only fills
|
|
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
|
-
|
|
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
|
|
660
|
+
local file="$1" skip="${2:-}" line
|
|
600
661
|
[ -f "$file" ] || return 0
|
|
601
662
|
while IFS= read -r line || [ -n "$line" ]; do
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
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`.
|
|
@@ -870,7 +933,7 @@ run_session() {
|
|
|
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
935
|
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 \
|
|
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,
|
|
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
|
-
|
|
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
|
|
3411
|
-
#
|
|
3412
|
-
#
|
|
3413
|
-
|
|
3414
|
-
|
|
3415
|
-
|
|
3416
|
-
|
|
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.
|
|
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/
|
|
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.
|
|
39
|
-
"aiur-cli-linux-arm64": "0.0.
|
|
40
|
-
"aiur-cli-darwin-arm64": "0.0.
|
|
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
|
}
|
package/share/aiur.tmux.conf
CHANGED
|
@@ -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 "
|
|
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)"
|