@aragon-agent/cli 0.5.9 → 0.5.11

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.
Files changed (62) hide show
  1. package/CHANGELOG.md +1157 -1026
  2. package/README.md +123 -0
  3. package/dist/boot/guard.d.ts +135 -0
  4. package/dist/boot/guard.js +218 -0
  5. package/dist/boot/rollback.d.ts +86 -0
  6. package/dist/boot/rollback.js +185 -0
  7. package/dist/cli.js +376 -2
  8. package/dist/commands/builtins.js +118 -0
  9. package/dist/commands/perf.d.ts +5 -1
  10. package/dist/commands/perf.js +43 -1
  11. package/dist/commands/registry.d.ts +17 -0
  12. package/dist/config/app-paths.d.ts +31 -0
  13. package/dist/config/app-paths.js +37 -0
  14. package/dist/config/cli-commands.d.ts +27 -1
  15. package/dist/config/cli-commands.js +51 -2
  16. package/dist/config/env.d.ts +11 -1
  17. package/dist/config/env.js +58 -1
  18. package/dist/config/load.d.ts +30 -0
  19. package/dist/config/load.js +56 -1
  20. package/dist/config/schema.d.ts +91 -0
  21. package/dist/config/schema.js +107 -0
  22. package/dist/config/store.js +13 -1
  23. package/dist/launcher.d.ts +14 -0
  24. package/dist/launcher.js +43 -0
  25. package/dist/logging/logger.d.ts +1 -1
  26. package/dist/ui/App.d.ts +11 -1
  27. package/dist/ui/App.js +88 -2
  28. package/dist/ui/BottomStatusRow.d.ts +25 -3
  29. package/dist/ui/BottomStatusRow.js +10 -7
  30. package/dist/ui/PromptInput.d.ts +10 -0
  31. package/dist/ui/PromptInput.js +41 -48
  32. package/dist/ui/UpdateLine.d.ts +44 -0
  33. package/dist/ui/UpdateLine.js +129 -0
  34. package/dist/ui/editor-reducer.d.ts +62 -0
  35. package/dist/ui/editor-reducer.js +90 -0
  36. package/dist/ui/frame-differ.d.ts +95 -0
  37. package/dist/ui/frame-differ.js +230 -0
  38. package/dist/ui/stdout-frame-writer.d.ts +41 -0
  39. package/dist/ui/stdout-frame-writer.js +102 -0
  40. package/dist/update/classify-failure.d.ts +32 -0
  41. package/dist/update/classify-failure.js +60 -0
  42. package/dist/update/install-lock.d.ts +55 -0
  43. package/dist/update/install-lock.js +184 -0
  44. package/dist/update/install-source.d.ts +97 -0
  45. package/dist/update/install-source.js +218 -0
  46. package/dist/update/installer.d.ts +108 -0
  47. package/dist/update/installer.js +242 -0
  48. package/dist/update/limits.d.ts +111 -0
  49. package/dist/update/limits.js +111 -0
  50. package/dist/update/npm-view.d.ts +62 -0
  51. package/dist/update/npm-view.js +133 -0
  52. package/dist/update/registry.d.ts +52 -0
  53. package/dist/update/registry.js +161 -0
  54. package/dist/update/semver.d.ts +89 -0
  55. package/dist/update/semver.js +295 -0
  56. package/dist/update/service.d.ts +193 -0
  57. package/dist/update/service.js +636 -0
  58. package/dist/update/state.d.ts +103 -0
  59. package/dist/update/state.js +142 -0
  60. package/dist/update/types.d.ts +169 -0
  61. package/dist/update/types.js +68 -0
  62. package/package.json +3 -3
package/dist/cli.js CHANGED
@@ -23,7 +23,7 @@ import { formatMigrationNotice, migrateLegacyState } from './config/migrate-lega
23
23
  import { formatHomeMigrationNotice, migrateToHome } from './config/migrate-home.js';
24
24
  import { migrateStateOutOfConfig } from './config/migrate-state-out-of-config.js';
25
25
  import { runHistoryCommand } from './config/history-commands.js';
26
- import { FAST_CONFIG_SET_KEYS, LOG_CONFIG_SET_KEYS, RETRY_CONFIG_SET_KEYS, TEAM_CONFIG_SET_KEYS, applyFastConfigSet, applyLogConfigSet, applyRetryConfigSet, applyTeamConfigSet, runConfigEdit, runConfigGet, runConfigHome, runConfigList, warnIfRedactionDisabled, } from './config/cli-commands.js';
26
+ import { FAST_CONFIG_SET_KEYS, LOG_CONFIG_SET_KEYS, RETRY_CONFIG_SET_KEYS, TEAM_CONFIG_SET_KEYS, UPDATE_CONFIG_SET_KEYS, applyFastConfigSet, applyLogConfigSet, applyRetryConfigSet, applyTeamConfigSet, applyUpdateConfigSet, runConfigEdit, runConfigGet, runConfigHome, runConfigList, warnIfRedactionDisabled, } from './config/cli-commands.js';
27
27
  import { attachAgentEvents, attachTeamEvents, installLogging, setScreenRestore, setSignalTerminator, } from './logging/install.js';
28
28
  import { getLogger } from './logging/logger.js';
29
29
  import { runLogsCommand } from './logging/cli-commands.js';
@@ -34,6 +34,9 @@ import { runSkillsCommand } from './skills/cli-commands.js';
34
34
  import { runHeadless } from './agent/headless.js';
35
35
  import { App } from './ui/App.js';
36
36
  import { decideRenderMode } from './ui/layout/frame.js';
37
+ import { createFrameDiffer } from './ui/frame-differ.js';
38
+ import { wrapStdoutForFrames } from './ui/stdout-frame-writer.js';
39
+ import { setFrameStatsProvider } from './commands/perf.js';
37
40
  import { tryCreateMouseFilter } from './input/stdin-mouse-filter.js';
38
41
  import { enterAltScreen, writeExitTranscript } from './ui/screen.js';
39
42
  import { readExitSnapshot } from './ui/exit-snapshot.js';
@@ -41,6 +44,15 @@ import { renderTranscriptText } from './ui/transcript-text.js';
41
44
  import { detectCapabilities } from './ui/capabilities.js';
42
45
  import { pickGlyphs } from './ui/glyphs.js';
43
46
  import { DENY_ALL_HUMAN_INPUT } from './tools/human-input.js';
47
+ // A VALUE import, and the one deliberate exception to the paragraph above
48
+ // (cli-auto-update-hardening P2-7). `boot/guard.js` statically pulls
49
+ // `update/state.js`, so `aragon -p` now EVALUATES that module - it is pure at
50
+ // module scope, calls nothing, and `update-wiring.test.ts` walks the transitive
51
+ // graph against an allow-list so it cannot quietly grow. The alternative, a
52
+ // dynamic import for a two-line disarm on the hottest path in the file, buys
53
+ // nothing: the guard has already loaded and read the same module milliseconds
54
+ // earlier, in the launcher.
55
+ import { markBootHealthy } from './boot/guard.js';
44
56
  // ---------------------------------------------------------------------------
45
57
  // Version (read from the shipped package.json next to dist/)
46
58
  // ---------------------------------------------------------------------------
@@ -131,6 +143,25 @@ function toFlags(opts) {
131
143
  transcriptRetain: opts.transcriptRetain,
132
144
  renderGovernor: opts.renderGovernor,
133
145
  maxRenderInterval: opts.maxRenderInterval,
146
+ // Site three of three for `--diff-render` / `--sync-output` — EIGHT
147
+ // features, one trap (`mouse` P1-2, `team` P1-4, `todo` C-9, render L4,
148
+ // retry, fast, update, now frame diffing). A flag needs the `.option()`
149
+ // declaration below, a field on `CliFlags`, AND a line here; miss this one
150
+ // and the flag is inert with no compile error and no runtime error. It bites
151
+ // hardest for `--no-diff-render`: that is the documented escape hatch for a
152
+ // user whose terminal renders the diffed frame wrongly, and a user who has
153
+ // been told to pass it and sees no change has no next move.
154
+ diffRender: opts.diffRender,
155
+ syncOutput: opts.syncOutput,
156
+ // Site three of three for `--update` / `--no-update` — SEVEN features, one
157
+ // trap (`mouse` P1-2, `team` P1-4, `todo` C-9, render L4, retry, fast, now
158
+ // update). A flag needs the `.option()` declaration below, a field on
159
+ // `CliFlags`, AND a line here; miss this one and the flag is inert with no
160
+ // compile error and no runtime error. It bites hardest here of all: this is
161
+ // the KILL SWITCH for a feature that installs software, so a missing line
162
+ // means `--no-update` reports nothing, does nothing, and leaves the updater
163
+ // running.
164
+ update: opts.update,
134
165
  };
135
166
  }
136
167
  // ---------------------------------------------------------------------------
@@ -206,6 +237,20 @@ function makeController(flags, opts = { interactive: true }) {
206
237
  });
207
238
  return { controller, confirmBridge, humanInputBridge };
208
239
  }
240
+ /**
241
+ * Raised once per session, on the 0->1 edge of the writer's `fallbacks` counter
242
+ * (tui-input-flicker-fix §5.6 / AC-15).
243
+ *
244
+ * The characteristic failure of frame diffing is SILENT — absolute addressing
245
+ * loses its origin and the user sees a subtly wrong screen — so leaving its only
246
+ * signal behind a slash command the affected user has no reason to run is not an
247
+ * acceptable end state for a fix whose whole premise is "the user should not have
248
+ * to notice the renderer". `warn`, not `error`: a single fallback is self-healing
249
+ * by construction (I-6), and it is worth reporting only because the PATTERN is
250
+ * not.
251
+ */
252
+ const FRAME_FALLBACK_NOTICE = 'Frame diffing lost sync with the terminal and fell back to a full repaint. ' +
253
+ 'If the display looks wrong, restart with `--no-diff-render`.';
209
254
  /**
210
255
  * The interactive TUI — and the ONLY place that may touch the screen.
211
256
  *
@@ -254,6 +299,40 @@ function runInteractive(flags, extras = {}) {
254
299
  ? tryCreateMouseFilter(process.stdin, (reason) => logger.warn('cli', 'mouse_filter_failed', { reason }))
255
300
  : null;
256
301
  const disposeMouse = () => mouseFilter?.dispose();
302
+ // --- Frame differ (tui-input-flicker-fix §3.2 / §4.2). -------------------
303
+ //
304
+ // FULL-SCREEN ONLY, and that is invariant I-3 rather than caution: inline mode
305
+ // prints settled entries through Ink's `<Static>`, starts wherever the shell's
306
+ // cursor happened to be and scrolls, so row k of a frame is not absolute row k
307
+ // there and never will be. `--no-diff-render` skips construction entirely,
308
+ // which is what makes rung 2 of the fail-safe ladder byte-identical to a
309
+ // pre-fix build rather than merely equivalent.
310
+ //
311
+ // `rows` is a THUNK, not a snapshot: it is the only input to the I-9 geometry
312
+ // guard and it changes under the differ's feet on every resize.
313
+ const frameWriter = mode === 'fullscreen' && config.diffRender
314
+ ? wrapStdoutForFrames(process.stdout, createFrameDiffer({
315
+ sync: config.syncOutput && process.env.TERM !== 'dumb',
316
+ rows: () => process.stdout.rows,
317
+ // The console bridge (`App.tsx`, full-screen only) turns this into
318
+ // `dispatch({type:'notice', level:'warn'})`, which is the only legal
319
+ // user-visible channel with the TUI mounted — a direct write would
320
+ // itself be the foreign write being reported. Latched on the 0->1
321
+ // edge inside the differ: a notice per fallback would be a repaint
322
+ // storm triggered by a repaint problem (§5.6).
323
+ onFirstFallback: () => console.warn(FRAME_FALLBACK_NOTICE),
324
+ }))
325
+ : null;
326
+ // `null` when no writer was built, so `/perf` can say "diff render off" rather
327
+ // than printing zeroes that read like a writer doing nothing (§5.4).
328
+ if (frameWriter)
329
+ setFrameStatsProvider(frameWriter.stats);
330
+ const disposeFrameWriter = () => {
331
+ if (!frameWriter)
332
+ return;
333
+ setFrameStatsProvider(null);
334
+ frameWriter.dispose();
335
+ };
257
336
  const replayTranscript = () => {
258
337
  if (replayed || mode !== 'fullscreen' || !config.exitTranscript)
259
338
  return;
@@ -302,7 +381,26 @@ function runInteractive(flags, extras = {}) {
302
381
  process.exit(128 + signo);
303
382
  });
304
383
  }
305
- const instance = render(_jsx(App, { controller: controller, version: VERSION, mode: mode, confirmBridge: confirmBridge, humanInputBridge: humanInputBridge, initialPrompt: extras.initialPrompt, initialOverlay: extras.initialOverlay, mouseSource: mouseFilter?.source }),
384
+ // --- Auto-update (cli-auto-update section 3.8). --------------------------
385
+ //
386
+ // THE ONLY CONSTRUCTION SITE. `runOneShot` / `runHeadless` never reach this
387
+ // function, so `aragon -p` executes zero update code and its stdout is
388
+ // byte-identical to a pre-feature build (AC-1); `aragon config <subcommand>`,
389
+ // `aragon logs`, `aragon skills` and `aragon --version` likewise. Bare
390
+ // `aragon config` DOES construct it, because it is a full interactive session
391
+ // with a settings overlay on top - which is correct, and is why AC-1's claim
392
+ // names the SUBCOMMAND form.
393
+ //
394
+ // `CI` is excluded on top of the two TTY tests because a CI runner with a pty
395
+ // allocated is otherwise a machine that silently mutates its own global
396
+ // toolchain mid-pipeline (D-11).
397
+ const updateBridge = { service: null, onAttach: null };
398
+ let updateService = null;
399
+ const updateEligible = !!process.stdout.isTTY &&
400
+ !!process.stdin.isTTY &&
401
+ !process.env.CI &&
402
+ config.update.mode !== 'off';
403
+ const instance = render(_jsx(App, { controller: controller, version: VERSION, mode: mode, confirmBridge: confirmBridge, humanInputBridge: humanInputBridge, initialPrompt: extras.initialPrompt, initialOverlay: extras.initialOverlay, mouseSource: mouseFilter?.source, updateBridge: updateEligible ? updateBridge : undefined }),
306
404
  // Full-screen owns console.* itself (I-4): Ink's patchConsole writes
307
405
  // straight to stdout and permanently shifts the fixed frame's accounting.
308
406
  {
@@ -311,7 +409,50 @@ function runInteractive(flags, extras = {}) {
311
409
  // I-5: with mouse support off the REAL stdin is handed over unwrapped, so
312
410
  // every existing path is provably unchanged.
313
411
  stdin: mouseFilter?.stdin ?? process.stdin,
412
+ // SPREAD, not `frameWriter?.stdout ?? process.stdout`. This call passed no
413
+ // `stdout` at all before, and Ink defaults it to `process.stdout`; omitting
414
+ // the key entirely when the writer is off keeps the options object the same
415
+ // SHAPE it has always had, which is what makes AC-5 / AC-6 ("byte-identical
416
+ // with --no-diff-render, inline mode and `aragon -p`") a statement about
417
+ // the code rather than about Ink's defaulting.
418
+ ...(frameWriter ? { stdout: frameWriter.stdout } : {}),
314
419
  });
420
+ // H1's primary disarm, and this is the earliest moment at which "this build
421
+ // STARTS" is proven: the module graph loaded, config resolved, Ink mounted
422
+ // (cli-auto-update-hardening section 5.1.4). It is what makes a long session
423
+ // that is later `SIGKILL`ed - terminal window closed, machine slept badly, OOM
424
+ // killer - still count as healthy.
425
+ //
426
+ // UNGATED BY `updateEligible` ON PURPOSE. The guard is armed only by an
427
+ // auto-install, which `mode: 'off'` already prevents, so on a machine that
428
+ // never armed it this is one read and one string compare. Gating it would mean
429
+ // a user who switches `mode` to `off` between the install and the next launch
430
+ // leaves the guard armed with nothing able to disarm it.
431
+ markBootHealthy(VERSION);
432
+ if (updateEligible) {
433
+ // DYNAMIC, and fire-and-forget AFTER `render()` (D-25). Dynamic because a
434
+ // static import would make AC-1 false for `aragon -p`, which shares this
435
+ // module and has no bundler to shake the subsystem out; after `render()`
436
+ // because the service must never be able to delay or fail the first frame.
437
+ // The `.catch` is not decoration: a subsystem that cannot even be imported
438
+ // must degrade to "no updater", never to a crashed TUI.
439
+ void import('./update/service.js')
440
+ .then(({ UpdateService }) => {
441
+ const service = new UpdateService({ config: config.update, currentVersion: VERSION });
442
+ updateService = service;
443
+ updateBridge.service = service;
444
+ // `App` may have mounted first and parked its listener here; it may also
445
+ // not have mounted yet, in which case its own effect reads
446
+ // `bridge.service` directly. Both orders happen (IF-4).
447
+ updateBridge.onAttach?.(service);
448
+ service.start();
449
+ })
450
+ .catch((err) => {
451
+ logger.warn('update', 'update_service_unavailable', {
452
+ reason: err instanceof Error ? err.message : String(err),
453
+ });
454
+ });
455
+ }
315
456
  // The return value MUST be captured — `waitUntilExit()` is the normal-exit
316
457
  // restore path and it cannot be registered otherwise. `dispose()` runs on
317
458
  // BOTH branches (I-7): a live `'data'` listener on stdin keeps the event loop
@@ -329,16 +470,33 @@ function runInteractive(flags, extras = {}) {
329
470
  .then(() => {
330
471
  screen?.restore();
331
472
  disposeMouse();
473
+ // BOTH BRANCHES, the same discipline `controller.dispose()` and
474
+ // `updateService?.dispose()` already follow. It drops the `'resize'`
475
+ // listener of I-8 and detaches the `/perf` provider so a later `/perf` in a
476
+ // torn-down tree reports honestly instead of reading a dead closure.
477
+ disposeFrameWriter();
332
478
  // Orphaned subagents survive `Ctrl+C` otherwise, and a live child holds
333
479
  // the event loop open so `aragon` never returns to the shell (R-15). The
334
480
  // App's unmount cleanup also calls this; `dispose()` is idempotent.
335
481
  controller.dispose();
482
+ // BOTH BRANCHES, exactly as `controller.dispose()` is (section 3.6 / P2-6),
483
+ // and idempotent for the same reason. It clears the timers and drops the
484
+ // listeners; it NEVER signals the installer child (U-3), because killing a
485
+ // running `npm install -g` is the one action here that can leave the
486
+ // user's global installation broken - and it would be triggered by the
487
+ // most ordinary event there is, the user pressing Ctrl+C. The signal path
488
+ // above calls `process.exit()` and reaches neither branch, which is
489
+ // correct and is precisely WHY U-3 matters: the detached child survives
490
+ // that exit and completes.
491
+ updateService?.dispose();
336
492
  replayTranscript();
337
493
  })
338
494
  .catch(() => {
339
495
  screen?.restore();
340
496
  disposeMouse();
497
+ disposeFrameWriter();
341
498
  controller.dispose();
499
+ updateService?.dispose();
342
500
  });
343
501
  }
344
502
  async function runOneShot(flags, prompt, quiet) {
@@ -432,6 +590,10 @@ const CONFIG_SET_KEYS = new Set([
432
590
  'transcriptRetain',
433
591
  'renderGovernor',
434
592
  'maxRenderIntervalMs',
593
+ // tui-input-flicker-fix §5.1. Listed here AND cased in the switch below, for
594
+ // the reason `transcriptRetain` records three lines up.
595
+ 'diffRender',
596
+ 'syncOutput',
435
597
  // Without these two, `density` and `hints` would be settable only as one-shot
436
598
  // flags and never persist -- inconsistent with every other config field.
437
599
  'density',
@@ -471,6 +633,8 @@ const CONFIG_SET_KEYS = new Set([
471
633
  ...RETRY_CONFIG_SET_KEYS,
472
634
  // `fast.*` keys are handled by `applyFastConfigSet`, same reasoning again.
473
635
  ...FAST_CONFIG_SET_KEYS,
636
+ // `update.*` keys are handled by `applyUpdateConfigSet`, same reasoning again.
637
+ ...UPDATE_CONFIG_SET_KEYS,
474
638
  ]);
475
639
  /**
476
640
  * `aragon config set maxTokens <n|auto|default>`.
@@ -546,6 +710,17 @@ function runConfigSet(key, value) {
546
710
  process.stdout.write(`Set ${key} = ${String(merged.fast[field])}\n`);
547
711
  return;
548
712
  }
713
+ const updatePatch = applyUpdateConfigSet(key, value);
714
+ if (updatePatch) {
715
+ // Echoed from the STORED value, so `config set update.checkIntervalMs 5`
716
+ // prints the clamped 900000 rather than reporting a number that was never
717
+ // written (AC-20) — the rule `team.*`, `retry.*` and `fast.*` each state
718
+ // above.
719
+ const merged = updatePersistedConfig(updatePatch);
720
+ const field = key.slice('update.'.length);
721
+ process.stdout.write(`Set ${key} = ${String(merged.update[field])}\n`);
722
+ return;
723
+ }
549
724
  const patch = {};
550
725
  switch (key) {
551
726
  case 'provider':
@@ -590,6 +765,12 @@ function runConfigSet(key, value) {
590
765
  case 'maxRenderIntervalMs':
591
766
  patch.maxRenderIntervalMs = clampMaxRenderInterval(value, DEFAULT_MAX_RENDER_INTERVAL_MS);
592
767
  break;
768
+ case 'diffRender':
769
+ patch.diffRender = value === 'true' || value === '1';
770
+ break;
771
+ case 'syncOutput':
772
+ patch.syncOutput = value === 'true' || value === '1';
773
+ break;
593
774
  case 'density':
594
775
  patch.density = clampDensity(value, 'comfortable');
595
776
  break;
@@ -662,6 +843,166 @@ function runConfigSet(key, value) {
662
843
  updatePersistedConfig(patch);
663
844
  process.stdout.write(`Set ${key} = ${value}\n`);
664
845
  }
846
+ /**
847
+ * Exit codes (cli-auto-update section 4.3): `0` = up to date, or installed;
848
+ * `1` = the check or the install failed; `2` = an install source we will not
849
+ * install for (the advice line is printed); `4` = `--rollback` had nothing to
850
+ * roll back to (cli-auto-update-hardening section 6.2 / P2-8).
851
+ *
852
+ * A SOURCE WE DECLINE IS `2` AND NOT `1`, and the difference matters to the only
853
+ * consumer that reads an exit code - a script. `1` says "try again later"; `2`
854
+ * says "this machine will never auto-update, run the command we printed".
855
+ */
856
+ function updateExitCode(snapshot) {
857
+ // REASON BEFORE PHASE, and the order is the whole change (P1-1). The three
858
+ // classified failures all arrive with `phase: 'failed'` - they come from
859
+ // `recordFailure` - so leaving the phase test first, as it was, means these
860
+ // two lines can never be reached and the mapping is a silent no-op.
861
+ //
862
+ // THIS IS A BEHAVIOUR CHANGE FOR SCRIPTS AND IS CALLED ONE (R-23). A
863
+ // `blocked-by-os` install used to exit `1` ("try again later") and now exits
864
+ // `2` ("this machine will never auto-update, run the command we printed").
865
+ // That is the honest answer - on Windows the same `npm i -g` from the same
866
+ // shell fails the same way until the user closes something - and it is
867
+ // recorded in the CHANGELOG under the version that ships it.
868
+ if (snapshot.reason === 'blocked-by-os' || snapshot.reason === 'no-space')
869
+ return 2;
870
+ if (snapshot.reason === 'source-ineligible' || snapshot.reason === 'not-writable')
871
+ return 2;
872
+ if (snapshot.phase === 'failed')
873
+ return 1;
874
+ if (snapshot.reason === 'install-ineffective')
875
+ return 1;
876
+ return 0;
877
+ }
878
+ /** Human output: three lines at most (section 4.3). */
879
+ function formatUpdateResult(snapshot) {
880
+ const lines = [];
881
+ switch (snapshot.phase) {
882
+ case 'ready':
883
+ lines.push(`Installed ${snapshot.latestVersion ?? ''}. Restart aragon to apply.`);
884
+ break;
885
+ case 'available':
886
+ lines.push(snapshot.reason === 'install-ineffective'
887
+ ? `${snapshot.latestVersion ?? 'The new version'} installed somewhere else - ` +
888
+ 'check your npm prefix.'
889
+ : `${snapshot.latestVersion ?? 'A new version'} is available ` +
890
+ `(running ${snapshot.currentVersion}).`);
891
+ if (snapshot.advice)
892
+ lines.push(` ${snapshot.advice}`);
893
+ break;
894
+ case 'failed':
895
+ lines.push('Update failed. See the log for details.');
896
+ if (snapshot.advice)
897
+ lines.push(` ${snapshot.advice}`);
898
+ break;
899
+ default:
900
+ lines.push(`aragon ${snapshot.currentVersion} is up to date.`);
901
+ break;
902
+ }
903
+ if (snapshot.reason && snapshot.phase !== 'ready')
904
+ lines.push(` (${snapshot.reason})`);
905
+ return lines.slice(0, 3).join('\n');
906
+ }
907
+ /**
908
+ * `aragon update --rollback` - the MANUAL form of the boot guard's rollback
909
+ * (cli-auto-update-hardening section 6.2).
910
+ *
911
+ * It exists because a release can be BAD WITHOUT FAILING TO START, which is the
912
+ * case non-goal 2 declines to detect automatically precisely because a human
913
+ * detects it in one second. It shares `performRollback` verbatim, so the latch,
914
+ * the re-classification, the lock and the post-install verification are the same
915
+ * code the automatic path uses.
916
+ *
917
+ * Exit `4` for "nothing to roll back to" is a fourth code on a documented
918
+ * contract, so the contract's own comment above moves with it (P2-8). A rollback
919
+ * that RAN and failed is an ordinary failed install and keeps exit `1`.
920
+ */
921
+ async function runRollbackCommand() {
922
+ const { readUpdateState, updateUpdateState } = await import('./update/state.js');
923
+ const good = readUpdateState().lastGoodVersion;
924
+ if (!good) {
925
+ process.stderr.write('Nothing to roll back to: no previous version was recorded by an auto-update.\n');
926
+ return 4;
927
+ }
928
+ if (good === VERSION) {
929
+ process.stdout.write(`aragon ${VERSION} is already the last known-good version.\n`);
930
+ return 0;
931
+ }
932
+ const { performRollback } = await import('./boot/rollback.js');
933
+ const result = await performRollback(VERSION, good);
934
+ if (!result.ok) {
935
+ process.stderr.write(`Rollback to ${good} did not complete (${result.failure}).\n`);
936
+ return 1;
937
+ }
938
+ // THE NOTICE IS FOR A DOWNGRADE THE USER DID NOT ASK FOR, AND THIS ONE WAS
939
+ // TYPED. `performRollback` sets `rolledBackFrom` because it cannot tell which
940
+ // caller it has, and the field has exactly one rendering: `rolled back to <to>
941
+ // after <bad> FAILED TO START` (section 8). On this path that sentence is
942
+ // false - the version started fine, the user simply did not want it - and a
943
+ // bottom row asserting a crash that never happened is worse than no row.
944
+ //
945
+ // Clearing it here rather than teaching `performRollback` a mode keeps the
946
+ // "verbatim shared mechanism" of section 6.2 intact and adds no fifth state
947
+ // field under C-17. D-38's purpose survives: the notice exists so a SILENT
948
+ // downgrade cannot go unnoticed, and the line below is this one's notice.
949
+ // `/update status` still shows `lastgood`, and `update_rolled_back` is in the
950
+ // log either way.
951
+ updateUpdateState({ rolledBackFrom: '' });
952
+ process.stdout.write(`Rolled back to ${good}. Restart aragon to apply.\n`);
953
+ return 0;
954
+ }
955
+ /**
956
+ * `aragon update` - a deliberate, foreground check.
957
+ *
958
+ * PRINTS TO STDOUT, unlike every other update surface in this package: here the
959
+ * update IS the command's output, so C-10's "the TUI is the only legal visible
960
+ * channel" does not apply - there is no TUI.
961
+ *
962
+ * Reached through the SAME dynamic `import()` the interactive gate uses, so
963
+ * `dist/cli.js` still carries no static specifier under `update/` (AC-1).
964
+ */
965
+ async function runUpdateCommand(flags, opts) {
966
+ const config = loadConfig(flags);
967
+ getLogger().reconfigure(config.log);
968
+ const { UpdateService } = await import('./update/service.js');
969
+ const service = new UpdateService({
970
+ // `--check` is exactly `mode: 'notify'` FOR THIS RUN, and expressing it that
971
+ // way rather than with a second flag through the service means there is one
972
+ // code path deciding whether to install, not two.
973
+ config: opts.check ? { ...config.update, mode: 'notify' } : config.update,
974
+ currentVersion: VERSION,
975
+ });
976
+ try {
977
+ if (opts.rollback)
978
+ return await runRollbackCommand();
979
+ const target = opts.to?.trim();
980
+ if (target) {
981
+ // REPORTED, not silently ignored. `installNow` also refuses a malformed
982
+ // version (it is the one entry point whose target comes straight from
983
+ // argv), but there the refusal is a no-op snapshot that would exit 0 and
984
+ // tell the user they are up to date — which is a lie about the command
985
+ // they actually typed.
986
+ const { STRICT_VERSION_RE } = await import('./update/semver.js');
987
+ if (!STRICT_VERSION_RE.test(target)) {
988
+ process.stderr.write(`Not a version: "${target}". Use e.g. --to 0.6.0\n`);
989
+ return 1;
990
+ }
991
+ }
992
+ const snapshot = target
993
+ ? await service.installNow(target)
994
+ : await service.checkNow({ force: true });
995
+ process.stdout.write(opts.json ? `${JSON.stringify(snapshot)}\n` : `${formatUpdateResult(snapshot)}\n`);
996
+ return updateExitCode(snapshot);
997
+ }
998
+ catch (err) {
999
+ process.stderr.write(`update failed: ${err instanceof Error ? err.message : String(err)}\n`);
1000
+ return 1;
1001
+ }
1002
+ finally {
1003
+ service.dispose();
1004
+ }
1005
+ }
665
1006
  // ---------------------------------------------------------------------------
666
1007
  // CLI definition
667
1008
  // ---------------------------------------------------------------------------
@@ -751,6 +1092,14 @@ function buildProgram() {
751
1092
  .option('--fast-model <id>', 'The fast model id (implies nothing about --fast)')
752
1093
  .option('--fast-provider <id>', 'Provider for the fast model (default: the main provider)')
753
1094
  .option('--fast-review <n|off>', 'Turns between automatic fast reviews, or "off"')
1095
+ // BOTH FORMS, positive first, for the reason recorded seven times above —
1096
+ // and this is the pair whose omission would be worst. `update.mode` is
1097
+ // PERSISTED and defaults to `'auto'`, so a lone `--no-update` would make
1098
+ // commander default `opts.update` to `true` and silently overwrite a stored
1099
+ // `off` on EVERY run that passed no flag at all: the kill switch for a
1100
+ // feature that installs software would quietly un-set itself (C-11).
1101
+ .option('--update', 'Check for and install updates in the background (the default)')
1102
+ .option('--no-update', 'Disable auto-update for this session')
754
1103
  .option('--no-skills', 'Disable the skill system entirely (no catalog, no skill tools)')
755
1104
  .option('--skill <name>', 'Force-load a skill at Level 2 for this run (repeatable)', (value, previous = []) => [...previous, value])
756
1105
  .option('--skills-yes', 'Approve skill installs for this run (CI / headless)')
@@ -772,6 +1121,15 @@ function buildProgram() {
772
1121
  .option('--render-governor', 'Adapt the frame interval under load (the default)')
773
1122
  .option('--no-render-governor', 'Pin the frame interval at 33ms however expensive a frame is')
774
1123
  .option('--max-render-interval <ms>', 'Governor ceiling in ms (33-1000, default 320)')
1124
+ // Composer flicker (tui-input-flicker-fix §5.2). Positive form FIRST for the
1125
+ // tri-state reason recorded five times above: both keys are persisted and
1126
+ // default to `true`, so a negative-only declaration would let commander
1127
+ // synthesize `true` and overwrite a stored `false` on every run — turning the
1128
+ // one documented escape hatch for a mis-rendering terminal into a no-op.
1129
+ .option('--diff-render', 'Repaint only changed rows (default, full-screen only)')
1130
+ .option('--no-diff-render', 'Repaint the whole frame every time (pre-fix behavior)')
1131
+ .option('--sync-output', 'Ask the terminal to present each repaint atomically (the default)')
1132
+ .option('--no-sync-output', 'Do not emit DEC 2026 begin/end synchronized update')
775
1133
  .option('--no-color', 'Disable ANSI color')
776
1134
  .option('--quiet', '(print mode) suppress tool/usage lines on stderr')
777
1135
  .action(async (prompt, opts) => {
@@ -881,6 +1239,22 @@ function buildProgram() {
881
1239
  const code = await runSkillsCommand(subcommand ?? 'list', argument, opts, toFlags(program.opts()), VERSION);
882
1240
  process.exitCode = code;
883
1241
  });
1242
+ // aragon update [--check] [--to <version>] [--json]
1243
+ //
1244
+ // THE GUARANTEED REPORTING SURFACE (C-12 / D-18), and the only update path
1245
+ // available to a headless user at all - `runInteractive` is the sole place the
1246
+ // background service is constructed, so without this command `aragon -p` users
1247
+ // could never update deliberately.
1248
+ program
1249
+ .command('update')
1250
+ .description('Check for a new release and install it')
1251
+ .option('--check', 'Report only; never install')
1252
+ .option('--to <version>', 'Install an exact version (an explicit escape hatch)')
1253
+ .option('--rollback', 'Reinstall the version that ran before the last auto-update')
1254
+ .option('--json', 'Machine-readable result on stdout')
1255
+ .action(async (opts) => {
1256
+ process.exitCode = await runUpdateCommand(toFlags(program.opts()), opts);
1257
+ });
884
1258
  // aragon models [--provider p]
885
1259
  program
886
1260
  .command('models')
@@ -31,6 +31,62 @@ import { perfCommand } from './perf.js';
31
31
  * feature that is spending money is the same class of lie as pricing a Haiku
32
32
  * child at Sonnet rates.
33
33
  */
34
+ /**
35
+ * `/update status` - the human-readable readout (cli-auto-update §4.4).
36
+ *
37
+ * ASCII ONLY and NO GLYPHS: this string goes into a transcript `notice`, which
38
+ * `Transcript.tsx` renders with its own marker; the glyph tier belongs to the
39
+ * bottom-row line, not here. Everything a user could want to act on is named:
40
+ * what is running, what is available, how it got installed, and when we look
41
+ * again.
42
+ */
43
+ function formatUpdateStatus(port, override) {
44
+ const snapshot = override ?? port.snapshot();
45
+ const lines = [
46
+ `Auto-update: ${snapshot.phase}`,
47
+ ` running ${snapshot.currentVersion}`,
48
+ ` latest ${snapshot.latestVersion ?? 'unknown'}`,
49
+ ` source ${snapshot.source}`,
50
+ ];
51
+ if (snapshot.reason)
52
+ lines.push(` reason ${snapshot.reason}`);
53
+ if (snapshot.advice)
54
+ lines.push(` install ${snapshot.advice}`);
55
+ // The three hardening lines, each only when it has something to say
56
+ // (cli-auto-update-hardening section 6.3).
57
+ //
58
+ // THE KEY COLUMN IS TEN WIDE, WHICH IS WHY THE FIRST KEY IS `rollback` AND NOT
59
+ // `rolledback`. Section 6.3 asks for "a padded key, matching the six lines
60
+ // `formatUpdateStatus` already emits", and every one of those pads the key to
61
+ // ten so the values start in the same column - the entire reason a readout is
62
+ // padded at all. `rolledback` is ten characters on its own, so it cannot take
63
+ // a separating space without pushing its value one column past the other six,
64
+ // which is the misalignment the padding exists to prevent. The spec's sample
65
+ // block is internally consistent (all three new lines at eleven) and
66
+ // inconsistent with the six it says it matches; the prose is the requirement.
67
+ //
68
+ // THE ROLLBACK LINE CARRIES NO FAILURE COUNT on purpose: `bootFailures` is
69
+ // zeroed by the rollback itself, so any number printed here would be `0`. The
70
+ // count that mattered is in the `update_rolled_back` log record.
71
+ if (snapshot.rolledBackFrom) {
72
+ const to = snapshot.rolledBackTo ?? snapshot.currentVersion;
73
+ lines.push(` rollback ${snapshot.rolledBackFrom} -> ${to}`);
74
+ }
75
+ // What `aragon update --rollback` would reinstall. This is the only place a
76
+ // user can find that out BEFORE running the command.
77
+ if (snapshot.lastGoodVersion)
78
+ lines.push(` lastgood ${snapshot.lastGoodVersion}`);
79
+ // The only place a user can learn their machine updates through the fallback.
80
+ if (snapshot.probe) {
81
+ lines.push(` probe ${snapshot.probe === 'npm' ? 'npm (proxy fallback)' : 'http'}`);
82
+ }
83
+ if (snapshot.consecutiveFailures > 0) {
84
+ lines.push(` failures ${snapshot.consecutiveFailures} in a row`);
85
+ }
86
+ const next = override ? port.nextCheckAt() : snapshot.nextCheckAt;
87
+ lines.push(` next ${next ? new Date(next).toLocaleTimeString() : 'not scheduled'}`);
88
+ return lines.join('\n');
89
+ }
34
90
  function formatFastStatus(controller) {
35
91
  const status = controller.getFastStatus();
36
92
  const cfg = controller.getFastConfig();
@@ -458,6 +514,68 @@ const COMMANDS = [
458
514
  'delegate on|off].');
459
515
  },
460
516
  },
517
+ {
518
+ name: 'update',
519
+ description: 'Auto-update: status | now | skip | off',
520
+ /**
521
+ * THE GUARANTEED REPORTING SURFACE for the updater (C-12 / D-18).
522
+ *
523
+ * The bottom row can be preempted by a toast or by the activity line, and it
524
+ * is deliberately last in that precedence (D-2), so a user who is actually
525
+ * working may never see it. This command always answers "is it on, what did
526
+ * it find, and when does it look again".
527
+ *
528
+ * REACHES THE SERVICE THROUGH `ctx.update`, NOT THROUGH `ctx.controller`
529
+ * (C-14 / P0-2). `/fast`, `/team` and `/todo` ride on the controller because
530
+ * their subsystems live there; `update/` may not import `agent/` at all, so
531
+ * it carries its own optional port. `undefined` is a legitimate state - `off`,
532
+ * a non-TTY, or CI - and says so rather than pretending to have failed.
533
+ */
534
+ run: (ctx) => {
535
+ const arg = ctx.args.trim();
536
+ const [rawVerb] = arg.split(/\s+/, 1);
537
+ const verb = (rawVerb ?? '').toLowerCase();
538
+ const port = ctx.update;
539
+ // `/update off` is the one branch that works WITHOUT a service: turning
540
+ // something off must not require it to be running, or a user who launched
541
+ // with `--no-update` can never make that stick.
542
+ if (verb === 'off') {
543
+ ctx.persistConfig({ update: { mode: 'off' } });
544
+ ctx.toast('success', 'Auto-update off. Takes effect on the next launch.');
545
+ return;
546
+ }
547
+ if (!port) {
548
+ ctx.notify('info', 'Updates are disabled for this session (update.mode: off, a non-TTY, or CI). ' +
549
+ 'Enable with: aragon config set update.mode auto');
550
+ return;
551
+ }
552
+ if (!verb || verb === 'status') {
553
+ ctx.notify('info', formatUpdateStatus(port));
554
+ return;
555
+ }
556
+ if (verb === 'now') {
557
+ // `force: true` bypasses the machine-wide throttle - a human typing this
558
+ // is the one caller entitled to ignore `lastCheckAt`.
559
+ void port
560
+ .checkNow({ force: true })
561
+ .then((snapshot) => ctx.notify('info', formatUpdateStatus(port, snapshot)))
562
+ .catch(() => ctx.notify('warn', 'Update check failed. See the log for details.'));
563
+ ctx.toast('info', 'Checking for updates...');
564
+ return;
565
+ }
566
+ if (verb === 'skip') {
567
+ const version = port.snapshot().latestVersion;
568
+ if (!version) {
569
+ ctx.notify('info', 'No update is being offered, so there is nothing to skip.');
570
+ return;
571
+ }
572
+ port.skip(version);
573
+ ctx.toast('success', `Skipped ${version}. You will hear about the next release.`);
574
+ return;
575
+ }
576
+ ctx.notify('warn', `Unknown argument "${verb}" - use /update [status|now|skip|off].`);
577
+ },
578
+ },
461
579
  {
462
580
  name: 'todo',
463
581
  description: 'Todo planning: status | on | off | panel on|off | follow <mode> | clear | continue',
@@ -15,6 +15,7 @@
15
15
  * live panel, because a performance readout that costs a repaint per second to
16
16
  * display would be self-defeating.
17
17
  */
18
+ import type { FrameWriterStats } from '../ui/frame-differ.js';
18
19
  import type { SlashCommand } from './registry.js';
19
20
  export interface PerfSnapshot {
20
21
  /** Governor rung, its resolved interval, and the last measured commit cost. */
@@ -40,6 +41,9 @@ export interface PerfSnapshot {
40
41
  export declare function setPerfSnapshotProvider(fn: (() => PerfSnapshot | null) | null): void;
41
42
  export declare function readPerfSnapshot(): PerfSnapshot | null;
42
43
  export declare function setPerfResetHook(fn: (() => void) | null): void;
43
- /** Format a snapshot as the five-line block documented in §5.4. */
44
+ /** Called by `cli.tsx` after building the writer. Passing `null` detaches. */
45
+ export declare function setFrameStatsProvider(fn: (() => FrameWriterStats) | null): void;
46
+ export declare function readFrameStats(): FrameWriterStats | null;
47
+ /** Format a snapshot as the six-line block documented in §5.4. */
44
48
  export declare function formatPerfReport(snap: PerfSnapshot): string;
45
49
  export declare const perfCommand: SlashCommand;