@aragon-agent/cli 0.5.9 → 0.5.10

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 (52) 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 +283 -2
  8. package/dist/commands/builtins.js +118 -0
  9. package/dist/commands/registry.d.ts +17 -0
  10. package/dist/config/app-paths.d.ts +31 -0
  11. package/dist/config/app-paths.js +37 -0
  12. package/dist/config/cli-commands.d.ts +27 -1
  13. package/dist/config/cli-commands.js +51 -2
  14. package/dist/config/env.d.ts +10 -1
  15. package/dist/config/env.js +41 -1
  16. package/dist/config/load.d.ts +17 -0
  17. package/dist/config/load.js +25 -1
  18. package/dist/config/schema.d.ts +56 -0
  19. package/dist/config/schema.js +81 -0
  20. package/dist/config/store.js +13 -1
  21. package/dist/launcher.d.ts +14 -0
  22. package/dist/launcher.js +43 -0
  23. package/dist/logging/logger.d.ts +1 -1
  24. package/dist/ui/App.d.ts +11 -1
  25. package/dist/ui/App.js +88 -2
  26. package/dist/ui/BottomStatusRow.d.ts +25 -3
  27. package/dist/ui/BottomStatusRow.js +10 -7
  28. package/dist/ui/UpdateLine.d.ts +44 -0
  29. package/dist/ui/UpdateLine.js +129 -0
  30. package/dist/update/classify-failure.d.ts +32 -0
  31. package/dist/update/classify-failure.js +60 -0
  32. package/dist/update/install-lock.d.ts +55 -0
  33. package/dist/update/install-lock.js +184 -0
  34. package/dist/update/install-source.d.ts +97 -0
  35. package/dist/update/install-source.js +218 -0
  36. package/dist/update/installer.d.ts +108 -0
  37. package/dist/update/installer.js +242 -0
  38. package/dist/update/limits.d.ts +111 -0
  39. package/dist/update/limits.js +111 -0
  40. package/dist/update/npm-view.d.ts +62 -0
  41. package/dist/update/npm-view.js +133 -0
  42. package/dist/update/registry.d.ts +52 -0
  43. package/dist/update/registry.js +161 -0
  44. package/dist/update/semver.d.ts +89 -0
  45. package/dist/update/semver.js +295 -0
  46. package/dist/update/service.d.ts +193 -0
  47. package/dist/update/service.js +636 -0
  48. package/dist/update/state.d.ts +103 -0
  49. package/dist/update/state.js +142 -0
  50. package/dist/update/types.d.ts +169 -0
  51. package/dist/update/types.js +68 -0
  52. package/package.json +3 -3
@@ -570,6 +570,81 @@ export function clampFastConfig(raw) {
570
570
  reviewMaxPerSession: clampInt(src.reviewMaxPerSession, DEFAULT_FAST_CONFIG.reviewMaxPerSession, REVIEW_SESSION_RANGE),
571
571
  };
572
572
  }
573
+ // ---------------------------------------------------------------------------
574
+ // Auto-update (cli-auto-update §4.1)
575
+ //
576
+ // The EIGHTH nested section in the persisted config, after `apiKeys`, `skills`,
577
+ // `log`, `team`, `todo`, `retry` and `fast` — and, like all of them, SCALARS
578
+ // ONLY, exactly one level deep. `store.ts` merges these sections BY HAND and
579
+ // that merge is only correct while nesting stays flat (C-5).
580
+ //
581
+ // BOTH OF `store.ts`'s MERGES MUST LEARN ABOUT THIS SECTION, AND THE OMISSION IS
582
+ // SILENT (C-4 / R-10). `config set update.mode notify` sends
583
+ // `{ update: { mode: 'notify' } }`; without the write merge the shallow
584
+ // top-level spread REPLACES the whole section and `checkIntervalMs`, `registry`
585
+ // and `distTag` silently revert on disk. This package has paid for that twice
586
+ // already — `todo` P0-1 and `fast` R-10 — which is why it is written down a
587
+ // third time rather than assumed to be common knowledge.
588
+ //
589
+ // FOUR KEYS AND NO MORE (D-16). `mode` covers on / notify / off; a fifth key
590
+ // (`autoInstall`) would be a second way to say the same thing, and two ways to
591
+ // express one setting is how they end up disagreeing.
592
+ // ---------------------------------------------------------------------------
593
+ /** `auto` installs; `notify` only reports; `off` disables the subsystem. */
594
+ export const UPDATE_MODES = ['auto', 'notify', 'off'];
595
+ export function isUpdateMode(v) {
596
+ return typeof v === 'string' && UPDATE_MODES.includes(v);
597
+ }
598
+ export const DEFAULT_UPDATE_CONFIG = {
599
+ mode: 'auto',
600
+ // FOUR HOURS, in ms. `checkIntervalMs` and not `checkIntervalHours` (D-17):
601
+ // every other duration in this config is in ms (`toolTimeoutMs`,
602
+ // `idleTimeoutMs`, `team.dispatchTimeoutMs`, `retry.initialDelayMs`), and one
603
+ // unit per file is worth more than one shorter number.
604
+ checkIntervalMs: 14_400_000,
605
+ registry: '',
606
+ distTag: 'latest',
607
+ };
608
+ /** 15 minutes to 7 days. The floor is a politeness bound on the registry. */
609
+ const UPDATE_INTERVAL_RANGE = { min: 900_000, max: 604_800_000 };
610
+ /** npm's own dist-tag grammar, loosely: no slashes, no spaces, no `@`. */
611
+ const DIST_TAG_RE = /^[a-z0-9][a-z0-9._-]{0,63}$/i;
612
+ /**
613
+ * THE single gate for every update-config read AND write, mirroring
614
+ * `clampSkillsConfig` / `clampLogConfig` / `clampTeamConfig` / `clampTodoConfig`
615
+ * / `clampRetryConfig` / `clampFastConfig` for the reason all six record:
616
+ * hardening only the read path leaves a bad value on disk that reverts to the
617
+ * default on every launch, which presents to the user as "my setting won't
618
+ * stick".
619
+ *
620
+ * `registry` is CLAMPED TO `''` rather than rejected when it is not an
621
+ * `http(s):` URL. `''` means "derive it" (§3.3), which is the safe reading of a
622
+ * typo — the alternative is a background timer that throws on a value the user
623
+ * cannot see it reading.
624
+ */
625
+ export function clampUpdateConfig(raw) {
626
+ const src = (raw && typeof raw === 'object' ? raw : {});
627
+ const registry = text(src.registry, DEFAULT_UPDATE_CONFIG.registry);
628
+ const distTag = text(src.distTag, DEFAULT_UPDATE_CONFIG.distTag);
629
+ return {
630
+ mode: isUpdateMode(src.mode) ? src.mode : DEFAULT_UPDATE_CONFIG.mode,
631
+ checkIntervalMs: clampInt(src.checkIntervalMs, DEFAULT_UPDATE_CONFIG.checkIntervalMs, UPDATE_INTERVAL_RANGE),
632
+ registry: isRegistryUrl(registry) ? registry : DEFAULT_UPDATE_CONFIG.registry,
633
+ distTag: DIST_TAG_RE.test(distTag) ? distTag : DEFAULT_UPDATE_CONFIG.distTag,
634
+ };
635
+ }
636
+ function isRegistryUrl(raw) {
637
+ if (raw.length === 0)
638
+ return false;
639
+ try {
640
+ const url = new URL(raw);
641
+ // `http:` is accepted for a LAN mirror; nothing else is a registry.
642
+ return url.protocol === 'https:' || url.protocol === 'http:';
643
+ }
644
+ catch {
645
+ return false;
646
+ }
647
+ }
573
648
  export const DEFAULT_CONFIG = {
574
649
  version: CONFIG_VERSION,
575
650
  provider: 'anthropic',
@@ -621,6 +696,12 @@ export const DEFAULT_CONFIG = {
621
696
  // falsy and so LOOKS correct — right up until `/fast on` writes a section that
622
697
  // has no other keys in it (P0-1's shape, one feature later).
623
698
  fast: DEFAULT_FAST_CONFIG,
699
+ // And one section later again, for the third time. Without this line a user
700
+ // whose `config.json` predates auto-update resolves `config.update.mode` to
701
+ // `undefined`, which is neither `'auto'` nor `'off'` — so the §3.8 gate
702
+ // (`cfg.update.mode !== 'off'`) would read TRUE and the feature would be on
703
+ // for a reason nobody could point at.
704
+ update: DEFAULT_UPDATE_CONFIG,
624
705
  };
625
706
  /**
626
707
  * 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
  }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * The update line (cli-auto-update section 6.2) - the THIRD occupant of the
3
+ * already-budgeted bottom row, after the toast stack and the activity line.
4
+ *
5
+ * ONE ROW, ALWAYS, AND NEVER `null` (C-15 / P0-1 / D-19). The return type is
6
+ * `React.ReactElement` rather than `React.ReactElement | null`, so the compiler
7
+ * refuses the shortcut: `BottomStatusRow` decides presence with `if (update)`,
8
+ * which tests a REACT ELEMENT, and an element whose render returns `null` is
9
+ * still truthy. Taking that branch would emit a box of ZERO rows - the frame
10
+ * keeps its fixed height, Yoga hands the row to the transcript, and
11
+ * `viewportRows()` goes on reporting the old number to `ScrollViewport`,
12
+ * `selectWindow`, `overlayMaxRows`, `popupMaxRows` and `todoRailRows`. The
13
+ * transcript would gain a row while the updater is idle and lose it the moment
14
+ * it has news. Presence is the CALLER's decision, through
15
+ * `shouldRenderUpdateLine`, exactly as `App.tsx` already decides the activity
16
+ * line's with `running && !overlayNode`.
17
+ *
18
+ * PURE: a function of `snapshot + compact + theme + caps`. No state, no timer,
19
+ * no clock. `<Text wrap="truncate">` so a narrow terminal degrades by truncation
20
+ * rather than by wrapping into a second row the fixed frame cannot afford.
21
+ *
22
+ * ASCII ONLY, and every user-visible character comes from `pickGlyphs(caps)`
23
+ * (C-3). No literal `...`, `+` or `~` may be spelled inline - `ActivityLine`'s
24
+ * routing of even the ellipsis through `pickGlyphs` is the precedent.
25
+ *
26
+ * `compact` RATHER THAN `cols` (IF-1). Section 6.2 describes the width tiers in
27
+ * terms of `cols`, but resolving them here would mean importing
28
+ * `UPDATE_LIMITS.statusCompactCols` - a VALUE - from `update/`, and section 3.1
29
+ * rule 1 requires this module's only edge into that tree to be erasable by tsc.
30
+ * The caller owns `UPDATE_LIMITS` already (it computes presence from it), so it
31
+ * resolves the threshold too. Same rule as presence: the call site decides.
32
+ */
33
+ import React from 'react';
34
+ import type { Theme } from './theme.js';
35
+ import type { TermCapabilities } from './capabilities.js';
36
+ import type { UpdateSnapshot } from '../update/types.js';
37
+ export interface UpdateLineProps {
38
+ snapshot: UpdateSnapshot;
39
+ /** `cols < UPDATE_LIMITS.statusCompactCols`, resolved by the caller. */
40
+ compact: boolean;
41
+ theme: Theme;
42
+ caps: TermCapabilities;
43
+ }
44
+ export declare function UpdateLine({ snapshot, compact, theme, caps }: UpdateLineProps): React.ReactElement;
@@ -0,0 +1,129 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from 'ink';
3
+ import { pickGlyphs } from './glyphs.js';
4
+ export function UpdateLine({ snapshot, compact, theme, caps }) {
5
+ const glyphs = pickGlyphs(caps);
6
+ const version = snapshot.latestVersion ?? snapshot.currentVersion;
7
+ const advice = snapshot.advice ?? '';
8
+ const dot = glyphs.midDot;
9
+ const line = (() => {
10
+ if (snapshot.phase === 'installing') {
11
+ return {
12
+ glyph: glyphs.arrowUp,
13
+ text: compact
14
+ ? `${version}${glyphs.ellipsis}`
15
+ : `updating to ${version}${glyphs.ellipsis}`,
16
+ color: theme.muted,
17
+ };
18
+ }
19
+ if (snapshot.phase === 'ready') {
20
+ return {
21
+ glyph: glyphs.check,
22
+ text: compact ? `${version} ready` : `${version} installed ${dot} restart aragon to apply`,
23
+ color: theme.toast.success,
24
+ };
25
+ }
26
+ if (snapshot.phase === 'failed') {
27
+ // H2's three classified failures (cli-auto-update-hardening section 8).
28
+ // Each names the REAL obstacle rather than the generic one, because each
29
+ // is allowed to break the three-strike silence and a notice that breaks
30
+ // silence must be worth the interruption.
31
+ if (snapshot.reason === 'blocked-by-os') {
32
+ // `npm i -g` FROM THE SAME SHELL HITS THE SAME HANDLE. On Windows the
33
+ // shim that launched us is being read by the `cmd.exe` running it for
34
+ // as long as our process lives, and npm's install rewrites that shim -
35
+ // so the generic advice is not merely unhelpful here, it is a wrong
36
+ // instruction (R-3 / section 5.3.1).
37
+ return {
38
+ glyph: glyphs.warn,
39
+ text: compact || !advice
40
+ ? 'update blocked'
41
+ : `update blocked ${dot} close other aragon windows, then: ${advice}`,
42
+ color: theme.noticeWarn,
43
+ };
44
+ }
45
+ if (snapshot.reason === 'no-space') {
46
+ return {
47
+ glyph: glyphs.warn,
48
+ text: compact ? 'no disk space' : `update failed ${dot} no disk space`,
49
+ color: theme.noticeWarn,
50
+ };
51
+ }
52
+ if (snapshot.reason === 'not-writable') {
53
+ return {
54
+ glyph: glyphs.warn,
55
+ text: compact || !advice
56
+ ? 'update blocked'
57
+ : `update needs write access ${dot} ${advice}`,
58
+ color: theme.noticeWarn,
59
+ };
60
+ }
61
+ return {
62
+ glyph: glyphs.warn,
63
+ text: compact || !advice ? 'update failed' : `update failed ${dot} ${advice}`,
64
+ color: theme.noticeWarn,
65
+ };
66
+ }
67
+ if (snapshot.phase === 'available') {
68
+ if (snapshot.reason === 'node-too-old') {
69
+ // Both numbers or neither: "needs a newer Node" without saying which is
70
+ // a notice the user cannot act on.
71
+ const detail = snapshot.requiredNode && snapshot.runningNode
72
+ ? `${version} needs Node ${snapshot.requiredNode} (running ${snapshot.runningNode})`
73
+ : `${version} needs a newer Node`;
74
+ return {
75
+ glyph: glyphs.warn,
76
+ text: compact ? 'needs newer Node' : detail,
77
+ color: theme.noticeWarn,
78
+ };
79
+ }
80
+ if (snapshot.reason === 'install-ineffective') {
81
+ return {
82
+ glyph: glyphs.warn,
83
+ text: compact
84
+ ? 'check npm prefix'
85
+ : `${version} installed elsewhere${advice ? ` ${dot} ${advice}` : ''}`,
86
+ color: theme.noticeWarn,
87
+ };
88
+ }
89
+ return {
90
+ glyph: glyphs.arrowUp,
91
+ text: compact
92
+ ? version
93
+ : `${version} available${advice ? ` ${dot} ${advice}` : ''}`,
94
+ color: theme.muted,
95
+ };
96
+ }
97
+ // H1's rollback notice, and it goes HERE rather than in a phase branch
98
+ // (cli-auto-update-hardening section 8 / P0-1). It is true for a whole
99
+ // SESSION rather than for a phase, so `shouldRenderUpdateLine` decides its
100
+ // presence from `snapshot.rolledBackFrom` outside `phase` entirely - and the
101
+ // phases that leaves are exactly `idle` / `checking`, which is this
102
+ // fallthrough. An earlier draft put it in the `failed` / `available`
103
+ // branches and keyed it on phase `idle`, a phase neither branch handles;
104
+ // three independent suppressions, so the one line telling a user their CLI
105
+ // had been silently downgraded could never have appeared.
106
+ //
107
+ // NOT `version` ABOVE: that is `latestVersion ?? currentVersion`, and after
108
+ // a rollback `latestVersion` may well be the BAD release we just escaped.
109
+ if (snapshot.rolledBackFrom) {
110
+ const to = snapshot.rolledBackTo ?? snapshot.currentVersion;
111
+ return {
112
+ glyph: glyphs.warn,
113
+ text: compact
114
+ ? 'rolled back'
115
+ : `rolled back to ${to} after ${snapshot.rolledBackFrom} failed to start`,
116
+ color: theme.noticeWarn,
117
+ };
118
+ }
119
+ // `idle` / `checking`, and a `failed` below the notice threshold. The CALLER
120
+ // never mounts us here (`shouldRenderUpdateLine` is false), but the row must
121
+ // still be exactly one row if it ever is - the same blank-row discipline
122
+ // `ToastStack` applies to its own budgeted row.
123
+ return null;
124
+ })();
125
+ if (!line) {
126
+ return (_jsx(Box, { flexShrink: 0, children: _jsx(Text, { children: " " }) }));
127
+ }
128
+ return (_jsx(Box, { flexShrink: 0, children: _jsxs(Text, { wrap: "truncate", color: line.color, children: [' ', line.glyph, " ", line.text] }) }));
129
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Turning a tail of npm output into a reason (cli-auto-update-hardening H2 /
3
+ * section 5.3).
4
+ *
5
+ * ASCII ONLY - this tree is inside the glyph scanner's scope (C-2 / C-3).
6
+ *
7
+ * PURE, AND THE ONLY IMPORT IS A TYPE. The interesting half of H2 is not this
8
+ * table, it is the REPORTING rule that decides which of the resulting reasons
9
+ * may break the three-strike silence - and that lives in `types.ts` beside
10
+ * `shouldRenderUpdateLine`, as `IMMEDIATE_NOTICE_REASONS`.
11
+ *
12
+ * WHY THIS EXISTS AT ALL. The one line a user is ever allowed to see on the
13
+ * failure path reads `update failed <dot> npm i -g @aragon-agent/cli` for EVERY
14
+ * cause. For `EPERM` / `EBUSY` - the Windows self-replacement case, R-3 - that
15
+ * advice hits the same held handle and fails identically, so the single visible
16
+ * failure is also a wrong instruction.
17
+ */
18
+ import type { UpdateReason } from './types.js';
19
+ /**
20
+ * Classify an install failure from the bounded stderr tail `runNpmInstall`
21
+ * already returns.
22
+ *
23
+ * ONE PARAMETER, NOT TWO. An earlier draft took `exitCode` and never read it:
24
+ * npm exits `1` for every one of these, so the code carries no information the
25
+ * tail does not, and a parameter that no branch and no test consumes is a
26
+ * maintenance liability that reads like a promise (P2-2).
27
+ *
28
+ * An absent or unrecognised tail is `install-failed` - the pre-H2 behaviour, so
29
+ * a machine whose npm says something we have never seen degrades to exactly what
30
+ * it did before rather than to a confident wrong answer.
31
+ */
32
+ export declare function classifyInstallFailure(tail: string | undefined): UpdateReason;