@aragon-agent/cli 0.5.8 → 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 (156) hide show
  1. package/CHANGELOG.md +1157 -747
  2. package/README.md +1552 -1163
  3. package/dist/agent/coalesce.d.ts +13 -0
  4. package/dist/agent/coalesce.js +50 -11
  5. package/dist/agent/controller.d.ts +141 -2
  6. package/dist/agent/controller.js +396 -4
  7. package/dist/agent/headless.d.ts +8 -0
  8. package/dist/agent/headless.js +40 -0
  9. package/dist/agent/reducer.d.ts +245 -3
  10. package/dist/agent/reducer.js +490 -17
  11. package/dist/agent/retry-view.d.ts +68 -0
  12. package/dist/agent/retry-view.js +95 -0
  13. package/dist/agent/system-prompt.d.ts +11 -0
  14. package/dist/agent/system-prompt.js +2 -0
  15. package/dist/boot/guard.d.ts +135 -0
  16. package/dist/boot/guard.js +218 -0
  17. package/dist/boot/rollback.d.ts +86 -0
  18. package/dist/boot/rollback.js +185 -0
  19. package/dist/cli.js +368 -3
  20. package/dist/commands/builtins.d.ts +1 -1
  21. package/dist/commands/builtins.js +426 -2
  22. package/dist/commands/registry.d.ts +17 -0
  23. package/dist/config/app-paths.d.ts +31 -0
  24. package/dist/config/app-paths.js +37 -0
  25. package/dist/config/cli-commands.d.ts +79 -2
  26. package/dist/config/cli-commands.js +185 -3
  27. package/dist/config/env.d.ts +15 -3
  28. package/dist/config/env.js +143 -3
  29. package/dist/config/load.d.ts +66 -0
  30. package/dist/config/load.js +107 -1
  31. package/dist/config/schema.d.ts +268 -2
  32. package/dist/config/schema.js +288 -1
  33. package/dist/config/store.js +32 -1
  34. package/dist/fast/critique.d.ts +43 -0
  35. package/dist/fast/critique.js +62 -0
  36. package/dist/fast/digest.d.ts +42 -0
  37. package/dist/fast/digest.js +85 -0
  38. package/dist/fast/frames.d.ts +99 -0
  39. package/dist/fast/frames.js +180 -0
  40. package/dist/fast/limits.d.ts +98 -0
  41. package/dist/fast/limits.js +98 -0
  42. package/dist/fast/prompt.d.ts +72 -0
  43. package/dist/fast/prompt.js +94 -0
  44. package/dist/fast/resolve.d.ts +43 -0
  45. package/dist/fast/resolve.js +105 -0
  46. package/dist/fast/review-call.d.ts +99 -0
  47. package/dist/fast/review-call.js +130 -0
  48. package/dist/fast/reviewer.d.ts +248 -0
  49. package/dist/fast/reviewer.js +664 -0
  50. package/dist/fast/types.d.ts +128 -0
  51. package/dist/fast/types.js +14 -0
  52. package/dist/fast/wiring.d.ts +170 -0
  53. package/dist/fast/wiring.js +316 -0
  54. package/dist/launcher.d.ts +14 -0
  55. package/dist/launcher.js +43 -0
  56. package/dist/logging/install.d.ts +15 -1
  57. package/dist/logging/install.js +37 -4
  58. package/dist/logging/logger.d.ts +1 -1
  59. package/dist/session/persist.js +57 -1
  60. package/dist/team/normalize.d.ts +26 -1
  61. package/dist/team/normalize.js +23 -3
  62. package/dist/team/prompt.d.ts +11 -0
  63. package/dist/team/prompt.js +10 -0
  64. package/dist/team/report.d.ts +18 -0
  65. package/dist/team/report.js +27 -1
  66. package/dist/team/runtime.d.ts +13 -2
  67. package/dist/team/runtime.js +33 -6
  68. package/dist/team/subagent.d.ts +19 -1
  69. package/dist/team/subagent.js +72 -10
  70. package/dist/team/task-tool.d.ts +26 -0
  71. package/dist/team/task-tool.js +48 -4
  72. package/dist/team/types.d.ts +80 -1
  73. package/dist/tools/bash-tool.js +19 -3
  74. package/dist/tools/diff.d.ts +25 -5
  75. package/dist/tools/diff.js +72 -41
  76. package/dist/tools/file-change-store.d.ts +34 -0
  77. package/dist/tools/file-change-store.js +57 -0
  78. package/dist/tools/fs-tools.d.ts +26 -0
  79. package/dist/tools/fs-tools.js +62 -5
  80. package/dist/tools/index.d.ts +31 -0
  81. package/dist/tools/index.js +7 -1
  82. package/dist/tools/patch.d.ts +115 -0
  83. package/dist/tools/patch.js +453 -0
  84. package/dist/tools/terminal-output.d.ts +107 -0
  85. package/dist/tools/terminal-output.js +198 -0
  86. package/dist/tools/tool-output-store.d.ts +68 -0
  87. package/dist/tools/tool-output-store.js +102 -0
  88. package/dist/ui/ActivityLine.d.ts +48 -0
  89. package/dist/ui/ActivityLine.js +22 -0
  90. package/dist/ui/App.d.ts +11 -4
  91. package/dist/ui/App.js +359 -37
  92. package/dist/ui/BottomStatusRow.d.ts +65 -0
  93. package/dist/ui/BottomStatusRow.js +17 -0
  94. package/dist/ui/Composer.d.ts +1 -6
  95. package/dist/ui/Composer.js +2 -2
  96. package/dist/ui/PromptInput.d.ts +1 -15
  97. package/dist/ui/PromptInput.js +1 -38
  98. package/dist/ui/StatusBar.d.ts +37 -0
  99. package/dist/ui/StatusBar.js +5 -3
  100. package/dist/ui/TeamPanel.d.ts +5 -4
  101. package/dist/ui/TeamPanel.js +1 -1
  102. package/dist/ui/TodoPanel.d.ts +5 -4
  103. package/dist/ui/Transcript.d.ts +26 -3
  104. package/dist/ui/Transcript.js +76 -10
  105. package/dist/ui/UpdateLine.d.ts +44 -0
  106. package/dist/ui/UpdateLine.js +129 -0
  107. package/dist/ui/activity-phrases.d.ts +33 -0
  108. package/dist/ui/activity-phrases.js +50 -0
  109. package/dist/ui/entries/AssistantEntry.d.ts +20 -0
  110. package/dist/ui/entries/AssistantEntry.js +3 -2
  111. package/dist/ui/entries/DiffView.d.ts +67 -0
  112. package/dist/ui/entries/DiffView.js +103 -0
  113. package/dist/ui/entries/FastCard.d.ts +53 -0
  114. package/dist/ui/entries/FastCard.js +87 -0
  115. package/dist/ui/entries/RetryCard.d.ts +56 -0
  116. package/dist/ui/entries/RetryCard.js +60 -0
  117. package/dist/ui/entries/TeamCard.js +6 -1
  118. package/dist/ui/entries/ToolCard.d.ts +38 -1
  119. package/dist/ui/entries/ToolCard.js +68 -5
  120. package/dist/ui/glyphs.d.ts +9 -0
  121. package/dist/ui/glyphs.js +2 -0
  122. package/dist/ui/layout/AppShell.d.ts +13 -22
  123. package/dist/ui/layout/AppShell.js +3 -38
  124. package/dist/ui/layout/virtual-window.d.ts +1 -1
  125. package/dist/ui/layout/virtual-window.js +109 -5
  126. package/dist/ui/overlays/HelpOverlay.js +16 -2
  127. package/dist/ui/overlays/SettingsScreen.d.ts +82 -1
  128. package/dist/ui/overlays/SettingsScreen.js +147 -1
  129. package/dist/ui/transcript-text.js +27 -0
  130. package/dist/ui/use-wheel-routing.d.ts +16 -32
  131. package/dist/ui/use-wheel-routing.js +21 -38
  132. package/dist/update/classify-failure.d.ts +32 -0
  133. package/dist/update/classify-failure.js +60 -0
  134. package/dist/update/install-lock.d.ts +55 -0
  135. package/dist/update/install-lock.js +184 -0
  136. package/dist/update/install-source.d.ts +97 -0
  137. package/dist/update/install-source.js +218 -0
  138. package/dist/update/installer.d.ts +108 -0
  139. package/dist/update/installer.js +242 -0
  140. package/dist/update/limits.d.ts +111 -0
  141. package/dist/update/limits.js +111 -0
  142. package/dist/update/npm-view.d.ts +62 -0
  143. package/dist/update/npm-view.js +133 -0
  144. package/dist/update/registry.d.ts +52 -0
  145. package/dist/update/registry.js +161 -0
  146. package/dist/update/semver.d.ts +89 -0
  147. package/dist/update/semver.js +295 -0
  148. package/dist/update/service.d.ts +193 -0
  149. package/dist/update/service.js +636 -0
  150. package/dist/update/state.d.ts +103 -0
  151. package/dist/update/state.js +142 -0
  152. package/dist/update/types.d.ts +169 -0
  153. package/dist/update/types.js +68 -0
  154. package/package.json +3 -3
  155. package/dist/ui/layout/regions.d.ts +0 -55
  156. package/dist/ui/layout/regions.js +0 -52
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Retry presentation — pure, React-free, unit-tested (llm-api-retry-backoff
3
+ * §6.5 / §6.6).
4
+ *
5
+ * ASCII ONLY: `src/agent/**` is inside the glyph scanner's scope
6
+ * (`glyphs.test.ts`). Every non-ASCII character in the rendered line comes from
7
+ * the `Glyphs` set the caller passes in, never from a literal here.
8
+ *
9
+ * The COUNTDOWN is derived from `resumeAt`, an absolute instant, and never from
10
+ * `delayMs`. Core deliberately sends the instant rather than a tick stream, so
11
+ * the clock belongs to whoever renders — and a card that recomputed from
12
+ * `delayMs` would freeze at the announced number the moment a frame was skipped.
13
+ */
14
+ import type { Glyphs } from '../ui/glyphs.js';
15
+ /**
16
+ * Presentation bounds. NAMED `RETRY_UI`, NOT `RETRY_LIMITS_UI`: core's
17
+ * `RETRY_LIMITS` bounds what the mechanism can physically do, this is a column
18
+ * breakpoint, and a name that read as its sibling would invite the next reader to
19
+ * look here for the missing keys.
20
+ */
21
+ export declare const RETRY_UI: {
22
+ /** Below this many columns the status-bar chip degrades to `[r3]`. */
23
+ readonly statusCompactCols: 100;
24
+ };
25
+ /** The five phases of the one card a turn gets. */
26
+ export type RetryPhase = 'waiting' | 'retrying' | 'recovered' | 'exhausted' | 'interrupted';
27
+ /** The shape `formatRetryLine` reads — the `kind: 'retry'` entry, structurally. */
28
+ export interface RetryLineInput {
29
+ attempt: number;
30
+ maxRetries: number;
31
+ errorType: string;
32
+ phase: RetryPhase;
33
+ resumeAt?: number;
34
+ totalRetries?: number;
35
+ elapsedMs?: number;
36
+ }
37
+ /**
38
+ * Whole seconds until `resumeAt`, floored at 0.
39
+ *
40
+ * `Math.ceil` rather than `round`: a countdown that shows `0s` for half a second
41
+ * before firing reads as a stall, and showing `1s` for slightly too long does
42
+ * not.
43
+ */
44
+ export declare function secondsLeft(resumeAt: number | undefined, now: number): number;
45
+ /**
46
+ * A short headline for a provider failure.
47
+ *
48
+ * Deliberately shorter than `formatStreamError`'s: that one is a standalone
49
+ * notice and has to explain what to do, while this shares one line with a
50
+ * counter and a countdown. Derived from `errorType` ONLY — nothing here parses
51
+ * the provider's message, because a human-facing sentence must never become a
52
+ * machine-readable contract (the rule `SubagentRun.retryable` records).
53
+ */
54
+ export declare function retryHeadline(errorType: string): string;
55
+ /**
56
+ * The card's single line, WITHOUT the rail glyph — `EntryView` supplies that, the
57
+ * same way it does for every other entry kind.
58
+ *
59
+ * `now` is a parameter rather than a `Date.now()` call so the function stays pure
60
+ * and the render can be asserted without a clock.
61
+ */
62
+ export declare function formatRetryLine(entry: RetryLineInput, glyphs: Glyphs, now: number): string;
63
+ /** The status-bar chip text: full at width, `[r3]` when the row is tight. */
64
+ export declare function formatRetryChip(active: {
65
+ attempt: number;
66
+ max: number;
67
+ secondsLeft: number;
68
+ }, cols: number): string;
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Retry presentation — pure, React-free, unit-tested (llm-api-retry-backoff
3
+ * §6.5 / §6.6).
4
+ *
5
+ * ASCII ONLY: `src/agent/**` is inside the glyph scanner's scope
6
+ * (`glyphs.test.ts`). Every non-ASCII character in the rendered line comes from
7
+ * the `Glyphs` set the caller passes in, never from a literal here.
8
+ *
9
+ * The COUNTDOWN is derived from `resumeAt`, an absolute instant, and never from
10
+ * `delayMs`. Core deliberately sends the instant rather than a tick stream, so
11
+ * the clock belongs to whoever renders — and a card that recomputed from
12
+ * `delayMs` would freeze at the announced number the moment a frame was skipped.
13
+ */
14
+ import { formatDuration } from './usage.js';
15
+ /**
16
+ * Presentation bounds. NAMED `RETRY_UI`, NOT `RETRY_LIMITS_UI`: core's
17
+ * `RETRY_LIMITS` bounds what the mechanism can physically do, this is a column
18
+ * breakpoint, and a name that read as its sibling would invite the next reader to
19
+ * look here for the missing keys.
20
+ */
21
+ export const RETRY_UI = {
22
+ /** Below this many columns the status-bar chip degrades to `[r3]`. */
23
+ statusCompactCols: 100,
24
+ };
25
+ /**
26
+ * Whole seconds until `resumeAt`, floored at 0.
27
+ *
28
+ * `Math.ceil` rather than `round`: a countdown that shows `0s` for half a second
29
+ * before firing reads as a stall, and showing `1s` for slightly too long does
30
+ * not.
31
+ */
32
+ export function secondsLeft(resumeAt, now) {
33
+ if (resumeAt === undefined)
34
+ return 0;
35
+ return Math.max(0, Math.ceil((resumeAt - now) / 1000));
36
+ }
37
+ /**
38
+ * A short headline for a provider failure.
39
+ *
40
+ * Deliberately shorter than `formatStreamError`'s: that one is a standalone
41
+ * notice and has to explain what to do, while this shares one line with a
42
+ * counter and a countdown. Derived from `errorType` ONLY — nothing here parses
43
+ * the provider's message, because a human-facing sentence must never become a
44
+ * machine-readable contract (the rule `SubagentRun.retryable` records).
45
+ */
46
+ export function retryHeadline(errorType) {
47
+ switch (errorType) {
48
+ case 'rate_limit':
49
+ return 'Rate limited by the provider';
50
+ case 'overloaded':
51
+ return 'Provider overloaded';
52
+ case 'server_error':
53
+ return 'Provider error';
54
+ case 'network_error':
55
+ return 'Network error reaching the provider';
56
+ case 'timeout':
57
+ return 'The request timed out';
58
+ default:
59
+ return 'Provider call failed';
60
+ }
61
+ }
62
+ function plural(n) {
63
+ return n === 1 ? 'retry' : 'retries';
64
+ }
65
+ /**
66
+ * The card's single line, WITHOUT the rail glyph — `EntryView` supplies that, the
67
+ * same way it does for every other entry kind.
68
+ *
69
+ * `now` is a parameter rather than a `Date.now()` call so the function stays pure
70
+ * and the render can be asserted without a clock.
71
+ */
72
+ export function formatRetryLine(entry, glyphs, now) {
73
+ const dot = ` ${glyphs.midDot} `;
74
+ const counter = `retry ${entry.attempt}/${entry.maxRetries}`;
75
+ const settled = entry.totalRetries ?? entry.attempt;
76
+ switch (entry.phase) {
77
+ case 'waiting':
78
+ return `${retryHeadline(entry.errorType)}${dot}${counter} in ${secondsLeft(entry.resumeAt, now)}s`;
79
+ case 'retrying':
80
+ return `${retryHeadline(entry.errorType)}${dot}${counter} ${glyphs.ellipsis}`;
81
+ case 'recovered':
82
+ return `recovered after ${settled} ${plural(settled)}${entry.elapsedMs === undefined ? '' : `${dot}${formatDuration(entry.elapsedMs)}`}`;
83
+ case 'exhausted':
84
+ return `${retryHeadline(entry.errorType)}${dot}gave up after ${settled} ${plural(settled)}${entry.elapsedMs === undefined ? '' : `${dot}${formatDuration(entry.elapsedMs)}`}`;
85
+ case 'interrupted':
86
+ return `interrupted after ${settled} ${plural(settled)}`;
87
+ }
88
+ }
89
+ /** The status-bar chip text: full at width, `[r3]` when the row is tight. */
90
+ export function formatRetryChip(active, cols) {
91
+ if (cols >= RETRY_UI.statusCompactCols) {
92
+ return `retry ${active.attempt}/${active.max} ${active.secondsLeft}s`;
93
+ }
94
+ return `[r${active.attempt}]`;
95
+ }
@@ -61,6 +61,17 @@ export interface SystemPromptParams {
61
61
  * (and `--no-todo`) produce the pre-todo prompt BYTE FOR BYTE.
62
62
  */
63
63
  todoBlock?: string;
64
+ /**
65
+ * The rendered `<fast_tier>` block, or `''` (fast-model-tier §3.8).
66
+ *
67
+ * Spliced conditionally exactly as `skillsBlock`, the plan block, `teamBlock`
68
+ * and `todoBlock` are, which is what keeps I-2 true: `fast.enabled: false`
69
+ * (and `--no-fast`) produce the pre-feature prompt BYTE FOR BYTE.
70
+ *
71
+ * A CHILD'S PROMPT NEVER CARRIES IT: children cannot delegate and are not
72
+ * reviewed, so `buildSubagentBlock`'s caller leaves this unset.
73
+ */
74
+ fastBlock?: string;
64
75
  }
65
76
  /** Describe the active shell so the model does not emit incompatible syntax. */
66
77
  export declare function describeShell(): {
@@ -40,6 +40,7 @@ export function buildSystemPrompt(params) {
40
40
  const teamBlock = params.teamBlock ?? '';
41
41
  const subagentBlock = params.subagentBlock ?? '';
42
42
  const todoBlock = params.todoBlock ?? '';
43
+ const fastBlock = params.fastBlock ?? '';
43
44
  return [
44
45
  'You are AragonAgent, an autonomous coding assistant operating inside a terminal (TUI).',
45
46
  'You help the user accomplish software-engineering tasks by reasoning step by step and using the provided tools.',
@@ -56,6 +57,7 @@ export function buildSystemPrompt(params) {
56
57
  ...(teamBlock ? ['', teamBlock] : []),
57
58
  ...(subagentBlock ? ['', subagentBlock] : []),
58
59
  ...(todoBlock ? ['', todoBlock] : []),
60
+ ...(fastBlock ? ['', fastBlock] : []),
59
61
  '',
60
62
  'Operating guidance:',
61
63
  '- Prefer reading files and inspecting the workspace before making changes.',
@@ -0,0 +1,135 @@
1
+ /**
2
+ * The boot guard (cli-auto-update-hardening H1 / section 5.1).
3
+ *
4
+ * ASCII ONLY - this tree is inside the glyph scanner's scope (C-19).
5
+ *
6
+ * WHY THIS LIVES BELOW `cli.tsx` AND NOT INSIDE `main()` (D-26). The instinct is
7
+ * to put crash recovery in `cli.tsx::main()`. That covers a crash AFTER the
8
+ * module graph loads and misses the class of failure that actually bricks npm
9
+ * CLIs: `dist/cli.js` statically imports ~60 modules, and a release that fails to
10
+ * IMPORT never reaches any line anyone wrote inside `main()`. An undeclared
11
+ * dependency, a Node API that moved, a syntax error past the transpile target -
12
+ * all of them raise before `main` exists.
13
+ *
14
+ * ==========================================================================
15
+ * THE IMPORT LIST HERE IS A CONSTRAINT, NOT A COINCIDENCE (section 4 rule 2).
16
+ *
17
+ * This module may import `node:*`, `../update/state.js` and its own sibling in
18
+ * `boot/` - AND NOTHING ELSE. Every module it pulls in is a module that can
19
+ * crash before the guard that exists to survive crashes has run.
20
+ *
21
+ * IN PARTICULAR IT MUST NOT IMPORT `logging/logger.js`. `getLogger()` resolves
22
+ * config on its way to a sink, so wiring it in here would drag the whole config
23
+ * graph into the one module that has to survive a broken build - which is
24
+ * exactly the failure this guard is for. So the guard is SILENT BY CONSTRUCTION
25
+ * (D-42); the two facts worth having after the event are recorded by
26
+ * `rollback.ts` and by `UpdateService`, both of which already have a logger.
27
+ *
28
+ * THE CHAIN IS NOT `node:*`-ONLY AND THE LIST MUST NOT BE READ AS CLAIMING SO
29
+ * (P1-2). `../update/state.js` imports `../config/app-paths.js`, which imports
30
+ * and CALLS `env-paths` at module scope. That third-party module is in this
31
+ * graph, which is why `launcher.ts` imports this file DYNAMICALLY and inside a
32
+ * `try` - so a release whose `node_modules` lost `env-paths` degrades to "no
33
+ * guard, and the CLI reports its own crash normally" rather than to an
34
+ * `ERR_MODULE_NOT_FOUND` from the bin entry. The residual hole is real and is
35
+ * stated in section 5.1.5 rather than argued away; this list is what keeps it
36
+ * from growing.
37
+ * ==========================================================================
38
+ */
39
+ import { type UpdateState } from '../update/state.js';
40
+ /**
41
+ * `<root>/package.json`'s `version`, or `''`.
42
+ *
43
+ * Resolves the root the way `install-source.ts` does - `dirname(fileURLToPath(
44
+ * import.meta.url))` then up two - which from `dist/boot/` lands on the package
45
+ * root exactly as it does from `dist/update/`. DUPLICATED RATHER THAN IMPORTED
46
+ * because the import list above keeps `install-source.js` out of this graph; it
47
+ * is four lines of `node:*` with no path NAME in it (P2-4).
48
+ *
49
+ * The level count is load-bearing for the same reason it is there: this package
50
+ * is compiled by plain `tsc` with `rootDir: ./src` and NO BUNDLER (C-16), so
51
+ * `src/boot/guard.ts` really does land at `dist/boot/guard.js`.
52
+ */
53
+ export declare function readOwnVersion(): string;
54
+ /** Injected wholesale by the tests; `runBootGuard()` uses the real ones. */
55
+ export interface BootGuardDeps {
56
+ readState?: () => UpdateState;
57
+ writeState?: (patch: Partial<Omit<UpdateState, 'schema'>>) => unknown;
58
+ version?: string;
59
+ /** Called instead of `performRollback`, so no test spawns npm. */
60
+ rollback?: (bad: string, good: string) => void;
61
+ onExit?: (listener: (code: number) => void) => void;
62
+ writeStderr?: (text: string) => void;
63
+ }
64
+ /**
65
+ * Arm, count, disarm, or decide a rollback. Called once, from `launcher.ts`,
66
+ * before `dist/cli.js` is imported.
67
+ *
68
+ * READ THE ORDER AGAINST THE FAILURE IT DEFENDS:
69
+ *
70
+ * - `autoInstalledVersion !== version` IS THE ARMING CONDITION AND IT IS FIRST.
71
+ * That field is written by `performInstall` on success and cleared by
72
+ * `markBootHealthy`, so the guard is armed for exactly the window between "we
73
+ * installed X" and "X has been seen to start", and for no other launch of any
74
+ * version. Steady state - every launch on every machine except the first one
75
+ * after an auto-update - is ONE `readFileSync` of ~300 bytes and one string
76
+ * compare, with NO WRITE AT ALL. Reordering this check after anything else
77
+ * costs a write on every `aragon --version`.
78
+ *
79
+ * - THE GUARD ONLY CLEANS UP DAMAGE WE CAUSED (D-29). If a user's own
80
+ * `npm i -g @aragon-agent/cli@broken` crashes, that is their install and
81
+ * their decision; an updater that silently reverts it is a worse actor than
82
+ * one that does nothing. It also bounds the blast radius of a bug in the
83
+ * guard itself to one unnecessary reinstall of a version the user was running
84
+ * an hour ago.
85
+ *
86
+ * - THE THRESHOLD IS TESTED BEFORE THIS LAUNCH CAN CONTRIBUTE, so `2` means
87
+ * "two PREVIOUS launches of X exited non-zero" and the rollback happens on the
88
+ * third (D-30).
89
+ *
90
+ * - THE ROLLBACK DOES NOT ABORT THE BOOT (D-31). Refusing to start would be the
91
+ * wrong trade: if the crash was transient the user is now locked out of a
92
+ * working CLI by our guess. The detached child completes regardless, so the
93
+ * worst case is one more crash followed by a good launch.
94
+ *
95
+ * - THE COUNTER MOVES IN THE EXIT HOOK AND ONLY ON A NON-ZERO CODE (D-30a /
96
+ * P1-5). See `UpdateState.bootFailures`. The cost is stated rather than
97
+ * hidden: a release that dies WITHOUT running exit handlers (`SIGKILL`, a
98
+ * native segfault, `process.abort()`) is never counted and never rolled back.
99
+ * For a pure-JS CLI whose target failure is a module that will not import -
100
+ * which raises, and therefore exits `1` through the launcher's `.catch` -
101
+ * that is the right side of the trade.
102
+ *
103
+ * NEVER THROWS. `launcher.ts` wraps the call anyway, because a guard that throws
104
+ * must never be the reason the CLI will not start.
105
+ */
106
+ export declare function runBootGuard(deps?: BootGuardDeps): void;
107
+ /**
108
+ * Disarm the guard and record this version as the one to roll back TO.
109
+ *
110
+ * Called from two places, and both matter:
111
+ *
112
+ * - `cli.tsx::runInteractive`, immediately after `render()` returns - the
113
+ * earliest moment at which "this build starts" is PROVEN: the module graph
114
+ * loaded, config resolved, Ink mounted. This is the primary, and it is what
115
+ * makes a long session that is later `SIGKILL`ed (terminal window closed,
116
+ * machine slept badly, OOM killer) still count as healthy.
117
+ * - the exit hook installed by `runBootGuard`, when the code is `0` - the
118
+ * backstop for `aragon -p`, `aragon config set`, `aragon --version` and every
119
+ * other path that never mounts Ink.
120
+ *
121
+ * IDEMPOTENT through the first-line guard, which is also the fast path: on every
122
+ * ordinary launch this is one read and one string compare.
123
+ *
124
+ * `lastGoodVersion` is set here as well as at install time, deliberately: a
125
+ * machine whose state file was deleted between the install and the next launch
126
+ * converges to a correct rollback target after one healthy run.
127
+ *
128
+ * IT DOES NOT TOUCH `rolledBackFrom`, AND IT MUST NOT (P0-1e). An earlier draft
129
+ * had this function clear the rollback notice; it could never have done so,
130
+ * because in the session AFTER a rollback `autoInstalledVersion` is `''` and this
131
+ * function returns on its first line before touching anything. The notice is
132
+ * consumed by `UpdateService.start()` instead, which is also the only place that
133
+ * can decide it has actually been shown (D-40).
134
+ */
135
+ export declare function markBootHealthy(version: string, deps?: BootGuardDeps): void;
@@ -0,0 +1,218 @@
1
+ /**
2
+ * The boot guard (cli-auto-update-hardening H1 / section 5.1).
3
+ *
4
+ * ASCII ONLY - this tree is inside the glyph scanner's scope (C-19).
5
+ *
6
+ * WHY THIS LIVES BELOW `cli.tsx` AND NOT INSIDE `main()` (D-26). The instinct is
7
+ * to put crash recovery in `cli.tsx::main()`. That covers a crash AFTER the
8
+ * module graph loads and misses the class of failure that actually bricks npm
9
+ * CLIs: `dist/cli.js` statically imports ~60 modules, and a release that fails to
10
+ * IMPORT never reaches any line anyone wrote inside `main()`. An undeclared
11
+ * dependency, a Node API that moved, a syntax error past the transpile target -
12
+ * all of them raise before `main` exists.
13
+ *
14
+ * ==========================================================================
15
+ * THE IMPORT LIST HERE IS A CONSTRAINT, NOT A COINCIDENCE (section 4 rule 2).
16
+ *
17
+ * This module may import `node:*`, `../update/state.js` and its own sibling in
18
+ * `boot/` - AND NOTHING ELSE. Every module it pulls in is a module that can
19
+ * crash before the guard that exists to survive crashes has run.
20
+ *
21
+ * IN PARTICULAR IT MUST NOT IMPORT `logging/logger.js`. `getLogger()` resolves
22
+ * config on its way to a sink, so wiring it in here would drag the whole config
23
+ * graph into the one module that has to survive a broken build - which is
24
+ * exactly the failure this guard is for. So the guard is SILENT BY CONSTRUCTION
25
+ * (D-42); the two facts worth having after the event are recorded by
26
+ * `rollback.ts` and by `UpdateService`, both of which already have a logger.
27
+ *
28
+ * THE CHAIN IS NOT `node:*`-ONLY AND THE LIST MUST NOT BE READ AS CLAIMING SO
29
+ * (P1-2). `../update/state.js` imports `../config/app-paths.js`, which imports
30
+ * and CALLS `env-paths` at module scope. That third-party module is in this
31
+ * graph, which is why `launcher.ts` imports this file DYNAMICALLY and inside a
32
+ * `try` - so a release whose `node_modules` lost `env-paths` degrades to "no
33
+ * guard, and the CLI reports its own crash normally" rather than to an
34
+ * `ERR_MODULE_NOT_FOUND` from the bin entry. The residual hole is real and is
35
+ * stated in section 5.1.5 rather than argued away; this list is what keeps it
36
+ * from growing.
37
+ * ==========================================================================
38
+ */
39
+ import { readFileSync } from 'node:fs';
40
+ import { dirname, resolve } from 'node:path';
41
+ import process from 'node:process';
42
+ import { fileURLToPath } from 'node:url';
43
+ import { readUpdateState, updateUpdateState } from '../update/state.js';
44
+ import { performRollback } from './rollback.js';
45
+ /**
46
+ * Non-zero exits of a version we installed before it is rolled back.
47
+ *
48
+ * DUPLICATED FROM `UPDATE_LIMITS.crashesBeforeRollback` RATHER THAN IMPORTED,
49
+ * and the duplication is the point: `update/limits.ts` is pure and dependency-
50
+ * free today, but it is a shared table that anyone may add an import to, and the
51
+ * import list above is worth more than one deduplicated integer. It is a bare
52
+ * number with no path and no name in it, so it is not the duplication
53
+ * `config/app-paths.ts`'s header forbids.
54
+ */
55
+ const CRASHES_BEFORE_ROLLBACK = 2;
56
+ let cachedVersion = null;
57
+ /**
58
+ * `<root>/package.json`'s `version`, or `''`.
59
+ *
60
+ * Resolves the root the way `install-source.ts` does - `dirname(fileURLToPath(
61
+ * import.meta.url))` then up two - which from `dist/boot/` lands on the package
62
+ * root exactly as it does from `dist/update/`. DUPLICATED RATHER THAN IMPORTED
63
+ * because the import list above keeps `install-source.js` out of this graph; it
64
+ * is four lines of `node:*` with no path NAME in it (P2-4).
65
+ *
66
+ * The level count is load-bearing for the same reason it is there: this package
67
+ * is compiled by plain `tsc` with `rootDir: ./src` and NO BUNDLER (C-16), so
68
+ * `src/boot/guard.ts` really does land at `dist/boot/guard.js`.
69
+ */
70
+ export function readOwnVersion() {
71
+ if (cachedVersion !== null)
72
+ return cachedVersion;
73
+ try {
74
+ const here = dirname(fileURLToPath(import.meta.url));
75
+ const parsed = JSON.parse(readFileSync(resolve(here, '..', '..', 'package.json'), 'utf-8'));
76
+ cachedVersion = typeof parsed.version === 'string' ? parsed.version : '';
77
+ }
78
+ catch {
79
+ cachedVersion = '';
80
+ }
81
+ return cachedVersion;
82
+ }
83
+ /**
84
+ * Arm, count, disarm, or decide a rollback. Called once, from `launcher.ts`,
85
+ * before `dist/cli.js` is imported.
86
+ *
87
+ * READ THE ORDER AGAINST THE FAILURE IT DEFENDS:
88
+ *
89
+ * - `autoInstalledVersion !== version` IS THE ARMING CONDITION AND IT IS FIRST.
90
+ * That field is written by `performInstall` on success and cleared by
91
+ * `markBootHealthy`, so the guard is armed for exactly the window between "we
92
+ * installed X" and "X has been seen to start", and for no other launch of any
93
+ * version. Steady state - every launch on every machine except the first one
94
+ * after an auto-update - is ONE `readFileSync` of ~300 bytes and one string
95
+ * compare, with NO WRITE AT ALL. Reordering this check after anything else
96
+ * costs a write on every `aragon --version`.
97
+ *
98
+ * - THE GUARD ONLY CLEANS UP DAMAGE WE CAUSED (D-29). If a user's own
99
+ * `npm i -g @aragon-agent/cli@broken` crashes, that is their install and
100
+ * their decision; an updater that silently reverts it is a worse actor than
101
+ * one that does nothing. It also bounds the blast radius of a bug in the
102
+ * guard itself to one unnecessary reinstall of a version the user was running
103
+ * an hour ago.
104
+ *
105
+ * - THE THRESHOLD IS TESTED BEFORE THIS LAUNCH CAN CONTRIBUTE, so `2` means
106
+ * "two PREVIOUS launches of X exited non-zero" and the rollback happens on the
107
+ * third (D-30).
108
+ *
109
+ * - THE ROLLBACK DOES NOT ABORT THE BOOT (D-31). Refusing to start would be the
110
+ * wrong trade: if the crash was transient the user is now locked out of a
111
+ * working CLI by our guess. The detached child completes regardless, so the
112
+ * worst case is one more crash followed by a good launch.
113
+ *
114
+ * - THE COUNTER MOVES IN THE EXIT HOOK AND ONLY ON A NON-ZERO CODE (D-30a /
115
+ * P1-5). See `UpdateState.bootFailures`. The cost is stated rather than
116
+ * hidden: a release that dies WITHOUT running exit handlers (`SIGKILL`, a
117
+ * native segfault, `process.abort()`) is never counted and never rolled back.
118
+ * For a pure-JS CLI whose target failure is a module that will not import -
119
+ * which raises, and therefore exits `1` through the launcher's `.catch` -
120
+ * that is the right side of the trade.
121
+ *
122
+ * NEVER THROWS. `launcher.ts` wraps the call anyway, because a guard that throws
123
+ * must never be the reason the CLI will not start.
124
+ */
125
+ export function runBootGuard(deps = {}) {
126
+ try {
127
+ const read = deps.readState ?? readUpdateState;
128
+ const write = deps.writeState ?? updateUpdateState;
129
+ const version = deps.version ?? readOwnVersion();
130
+ if (!version)
131
+ return;
132
+ const state = read();
133
+ // FAST PATH: not our install, or already seen healthy. No write, no hook.
134
+ if (state.autoInstalledVersion !== version)
135
+ return;
136
+ if (state.bootFailures >= CRASHES_BEFORE_ROLLBACK &&
137
+ state.lastGoodVersion &&
138
+ state.lastGoodVersion !== version) {
139
+ // WRITTEN HERE, SYNCHRONOUSLY, AND NOT INSIDE `performRollback` (P2-9).
140
+ // That function is `async` and its first statement is a dynamic `import`,
141
+ // which yields - so a line written in there would race the
142
+ // `await import('./cli.js')` that follows and could land below whatever
143
+ // the CLI prints. Written before the call, the ordering is a property of
144
+ // the code rather than of the module loader's timing. C-10 permits this
145
+ // one visible message because the TUI is not mounted yet: there is no
146
+ // fixed frame to shift.
147
+ const stderr = deps.writeStderr ?? ((text) => process.stderr.write(text));
148
+ stderr(`aragon ${version} failed to start twice; rolling back to ${state.lastGoodVersion}.\n`);
149
+ const roll = deps.rollback ??
150
+ ((bad, good) => {
151
+ void performRollback(bad, good);
152
+ });
153
+ roll(version, state.lastGoodVersion);
154
+ // No exit hook: the rollback has already disarmed the guard, and this
155
+ // launch's own crash must not be counted against a cycle that is over.
156
+ return;
157
+ }
158
+ const on = deps.onExit ?? ((listener) => process.on('exit', listener));
159
+ on((code) => {
160
+ try {
161
+ if (code === 0)
162
+ markBootHealthy(version, { readState: read, writeState: write });
163
+ else
164
+ write({ bootFailures: state.bootFailures + 1 });
165
+ }
166
+ catch {
167
+ // An exit hook that throws prints an unactionable stack over whatever
168
+ // the CLI last said. The bookkeeping is not worth that.
169
+ }
170
+ });
171
+ }
172
+ catch {
173
+ // See the contract above: never throws.
174
+ }
175
+ }
176
+ /**
177
+ * Disarm the guard and record this version as the one to roll back TO.
178
+ *
179
+ * Called from two places, and both matter:
180
+ *
181
+ * - `cli.tsx::runInteractive`, immediately after `render()` returns - the
182
+ * earliest moment at which "this build starts" is PROVEN: the module graph
183
+ * loaded, config resolved, Ink mounted. This is the primary, and it is what
184
+ * makes a long session that is later `SIGKILL`ed (terminal window closed,
185
+ * machine slept badly, OOM killer) still count as healthy.
186
+ * - the exit hook installed by `runBootGuard`, when the code is `0` - the
187
+ * backstop for `aragon -p`, `aragon config set`, `aragon --version` and every
188
+ * other path that never mounts Ink.
189
+ *
190
+ * IDEMPOTENT through the first-line guard, which is also the fast path: on every
191
+ * ordinary launch this is one read and one string compare.
192
+ *
193
+ * `lastGoodVersion` is set here as well as at install time, deliberately: a
194
+ * machine whose state file was deleted between the install and the next launch
195
+ * converges to a correct rollback target after one healthy run.
196
+ *
197
+ * IT DOES NOT TOUCH `rolledBackFrom`, AND IT MUST NOT (P0-1e). An earlier draft
198
+ * had this function clear the rollback notice; it could never have done so,
199
+ * because in the session AFTER a rollback `autoInstalledVersion` is `''` and this
200
+ * function returns on its first line before touching anything. The notice is
201
+ * consumed by `UpdateService.start()` instead, which is also the only place that
202
+ * can decide it has actually been shown (D-40).
203
+ */
204
+ export function markBootHealthy(version, deps = {}) {
205
+ try {
206
+ if (!version)
207
+ return;
208
+ const read = deps.readState ?? readUpdateState;
209
+ const write = deps.writeState ?? updateUpdateState;
210
+ const state = read();
211
+ if (state.autoInstalledVersion !== version)
212
+ return;
213
+ write({ autoInstalledVersion: '', bootFailures: 0, lastGoodVersion: version });
214
+ }
215
+ catch {
216
+ // Bookkeeping. Never a reason to fail a session that is otherwise healthy.
217
+ }
218
+ }
@@ -0,0 +1,86 @@
1
+ /**
2
+ * The rollback (cli-auto-update-hardening H1 / section 5.1.3).
3
+ *
4
+ * ASCII ONLY - this tree is inside the glyph scanner's scope (C-19).
5
+ *
6
+ * EVERY IMPORT THAT DOES REAL WORK IS DYNAMIC, AND FOR A REASON THAT IS NOT THE
7
+ * USUAL ONE. This function runs inside a build we have CONCRETE EVIDENCE is
8
+ * broken - it was reached because that build failed to start twice - so every
9
+ * one of `install-lock`, `installer` and `install-source` can reject on import.
10
+ * The whole body is inside one `try`, and the `catch` records one line and
11
+ * returns. Static `import type` specifiers below are erased by tsc and are not
12
+ * runtime edges, which is what keeps `guard.ts`'s own graph inside the list its
13
+ * header pins down.
14
+ *
15
+ * `../update/state.js` IS THE ONE EXCEPTION and is imported statically: the
16
+ * guard has already loaded and read it before it can possibly call us, so a
17
+ * static edge here adds no failure mode that has not already been survived.
18
+ */
19
+ import { type UpdateState } from '../update/state.js';
20
+ import type { InstallLockHandle } from '../update/install-lock.js';
21
+ import type { InstallResult, RunNpmInstallInput } from '../update/installer.js';
22
+ /** What stopped us, when something did. `undefined` on the happy path. */
23
+ export type RollbackFailure = 'no-package-root' | 'source-ineligible' | 'not-writable' | 'locked' | 'install-failed' | 'error';
24
+ export interface RollbackResult {
25
+ ok: boolean;
26
+ failure?: RollbackFailure;
27
+ }
28
+ /** What this module resolved about the machine, injected wholesale by tests. */
29
+ export interface RollbackTarget {
30
+ root: string | null;
31
+ packageName: string;
32
+ autoInstallable: boolean;
33
+ writable: boolean;
34
+ }
35
+ export interface RollbackDeps {
36
+ readState?: () => UpdateState;
37
+ writeState?: (patch: Partial<Omit<UpdateState, 'schema'>>) => unknown;
38
+ /** Injected together; when all four are present nothing is imported at all. */
39
+ resolveTarget?: () => Promise<RollbackTarget>;
40
+ acquireLock?: () => InstallLockHandle | null;
41
+ install?: (input: RunNpmInstallInput) => Promise<InstallResult>;
42
+ log?: (level: 'info' | 'warn', msg: string, data: Record<string, unknown>) => void;
43
+ }
44
+ /**
45
+ * Reinstall `good` and latch `bad` so nothing puts it back.
46
+ *
47
+ * THE ORDER OF OPERATIONS IS LOAD-BEARING.
48
+ *
49
+ * 1. LATCH FIRST, INSTALL SECOND. If the process dies between the latch and the
50
+ * spawn, the machine is left running a broken version that WILL NOT BE
51
+ * REINSTALLED - recoverable by hand. In the other order, a death between the
52
+ * spawn and the latch leaves a good version installed with no latch, and the
53
+ * next check reinstalls the bad one.
54
+ *
55
+ * `skippedVersion` is part of that write and is the reason it works across
56
+ * versions (C-17 / D-28): `readUpdateState` rebuilds from a fixed field list,
57
+ * so an OLDER `aragon` writing this file erases every field it does not know.
58
+ * `skippedVersion` is schema-1 and every shipped version honours it through
59
+ * `decideUpdate`. Per section 1.2 the ordinary rollback target is itself an H1
60
+ * build and preserves all four new fields; this covers the cases that
61
+ * argument does not reach - a hand-edited state file, a user who manually
62
+ * installs a pre-H1 version, and any future field that matters across the
63
+ * boundary.
64
+ *
65
+ * `rolledBackFrom` IS WRITTEN HERE AND NOWHERE ELSE (P0-1d). It is consumed
66
+ * exactly once, by `UpdateService.start()`, which is the only thing that can
67
+ * decide the notice has actually been shown.
68
+ *
69
+ * 2. RE-CLASSIFY BEFORE SPAWNING. The machine may have changed since the
70
+ * install; we do not run `npm i -g` on a tree we would not have installed
71
+ * into.
72
+ *
73
+ * 3. ONE NON-BLOCKING LOCK ATTEMPT (U-5b). If another process holds it, return:
74
+ * it is either installing or already rolling back, and two concurrent
75
+ * `npm i -g` on one prefix is the one thing the lock exists to stop.
76
+ *
77
+ * 4. `runNpmInstall` VERBATIM. It already validates the version against
78
+ * `STRICT_VERSION_RE`, spawns detached through `process.execPath`, writes to
79
+ * a real fd and verifies the classified root afterwards (U-6). A rollback is
80
+ * an install; it does not deserve a second code path.
81
+ *
82
+ * The stderr line belongs to `runBootGuard` and is written before this is called
83
+ * (P2-9). NEVER THROWS: the guard invokes it without `await` and cannot handle a
84
+ * rejection.
85
+ */
86
+ export declare function performRollback(bad: string, good: string, deps?: RollbackDeps): Promise<RollbackResult>;