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 +61 -86
- package/libexec/aiur-engine.sh +437 -83
- 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/README.md
CHANGED
|
@@ -1,61 +1,77 @@
|
|
|
1
1
|
# AIUR
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Oversee a fleet of coding agents that coordinate via events.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
## Install
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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
|
-
##
|
|
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
|
|
26
|
-
aiur
|
|
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
|
-
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
49
|
+
## Interfaces
|
|
50
|
+
|
|
51
|
+
### GUI
|
|
52
|
+
|
|
53
|
+

|
|
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
|
+

|
|
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
|
|
58
|
-
| `aiur --bg` | Start a detached headless run,
|
|
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
|
-
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
>
|
|
101
|
-
>
|
|
102
|
-
>
|
|
103
|
-
>
|
|
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
|
-
[
|
|
94
|
+
This project is licensed under the [Apache License 2.0](https://github.com/aiur-team/aiur/blob/main/LICENSE).
|
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,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
|
-
#
|
|
590
|
-
# 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
|
+
|
|
591
605
|
load_dotenv() {
|
|
592
|
-
|
|
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
|
|
660
|
+
local file="$1" skip="${2:-}" line
|
|
598
661
|
[ -f "$file" ] || return 0
|
|
599
662
|
while IFS= read -r line || [ -n "$line" ]; do
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
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
|
-
|
|
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
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
3216
|
-
#
|
|
3217
|
-
#
|
|
3218
|
-
|
|
3219
|
-
|
|
3220
|
-
|
|
3221
|
-
|
|
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.
|
|
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)"
|