aiur-cli 0.0.0 → 0.0.2

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,72 @@
1
+ # AIUR
2
+
3
+ **AI Unit Runtime for Executors**
4
+
5
+ Oversee a fleet of coding agents that **coordinate via events**.
6
+
7
+ > [!WARNING]
8
+ > **Use at your own risk.** Aiur is an unstable, vibecoded engineering preview for trusted
9
+ > environments only. It **bypasses all agent permission prompts** and has very few efficiency
10
+ > optimizations. Suggested for simple tasks under supervision.
11
+ >
12
+ > Provided "as is", without warranty of any kind. You assume all risk for any cost, token
13
+ > spend, data loss, or damage from running it.
14
+
15
+ ## Install
16
+
17
+ ```bash
18
+ npm install -g aiur-cli
19
+ ```
20
+
21
+ Requires [tmux](https://github.com/tmux/tmux) at runtime. Install
22
+ [opencode](https://opencode.ai) too for in-pane agent chat.
23
+
24
+ ## Get started
25
+
26
+ ```bash
27
+ cd your-repo
28
+ aiur init # interactive setup wizard
29
+ aiur # start agents (foreground); use `aiur --bg` to detach
30
+ ```
31
+
32
+ `aiur init` scaffolds `.aiurconfig` and provisions the repo. It walks you through:
33
+
34
+ 1. **Where to store config** — repo-local `./.aiurconfig` or global `~/.aiurconfig`.
35
+ 2. **Tracker** — GitHub or Linear, plus the repo.
36
+ 3. **Agents & routing** — Claude and/or Codex, optional per-complexity model
37
+ routing, and the permission mode.
38
+ 4. **Limits** — max concurrent agents, turns, duration, pre-warmed sessions, and
39
+ polling interval.
40
+ 5. **GitHub token** — to create labels and act as the bot account.
41
+ 6. **Labels** — the lifecycle (`agent:*`), complexity, model, and remote-control
42
+ labels aiur routes on. Only missing labels are created; a group that already
43
+ exists is reported as created and skipped.
44
+
45
+ Re-running resumes from your saved answers. When setup finishes, add `agent:todo`
46
+ to the issues you want worked and run `aiur`.
47
+
48
+ ## Commands
49
+
50
+ | Command | What it does |
51
+ |---|---|
52
+ | `aiur init` | Interactive setup wizard (scaffold `.aiurconfig`) |
53
+ | `aiur` | Start the workflow in the foreground (local-only bind) |
54
+ | `aiur --bg` | Start in a detached tmux session |
55
+ | `aiur status` | Show active agents and their state |
56
+ | `aiur pause <id…>` / `resume <id…>` | Pause or resume agents by issue ID |
57
+ | `aiur stop` | Stop the running session |
58
+
59
+ ## Features
60
+
61
+ - **Issue Monitoring** — Dispatch agents automatically via GitHub or Linear tickets.
62
+ - **Shared Bus** — Agents auto-pub/sub to dependency events, unblocking work early.
63
+ - **Optimize Effort** — Story points guide model sizes and skills. Supports Claude and Codex.
64
+ - **Take the Wheel** — Drive at any moment via pre-warmed [Opencode](https://opencode.ai) sessions in configurable tmux panes.
65
+ - **Smart Alerts** — Customize notifications and sounds when agents finish, stall, or need a decision.
66
+ - **Live Dashboard** — Shareable web view of every agent, event, and progress bar.
67
+
68
+ ---
69
+
70
+ _Command macro, delegate micro, maximize APM._
71
+
72
+ [github.com/its-everdred/aiur](https://github.com/its-everdred/aiur)
package/bin/aiur.js CHANGED
@@ -1,6 +1,246 @@
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
+ // Node's spawnSync reports a missing executable via `result.error`; some
9
+ // runtimes (e.g. Bun) throw instead. Normalize to the error-return shape so a
10
+ // missing tmux/opencode/brew degrades gracefully rather than crashing.
11
+ function safeSpawn(command, args, options) {
12
+ try {
13
+ return spawnSync(command, args, options);
14
+ } catch (error) {
15
+ return { error, status: null, stdout: "" };
16
+ }
17
+ }
18
+
19
+ // Map this host to the platform package that carries its OTP release.
20
+ const PLATFORMS = {
21
+ "darwin arm64": "darwin-arm64",
22
+ "darwin x64": "darwin-x64",
23
+ "linux arm64": "linux-arm64",
24
+ "linux x64": "linux-x64",
25
+ };
26
+
27
+ const MIN_TMUX = [3, 3];
28
+
29
+ function fail(message) {
30
+ process.stderr.write(`aiur: ${message}\n`);
31
+ process.exit(1);
32
+ }
33
+
34
+ function resolveReleaseDir() {
35
+ // A pre-set AIUR_RELEASE_DIR (the dev shim, or local `npm pack` install
36
+ // verification) wins over platform-package resolution.
37
+ const preset = process.env.AIUR_RELEASE_DIR;
38
+ if (preset) {
39
+ try {
40
+ if (fs.statSync(preset).isDirectory()) return preset;
41
+ } catch (_) {
42
+ // fall through to the explicit error below
43
+ }
44
+ fail(`AIUR_RELEASE_DIR is set but not a directory: ${preset}`);
45
+ }
46
+
47
+ const triple = PLATFORMS[`${process.platform} ${process.arch}`];
48
+ if (!triple) {
49
+ fail(
50
+ `unsupported platform ${process.platform}/${process.arch}. ` +
51
+ "Supported: darwin/linux on arm64/x64. " +
52
+ "See https://github.com/its-everdred/aiur",
53
+ );
54
+ }
55
+
56
+ const pkg = `aiur-cli-${triple}`;
57
+ let pkgJson;
58
+ try {
59
+ pkgJson = require.resolve(`${pkg}/package.json`);
60
+ } catch (err) {
61
+ if (err && err.code === "MODULE_NOT_FOUND") {
62
+ fail(
63
+ `platform package "${pkg}" is not installed. ` +
64
+ "This usually means optional dependencies were skipped " +
65
+ "(--no-optional, a shared lockfile, or a known npm install bug). " +
66
+ "Reinstall aiur-cli, or see https://github.com/its-everdred/aiur",
67
+ );
68
+ }
69
+ throw err;
70
+ }
71
+
72
+ return path.join(path.dirname(pkgJson), "release");
73
+ }
74
+
75
+ // Pinned opencode version — single source of truth is this package's
76
+ // package.json `opencodeVersion` (kept in sync with scripts/postinstall.mjs
77
+ // and mise.toml).
78
+ function opencodeVersion() {
79
+ const pkg = JSON.parse(
80
+ fs.readFileSync(path.join(__dirname, "..", "package.json"), "utf8"),
81
+ );
82
+ return pkg.opencodeVersion;
83
+ }
84
+
85
+ // ---------------------------------------------------------------------------
86
+ // Lazy first-run provisioning
87
+ //
88
+ // The npm postinstall (scripts/postinstall.mjs) provisions tmux + opencode, but
89
+ // bun/pnpm/yarn block postinstall scripts by default — a package cannot force a
90
+ // consumer to run them. So the launcher guarantees the peer tools here, on the
91
+ // first real (TUI) launch, regardless of which package manager installed
92
+ // aiur-cli. Both steps are idempotent (skip when already satisfied); tmux is a
93
+ // hard requirement, opencode is best-effort. Honors the same AIUR_SKIP_* env
94
+ // vars as the postinstall. This logic mirrors postinstall.mjs intentionally —
95
+ // the launcher stays dependency-free and cannot import the .mjs counterpart.
96
+ // ---------------------------------------------------------------------------
97
+
98
+ function tmuxVersion() {
99
+ const result = safeSpawn("tmux", ["-V"], { encoding: "utf8" });
100
+ if (result.error || result.status !== 0) return null;
101
+ const match = /tmux\s+(\d+)\.(\d+)/.exec(result.stdout || "");
102
+ if (!match) return null;
103
+ return [Number(match[1]), Number(match[2])];
104
+ }
105
+
106
+ function tmuxSatisfiesMin() {
107
+ const version = tmuxVersion();
108
+ if (!version) return false;
109
+ const [major, minor] = version;
110
+ const [needMajor, needMinor] = MIN_TMUX;
111
+ return major > needMajor || (major === needMajor && minor >= needMinor);
112
+ }
113
+
114
+ function tmuxHint() {
115
+ return process.platform === "darwin"
116
+ ? "install it with: brew install tmux"
117
+ : "install it with: apt install tmux (or your distro's package manager)";
118
+ }
119
+
120
+ // Best-effort system install of tmux. macOS uses Homebrew; Linux uses the first
121
+ // package manager present. Never invokes sudo (a hidden password prompt mid-
122
+ // launch would hang). Returns false so the caller can fail with a clear hint.
123
+ function installTmux(platform) {
124
+ const has = (bin) => !safeSpawn(bin, ["--version"], { stdio: "ignore" }).error;
125
+ if (platform === "darwin") {
126
+ if (!has("brew")) return false;
127
+ return safeSpawn("brew", ["install", "tmux"], { stdio: "inherit" }).status === 0;
128
+ }
129
+ if (platform === "linux") {
130
+ const managers = [
131
+ ["apt-get", ["install", "-y", "tmux"]],
132
+ ["dnf", ["install", "-y", "tmux"]],
133
+ ["apk", ["add", "tmux"]],
134
+ ["pacman", ["-S", "--noconfirm", "tmux"]],
135
+ ];
136
+ for (const [bin, argv] of managers) {
137
+ if (!has(bin)) continue;
138
+ return safeSpawn(bin, argv, { stdio: "inherit" }).status === 0;
139
+ }
140
+ }
141
+ return false;
142
+ }
143
+
144
+ // tmux is required — the TUI runs inside it — so this provisions if missing and
145
+ // fails hard only when tmux still can't be made available.
146
+ function ensureTmux() {
147
+ if (tmuxSatisfiesMin()) return;
148
+ if (process.env.AIUR_SKIP_TMUX_INSTALL !== "1") {
149
+ process.stderr.write(
150
+ `aiur: tmux (>= ${MIN_TMUX.join(".")}) runs the interactive TUI; installing it…\n`,
151
+ );
152
+ installTmux(process.platform);
153
+ }
154
+ if (!tmuxSatisfiesMin()) {
155
+ const v = tmuxVersion();
156
+ fail(
157
+ v
158
+ ? `tmux ${v[0]}.${v[1]} is too old; aiur needs >= ${MIN_TMUX.join(".")}. ${tmuxHint()}`
159
+ : `tmux is required but was not found on PATH. ${tmuxHint()}`,
160
+ );
161
+ }
162
+ }
163
+
164
+ // True only when the pinned opencode is on PATH; a wrong version (e.g. 1.17.x,
165
+ // which broke the panes) must be replaced, so presence alone isn't enough.
166
+ function opencodeMatchesPin() {
167
+ const result = safeSpawn("opencode", ["--version"], { encoding: "utf8" });
168
+ return !result.error && result.status === 0 && (result.stdout || "").trim() === opencodeVersion();
169
+ }
170
+
171
+ // Install the pinned opencode globally. Forces online so it never inherits an
172
+ // --offline / prefer-offline context (CI, mirrors, air-gapped) that would fail
173
+ // on a cold cache — mirrors scripts/postinstall.mjs:installOpencode.
174
+ function installOpencode() {
175
+ const env = { ...process.env };
176
+ delete env.npm_config_offline;
177
+ delete env.npm_config_prefer_offline;
178
+ const result = safeSpawn(
179
+ "npm",
180
+ ["install", "-g", `opencode-ai@${opencodeVersion()}`, "--no-offline", "--no-prefer-offline"],
181
+ { stdio: "inherit", env },
182
+ );
183
+ return !result.error && result.status === 0;
184
+ }
185
+
186
+ // opencode powers the interactive "take the wheel" panes. Non-fatal: everything
187
+ // else works without it.
188
+ function ensureOpencode() {
189
+ if (opencodeMatchesPin()) return;
190
+ if (process.env.AIUR_SKIP_OPENCODE_INSTALL !== "1") {
191
+ process.stderr.write(
192
+ `aiur: provisioning opencode@${opencodeVersion()} (one-time, for the interactive 'take the wheel' panes)…\n`,
193
+ );
194
+ installOpencode();
195
+ if (opencodeMatchesPin()) return;
196
+ }
197
+ process.stderr.write(
198
+ "aiur: opencode was not found on PATH. The interactive 'take the wheel' " +
199
+ "feature needs it; everything else works without it. " +
200
+ "Install from https://opencode.ai\n",
201
+ );
202
+ }
203
+
204
+ // `init` and `--version` run as foreground one-shots that never start the
205
+ // tmux-backed UI, so their tmux/opencode provisioning is irrelevant — and tmux
206
+ // may legitimately be absent on a machine that only ever runs `aiur init`.
207
+ function isForegroundOneShot(argv) {
208
+ for (const arg of argv) {
209
+ if (arg === "--version") return true;
210
+ if (arg.startsWith("-")) continue;
211
+ return arg === "init";
212
+ }
213
+ return false;
214
+ }
215
+
216
+ function main() {
217
+ const releaseDir = resolveReleaseDir();
218
+
219
+ if (!isForegroundOneShot(process.argv.slice(2))) {
220
+ ensureTmux();
221
+ ensureOpencode();
222
+ }
223
+
224
+ const pkgRoot = path.resolve(__dirname, "..");
225
+ const launcher = path.join(pkgRoot, "libexec", "aiur-engine.sh");
226
+
227
+ const result = spawnSync(launcher, process.argv.slice(2), {
228
+ stdio: "inherit",
229
+ env: {
230
+ ...process.env,
231
+ AIUR_RELEASE_DIR: releaseDir,
232
+ AIUR_TMUX_CONF: path.join(pkgRoot, "share", "aiur.tmux.conf"),
233
+ },
234
+ });
235
+
236
+ if (result.error) {
237
+ fail(`failed to launch ${launcher}: ${result.error.message}`);
238
+ }
239
+ if (result.signal) {
240
+ process.kill(process.pid, result.signal);
241
+ return;
242
+ }
243
+ process.exit(result.status === null ? 1 : result.status);
244
+ }
245
+
246
+ 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,7 +1,7 @@
1
1
  {
2
2
  "name": "aiur-cli",
3
- "version": "0.0.0",
4
- "description": "AI Unit Runtime for Executors — placeholder, full release coming soon.",
3
+ "version": "0.0.2",
4
+ "description": "AI Unit Runtime for Executors.",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://github.com/its-everdred/aiur",
7
7
  "repository": {
@@ -11,10 +11,24 @@
11
11
  "bin": {
12
12
  "aiur": "bin/aiur.js"
13
13
  },
14
+ "scripts": {
15
+ "postinstall": "node scripts/postinstall.mjs",
16
+ "test": "bun test"
17
+ },
14
18
  "files": [
15
- "bin/aiur.js"
19
+ "bin/aiur.js",
20
+ "libexec/aiur-engine.sh",
21
+ "scripts/postinstall.mjs",
22
+ "share/aiur.tmux.conf"
16
23
  ],
17
24
  "engines": {
18
25
  "node": ">=18"
26
+ },
27
+ "opencodeVersion": "1.15.6",
28
+ "optionalDependencies": {
29
+ "aiur-cli-linux-x64": "0.0.2",
30
+ "aiur-cli-linux-arm64": "0.0.2",
31
+ "aiur-cli-darwin-x64": "0.0.2",
32
+ "aiur-cli-darwin-arm64": "0.0.2"
19
33
  }
20
34
  }
@@ -0,0 +1,173 @@
1
+ #!/usr/bin/env node
2
+ // Provisions the peer tools every aiur user needs at runtime, so that a single
3
+ // global `npm install -g aiur-cli` yields a working install with nothing left
4
+ // to install by hand:
5
+ //
6
+ // - tmux the multiplexer the interactive TUI runs inside. aiur refuses
7
+ // to launch without tmux >= MIN_TMUX, so it is a hard runtime
8
+ // requirement (not merely nice-to-have).
9
+ // - opencode the peer CLI behind the interactive "take the wheel" panes.
10
+ //
11
+ // A global aiur-cli install does not link a dependency's bin onto PATH, and
12
+ // tmux is a system package rather than an npm one, so we provision both here.
13
+ // Every step is idempotent (skips when already satisfied) and non-fatal (a
14
+ // failure prints a hint but never breaks the aiur install).
15
+ import { spawnSync } from "node:child_process";
16
+ import { fileURLToPath } from "node:url";
17
+ import { readFileSync } from "node:fs";
18
+ import path from "node:path";
19
+
20
+ // ---------------------------------------------------------------------------
21
+ // opencode
22
+ // ---------------------------------------------------------------------------
23
+
24
+ // Pinned opencode version, read from package.json `opencodeVersion` (single
25
+ // source of truth — keep in sync with mise.toml). aiur's opencode pane
26
+ // integration is validated against this version; 1.17.x broke custom-provider
27
+ // model resolution (ProviderModelNotFoundError), blanking the chat panes.
28
+ const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
29
+ export const OPENCODE_PACKAGE = `opencode-ai@${pkg.opencodeVersion}`;
30
+
31
+ // True only when the *pinned* opencode version is on PATH. A wrong (e.g.
32
+ // newer) version must be reinstalled — leaving 1.17.x in place keeps the
33
+ // panes broken — so a mere presence check isn't enough.
34
+ export function opencodeMatchesPin() {
35
+ const result = spawnSync("opencode", ["--version"], { encoding: "utf8" });
36
+ return !result.error && result.status === 0 && result.stdout.trim() === pkg.opencodeVersion;
37
+ }
38
+
39
+ // Build the nested-install command. Provisioning fetches opencode from the
40
+ // registry, so it must NOT inherit an offline context from the outer
41
+ // `npm install` that triggered this postinstall — CI's `npm install -g
42
+ // --offline`, a `prefer-offline` config, or an air-gapped box. npm hands those
43
+ // to lifecycle scripts as npm_config_* env vars; a naive nested install
44
+ // inherits them and dies with ENOTCACHED on a cold cache, silently leaving
45
+ // opencode unprovisioned. We force online two ways (CLI flags outrank env, and
46
+ // stripping the env vars is belt-and-suspenders) so a plain `npm install -g
47
+ // aiur-cli` provisions opencode regardless of how the parent was invoked.
48
+ export function opencodeInstallSpec(env = process.env) {
49
+ const childEnv = { ...env };
50
+ delete childEnv.npm_config_offline;
51
+ delete childEnv.npm_config_prefer_offline;
52
+ return {
53
+ command: "npm",
54
+ args: ["install", "-g", OPENCODE_PACKAGE, "--no-offline", "--no-prefer-offline"],
55
+ env: childEnv,
56
+ };
57
+ }
58
+
59
+ export function installOpencode() {
60
+ const spec = opencodeInstallSpec();
61
+ const result = spawnSync(spec.command, spec.args, { stdio: "inherit", env: spec.env });
62
+ return !result.error && result.status === 0;
63
+ }
64
+
65
+ export function provisionOpencode({
66
+ env = process.env,
67
+ isPresent = opencodeMatchesPin,
68
+ install = installOpencode,
69
+ log = (message) => process.stderr.write(message + "\n"),
70
+ } = {}) {
71
+ if (env.AIUR_SKIP_OPENCODE_INSTALL === "1") return "skipped:disabled";
72
+ if (isPresent()) return "skipped:present";
73
+
74
+ log(`aiur: installing pinned opencode (${OPENCODE_PACKAGE}) for the interactive 'take the wheel' feature…`);
75
+
76
+ if (install()) return "installed";
77
+
78
+ log(
79
+ "aiur: couldn't install opencode automatically. Install it manually: " +
80
+ "npm install -g opencode-ai (see https://opencode.ai)",
81
+ );
82
+ return "failed";
83
+ }
84
+
85
+ // ---------------------------------------------------------------------------
86
+ // tmux
87
+ // ---------------------------------------------------------------------------
88
+
89
+ // Minimum tmux the TUI needs. Keep in sync with the runtime preflight in
90
+ // bin/aiur.js — that preflight stays as a safety net, this just front-loads
91
+ // the install so the first `aiur` run isn't a dead end.
92
+ export const MIN_TMUX = [3, 3];
93
+
94
+ function tmuxHint(platform = process.platform) {
95
+ return platform === "darwin"
96
+ ? "install it with: brew install tmux"
97
+ : "install it with: apt install tmux (or your distro's package manager)";
98
+ }
99
+
100
+ // True only when a new-enough tmux is on PATH. The TUI won't launch with an
101
+ // older tmux, so — as with opencode's pin — presence alone isn't enough.
102
+ export function tmuxSatisfiesMin(run = (cmd, argv) => spawnSync(cmd, argv, { encoding: "utf8" })) {
103
+ const result = run("tmux", ["-V"]);
104
+ if (result.error || result.status !== 0) return false;
105
+ const match = /tmux\s+(\d+)\.(\d+)/.exec(result.stdout || "");
106
+ if (!match) return false;
107
+ const major = Number(match[1]);
108
+ const minor = Number(match[2]);
109
+ const [needMajor, needMinor] = MIN_TMUX;
110
+ return major > needMajor || (major === needMajor && minor >= needMinor);
111
+ }
112
+
113
+ // Best-effort system install of tmux. macOS uses Homebrew; Linux uses the
114
+ // first package manager that is present. Returns false (so the caller can
115
+ // print a manual hint) when no usable installer exists or the install fails —
116
+ // including the common non-root case where a Linux package manager can't
117
+ // proceed. We never invoke sudo: a hidden password prompt during `npm install`
118
+ // would hang the install.
119
+ export function installTmux(platform = process.platform) {
120
+ const has = (bin) => !spawnSync(bin, ["--version"], { stdio: "ignore" }).error;
121
+
122
+ if (platform === "darwin") {
123
+ if (!has("brew")) return false;
124
+ return spawnSync("brew", ["install", "tmux"], { stdio: "inherit" }).status === 0;
125
+ }
126
+
127
+ if (platform === "linux") {
128
+ const managers = [
129
+ ["apt-get", ["install", "-y", "tmux"]],
130
+ ["dnf", ["install", "-y", "tmux"]],
131
+ ["apk", ["add", "tmux"]],
132
+ ["pacman", ["-S", "--noconfirm", "tmux"]],
133
+ ];
134
+ for (const [bin, argv] of managers) {
135
+ if (!has(bin)) continue;
136
+ return spawnSync(bin, argv, { stdio: "inherit" }).status === 0;
137
+ }
138
+ }
139
+
140
+ return false;
141
+ }
142
+
143
+ export function provisionTmux({
144
+ env = process.env,
145
+ isPresent = tmuxSatisfiesMin,
146
+ install = installTmux,
147
+ platform = process.platform,
148
+ log = (message) => process.stderr.write(message + "\n"),
149
+ } = {}) {
150
+ if (env.AIUR_SKIP_TMUX_INSTALL === "1") return "skipped:disabled";
151
+ if (isPresent()) return "skipped:present";
152
+
153
+ log(
154
+ `aiur: tmux (>= ${MIN_TMUX.join(".")}) not found; aiur's interactive TUI runs inside tmux. Installing…`,
155
+ );
156
+
157
+ if (install(platform)) return "installed";
158
+
159
+ log(`aiur: couldn't install tmux automatically. ${tmuxHint(platform)}`);
160
+ return "failed";
161
+ }
162
+
163
+ // ---------------------------------------------------------------------------
164
+ // Entry point
165
+ // ---------------------------------------------------------------------------
166
+
167
+ // Run only when invoked directly as the postinstall step, not when a test
168
+ // imports the helpers above. tmux is provisioned first — it is the hard
169
+ // requirement — then opencode for the optional panes.
170
+ if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
171
+ provisionTmux();
172
+ provisionOpencode();
173
+ }
@@ -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