@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
@@ -98,6 +98,23 @@ export declare const DEFAULT_RENDER_GOVERNOR = true;
98
98
  */
99
99
  export declare const DEFAULT_MAX_RENDER_INTERVAL_MS = 320;
100
100
  export declare function clampMaxRenderInterval(v: unknown, fallback: number): number;
101
+ /**
102
+ * Repaint only the frame lines that changed (full-screen only).
103
+ *
104
+ * ON by default, and `--no-diff-render` restores today's byte stream EXACTLY —
105
+ * the interposer is not constructed at all, so a regression on an exotic
106
+ * terminal is one flag away from being neutralised (§3.5 rung 2).
107
+ */
108
+ export declare const DEFAULT_DIFF_RENDER = true;
109
+ /**
110
+ * Wrap each repaint batch in DEC 2026 begin/end synchronized update.
111
+ *
112
+ * ON by default with NO capability probe (§3.3): terminals that do not implement
113
+ * mode 2026 parse the CSI sequence, find an unknown private mode and discard it,
114
+ * so a false positive costs nothing — and a false negative costs nothing either,
115
+ * because the frame differ already removes the flicker on its own.
116
+ */
117
+ export declare const DEFAULT_SYNC_OUTPUT = true;
101
118
  /** `ask_user` calls allowed per user turn. */
102
119
  export declare const DEFAULT_PLAN_MAX_ASK_ROUNDS = 4;
103
120
  /**
@@ -434,6 +451,44 @@ export declare const DEFAULT_FAST_CONFIG: FastConfig;
434
451
  * user really did configure a dead vendor as the MAIN one.
435
452
  */
436
453
  export declare function clampFastConfig(raw: unknown): FastConfig;
454
+ /** `auto` installs; `notify` only reports; `off` disables the subsystem. */
455
+ export declare const UPDATE_MODES: readonly ["auto", "notify", "off"];
456
+ export type UpdateMode = (typeof UPDATE_MODES)[number];
457
+ export declare function isUpdateMode(v: unknown): v is UpdateMode;
458
+ export interface UpdateConfig {
459
+ /**
460
+ * Whether the updater installs, merely reports, or does nothing at all.
461
+ *
462
+ * DEFAULTS TO `'auto'`, and that is a PRODUCT decision rather than a technical
463
+ * one: this CLI's users are overwhelmingly on a stale version they did not
464
+ * choose. The trade it makes — a wider supply-chain window in exchange for
465
+ * users who are actually on the current release — is stated plainly in the
466
+ * README (R-1), and `'off'` plus `ARAGON_UPDATE=0` are the documented kill
467
+ * switches.
468
+ */
469
+ mode: UpdateMode;
470
+ /** Minimum wall-clock between registry checks, MACHINE-WIDE (§3.7). */
471
+ checkIntervalMs: number;
472
+ /** `''` = derive: `ARAGON_UPDATE_REGISTRY` › `npm_config_registry` › npmjs. */
473
+ registry: string;
474
+ /** The dist-tag to track. */
475
+ distTag: string;
476
+ }
477
+ export declare const DEFAULT_UPDATE_CONFIG: UpdateConfig;
478
+ /**
479
+ * THE single gate for every update-config read AND write, mirroring
480
+ * `clampSkillsConfig` / `clampLogConfig` / `clampTeamConfig` / `clampTodoConfig`
481
+ * / `clampRetryConfig` / `clampFastConfig` for the reason all six record:
482
+ * hardening only the read path leaves a bad value on disk that reverts to the
483
+ * default on every launch, which presents to the user as "my setting won't
484
+ * stick".
485
+ *
486
+ * `registry` is CLAMPED TO `''` rather than rejected when it is not an
487
+ * `http(s):` URL. `''` means "derive it" (§3.3), which is the safe reading of a
488
+ * typo — the alternative is a background timer that throws on a value the user
489
+ * cannot see it reading.
490
+ */
491
+ export declare function clampUpdateConfig(raw: unknown): UpdateConfig;
437
492
  export interface PersistedConfig {
438
493
  version: number;
439
494
  provider: string;
@@ -492,6 +547,10 @@ export interface PersistedConfig {
492
547
  renderGovernor: boolean;
493
548
  /** Ceiling of the governor ladder in ms; `33` flattens it (L4). */
494
549
  maxRenderIntervalMs: number;
550
+ /** Repaint only the frame rows that changed (tui-input-flicker-fix §5.1). */
551
+ diffRender: boolean;
552
+ /** Wrap each repaint batch in DEC 2026 synchronized update (§5.1). */
553
+ syncOutput: boolean;
495
554
  confirmTools: boolean;
496
555
  toolTimeoutMs: number;
497
556
  idleTimeoutMs: number;
@@ -537,6 +596,8 @@ export interface PersistedConfig {
537
596
  retry: RetryConfig;
538
597
  /** Fast model tier. The SEVENTH nested object; scalars only (see FastConfig). */
539
598
  fast: FastConfig;
599
+ /** Auto-update. The EIGHTH nested object; scalars only (see UpdateConfig). */
600
+ update: UpdateConfig;
540
601
  }
541
602
  export declare const DEFAULT_CONFIG: PersistedConfig;
542
603
  /**
@@ -631,6 +692,20 @@ export interface CliConfig {
631
692
  renderGovernor: boolean;
632
693
  /** Ceiling of the governor ladder in ms; `33` flattens it (L4). */
633
694
  maxRenderIntervalMs: number;
695
+ /**
696
+ * Repaint only the frame rows that changed (tui-input-flicker-fix §5.1).
697
+ *
698
+ * IT MUST EXIST HERE AND NOT ONLY IN `PersistedConfig`, for the reason
699
+ * `showThinking` / `liveToolOutput` / `update` each record: `runInteractive`
700
+ * reads `config.diffRender`, where `config` is a `CliConfig`, so a key added to
701
+ * the persisted shape alone would round-trip through `config set`, appear in
702
+ * `config list` — and never reach the one gate that constructs the writer.
703
+ * `--no-diff-render` and `ARAGON_DIFF_RENDER=0` would parse and be discarded,
704
+ * with no compile error and no runtime error.
705
+ */
706
+ diffRender: boolean;
707
+ /** Wrap each repaint batch in DEC 2026 synchronized update (§5.1). */
708
+ syncOutput: boolean;
634
709
  confirmTools: boolean;
635
710
  toolTimeoutMs: number;
636
711
  idleTimeoutMs: number;
@@ -709,6 +784,22 @@ export interface CliConfig {
709
784
  * `/provider`, `/reload` and the settings screen.
710
785
  */
711
786
  fast: FastConfig;
787
+ /**
788
+ * Resolved auto-update settings (cli-auto-update §4.1).
789
+ *
790
+ * `mode` here decides whether `runInteractive` CONSTRUCTS the service at all
791
+ * (§3.8), which is what makes `'off'` byte-identical to a pre-feature build:
792
+ * no timer, no file read, no socket. The other three keys are read by the
793
+ * service on every check, so a `/update off` mid-session stops the next one.
794
+ *
795
+ * IT MUST EXIST HERE AND NOT ONLY IN `PersistedConfig`, for the reason
796
+ * `showThinking` and `liveToolOutput` each record above: the gate reads
797
+ * `cfg.update`, where `cfg` is a `CliConfig`, so a key added to the persisted
798
+ * shape alone would round-trip through `config set`, appear in `config list` —
799
+ * and never reach the gate. `--no-update` and `ARAGON_UPDATE=0` would parse
800
+ * and be discarded, with no compile error and no runtime error.
801
+ */
802
+ update: UpdateConfig;
712
803
  /**
713
804
  * Lifetime submit count driving the composer hint fade (`HINT_FADE_AFTER`).
714
805
  *
@@ -145,6 +145,30 @@ export function clampMaxRenderInterval(v, fallback) {
145
145
  return clampInt(v, fallback, RENDER_INTERVAL_RANGE);
146
146
  }
147
147
  // ---------------------------------------------------------------------------
148
+ // Frame diffing (tui-input-flicker-fix §5.1)
149
+ //
150
+ // TWO TROUBLESHOOTING SWITCHES, in the same class as `historyEnabled`: neither
151
+ // appears on the settings overlay, because a user who needs them is debugging a
152
+ // terminal, not adjusting a preference.
153
+ // ---------------------------------------------------------------------------
154
+ /**
155
+ * Repaint only the frame lines that changed (full-screen only).
156
+ *
157
+ * ON by default, and `--no-diff-render` restores today's byte stream EXACTLY —
158
+ * the interposer is not constructed at all, so a regression on an exotic
159
+ * terminal is one flag away from being neutralised (§3.5 rung 2).
160
+ */
161
+ export const DEFAULT_DIFF_RENDER = true;
162
+ /**
163
+ * Wrap each repaint batch in DEC 2026 begin/end synchronized update.
164
+ *
165
+ * ON by default with NO capability probe (§3.3): terminals that do not implement
166
+ * mode 2026 parse the CSI sequence, find an unknown private mode and discard it,
167
+ * so a false positive costs nothing — and a false negative costs nothing either,
168
+ * because the frame differ already removes the flicker on its own.
169
+ */
170
+ export const DEFAULT_SYNC_OUTPUT = true;
171
+ // ---------------------------------------------------------------------------
148
172
  // Plan mode (plan-mode §4.4)
149
173
  //
150
174
  // THREE FLAT KEYS, not a nested `planMode` object. `store.ts` documents that its
@@ -570,6 +594,81 @@ export function clampFastConfig(raw) {
570
594
  reviewMaxPerSession: clampInt(src.reviewMaxPerSession, DEFAULT_FAST_CONFIG.reviewMaxPerSession, REVIEW_SESSION_RANGE),
571
595
  };
572
596
  }
597
+ // ---------------------------------------------------------------------------
598
+ // Auto-update (cli-auto-update §4.1)
599
+ //
600
+ // The EIGHTH nested section in the persisted config, after `apiKeys`, `skills`,
601
+ // `log`, `team`, `todo`, `retry` and `fast` — and, like all of them, SCALARS
602
+ // ONLY, exactly one level deep. `store.ts` merges these sections BY HAND and
603
+ // that merge is only correct while nesting stays flat (C-5).
604
+ //
605
+ // BOTH OF `store.ts`'s MERGES MUST LEARN ABOUT THIS SECTION, AND THE OMISSION IS
606
+ // SILENT (C-4 / R-10). `config set update.mode notify` sends
607
+ // `{ update: { mode: 'notify' } }`; without the write merge the shallow
608
+ // top-level spread REPLACES the whole section and `checkIntervalMs`, `registry`
609
+ // and `distTag` silently revert on disk. This package has paid for that twice
610
+ // already — `todo` P0-1 and `fast` R-10 — which is why it is written down a
611
+ // third time rather than assumed to be common knowledge.
612
+ //
613
+ // FOUR KEYS AND NO MORE (D-16). `mode` covers on / notify / off; a fifth key
614
+ // (`autoInstall`) would be a second way to say the same thing, and two ways to
615
+ // express one setting is how they end up disagreeing.
616
+ // ---------------------------------------------------------------------------
617
+ /** `auto` installs; `notify` only reports; `off` disables the subsystem. */
618
+ export const UPDATE_MODES = ['auto', 'notify', 'off'];
619
+ export function isUpdateMode(v) {
620
+ return typeof v === 'string' && UPDATE_MODES.includes(v);
621
+ }
622
+ export const DEFAULT_UPDATE_CONFIG = {
623
+ mode: 'auto',
624
+ // FOUR HOURS, in ms. `checkIntervalMs` and not `checkIntervalHours` (D-17):
625
+ // every other duration in this config is in ms (`toolTimeoutMs`,
626
+ // `idleTimeoutMs`, `team.dispatchTimeoutMs`, `retry.initialDelayMs`), and one
627
+ // unit per file is worth more than one shorter number.
628
+ checkIntervalMs: 14_400_000,
629
+ registry: '',
630
+ distTag: 'latest',
631
+ };
632
+ /** 15 minutes to 7 days. The floor is a politeness bound on the registry. */
633
+ const UPDATE_INTERVAL_RANGE = { min: 900_000, max: 604_800_000 };
634
+ /** npm's own dist-tag grammar, loosely: no slashes, no spaces, no `@`. */
635
+ const DIST_TAG_RE = /^[a-z0-9][a-z0-9._-]{0,63}$/i;
636
+ /**
637
+ * THE single gate for every update-config read AND write, mirroring
638
+ * `clampSkillsConfig` / `clampLogConfig` / `clampTeamConfig` / `clampTodoConfig`
639
+ * / `clampRetryConfig` / `clampFastConfig` for the reason all six record:
640
+ * hardening only the read path leaves a bad value on disk that reverts to the
641
+ * default on every launch, which presents to the user as "my setting won't
642
+ * stick".
643
+ *
644
+ * `registry` is CLAMPED TO `''` rather than rejected when it is not an
645
+ * `http(s):` URL. `''` means "derive it" (§3.3), which is the safe reading of a
646
+ * typo — the alternative is a background timer that throws on a value the user
647
+ * cannot see it reading.
648
+ */
649
+ export function clampUpdateConfig(raw) {
650
+ const src = (raw && typeof raw === 'object' ? raw : {});
651
+ const registry = text(src.registry, DEFAULT_UPDATE_CONFIG.registry);
652
+ const distTag = text(src.distTag, DEFAULT_UPDATE_CONFIG.distTag);
653
+ return {
654
+ mode: isUpdateMode(src.mode) ? src.mode : DEFAULT_UPDATE_CONFIG.mode,
655
+ checkIntervalMs: clampInt(src.checkIntervalMs, DEFAULT_UPDATE_CONFIG.checkIntervalMs, UPDATE_INTERVAL_RANGE),
656
+ registry: isRegistryUrl(registry) ? registry : DEFAULT_UPDATE_CONFIG.registry,
657
+ distTag: DIST_TAG_RE.test(distTag) ? distTag : DEFAULT_UPDATE_CONFIG.distTag,
658
+ };
659
+ }
660
+ function isRegistryUrl(raw) {
661
+ if (raw.length === 0)
662
+ return false;
663
+ try {
664
+ const url = new URL(raw);
665
+ // `http:` is accepted for a LAN mirror; nothing else is a registry.
666
+ return url.protocol === 'https:' || url.protocol === 'http:';
667
+ }
668
+ catch {
669
+ return false;
670
+ }
671
+ }
573
672
  export const DEFAULT_CONFIG = {
574
673
  version: CONFIG_VERSION,
575
674
  provider: 'anthropic',
@@ -594,6 +693,8 @@ export const DEFAULT_CONFIG = {
594
693
  transcriptRetain: DEFAULT_TRANSCRIPT_RETAIN,
595
694
  renderGovernor: DEFAULT_RENDER_GOVERNOR,
596
695
  maxRenderIntervalMs: DEFAULT_MAX_RENDER_INTERVAL_MS,
696
+ diffRender: DEFAULT_DIFF_RENDER,
697
+ syncOutput: DEFAULT_SYNC_OUTPUT,
597
698
  confirmTools: false,
598
699
  toolTimeoutMs: DEFAULT_TOOL_TIMEOUT_MS,
599
700
  idleTimeoutMs: DEFAULT_IDLE_TIMEOUT_MS,
@@ -621,6 +722,12 @@ export const DEFAULT_CONFIG = {
621
722
  // falsy and so LOOKS correct — right up until `/fast on` writes a section that
622
723
  // has no other keys in it (P0-1's shape, one feature later).
623
724
  fast: DEFAULT_FAST_CONFIG,
725
+ // And one section later again, for the third time. Without this line a user
726
+ // whose `config.json` predates auto-update resolves `config.update.mode` to
727
+ // `undefined`, which is neither `'auto'` nor `'off'` — so the §3.8 gate
728
+ // (`cfg.update.mode !== 'off'`) would read TRUE and the feature would be on
729
+ // for a reason nobody could point at.
730
+ update: DEFAULT_UPDATE_CONFIG,
624
731
  };
625
732
  /**
626
733
  * Runtime state that used to be stored in `config.json` by mistake
@@ -14,7 +14,7 @@ import { dirname } from 'node:path';
14
14
  import process from 'node:process';
15
15
  import { getConfigPath, getSessionsDir } from './app-paths.js';
16
16
  import { registerSecretsFrom } from '../logging/secret-registry.js';
17
- import { CONFIG_VERSION, DEFAULT_CONFIG, clampFastConfig, clampLogConfig, clampMaxTokens, clampRetryConfig, clampSkillsConfig, clampTeamConfig, clampTodoConfig, isAutoToken, stripLegacyStateKeys, } from './schema.js';
17
+ import { CONFIG_VERSION, DEFAULT_CONFIG, clampFastConfig, clampLogConfig, clampMaxTokens, clampRetryConfig, clampSkillsConfig, clampTeamConfig, clampTodoConfig, clampUpdateConfig, isAutoToken, stripLegacyStateKeys, } from './schema.js';
18
18
  /**
19
19
  * Normalize `maxTokens` for both directions of the config file.
20
20
  *
@@ -134,6 +134,11 @@ export function loadPersistedConfig() {
134
134
  // READ half of the pair that keeps `/fast review 8` from silently switching
135
135
  // the whole tier off on the next launch (C-3 / R-10).
136
136
  fast: clampFastConfig({ ...DEFAULT_CONFIG.fast, ...(partial.fast ?? {}) }),
137
+ // `update` is the eighth. Same one-level rule, same merge — and the READ
138
+ // half is what supplies a whole section to every user whose `config.json`
139
+ // predates auto-update, which is all of them. Without it `config.update.mode`
140
+ // reads `undefined`, and the §3.8 gate tests `!== 'off'`.
141
+ update: clampUpdateConfig({ ...DEFAULT_CONFIG.update, ...(partial.update ?? {}) }),
137
142
  };
138
143
  }
139
144
  /**
@@ -196,6 +201,13 @@ export function updatePersistedConfig(patch) {
196
201
  // tomorrow with no error anywhere. This package has paid for that once
197
202
  // already, in `todo` (P0-1).
198
203
  fast: clampFastConfig({ ...current.fast, ...(patch.fast ?? {}) }),
204
+ // The eighth section, and the third time this file records the same trap.
205
+ // `config set update.mode notify` sends `{ update: { mode: 'notify' } }`;
206
+ // without this line the shallow top-level spread REPLACES the section,
207
+ // `checkIntervalMs` / `registry` / `distTag` are written absent, and an
208
+ // enterprise mirror the user configured last month is gone with no error
209
+ // anywhere. AC-20 is the assertion that proves both halves exist.
210
+ update: clampUpdateConfig({ ...current.update, ...(patch.update ?? {}) }),
199
211
  };
200
212
  writeConfigFile(merged);
201
213
  return merged;
@@ -0,0 +1,14 @@
1
+ /**
2
+ * The bin entry (cli-auto-update-hardening section 5.1.1).
3
+ *
4
+ * ASCII ONLY - this file is inside the glyph scanner's scope (C-19).
5
+ *
6
+ * KEEP IT THIS SMALL. Every import here is a module that can crash before the
7
+ * guard that exists to survive crashes. `node:process` is the only static one,
8
+ * and `__tests__/update-wiring.test.ts` asserts that on the source text.
9
+ *
10
+ * `dist/cli.js` IS UNCHANGED AND KEEPS ITS SHEBANG (R-17 / AC-50). Only the
11
+ * `bin` mapping moves; `npm start` (`node dist/cli.js`) and any user script that
12
+ * invokes `dist/cli.js` directly keep working byte for byte.
13
+ */
14
+ export {};
@@ -0,0 +1,43 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * The bin entry (cli-auto-update-hardening section 5.1.1).
4
+ *
5
+ * ASCII ONLY - this file is inside the glyph scanner's scope (C-19).
6
+ *
7
+ * KEEP IT THIS SMALL. Every import here is a module that can crash before the
8
+ * guard that exists to survive crashes. `node:process` is the only static one,
9
+ * and `__tests__/update-wiring.test.ts` asserts that on the source text.
10
+ *
11
+ * `dist/cli.js` IS UNCHANGED AND KEEPS ITS SHEBANG (R-17 / AC-50). Only the
12
+ * `bin` mapping moves; `npm start` (`node dist/cli.js`) and any user script that
13
+ * invokes `dist/cli.js` directly keep working byte for byte.
14
+ */
15
+ import process from 'node:process';
16
+ try {
17
+ // DYNAMIC, AND THAT IS THE WHOLE POINT (P1-2 / R-22). A STATIC import of the
18
+ // guard would put `boot/guard -> update/state -> config/app-paths ->
19
+ // env-paths` in the bin entry's OWN module graph, and a resolution failure
20
+ // anywhere in that chain throws during THIS module's evaluation - outside the
21
+ // `try`, before any line of it runs. "An undeclared dependency" is the first
22
+ // way section 1.1 says a release bricks itself; a guard whose own import fails
23
+ // in that exact case has to degrade to "no guard", never to "no CLI".
24
+ const { runBootGuard } = await import('./boot/guard.js');
25
+ runBootGuard();
26
+ }
27
+ catch {
28
+ // A guard that throws must never be the reason the CLI will not start. This is
29
+ // the one catch in the package that is allowed to be empty: there is no logger
30
+ // yet, and there is nothing the user could do with the message.
31
+ }
32
+ // The `.catch` is for the IMPORT rejecting, which is precisely the import-time
33
+ // crash H1 exists for and which today produces an unhandled rejection with a
34
+ // stack the user cannot act on. `cli.js` runs `main()` at module scope and
35
+ // installs its own `.catch` for everything after that point.
36
+ //
37
+ // EXITING NON-ZERO HERE IS WHAT FEEDS THE GUARD'S COUNTER (D-30a): the exit hook
38
+ // the guard installed counts non-zero exits, and a module that will not import
39
+ // raises, so it arrives here.
40
+ await import('./cli.js').catch((err) => {
41
+ process.stderr.write(`${err instanceof Error ? (err.stack ?? err.message) : String(err)}\n`);
42
+ process.exitCode = 1;
43
+ });
@@ -23,7 +23,7 @@
23
23
  import type { LogConfig } from '../config/schema.js';
24
24
  import { type LogLevelName } from './levels.js';
25
25
  /** Closed set, not free text: a typo'd scope is a record nobody can filter on. */
26
- export type LogScope = 'cli' | 'config' | 'agent' | 'tool' | 'skills' | 'llm' | 'migrate' | 'log' | 'fast' | 'history';
26
+ export type LogScope = 'cli' | 'config' | 'agent' | 'tool' | 'skills' | 'llm' | 'migrate' | 'log' | 'fast' | 'history' | 'update';
27
27
  export interface LogRecord {
28
28
  ts: string;
29
29
  lv: LogLevelName;
package/dist/ui/App.d.ts CHANGED
@@ -8,6 +8,7 @@ import React from 'react';
8
8
  import type { AgentController } from '../agent/controller.js';
9
9
  import type { ConfirmRequest } from '../tools/index.js';
10
10
  import { type Overlay, type ToastLevel } from '../agent/reducer.js';
11
+ import type { UpdateBridge } from '../update/types.js';
11
12
  import { type RenderMode } from './layout/frame.js';
12
13
  import type { MouseSource } from '../input/stdin-mouse-filter.js';
13
14
  import type { HumanInputBridge } from '../tools/human-input.js';
@@ -49,5 +50,14 @@ export interface AppProps {
49
50
  * is off: inline mode, `--no-mouse`, a non-TTY, or a filter that failed to
50
51
  * build (mouse-wheel-region-routing §4.2). */
51
52
  mouseSource?: MouseSource;
53
+ /**
54
+ * The auto-updater's late-arrival channel (cli-auto-update §3.8).
55
+ *
56
+ * A BRIDGE AND NOT THE SERVICE ITSELF, for the reason `confirmBridge` above is
57
+ * one: the service is built by a DYNAMIC `import()` fired after `render()`, so
58
+ * it does not exist when this component mounts. Absent under `update.mode:
59
+ * 'off'`, on a non-TTY, in CI, and in every test that does not exercise it.
60
+ */
61
+ updateBridge?: UpdateBridge;
52
62
  }
53
- export declare function App({ controller, version, mode, initialOverlay, initialPrompt, confirmBridge, humanInputBridge, mouseSource, }: AppProps): React.ReactElement;
63
+ export declare function App({ controller, version, mode, initialOverlay, initialPrompt, confirmBridge, humanInputBridge, mouseSource, updateBridge, }: AppProps): React.ReactElement;
package/dist/ui/App.js CHANGED
@@ -34,6 +34,15 @@ import { TodoPanel } from './TodoPanel.js';
34
34
  import { TodoStrip } from './TodoStrip.js';
35
35
  import { ActivityLine } from './ActivityLine.js';
36
36
  import { BottomStatusRow } from './BottomStatusRow.js';
37
+ import { UpdateLine } from './UpdateLine.js';
38
+ // The updater's two PURE modules. `shouldRenderUpdateLine` decides the row's
39
+ // presence AT THE CALL SITE (C-15), and `UPDATE_LIMITS` owns the width
40
+ // threshold - both are values, and both are resolved here rather than inside
41
+ // `UpdateLine` so that component's only edge into `update/` stays erasable by
42
+ // tsc (cli-auto-update section 3.1 rule 1 / IF-1). Neither module opens a
43
+ // socket, spawns a process or creates a timer.
44
+ import { shouldRenderUpdateLine } from '../update/types.js';
45
+ import { UPDATE_LIMITS } from '../update/limits.js';
37
46
  import { AppShell } from './layout/AppShell.js';
38
47
  import { ScrollViewport } from './layout/ScrollViewport.js';
39
48
  import { todoRailRows, todoRailWidth } from './layout/rail.js';
@@ -101,7 +110,7 @@ export function resolveSettingsMaxTokens(raw, previous) {
101
110
  function seedViewState(thinkingVisible) {
102
111
  return initialViewState({ thinkingVisible });
103
112
  }
104
- export function App({ controller, version, mode, initialOverlay, initialPrompt, confirmBridge, humanInputBridge, mouseSource, }) {
113
+ export function App({ controller, version, mode, initialOverlay, initialPrompt, confirmBridge, humanInputBridge, mouseSource, updateBridge, }) {
105
114
  const { exit } = useApp();
106
115
  const { stdout } = useStdout();
107
116
  const fullscreen = mode === 'fullscreen';
@@ -125,6 +134,14 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
125
134
  const [state, dispatch] = useReducer(viewReducer, cfg.showThinking, seedViewState);
126
135
  const [elapsedMs, setElapsedMs] = useState(0);
127
136
  const [confirmState, setConfirmState] = useState(null);
137
+ /**
138
+ * The updater's latest snapshot, or `null` while there is no service
139
+ * (cli-auto-update section 6.1).
140
+ *
141
+ * `UpdateSnapshot` is a VALUE OBJECT the service replaces wholesale on every
142
+ * transition, so this state can use referential equality and never diff fields.
143
+ */
144
+ const [updateSnapshot, setUpdateSnapshot] = useState(null);
128
145
  /** The request the question / plan overlay is currently rendering. */
129
146
  const [humanRequest, setHumanRequest] = useState(null);
130
147
  // Seeded from the history file, not from `CliConfig`: the list is no longer
@@ -200,6 +217,15 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
200
217
  // `ViewState` is a field `restoreEntries`, `/clear`, `/reset` and the session
201
218
  // round-trip each have to have an opinion about, and none of them has an
202
219
  // opinion about a 3-second timer.
220
+ /**
221
+ * Whether the one `installing -> ready` toast has already been pushed
222
+ * (cli-auto-update section 6.3).
223
+ *
224
+ * A REF AND NOT STATE: this must survive every re-render without causing one,
225
+ * and it is the whole of AC-19 - ten re-renders while the phase sits at
226
+ * `ready` must still produce exactly one toast.
227
+ */
228
+ const updateToastPushed = useRef(false);
203
229
  const budgetRef = useRef(emptyBudget());
204
230
  const followTimer = useRef(null);
205
231
  /** True while the run in flight was started by the grace timer, not the user. */
@@ -801,6 +827,48 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
801
827
  // --- Helpers -----------------------------------------------------------
802
828
  const notify = (level, text) => dispatch({ type: 'notice', level, text });
803
829
  const toast = (level, text) => dispatch({ type: 'pushToast', level, text });
830
+ // --- Auto-update bridge (cli-auto-update section 3.8 / 6.3). ------------
831
+ //
832
+ // TWO ARRIVAL ORDERS, AND BOTH HAPPEN. The service is built by a dynamic
833
+ // `import()` fired after `render()`, so on a warm module cache it can be
834
+ // attached BEFORE this effect runs, and on a cold one long after. Reading
835
+ // `bridge.service` first and installing `onAttach` second covers both without
836
+ // a race - the same shape `confirmBridge` uses one screen up.
837
+ useEffect(() => {
838
+ if (!updateBridge)
839
+ return undefined;
840
+ const bridge = updateBridge;
841
+ let unsubscribe = null;
842
+ const attach = (service) => {
843
+ setUpdateSnapshot(service.snapshot());
844
+ unsubscribe = service.subscribe(setUpdateSnapshot);
845
+ };
846
+ if (bridge.service)
847
+ attach(bridge.service);
848
+ else
849
+ bridge.onAttach = attach;
850
+ return () => {
851
+ bridge.onAttach = null;
852
+ unsubscribe?.();
853
+ };
854
+ }, [updateBridge]);
855
+ // ONE TOAST, ON THE `installing -> ready` EDGE ONLY (section 6.3 / AC-19).
856
+ //
857
+ // It exists because the moment of completion may arrive while the user is
858
+ // reading the transcript with the row occupied by a run: the toast wins the row
859
+ // for its TTL and then hands it back to the persistent line. The ref is what
860
+ // makes ten re-renders at `ready` push exactly one.
861
+ useEffect(() => {
862
+ if (updateSnapshot?.phase !== 'ready')
863
+ return;
864
+ if (updateToastPushed.current)
865
+ return;
866
+ updateToastPushed.current = true;
867
+ toast('info', `${updateSnapshot.latestVersion ?? 'Update'} installed - restart aragon to apply.`);
868
+ // `toast` is a stable dispatch wrapper; depending on it would re-run this on
869
+ // every render and defeat the ref guard's purpose rather than its mechanism.
870
+ // eslint-disable-next-line react-hooks/exhaustive-deps
871
+ }, [updateSnapshot?.phase, updateSnapshot?.latestVersion]);
804
872
  const persistConfig = (patch) => {
805
873
  try {
806
874
  updatePersistedConfig(patch);
@@ -858,6 +926,12 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
858
926
  // Read at command time, so `/todo status` reports the live count rather than
859
927
  // whatever it was when this context was built.
860
928
  followBudget: budgetRef.current,
929
+ // Read at command time for the same reason, and additionally because the
930
+ // service ATTACHES LATE (section 3.8): a value captured when this closure was
931
+ // first created would be `undefined` for the whole session on a cold module
932
+ // cache, and `/update` would report "disabled" on a session where it is on.
933
+ // `?? undefined` because the bridge holds `null` and the port is optional.
934
+ update: updateBridge?.service ?? undefined,
861
935
  });
862
936
  /**
863
937
  * Submitting a prompt no longer touches `config.json` at all — it appends one
@@ -1379,7 +1453,19 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
1379
1453
  // surface the run is not visible on. (Inline overlays are part of the
1380
1454
  // document flow, so there the suppression is conservative rather than
1381
1455
  // necessary — P2-5.)
1382
- activity: running && !overlayNode ? (_jsx(ActivityLine, { startedAt: runStartedAt.current, elapsedMs: elapsedMs, reducedMotion: reducedMotion, runningTool: runningTool, theme: theme, caps: caps })) : null, theme: theme }), composer: composer, status: _jsx(StatusBar, { model: cfg.model, provider: cfg.provider, usageTotal: state.usageTotal, contextTokens: state.contextTokens, contextWindow: modelInfo.contextWindow, contextWindowKnown: modelKnown, status: state.status, elapsedMs: elapsedMs, thinkingLevel: cfg.thinkingLevel, tokPerSec: tokPerSec, theme: theme, caps: caps, scrolledLines: scrolledLines, redrawNonce: redrawNonce, agentMode: state.agentMode, pendingAgentMode: state.pendingAgentMode, ecoRung: governor.rung, ...(state.team
1456
+ activity: running && !overlayNode ? (_jsx(ActivityLine, { startedAt: runStartedAt.current, elapsedMs: elapsedMs, reducedMotion: reducedMotion, runningTool: runningTool, theme: theme, caps: caps })) : null,
1457
+ // PRESENCE IS DECIDED HERE, NOT BY `UpdateLine` RETURNING `null`
1458
+ // (C-15 / cli-auto-update P0-1 / D-19). `BottomStatusRow` tests this
1459
+ // prop for truthiness, and a React element is truthy however it
1460
+ // renders - so a component that rendered nothing would give the row
1461
+ // ZERO rows and silently unbalance the frame budget. Same rule the
1462
+ // activity line above follows with `running && !overlayNode`.
1463
+ //
1464
+ // `!overlayNode` for the same reason it appears there: the full-screen
1465
+ // branch replaces the whole viewport with the overlay, and an update
1466
+ // notice under a settings screen is noise attached to a surface it is
1467
+ // not visible on.
1468
+ update: updateSnapshot && !overlayNode && shouldRenderUpdateLine(updateSnapshot) ? (_jsx(UpdateLine, { snapshot: updateSnapshot, compact: cols < UPDATE_LIMITS.statusCompactCols, theme: theme, caps: caps })) : null, theme: theme }), composer: composer, status: _jsx(StatusBar, { model: cfg.model, provider: cfg.provider, usageTotal: state.usageTotal, contextTokens: state.contextTokens, contextWindow: modelInfo.contextWindow, contextWindowKnown: modelKnown, status: state.status, elapsedMs: elapsedMs, thinkingLevel: cfg.thinkingLevel, tokPerSec: tokPerSec, theme: theme, caps: caps, scrolledLines: scrolledLines, redrawNonce: redrawNonce, agentMode: state.agentMode, pendingAgentMode: state.pendingAgentMode, ecoRung: governor.rung, ...(state.team
1383
1469
  ? {
1384
1470
  teamActive: {
1385
1471
  running: state.team.runs.filter((r) => r.phase !== 'queued' && r.phase !== 'done' &&
@@ -26,8 +26,13 @@
26
26
  * Sharing the row means `budget.ts` and `AppShell.tsx` come out of this round
27
27
  * UNMODIFIED — and `budget.test.ts` asserts that they did (AC-8a, DoD #8).
28
28
  *
29
- * PRECEDENCE: a toast wins. A transient ack is a RESPONSE TO THE USER, and the
30
- * row nearest the input belongs to it.
29
+ * PRECEDENCE: toast > activity > update > blank. A toast wins because a
30
+ * transient ack is a RESPONSE TO THE USER, and the row nearest the input belongs
31
+ * to it. The update line is LAST because it is the only one of the three that is
32
+ * PERSISTENT (cli-auto-update D-2): it loses nothing by waiting for the run to
33
+ * end, whereas an activity line deferred is an activity line that never renders.
34
+ * That also means a user who is actively working never sees the updater at all
35
+ * until they stop, which is the whole of the silence requirement.
31
36
  */
32
37
  import React from 'react';
33
38
  import type { Theme } from './theme.js';
@@ -38,6 +43,23 @@ export interface BottomStatusRowProps {
38
43
  toasts: Toast[];
39
44
  /** `<ActivityLine>` while a run owns the screen, else `null`. */
40
45
  activity: React.ReactNode;
46
+ /**
47
+ * `<UpdateLine>` when the updater has something to say, else `null` — AND THE
48
+ * CALLER DECIDES WHICH (C-15 / cli-auto-update P0-1).
49
+ *
50
+ * `if (update)` below tests a REACT ELEMENT, and an element whose own render
51
+ * returns `null` is still truthy: that branch would emit a box of ZERO rows,
52
+ * the frame would keep its fixed height, Yoga would hand the row to the
53
+ * transcript, and `viewportRows()` would go on reporting the old number to
54
+ * five consumers. So `UpdateLine` is typed `React.ReactElement` and `App`
55
+ * gates it with `shouldRenderUpdateLine(snapshot)`.
56
+ *
57
+ * OPTIONAL, because `bottom-status-row.test.tsx` and the `App.tsx` fixtures
58
+ * construct this component without it and the test tree is inside
59
+ * `tsconfig.test.json` (`typecheck-scope.test.ts`); a required prop turns
60
+ * `npm run typecheck` red for a reason unrelated to this feature (P2-1).
61
+ */
62
+ update?: React.ReactNode;
41
63
  theme: Theme;
42
64
  }
43
- export declare function BottomStatusRow({ mode, toasts, activity, theme, }: BottomStatusRowProps): React.ReactElement | null;
65
+ export declare function BottomStatusRow({ mode, toasts, activity, update, theme, }: BottomStatusRowProps): React.ReactElement | null;
@@ -1,14 +1,17 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { Box } from 'ink';
3
3
  import { ToastStack } from './ToastStack.js';
4
- export function BottomStatusRow({ mode, toasts, activity, theme, }) {
5
- // A toast wins the row; so does an empty toast stack in full-screen, whose
6
- // blank `<Text> </Text>` IS the budgeted row and must keep being emitted.
7
- if (toasts.length > 0 || !activity) {
4
+ export function BottomStatusRow({ mode, toasts, activity, update, theme, }) {
5
+ // A toast wins the row outright.
6
+ if (toasts.length > 0)
8
7
  return _jsx(ToastStack, { toasts: toasts, theme: theme, mode: mode });
9
- }
10
8
  // Inline has no frame and therefore nothing to protect: `ToastStack`'s inline
11
- // branch is already a conditional 0-3 row stack, and this is simply one more
9
+ // branch is already a conditional 0-3 row stack, and these are simply one more
12
10
  // conditional row above it.
13
- return _jsx(Box, { flexShrink: 0, children: activity });
11
+ if (activity)
12
+ return _jsx(Box, { flexShrink: 0, children: activity });
13
+ if (update)
14
+ return _jsx(Box, { flexShrink: 0, children: update });
15
+ // The blank full-screen row that IS the budget must keep being emitted.
16
+ return _jsx(ToastStack, { toasts: toasts, theme: theme, mode: mode });
14
17
  }
@@ -7,6 +7,16 @@
7
7
  * The buffer/cursor transforms (`applyEdit`, `moveVertical`) and the suggestion
8
8
  * computations (`slashSuggestions`, `fileTokenAt`) are pure module functions,
9
9
  * exported for `input.test.ts`, so the component itself stays thin.
10
+ *
11
+ * THE EDITOR'S FIVE PIECES OF STATE LIVE IN ONE REDUCER, and every branch of the
12
+ * input handler performs AT MOST ONE `dispatch` (tui-input-flicker-fix §3.4).
13
+ * Ink mounts a LEGACY React root, where `setState` outside a React event handler
14
+ * is not batched — and `useInput` runs from a stdin `'data'` listener, which is
15
+ * outside React entirely. The five `setState` calls one keystroke used to make
16
+ * were five commits, five whole-tree Yoga layouts and, through Ink's 32 ms
17
+ * leading+trailing throttle, two full-frame repaints per character. `fileMatches`
18
+ * stays a plain `useState` because it is written from the debounced glob effect
19
+ * rather than from a key.
10
20
  */
11
21
  import React from 'react';
12
22
  import type { Theme } from './theme.js';