@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
@@ -31,6 +31,62 @@ import { perfCommand } from './perf.js';
31
31
  * feature that is spending money is the same class of lie as pricing a Haiku
32
32
  * child at Sonnet rates.
33
33
  */
34
+ /**
35
+ * `/update status` - the human-readable readout (cli-auto-update §4.4).
36
+ *
37
+ * ASCII ONLY and NO GLYPHS: this string goes into a transcript `notice`, which
38
+ * `Transcript.tsx` renders with its own marker; the glyph tier belongs to the
39
+ * bottom-row line, not here. Everything a user could want to act on is named:
40
+ * what is running, what is available, how it got installed, and when we look
41
+ * again.
42
+ */
43
+ function formatUpdateStatus(port, override) {
44
+ const snapshot = override ?? port.snapshot();
45
+ const lines = [
46
+ `Auto-update: ${snapshot.phase}`,
47
+ ` running ${snapshot.currentVersion}`,
48
+ ` latest ${snapshot.latestVersion ?? 'unknown'}`,
49
+ ` source ${snapshot.source}`,
50
+ ];
51
+ if (snapshot.reason)
52
+ lines.push(` reason ${snapshot.reason}`);
53
+ if (snapshot.advice)
54
+ lines.push(` install ${snapshot.advice}`);
55
+ // The three hardening lines, each only when it has something to say
56
+ // (cli-auto-update-hardening section 6.3).
57
+ //
58
+ // THE KEY COLUMN IS TEN WIDE, WHICH IS WHY THE FIRST KEY IS `rollback` AND NOT
59
+ // `rolledback`. Section 6.3 asks for "a padded key, matching the six lines
60
+ // `formatUpdateStatus` already emits", and every one of those pads the key to
61
+ // ten so the values start in the same column - the entire reason a readout is
62
+ // padded at all. `rolledback` is ten characters on its own, so it cannot take
63
+ // a separating space without pushing its value one column past the other six,
64
+ // which is the misalignment the padding exists to prevent. The spec's sample
65
+ // block is internally consistent (all three new lines at eleven) and
66
+ // inconsistent with the six it says it matches; the prose is the requirement.
67
+ //
68
+ // THE ROLLBACK LINE CARRIES NO FAILURE COUNT on purpose: `bootFailures` is
69
+ // zeroed by the rollback itself, so any number printed here would be `0`. The
70
+ // count that mattered is in the `update_rolled_back` log record.
71
+ if (snapshot.rolledBackFrom) {
72
+ const to = snapshot.rolledBackTo ?? snapshot.currentVersion;
73
+ lines.push(` rollback ${snapshot.rolledBackFrom} -> ${to}`);
74
+ }
75
+ // What `aragon update --rollback` would reinstall. This is the only place a
76
+ // user can find that out BEFORE running the command.
77
+ if (snapshot.lastGoodVersion)
78
+ lines.push(` lastgood ${snapshot.lastGoodVersion}`);
79
+ // The only place a user can learn their machine updates through the fallback.
80
+ if (snapshot.probe) {
81
+ lines.push(` probe ${snapshot.probe === 'npm' ? 'npm (proxy fallback)' : 'http'}`);
82
+ }
83
+ if (snapshot.consecutiveFailures > 0) {
84
+ lines.push(` failures ${snapshot.consecutiveFailures} in a row`);
85
+ }
86
+ const next = override ? port.nextCheckAt() : snapshot.nextCheckAt;
87
+ lines.push(` next ${next ? new Date(next).toLocaleTimeString() : 'not scheduled'}`);
88
+ return lines.join('\n');
89
+ }
34
90
  function formatFastStatus(controller) {
35
91
  const status = controller.getFastStatus();
36
92
  const cfg = controller.getFastConfig();
@@ -458,6 +514,68 @@ const COMMANDS = [
458
514
  'delegate on|off].');
459
515
  },
460
516
  },
517
+ {
518
+ name: 'update',
519
+ description: 'Auto-update: status | now | skip | off',
520
+ /**
521
+ * THE GUARANTEED REPORTING SURFACE for the updater (C-12 / D-18).
522
+ *
523
+ * The bottom row can be preempted by a toast or by the activity line, and it
524
+ * is deliberately last in that precedence (D-2), so a user who is actually
525
+ * working may never see it. This command always answers "is it on, what did
526
+ * it find, and when does it look again".
527
+ *
528
+ * REACHES THE SERVICE THROUGH `ctx.update`, NOT THROUGH `ctx.controller`
529
+ * (C-14 / P0-2). `/fast`, `/team` and `/todo` ride on the controller because
530
+ * their subsystems live there; `update/` may not import `agent/` at all, so
531
+ * it carries its own optional port. `undefined` is a legitimate state - `off`,
532
+ * a non-TTY, or CI - and says so rather than pretending to have failed.
533
+ */
534
+ run: (ctx) => {
535
+ const arg = ctx.args.trim();
536
+ const [rawVerb] = arg.split(/\s+/, 1);
537
+ const verb = (rawVerb ?? '').toLowerCase();
538
+ const port = ctx.update;
539
+ // `/update off` is the one branch that works WITHOUT a service: turning
540
+ // something off must not require it to be running, or a user who launched
541
+ // with `--no-update` can never make that stick.
542
+ if (verb === 'off') {
543
+ ctx.persistConfig({ update: { mode: 'off' } });
544
+ ctx.toast('success', 'Auto-update off. Takes effect on the next launch.');
545
+ return;
546
+ }
547
+ if (!port) {
548
+ ctx.notify('info', 'Updates are disabled for this session (update.mode: off, a non-TTY, or CI). ' +
549
+ 'Enable with: aragon config set update.mode auto');
550
+ return;
551
+ }
552
+ if (!verb || verb === 'status') {
553
+ ctx.notify('info', formatUpdateStatus(port));
554
+ return;
555
+ }
556
+ if (verb === 'now') {
557
+ // `force: true` bypasses the machine-wide throttle - a human typing this
558
+ // is the one caller entitled to ignore `lastCheckAt`.
559
+ void port
560
+ .checkNow({ force: true })
561
+ .then((snapshot) => ctx.notify('info', formatUpdateStatus(port, snapshot)))
562
+ .catch(() => ctx.notify('warn', 'Update check failed. See the log for details.'));
563
+ ctx.toast('info', 'Checking for updates...');
564
+ return;
565
+ }
566
+ if (verb === 'skip') {
567
+ const version = port.snapshot().latestVersion;
568
+ if (!version) {
569
+ ctx.notify('info', 'No update is being offered, so there is nothing to skip.');
570
+ return;
571
+ }
572
+ port.skip(version);
573
+ ctx.toast('success', `Skipped ${version}. You will hear about the next release.`);
574
+ return;
575
+ }
576
+ ctx.notify('warn', `Unknown argument "${verb}" - use /update [status|now|skip|off].`);
577
+ },
578
+ },
461
579
  {
462
580
  name: 'todo',
463
581
  description: 'Todo planning: status | on | off | panel on|off | follow <mode> | clear | continue',
@@ -7,6 +7,7 @@ import type { AgentMode } from '../agent/agent-mode.js';
7
7
  import type { NoticeLevel, Overlay, ToastLevel, ViewAction, ViewState } from '../agent/reducer.js';
8
8
  import type { PersistedConfig } from '../config/schema.js';
9
9
  import type { FollowThroughBudget } from '../todo/follow-through.js';
10
+ import type { UpdateCommandPort } from '../update/types.js';
10
11
  export interface CommandContext {
11
12
  /** Raw argument string after the command word (may be empty). */
12
13
  args: string;
@@ -52,6 +53,22 @@ export interface CommandContext {
52
53
  * change; absent means the clause degrades to naming the ceiling alone.
53
54
  */
54
55
  followBudget?: FollowThroughBudget;
56
+ /**
57
+ * The updater, when this session has one (cli-auto-update §4.4 / C-14 / P0-2).
58
+ *
59
+ * `/fast`, `/team` and `/todo` reach their subsystems through `controller`,
60
+ * because those subsystems live on `AgentController`. This one does not, and
61
+ * by §3.1 it MUST NOT — `update/` may not import `agent/` — so it needs its
62
+ * own field, and this is the only seam a slash command has.
63
+ *
64
+ * OPTIONAL, for two independent reasons. The service is legitimately absent
65
+ * under `update.mode: 'off'`, on a non-TTY and in CI (§3.8), so `/update` has
66
+ * to have something honest to say when it is missing; and a REQUIRED field
67
+ * would break every existing `CommandContext` fixture in this package at
68
+ * compile time, for a feature none of them are about. `followBudget` above is
69
+ * the precedent for an optional, read-at-command-time member.
70
+ */
71
+ update?: UpdateCommandPort;
55
72
  /** Rebuild the system prompt and the dynamic command list after a skill change. */
56
73
  refreshSkills: () => void;
57
74
  /**
@@ -14,6 +14,9 @@
14
14
  * ├── config.json.bak `aragon config edit` pre-edit backup
15
15
  * ├── prompt-history.jsonl submitted prompts, append-only (0600 on POSIX)
16
16
  * ├── state.json UI bookkeeping scalars (0600 on POSIX)
17
+ * ├── update-state.json auto-update coordination (0600 on POSIX)
18
+ * ├── update-install.lock held by the running `npm i -g` child
19
+ * ├── update-install.log that child's stdout+stderr; unlinked on exit
17
20
  * ├── logs/ JSONL logs (0700 on POSIX)
18
21
  * ├── sessions/ /save · /resume
19
22
  * ├── skills/ `aragon skills install` target
@@ -79,6 +82,34 @@ export declare function getConfigBackupPath(): string;
79
82
  export declare function getPromptHistoryPath(): string;
80
83
  /** `<home>/state.json` — UI bookkeeping scalars only; see `ui-state.ts`. */
81
84
  export declare function getUiStatePath(): string;
85
+ /**
86
+ * `<home>/update-state.json` — cross-process auto-update coordination
87
+ * (cli-auto-update §5.2).
88
+ *
89
+ * Deliberately NOT inside `state.json`: that file is documented as UI
90
+ * bookkeeping scalars only (C-6), and this is coordination state that a SECOND
91
+ * aragon process reads to decide whether anyone has already asked the registry.
92
+ * Different lifetime, different failure policy, different reader.
93
+ */
94
+ export declare function getUpdateStatePath(): string;
95
+ /**
96
+ * `<home>/update-install.lock` — the `wx` lock the running installer child holds
97
+ * (cli-auto-update §3.7).
98
+ *
99
+ * A FILE, and a direct child of the root, for the reason the two files above
100
+ * are: `logs/`, `sessions/` and `skills/` are all scan roots, and every extra
101
+ * file inside one is another thing each walk has to recognise and skip.
102
+ */
103
+ export declare function getUpdateLockPath(): string;
104
+ /**
105
+ * `<home>/update-install.log` — the installer child's stdout+stderr
106
+ * (cli-auto-update §3.5 / U-5).
107
+ *
108
+ * A REAL FILE and not a pipe: the child is detached and outlives the parent
109
+ * (D-10), and a pipe whose parent has exited takes EPIPE on the next write. The
110
+ * parent tails it on the child's exit, redacts, logs, and unlinks.
111
+ */
112
+ export declare function getUpdateInstallLogPath(): string;
82
113
  export declare function getLogsDir(): string;
83
114
  export declare function getSessionsDir(): string;
84
115
  /** `<home>` — parent of `skills/`, and where sidecar state such as usage lives. */
@@ -14,6 +14,9 @@
14
14
  * ├── config.json.bak `aragon config edit` pre-edit backup
15
15
  * ├── prompt-history.jsonl submitted prompts, append-only (0600 on POSIX)
16
16
  * ├── state.json UI bookkeeping scalars (0600 on POSIX)
17
+ * ├── update-state.json auto-update coordination (0600 on POSIX)
18
+ * ├── update-install.lock held by the running `npm i -g` child
19
+ * ├── update-install.log that child's stdout+stderr; unlinked on exit
17
20
  * ├── logs/ JSONL logs (0700 on POSIX)
18
21
  * ├── sessions/ /save · /resume
19
22
  * ├── skills/ `aragon skills install` target
@@ -140,6 +143,40 @@ export function getPromptHistoryPath() {
140
143
  export function getUiStatePath() {
141
144
  return join(resolution.root, 'state.json');
142
145
  }
146
+ /**
147
+ * `<home>/update-state.json` — cross-process auto-update coordination
148
+ * (cli-auto-update §5.2).
149
+ *
150
+ * Deliberately NOT inside `state.json`: that file is documented as UI
151
+ * bookkeeping scalars only (C-6), and this is coordination state that a SECOND
152
+ * aragon process reads to decide whether anyone has already asked the registry.
153
+ * Different lifetime, different failure policy, different reader.
154
+ */
155
+ export function getUpdateStatePath() {
156
+ return join(resolution.root, 'update-state.json');
157
+ }
158
+ /**
159
+ * `<home>/update-install.lock` — the `wx` lock the running installer child holds
160
+ * (cli-auto-update §3.7).
161
+ *
162
+ * A FILE, and a direct child of the root, for the reason the two files above
163
+ * are: `logs/`, `sessions/` and `skills/` are all scan roots, and every extra
164
+ * file inside one is another thing each walk has to recognise and skip.
165
+ */
166
+ export function getUpdateLockPath() {
167
+ return join(resolution.root, 'update-install.lock');
168
+ }
169
+ /**
170
+ * `<home>/update-install.log` — the installer child's stdout+stderr
171
+ * (cli-auto-update §3.5 / U-5).
172
+ *
173
+ * A REAL FILE and not a pipe: the child is detached and outlives the parent
174
+ * (D-10), and a pipe whose parent has exited takes EPIPE on the next write. The
175
+ * parent tails it on the child's exit, redacts, logs, and unlinks.
176
+ */
177
+ export function getUpdateInstallLogPath() {
178
+ return join(resolution.root, 'update-install.log');
179
+ }
143
180
  export function getLogsDir() {
144
181
  return join(resolution.root, 'logs');
145
182
  }
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * `aragon config get | list | edit | home`, plus the `log.*`, `team.*`,
3
- * `retry.*` and `fast.*` branches of `config set`.
3
+ * `retry.*`, `fast.*` and `update.*` branches of `config set`.
4
4
  *
5
5
  * These live here rather than in `cli.tsx` because `runConfigSet` is already a
6
6
  * 21-case switch — well past the complexity ceiling this package writes down —
@@ -43,6 +43,17 @@ export declare const RETRY_CONFIG_SET_KEYS: readonly ["retry.enabled", "retry.ma
43
43
  * `Set fast.model = haiku`.
44
44
  */
45
45
  export declare const FAST_CONFIG_SET_KEYS: readonly ["fast.enabled", "fast.provider", "fast.model", "fast.baseUrl", "fast.thinkingLevel", "fast.delegate", "fast.review", "fast.reviewEveryTurns", "fast.reviewContextTurns", "fast.reviewMaxChars", "fast.reviewMaxPerSession"];
46
+ /**
47
+ * Config keys under `update.` that `aragon config set` accepts (cli-auto-update
48
+ * §4.1).
49
+ *
50
+ * A DEDICATED SETTER RATHER THAN A GENERIC DOTTED WRITER, for the reason the
51
+ * four sets above each record: a generic setter would write `update` as a FLAT
52
+ * string key (`"update.mode": "notify"` at the top level of the file), the
53
+ * section would silently never take effect, and `config set` would still print
54
+ * `Set update.mode = notify`.
55
+ */
56
+ export declare const UPDATE_CONFIG_SET_KEYS: readonly ["update.mode", "update.checkIntervalMs", "update.registry", "update.distTag"];
46
57
  /**
47
58
  * Translate a `log.*` dotted key into a patch, or `null` for anything else.
48
59
  *
@@ -94,6 +105,21 @@ export declare function applyRetryConfigSet(key: string, value: string): Partial
94
105
  * the clamped 50 while the caller echoes the STORED value (AC-13).
95
106
  */
96
107
  export declare function applyFastConfigSet(key: string, value: string): Partial<PersistedConfig> | null;
108
+ /**
109
+ * Translate an `update.*` dotted key into a patch, or `null` for anything else.
110
+ *
111
+ * The patch is a PARTIAL `update` section, safe only because
112
+ * `updatePersistedConfig` deep-merges that section — with a shallow merge,
113
+ * setting the mode would silently reset the interval, the registry mirror and
114
+ * the dist-tag (C-4 / R-10).
115
+ *
116
+ * EVERY KEY ROUTES THROUGH `clampUpdateConfig`, not through a local parse, for
117
+ * the reason `applyRetryConfigSet` and `applyFastConfigSet` both record: one
118
+ * gate for both directions means no key here can pick up a different coercion
119
+ * later, and `config set update.checkIntervalMs 5` writes the clamped 900000
120
+ * while the caller echoes the STORED value (AC-20).
121
+ */
122
+ export declare function applyUpdateConfigSet(key: string, value: string): Partial<PersistedConfig> | null;
97
123
  /**
98
124
  * `log.redactSecrets false` is the one setting that lets credentials reach the
99
125
  * disk. It is command-line-only (there is no settings-screen entry) and it says
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * `aragon config get | list | edit | home`, plus the `log.*`, `team.*`,
3
- * `retry.*` and `fast.*` branches of `config set`.
3
+ * `retry.*`, `fast.*` and `update.*` branches of `config set`.
4
4
  *
5
5
  * These live here rather than in `cli.tsx` because `runConfigSet` is already a
6
6
  * 21-case switch — well past the complexity ceiling this package writes down —
@@ -13,7 +13,7 @@ import process from 'node:process';
13
13
  import { getConfigBackupPath, getConfigPath, getHomeRoot, getLogsDir, getPromptHistoryPath, getUiStatePath, isHomeOverridden, } from './app-paths.js';
14
14
  import { getHomeMigrationBreadcrumbPath } from './migrate-home.js';
15
15
  import { loadPersistedConfig, updatePersistedConfig } from './store.js';
16
- import { clampFastConfig, clampRetryConfig, coercePositiveInt, maskSecret, DEFAULT_FAST_CONFIG, DEFAULT_RETRY_CONFIG, DEFAULT_TEAM_CONFIG, } from './schema.js';
16
+ import { clampFastConfig, clampRetryConfig, clampUpdateConfig, coercePositiveInt, maskSecret, DEFAULT_FAST_CONFIG, DEFAULT_RETRY_CONFIG, DEFAULT_TEAM_CONFIG, DEFAULT_UPDATE_CONFIG, } from './schema.js';
17
17
  import { clampLogLevel } from '../logging/levels.js';
18
18
  /** Config keys under `log.` that `aragon config set` accepts. */
19
19
  export const LOG_CONFIG_SET_KEYS = [
@@ -87,6 +87,22 @@ export const FAST_CONFIG_SET_KEYS = [
87
87
  'fast.reviewMaxChars',
88
88
  'fast.reviewMaxPerSession',
89
89
  ];
90
+ /**
91
+ * Config keys under `update.` that `aragon config set` accepts (cli-auto-update
92
+ * §4.1).
93
+ *
94
+ * A DEDICATED SETTER RATHER THAN A GENERIC DOTTED WRITER, for the reason the
95
+ * four sets above each record: a generic setter would write `update` as a FLAT
96
+ * string key (`"update.mode": "notify"` at the top level of the file), the
97
+ * section would silently never take effect, and `config set` would still print
98
+ * `Set update.mode = notify`.
99
+ */
100
+ export const UPDATE_CONFIG_SET_KEYS = [
101
+ 'update.mode',
102
+ 'update.checkIntervalMs',
103
+ 'update.registry',
104
+ 'update.distTag',
105
+ ];
90
106
  function isTrue(value) {
91
107
  return value === 'true' || value === '1';
92
108
  }
@@ -245,6 +261,39 @@ export function applyFastConfigSet(key, value) {
245
261
  return null;
246
262
  }
247
263
  }
264
+ /**
265
+ * Translate an `update.*` dotted key into a patch, or `null` for anything else.
266
+ *
267
+ * The patch is a PARTIAL `update` section, safe only because
268
+ * `updatePersistedConfig` deep-merges that section — with a shallow merge,
269
+ * setting the mode would silently reset the interval, the registry mirror and
270
+ * the dist-tag (C-4 / R-10).
271
+ *
272
+ * EVERY KEY ROUTES THROUGH `clampUpdateConfig`, not through a local parse, for
273
+ * the reason `applyRetryConfigSet` and `applyFastConfigSet` both record: one
274
+ * gate for both directions means no key here can pick up a different coercion
275
+ * later, and `config set update.checkIntervalMs 5` writes the clamped 900000
276
+ * while the caller echoes the STORED value (AC-20).
277
+ */
278
+ export function applyUpdateConfigSet(key, value) {
279
+ const patch = (update) => ({ update });
280
+ const clampOne = (field) => {
281
+ const merged = clampUpdateConfig({ ...DEFAULT_UPDATE_CONFIG, [field]: value });
282
+ return { [field]: merged[field] };
283
+ };
284
+ switch (key) {
285
+ case 'update.mode':
286
+ return patch(clampOne('mode'));
287
+ case 'update.checkIntervalMs':
288
+ return patch(clampOne('checkIntervalMs'));
289
+ case 'update.registry':
290
+ return patch(clampOne('registry'));
291
+ case 'update.distTag':
292
+ return patch(clampOne('distTag'));
293
+ default:
294
+ return null;
295
+ }
296
+ }
248
297
  /**
249
298
  * `log.redactSecrets false` is the one setting that lets credentials reach the
250
299
  * disk. It is command-line-only (there is no settings-screen entry) and it says
@@ -12,7 +12,16 @@
12
12
  * ARAGON_FAST_PROVIDER, ARAGON_FAST_MODEL, ARAGON_FAST_BASE_URL,
13
13
  * ARAGON_LOG_LEVEL,
14
14
  * ARAGON_LOG_FILE, ARAGON_LOG_DIR, ARAGON_TRANSCRIPT_RETAIN,
15
- * ARAGON_RENDER_GOVERNOR, ARAGON_MAX_RENDER_INTERVAL_MS.
15
+ * ARAGON_RENDER_GOVERNOR, ARAGON_MAX_RENDER_INTERVAL_MS,
16
+ * ARAGON_UPDATE.
17
+ *
18
+ * READ ELSEWHERE, ON PURPOSE: `ARAGON_UPDATE_REGISTRY` and `npm_config_registry`
19
+ * are resolved by `update/registry.ts::resolveRegistryUrl`, NOT here, because
20
+ * §3.3 puts them BELOW `config.update.registry` in precedence — the opposite of
21
+ * the env-beats-file rule this file implements. Writing them into `partial`
22
+ * would silently invert that order and make a config-file mirror unreachable on
23
+ * any machine where npm exports its own registry (which is every machine npm
24
+ * runs a script on).
16
25
  *
17
26
  * NOT here: `ARAGON_HOME`. It is read when `config/app-paths.ts` is first
18
27
  * evaluated, which is long before `loadDotenv()` runs, so a `.env` can never
@@ -12,7 +12,16 @@
12
12
  * ARAGON_FAST_PROVIDER, ARAGON_FAST_MODEL, ARAGON_FAST_BASE_URL,
13
13
  * ARAGON_LOG_LEVEL,
14
14
  * ARAGON_LOG_FILE, ARAGON_LOG_DIR, ARAGON_TRANSCRIPT_RETAIN,
15
- * ARAGON_RENDER_GOVERNOR, ARAGON_MAX_RENDER_INTERVAL_MS.
15
+ * ARAGON_RENDER_GOVERNOR, ARAGON_MAX_RENDER_INTERVAL_MS,
16
+ * ARAGON_UPDATE.
17
+ *
18
+ * READ ELSEWHERE, ON PURPOSE: `ARAGON_UPDATE_REGISTRY` and `npm_config_registry`
19
+ * are resolved by `update/registry.ts::resolveRegistryUrl`, NOT here, because
20
+ * §3.3 puts them BELOW `config.update.registry` in precedence — the opposite of
21
+ * the env-beats-file rule this file implements. Writing them into `partial`
22
+ * would silently invert that order and make a config-file mirror unreachable on
23
+ * any machine where npm exports its own registry (which is every machine npm
24
+ * runs a script on).
16
25
  *
17
26
  * NOT here: `ARAGON_HOME`. It is read when `config/app-paths.ts` is first
18
27
  * evaluated, which is long before `loadDotenv()` runs, so a `.env` can never
@@ -279,6 +288,37 @@ export function readEnvConfig() {
279
288
  if (Object.keys(section).length > 0)
280
289
  partial.fast = section;
281
290
  }
291
+ // ARAGON_UPDATE=0|off|false turns auto-update off; `notify` reports without
292
+ // installing; 1|on|true|yes turns it on (cli-auto-update §4.2).
293
+ //
294
+ // A TRI-STATE, so it is parsed by an EXPLICIT POSITIVE LIST PER OUTCOME rather
295
+ // than by the boolean positive list its neighbours use — and emphatically not
296
+ // by `envBool` below, whose NEGATIVE list would resolve `ARAGON_UPDATE=disable`
297
+ // to `true`.
298
+ //
299
+ // ANYTHING UNRECOGNISED IS LEFT ABSENT, which is the invariant this file
300
+ // states three times over (`ARAGON_TEAM_MAX`, `ARAGON_RETRY_MAX`,
301
+ // `ARAGON_MAX_TOKENS`): a value that resolves to a default looks PERMANENTLY
302
+ // SUPPLIED, and the config file underneath can then never win again. For a
303
+ // kill switch that is the worst possible failure — `ARAGON_UPDATE=disabled`
304
+ // would read as "on" while the user believes they turned it off.
305
+ //
306
+ // `ARAGON_TODO_FOLLOW` is the tri-state precedent: the recognised string is
307
+ // written through and `clampUpdateConfig` stays the single gate, so there is
308
+ // never a second validator that can disagree with the first.
309
+ const update = process.env.ARAGON_UPDATE?.trim().toLowerCase();
310
+ if (update !== undefined && update.length > 0) {
311
+ const mode = update === '0' || update === 'false' || update === 'off' || update === 'no'
312
+ ? 'off'
313
+ : update === 'notify'
314
+ ? 'notify'
315
+ : update === '1' || update === 'true' || update === 'on' || update === 'yes'
316
+ ? 'auto'
317
+ : undefined;
318
+ if (mode !== undefined) {
319
+ partial.update = { mode };
320
+ }
321
+ }
282
322
  // Render bounds (tui-render-performance §5.3). Same channel story as
283
323
  // ARAGON_FULLSCREEN: a container or a wrapper that owns argv can reach neither
284
324
  // a flag nor a config file, and a session that freezes is exactly the case
@@ -148,6 +148,23 @@ export interface CliFlags {
148
148
  * to keep in step with `toFlags`.
149
149
  */
150
150
  fastReview?: string;
151
+ /**
152
+ * `--update` ⇒ true, `--no-update` ⇒ false, absent ⇒ undefined
153
+ * (cli-auto-update §4.2).
154
+ *
155
+ * DECLARED AS A PAIR — MANDATORY (C-11), for the reason `--team`,
156
+ * `--todo-panel`, `--render-governor`, `--retry` and `--fast` each record in
157
+ * turn. `update.mode` is PERSISTED and defaults to `'auto'`, so a lone
158
+ * `--no-update` would make commander default `opts.update` to `true` and
159
+ * silently overwrite a stored `off` on every run that passed no flag at all —
160
+ * i.e. the kill switch would un-set itself.
161
+ *
162
+ * A BOOLEAN FOR A TRI-STATE KEY, deliberately: the flag is a SESSION override
163
+ * and `notify` is a preference, not something a user reaches for once. `true`
164
+ * maps to `'auto'`, `false` to `'off'`; `notify` is reachable through the
165
+ * config file and `ARAGON_UPDATE=notify`.
166
+ */
167
+ update?: boolean;
151
168
  }
152
169
  /**
153
170
  * Resolve the effective config by merging all layers, then derive the runtime
@@ -6,7 +6,7 @@
6
6
  * legitimately long single tool run can never be watchdog-killed.
7
7
  */
8
8
  import process from 'node:process';
9
- import { DEFAULT_CONFIG, IDLE_TIMEOUT_MARGIN_MS, clampAskRounds, clampDensity, clampHumanTimeout, clampLogConfig, clampFastConfig, clampRetryConfig, clampSkillsConfig, clampTeamConfig, clampTodoConfig, clampTheme, clampThinkingLevel, clampMaxTokens, clampMaxRenderInterval, clampTranscriptRetain, clampTranscriptWindow, coercePositiveInt, isAutoToken, isSkillsToolPolicyMode, } from './schema.js';
9
+ import { DEFAULT_CONFIG, IDLE_TIMEOUT_MARGIN_MS, clampAskRounds, clampDensity, clampHumanTimeout, clampLogConfig, clampFastConfig, clampRetryConfig, clampSkillsConfig, clampTeamConfig, clampTodoConfig, clampUpdateConfig, clampTheme, clampThinkingLevel, clampMaxTokens, clampMaxRenderInterval, clampTranscriptRetain, clampTranscriptWindow, coercePositiveInt, isAutoToken, isSkillsToolPolicyMode, } from './schema.js';
10
10
  import { loadDotenv, readEnvConfig, readEnvDisabledSkills } from './env.js';
11
11
  import { readConfigFile } from './store.js';
12
12
  import { getConfigPath } from './app-paths.js';
@@ -185,6 +185,29 @@ function resolveFastConfig(flags, env, file) {
185
185
  ...reviewPatch,
186
186
  });
187
187
  }
188
+ /**
189
+ * Merge the `update` section across all four layers (defaults › file › env ›
190
+ * flags), cli-auto-update §4.2.
191
+ *
192
+ * `!== undefined` ON `flags.update`, which is the rule `resolveTeamConfig`,
193
+ * `resolveTodoConfig`, `resolveRetryConfig` and `resolveFastConfig` each state
194
+ * once (C-11): commander materialises a lone `--no-update` as
195
+ * `opts.update = true` when the flag is absent, so a truthiness check cannot
196
+ * tell `--no-update` from "not passed" — and here that would silently re-enable
197
+ * a subsystem the user turned off, which is the one direction this feature must
198
+ * never fail in.
199
+ *
200
+ * The flag maps onto `mode` rather than carrying its own key, so there is
201
+ * exactly one place that says whether the updater runs.
202
+ */
203
+ function resolveUpdateConfig(flags, env, file) {
204
+ return clampUpdateConfig({
205
+ ...DEFAULT_CONFIG.update,
206
+ ...(file.update ?? {}),
207
+ ...(env.update ?? {}),
208
+ ...(flags.update !== undefined ? { mode: flags.update ? 'auto' : 'off' } : {}),
209
+ });
210
+ }
188
211
  /**
189
212
  * Resolve the adaptive render governor: flag › env › file › default `true`
190
213
  * (tui-render-performance L4).
@@ -428,6 +451,7 @@ export function loadConfig(flags = {}) {
428
451
  todo: resolveTodoConfig(flags, env.partial, file),
429
452
  retry: resolveRetryConfig(flags, env.partial, file),
430
453
  fast: resolveFastConfig(flags, env.partial, file),
454
+ update: resolveUpdateConfig(flags, env.partial, file),
431
455
  cwd,
432
456
  color,
433
457
  colorLevel,
@@ -434,6 +434,44 @@ export declare const DEFAULT_FAST_CONFIG: FastConfig;
434
434
  * user really did configure a dead vendor as the MAIN one.
435
435
  */
436
436
  export declare function clampFastConfig(raw: unknown): FastConfig;
437
+ /** `auto` installs; `notify` only reports; `off` disables the subsystem. */
438
+ export declare const UPDATE_MODES: readonly ["auto", "notify", "off"];
439
+ export type UpdateMode = (typeof UPDATE_MODES)[number];
440
+ export declare function isUpdateMode(v: unknown): v is UpdateMode;
441
+ export interface UpdateConfig {
442
+ /**
443
+ * Whether the updater installs, merely reports, or does nothing at all.
444
+ *
445
+ * DEFAULTS TO `'auto'`, and that is a PRODUCT decision rather than a technical
446
+ * one: this CLI's users are overwhelmingly on a stale version they did not
447
+ * choose. The trade it makes — a wider supply-chain window in exchange for
448
+ * users who are actually on the current release — is stated plainly in the
449
+ * README (R-1), and `'off'` plus `ARAGON_UPDATE=0` are the documented kill
450
+ * switches.
451
+ */
452
+ mode: UpdateMode;
453
+ /** Minimum wall-clock between registry checks, MACHINE-WIDE (§3.7). */
454
+ checkIntervalMs: number;
455
+ /** `''` = derive: `ARAGON_UPDATE_REGISTRY` › `npm_config_registry` › npmjs. */
456
+ registry: string;
457
+ /** The dist-tag to track. */
458
+ distTag: string;
459
+ }
460
+ export declare const DEFAULT_UPDATE_CONFIG: UpdateConfig;
461
+ /**
462
+ * THE single gate for every update-config read AND write, mirroring
463
+ * `clampSkillsConfig` / `clampLogConfig` / `clampTeamConfig` / `clampTodoConfig`
464
+ * / `clampRetryConfig` / `clampFastConfig` for the reason all six record:
465
+ * hardening only the read path leaves a bad value on disk that reverts to the
466
+ * default on every launch, which presents to the user as "my setting won't
467
+ * stick".
468
+ *
469
+ * `registry` is CLAMPED TO `''` rather than rejected when it is not an
470
+ * `http(s):` URL. `''` means "derive it" (§3.3), which is the safe reading of a
471
+ * typo — the alternative is a background timer that throws on a value the user
472
+ * cannot see it reading.
473
+ */
474
+ export declare function clampUpdateConfig(raw: unknown): UpdateConfig;
437
475
  export interface PersistedConfig {
438
476
  version: number;
439
477
  provider: string;
@@ -537,6 +575,8 @@ export interface PersistedConfig {
537
575
  retry: RetryConfig;
538
576
  /** Fast model tier. The SEVENTH nested object; scalars only (see FastConfig). */
539
577
  fast: FastConfig;
578
+ /** Auto-update. The EIGHTH nested object; scalars only (see UpdateConfig). */
579
+ update: UpdateConfig;
540
580
  }
541
581
  export declare const DEFAULT_CONFIG: PersistedConfig;
542
582
  /**
@@ -709,6 +749,22 @@ export interface CliConfig {
709
749
  * `/provider`, `/reload` and the settings screen.
710
750
  */
711
751
  fast: FastConfig;
752
+ /**
753
+ * Resolved auto-update settings (cli-auto-update §4.1).
754
+ *
755
+ * `mode` here decides whether `runInteractive` CONSTRUCTS the service at all
756
+ * (§3.8), which is what makes `'off'` byte-identical to a pre-feature build:
757
+ * no timer, no file read, no socket. The other three keys are read by the
758
+ * service on every check, so a `/update off` mid-session stops the next one.
759
+ *
760
+ * IT MUST EXIST HERE AND NOT ONLY IN `PersistedConfig`, for the reason
761
+ * `showThinking` and `liveToolOutput` each record above: the gate reads
762
+ * `cfg.update`, where `cfg` is a `CliConfig`, so a key added to the persisted
763
+ * shape alone would round-trip through `config set`, appear in `config list` —
764
+ * and never reach the gate. `--no-update` and `ARAGON_UPDATE=0` would parse
765
+ * and be discarded, with no compile error and no runtime error.
766
+ */
767
+ update: UpdateConfig;
712
768
  /**
713
769
  * Lifetime submit count driving the composer hint fade (`HINT_FADE_AFTER`).
714
770
  *