aiur-cli 0.0.2 → 0.0.3
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 +12 -1
- package/bin/aiur.js +232 -1
- package/libexec/aiur-engine.sh +688 -68
- package/package.json +5 -5
- package/share/aiur.tmux.conf +10 -0
package/README.md
CHANGED
|
@@ -21,6 +21,11 @@ npm install -g aiur-cli
|
|
|
21
21
|
Requires [tmux](https://github.com/tmux/tmux) at runtime. Install
|
|
22
22
|
[opencode](https://opencode.ai) too for in-pane agent chat.
|
|
23
23
|
|
|
24
|
+
When a newer `aiur-cli` is published, a one-line update notice is printed to
|
|
25
|
+
stderr (checked in the background at most once a day, never blocking your
|
|
26
|
+
command). Set `AIUR_NO_UPDATE_NOTIFIER=1` to silence it; it is also skipped under
|
|
27
|
+
CI (`CI=true`).
|
|
28
|
+
|
|
24
29
|
## Get started
|
|
25
30
|
|
|
26
31
|
```bash
|
|
@@ -51,7 +56,7 @@ to the issues you want worked and run `aiur`.
|
|
|
51
56
|
|---|---|
|
|
52
57
|
| `aiur init` | Interactive setup wizard (scaffold `.aiurconfig`) |
|
|
53
58
|
| `aiur` | Start the workflow in the foreground (local-only bind) |
|
|
54
|
-
| `aiur --bg` | Start in
|
|
59
|
+
| `aiur --bg` | Start a headless BEAM in one detached tmux lifetime session |
|
|
55
60
|
| `aiur status` | Show active agents and their state |
|
|
56
61
|
| `aiur pause <id…>` / `resume <id…>` | Pause or resume agents by issue ID |
|
|
57
62
|
| `aiur stop` | Stop the running session |
|
|
@@ -65,6 +70,12 @@ to the issues you want worked and run `aiur`.
|
|
|
65
70
|
- **Smart Alerts** — Customize notifications and sounds when agents finish, stall, or need a decision.
|
|
66
71
|
- **Live Dashboard** — Shareable web view of every agent, event, and progress bar.
|
|
67
72
|
|
|
73
|
+
Background mode is headless inside Aiur: it skips the interactive agent-list
|
|
74
|
+
pane, chat/prewarm panes, and dashboard unless you explicitly opt into a
|
|
75
|
+
dashboard port. The launcher still keeps one detached tmux session as the BEAM
|
|
76
|
+
lifetime holder. Re-running `aiur --bg` against a live session exits with an
|
|
77
|
+
already-running hint; stale tmux state is cleaned up before restart.
|
|
78
|
+
|
|
68
79
|
---
|
|
69
80
|
|
|
70
81
|
_Command macro, delegate micro, maximize APM._
|
package/bin/aiur.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
"use strict";
|
|
3
3
|
|
|
4
|
-
const { spawnSync } = require("node:child_process");
|
|
4
|
+
const { spawnSync, spawn } = require("node:child_process");
|
|
5
5
|
const path = require("node:path");
|
|
6
6
|
const fs = require("node:fs");
|
|
7
|
+
const os = require("node:os");
|
|
7
8
|
|
|
8
9
|
// Node's spawnSync reports a missing executable via `result.error`; some
|
|
9
10
|
// runtimes (e.g. Bun) throw instead. Normalize to the error-return shape so a
|
|
@@ -201,6 +202,228 @@ function ensureOpencode() {
|
|
|
201
202
|
);
|
|
202
203
|
}
|
|
203
204
|
|
|
205
|
+
// ---------------------------------------------------------------------------
|
|
206
|
+
// Update notifier
|
|
207
|
+
//
|
|
208
|
+
// When a newer aiur-cli is published, surface a one-line "update available"
|
|
209
|
+
// notice — npm-update-notifier style: every run reads a small cache file and
|
|
210
|
+
// prints from it; the registry is only hit in a detached background worker,
|
|
211
|
+
// at most once per interval, so the check never blocks or errors the command.
|
|
212
|
+
// The notice always goes to stderr so piped stdout stays clean.
|
|
213
|
+
//
|
|
214
|
+
// This file is its own background worker: main() re-spawns it with
|
|
215
|
+
// AIUR_UPDATE_NOTIFIER_WORKER=1 set, which short-circuits to runUpdateWorker().
|
|
216
|
+
// Keeps the launcher dependency-free (a tiny semver compare + https.get).
|
|
217
|
+
// ---------------------------------------------------------------------------
|
|
218
|
+
|
|
219
|
+
const UPDATE_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24h
|
|
220
|
+
const REGISTRY_LATEST_URL = "https://registry.npmjs.org/aiur-cli/latest";
|
|
221
|
+
// Unpublished/source builds carry this sentinel; never nag on a dev checkout.
|
|
222
|
+
const DEV_VERSION = "0.0.0";
|
|
223
|
+
|
|
224
|
+
function currentVersion() {
|
|
225
|
+
try {
|
|
226
|
+
const pkg = JSON.parse(
|
|
227
|
+
fs.readFileSync(path.join(__dirname, "..", "package.json"), "utf8"),
|
|
228
|
+
);
|
|
229
|
+
return typeof pkg.version === "string" ? pkg.version : null;
|
|
230
|
+
} catch (_) {
|
|
231
|
+
return null;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Minimal semver: parse major.minor.patch with an optional prerelease tag.
|
|
236
|
+
// Enough for the launcher's own clean release versions — not a full spec impl.
|
|
237
|
+
// The regex is fully anchored (and tolerates a +build suffix) so a registry- or
|
|
238
|
+
// cache-supplied string with trailing junk — e.g. control/ANSI bytes meant to
|
|
239
|
+
// hijack the terminal when the notice is printed — fails to parse and is treated
|
|
240
|
+
// as not-newer rather than reaching printUpdateNotice verbatim.
|
|
241
|
+
function parseSemver(value) {
|
|
242
|
+
const match = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/.exec(
|
|
243
|
+
String(value).trim(),
|
|
244
|
+
);
|
|
245
|
+
if (!match) return null;
|
|
246
|
+
return { major: +match[1], minor: +match[2], patch: +match[3], pre: match[4] || "" };
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// True when `a` is strictly newer than `b`. A release outranks a prerelease at
|
|
250
|
+
// the same core version (1.0.0 > 1.0.0-rc.1); unparseable input is never newer.
|
|
251
|
+
function semverGt(a, b) {
|
|
252
|
+
const pa = parseSemver(a);
|
|
253
|
+
const pb = parseSemver(b);
|
|
254
|
+
if (!pa || !pb) return false;
|
|
255
|
+
if (pa.major !== pb.major) return pa.major > pb.major;
|
|
256
|
+
if (pa.minor !== pb.minor) return pa.minor > pb.minor;
|
|
257
|
+
if (pa.patch !== pb.patch) return pa.patch > pb.patch;
|
|
258
|
+
if (pa.pre === pb.pre) return false;
|
|
259
|
+
if (!pa.pre) return true; // a is a release, b is a prerelease
|
|
260
|
+
if (!pb.pre) return false; // a is a prerelease, b is a release
|
|
261
|
+
return pa.pre > pb.pre; // both prereleases: lexical tiebreak
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function cacheFile() {
|
|
265
|
+
const base = process.env.XDG_CACHE_HOME
|
|
266
|
+
? path.join(process.env.XDG_CACHE_HOME, "aiur")
|
|
267
|
+
: path.join(os.homedir(), ".aiur");
|
|
268
|
+
return path.join(base, "update-check.json");
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function readCache() {
|
|
272
|
+
try {
|
|
273
|
+
return JSON.parse(fs.readFileSync(cacheFile(), "utf8"));
|
|
274
|
+
} catch (_) {
|
|
275
|
+
return null;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function writeCache(data) {
|
|
280
|
+
try {
|
|
281
|
+
const file = cacheFile();
|
|
282
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
283
|
+
// Write-then-rename so a reader never sees a half-written file: if the
|
|
284
|
+
// detached worker is killed mid-write, or two workers race, the rename is
|
|
285
|
+
// atomic on POSIX and a torn cache can't trigger a respawn-every-run loop.
|
|
286
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
287
|
+
fs.writeFileSync(tmp, JSON.stringify(data));
|
|
288
|
+
fs.renameSync(tmp, file);
|
|
289
|
+
} catch (_) {
|
|
290
|
+
// A missing-cache write must never break the run; silently skip.
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// Treat any non-falsy CI value except an explicit "false"/"0" as a CI run.
|
|
295
|
+
function isCI() {
|
|
296
|
+
const value = process.env.CI;
|
|
297
|
+
return !!value && value !== "false" && value !== "0";
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function notifierDisabled(current) {
|
|
301
|
+
return (
|
|
302
|
+
process.env.AIUR_NO_UPDATE_NOTIFIER === "1" ||
|
|
303
|
+
isCI() ||
|
|
304
|
+
!current ||
|
|
305
|
+
current === DEV_VERSION
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function printUpdateNotice(current, latest) {
|
|
310
|
+
process.stderr.write(
|
|
311
|
+
`aiur: a new version is available — ${current} → ${latest}\n` +
|
|
312
|
+
" update: npm install -g aiur-cli@latest\n",
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// Synchronous, fast, never-throwing: print from cache when a newer version is
|
|
317
|
+
// known, then kick off a detached refresh if the cache is stale. The notice is
|
|
318
|
+
// TTY-gated so non-interactive/piped invocations stay quiet.
|
|
319
|
+
function maybeNotifyUpdate() {
|
|
320
|
+
try {
|
|
321
|
+
const current = currentVersion();
|
|
322
|
+
if (notifierDisabled(current)) return;
|
|
323
|
+
|
|
324
|
+
const cache = readCache();
|
|
325
|
+
if (
|
|
326
|
+
cache &&
|
|
327
|
+
cache.latest &&
|
|
328
|
+
process.stderr.isTTY &&
|
|
329
|
+
semverGt(cache.latest, current)
|
|
330
|
+
) {
|
|
331
|
+
printUpdateNotice(current, cache.latest);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
const stale =
|
|
335
|
+
!cache ||
|
|
336
|
+
typeof cache.lastCheck !== "number" ||
|
|
337
|
+
Date.now() - cache.lastCheck > UPDATE_CHECK_INTERVAL_MS;
|
|
338
|
+
if (stale) spawnUpdateWorker();
|
|
339
|
+
} catch (_) {
|
|
340
|
+
// The version check is best-effort; never let it affect the command.
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function spawnUpdateWorker() {
|
|
345
|
+
try {
|
|
346
|
+
const child = spawn(process.execPath, [__filename], {
|
|
347
|
+
detached: true,
|
|
348
|
+
stdio: "ignore",
|
|
349
|
+
env: { ...process.env, AIUR_UPDATE_NOTIFIER_WORKER: "1" },
|
|
350
|
+
});
|
|
351
|
+
child.unref();
|
|
352
|
+
} catch (_) {
|
|
353
|
+
// If we can't detach a worker, just skip — the next run retries.
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// Fetch the `latest` dist-tag's version. Bounded by a timeout and a response
|
|
358
|
+
// cap; any error resolves to null so the worker stays silent when offline.
|
|
359
|
+
function fetchLatestVersion(url, done) {
|
|
360
|
+
let settled = false;
|
|
361
|
+
const finish = (value) => {
|
|
362
|
+
if (settled) return;
|
|
363
|
+
settled = true;
|
|
364
|
+
done(value);
|
|
365
|
+
};
|
|
366
|
+
try {
|
|
367
|
+
const transport = url.startsWith("http://") ? require("node:http") : require("node:https");
|
|
368
|
+
const req = transport.get(
|
|
369
|
+
url,
|
|
370
|
+
{ headers: { Accept: "application/json" }, timeout: 5000 },
|
|
371
|
+
(res) => {
|
|
372
|
+
if (res.statusCode !== 200) {
|
|
373
|
+
res.resume();
|
|
374
|
+
return finish(null);
|
|
375
|
+
}
|
|
376
|
+
let body = "";
|
|
377
|
+
res.setEncoding("utf8");
|
|
378
|
+
res.on("data", (chunk) => {
|
|
379
|
+
body += chunk;
|
|
380
|
+
// Abort an oversized body. req.destroy() emits only "close" (no "end"
|
|
381
|
+
// or "error"), so resolve here explicitly — otherwise the worker would
|
|
382
|
+
// hang forever and never stamp the cache, respawning on every run.
|
|
383
|
+
if (body.length > 1_000_000) {
|
|
384
|
+
req.destroy();
|
|
385
|
+
finish(null);
|
|
386
|
+
}
|
|
387
|
+
});
|
|
388
|
+
res.on("end", () => {
|
|
389
|
+
try {
|
|
390
|
+
const version = JSON.parse(body).version;
|
|
391
|
+
finish(typeof version === "string" ? version : null);
|
|
392
|
+
} catch (_) {
|
|
393
|
+
finish(null);
|
|
394
|
+
}
|
|
395
|
+
});
|
|
396
|
+
},
|
|
397
|
+
);
|
|
398
|
+
// A timeout's req.destroy() likewise emits only "close", so resolve here too
|
|
399
|
+
// rather than waiting on an event that never comes. "error" covers the
|
|
400
|
+
// connection-failure case (offline, refused).
|
|
401
|
+
req.on("timeout", () => {
|
|
402
|
+
req.destroy();
|
|
403
|
+
finish(null);
|
|
404
|
+
});
|
|
405
|
+
req.on("error", () => finish(null));
|
|
406
|
+
} catch (_) {
|
|
407
|
+
finish(null);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// Detached worker entrypoint: refresh the cache, then exit. Always stamps
|
|
412
|
+
// lastCheck (even on failure) so an offline machine doesn't respawn every run.
|
|
413
|
+
function runUpdateWorker() {
|
|
414
|
+
const previous = readCache();
|
|
415
|
+
const url = process.env.AIUR_REGISTRY_URL || REGISTRY_LATEST_URL;
|
|
416
|
+
fetchLatestVersion(url, (latest) => {
|
|
417
|
+
writeCache({
|
|
418
|
+
lastCheck: Date.now(),
|
|
419
|
+
// Keep the last known `latest` when the fetch fails so a transient outage
|
|
420
|
+
// doesn't drop a pending notice.
|
|
421
|
+
latest: latest || (previous && previous.latest) || null,
|
|
422
|
+
});
|
|
423
|
+
process.exit(0);
|
|
424
|
+
});
|
|
425
|
+
}
|
|
426
|
+
|
|
204
427
|
// `init` and `--version` run as foreground one-shots that never start the
|
|
205
428
|
// tmux-backed UI, so their tmux/opencode provisioning is irrelevant — and tmux
|
|
206
429
|
// may legitimately be absent on a machine that only ever runs `aiur init`.
|
|
@@ -214,6 +437,14 @@ function isForegroundOneShot(argv) {
|
|
|
214
437
|
}
|
|
215
438
|
|
|
216
439
|
function main() {
|
|
440
|
+
// Detached refresh worker: do only the registry check, never the real launch.
|
|
441
|
+
if (process.env.AIUR_UPDATE_NOTIFIER_WORKER === "1") {
|
|
442
|
+
runUpdateWorker();
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
maybeNotifyUpdate();
|
|
447
|
+
|
|
217
448
|
const releaseDir = resolveReleaseDir();
|
|
218
449
|
|
|
219
450
|
if (!isForegroundOneShot(process.argv.slice(2))) {
|
package/libexec/aiur-engine.sh
CHANGED
|
@@ -23,6 +23,18 @@
|
|
|
23
23
|
|
|
24
24
|
set -euo pipefail
|
|
25
25
|
|
|
26
|
+
# Raise the soft open-file limit toward the hard maximum. High agent concurrency
|
|
27
|
+
# spawns many tmux/opencode/git subprocesses + sockets; on hosts with a low
|
|
28
|
+
# default (macOS ships 256) that exhausts file descriptors (:emfile) and crashes
|
|
29
|
+
# the node. Soft<=hard needs no privilege; best-effort, never fatal.
|
|
30
|
+
__aiur_hard_nofile="$(ulimit -Hn 2>/dev/null || echo)"
|
|
31
|
+
if [ "${__aiur_hard_nofile}" = "unlimited" ]; then
|
|
32
|
+
ulimit -Sn 65536 2>/dev/null || true
|
|
33
|
+
elif [ -n "${__aiur_hard_nofile}" ]; then
|
|
34
|
+
ulimit -Sn "${__aiur_hard_nofile}" 2>/dev/null || true
|
|
35
|
+
fi
|
|
36
|
+
unset __aiur_hard_nofile
|
|
37
|
+
|
|
26
38
|
die() {
|
|
27
39
|
echo "❌ $*" >&2
|
|
28
40
|
exit 1
|
|
@@ -30,7 +42,35 @@ die() {
|
|
|
30
42
|
|
|
31
43
|
engine_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
32
44
|
|
|
33
|
-
# --- distribution identity (
|
|
45
|
+
# --- distribution identity (per-instance: keyed by the aiur project root) -----
|
|
46
|
+
|
|
47
|
+
# The aiur project root (dir holding the active config). AIUR_REPO_ROOT (set by
|
|
48
|
+
# the dev shim) wins; otherwise walk up from $PWD. Empty when run outside a project.
|
|
49
|
+
aiur_project_root() {
|
|
50
|
+
if [ -n "${AIUR_REPO_ROOT:-}" ]; then printf '%s' "$AIUR_REPO_ROOT"; return; fi
|
|
51
|
+
local d="$PWD"
|
|
52
|
+
while [ -n "$d" ] && [ "$d" != "/" ]; do
|
|
53
|
+
if [ -f "$d/.aiur/config" ] || [ -f "$d/.aiurconfig" ]; then
|
|
54
|
+
printf '%s' "$d"
|
|
55
|
+
return
|
|
56
|
+
fi
|
|
57
|
+
d="$(dirname "$d")"
|
|
58
|
+
done
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
# Short, stable, node-name-legal (lowercase hex) key for the project root, so two
|
|
62
|
+
# aiur instances for the same user get distinct node/session/socket names and can't
|
|
63
|
+
# reap each other. Empty when no project resolves (names fall back to the legacy form).
|
|
64
|
+
aiur_instance_key() {
|
|
65
|
+
local root
|
|
66
|
+
root="$(aiur_project_root)"
|
|
67
|
+
[ -n "$root" ] || return 0
|
|
68
|
+
# Canonicalize (resolve symlinks) so launch and control commands invoked via
|
|
69
|
+
# different logical paths to the same project agree on the key. Fall back to the
|
|
70
|
+
# literal path when the dir doesn't exist (e.g. a test-fixture root).
|
|
71
|
+
root="$(cd "$root" 2>/dev/null && pwd -P || printf '%s' "$root")"
|
|
72
|
+
printf '%s' "$root" | { shasum -a 256 2>/dev/null || sha256sum; } | cut -c1-10
|
|
73
|
+
}
|
|
34
74
|
|
|
35
75
|
aiur_resolve_identity() {
|
|
36
76
|
local config_home="${XDG_CONFIG_HOME:-$HOME/.config}"
|
|
@@ -39,10 +79,15 @@ aiur_resolve_identity() {
|
|
|
39
79
|
: "${AIUR_COOKIE_FILE:=$AIUR_BG_STATE_DIR/cookie}"
|
|
40
80
|
: "${AIUR_SESSION_PREFIX:=aiur}"
|
|
41
81
|
: "${AIUR_PROFILES_FILE:=$config_home/aiur/aiur.profiles}"
|
|
42
|
-
|
|
82
|
+
# Compute the per-instance key once. `${VAR+x}` so an explicit empty value (no
|
|
83
|
+
# project resolved) is honored and not recomputed on each call.
|
|
84
|
+
if [ -z "${AIUR_INSTANCE_KEY+x}" ]; then
|
|
85
|
+
AIUR_INSTANCE_KEY="$(aiur_instance_key)"
|
|
86
|
+
fi
|
|
87
|
+
: "${AIUR_RELEASE_NODE:=aiur-${USER}${AIUR_INSTANCE_KEY:+-$AIUR_INSTANCE_KEY}@127.0.0.1}"
|
|
43
88
|
|
|
44
89
|
export AIUR_BG_STATE_DIR AIUR_COOKIE_FILE AIUR_SESSION_PREFIX \
|
|
45
|
-
AIUR_PROFILES_FILE AIUR_RELEASE_NODE
|
|
90
|
+
AIUR_PROFILES_FILE AIUR_RELEASE_NODE AIUR_INSTANCE_KEY
|
|
46
91
|
}
|
|
47
92
|
|
|
48
93
|
aiur_print_identity() {
|
|
@@ -52,6 +97,7 @@ aiur_print_identity() {
|
|
|
52
97
|
printf 'AIUR_SESSION_PREFIX=%s\n' "$AIUR_SESSION_PREFIX"
|
|
53
98
|
printf 'AIUR_PROFILES_FILE=%s\n' "$AIUR_PROFILES_FILE"
|
|
54
99
|
printf 'AIUR_RELEASE_NODE=%s\n' "$AIUR_RELEASE_NODE"
|
|
100
|
+
printf 'AIUR_INSTANCE_KEY=%s\n' "$AIUR_INSTANCE_KEY"
|
|
55
101
|
printf 'AIUR_COOKIE_FILE=%s\n' "$AIUR_COOKIE_FILE"
|
|
56
102
|
}
|
|
57
103
|
|
|
@@ -172,12 +218,15 @@ build_init_cmd() {
|
|
|
172
218
|
|
|
173
219
|
usage() {
|
|
174
220
|
cat <<'EOF'
|
|
175
|
-
Usage: aiur [--interactive] [--logs-root <path>] [--port <port>] [--host <host>] [path-to-.aiurconfig]
|
|
221
|
+
Usage: aiur [--interactive] [--max-agents <n>] [--logs-root <path>] [--port <port>] [--host <host>] [path-to-.aiurconfig]
|
|
176
222
|
aiur init [--force] scaffold .aiurconfig (interactive setup wizard)
|
|
177
|
-
aiur --bg start in a detached tmux session
|
|
223
|
+
aiur --bg start in a lean, headless detached tmux session
|
|
178
224
|
aiur stop stop the running session
|
|
179
225
|
aiur status show agent status
|
|
226
|
+
aiur agents show each agent's state + current activity
|
|
227
|
+
aiur set max-agents <n> change the concurrent-agent cap at runtime
|
|
180
228
|
aiur pause <ids|--all> | resume <ids|--all>
|
|
229
|
+
aiur message <id> <text> send operator text to a running agent
|
|
181
230
|
aiur --version
|
|
182
231
|
EOF
|
|
183
232
|
}
|
|
@@ -265,19 +314,28 @@ run_session() {
|
|
|
265
314
|
|
|
266
315
|
init_argv_file
|
|
267
316
|
|
|
268
|
-
# Inject the flags a bare `aiur` needs: loopback bind,
|
|
269
|
-
# no-guardrails ack. Skip any the user already passed.
|
|
270
|
-
|
|
317
|
+
# Inject the flags a bare `aiur` needs: loopback bind, UI mode, and the
|
|
318
|
+
# no-guardrails ack. Skip any the user already passed. Foreground runs are
|
|
319
|
+
# interactive (tmux panes + dashboard); `--bg` runs lean/headless — no panes,
|
|
320
|
+
# no dashboard bind, no chat backfill — and is driven over the control RPC
|
|
321
|
+
# (status/agents/message/pause/set). `aiur --bg --interactive` opts back into
|
|
322
|
+
# the full interactive stack for an attachable background session.
|
|
323
|
+
local has_host=0 has_interactive=0 has_headless=0 has_ack=0 arg
|
|
271
324
|
for arg in "$@"; do
|
|
272
325
|
case "$arg" in
|
|
273
326
|
--host | --host=*) has_host=1 ;;
|
|
274
327
|
--interactive) has_interactive=1 ;;
|
|
328
|
+
--headless) has_headless=1 ;;
|
|
275
329
|
--i-understand-that-this-will-be-running-without-the-usual-guardrails) has_ack=1 ;;
|
|
276
330
|
esac
|
|
277
331
|
done
|
|
278
332
|
local injected=()
|
|
279
333
|
[ "$has_host" -eq 1 ] || injected+=(--host 127.0.0.1)
|
|
280
|
-
[ "$has_interactive" -eq
|
|
334
|
+
if [ "$mode" = "background" ] && [ "$has_interactive" -eq 0 ]; then
|
|
335
|
+
[ "$has_headless" -eq 1 ] || injected+=(--headless)
|
|
336
|
+
else
|
|
337
|
+
[ "$has_interactive" -eq 1 ] || injected+=(--interactive)
|
|
338
|
+
fi
|
|
281
339
|
[ "$has_ack" -eq 1 ] || injected+=(--i-understand-that-this-will-be-running-without-the-usual-guardrails)
|
|
282
340
|
|
|
283
341
|
write_argv "${injected[@]}" "$@"
|
|
@@ -291,8 +349,8 @@ run_session() {
|
|
|
291
349
|
export ELIXIR_ERL_OPTIONS="${ELIXIR_ERL_OPTIONS:-} +fnu"
|
|
292
350
|
fi
|
|
293
351
|
|
|
294
|
-
local session="${AIUR_SESSION_PREFIX}-${USER:-user}-default"
|
|
295
|
-
local socket="${AIUR_SESSION_PREFIX}-${USER:-user}"
|
|
352
|
+
local session="${AIUR_SESSION_PREFIX}-${USER:-user}${AIUR_INSTANCE_KEY:+-$AIUR_INSTANCE_KEY}-default"
|
|
353
|
+
local socket="${AIUR_SESSION_PREFIX}-${USER:-user}${AIUR_INSTANCE_KEY:+-$AIUR_INSTANCE_KEY}"
|
|
296
354
|
local conf
|
|
297
355
|
conf="$(resolve_tmux_conf)"
|
|
298
356
|
[ -f "$conf" ] || die "tmux conf not found at $conf"
|
|
@@ -308,8 +366,32 @@ run_session() {
|
|
|
308
366
|
export AIUR_SESSION_TMPFILE="${session_root}/aiur-${$}-sessions"
|
|
309
367
|
: >"$AIUR_SESSION_TMPFILE"
|
|
310
368
|
|
|
369
|
+
# Agent pidfile: the BEAM appends one line per spawned agent (pane or headless
|
|
370
|
+
# os_pid) via Aiur.ProcessReaper. The BEAM-death watchdog and session_cleanup
|
|
371
|
+
# reap from it after the BEAM is gone — a crashed BEAM can kill nothing itself.
|
|
372
|
+
export AIUR_AGENT_TMPFILE="${session_root}/aiur-${$}-agents"
|
|
373
|
+
: >"$AIUR_AGENT_TMPFILE"
|
|
374
|
+
|
|
375
|
+
# Background runs persist under a known run log dir so (a) the BEAM-death
|
|
376
|
+
# watchdog, which outlives the BEAM, can drop a crash record next to aiur.log,
|
|
377
|
+
# and (b) the post-start boot capture is not lost to /tmp when the launcher
|
|
378
|
+
# removes its startup tempfile. Minting it here and exporting it makes the
|
|
379
|
+
# shell and the BEAM (Aiur.LogFile honors AIUR_LOGS_ROOT) agree on one dir.
|
|
380
|
+
if [ "$mode" = "background" ] && [ -z "${AIUR_LOGS_ROOT:-}" ]; then
|
|
381
|
+
AIUR_LOGS_ROOT="$(printf '%s/%s-%s' "$HOME/.aiur/logs" "$(date -u +%Y%m%dT%H%M%SZ)" "$$")"
|
|
382
|
+
export AIUR_LOGS_ROOT
|
|
383
|
+
fi
|
|
384
|
+
|
|
385
|
+
# Capture sink for BEAM startup (and, in background mode, the whole run's
|
|
386
|
+
# boot stdout/stderr). Foreground uses a throwaway tempfile; background points
|
|
387
|
+
# at a durable file in the run log dir. The dir/file is created lazily just
|
|
388
|
+
# before launch (below) so an idempotent early-return start leaves no empty dir.
|
|
311
389
|
local startup_capture
|
|
312
|
-
|
|
390
|
+
if [ "$mode" = "background" ] && [ -n "${AIUR_LOGS_ROOT:-}" ]; then
|
|
391
|
+
startup_capture="$AIUR_LOGS_ROOT/log/boot.out.log"
|
|
392
|
+
else
|
|
393
|
+
startup_capture="$(mktemp "${TMPDIR:-/tmp}/aiur-startup.XXXXXX")"
|
|
394
|
+
fi
|
|
313
395
|
|
|
314
396
|
# Inner pane script: tmux's server may pre-exist and not inherit our env, so
|
|
315
397
|
# re-export every var the BEAM needs. tee preserves a startup capture.
|
|
@@ -324,7 +406,8 @@ run_session() {
|
|
|
324
406
|
for v in AIUR_RELEASE_DIR AIUR_ARGV_FILE RELEASE_DISTRIBUTION RELEASE_NODE \
|
|
325
407
|
RELEASE_COOKIE ERL_AFLAGS ERL_EPMD_ADDRESS AIUR_NODE AIUR_ERLANG_COOKIE \
|
|
326
408
|
AIUR_TMUX_SESSION AIUR_TMUX_SOCKET AIUR_TMUX_CONF AIUR_BIN \
|
|
327
|
-
AIUR_SESSION_TMPFILE ELIXIR_ERL_OPTIONS AIUR_LOGS_ROOT
|
|
409
|
+
AIUR_SESSION_TMPFILE AIUR_AGENT_TMPFILE ELIXIR_ERL_OPTIONS AIUR_LOGS_ROOT \
|
|
410
|
+
AIUR_OPENCODE_BRIDGE_PORT AIUR_DEBUG; do
|
|
328
411
|
if [ -n "${!v:-}" ]; then printf 'export %s=%q\n' "$v" "${!v}"; fi
|
|
329
412
|
done
|
|
330
413
|
printf 'capture=%q\n' "$startup_capture"
|
|
@@ -341,45 +424,108 @@ run_session() {
|
|
|
341
424
|
_session_socket="$socket" _session_name="$session" _session_conf="$conf" \
|
|
342
425
|
_session_tmpfile="$AIUR_SESSION_TMPFILE" _session_capture="$startup_capture" \
|
|
343
426
|
_session_argv="$argv_file" _session_release="$release_dir" _session_tmux="$tmux_bin" \
|
|
344
|
-
_session_node="$AIUR_RELEASE_NODE"
|
|
427
|
+
_session_node="$AIUR_RELEASE_NODE" _session_pidfile="$AIUR_AGENT_TMPFILE"
|
|
345
428
|
install_foreground_traps
|
|
346
429
|
fi
|
|
347
430
|
|
|
348
|
-
#
|
|
349
|
-
#
|
|
350
|
-
#
|
|
351
|
-
|
|
431
|
+
# Background starts are intentionally idempotent. A prior live node should not
|
|
432
|
+
# fall through to tmux's opaque "duplicate session" failure, and a stale tmux
|
|
433
|
+
# session whose BEAM/control plane is gone should be reclaimed before retry.
|
|
434
|
+
if [ "$mode" = "background" ] && "$tmux_bin" -L "$socket" -f "$conf" has-session -t "$session" 2>/dev/null; then
|
|
435
|
+
if [ "$(probe_control_liveness)" = "up" ]; then
|
|
436
|
+
echo "aiur is already running in the background (tmux session ${session})." >&2
|
|
437
|
+
echo "Use: aiur status # inspect agents" >&2
|
|
438
|
+
echo "Use: aiur stop # stop it before starting a fresh session" >&2
|
|
439
|
+
rm -f "$startup_capture" "$argv_file" "$launcher" "$AIUR_SESSION_TMPFILE" "$AIUR_AGENT_TMPFILE" 2>/dev/null || true
|
|
440
|
+
return 0
|
|
441
|
+
fi
|
|
442
|
+
|
|
443
|
+
echo "aiur found stale tmux session ${session}; cleaning it up before restart" >&2
|
|
444
|
+
reap_aiur_agents "$socket" "$AIUR_AGENT_TMPFILE"
|
|
445
|
+
kill_beams_matching "-name ${AIUR_RELEASE_NODE}"
|
|
446
|
+
fi
|
|
447
|
+
|
|
448
|
+
# An orphaned BEAM (its tmux session gone) can still hold THIS instance's node
|
|
449
|
+
# name. With no live session on our (instance-keyed) socket, reap the name-holder
|
|
450
|
+
# so the launch isn't blocked by "name seems to be in use". The keyed name means
|
|
451
|
+
# this only ever reaps our own instance's orphan, never another live aiur.
|
|
352
452
|
if ! "$tmux_bin" -L "$socket" -f "$conf" has-session -t "$session" 2>/dev/null; then
|
|
353
453
|
kill_beams_matching "-name ${AIUR_RELEASE_NODE}"
|
|
354
454
|
fi
|
|
355
455
|
|
|
456
|
+
# Transition reclaim: a keyed instance also reaps a stale beam under the legacy
|
|
457
|
+
# un-keyed name (aiur-$USER@127.0.0.1) — but only when no live legacy session
|
|
458
|
+
# exists, so a pre-fix run still in progress is never killed. (Runs on every keyed
|
|
459
|
+
# launch, not latched; it's a cheap best-effort sweep guarded by has-session.)
|
|
460
|
+
if [ -n "${AIUR_INSTANCE_KEY:-}" ]; then
|
|
461
|
+
local legacy_socket="${AIUR_SESSION_PREFIX}-${USER:-user}"
|
|
462
|
+
if ! "$tmux_bin" -L "$legacy_socket" -f "$conf" has-session -t "${legacy_socket}-default" 2>/dev/null; then
|
|
463
|
+
kill_beams_matching "-name aiur-${USER}@127.0.0.1"
|
|
464
|
+
fi
|
|
465
|
+
fi
|
|
466
|
+
|
|
467
|
+
# Create the capture sink now (after the idempotent early-return checks above,
|
|
468
|
+
# so a no-op "already running" start never mints an empty run log dir). The
|
|
469
|
+
# launcher's `tee -a` needs the file's parent to exist when the pane runs.
|
|
470
|
+
mkdir -p "$(dirname "$startup_capture")" 2>/dev/null || true
|
|
471
|
+
: >"$startup_capture" 2>/dev/null || true
|
|
472
|
+
|
|
356
473
|
if ! "$tmux_bin" -L "$socket" -f "$conf" new-session -d -s "$session" \
|
|
357
474
|
-x "${COLUMNS:-200}" -y "${LINES:-50}" "$inner_cmd"; then
|
|
358
475
|
echo "❌ aiur failed to start; captured output:" >&2
|
|
359
476
|
tail -n 30 "$startup_capture" 2>/dev/null | sed 's/^/ /' >&2 || true
|
|
360
|
-
rm -f "$startup_capture" "$argv_file" 2>/dev/null || true
|
|
477
|
+
rm -f "$startup_capture" "$argv_file" "$launcher" "$AIUR_SESSION_TMPFILE" "$AIUR_AGENT_TMPFILE" 2>/dev/null || true
|
|
361
478
|
exit 1
|
|
362
479
|
fi
|
|
363
480
|
|
|
364
|
-
#
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
481
|
+
# Title the agent-list pane (the only pane in the fresh session). The conf's
|
|
482
|
+
# `pane-border-status`/`pane-border-format` render it; PaneManager titles the
|
|
483
|
+
# chat panes it opens. Best-effort — a missing title just shows the default.
|
|
484
|
+
"$tmux_bin" -L "$socket" -f "$conf" select-pane -t "$session" -T "AIUR Agents" 2>/dev/null || true
|
|
485
|
+
|
|
486
|
+
# Grace window: surface a boot crash instead of attaching to nothing. For
|
|
487
|
+
# headless background runs, also prove the control plane can answer before
|
|
488
|
+
# claiming startup succeeded; control commands depend on that RPC path.
|
|
489
|
+
local require_control=0
|
|
490
|
+
[ "$mode" = "background" ] && require_control=1
|
|
491
|
+
if ! wait_for_session_startup "$tmux_bin" "$socket" "$conf" "$session" "$startup_capture" "$require_control"; then
|
|
492
|
+
if [ "$mode" = "background" ]; then
|
|
493
|
+
"$tmux_bin" -L "$socket" -f "$conf" kill-session -t "$session" 2>/dev/null || true
|
|
494
|
+
reap_aiur_agents "$socket" "$AIUR_AGENT_TMPFILE"
|
|
495
|
+
kill_beams_matching "-name ${AIUR_RELEASE_NODE}"
|
|
373
496
|
fi
|
|
374
|
-
|
|
375
|
-
|
|
497
|
+
[ "$mode" = "foreground" ] && return 1
|
|
498
|
+
rm -f "$startup_capture" "$argv_file" "$launcher" "$AIUR_SESSION_TMPFILE" "$AIUR_AGENT_TMPFILE" 2>/dev/null || true
|
|
499
|
+
exit 1
|
|
500
|
+
fi
|
|
376
501
|
|
|
377
502
|
if [ "$mode" = "background" ]; then
|
|
503
|
+
# Fresh run: drop any stale crash/stop state from a prior dead instance so
|
|
504
|
+
# `status` doesn't report a phantom orphan and the watchdog starts clean.
|
|
505
|
+
rm -f "$(aiur_stop_sentinel_path)" "$(aiur_crash_marker_path)" 2>/dev/null || true
|
|
506
|
+
|
|
507
|
+
local background_watchdog_pid
|
|
508
|
+
background_watchdog_pid="$(start_beam_death_watchdog \
|
|
509
|
+
"-name ${AIUR_RELEASE_NODE}" "$socket" "$AIUR_AGENT_TMPFILE" 1 1 \
|
|
510
|
+
"$AIUR_RELEASE_NODE" "${AIUR_LOGS_ROOT:-}" \
|
|
511
|
+
"$(aiur_stop_sentinel_path)" "$(aiur_crash_marker_path)")"
|
|
512
|
+
disown "$background_watchdog_pid" 2>/dev/null || true
|
|
378
513
|
echo "aiur started in the background (tmux session ${session}). Attach with: aiur" >&2
|
|
379
|
-
|
|
514
|
+
# Keep $startup_capture (boot.out.log) for the run's lifetime; only the
|
|
515
|
+
# transient argv file is no longer needed.
|
|
516
|
+
rm -f "$argv_file" 2>/dev/null || true
|
|
380
517
|
return 0
|
|
381
518
|
fi
|
|
382
519
|
|
|
520
|
+
# Arm the BEAM-death watchdog before attaching. If the BEAM crashes
|
|
521
|
+
# (:emfile) mid-run, agent windows keep the orphaned session alive so the
|
|
522
|
+
# `tmux attach` below never returns and the EXIT trap never fires — the
|
|
523
|
+
# watchdog is the external reaper that survives the dead BEAM, kill-servers
|
|
524
|
+
# the session, and unblocks the attach. It polls the node name rather than a
|
|
525
|
+
# captured pid, so another Aiur instance from the same release cannot hold it open.
|
|
526
|
+
_session_watchdog_pid="$(start_beam_death_watchdog \
|
|
527
|
+
"-name ${AIUR_RELEASE_NODE}" "$socket" "$AIUR_AGENT_TMPFILE" 1)"
|
|
528
|
+
|
|
383
529
|
# Foreground: attach the UI. Do not exec — that would drop the teardown trap.
|
|
384
530
|
"$tmux_bin" -L "$socket" -f "$conf" attach -t "$session" 2> >(grep -v -F "[server exited]" >&2)
|
|
385
531
|
return $?
|
|
@@ -417,37 +563,272 @@ kill_beams_matching() {
|
|
|
417
563
|
for pid in $pids; do kill -KILL "$pid" 2>/dev/null || true; done
|
|
418
564
|
}
|
|
419
565
|
|
|
566
|
+
# Echo $1 and every descendant pid, depth-first. Mac-safe (`pgrep -P`, no
|
|
567
|
+
# /proc), mirroring Aiur.RemoteControl.collect_descendants so the launcher reaps
|
|
568
|
+
# the same tree the BEAM-side reaper would. The recorded agent pid is a bash
|
|
569
|
+
# `-lc` wrapper; the model process (claude --print / codex) is its child and
|
|
570
|
+
# reparents to init if only the wrapper is signalled — orphan-and-survive is
|
|
571
|
+
# exactly the bug — so the whole tree must be collected before any kill lands.
|
|
572
|
+
agent_pid_tree() {
|
|
573
|
+
local root="$1" child
|
|
574
|
+
printf '%s\n' "$root"
|
|
575
|
+
for child in $(pgrep -P "$root" 2>/dev/null || true); do
|
|
576
|
+
agent_pid_tree "$child"
|
|
577
|
+
done
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
# pid-reuse guard: succeeds when pid $1 is alive AND its command still contains
|
|
581
|
+
# the comm substring $2 the BEAM recorded. An empty comm kills unconditionally.
|
|
582
|
+
# Mirrors the BEAM-side cmdline guard (Aiur.ProcessReaper) but Mac-safe via
|
|
583
|
+
# `ps -o command=`, so a recycled pid whose command no longer matches is spared.
|
|
584
|
+
agent_pid_matches() {
|
|
585
|
+
local pid="$1" comm="$2" cmd
|
|
586
|
+
kill -0 "$pid" 2>/dev/null || return 1
|
|
587
|
+
[ -n "$comm" ] || return 0
|
|
588
|
+
cmd="$(ps -p "$pid" -o command= 2>/dev/null || true)"
|
|
589
|
+
case "$cmd" in *"$comm"*) return 0 ;; *) return 1 ;; esac
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
# Reap every aiur agent the BEAM can no longer reap itself.
|
|
593
|
+
#
|
|
594
|
+
# $1 socket aiur tmux socket (-L); kill-server nukes all panes (may be empty)
|
|
595
|
+
# $2 pidfile AIUR_AGENT_TMPFILE (BEAM-written agent refs; may be empty/missing)
|
|
596
|
+
#
|
|
597
|
+
# tmux runs on aiur's PRIVATE socket (-L aiur-$USER), so kill-server tears down
|
|
598
|
+
# every REPL/chat pane agent in one shot AND leaves no live aiur tmux server —
|
|
599
|
+
# never touching the operator's own default tmux. Headless agents (claude/codex
|
|
600
|
+
# app-servers spawned via Port) are bare OS processes that reparent to init on a
|
|
601
|
+
# BEAM crash; kill-server can't see them, so they're reaped from the pidfile by
|
|
602
|
+
# process tree, comm-guarded against pid reuse. Idempotent.
|
|
603
|
+
reap_aiur_agents() {
|
|
604
|
+
local socket="$1" pidfile="${2:-}"
|
|
605
|
+
local tmux_bin
|
|
606
|
+
tmux_bin="$(command -v tmux || true)"
|
|
607
|
+
|
|
608
|
+
if [ -n "$tmux_bin" ] && [ -n "$socket" ]; then
|
|
609
|
+
"$tmux_bin" -L "$socket" kill-server 2>/dev/null || true
|
|
610
|
+
fi
|
|
611
|
+
|
|
612
|
+
[ -n "$pidfile" ] && [ -r "$pidfile" ] || return 0
|
|
613
|
+
|
|
614
|
+
# Snapshot the full process tree of every still-matching headless agent before
|
|
615
|
+
# signalling, so descendants that reparent mid-reap are already on the list.
|
|
616
|
+
local kind pid comm tree=() p
|
|
617
|
+
while read -r kind pid comm; do
|
|
618
|
+
[ "$kind" = "pid" ] && [ -n "$pid" ] || continue
|
|
619
|
+
agent_pid_matches "$pid" "$comm" || continue
|
|
620
|
+
while IFS= read -r p; do tree+=("$p"); done < <(agent_pid_tree "$pid")
|
|
621
|
+
done <"$pidfile"
|
|
622
|
+
|
|
623
|
+
[ "${#tree[@]}" -gt 0 ] || return 0
|
|
624
|
+
|
|
625
|
+
for p in "${tree[@]}"; do kill -TERM "$p" 2>/dev/null || true; done
|
|
626
|
+
local waited=0
|
|
627
|
+
while [ "$waited" -lt 20 ]; do
|
|
628
|
+
local any=0
|
|
629
|
+
for p in "${tree[@]}"; do kill -0 "$p" 2>/dev/null && { any=1; break; }; done
|
|
630
|
+
[ "$any" -eq 0 ] && break
|
|
631
|
+
sleep 0.1
|
|
632
|
+
waited=$((waited + 1))
|
|
633
|
+
done
|
|
634
|
+
for p in "${tree[@]}"; do kill -KILL "$p" 2>/dev/null || true; done
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
# Stable, per-instance state paths the background BEAM-death machinery shares
|
|
638
|
+
# across `run`, `stop`, and `status`. Keyed by the (instance-keyed) release node
|
|
639
|
+
# rather than the tmux socket so `status` — which resolves the node but not the
|
|
640
|
+
# socket — agrees on the same files. AIUR_BG_STATE_DIR and AIUR_RELEASE_NODE are
|
|
641
|
+
# resolved by aiur_resolve_identity, which every one of those paths calls first.
|
|
642
|
+
aiur_state_slug() {
|
|
643
|
+
printf '%s' "${AIUR_RELEASE_NODE:-aiur}" | tr -c 'A-Za-z0-9._-' '_'
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
# Marker `status` reads to tell "daemon crashed, agents may be orphaned" apart
|
|
647
|
+
# from "nothing was ever running". Written by the watchdog on an unexpected exit,
|
|
648
|
+
# cleared on a clean stop and on the next successful background start.
|
|
649
|
+
aiur_crash_marker_path() {
|
|
650
|
+
printf '%s/%s.last-crash' "${AIUR_BG_STATE_DIR:?}" "$(aiur_state_slug)"
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
# Sentinel `stop` drops before killing the BEAM so the watchdog knows the exit
|
|
654
|
+
# was intentional and does not record a false crash.
|
|
655
|
+
aiur_stop_sentinel_path() {
|
|
656
|
+
printf '%s/%s.stopping' "${AIUR_BG_STATE_DIR:?}" "$(aiur_state_slug)"
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
# Write a durable record that the background BEAM exited unexpectedly. Two sinks:
|
|
660
|
+
# the run log dir (full record next to aiur.log, for forensics) and the stable
|
|
661
|
+
# per-instance marker (for `status` to surface). Best-effort throughout — this
|
|
662
|
+
# runs in the disowned watchdog after the BEAM is already gone, so it must never
|
|
663
|
+
# fail loudly or block the reap that follows.
|
|
664
|
+
record_beam_crash() {
|
|
665
|
+
local node="$1" run_log_dir="$2" marker="$3"
|
|
666
|
+
local ts boot_tail=""
|
|
667
|
+
ts="$(date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || true)"
|
|
668
|
+
if [ -n "$run_log_dir" ] && [ -r "$run_log_dir/log/boot.out.log" ]; then
|
|
669
|
+
boot_tail="$(tail -n 20 "$run_log_dir/log/boot.out.log" 2>/dev/null || true)"
|
|
670
|
+
fi
|
|
671
|
+
|
|
672
|
+
local body
|
|
673
|
+
body="$(
|
|
674
|
+
printf 'aiur background BEAM exited unexpectedly\n'
|
|
675
|
+
printf 'timestamp: %s\n' "${ts:-unknown}"
|
|
676
|
+
printf 'node: %s\n' "${node:-unknown}"
|
|
677
|
+
printf 'run_log_dir: %s\n' "${run_log_dir:-unknown}"
|
|
678
|
+
printf 'detected_by: background BEAM-death watchdog (no clean stop sentinel)\n'
|
|
679
|
+
if [ -n "$boot_tail" ]; then
|
|
680
|
+
printf -- '--- last 20 lines of boot.out.log ---\n%s\n' "$boot_tail"
|
|
681
|
+
fi
|
|
682
|
+
)"
|
|
683
|
+
|
|
684
|
+
if [ -n "$run_log_dir" ]; then
|
|
685
|
+
mkdir -p "$run_log_dir/log" 2>/dev/null || true
|
|
686
|
+
printf '%s\n' "$body" >>"$run_log_dir/log/aiur.crash" 2>/dev/null || true
|
|
687
|
+
fi
|
|
688
|
+
if [ -n "$marker" ]; then
|
|
689
|
+
printf '%s\n' "$body" >"$marker" 2>/dev/null || true
|
|
690
|
+
fi
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
# Background watchdog that survives the BEAM. Polls for the release BEAM by
|
|
694
|
+
# command pattern and, once it has SEEN the BEAM and then the BEAM disappears
|
|
695
|
+
# (orderly halt OR :emfile-style crash), reaps every agent. Polling the pattern
|
|
696
|
+
# rather than a captured pid avoids two failure modes: a recycled BEAM pid the
|
|
697
|
+
# watchdog would poll forever, and an empty pid at arm time that would silently
|
|
698
|
+
# disarm the only crash reaper. The `seen` latch prevents a startup race from
|
|
699
|
+
# reaping before the BEAM has come up. kill-server collapses the orphaned tmux
|
|
700
|
+
# session, which returns the foreground `tmux attach` so the EXIT trap
|
|
701
|
+
# (session_cleanup) runs its idempotent reap too.
|
|
702
|
+
#
|
|
703
|
+
# $1 beam_pattern $2 socket $3 pidfile $4 interval_s $5 initial_seen
|
|
704
|
+
# $6 node $7 run_log_dir $8 stop_sentinel $9 crash_marker
|
|
705
|
+
# Args 6-9 arm crash recording (background mode). Omit them (foreground) and the
|
|
706
|
+
# watchdog only reaps — a foreground BEAM death is already visible at the UI.
|
|
707
|
+
# Prints the watchdog's own pid so the caller can kill it on a clean teardown.
|
|
708
|
+
start_beam_death_watchdog() {
|
|
709
|
+
local beam_pattern="$1" socket="$2" pidfile="$3" interval="${4:-1}" initial_seen="${5:-0}"
|
|
710
|
+
local node="${6:-}" run_log_dir="${7:-}" stop_sentinel="${8:-}" crash_marker="${9:-}"
|
|
711
|
+
# Redirect the subshell's stdout so a command-substitution caller
|
|
712
|
+
# (`pid=$(start_beam_death_watchdog ...)`) returns immediately instead of
|
|
713
|
+
# blocking on the still-open pipe until the watchdog finishes.
|
|
714
|
+
(
|
|
715
|
+
seen="$initial_seen"
|
|
716
|
+
while :; do
|
|
717
|
+
if pgrep -f -- "$beam_pattern" >/dev/null 2>&1; then
|
|
718
|
+
seen=1
|
|
719
|
+
elif [ "$seen" = 1 ]; then
|
|
720
|
+
break
|
|
721
|
+
fi
|
|
722
|
+
sleep "$interval"
|
|
723
|
+
done
|
|
724
|
+
# The BEAM we had seen is gone. A clean `aiur stop` drops the sentinel first;
|
|
725
|
+
# its presence means an intentional exit (consume it, no crash record).
|
|
726
|
+
# Anything else is an unexpected exit worth a durable record before reaping.
|
|
727
|
+
if [ -n "$stop_sentinel" ] && [ -f "$stop_sentinel" ]; then
|
|
728
|
+
rm -f "$stop_sentinel" 2>/dev/null || true
|
|
729
|
+
elif [ -n "$crash_marker" ] || [ -n "$run_log_dir" ]; then
|
|
730
|
+
record_beam_crash "$node" "$run_log_dir" "$crash_marker"
|
|
731
|
+
fi
|
|
732
|
+
reap_aiur_agents "$socket" "$pidfile"
|
|
733
|
+
) >/dev/null 2>&1 &
|
|
734
|
+
printf '%s\n' "$!"
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
probe_control_liveness() {
|
|
738
|
+
local expression output status
|
|
739
|
+
expression='case Process.whereis(Aiur.Orchestrator) do pid when is_pid(pid) -> case Aiur.Orchestrator.status(Aiur.Orchestrator, 100) do statuses when is_list(statuses) -> IO.puts("__AIUR_CONTROL_READY__"); _ -> IO.puts("__AIUR_CONTROL_NOT_READY__") end; _ -> IO.puts("__AIUR_CONTROL_NOT_READY__") end'
|
|
740
|
+
|
|
741
|
+
set +e
|
|
742
|
+
output="$("$release_bin" rpc "$expression" 2>&1)"
|
|
743
|
+
status=$?
|
|
744
|
+
set -e
|
|
745
|
+
|
|
746
|
+
if [ "$status" -eq 0 ] && [[ "$output" == *"__AIUR_CONTROL_READY__"* ]]; then
|
|
747
|
+
printf 'up'
|
|
748
|
+
else
|
|
749
|
+
printf 'down'
|
|
750
|
+
fi
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
wait_for_session_startup() {
|
|
754
|
+
local tmux_bin="$1" socket="$2" conf="$3" session="$4" startup_capture="$5" require_control="$6"
|
|
755
|
+
local max_ticks="${AIUR_TMUX_GRACE_TICKS:-30}" tick control_state
|
|
756
|
+
if [ "$require_control" = "1" ]; then
|
|
757
|
+
max_ticks="${AIUR_NODE_GRACE_TICKS:-100}"
|
|
758
|
+
fi
|
|
759
|
+
|
|
760
|
+
for ((tick = 0; tick < max_ticks; tick++)); do
|
|
761
|
+
if ! "$tmux_bin" -L "$socket" -f "$conf" has-session -t "$session" 2>/dev/null; then
|
|
762
|
+
echo "❌ aiur exited during startup; captured output:" >&2
|
|
763
|
+
tail -n 30 "$startup_capture" 2>/dev/null | sed 's/^/ /' >&2 || true
|
|
764
|
+
return 1
|
|
765
|
+
fi
|
|
766
|
+
|
|
767
|
+
if [ "$require_control" != "1" ]; then
|
|
768
|
+
sleep 0.1
|
|
769
|
+
continue
|
|
770
|
+
fi
|
|
771
|
+
|
|
772
|
+
control_state="$(probe_control_liveness)"
|
|
773
|
+
if [ "$control_state" = "up" ]; then
|
|
774
|
+
return 0
|
|
775
|
+
fi
|
|
776
|
+
|
|
777
|
+
sleep 0.1
|
|
778
|
+
done
|
|
779
|
+
|
|
780
|
+
if [ "$require_control" = "1" ]; then
|
|
781
|
+
echo "❌ aiur control plane did not become ready at ${RELEASE_NODE:-unknown} during startup; captured output:" >&2
|
|
782
|
+
tail -n 30 "$startup_capture" 2>/dev/null | sed 's/^/ /' >&2 || true
|
|
783
|
+
return 1
|
|
784
|
+
fi
|
|
785
|
+
|
|
786
|
+
return 0
|
|
787
|
+
}
|
|
788
|
+
|
|
420
789
|
# Foreground teardown: kill the session + BEAM and reap opencode sessions on exit.
|
|
421
790
|
_session_socket="" _session_name="" _session_conf="" _session_tmpfile=""
|
|
422
791
|
_session_capture="" _session_argv="" _session_release="" _session_tmux="" _session_node=""
|
|
792
|
+
_session_pidfile="" _session_watchdog_pid=""
|
|
423
793
|
_cleanup_ran=0
|
|
424
794
|
session_cleanup() {
|
|
425
795
|
[ "$_cleanup_ran" = 1 ] && return 0
|
|
426
796
|
_cleanup_ran=1
|
|
427
797
|
local code=$?
|
|
428
798
|
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
799
|
+
# Stop the BEAM-death watchdog: cleanup is running, so it has no work left and
|
|
800
|
+
# a lingering poller would outlive this teardown.
|
|
801
|
+
[ -n "$_session_watchdog_pid" ] && kill "$_session_watchdog_pid" 2>/dev/null || true
|
|
432
802
|
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
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
|
|
803
|
+
# kill-session on the detached aiur session FIRST: this is what propagates
|
|
804
|
+
# SIGHUP to the BEAM in that session so it begins its own orderly shutdown.
|
|
805
|
+
# Without it the BEAM survives Ctrl+C, holding port 4000 + the node name and
|
|
806
|
+
# breaking the next launch (regression test/aiur/regression/shutdown_cleanup_test.exs).
|
|
807
|
+
if [ -n "$_session_tmux" ] && [ -n "$_session_socket" ] && [ -n "$_session_name" ]; then
|
|
808
|
+
"$_session_tmux" -L "$_session_socket" kill-session -t "$_session_name" 2>/dev/null || true
|
|
445
809
|
fi
|
|
446
810
|
|
|
447
|
-
#
|
|
448
|
-
#
|
|
811
|
+
# Then kill-server on aiur's private socket: tears down every pane agent across
|
|
812
|
+
# all windows AND leaves no live aiur tmux server, then reaps headless agents
|
|
813
|
+
# from the pidfile. Idempotent with the watchdog's own reap and the kill-session
|
|
814
|
+
# above. reap_aiur_agents re-resolves tmux itself and reaps headless agents even
|
|
815
|
+
# when tmux is absent, so it is not gated on $_session_tmux.
|
|
816
|
+
reap_aiur_agents "$_session_socket" "$_session_pidfile"
|
|
817
|
+
|
|
818
|
+
# Reap only this instance's BEAM. Multiple keyed instances can share the same
|
|
819
|
+
# release dir, so release-path pgrep would terminate sibling workflows.
|
|
449
820
|
[ -n "$_session_node" ] && kill_beams_matching "-name ${_session_node}"
|
|
450
821
|
|
|
822
|
+
# Reap the epmd our BEAM spawned for distribution so it doesn't linger after
|
|
823
|
+
# exit. The node is dead by now, so `epmd -kill` succeeds; it safely refuses
|
|
824
|
+
# while any *other* Erlang node is alive, so a daemon shared with an unrelated
|
|
825
|
+
# Erlang program is never disrupted. Use the release's bundled epmd and pin it
|
|
826
|
+
# to the loopback address aiur starts it on.
|
|
827
|
+
for _epmd in "$_session_release"/erts-*/bin/epmd; do
|
|
828
|
+
[ -x "$_epmd" ] || continue
|
|
829
|
+
ERL_EPMD_ADDRESS="${ERL_EPMD_ADDRESS:-127.0.0.1}" "$_epmd" -kill >/dev/null 2>&1 || true
|
|
830
|
+
done
|
|
831
|
+
|
|
451
832
|
if command -v opencode >/dev/null 2>&1 && [ -s "$_session_tmpfile" ]; then
|
|
452
833
|
local id
|
|
453
834
|
while IFS= read -r id; do
|
|
@@ -460,7 +841,12 @@ session_cleanup() {
|
|
|
460
841
|
# Reap agent-driver sockets this run orphaned at close, not next launch.
|
|
461
842
|
sweep_dead_tmux_sockets || true
|
|
462
843
|
|
|
463
|
-
|
|
844
|
+
# Reap stale aiur /tmp debris (per-run tempfiles, leaked test artifacts) so a
|
|
845
|
+
# tmpfs /tmp can't fill across runs. Age-gated + pid-safe; our own still-present
|
|
846
|
+
# tempfiles are spared (fresh + live pid) before the explicit rm below clears them.
|
|
847
|
+
sweep_stale_tmp_artifacts || true
|
|
848
|
+
|
|
849
|
+
rm -f "$_session_tmpfile" "$_session_capture" "$_session_argv" "$_session_pidfile" 2>/dev/null || true
|
|
464
850
|
return $code
|
|
465
851
|
}
|
|
466
852
|
install_foreground_traps() {
|
|
@@ -479,6 +865,32 @@ trim() {
|
|
|
479
865
|
printf '%s' "$s"
|
|
480
866
|
}
|
|
481
867
|
|
|
868
|
+
# Classify our distribution node's liveness via epmd, which only advertises a
|
|
869
|
+
# node while its BEAM holds the registration. Echoes exactly one word:
|
|
870
|
+
# up — node is registered: it is alive, so an rpc failure is a real error
|
|
871
|
+
# down — epmd answered and our node is absent: it genuinely is not running
|
|
872
|
+
# unknown — epmd could not be queried (binary missing, or daemon unreachable),
|
|
873
|
+
# so node state is indeterminate and callers must NOT assume "down"
|
|
874
|
+
# and mask the real error
|
|
875
|
+
# Distinguishing `unknown` from `down` matters: a live node whose epmd we cannot
|
|
876
|
+
# reach must still surface its real rpc error rather than the "start aiur" hint.
|
|
877
|
+
# Relies on RELEASE_NODE + ERL_EPMD_ADDRESS (prepare_distribution) and
|
|
878
|
+
# release_dir (resolve_release), so call it only after both have run.
|
|
879
|
+
probe_node_liveness() {
|
|
880
|
+
local epmd names short="${RELEASE_NODE%@*}"
|
|
881
|
+
for epmd in "$release_dir"/erts-*/bin/epmd; do
|
|
882
|
+
[ -x "$epmd" ] || continue
|
|
883
|
+
names="$(ERL_EPMD_ADDRESS="${ERL_EPMD_ADDRESS:-127.0.0.1}" "$epmd" -names 2>/dev/null)" \
|
|
884
|
+
|| { printf 'unknown'; return; }
|
|
885
|
+
case "$names" in
|
|
886
|
+
*"name ${short} at port "*) printf 'up' ;;
|
|
887
|
+
*) printf 'down' ;;
|
|
888
|
+
esac
|
|
889
|
+
return
|
|
890
|
+
done
|
|
891
|
+
printf 'unknown'
|
|
892
|
+
}
|
|
893
|
+
|
|
482
894
|
# RPC an expression into the running node. The control CLI prints a trailing
|
|
483
895
|
# `__AIUR_CONTROL_EXIT__:<code>` marker we translate into the process exit code.
|
|
484
896
|
run_control_rpc() {
|
|
@@ -495,7 +907,35 @@ run_control_rpc() {
|
|
|
495
907
|
set -e
|
|
496
908
|
|
|
497
909
|
if [ "$status" -ne 0 ]; then
|
|
498
|
-
|
|
910
|
+
# A non-zero exit here is the rpc transport failing — application-level
|
|
911
|
+
# outcomes ride the marker path below. `bin/aiur rpc` (Elixir --rpc-eval)
|
|
912
|
+
# reports a genuinely-down node and a live-but-unreachable one identically
|
|
913
|
+
# (`:noconnection`), and prints any exception raised inside the expression.
|
|
914
|
+
# So the reason string can't be trusted; classify via epmd instead. Only a
|
|
915
|
+
# node epmd confirms is down earns the friendly "start aiur" hint; an `up`
|
|
916
|
+
# node failed for a real reason, and an `unknown` probe must not be assumed
|
|
917
|
+
# down — in both of those cases surface the actual rpc output, never mask it.
|
|
918
|
+
case "$(probe_node_liveness)" in
|
|
919
|
+
down)
|
|
920
|
+
local crash_marker
|
|
921
|
+
crash_marker="$(aiur_crash_marker_path)"
|
|
922
|
+
if [ -f "$crash_marker" ]; then
|
|
923
|
+
echo "aiur: background daemon at ${RELEASE_NODE} is DOWN after an unexpected exit; agents may be orphaned" >&2
|
|
924
|
+
sed 's/^/ /' "$crash_marker" >&2 2>/dev/null || true
|
|
925
|
+
echo "aiur: run 'aiur stop' to reap any orphaned agents, then start aiur again" >&2
|
|
926
|
+
else
|
|
927
|
+
echo "aiur: no running aiur node at ${RELEASE_NODE}; start aiur and try again" >&2
|
|
928
|
+
fi
|
|
929
|
+
;;
|
|
930
|
+
up)
|
|
931
|
+
[ -n "$output" ] && printf '%s\n' "$output" >&2
|
|
932
|
+
echo "aiur: rpc to ${RELEASE_NODE} failed (node is running); see the error above" >&2
|
|
933
|
+
;;
|
|
934
|
+
*)
|
|
935
|
+
[ -n "$output" ] && printf '%s\n' "$output" >&2
|
|
936
|
+
echo "aiur: rpc to ${RELEASE_NODE} failed (could not query epmd to confirm node state); see the error above" >&2
|
|
937
|
+
;;
|
|
938
|
+
esac
|
|
499
939
|
return 1
|
|
500
940
|
fi
|
|
501
941
|
|
|
@@ -575,6 +1015,59 @@ cmd_pause_resume() {
|
|
|
575
1015
|
run_control_rpc "$expression"
|
|
576
1016
|
}
|
|
577
1017
|
|
|
1018
|
+
# `aiur message <issue> <text>` — deliver operator text to one running agent.
|
|
1019
|
+
# The text is base64-encoded for the RPC hop so arbitrary content (quotes,
|
|
1020
|
+
# backslashes, `#{}`, newlines) survives without Elixir-string escaping.
|
|
1021
|
+
cmd_message() {
|
|
1022
|
+
local usage="aiur: message expects an issue ID and text (e.g. aiur message 44 \"ship it\")"
|
|
1023
|
+
|
|
1024
|
+
local issue="${1:-}"
|
|
1025
|
+
if [ -z "$issue" ] || [[ ! "$issue" =~ ^[0-9]+$ ]]; then
|
|
1026
|
+
echo "$usage" >&2
|
|
1027
|
+
exit 64
|
|
1028
|
+
fi
|
|
1029
|
+
shift
|
|
1030
|
+
|
|
1031
|
+
local text="$*"
|
|
1032
|
+
if [ -z "$text" ]; then
|
|
1033
|
+
echo "$usage" >&2
|
|
1034
|
+
exit 64
|
|
1035
|
+
fi
|
|
1036
|
+
|
|
1037
|
+
local encoded
|
|
1038
|
+
encoded="$(printf '%s' "$text" | base64 | tr -d '\n')"
|
|
1039
|
+
run_control_rpc "Aiur.AgentControlCLI.message(\"$issue\", Base.decode64!(\"$encoded\"))"
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
# `aiur agents` — concise one-line-per-agent state + current activity from a
|
|
1043
|
+
# live node (the headless equivalent of the dashboard / aiur-status skill).
|
|
1044
|
+
cmd_agents() {
|
|
1045
|
+
[ "$#" -eq 0 ] || die "agents does not accept arguments"
|
|
1046
|
+
run_control_rpc "Aiur.AgentControlCLI.agents()"
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
# `aiur set <key> <value>` — runtime config overrides without editing
|
|
1050
|
+
# `.aiur/config`. Currently: `aiur set max-agents N`.
|
|
1051
|
+
cmd_set() {
|
|
1052
|
+
local key="${1:-}"
|
|
1053
|
+
shift 2>/dev/null || true
|
|
1054
|
+
|
|
1055
|
+
case "$key" in
|
|
1056
|
+
max-agents)
|
|
1057
|
+
local n="${1:-}"
|
|
1058
|
+
if [ -z "$n" ] || [[ ! "$n" =~ ^[0-9]+$ ]] || [ "$n" -lt 1 ]; then
|
|
1059
|
+
echo "aiur: set max-agents expects a positive integer (e.g. aiur set max-agents 5)" >&2
|
|
1060
|
+
exit 64
|
|
1061
|
+
fi
|
|
1062
|
+
run_control_rpc "Aiur.AgentControlCLI.set_max_agents($n)"
|
|
1063
|
+
;;
|
|
1064
|
+
*)
|
|
1065
|
+
echo "aiur: unknown setting: ${key:-(none)} (supported: max-agents)" >&2
|
|
1066
|
+
exit 64
|
|
1067
|
+
;;
|
|
1068
|
+
esac
|
|
1069
|
+
}
|
|
1070
|
+
|
|
578
1071
|
sweep_dead_tmux_sockets() {
|
|
579
1072
|
local tmux_bin
|
|
580
1073
|
tmux_bin="$(command -v tmux || true)"
|
|
@@ -600,38 +1093,153 @@ sweep_dead_tmux_sockets() {
|
|
|
600
1093
|
return 0
|
|
601
1094
|
}
|
|
602
1095
|
|
|
603
|
-
#
|
|
1096
|
+
# Reap stale aiur /tmp debris so a tmpfs /tmp can't fill from accumulated per-run
|
|
1097
|
+
# tempfiles and leaked test artifacts. Runs alongside sweep_dead_tmux_sockets on
|
|
1098
|
+
# foreground teardown (session_cleanup) and on `aiur stop`. Bounded and safe:
|
|
1099
|
+
#
|
|
1100
|
+
# * Only exact top-level Aiur artifact families under the temp roots the engine
|
|
1101
|
+
# and BEAM write to. Arbitrary operator worktrees/checkouts like
|
|
1102
|
+
# `/tmp/aiur-pr490` are not candidates.
|
|
1103
|
+
# * Age-gated by AIUR_TMP_REAP_MINUTES (default 1440 = 24h). An entry is removed
|
|
1104
|
+
# only when NOTHING in its subtree was modified within the window, so a live
|
|
1105
|
+
# run's shared debug dir (aiur-rc / aiur-claude-hooks / aiur-debug) holding a
|
|
1106
|
+
# fresh file is spared even when the dir's own mtime is stale. A non-numeric or
|
|
1107
|
+
# 0 value disables the sweep.
|
|
1108
|
+
# * Ownership-gated: if anything in the tree is not owned by the effective user,
|
|
1109
|
+
# the whole tree is spared.
|
|
1110
|
+
# * A live run's `aiur-<pid>-sessions|-agents` bookkeeping is kept while its pid
|
|
1111
|
+
# is alive, regardless of age.
|
|
1112
|
+
is_aiur_tmp_artifact_candidate() {
|
|
1113
|
+
local base="$1" pid
|
|
1114
|
+
case "$base" in
|
|
1115
|
+
aiur-argv.* | aiur-startup.* | aiur-pane.* | aiur-launcher.* | aiur-capture.* | \
|
|
1116
|
+
aiur-trap.*.log | aiur-tree.*.json | aiur-wrapper.pid | aiur-*-frames.bin | \
|
|
1117
|
+
aiur-rc | aiur-claude-hooks | aiur-debug)
|
|
1118
|
+
return 0
|
|
1119
|
+
;;
|
|
1120
|
+
aiur-*-sessions | aiur-*-agents)
|
|
1121
|
+
pid="${base#aiur-}"
|
|
1122
|
+
pid="${pid%-*}"
|
|
1123
|
+
[ -n "$pid" ] && [ -z "${pid//[0-9]/}" ]
|
|
1124
|
+
return
|
|
1125
|
+
;;
|
|
1126
|
+
*)
|
|
1127
|
+
return 1
|
|
1128
|
+
;;
|
|
1129
|
+
esac
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
sweep_stale_tmp_artifacts() {
|
|
1133
|
+
local minutes="${AIUR_TMP_REAP_MINUTES:-1440}"
|
|
1134
|
+
case "$minutes" in '' | *[!0-9]*) return 0 ;; esac
|
|
1135
|
+
[ "$minutes" -gt 0 ] || return 0
|
|
1136
|
+
|
|
1137
|
+
# The dirs the engine + BEAM target: ${TMPDIR:-/tmp} (argv/startup/pane tempfiles
|
|
1138
|
+
# plus the BEAM's System.tmp_dir debug dirs) and ${XDG_RUNTIME_DIR:-${TMPDIR:-/tmp}}
|
|
1139
|
+
# (the session/agent pidfiles' session_root). Deduped — a typical box scans one dir.
|
|
1140
|
+
local roots=() d seen
|
|
1141
|
+
for d in "${TMPDIR:-/tmp}" "${XDG_RUNTIME_DIR:-${TMPDIR:-/tmp}}"; do
|
|
1142
|
+
[ -d "$d" ] || continue
|
|
1143
|
+
seen=0
|
|
1144
|
+
local root
|
|
1145
|
+
if [ "${#roots[@]}" -gt 0 ]; then
|
|
1146
|
+
for root in "${roots[@]}"; do
|
|
1147
|
+
[ "$root" = "$d" ] && { seen=1; break; }
|
|
1148
|
+
done
|
|
1149
|
+
fi
|
|
1150
|
+
[ "$seen" -eq 1 ] || roots+=("$d")
|
|
1151
|
+
done
|
|
1152
|
+
[ "${#roots[@]}" -gt 0 ] || return 0
|
|
1153
|
+
|
|
1154
|
+
local removed=0 path base pid uid found
|
|
1155
|
+
uid="$(id -u)"
|
|
1156
|
+
for d in "${roots[@]}"; do
|
|
1157
|
+
while IFS= read -r -d '' path; do
|
|
1158
|
+
base="${path##*/}"
|
|
1159
|
+
is_aiur_tmp_artifact_candidate "$base" || continue
|
|
1160
|
+
|
|
1161
|
+
# Spare mixed-ownership trees; cleanup should never cross user boundaries.
|
|
1162
|
+
if ! found="$(find "$path" ! -user "$uid" -print -quit 2>/dev/null)"; then
|
|
1163
|
+
continue
|
|
1164
|
+
fi
|
|
1165
|
+
if [ -n "$found" ]; then
|
|
1166
|
+
continue
|
|
1167
|
+
fi
|
|
1168
|
+
# Spare anything modified within the window anywhere in its subtree.
|
|
1169
|
+
if ! found="$(find "$path" -mmin "-$minutes" -print -quit 2>/dev/null)"; then
|
|
1170
|
+
continue
|
|
1171
|
+
fi
|
|
1172
|
+
if [ -n "$found" ]; then
|
|
1173
|
+
continue
|
|
1174
|
+
fi
|
|
1175
|
+
# Spare a live run's session/agent bookkeeping (name is aiur-<pid>-<kind>).
|
|
1176
|
+
case "$base" in
|
|
1177
|
+
aiur-*-sessions | aiur-*-agents)
|
|
1178
|
+
pid="${base#aiur-}"
|
|
1179
|
+
pid="${pid%-*}"
|
|
1180
|
+
if [ -n "$pid" ] && [ -z "${pid//[0-9]/}" ] && kill -0 "$pid" 2>/dev/null; then
|
|
1181
|
+
continue
|
|
1182
|
+
fi
|
|
1183
|
+
;;
|
|
1184
|
+
esac
|
|
1185
|
+
if rm -rf -- "$path" 2>/dev/null; then
|
|
1186
|
+
removed=$((removed + 1))
|
|
1187
|
+
fi
|
|
1188
|
+
done < <(find "$d" -maxdepth 1 -mindepth 1 -name 'aiur-*' -print0 2>/dev/null)
|
|
1189
|
+
done
|
|
1190
|
+
|
|
1191
|
+
[ "$removed" -gt 0 ] && echo "🧹 swept $removed stale aiur tmp artifact(s)" >&2
|
|
1192
|
+
return 0
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
# Stop the running session: kill this instance's tmux + BEAM, then sweep.
|
|
604
1196
|
cmd_stop() {
|
|
605
1197
|
resolve_release
|
|
606
1198
|
aiur_resolve_identity
|
|
607
1199
|
|
|
608
1200
|
local tmux_bin
|
|
609
1201
|
tmux_bin="$(command -v tmux || true)"
|
|
610
|
-
local session="${AIUR_SESSION_PREFIX}-${USER:-user}-default"
|
|
611
|
-
local socket="${AIUR_SESSION_PREFIX}-${USER:-user}"
|
|
1202
|
+
local session="${AIUR_SESSION_PREFIX}-${USER:-user}${AIUR_INSTANCE_KEY:+-$AIUR_INSTANCE_KEY}-default"
|
|
1203
|
+
local socket="${AIUR_SESSION_PREFIX}-${USER:-user}${AIUR_INSTANCE_KEY:+-$AIUR_INSTANCE_KEY}"
|
|
1204
|
+
|
|
1205
|
+
# Tell the background BEAM-death watchdog this exit is intentional before we
|
|
1206
|
+
# kill the BEAM, so it consumes the sentinel instead of recording a crash. The
|
|
1207
|
+
# watchdog removes the sentinel when it fires; a fresh start also clears it.
|
|
1208
|
+
# Clear any prior crash marker too — `status` should report a clean stop, not
|
|
1209
|
+
# a stale orphan from an earlier dead run.
|
|
1210
|
+
mkdir -p "$AIUR_BG_STATE_DIR" 2>/dev/null || true
|
|
1211
|
+
: >"$(aiur_stop_sentinel_path)" 2>/dev/null || true
|
|
1212
|
+
rm -f "$(aiur_crash_marker_path)" 2>/dev/null || true
|
|
1213
|
+
|
|
612
1214
|
if [ -n "$tmux_bin" ]; then
|
|
613
1215
|
"$tmux_bin" -L "$socket" kill-session -t "$session" 2>/dev/null || true
|
|
614
1216
|
fi
|
|
615
1217
|
|
|
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
1218
|
# Reap any BEAM holding our node name regardless of which release dir launched
|
|
631
|
-
# it
|
|
1219
|
+
# it. Do not sweep by release dir: sibling instances share dev releases.
|
|
632
1220
|
kill_beams_matching "-name ${AIUR_RELEASE_NODE}"
|
|
633
1221
|
|
|
1222
|
+
# The BEAM (alive until the TERM above) reaped its own headless agents through
|
|
1223
|
+
# ProcessReaper on Application.stop. kill-server is the guarantee the earlier
|
|
1224
|
+
# kill-session can't give for mid-turn agents: every pane agent across all
|
|
1225
|
+
# windows dies and no live aiur tmux server is left behind.
|
|
1226
|
+
if [ -n "$tmux_bin" ]; then
|
|
1227
|
+
"$tmux_bin" -L "$socket" kill-server 2>/dev/null || true
|
|
1228
|
+
fi
|
|
1229
|
+
|
|
1230
|
+
# Belt-and-suspenders for a mid-turn stop: sweep any headless agent (this run
|
|
1231
|
+
# or a prior crashed one) still recorded in a pidfile. comm-guarded, so a
|
|
1232
|
+
# recycled pid is spared. Empty socket: the kill-server above already ran.
|
|
1233
|
+
local session_root agentfile
|
|
1234
|
+
session_root="${XDG_RUNTIME_DIR:-${TMPDIR:-/tmp}}"
|
|
1235
|
+
for agentfile in "$session_root"/aiur-*-agents; do
|
|
1236
|
+
[ -e "$agentfile" ] || continue
|
|
1237
|
+
reap_aiur_agents "" "$agentfile"
|
|
1238
|
+
rm -f "$agentfile" 2>/dev/null || true
|
|
1239
|
+
done
|
|
1240
|
+
|
|
634
1241
|
sweep_dead_tmux_sockets
|
|
1242
|
+
sweep_stale_tmp_artifacts
|
|
635
1243
|
}
|
|
636
1244
|
|
|
637
1245
|
# --- dispatch ----------------------------------------------------------------
|
|
@@ -663,10 +1271,22 @@ aiur_engine_main() {
|
|
|
663
1271
|
shift
|
|
664
1272
|
cmd_status "$@"
|
|
665
1273
|
;;
|
|
1274
|
+
agents)
|
|
1275
|
+
shift
|
|
1276
|
+
cmd_agents "$@"
|
|
1277
|
+
;;
|
|
1278
|
+
set)
|
|
1279
|
+
shift
|
|
1280
|
+
cmd_set "$@"
|
|
1281
|
+
;;
|
|
666
1282
|
pause | resume)
|
|
667
1283
|
shift
|
|
668
1284
|
cmd_pause_resume "$cmd" "$@"
|
|
669
1285
|
;;
|
|
1286
|
+
message)
|
|
1287
|
+
shift
|
|
1288
|
+
cmd_message "$@"
|
|
1289
|
+
;;
|
|
670
1290
|
stop)
|
|
671
1291
|
cmd_stop
|
|
672
1292
|
;;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "aiur-cli",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.3",
|
|
4
4
|
"description": "AI Unit Runtime for Executors.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"homepage": "https://github.com/its-everdred/aiur",
|
|
@@ -26,9 +26,9 @@
|
|
|
26
26
|
},
|
|
27
27
|
"opencodeVersion": "1.15.6",
|
|
28
28
|
"optionalDependencies": {
|
|
29
|
-
"aiur-cli-linux-x64": "0.0.
|
|
30
|
-
"aiur-cli-linux-arm64": "0.0.
|
|
31
|
-
"aiur-cli-darwin-x64": "0.0.
|
|
32
|
-
"aiur-cli-darwin-arm64": "0.0.
|
|
29
|
+
"aiur-cli-linux-x64": "0.0.3",
|
|
30
|
+
"aiur-cli-linux-arm64": "0.0.3",
|
|
31
|
+
"aiur-cli-darwin-x64": "0.0.3",
|
|
32
|
+
"aiur-cli-darwin-arm64": "0.0.3"
|
|
33
33
|
}
|
|
34
34
|
}
|
package/share/aiur.tmux.conf
CHANGED
|
@@ -20,6 +20,16 @@ set -g mouse on
|
|
|
20
20
|
set -s extended-keys on
|
|
21
21
|
set -as terminal-features "*:extkeys"
|
|
22
22
|
set -g status off
|
|
23
|
+
|
|
24
|
+
# Show each pane's title in a one-line top border. PaneManager sets every
|
|
25
|
+
# chat pane's title to "<issue id> <issue title>" via `select-pane -T`, and
|
|
26
|
+
# the launcher sets the agent-list pane's title to "AIUR Agents". The format
|
|
27
|
+
# truncates the title to the pane's current width with an ellipsis (tmux
|
|
28
|
+
# evaluates the nested `#{pane_width}` before applying the `=/N/…` limit), so
|
|
29
|
+
# a long title never wraps and re-clips automatically on resize.
|
|
30
|
+
set -g pane-border-status top
|
|
31
|
+
set -g pane-border-format "#{=/#{pane_width}/…:#{pane_title}}"
|
|
32
|
+
|
|
23
33
|
set -g history-limit 50000
|
|
24
34
|
set -g default-terminal "tmux-256color"
|
|
25
35
|
set -ga terminal-overrides ",*256col*:Tc"
|