aiur-cli 0.0.2 → 0.0.4

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,8 +56,9 @@ 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 |
61
+ | `aiur alerts [--needs-attention]` | Show structured alert feed JSON lines |
56
62
  | `aiur pause <id…>` / `resume <id…>` | Pause or resume agents by issue ID |
57
63
  | `aiur stop` | Stop the running session |
58
64
 
@@ -65,6 +71,12 @@ to the issues you want worked and run `aiur`.
65
71
  - **Smart Alerts** — Customize notifications and sounds when agents finish, stall, or need a decision.
66
72
  - **Live Dashboard** — Shareable web view of every agent, event, and progress bar.
67
73
 
74
+ Background mode is headless inside Aiur: it skips the interactive agent-list
75
+ pane, chat/prewarm panes, and dashboard unless you explicitly opt into a
76
+ dashboard port. The launcher still keeps one detached tmux session as the BEAM
77
+ lifetime holder. Re-running `aiur --bg` against a live session exits with an
78
+ already-running hint; stale tmux state is cleaned up before restart.
79
+
68
80
  ---
69
81
 
70
82
  _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
@@ -161,8 +162,9 @@ function ensureTmux() {
161
162
  }
162
163
  }
163
164
 
164
- // True only when the pinned opencode is on PATH; a wrong version (e.g. 1.17.x,
165
- // which broke the panes) must be replaced, so presence alone isn't enough.
165
+ // True only when the pinned opencode is on PATH; a wrong (e.g. newer,
166
+ // unverified) version must be replaced with the pin, so presence alone
167
+ // isn't enough.
166
168
  function opencodeMatchesPin() {
167
169
  const result = safeSpawn("opencode", ["--version"], { encoding: "utf8" });
168
170
  return !result.error && result.status === 0 && (result.stdout || "").trim() === opencodeVersion();
@@ -201,6 +203,228 @@ function ensureOpencode() {
201
203
  );
202
204
  }
203
205
 
206
+ // ---------------------------------------------------------------------------
207
+ // Update notifier
208
+ //
209
+ // When a newer aiur-cli is published, surface a one-line "update available"
210
+ // notice — npm-update-notifier style: every run reads a small cache file and
211
+ // prints from it; the registry is only hit in a detached background worker,
212
+ // at most once per interval, so the check never blocks or errors the command.
213
+ // The notice always goes to stderr so piped stdout stays clean.
214
+ //
215
+ // This file is its own background worker: main() re-spawns it with
216
+ // AIUR_UPDATE_NOTIFIER_WORKER=1 set, which short-circuits to runUpdateWorker().
217
+ // Keeps the launcher dependency-free (a tiny semver compare + https.get).
218
+ // ---------------------------------------------------------------------------
219
+
220
+ const UPDATE_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24h
221
+ const REGISTRY_LATEST_URL = "https://registry.npmjs.org/aiur-cli/latest";
222
+ // Unpublished/source builds carry this sentinel; never nag on a dev checkout.
223
+ const DEV_VERSION = "0.0.0";
224
+
225
+ function currentVersion() {
226
+ try {
227
+ const pkg = JSON.parse(
228
+ fs.readFileSync(path.join(__dirname, "..", "package.json"), "utf8"),
229
+ );
230
+ return typeof pkg.version === "string" ? pkg.version : null;
231
+ } catch (_) {
232
+ return null;
233
+ }
234
+ }
235
+
236
+ // Minimal semver: parse major.minor.patch with an optional prerelease tag.
237
+ // Enough for the launcher's own clean release versions — not a full spec impl.
238
+ // The regex is fully anchored (and tolerates a +build suffix) so a registry- or
239
+ // cache-supplied string with trailing junk — e.g. control/ANSI bytes meant to
240
+ // hijack the terminal when the notice is printed — fails to parse and is treated
241
+ // as not-newer rather than reaching printUpdateNotice verbatim.
242
+ function parseSemver(value) {
243
+ const match = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/.exec(
244
+ String(value).trim(),
245
+ );
246
+ if (!match) return null;
247
+ return { major: +match[1], minor: +match[2], patch: +match[3], pre: match[4] || "" };
248
+ }
249
+
250
+ // True when `a` is strictly newer than `b`. A release outranks a prerelease at
251
+ // the same core version (1.0.0 > 1.0.0-rc.1); unparseable input is never newer.
252
+ function semverGt(a, b) {
253
+ const pa = parseSemver(a);
254
+ const pb = parseSemver(b);
255
+ if (!pa || !pb) return false;
256
+ if (pa.major !== pb.major) return pa.major > pb.major;
257
+ if (pa.minor !== pb.minor) return pa.minor > pb.minor;
258
+ if (pa.patch !== pb.patch) return pa.patch > pb.patch;
259
+ if (pa.pre === pb.pre) return false;
260
+ if (!pa.pre) return true; // a is a release, b is a prerelease
261
+ if (!pb.pre) return false; // a is a prerelease, b is a release
262
+ return pa.pre > pb.pre; // both prereleases: lexical tiebreak
263
+ }
264
+
265
+ function cacheFile() {
266
+ const base = process.env.XDG_CACHE_HOME
267
+ ? path.join(process.env.XDG_CACHE_HOME, "aiur")
268
+ : path.join(os.homedir(), ".aiur");
269
+ return path.join(base, "update-check.json");
270
+ }
271
+
272
+ function readCache() {
273
+ try {
274
+ return JSON.parse(fs.readFileSync(cacheFile(), "utf8"));
275
+ } catch (_) {
276
+ return null;
277
+ }
278
+ }
279
+
280
+ function writeCache(data) {
281
+ try {
282
+ const file = cacheFile();
283
+ fs.mkdirSync(path.dirname(file), { recursive: true });
284
+ // Write-then-rename so a reader never sees a half-written file: if the
285
+ // detached worker is killed mid-write, or two workers race, the rename is
286
+ // atomic on POSIX and a torn cache can't trigger a respawn-every-run loop.
287
+ const tmp = `${file}.${process.pid}.tmp`;
288
+ fs.writeFileSync(tmp, JSON.stringify(data));
289
+ fs.renameSync(tmp, file);
290
+ } catch (_) {
291
+ // A missing-cache write must never break the run; silently skip.
292
+ }
293
+ }
294
+
295
+ // Treat any non-falsy CI value except an explicit "false"/"0" as a CI run.
296
+ function isCI() {
297
+ const value = process.env.CI;
298
+ return !!value && value !== "false" && value !== "0";
299
+ }
300
+
301
+ function notifierDisabled(current) {
302
+ return (
303
+ process.env.AIUR_NO_UPDATE_NOTIFIER === "1" ||
304
+ isCI() ||
305
+ !current ||
306
+ current === DEV_VERSION
307
+ );
308
+ }
309
+
310
+ function printUpdateNotice(current, latest) {
311
+ process.stderr.write(
312
+ `aiur: a new version is available — ${current} → ${latest}\n` +
313
+ " update: npm install -g aiur-cli@latest\n",
314
+ );
315
+ }
316
+
317
+ // Synchronous, fast, never-throwing: print from cache when a newer version is
318
+ // known, then kick off a detached refresh if the cache is stale. The notice is
319
+ // TTY-gated so non-interactive/piped invocations stay quiet.
320
+ function maybeNotifyUpdate() {
321
+ try {
322
+ const current = currentVersion();
323
+ if (notifierDisabled(current)) return;
324
+
325
+ const cache = readCache();
326
+ if (
327
+ cache &&
328
+ cache.latest &&
329
+ process.stderr.isTTY &&
330
+ semverGt(cache.latest, current)
331
+ ) {
332
+ printUpdateNotice(current, cache.latest);
333
+ }
334
+
335
+ const stale =
336
+ !cache ||
337
+ typeof cache.lastCheck !== "number" ||
338
+ Date.now() - cache.lastCheck > UPDATE_CHECK_INTERVAL_MS;
339
+ if (stale) spawnUpdateWorker();
340
+ } catch (_) {
341
+ // The version check is best-effort; never let it affect the command.
342
+ }
343
+ }
344
+
345
+ function spawnUpdateWorker() {
346
+ try {
347
+ const child = spawn(process.execPath, [__filename], {
348
+ detached: true,
349
+ stdio: "ignore",
350
+ env: { ...process.env, AIUR_UPDATE_NOTIFIER_WORKER: "1" },
351
+ });
352
+ child.unref();
353
+ } catch (_) {
354
+ // If we can't detach a worker, just skip — the next run retries.
355
+ }
356
+ }
357
+
358
+ // Fetch the `latest` dist-tag's version. Bounded by a timeout and a response
359
+ // cap; any error resolves to null so the worker stays silent when offline.
360
+ function fetchLatestVersion(url, done) {
361
+ let settled = false;
362
+ const finish = (value) => {
363
+ if (settled) return;
364
+ settled = true;
365
+ done(value);
366
+ };
367
+ try {
368
+ const transport = url.startsWith("http://") ? require("node:http") : require("node:https");
369
+ const req = transport.get(
370
+ url,
371
+ { headers: { Accept: "application/json" }, timeout: 5000 },
372
+ (res) => {
373
+ if (res.statusCode !== 200) {
374
+ res.resume();
375
+ return finish(null);
376
+ }
377
+ let body = "";
378
+ res.setEncoding("utf8");
379
+ res.on("data", (chunk) => {
380
+ body += chunk;
381
+ // Abort an oversized body. req.destroy() emits only "close" (no "end"
382
+ // or "error"), so resolve here explicitly — otherwise the worker would
383
+ // hang forever and never stamp the cache, respawning on every run.
384
+ if (body.length > 1_000_000) {
385
+ req.destroy();
386
+ finish(null);
387
+ }
388
+ });
389
+ res.on("end", () => {
390
+ try {
391
+ const version = JSON.parse(body).version;
392
+ finish(typeof version === "string" ? version : null);
393
+ } catch (_) {
394
+ finish(null);
395
+ }
396
+ });
397
+ },
398
+ );
399
+ // A timeout's req.destroy() likewise emits only "close", so resolve here too
400
+ // rather than waiting on an event that never comes. "error" covers the
401
+ // connection-failure case (offline, refused).
402
+ req.on("timeout", () => {
403
+ req.destroy();
404
+ finish(null);
405
+ });
406
+ req.on("error", () => finish(null));
407
+ } catch (_) {
408
+ finish(null);
409
+ }
410
+ }
411
+
412
+ // Detached worker entrypoint: refresh the cache, then exit. Always stamps
413
+ // lastCheck (even on failure) so an offline machine doesn't respawn every run.
414
+ function runUpdateWorker() {
415
+ const previous = readCache();
416
+ const url = process.env.AIUR_REGISTRY_URL || REGISTRY_LATEST_URL;
417
+ fetchLatestVersion(url, (latest) => {
418
+ writeCache({
419
+ lastCheck: Date.now(),
420
+ // Keep the last known `latest` when the fetch fails so a transient outage
421
+ // doesn't drop a pending notice.
422
+ latest: latest || (previous && previous.latest) || null,
423
+ });
424
+ process.exit(0);
425
+ });
426
+ }
427
+
204
428
  // `init` and `--version` run as foreground one-shots that never start the
205
429
  // tmux-backed UI, so their tmux/opencode provisioning is irrelevant — and tmux
206
430
  // may legitimately be absent on a machine that only ever runs `aiur init`.
@@ -214,6 +438,14 @@ function isForegroundOneShot(argv) {
214
438
  }
215
439
 
216
440
  function main() {
441
+ // Detached refresh worker: do only the registry check, never the real launch.
442
+ if (process.env.AIUR_UPDATE_NOTIFIER_WORKER === "1") {
443
+ runUpdateWorker();
444
+ return;
445
+ }
446
+
447
+ maybeNotifyUpdate();
448
+
217
449
  const releaseDir = resolveReleaseDir();
218
450
 
219
451
  if (!isForegroundOneShot(process.argv.slice(2))) {