aiur-cli 0.0.0 → 0.0.1

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 ADDED
@@ -0,0 +1,64 @@
1
+ # AIUR
2
+
3
+ **AI Unit Runtime for Executors**
4
+
5
+ Oversee a fleet of coding agents that **coordinate via events**.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ npm install -g aiur-cli
11
+ ```
12
+
13
+ Requires [tmux](https://github.com/tmux/tmux) at runtime. Install
14
+ [opencode](https://opencode.ai) too for in-pane agent chat.
15
+
16
+ ## Get started
17
+
18
+ ```bash
19
+ cd your-repo
20
+ aiur init # interactive setup wizard
21
+ aiur # start agents (foreground); use `aiur --bg` to detach
22
+ ```
23
+
24
+ `aiur init` scaffolds `.aiurconfig` and provisions the repo. It walks you through:
25
+
26
+ 1. **Where to store config** — repo-local `./.aiurconfig` or global `~/.aiurconfig`.
27
+ 2. **Tracker** — GitHub or Linear, plus the repo.
28
+ 3. **Agents & routing** — Claude and/or Codex, optional per-complexity model
29
+ routing, and the permission mode.
30
+ 4. **Limits** — max concurrent agents, turns, duration, pre-warmed sessions, and
31
+ polling interval.
32
+ 5. **GitHub token** — to create labels and act as the bot account.
33
+ 6. **Labels** — the lifecycle (`agent:*`), complexity, model, and remote-control
34
+ labels aiur routes on. Only missing labels are created; a group that already
35
+ exists is reported as created and skipped.
36
+
37
+ Re-running resumes from your saved answers. When setup finishes, add `agent:todo`
38
+ to the issues you want worked and run `aiur`.
39
+
40
+ ## Commands
41
+
42
+ | Command | What it does |
43
+ |---|---|
44
+ | `aiur init` | Interactive setup wizard (scaffold `.aiurconfig`) |
45
+ | `aiur` | Start the workflow in the foreground (local-only bind) |
46
+ | `aiur --bg` | Start in a detached tmux session |
47
+ | `aiur status` | Show active agents and their state |
48
+ | `aiur pause <id…>` / `resume <id…>` | Pause or resume agents by issue ID |
49
+ | `aiur stop` | Stop the running session |
50
+
51
+ ## Features
52
+
53
+ - **Issue Monitoring** — Dispatch agents automatically via GitHub or Linear tickets.
54
+ - **Shared Bus** — Agents auto-pub/sub to dependency events, unblocking work early.
55
+ - **Optimize Effort** — Story points guide model sizes and skills. Supports Claude and Codex.
56
+ - **Take the Wheel** — Drive at any moment via pre-warmed [Opencode](https://opencode.ai) sessions in configurable tmux panes.
57
+ - **Smart Alerts** — Customize notifications and sounds when agents finish, stall, or need a decision.
58
+ - **Live Dashboard** — Shareable web view of every agent, event, and progress bar.
59
+
60
+ ---
61
+
62
+ _Command macro, delegate micro, maximize APM._
63
+
64
+ [github.com/its-everdred/aiur](https://github.com/its-everdred/aiur)
package/bin/aiur.js CHANGED
@@ -1,6 +1,148 @@
1
1
  #!/usr/bin/env node
2
- console.error(
3
- "aiur-cli is not released yet — this is a placeholder reserving the name.\n" +
4
- "Track progress at https://github.com/its-everdred/aiur",
5
- );
6
- process.exit(1);
2
+ "use strict";
3
+
4
+ const { spawnSync } = require("node:child_process");
5
+ const path = require("node:path");
6
+ const fs = require("node:fs");
7
+
8
+ // Map this host to the platform package that carries its OTP release.
9
+ const PLATFORMS = {
10
+ "darwin arm64": "darwin-arm64",
11
+ "darwin x64": "darwin-x64",
12
+ "linux arm64": "linux-arm64",
13
+ "linux x64": "linux-x64",
14
+ };
15
+
16
+ const MIN_TMUX = [3, 3];
17
+
18
+ function fail(message) {
19
+ process.stderr.write(`aiur: ${message}\n`);
20
+ process.exit(1);
21
+ }
22
+
23
+ function resolveReleaseDir() {
24
+ // A pre-set AIUR_RELEASE_DIR (the dev shim, or local `npm pack` install
25
+ // verification) wins over platform-package resolution.
26
+ const preset = process.env.AIUR_RELEASE_DIR;
27
+ if (preset) {
28
+ try {
29
+ if (fs.statSync(preset).isDirectory()) return preset;
30
+ } catch (_) {
31
+ // fall through to the explicit error below
32
+ }
33
+ fail(`AIUR_RELEASE_DIR is set but not a directory: ${preset}`);
34
+ }
35
+
36
+ const triple = PLATFORMS[`${process.platform} ${process.arch}`];
37
+ if (!triple) {
38
+ fail(
39
+ `unsupported platform ${process.platform}/${process.arch}. ` +
40
+ "Supported: darwin/linux on arm64/x64. " +
41
+ "See https://github.com/its-everdred/aiur",
42
+ );
43
+ }
44
+
45
+ const pkg = `aiur-cli-${triple}`;
46
+ let pkgJson;
47
+ try {
48
+ pkgJson = require.resolve(`${pkg}/package.json`);
49
+ } catch (err) {
50
+ if (err && err.code === "MODULE_NOT_FOUND") {
51
+ fail(
52
+ `platform package "${pkg}" is not installed. ` +
53
+ "This usually means optional dependencies were skipped " +
54
+ "(--no-optional, a shared lockfile, or a known npm install bug). " +
55
+ "Reinstall aiur-cli, or see https://github.com/its-everdred/aiur",
56
+ );
57
+ }
58
+ throw err;
59
+ }
60
+
61
+ return path.join(path.dirname(pkgJson), "release");
62
+ }
63
+
64
+ function tmuxVersion() {
65
+ const result = spawnSync("tmux", ["-V"], { encoding: "utf8" });
66
+ if (result.error || result.status !== 0) return null;
67
+ const match = /tmux\s+(\d+)\.(\d+)/.exec(result.stdout || "");
68
+ if (!match) return null;
69
+ return [Number(match[1]), Number(match[2])];
70
+ }
71
+
72
+ function tmuxHint() {
73
+ return process.platform === "darwin"
74
+ ? "install it with: brew install tmux"
75
+ : "install it with: apt install tmux (or your distro's package manager)";
76
+ }
77
+
78
+ function preflightTmux() {
79
+ const version = tmuxVersion();
80
+ if (!version) {
81
+ fail(`tmux is required but was not found on PATH. ${tmuxHint()}`);
82
+ }
83
+ const [major, minor] = version;
84
+ const [needMajor, needMinor] = MIN_TMUX;
85
+ const tooOld =
86
+ major < needMajor || (major === needMajor && minor < needMinor);
87
+ if (tooOld) {
88
+ fail(
89
+ `tmux ${major}.${minor} is too old; aiur needs ` +
90
+ `>= ${needMajor}.${needMinor}. ${tmuxHint()}`,
91
+ );
92
+ }
93
+ }
94
+
95
+ function preflightOpencode() {
96
+ const result = spawnSync("opencode", ["--version"], { stdio: "ignore" });
97
+ if (result.error || result.status !== 0) {
98
+ process.stderr.write(
99
+ "aiur: opencode was not found on PATH. The interactive 'take the wheel' " +
100
+ "feature needs it; everything else works without it. " +
101
+ "Install from https://opencode.ai\n",
102
+ );
103
+ }
104
+ }
105
+
106
+ // `init` and `--version` run as foreground one-shots that never start the
107
+ // tmux-backed UI, so their tmux/opencode preflight is irrelevant — and tmux may
108
+ // legitimately be absent on a machine that only ever runs `aiur init`.
109
+ function isForegroundOneShot(argv) {
110
+ for (const arg of argv) {
111
+ if (arg === "--version") return true;
112
+ if (arg.startsWith("-")) continue;
113
+ return arg === "init";
114
+ }
115
+ return false;
116
+ }
117
+
118
+ function main() {
119
+ const releaseDir = resolveReleaseDir();
120
+
121
+ if (!isForegroundOneShot(process.argv.slice(2))) {
122
+ preflightTmux();
123
+ preflightOpencode();
124
+ }
125
+
126
+ const pkgRoot = path.resolve(__dirname, "..");
127
+ const launcher = path.join(pkgRoot, "libexec", "aiur-engine.sh");
128
+
129
+ const result = spawnSync(launcher, process.argv.slice(2), {
130
+ stdio: "inherit",
131
+ env: {
132
+ ...process.env,
133
+ AIUR_RELEASE_DIR: releaseDir,
134
+ AIUR_TMUX_CONF: path.join(pkgRoot, "share", "aiur.tmux.conf"),
135
+ },
136
+ });
137
+
138
+ if (result.error) {
139
+ fail(`failed to launch ${launcher}: ${result.error.message}`);
140
+ }
141
+ if (result.signal) {
142
+ process.kill(process.pid, result.signal);
143
+ return;
144
+ }
145
+ process.exit(result.status === null ? 1 : result.status);
146
+ }
147
+
148
+ main();
@@ -0,0 +1,695 @@
1
+ #!/usr/bin/env bash
2
+ #
3
+ # Shared aiur launcher engine.
4
+ #
5
+ # Owns every aiur subcommand. Both the installed `aiur` (bin/aiur.js) and the dev
6
+ # `aiurdev` (scripts/aiurdev) exec this one engine, differing only in which
7
+ # release is run:
8
+ #
9
+ # AIUR_RELEASE_DIR release dir whose bin/aiur is exec'd (required; set by the caller)
10
+ #
11
+ # There is a single `aiur` distribution identity (node/cookie/session). aiurdev
12
+ # shares it. The identity vars resolve to the fixed `aiur` values; they remain
13
+ # overridable only so tests can redirect state to a temp dir:
14
+ #
15
+ # AIUR_BG_STATE_DIR cookie + state dir (default: ~/.config/aiur)
16
+ # AIUR_COOKIE_FILE cookie file (default: $AIUR_BG_STATE_DIR/cookie)
17
+ # AIUR_SESSION_PREFIX tmux session prefix (default: aiur)
18
+ # AIUR_PROFILES_FILE profiles file (default: ~/.config/aiur/aiur.profiles)
19
+ # AIUR_RELEASE_NODE full node name (default: aiur-$USER@127.0.0.1)
20
+ #
21
+ # The release is self-contained (bundled ERTS), so it runs without mise/Elixir on
22
+ # PATH. Dev's build-if-stale step lives in the aiurdev shim, not here.
23
+
24
+ set -euo pipefail
25
+
26
+ die() {
27
+ echo "❌ $*" >&2
28
+ exit 1
29
+ }
30
+
31
+ engine_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
32
+
33
+ # --- distribution identity (fixed: the single `aiur` identity) ---------------
34
+
35
+ aiur_resolve_identity() {
36
+ local config_home="${XDG_CONFIG_HOME:-$HOME/.config}"
37
+
38
+ : "${AIUR_BG_STATE_DIR:=$config_home/aiur}"
39
+ : "${AIUR_COOKIE_FILE:=$AIUR_BG_STATE_DIR/cookie}"
40
+ : "${AIUR_SESSION_PREFIX:=aiur}"
41
+ : "${AIUR_PROFILES_FILE:=$config_home/aiur/aiur.profiles}"
42
+ : "${AIUR_RELEASE_NODE:=aiur-${USER}@127.0.0.1}"
43
+
44
+ export AIUR_BG_STATE_DIR AIUR_COOKIE_FILE AIUR_SESSION_PREFIX \
45
+ AIUR_PROFILES_FILE AIUR_RELEASE_NODE
46
+ }
47
+
48
+ aiur_print_identity() {
49
+ aiur_resolve_identity
50
+ printf 'AIUR_RELEASE_DIR=%s\n' "${AIUR_RELEASE_DIR:-}"
51
+ printf 'AIUR_BG_STATE_DIR=%s\n' "$AIUR_BG_STATE_DIR"
52
+ printf 'AIUR_SESSION_PREFIX=%s\n' "$AIUR_SESSION_PREFIX"
53
+ printf 'AIUR_PROFILES_FILE=%s\n' "$AIUR_PROFILES_FILE"
54
+ printf 'AIUR_RELEASE_NODE=%s\n' "$AIUR_RELEASE_NODE"
55
+ printf 'AIUR_COOKIE_FILE=%s\n' "$AIUR_COOKIE_FILE"
56
+ }
57
+
58
+ # --- BEAM distribution (cookie + named node) ---------------------------------
59
+
60
+ ensure_bg_state_dir() {
61
+ aiur_resolve_identity
62
+ mkdir -p "$AIUR_BG_STATE_DIR"
63
+ }
64
+
65
+ ensure_erlang_cookie() {
66
+ aiur_resolve_identity
67
+ ensure_bg_state_dir
68
+
69
+ local cookie_file="$AIUR_COOKIE_FILE"
70
+
71
+ if [ ! -f "$cookie_file" ]; then
72
+ (
73
+ umask 0177
74
+ tmp_file="$(mktemp "$AIUR_BG_STATE_DIR/cookie.XXXXXX")"
75
+ head -c 32 /dev/urandom | base64 | tr -d '\n=+/' | head -c 32 >"$tmp_file"
76
+ mv "$tmp_file" "$cookie_file"
77
+ )
78
+ chmod 0400 "$cookie_file"
79
+ fi
80
+
81
+ [ -r "$cookie_file" ] || die "$cookie_file is not readable"
82
+
83
+ if [ "$(stat -c '%U' "$cookie_file" 2>/dev/null || stat -f '%Su' "$cookie_file")" != "$USER" ]; then
84
+ die "$cookie_file is not owned by $USER"
85
+ fi
86
+
87
+ local size
88
+ size="$(wc -c <"$cookie_file" | tr -d ' ')"
89
+ [ "$size" -ge 16 ] || die "$cookie_file is shorter than 16 bytes"
90
+
91
+ printf '%s' "$cookie_file"
92
+ }
93
+
94
+ prepare_distribution() {
95
+ aiur_resolve_identity
96
+
97
+ local cookie_file
98
+ cookie_file="$(ensure_erlang_cookie)" || return 1
99
+
100
+ local cookie
101
+ cookie="$(cat "$cookie_file")"
102
+
103
+ export RELEASE_DISTRIBUTION="name"
104
+ export RELEASE_NODE="$AIUR_RELEASE_NODE"
105
+ export RELEASE_COOKIE="$cookie"
106
+ # Pin the distribution listener to 127.0.0.1 too, matching the node-name IP.
107
+ # {127,0,0,1} is the Erlang tuple — unquoted because bash inside the
108
+ # surrounding double quotes does not brace-expand it.
109
+ export ERL_AFLAGS=" -proto_dist inet_tcp -kernel inet_dist_use_interface {127,0,0,1}"
110
+ export ERL_EPMD_ADDRESS="127.0.0.1"
111
+ export AIUR_NODE="$RELEASE_NODE"
112
+ export AIUR_ERLANG_COOKIE="$RELEASE_COOKIE"
113
+ }
114
+
115
+ # --- release resolution ------------------------------------------------------
116
+
117
+ release_dir=""
118
+ vsn_dir=""
119
+ release_bin=""
120
+
121
+ resolve_release() {
122
+ release_dir="${AIUR_RELEASE_DIR:-}"
123
+ [ -n "$release_dir" ] || die "AIUR_RELEASE_DIR is not set; the engine must be invoked via the aiur or aiurdev wrapper"
124
+ [ -d "$release_dir" ] || die "AIUR_RELEASE_DIR does not exist: $release_dir"
125
+
126
+ local release_vsn
127
+ release_vsn="$(cut -d' ' -f2 "$release_dir/releases/start_erl.data")"
128
+ vsn_dir="$release_dir/releases/$release_vsn"
129
+ release_bin="$release_dir/bin/aiur"
130
+ [ -x "$vsn_dir/elixir" ] || die "release elixir launcher not found at $vsn_dir/elixir"
131
+ }
132
+
133
+ # --- argv round-trip (System.argv is empty under `elixir --eval`) -------------
134
+
135
+ argv_file=""
136
+ init_argv_file() {
137
+ argv_file="$(mktemp "${TMPDIR:-/tmp}/aiur-argv.XXXXXX")"
138
+ : >"$argv_file"
139
+ }
140
+ write_argv() {
141
+ local a
142
+ for a in "$@"; do printf '%s\n' "$a" >>"$argv_file"; done
143
+ }
144
+
145
+ release_cmd=()
146
+ build_release_cmd() {
147
+ release_cmd=(
148
+ "$vsn_dir/elixir"
149
+ --cookie "$RELEASE_COOKIE"
150
+ --name "$RELEASE_NODE"
151
+ --erl-config "$vsn_dir/sys"
152
+ --boot "$vsn_dir/start_clean"
153
+ --boot-var RELEASE_LIB "$release_dir/lib"
154
+ --vm-args "$vsn_dir/vm.args"
155
+ --eval "Aiur.CLI.main(Aiur.CLI.argv_from_file())"
156
+ )
157
+ }
158
+
159
+ # Distribution-free boot for the `init` wizard: same interactive `--eval` form
160
+ # (so the wizard's prompts receive keystrokes — `bin/aiur eval` is -noinput),
161
+ # but with no --name/--cookie since the wizard makes no RPC calls.
162
+ build_init_cmd() {
163
+ release_cmd=(
164
+ "$vsn_dir/elixir"
165
+ --erl-config "$vsn_dir/sys"
166
+ --boot "$vsn_dir/start_clean"
167
+ --boot-var RELEASE_LIB "$release_dir/lib"
168
+ --vm-args "$vsn_dir/vm.args"
169
+ --eval "Aiur.CLI.main(Aiur.CLI.argv_from_file())"
170
+ )
171
+ }
172
+
173
+ usage() {
174
+ cat <<'EOF'
175
+ Usage: aiur [--interactive] [--logs-root <path>] [--port <port>] [--host <host>] [path-to-.aiurconfig]
176
+ aiur init [--force] scaffold .aiurconfig (interactive setup wizard)
177
+ aiur --bg start in a detached tmux session
178
+ aiur stop stop the running session
179
+ aiur status show agent status
180
+ aiur pause <ids|--all> | resume <ids|--all>
181
+ aiur --version
182
+ EOF
183
+ }
184
+
185
+ # --- one-shot: --version (no tmux) -------------------------------------------
186
+
187
+ run_version() {
188
+ resolve_release
189
+ # Distribution-free (like init): printing the version is a compile-time
190
+ # constant, so never claim the node name — otherwise `aiur --version` fails
191
+ # whenever an aiur session is already running.
192
+ build_init_cmd
193
+ init_argv_file
194
+ write_argv "$@"
195
+ export AIUR_ARGV_FILE="$argv_file"
196
+ exec "${release_cmd[@]}"
197
+ }
198
+
199
+ # --- one-shot: init (interactive wizard, distribution-free, no tmux) ----------
200
+
201
+ run_init() {
202
+ resolve_release
203
+ build_init_cmd
204
+ init_argv_file
205
+ write_argv "$@"
206
+ export AIUR_ARGV_FILE="$argv_file"
207
+ exec "${release_cmd[@]}"
208
+ }
209
+
210
+ # --- interactive / background run -------------------------------------------
211
+ #
212
+ # mode=foreground attaches the UI and tears down on exit; mode=background leaves
213
+ # the detached tmux session running and returns.
214
+
215
+ # Load KEY=VALUE pairs from ./.env into the environment so the running release
216
+ # (e.g. GITHUB_TOKEN, dashboard creds) sees what `aiur init` scaffolded there.
217
+ # An already-exported variable always wins, so a shell export overrides the file.
218
+ load_dotenv() {
219
+ local file=".env" line key val
220
+ [ -f "$file" ] || return 0
221
+ while IFS= read -r line || [ -n "$line" ]; do
222
+ line="${line#"${line%%[![:space:]]*}"}"
223
+ case "$line" in '' | '#'*) continue ;; esac
224
+ [ "${line#*=}" = "$line" ] && continue
225
+ key="${line%%=*}"
226
+ key="${key%"${key##*[![:space:]]}"}"
227
+ case "$key" in '' | *[!A-Za-z0-9_]*) continue ;; esac
228
+ val="${line#*=}"
229
+ val="${val#"${val%%[![:space:]]*}"}"
230
+ case "$val" in
231
+ \"*\") val="${val#\"}" && val="${val%\"}" ;;
232
+ \'*\') val="${val#\'}" && val="${val%\'}" ;;
233
+ esac
234
+ [ -n "${!key+x}" ] && continue
235
+ export "$key=$val"
236
+ done <"$file"
237
+ }
238
+
239
+ run_session() {
240
+ local mode="$1"
241
+ shift
242
+
243
+ # --debug is an engine-level convenience on the run path: turn on verbose
244
+ # logging (AIUR_DEBUG) and strip the flag before the release parses argv.
245
+ local run_args=() run_arg
246
+ for run_arg in "$@"; do
247
+ case "$run_arg" in
248
+ --debug) export AIUR_DEBUG=1 ;;
249
+ *) run_args+=("$run_arg") ;;
250
+ esac
251
+ done
252
+ if [ "${#run_args[@]}" -gt 0 ]; then set -- "${run_args[@]}"; else set --; fi
253
+
254
+ resolve_release
255
+
256
+ local tmux_bin
257
+ tmux_bin="$(command -v tmux || true)"
258
+ [ -n "$tmux_bin" ] || die "tmux is required to run aiur; install tmux and retry"
259
+
260
+ aiur_resolve_identity
261
+
262
+ # Pick up GITHUB_TOKEN / dashboard creds the wizard wrote to ./.env so the
263
+ # running tracker can authenticate. Shell exports still take precedence.
264
+ load_dotenv
265
+
266
+ init_argv_file
267
+
268
+ # Inject the flags a bare `aiur` needs: loopback bind, interactive UI, and the
269
+ # no-guardrails ack. Skip any the user already passed.
270
+ local has_host=0 has_interactive=0 has_ack=0 arg
271
+ for arg in "$@"; do
272
+ case "$arg" in
273
+ --host | --host=*) has_host=1 ;;
274
+ --interactive) has_interactive=1 ;;
275
+ --i-understand-that-this-will-be-running-without-the-usual-guardrails) has_ack=1 ;;
276
+ esac
277
+ done
278
+ local injected=()
279
+ [ "$has_host" -eq 1 ] || injected+=(--host 127.0.0.1)
280
+ [ "$has_interactive" -eq 1 ] || injected+=(--interactive)
281
+ [ "$has_ack" -eq 1 ] || injected+=(--i-understand-that-this-will-be-running-without-the-usual-guardrails)
282
+
283
+ write_argv "${injected[@]}" "$@"
284
+ export AIUR_ARGV_FILE="$argv_file"
285
+
286
+ prepare_distribution
287
+ build_release_cmd
288
+
289
+ # Force +fnu when no locale is set so the BEAM does not mangle non-ASCII paths.
290
+ if [ -z "${LANG:-}" ] && [ -z "${LC_ALL:-}" ] && [ -z "${LC_CTYPE:-}" ]; then
291
+ export ELIXIR_ERL_OPTIONS="${ELIXIR_ERL_OPTIONS:-} +fnu"
292
+ fi
293
+
294
+ local session="${AIUR_SESSION_PREFIX}-${USER:-user}-default"
295
+ local socket="${AIUR_SESSION_PREFIX}-${USER:-user}"
296
+ local conf
297
+ conf="$(resolve_tmux_conf)"
298
+ [ -f "$conf" ] || die "tmux conf not found at $conf"
299
+
300
+ mkdir -p "$AIUR_BG_STATE_DIR"
301
+ printf '%s\n' "$session" >"$AIUR_BG_STATE_DIR/state"
302
+ export AIUR_TMUX_SESSION="$session"
303
+ export AIUR_TMUX_SOCKET="$socket"
304
+ export AIUR_TMUX_CONF="$conf"
305
+ export AIUR_BIN="${BASH_SOURCE[0]}"
306
+
307
+ local session_root="${XDG_RUNTIME_DIR:-${TMPDIR:-/tmp}}"
308
+ export AIUR_SESSION_TMPFILE="${session_root}/aiur-${$}-sessions"
309
+ : >"$AIUR_SESSION_TMPFILE"
310
+
311
+ local startup_capture
312
+ startup_capture="$(mktemp "${TMPDIR:-/tmp}/aiur-startup.XXXXXX")"
313
+
314
+ # Inner pane script: tmux's server may pre-exist and not inherit our env, so
315
+ # re-export every var the BEAM needs. tee preserves a startup capture.
316
+ local launcher
317
+ launcher="$(mktemp "${TMPDIR:-/tmp}/aiur-pane.XXXXXX")"
318
+ chmod +x "$launcher"
319
+ {
320
+ printf '#!/usr/bin/env bash\n'
321
+ printf 'set -o pipefail\n'
322
+ printf 'cd %q || exit 1\n' "$PWD"
323
+ local v
324
+ for v in AIUR_RELEASE_DIR AIUR_ARGV_FILE RELEASE_DISTRIBUTION RELEASE_NODE \
325
+ RELEASE_COOKIE ERL_AFLAGS ERL_EPMD_ADDRESS AIUR_NODE AIUR_ERLANG_COOKIE \
326
+ AIUR_TMUX_SESSION AIUR_TMUX_SOCKET AIUR_TMUX_CONF AIUR_BIN \
327
+ AIUR_SESSION_TMPFILE ELIXIR_ERL_OPTIONS AIUR_LOGS_ROOT AIUR_DEBUG; do
328
+ if [ -n "${!v:-}" ]; then printf 'export %s=%q\n' "$v" "${!v}"; fi
329
+ done
330
+ printf 'capture=%q\n' "$startup_capture"
331
+ printf '%q' "${release_cmd[0]}"
332
+ for arg in "${release_cmd[@]:1}"; do printf ' %q' "$arg"; done
333
+ printf ' 2>&1 | tee -a "$capture"\n'
334
+ printf 'exit ${PIPESTATUS[0]}\n'
335
+ } >"$launcher"
336
+
337
+ local inner_cmd
338
+ printf -v inner_cmd '%q; rc=$?; rm -f %q; exit $rc' "$launcher" "$launcher"
339
+
340
+ if [ "$mode" = "foreground" ]; then
341
+ _session_socket="$socket" _session_name="$session" _session_conf="$conf" \
342
+ _session_tmpfile="$AIUR_SESSION_TMPFILE" _session_capture="$startup_capture" \
343
+ _session_argv="$argv_file" _session_release="$release_dir" _session_tmux="$tmux_bin" \
344
+ _session_node="$AIUR_RELEASE_NODE"
345
+ install_foreground_traps
346
+ fi
347
+
348
+ # An orphaned BEAM (its tmux session gone) can still hold the node name —
349
+ # under the unified identity, even one from a different release dir. With no
350
+ # live session on our socket, reap any name-holder so the launch isn't blocked
351
+ # by "name seems to be in use".
352
+ if ! "$tmux_bin" -L "$socket" -f "$conf" has-session -t "$session" 2>/dev/null; then
353
+ kill_beams_matching "-name ${AIUR_RELEASE_NODE}"
354
+ fi
355
+
356
+ if ! "$tmux_bin" -L "$socket" -f "$conf" new-session -d -s "$session" \
357
+ -x "${COLUMNS:-200}" -y "${LINES:-50}" "$inner_cmd"; then
358
+ echo "❌ aiur failed to start; captured output:" >&2
359
+ tail -n 30 "$startup_capture" 2>/dev/null | sed 's/^/ /' >&2 || true
360
+ rm -f "$startup_capture" "$argv_file" 2>/dev/null || true
361
+ exit 1
362
+ fi
363
+
364
+ # Grace window: surface a boot crash instead of attaching to nothing.
365
+ local tick
366
+ for ((tick = 0; tick < 30; tick++)); do
367
+ if ! "$tmux_bin" -L "$socket" -f "$conf" has-session -t "$session" 2>/dev/null; then
368
+ echo "❌ aiur exited during startup; captured output:" >&2
369
+ tail -n 30 "$startup_capture" 2>/dev/null | sed 's/^/ /' >&2 || true
370
+ [ "$mode" = "foreground" ] && return 1
371
+ rm -f "$startup_capture" "$argv_file" 2>/dev/null || true
372
+ exit 1
373
+ fi
374
+ sleep 0.1
375
+ done
376
+
377
+ if [ "$mode" = "background" ]; then
378
+ echo "aiur started in the background (tmux session ${session}). Attach with: aiur" >&2
379
+ rm -f "$startup_capture" "$argv_file" 2>/dev/null || true
380
+ return 0
381
+ fi
382
+
383
+ # Foreground: attach the UI. Do not exec — that would drop the teardown trap.
384
+ "$tmux_bin" -L "$socket" -f "$conf" attach -t "$session" 2> >(grep -v -F "[server exited]" >&2)
385
+ return $?
386
+ }
387
+
388
+ resolve_tmux_conf() {
389
+ if [ -n "${AIUR_TMUX_CONF:-}" ] && [ -f "${AIUR_TMUX_CONF:-}" ]; then
390
+ printf '%s\n' "$AIUR_TMUX_CONF"
391
+ return
392
+ fi
393
+ local user_conf="${XDG_CONFIG_HOME:-$HOME/.config}/aiur/tmux.conf"
394
+ if [ -f "$user_conf" ]; then
395
+ printf '%s\n' "$user_conf"
396
+ return
397
+ fi
398
+ printf '%s\n' "$engine_dir/../share/aiur.tmux.conf"
399
+ }
400
+
401
+ # TERM-then-KILL every BEAM whose command line matches $1. Reaps a stale node by
402
+ # name (covers orphans from any release dir): under the unified identity a dev
403
+ # `_build` BEAM and an installed BEAM both claim the same node name, so a
404
+ # release-path pgrep misses whichever one this run didn't launch.
405
+ kill_beams_matching() {
406
+ local pattern="$1" pids pid waited=0
407
+ pids="$(pgrep -f -- "$pattern" 2>/dev/null || true)"
408
+ [ -n "$pids" ] || return 0
409
+ for pid in $pids; do kill -TERM "$pid" 2>/dev/null || true; done
410
+ while [ "$waited" -lt 30 ]; do
411
+ pids="$(pgrep -f -- "$pattern" 2>/dev/null || true)"
412
+ [ -z "$pids" ] && break
413
+ sleep 0.1
414
+ waited=$((waited + 1))
415
+ done
416
+ pids="$(pgrep -f -- "$pattern" 2>/dev/null || true)"
417
+ for pid in $pids; do kill -KILL "$pid" 2>/dev/null || true; done
418
+ }
419
+
420
+ # Foreground teardown: kill the session + BEAM and reap opencode sessions on exit.
421
+ _session_socket="" _session_name="" _session_conf="" _session_tmpfile=""
422
+ _session_capture="" _session_argv="" _session_release="" _session_tmux="" _session_node=""
423
+ _cleanup_ran=0
424
+ session_cleanup() {
425
+ [ "$_cleanup_ran" = 1 ] && return 0
426
+ _cleanup_ran=1
427
+ local code=$?
428
+
429
+ if [ -n "$_session_tmux" ]; then
430
+ "$_session_tmux" -L "$_session_socket" -f "$_session_conf" kill-session -t "$_session_name" 2>/dev/null || true
431
+ fi
432
+
433
+ local beam_pids pid waited=0
434
+ beam_pids="$(pgrep -f "$_session_release/.*erts.*beam.smp" 2>/dev/null || true)"
435
+ if [ -n "$beam_pids" ]; then
436
+ for pid in $beam_pids; do kill -TERM "$pid" 2>/dev/null || true; done
437
+ while [ $waited -lt 30 ]; do
438
+ beam_pids="$(pgrep -f "$_session_release/.*erts.*beam.smp" 2>/dev/null || true)"
439
+ [ -z "$beam_pids" ] && break
440
+ sleep 0.1
441
+ waited=$((waited + 1))
442
+ done
443
+ beam_pids="$(pgrep -f "$_session_release/.*erts.*beam.smp" 2>/dev/null || true)"
444
+ for pid in $beam_pids; do kill -KILL "$pid" 2>/dev/null || true; done
445
+ fi
446
+
447
+ # Belt-and-suspenders: reap any BEAM still holding our node name, even one
448
+ # launched from a different release dir than this run.
449
+ [ -n "$_session_node" ] && kill_beams_matching "-name ${_session_node}"
450
+
451
+ if command -v opencode >/dev/null 2>&1 && [ -s "$_session_tmpfile" ]; then
452
+ local id
453
+ while IFS= read -r id; do
454
+ [ -n "$id" ] || continue
455
+ opencode session delete "$id" >/dev/null 2>&1 &
456
+ done <"$_session_tmpfile"
457
+ wait 2>/dev/null || true
458
+ fi
459
+
460
+ # Reap agent-driver sockets this run orphaned at close, not next launch.
461
+ sweep_dead_tmux_sockets || true
462
+
463
+ rm -f "$_session_tmpfile" "$_session_capture" "$_session_argv" 2>/dev/null || true
464
+ return $code
465
+ }
466
+ install_foreground_traps() {
467
+ trap 'session_cleanup' EXIT
468
+ trap 'trap - EXIT INT TERM HUP; session_cleanup; exit 130' INT
469
+ trap 'trap - EXIT INT TERM HUP; session_cleanup; exit 143' TERM
470
+ trap 'trap - EXIT INT TERM HUP; session_cleanup; exit 129' HUP
471
+ }
472
+
473
+ # --- lifecycle (RPC into the running node + tmux ops) ------------------------
474
+
475
+ trim() {
476
+ local s="$1"
477
+ s="${s#"${s%%[![:space:]]*}"}"
478
+ s="${s%"${s##*[![:space:]]}"}"
479
+ printf '%s' "$s"
480
+ }
481
+
482
+ # RPC an expression into the running node. The control CLI prints a trailing
483
+ # `__AIUR_CONTROL_EXIT__:<code>` marker we translate into the process exit code.
484
+ run_control_rpc() {
485
+ local expression="$1"
486
+ resolve_release
487
+ prepare_distribution || die "distribution setup failed; cannot contact aiur"
488
+
489
+ local marker="__AIUR_CONTROL_EXIT__:"
490
+ local output status exit_code=0 saw_marker=0 line
491
+
492
+ set +e
493
+ output="$("$release_bin" rpc "$expression" 2>&1)"
494
+ status=$?
495
+ set -e
496
+
497
+ if [ "$status" -ne 0 ]; then
498
+ echo "aiur: no running aiur node at ${RELEASE_NODE}; start aiur and try again" >&2
499
+ return 1
500
+ fi
501
+
502
+ while IFS= read -r line; do
503
+ case "$line" in
504
+ "$marker"*)
505
+ saw_marker=1
506
+ exit_code="${line#"$marker"}"
507
+ ;;
508
+ :ok | "") ;;
509
+ *) printf '%s\n' "$line" ;;
510
+ esac
511
+ done <<<"$output"
512
+
513
+ [ "$saw_marker" -eq 1 ] || return 1
514
+ return "$exit_code"
515
+ }
516
+
517
+ elixir_list_literal() {
518
+ local first=1 item
519
+ printf '['
520
+ for item in "$@"; do
521
+ [ "$first" -eq 0 ] && printf ', '
522
+ printf '"%s"' "$item"
523
+ first=0
524
+ done
525
+ printf ']'
526
+ }
527
+
528
+ # Parse issue-id targets (e.g. `44 45,46` or `--all`) into parsed_targets/parsed_all.
529
+ parsed_targets=()
530
+ parsed_all=0
531
+ parse_issue_targets() {
532
+ parsed_targets=()
533
+ parsed_all=0
534
+ [ "$#" -gt 0 ] || return 1
535
+
536
+ if [ "$#" -eq 1 ] && [ "$1" = "--all" ]; then
537
+ parsed_all=1
538
+ return 0
539
+ fi
540
+
541
+ local raw part parts
542
+ for raw in "$@"; do
543
+ [ "$raw" = "--all" ] && return 1
544
+ IFS=',' read -ra parts <<<"$raw"
545
+ for part in "${parts[@]}"; do
546
+ part="$(trim "$part")"
547
+ if [ -z "$part" ] || [[ ! "$part" =~ ^[0-9]+$ ]]; then return 1; fi
548
+ parsed_targets+=("$part")
549
+ done
550
+ done
551
+
552
+ [ "${#parsed_targets[@]}" -gt 0 ]
553
+ }
554
+
555
+ cmd_status() {
556
+ [ "$#" -eq 0 ] || die "status does not accept arguments"
557
+ run_control_rpc "Aiur.AgentControlCLI.status()"
558
+ }
559
+
560
+ cmd_pause_resume() {
561
+ local command="$1"
562
+ shift
563
+ if ! parse_issue_targets "$@"; then
564
+ echo "aiur: $command expects issue IDs or --all (e.g. aiur $command 44 45,46; aiur $command --all)" >&2
565
+ exit 64
566
+ fi
567
+
568
+ local expression
569
+ if [ "$parsed_all" -eq 1 ]; then
570
+ expression="Aiur.AgentControlCLI.${command}(:all)"
571
+ else
572
+ expression="Aiur.AgentControlCLI.${command}($(elixir_list_literal "${parsed_targets[@]}"))"
573
+ fi
574
+
575
+ run_control_rpc "$expression"
576
+ }
577
+
578
+ sweep_dead_tmux_sockets() {
579
+ local tmux_bin
580
+ tmux_bin="$(command -v tmux || true)"
581
+ [ -n "$tmux_bin" ] || return 0
582
+
583
+ local sockdir="${TMUX_TMPDIR:-/tmp}/tmux-$(id -u)"
584
+ [ -d "$sockdir" ] || return 0
585
+
586
+ local removed=0 name path
587
+ for path in "$sockdir"/*; do
588
+ [ -S "$path" ] || continue
589
+ name="${path##*/}"
590
+ case "$name" in
591
+ aiur* | *-driver | *-driver-*) ;;
592
+ *) continue ;;
593
+ esac
594
+ if ! "$tmux_bin" -L "$name" list-sessions >/dev/null 2>&1; then
595
+ rm -f "$path" 2>/dev/null && removed=$((removed + 1))
596
+ fi
597
+ done
598
+
599
+ [ "$removed" -gt 0 ] && echo "🧹 swept $removed dead aiur tmux socket(s)" >&2
600
+ return 0
601
+ }
602
+
603
+ # Stop the running session: kill the tmux session + the release BEAM, then sweep.
604
+ cmd_stop() {
605
+ resolve_release
606
+ aiur_resolve_identity
607
+
608
+ local tmux_bin
609
+ tmux_bin="$(command -v tmux || true)"
610
+ local session="${AIUR_SESSION_PREFIX}-${USER:-user}-default"
611
+ local socket="${AIUR_SESSION_PREFIX}-${USER:-user}"
612
+ if [ -n "$tmux_bin" ]; then
613
+ "$tmux_bin" -L "$socket" kill-session -t "$session" 2>/dev/null || true
614
+ fi
615
+
616
+ local beam_pids pid waited=0
617
+ beam_pids="$(pgrep -f "$release_dir/.*erts.*beam.smp" 2>/dev/null || true)"
618
+ if [ -n "$beam_pids" ]; then
619
+ for pid in $beam_pids; do kill -TERM "$pid" 2>/dev/null || true; done
620
+ while [ $waited -lt 30 ]; do
621
+ beam_pids="$(pgrep -f "$release_dir/.*erts.*beam.smp" 2>/dev/null || true)"
622
+ [ -z "$beam_pids" ] && break
623
+ sleep 0.1
624
+ waited=$((waited + 1))
625
+ done
626
+ beam_pids="$(pgrep -f "$release_dir/.*erts.*beam.smp" 2>/dev/null || true)"
627
+ for pid in $beam_pids; do kill -KILL "$pid" 2>/dev/null || true; done
628
+ fi
629
+
630
+ # Reap any BEAM holding our node name regardless of which release dir launched
631
+ # it — orphans from a dev build or a prior install share the unified name.
632
+ kill_beams_matching "-name ${AIUR_RELEASE_NODE}"
633
+
634
+ sweep_dead_tmux_sockets
635
+ }
636
+
637
+ # --- dispatch ----------------------------------------------------------------
638
+
639
+ aiur_engine_main() {
640
+ local cmd="${1:-}"
641
+ case "$cmd" in
642
+ __identity)
643
+ aiur_print_identity
644
+ ;;
645
+ help | -h | -help | --h | --help)
646
+ usage
647
+ ;;
648
+ --version)
649
+ run_version "$@"
650
+ ;;
651
+ init)
652
+ run_init "$@"
653
+ ;;
654
+ --bg)
655
+ shift
656
+ run_session background "$@"
657
+ ;;
658
+ run)
659
+ shift
660
+ run_session foreground "$@"
661
+ ;;
662
+ status)
663
+ shift
664
+ cmd_status "$@"
665
+ ;;
666
+ pause | resume)
667
+ shift
668
+ cmd_pause_resume "$cmd" "$@"
669
+ ;;
670
+ stop)
671
+ cmd_stop
672
+ ;;
673
+ "")
674
+ run_session foreground
675
+ ;;
676
+ -*)
677
+ # leading-flag forms (e.g. `aiur --interactive <config>`) are a run
678
+ run_session foreground "$@"
679
+ ;;
680
+ *)
681
+ # a path/config argument is a run; anything else is a usage error
682
+ if [ -e "$cmd" ]; then
683
+ run_session foreground "$@"
684
+ else
685
+ echo "aiur: unknown command: $cmd" >&2
686
+ usage >&2
687
+ exit 64
688
+ fi
689
+ ;;
690
+ esac
691
+ }
692
+
693
+ if [ "${BASH_SOURCE[0]}" = "${0}" ]; then
694
+ aiur_engine_main "$@"
695
+ fi
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aiur-cli",
3
- "version": "0.0.0",
3
+ "version": "0.0.1",
4
4
  "description": "AI Unit Runtime for Executors — placeholder, full release coming soon.",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://github.com/its-everdred/aiur",
@@ -11,10 +11,21 @@
11
11
  "bin": {
12
12
  "aiur": "bin/aiur.js"
13
13
  },
14
+ "scripts": {
15
+ "test": "bun test"
16
+ },
14
17
  "files": [
15
- "bin/aiur.js"
18
+ "bin/aiur.js",
19
+ "libexec/aiur-engine.sh",
20
+ "share/aiur.tmux.conf"
16
21
  ],
17
22
  "engines": {
18
23
  "node": ">=18"
24
+ },
25
+ "optionalDependencies": {
26
+ "aiur-cli-linux-x64": "0.0.1",
27
+ "aiur-cli-linux-arm64": "0.0.1",
28
+ "aiur-cli-darwin-x64": "0.0.1",
29
+ "aiur-cli-darwin-arm64": "0.0.1"
19
30
  }
20
31
  }
@@ -0,0 +1,76 @@
1
+ # Aiur's isolated tmux configuration.
2
+ #
3
+ # Loaded by `scripts/aiur` via `tmux -L aiur-$USER -f <path>` so it
4
+ # runs on its own socket and never touches the user's normal tmux server
5
+ # or `~/.tmux.conf`.
6
+ #
7
+ # To customize without touching this tracked file, copy it to
8
+ # `~/.config/aiur/tmux.conf` and edit there — that path takes
9
+ # precedence and survives `git pull`.
10
+
11
+ # No prefix — every Aiur binding is direct (`-n`).
12
+ set -g prefix None
13
+ unbind-key -aT prefix
14
+
15
+ set -sg escape-time 0
16
+ set -g mouse on
17
+ # Pass CSI-u extended-key sequences (e.g. Shift+Enter as `\e[13;2u`)
18
+ # through to the foreground pane instead of collapsing them. Required
19
+ # for the agent list pane's Shift+Enter "open in new pane" keybind.
20
+ set -s extended-keys on
21
+ set -as terminal-features "*:extkeys"
22
+ set -g status off
23
+ set -g history-limit 50000
24
+ set -g default-terminal "tmux-256color"
25
+ set -ga terminal-overrides ",*256col*:Tc"
26
+ set -g remain-on-exit off
27
+ set -g destroy-unattached off
28
+
29
+ # Pane navigation (cycle right / cycle left).
30
+ bind-key -n Tab select-pane -t :.+
31
+ bind-key -n BTab select-pane -t :.-
32
+
33
+ # Option+Tab from inside any chat pane jumps focus to pane 0 (the
34
+ # agent list, by convention — see the C-c handler below for the same
35
+ # pane-index assumption).
36
+ bind-key -n M-Tab select-pane -t :.0
37
+
38
+ # Close: pane 0 (the agent list, by convention) tears down the whole
39
+ # session; any other pane routes Ctrl+C through the opencode bridge
40
+ # helper, which asks Aiur for the 3-state action (drain a queued message,
41
+ # pause an idle agent, or close an already-paused pane). `@aiur_ctrlc` is
42
+ # the helper path (set by the launcher) and `@aiur_control_url` is the
43
+ # dashboard URL (published by Aiur once the server binds). When either is
44
+ # unset, or the helper isn't executable, Ctrl+C degrades to a plain pane
45
+ # close — the pre-bridge behaviour. `if-shell -F` is the synchronous
46
+ # format-string form so the condition is evaluated against tmux's format
47
+ # engine (the shell-mode `if-shell` has been buggy since tmux 2.4 —
48
+ # tmux/tmux#882).
49
+ set -gq @aiur_ctrlc ''
50
+ set -gq @aiur_control_url ''
51
+ bind-key -n C-c if-shell -F '#{==:#{pane_index},0}' \
52
+ 'kill-session' \
53
+ 'run-shell "h=\"#{@aiur_ctrlc}\"; if [ -x \"$h\" ]; then \"$h\" \"#{pane_id}\" \"#{@aiur_control_url}\"; else tmux kill-pane -t \"#{pane_id}\"; fi"'
54
+
55
+ # Pane layout is owned exclusively by `Aiur.PaneManager`, which applies an
56
+ # explicit `select-layout <string>` after every slot change. No
57
+ # `after-split-window` hook lives here — historically a hook ran
58
+ # `select-layout even-horizontal` to "rebalance widths", but it ran on every
59
+ # split (including PaneManager's anchored slot splits) and forced every pane
60
+ # into a single equal-width row, defeating the slot grid. See issue #34.
61
+
62
+ # Wheel routing:
63
+ # 1. If the foreground app set mouse mode (opencode-attach / any Bubble Tea
64
+ # TUI does), forward wheel events with `send-keys -M` so the app handles
65
+ # scroll inside its own viewport.
66
+ # 2. Else if tmux is already in copy mode (`pane_in_mode`), forward.
67
+ # 3. Else (plain shell pane, mobile SSH client doing touch-scroll) enter
68
+ # copy mode so the user can scroll the pane's scrollback buffer.
69
+ #
70
+ # Without (1), wheel up on an opencode chat pane would force tmux into copy
71
+ # mode against opencode's full-screen TUI, which has no tmux-side scrollback
72
+ # — the indicator just sticks at `[0/0]` and chat history can't be reached.
73
+ bind-key -n WheelUpPane if-shell -F -t = '#{?mouse_any_flag,1,0}' \
74
+ 'send-keys -M' \
75
+ "if-shell -F -t = '#{pane_in_mode}' 'send-keys -M' 'copy-mode -e -t ='"
76
+ bind-key -n WheelDownPane send-keys -M