aiur-cli 0.0.1 → 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
@@ -4,6 +4,14 @@
4
4
 
5
5
  Oversee a fleet of coding agents that **coordinate via events**.
6
6
 
7
+ > [!WARNING]
8
+ > **Use at your own risk.** Aiur is an unstable, vibecoded engineering preview for trusted
9
+ > environments only. It **bypasses all agent permission prompts** and has very few efficiency
10
+ > optimizations. Suggested for simple tasks under supervision.
11
+ >
12
+ > Provided "as is", without warranty of any kind. You assume all risk for any cost, token
13
+ > spend, data loss, or damage from running it.
14
+
7
15
  ## Install
8
16
 
9
17
  ```bash
@@ -13,6 +21,11 @@ npm install -g aiur-cli
13
21
  Requires [tmux](https://github.com/tmux/tmux) at runtime. Install
14
22
  [opencode](https://opencode.ai) too for in-pane agent chat.
15
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
+
16
29
  ## Get started
17
30
 
18
31
  ```bash
@@ -43,7 +56,7 @@ to the issues you want worked and run `aiur`.
43
56
  |---|---|
44
57
  | `aiur init` | Interactive setup wizard (scaffold `.aiurconfig`) |
45
58
  | `aiur` | Start the workflow in the foreground (local-only bind) |
46
- | `aiur --bg` | Start in a detached tmux session |
59
+ | `aiur --bg` | Start a headless BEAM in one detached tmux lifetime session |
47
60
  | `aiur status` | Show active agents and their state |
48
61
  | `aiur pause <id…>` / `resume <id…>` | Pause or resume agents by issue ID |
49
62
  | `aiur stop` | Stop the running session |
@@ -57,6 +70,12 @@ to the issues you want worked and run `aiur`.
57
70
  - **Smart Alerts** — Customize notifications and sounds when agents finish, stall, or need a decision.
58
71
  - **Live Dashboard** — Shareable web view of every agent, event, and progress bar.
59
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
+
60
79
  ---
61
80
 
62
81
  _Command macro, delegate micro, maximize APM._
package/bin/aiur.js CHANGED
@@ -1,9 +1,21 @@
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");
8
+
9
+ // Node's spawnSync reports a missing executable via `result.error`; some
10
+ // runtimes (e.g. Bun) throw instead. Normalize to the error-return shape so a
11
+ // missing tmux/opencode/brew degrades gracefully rather than crashing.
12
+ function safeSpawn(command, args, options) {
13
+ try {
14
+ return spawnSync(command, args, options);
15
+ } catch (error) {
16
+ return { error, status: null, stdout: "" };
17
+ }
18
+ }
7
19
 
8
20
  // Map this host to the platform package that carries its OTP release.
9
21
  const PLATFORMS = {
@@ -61,51 +73,360 @@ function resolveReleaseDir() {
61
73
  return path.join(path.dirname(pkgJson), "release");
62
74
  }
63
75
 
76
+ // Pinned opencode version — single source of truth is this package's
77
+ // package.json `opencodeVersion` (kept in sync with scripts/postinstall.mjs
78
+ // and mise.toml).
79
+ function opencodeVersion() {
80
+ const pkg = JSON.parse(
81
+ fs.readFileSync(path.join(__dirname, "..", "package.json"), "utf8"),
82
+ );
83
+ return pkg.opencodeVersion;
84
+ }
85
+
86
+ // ---------------------------------------------------------------------------
87
+ // Lazy first-run provisioning
88
+ //
89
+ // The npm postinstall (scripts/postinstall.mjs) provisions tmux + opencode, but
90
+ // bun/pnpm/yarn block postinstall scripts by default — a package cannot force a
91
+ // consumer to run them. So the launcher guarantees the peer tools here, on the
92
+ // first real (TUI) launch, regardless of which package manager installed
93
+ // aiur-cli. Both steps are idempotent (skip when already satisfied); tmux is a
94
+ // hard requirement, opencode is best-effort. Honors the same AIUR_SKIP_* env
95
+ // vars as the postinstall. This logic mirrors postinstall.mjs intentionally —
96
+ // the launcher stays dependency-free and cannot import the .mjs counterpart.
97
+ // ---------------------------------------------------------------------------
98
+
64
99
  function tmuxVersion() {
65
- const result = spawnSync("tmux", ["-V"], { encoding: "utf8" });
100
+ const result = safeSpawn("tmux", ["-V"], { encoding: "utf8" });
66
101
  if (result.error || result.status !== 0) return null;
67
102
  const match = /tmux\s+(\d+)\.(\d+)/.exec(result.stdout || "");
68
103
  if (!match) return null;
69
104
  return [Number(match[1]), Number(match[2])];
70
105
  }
71
106
 
107
+ function tmuxSatisfiesMin() {
108
+ const version = tmuxVersion();
109
+ if (!version) return false;
110
+ const [major, minor] = version;
111
+ const [needMajor, needMinor] = MIN_TMUX;
112
+ return major > needMajor || (major === needMajor && minor >= needMinor);
113
+ }
114
+
72
115
  function tmuxHint() {
73
116
  return process.platform === "darwin"
74
117
  ? "install it with: brew install tmux"
75
118
  : "install it with: apt install tmux (or your distro's package manager)";
76
119
  }
77
120
 
78
- function preflightTmux() {
79
- const version = tmuxVersion();
80
- if (!version) {
81
- fail(`tmux is required but was not found on PATH. ${tmuxHint()}`);
121
+ // Best-effort system install of tmux. macOS uses Homebrew; Linux uses the first
122
+ // package manager present. Never invokes sudo (a hidden password prompt mid-
123
+ // launch would hang). Returns false so the caller can fail with a clear hint.
124
+ function installTmux(platform) {
125
+ const has = (bin) => !safeSpawn(bin, ["--version"], { stdio: "ignore" }).error;
126
+ if (platform === "darwin") {
127
+ if (!has("brew")) return false;
128
+ return safeSpawn("brew", ["install", "tmux"], { stdio: "inherit" }).status === 0;
82
129
  }
83
- const [major, minor] = version;
84
- const [needMajor, needMinor] = MIN_TMUX;
85
- const tooOld =
86
- major < needMajor || (major === needMajor && minor < needMinor);
87
- if (tooOld) {
130
+ if (platform === "linux") {
131
+ const managers = [
132
+ ["apt-get", ["install", "-y", "tmux"]],
133
+ ["dnf", ["install", "-y", "tmux"]],
134
+ ["apk", ["add", "tmux"]],
135
+ ["pacman", ["-S", "--noconfirm", "tmux"]],
136
+ ];
137
+ for (const [bin, argv] of managers) {
138
+ if (!has(bin)) continue;
139
+ return safeSpawn(bin, argv, { stdio: "inherit" }).status === 0;
140
+ }
141
+ }
142
+ return false;
143
+ }
144
+
145
+ // tmux is required — the TUI runs inside it — so this provisions if missing and
146
+ // fails hard only when tmux still can't be made available.
147
+ function ensureTmux() {
148
+ if (tmuxSatisfiesMin()) return;
149
+ if (process.env.AIUR_SKIP_TMUX_INSTALL !== "1") {
150
+ process.stderr.write(
151
+ `aiur: tmux (>= ${MIN_TMUX.join(".")}) runs the interactive TUI; installing it…\n`,
152
+ );
153
+ installTmux(process.platform);
154
+ }
155
+ if (!tmuxSatisfiesMin()) {
156
+ const v = tmuxVersion();
88
157
  fail(
89
- `tmux ${major}.${minor} is too old; aiur needs ` +
90
- `>= ${needMajor}.${needMinor}. ${tmuxHint()}`,
158
+ v
159
+ ? `tmux ${v[0]}.${v[1]} is too old; aiur needs >= ${MIN_TMUX.join(".")}. ${tmuxHint()}`
160
+ : `tmux is required but was not found on PATH. ${tmuxHint()}`,
91
161
  );
92
162
  }
93
163
  }
94
164
 
95
- function preflightOpencode() {
96
- const result = spawnSync("opencode", ["--version"], { stdio: "ignore" });
97
- if (result.error || result.status !== 0) {
165
+ // True only when the pinned opencode is on PATH; a wrong version (e.g. 1.17.x,
166
+ // which broke the panes) must be replaced, so presence alone isn't enough.
167
+ function opencodeMatchesPin() {
168
+ const result = safeSpawn("opencode", ["--version"], { encoding: "utf8" });
169
+ return !result.error && result.status === 0 && (result.stdout || "").trim() === opencodeVersion();
170
+ }
171
+
172
+ // Install the pinned opencode globally. Forces online so it never inherits an
173
+ // --offline / prefer-offline context (CI, mirrors, air-gapped) that would fail
174
+ // on a cold cache — mirrors scripts/postinstall.mjs:installOpencode.
175
+ function installOpencode() {
176
+ const env = { ...process.env };
177
+ delete env.npm_config_offline;
178
+ delete env.npm_config_prefer_offline;
179
+ const result = safeSpawn(
180
+ "npm",
181
+ ["install", "-g", `opencode-ai@${opencodeVersion()}`, "--no-offline", "--no-prefer-offline"],
182
+ { stdio: "inherit", env },
183
+ );
184
+ return !result.error && result.status === 0;
185
+ }
186
+
187
+ // opencode powers the interactive "take the wheel" panes. Non-fatal: everything
188
+ // else works without it.
189
+ function ensureOpencode() {
190
+ if (opencodeMatchesPin()) return;
191
+ if (process.env.AIUR_SKIP_OPENCODE_INSTALL !== "1") {
98
192
  process.stderr.write(
99
- "aiur: opencode was not found on PATH. The interactive 'take the wheel' " +
100
- "feature needs it; everything else works without it. " +
101
- "Install from https://opencode.ai\n",
193
+ `aiur: provisioning opencode@${opencodeVersion()} (one-time, for the interactive 'take the wheel' panes)…\n`,
194
+ );
195
+ installOpencode();
196
+ if (opencodeMatchesPin()) return;
197
+ }
198
+ process.stderr.write(
199
+ "aiur: opencode was not found on PATH. The interactive 'take the wheel' " +
200
+ "feature needs it; everything else works without it. " +
201
+ "Install from https://opencode.ai\n",
202
+ );
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
+ },
102
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);
103
408
  }
104
409
  }
105
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
+
106
427
  // `init` and `--version` run as foreground one-shots that never start the
107
- // tmux-backed UI, so their tmux/opencode preflight is irrelevant — and tmux may
108
- // legitimately be absent on a machine that only ever runs `aiur init`.
428
+ // tmux-backed UI, so their tmux/opencode provisioning is irrelevant — and tmux
429
+ // may legitimately be absent on a machine that only ever runs `aiur init`.
109
430
  function isForegroundOneShot(argv) {
110
431
  for (const arg of argv) {
111
432
  if (arg === "--version") return true;
@@ -116,11 +437,19 @@ function isForegroundOneShot(argv) {
116
437
  }
117
438
 
118
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
+
119
448
  const releaseDir = resolveReleaseDir();
120
449
 
121
450
  if (!isForegroundOneShot(process.argv.slice(2))) {
122
- preflightTmux();
123
- preflightOpencode();
451
+ ensureTmux();
452
+ ensureOpencode();
124
453
  }
125
454
 
126
455
  const pkgRoot = path.resolve(__dirname, "..");