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 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 a detached tmux session |
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))) {
@@ -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 (fixed: the single `aiur` 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
- : "${AIUR_RELEASE_NODE:=aiur-${USER}@127.0.0.1}"
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, 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
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 1 ] || injected+=(--interactive)
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
- startup_capture="$(mktemp "${TMPDIR:-/tmp}/aiur-startup.XXXXXX")"
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 AIUR_DEBUG; do
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
- # 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".
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
- # 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
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
- sleep 0.1
375
- done
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
- rm -f "$startup_capture" "$argv_file" 2>/dev/null || true
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
- 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
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
- 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
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
- # Belt-and-suspenders: reap any BEAM still holding our node name, even one
448
- # launched from a different release dir than this run.
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
- rm -f "$_session_tmpfile" "$_session_capture" "$_session_argv" 2>/dev/null || true
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
- echo "aiur: no running aiur node at ${RELEASE_NODE}; start aiur and try again" >&2
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
- # Stop the running session: kill the tmux session + the release BEAM, then sweep.
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 — orphans from a dev build or a prior install share the unified name.
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.2",
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.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"
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
  }
@@ -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"