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 +72 -0
- package/bin/aiur.js +245 -5
- package/libexec/aiur-engine.sh +695 -0
- package/package.json +17 -3
- package/scripts/postinstall.mjs +173 -0
- package/share/aiur.tmux.conf +76 -0
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
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
);
|
|
6
|
-
|
|
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.
|
|
4
|
-
"description": "AI Unit Runtime for Executors
|
|
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
|