@aragon-agent/cli 0.6.12 → 0.6.14

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 (119) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/README.md +108 -31
  3. package/dist/agent/controller.d.ts +38 -2
  4. package/dist/agent/controller.js +185 -34
  5. package/dist/agent/model-profile-settings.d.ts +31 -0
  6. package/dist/agent/model-profile-settings.js +208 -0
  7. package/dist/agent/queued-messages.d.ts +12 -0
  8. package/dist/agent/queued-messages.js +18 -0
  9. package/dist/agent/reducer.d.ts +13 -1
  10. package/dist/agent/reducer.js +52 -12
  11. package/dist/boot/guard.d.ts +3 -3
  12. package/dist/boot/guard.js +3 -3
  13. package/dist/cli.js +48 -16
  14. package/dist/commands/builtins.js +66 -39
  15. package/dist/commands/queue-command.d.ts +3 -0
  16. package/dist/commands/queue-command.js +5 -0
  17. package/dist/commands/registry.d.ts +3 -0
  18. package/dist/compaction/child.d.ts +4 -2
  19. package/dist/compaction/child.js +8 -1
  20. package/dist/compaction/compactor.d.ts +7 -2
  21. package/dist/compaction/compactor.js +21 -16
  22. package/dist/compaction/context-command.js +10 -3
  23. package/dist/compaction/meter.js +2 -0
  24. package/dist/compaction/pressure.d.ts +1 -0
  25. package/dist/compaction/pressure.js +1 -0
  26. package/dist/compaction/types.d.ts +2 -0
  27. package/dist/compaction/wiring.d.ts +4 -3
  28. package/dist/compaction/wiring.js +10 -2
  29. package/dist/config/cli-commands.d.ts +2 -0
  30. package/dist/config/cli-commands.js +16 -4
  31. package/dist/config/load.d.ts +5 -2
  32. package/dist/config/load.js +23 -12
  33. package/dist/config/model-profile-resolution.d.ts +20 -0
  34. package/dist/config/model-profile-resolution.js +81 -0
  35. package/dist/config/model-profile-store.d.ts +67 -0
  36. package/dist/config/model-profile-store.js +153 -0
  37. package/dist/config/model-profiles.d.ts +44 -0
  38. package/dist/config/model-profiles.js +121 -0
  39. package/dist/config/schema.d.ts +11 -2
  40. package/dist/config/store.d.ts +11 -1
  41. package/dist/config/store.js +43 -27
  42. package/dist/fast/resolve.d.ts +2 -1
  43. package/dist/fast/resolve.js +6 -4
  44. package/dist/fast/wiring.d.ts +5 -3
  45. package/dist/fast/wiring.js +8 -4
  46. package/dist/input/stdin-filter.js +3 -2
  47. package/dist/logging/install.js +4 -2
  48. package/dist/logging/secret-registry.d.ts +2 -0
  49. package/dist/logging/secret-registry.js +18 -0
  50. package/dist/team/runtime.d.ts +2 -1
  51. package/dist/team/subagent.d.ts +3 -1
  52. package/dist/team/subagent.js +3 -1
  53. package/dist/ui/ActivityLine.d.ts +6 -70
  54. package/dist/ui/ActivityLine.js +12 -34
  55. package/dist/ui/App.d.ts +1 -0
  56. package/dist/ui/App.js +116 -215
  57. package/dist/ui/BottomStatusRow.d.ts +6 -86
  58. package/dist/ui/BottomStatusRow.js +11 -26
  59. package/dist/ui/Composer.d.ts +16 -83
  60. package/dist/ui/Composer.js +9 -100
  61. package/dist/ui/Logo.d.ts +3 -5
  62. package/dist/ui/Logo.js +4 -8
  63. package/dist/ui/PromptInput.d.ts +4 -1
  64. package/dist/ui/PromptInput.js +44 -35
  65. package/dist/ui/QueuePanel.d.ts +8 -0
  66. package/dist/ui/QueuePanel.js +9 -0
  67. package/dist/ui/QueueStatusRow.d.ts +2 -0
  68. package/dist/ui/QueueStatusRow.js +1 -1
  69. package/dist/ui/StatusBar.d.ts +13 -149
  70. package/dist/ui/StatusBar.js +27 -142
  71. package/dist/ui/capabilities.d.ts +2 -0
  72. package/dist/ui/capabilities.js +6 -0
  73. package/dist/ui/clipboard-task.d.ts +29 -0
  74. package/dist/ui/clipboard-task.js +66 -0
  75. package/dist/ui/clipboard.d.ts +29 -50
  76. package/dist/ui/clipboard.js +141 -75
  77. package/dist/ui/color-runtime.d.ts +5 -0
  78. package/dist/ui/color-runtime.js +29 -0
  79. package/dist/ui/composer-limits.d.ts +3 -4
  80. package/dist/ui/composer-limits.js +4 -9
  81. package/dist/ui/composer-rows.d.ts +1 -1
  82. package/dist/ui/composer-rows.js +24 -137
  83. package/dist/ui/editor-navigation.d.ts +45 -0
  84. package/dist/ui/editor-navigation.js +85 -0
  85. package/dist/ui/editor-reducer.d.ts +4 -0
  86. package/dist/ui/editor-reducer.js +29 -15
  87. package/dist/ui/glyphs.js +10 -8
  88. package/dist/ui/ink-runtime.d.ts +3 -0
  89. package/dist/ui/ink-runtime.js +30 -0
  90. package/dist/ui/interaction-copy.d.ts +77 -0
  91. package/dist/ui/interaction-copy.js +40 -0
  92. package/dist/ui/interaction-hints.d.ts +18 -0
  93. package/dist/ui/interaction-hints.js +92 -0
  94. package/dist/ui/layout/budget.js +1 -1
  95. package/dist/ui/layout/queue-layout.d.ts +19 -0
  96. package/dist/ui/layout/queue-layout.js +26 -0
  97. package/dist/ui/layout/status-layout.d.ts +55 -0
  98. package/dist/ui/layout/status-layout.js +110 -0
  99. package/dist/ui/layout/todo-layout.d.ts +1 -0
  100. package/dist/ui/layout/todo-layout.js +1 -1
  101. package/dist/ui/overlays/HelpOverlay.js +8 -8
  102. package/dist/ui/overlays/ModelProfileEditor.d.ts +12 -0
  103. package/dist/ui/overlays/ModelProfileEditor.js +103 -0
  104. package/dist/ui/overlays/ModelProfilePicker.d.ts +35 -0
  105. package/dist/ui/overlays/ModelProfilePicker.js +167 -0
  106. package/dist/ui/overlays/QueueOverlay.d.ts +22 -0
  107. package/dist/ui/overlays/QueueOverlay.js +79 -0
  108. package/dist/ui/overlays/SettingsScreen.d.ts +9 -2
  109. package/dist/ui/overlays/SettingsScreen.js +347 -3
  110. package/dist/ui/overlays/model-profile-state.d.ts +74 -0
  111. package/dist/ui/overlays/model-profile-state.js +119 -0
  112. package/dist/ui/render-cache.d.ts +2 -2
  113. package/dist/ui/render-cache.js +4 -3
  114. package/dist/ui/run-status-row.d.ts +7 -19
  115. package/dist/ui/run-status-row.js +2 -18
  116. package/dist/ui/selection/selection-controller.d.ts +2 -2
  117. package/dist/ui/selection/selection-controller.js +1 -0
  118. package/dist/ui/use-wheel-routing.js +2 -1
  119. package/package.json +2 -2
@@ -1,6 +1,8 @@
1
+ import { createModelSettingsDraft, saveModelSettings, reloadModelSettings, } from './model-profile-settings.js';
1
2
  import { randomUUID } from 'node:crypto';
2
3
  import { Agent, initProviders, ModelRegistry, } from '@aragon-agent/core';
3
4
  import { clampCompactionConfig, clampFastConfig, clampRetryConfig, clampTeamConfig, clampTodoConfig, isAdapterProvider, toRetryPolicy, } from '../config/schema.js';
5
+ import { resolveModelProfileState } from '../config/model-profile-resolution.js';
4
6
  import { makeGetApiKey } from '../config/load.js';
5
7
  import { updatePersistedConfig } from '../config/store.js';
6
8
  import { createBuiltinTools } from '../tools/index.js';
@@ -49,6 +51,10 @@ export class AgentController {
49
51
  agent;
50
52
  providerRegistry;
51
53
  modelRegistry;
54
+ modelMetadataEnabled = false;
55
+ modelMetadataGeneration = 0;
56
+ modelMetadataAbort = new AbortController();
57
+ disposed = false;
52
58
  tools;
53
59
  skills;
54
60
  skillsEnabled;
@@ -194,6 +200,9 @@ export class AgentController {
194
200
  // no timer while it has no subscribers, so the extra object is a few scalars
195
201
  // and a `Set`.
196
202
  // -----------------------------------------------------------------------
203
+ settingsRevision = 0;
204
+ modelSettingsBlocked = false;
205
+ preparedSystemPrompt;
197
206
  contextMeter;
198
207
  // -----------------------------------------------------------------------
199
208
  // Background services (background-service-supervision §3.3 / §3.6)
@@ -297,7 +306,7 @@ export class AgentController {
297
306
  // ONCE (C-2). `FastWiring` re-runs the same pure function in its own
298
307
  // constructor; the two agree by construction because they read the same
299
308
  // config through the same resolver.
300
- const fastTier = resolveFastTier(config, (id) => this.hasApiKey(id));
309
+ const fastTier = resolveFastTier(config, (id, role) => this.hasApiKey(id, role));
301
310
  const fastRegistered = fastTier.ok;
302
311
  // --- Team mode ---------------------------------------------------------
303
312
  this.teamRegistered = config.team.enabled;
@@ -321,11 +330,12 @@ export class AgentController {
321
330
  ...(this.config.baseUrl ? { baseUrl: this.config.baseUrl } : {}),
322
331
  },
323
332
  thinkingLevel: this.config.thinkingLevel,
333
+ role: 'main',
324
334
  },
325
335
  providerRegistry: this.providerRegistry,
326
336
  getCwd: () => this.cwd,
327
337
  getMode: () => this.effectiveMode,
328
- getApiKey: (id) => this.resolveKey(id),
338
+ getApiKey: (id, role) => this.resolveKey(id, role),
329
339
  // `skill_find` only, and the lead's always-on bodies. See the long
330
340
  // note in `subagent.ts` for why `skill` itself is withheld (D-16).
331
341
  ...(this.skillsEnabled
@@ -523,15 +533,8 @@ export class AgentController {
523
533
  getMessages: () => this.agent.state.messages,
524
534
  getSystemPrompt: () => this.agent.state.systemPrompt,
525
535
  getModelInfo: () => this.getModelInfo(),
526
- // ONE PREDICATE FOR "IS THE DENOMINATOR REAL". `isPricedModel` is
527
- // `modelRegistry.getModel(...) !== undefined`, and `getModelInfoFor` falls
528
- // back to `buildRuntimeModel` - a 128k placeholder AND a zero cost table -
529
- // for exactly the models it answers `false` for. The two are the same
530
- // question about the same static table, not a coincidence.
531
- isWindowKnown: () => this.isPricedModel({
532
- providerId: this.config.provider,
533
- modelId: this.config.model,
534
- }),
536
+ // Context discovery does not imply that the model's price is known.
537
+ isWindowKnown: () => this.getModelInfo().contextWindowSource !== 'fallback',
535
538
  // LIVE, so a settings-screen edit moves the denominator without a relaunch.
536
539
  getWindowOverride: () => this.config.contextWindow,
537
540
  });
@@ -545,8 +548,8 @@ export class AgentController {
545
548
  this.compaction = config.compaction.enabled
546
549
  ? new CompactionWiring({
547
550
  getConfig: () => this.config,
548
- hasKey: (id) => this.hasApiKey(id),
549
- getApiKey: (id) => this.resolveKey(id),
551
+ hasKey: (id, role) => this.hasApiKey(id, role),
552
+ getApiKey: (id, role) => this.resolveKey(id, role),
550
553
  getModelInfoFor: (ref) => this.getModelInfoFor(ref),
551
554
  isPricedModel: (ref) => this.isPricedModel(ref),
552
555
  getMessages: () => this.agent.state.messages,
@@ -623,8 +626,8 @@ export class AgentController {
623
626
  this.fast = fastRegistered
624
627
  ? new FastWiring({
625
628
  getConfig: () => this.config,
626
- hasKey: (id) => this.hasApiKey(id),
627
- getApiKey: (id) => this.resolveKey(id),
629
+ hasKey: (id, role) => this.hasApiKey(id, role),
630
+ getApiKey: (id, role) => this.resolveKey(id, role),
628
631
  isPricedModel: (ref) => this.isPricedModel(ref),
629
632
  subscribe: (listener) => this.agent.subscribe(listener),
630
633
  complete: (providerId, request) => this.providerRegistry.complete(providerId, request),
@@ -673,8 +676,8 @@ export class AgentController {
673
676
  if (problem)
674
677
  deps.notify?.('warn', problem);
675
678
  }
676
- resolveKey(providerId) {
677
- return makeGetApiKey(this.config)(providerId);
679
+ resolveKey(providerId, role = 'main') {
680
+ return makeGetApiKey(this.config, role)(providerId);
678
681
  }
679
682
  /**
680
683
  * The tool names registered right now — LAZY, and the laziness is required.
@@ -705,27 +708,30 @@ export class AgentController {
705
708
  // suddenly denies having any skills). Structure, not call ordering, is what
706
709
  // rules that out — so if you add a third caller, route it through here.
707
710
  // -----------------------------------------------------------------------
708
- composeSystemPrompt() {
711
+ composeSystemPrompt(config = this.config) {
712
+ const tier = resolveFastTier(config, (id, role) => !!makeGetApiKey(config, role)(id));
713
+ const fastAvailable = config === this.config ? this.fast?.available() === true
714
+ : this.fast?.isRegistered() && config.fast.enabled && tier.ok;
709
715
  return buildSystemPrompt({
710
716
  cwd: this.cwd,
711
717
  tools: this.tools,
712
718
  skillsBlock: this.skillsEnabled ? this.skills.catalogBlock() + this.skills.alwaysBlock() : '',
713
719
  agentMode: this.effectiveMode,
714
720
  planInteractive: this.planInteractive,
715
- planMaxAskRounds: this.config.planModeMaxAskRounds,
721
+ planMaxAskRounds: config.planModeMaxAskRounds,
716
722
  // BOTH flags, and both are required (§4.5). `teamRegistered` false means
717
723
  // there is no `task` tool to advertise; `teamEnabled` false means the user
718
724
  // turned it off for this session. Advertising in either case would
719
725
  // describe a capability the model does not have.
720
726
  teamBlock: this.teamRegistered && this.teamEnabled
721
727
  ? buildTeamBlock({
722
- maxSubagents: this.config.team.maxSubagents,
723
- maxConcurrent: this.config.team.maxConcurrent,
728
+ maxSubagents: config.team.maxSubagents,
729
+ maxConcurrent: config.team.maxConcurrent,
724
730
  // One cross-reference sentence, present only when the capability
725
731
  // is (§3.8). `this.fast` is null during the FIRST compose (the
726
732
  // wiring needs the Agent), which is why the constructor composes
727
733
  // again once it exists.
728
- fastDelegation: this.fast?.delegationAvailable() === true,
734
+ fastDelegation: !!fastAvailable && config.fast.delegate,
729
735
  })
730
736
  : '',
731
737
  // BOTH FLAGS, exactly as team mode uses both, and for the identical
@@ -735,18 +741,18 @@ export class AgentController {
735
741
  // model does not have.
736
742
  todoBlock: this.todoRegistered && this.todoEnabled
737
743
  ? buildTodoBlock({
738
- panelVisible: this.todoPanelCapable && this.config.todo.panel,
744
+ panelVisible: this.todoPanelCapable && config.todo.panel,
739
745
  })
740
746
  : '',
741
747
  // The SAME two flags one more time, plus the live resolution — all three
742
748
  // live inside `FastWiring.available()` (§3.3). `buildFastBlock` itself
743
749
  // returns `''` when neither capability is on, so a user who turned both
744
750
  // off pays nothing for the tier being technically live.
745
- fastBlock: this.fast && this.fast.available()
751
+ fastBlock: fastAvailable
746
752
  ? buildFastBlock({
747
- model: this.fast.fastRef()?.modelId ?? '',
748
- delegate: this.config.fast.delegate,
749
- review: this.config.fast.review,
753
+ model: tier.ok ? tier.ref.modelId : '',
754
+ delegate: config.fast.delegate,
755
+ review: config.fast.review,
750
756
  })
751
757
  : '',
752
758
  // ONE FLAG, unlike team/todo's two: there is no live on/off switch for
@@ -762,7 +768,7 @@ export class AgentController {
762
768
  });
763
769
  }
764
770
  rebuildSystemPrompt() {
765
- this.agent.setSystemPrompt(this.composeSystemPrompt());
771
+ this.agent.setSystemPrompt(this.preparedSystemPrompt ?? this.composeSystemPrompt());
766
772
  }
767
773
  /** Re-render the prompt from the current skill set and tell the UI. */
768
774
  refreshSkills() {
@@ -781,8 +787,10 @@ export class AgentController {
781
787
  * Read-only CLI-layer helper for the Header/Welcome key-status dot (P1-1);
782
788
  * does not touch `@aragon-agent/core`.
783
789
  */
784
- hasApiKey(provider) {
785
- const key = this.resolveKey(provider ?? this.config.provider);
790
+ hasApiKey(provider, role = 'main') {
791
+ const activeProvider = role === 'fast'
792
+ ? this.config.fast.provider || this.config.provider : this.config.provider;
793
+ const key = this.resolveKey(provider ?? activeProvider, role);
786
794
  return !!key && key.trim().length > 0;
787
795
  }
788
796
  /**
@@ -791,6 +799,7 @@ export class AgentController {
791
799
  * re-memoizes the palette (P1-1). Does not touch `@aragon-agent/core`.
792
800
  */
793
801
  setTheme(name) {
802
+ this.settingsRevision += 1;
794
803
  this.config = { ...this.config, theme: name };
795
804
  }
796
805
  /**
@@ -811,6 +820,9 @@ export class AgentController {
811
820
  // Pre-flight (R2): validate BEFORE entering the loop.
812
821
  // -----------------------------------------------------------------------
813
822
  preflight() {
823
+ if (this.modelSettingsBlocked)
824
+ return { ok: false, kind: 'config',
825
+ message: 'Settings were saved but not fully applied. Restart before sending requests.' };
814
826
  const provider = this.config.provider;
815
827
  if (!isAdapterProvider(provider)) {
816
828
  return {
@@ -845,6 +857,11 @@ export class AgentController {
845
857
  */
846
858
  async prompt(text, options = {}) {
847
859
  // Capture BEFORE stopping: synchronous abort listeners may cancel this request.
860
+ if (this.modelSettingsBlocked) {
861
+ return { status: 'not-started', reason: 'failed' };
862
+ }
863
+ if (this.modelMetadataEnabled)
864
+ void this.refreshModelMetadata();
848
865
  const request = ++this.startupSequence;
849
866
  try {
850
867
  if (this.agent.state.isRunning) {
@@ -1054,7 +1071,14 @@ export class AgentController {
1054
1071
  const id = user ? `${this.steeringPrefix}:${++this.steeringSequence}` : undefined;
1055
1072
  if (id !== undefined)
1056
1073
  this.pendingUserSteering.add(id);
1057
- this.agent.steer(text, id);
1074
+ try {
1075
+ this.agent.steer(text, id);
1076
+ }
1077
+ catch (error) {
1078
+ if (id !== undefined)
1079
+ this.pendingUserSteering.delete(id);
1080
+ throw error;
1081
+ }
1058
1082
  return id;
1059
1083
  }
1060
1084
  followUp(text) {
@@ -1192,6 +1216,7 @@ export class AgentController {
1192
1216
  * honestly by checking `isTeamRegistered()` first.
1193
1217
  */
1194
1218
  setTeamEnabled(enabled) {
1219
+ this.settingsRevision += 1;
1195
1220
  this.teamEnabled = enabled;
1196
1221
  this.rebuildSystemPrompt();
1197
1222
  }
@@ -1205,6 +1230,7 @@ export class AgentController {
1205
1230
  */
1206
1231
  setTeamConfig(patch) {
1207
1232
  const team = clampTeamConfig({ ...this.config.team, ...patch });
1233
+ this.settingsRevision += 1;
1208
1234
  this.config = { ...this.config, team };
1209
1235
  this.rebuildSystemPrompt();
1210
1236
  return team;
@@ -1230,6 +1256,9 @@ export class AgentController {
1230
1256
  }
1231
1257
  /** Abort every child and clear timers. Idempotent; safe to call twice. */
1232
1258
  dispose() {
1259
+ this.disposed = true;
1260
+ this.modelMetadataGeneration += 1;
1261
+ this.modelMetadataAbort.abort();
1233
1262
  this.teamRuntime?.dispose();
1234
1263
  this.fast?.dispose();
1235
1264
  this.compaction?.dispose();
@@ -1266,6 +1295,7 @@ export class AgentController {
1266
1295
  * reports that honestly by checking `isTodoRegistered()` first.
1267
1296
  */
1268
1297
  setTodoEnabled(enabled) {
1298
+ this.settingsRevision += 1;
1269
1299
  this.todoEnabled = enabled;
1270
1300
  this.rebuildSystemPrompt();
1271
1301
  }
@@ -1288,6 +1318,7 @@ export class AgentController {
1288
1318
  */
1289
1319
  setTodoConfig(patch) {
1290
1320
  const todo = clampTodoConfig({ ...this.config.todo, ...patch });
1321
+ this.settingsRevision += 1;
1291
1322
  this.config = { ...this.config, todo };
1292
1323
  this.rebuildSystemPrompt();
1293
1324
  return todo;
@@ -1343,6 +1374,7 @@ export class AgentController {
1343
1374
  */
1344
1375
  setRetryConfig(patch) {
1345
1376
  const retry = clampRetryConfig({ ...this.config.retry, ...patch });
1377
+ this.settingsRevision += 1;
1346
1378
  this.config = { ...this.config, retry };
1347
1379
  this.providerRegistry.setRetryPolicy(toRetryPolicy(retry));
1348
1380
  return retry;
@@ -1350,8 +1382,80 @@ export class AgentController {
1350
1382
  // -----------------------------------------------------------------------
1351
1383
  // Mutators
1352
1384
  // -----------------------------------------------------------------------
1385
+ /** True while any request can still use a model or credential snapshot. */
1386
+ isModelSettingsBusy() {
1387
+ return this.isRunning() || this.isTeamBusy() || this.getCompactionSnapshot().inFlight
1388
+ || this.getFastStatus().snapshot.inFlight;
1389
+ }
1390
+ /** Monotonic revision protects an editor from all intervening settings changes. */
1391
+ getSettingsRevision() { return this.settingsRevision; }
1392
+ /** Read-only draft creation; no startup migration or credential copying. */
1393
+ createModelSettingsDraft() {
1394
+ return createModelSettingsDraft(this.config, this.settingsRevision);
1395
+ }
1396
+ /** Save an explicit draft as a single disk/live transaction. */
1397
+ saveModelSettings(draft) {
1398
+ return saveModelSettings({ draft, controller: this });
1399
+ }
1400
+ /** Legacy commands share the same lossless conversion and atomic save path. */
1401
+ saveModelSettingsPatch(patch) {
1402
+ const draft = this.createModelSettingsDraft();
1403
+ draft.profiles = undefined;
1404
+ draft.patch = patch;
1405
+ return this.saveModelSettings(draft);
1406
+ }
1407
+ /** Refresh both role connections and credentials while retaining conversation state. */
1408
+ reloadModelSettings() { return reloadModelSettings(this); }
1409
+ /** Prebuild all potentially failing model/prompt work before committing the file. */
1410
+ prepareModelSettingsSnapshot(config) {
1411
+ return { config, model: { providerId: config.provider, modelId: config.model,
1412
+ ...(config.baseUrl ? { baseUrl: config.baseUrl } : {}) },
1413
+ systemPrompt: this.composeSystemPrompt(config) };
1414
+ }
1415
+ /** Apply prepared state synchronously, without rebuilding the controller or its history. */
1416
+ applyModelSettingsSnapshot(snapshot) {
1417
+ this.preparedSystemPrompt = snapshot.systemPrompt;
1418
+ try {
1419
+ this.config = snapshot.config;
1420
+ this.settingsRevision += 1;
1421
+ this.agent.setModel(snapshot.model);
1422
+ this.agent.setThinkingLevel(snapshot.config.thinkingLevel);
1423
+ this.agent.setMaxTokens(snapshot.config.maxTokens);
1424
+ // Credentials may change even when endpoint/model do not. Invalidate
1425
+ // before publishing a window, including when the new profile has no key.
1426
+ if (this.modelMetadataEnabled) {
1427
+ this.resetModelMetadataRequest();
1428
+ this.modelRegistry.clearCache();
1429
+ }
1430
+ this.fast?.onConfigChanged(false, snapshot.config.fast.enabled);
1431
+ this.compaction?.onConfigChanged(snapshot.config.compaction.enabled);
1432
+ this.contextMeter.onWindowChanged();
1433
+ this.rebuildSystemPrompt();
1434
+ }
1435
+ finally {
1436
+ this.preparedSystemPrompt = undefined;
1437
+ }
1438
+ if (this.modelMetadataEnabled)
1439
+ void this.refreshModelMetadata();
1440
+ }
1441
+ /** Potentially failing ordinary-setting side effects are isolated from model application. */
1442
+ applyModelSettingsEffects() { getLogger().reconfigure(this.config.log); }
1443
+ /** A partially applied model snapshot must never reach another provider request. */
1444
+ blockModelSettingsRequests() {
1445
+ this.modelSettingsBlocked = true;
1446
+ this.config.modelSettingsRestartRequired = true;
1447
+ this.resetModelMetadataRequest();
1448
+ }
1449
+ /** The UI uses this before accepting or draining queued prompts. */
1450
+ areModelSettingsBlocked() { return this.modelSettingsBlocked; }
1353
1451
  setModel(provider, model, baseUrl) {
1452
+ if (this.isModelSettingsBusy())
1453
+ throw new Error('Model settings are busy.');
1454
+ if (this.modelMetadataEnabled)
1455
+ this.resetModelMetadataRequest();
1456
+ this.settingsRevision += 1;
1354
1457
  this.config = { ...this.config, provider, model, baseUrl };
1458
+ this.config.modelProfileState = resolveModelProfileState(this.config);
1355
1459
  this.agent.setModel({ providerId: provider, modelId: model, ...(baseUrl ? { baseUrl } : {}) });
1356
1460
  // NOT NEUTRAL TO THE FAST TIER even when the user never touched a `fast.*`
1357
1461
  // key (§3.9 / RV-3): `fast.provider: ''` and `fast.baseUrl: ''` INHERIT from
@@ -1371,22 +1475,33 @@ export class AgentController {
1371
1475
  // numerator's calibration is one turn stale until the next `turn_end`
1372
1476
  // (RV-13), which is the trade this call names.
1373
1477
  this.contextMeter.onWindowChanged();
1478
+ if (this.modelMetadataEnabled)
1479
+ void this.refreshModelMetadata();
1374
1480
  }
1375
1481
  setThinkingLevel(level) {
1482
+ this.settingsRevision += 1;
1376
1483
  this.config = { ...this.config, thinkingLevel: level };
1377
1484
  this.agent.setThinkingLevel(level);
1378
1485
  }
1379
1486
  setMaxTokens(value) {
1487
+ this.settingsRevision += 1;
1380
1488
  this.config = { ...this.config, maxTokens: value };
1381
1489
  this.agent.setMaxTokens(value);
1382
1490
  }
1383
1491
  setApiKey(provider, key) {
1384
1492
  // The Agent resolves keys via `resolveKey(this.config)` at call time, so
1385
1493
  // updating the config here is sufficient — no Agent rebuild needed.
1494
+ this.settingsRevision += 1;
1386
1495
  this.config = {
1387
1496
  ...this.config,
1388
1497
  apiKeys: { ...this.config.apiKeys, [provider]: key },
1389
1498
  };
1499
+ if (this.modelMetadataEnabled) {
1500
+ this.resetModelMetadataRequest();
1501
+ this.modelRegistry.clearCache(provider);
1502
+ this.contextMeter.onWindowChanged();
1503
+ void this.refreshModelMetadata();
1504
+ }
1390
1505
  // Rule 5 of §3.2 reads `apiKeys`, so a key edit is a tier event: this is the
1391
1506
  // path that turns `no_key` back into a working tier without a relaunch.
1392
1507
  this.fast?.onConfigChanged();
@@ -1397,6 +1512,7 @@ export class AgentController {
1397
1512
  }
1398
1513
  setCwd(cwd) {
1399
1514
  this.cwd = cwd;
1515
+ this.settingsRevision += 1;
1400
1516
  this.config = { ...this.config, cwd };
1401
1517
  // The project scope moved with the cwd, so rescan BEFORE rebuilding — then
1402
1518
  // let the single prompt entry point emit a prompt that reflects both the new
@@ -1544,9 +1660,38 @@ export class AgentController {
1544
1660
  */
1545
1661
  getModelInfoFor(ref) {
1546
1662
  const found = this.modelRegistry.getModel(ref.providerId, ref.modelId);
1547
- if (found)
1548
- return found;
1549
- return this.modelRegistry.buildRuntimeModel(ref.providerId, ref.modelId);
1663
+ const baseUrl = ref.baseUrl ?? (ref.providerId === this.config.provider && ref.modelId === this.config.model
1664
+ ? this.config.baseUrl : undefined);
1665
+ return {
1666
+ ...(found ?? this.modelRegistry.buildRuntimeModel(ref.providerId, ref.modelId)),
1667
+ ...this.modelRegistry.getContextWindow(ref.providerId, ref.modelId, baseUrl),
1668
+ };
1669
+ }
1670
+ /** Best-effort metadata lookup; never block typing or a model request. */
1671
+ async refreshModelMetadata() {
1672
+ if (this.disposed || this.modelSettingsBlocked)
1673
+ return;
1674
+ this.modelMetadataEnabled = true;
1675
+ const generation = ++this.modelMetadataGeneration;
1676
+ const { provider, baseUrl } = this.config;
1677
+ try {
1678
+ const key = this.resolveKey(provider);
1679
+ if (!key)
1680
+ return;
1681
+ await this.modelRegistry.discoverModels(provider, key, baseUrl, this.modelMetadataAbort.signal);
1682
+ if (this.disposed || generation !== this.modelMetadataGeneration)
1683
+ return;
1684
+ this.contextMeter.onWindowChanged();
1685
+ }
1686
+ catch {
1687
+ // Offline or unsupported discovery leaves catalog/unknown metadata intact.
1688
+ }
1689
+ }
1690
+ /** Invalidate an old connection without restarting a lookup on every prompt. */
1691
+ resetModelMetadataRequest() {
1692
+ this.modelMetadataGeneration += 1;
1693
+ this.modelMetadataAbort.abort();
1694
+ this.modelMetadataAbort = new AbortController();
1550
1695
  }
1551
1696
  /**
1552
1697
  * Whether the STATIC table knows this model's price (C-11 / RV-4).
@@ -1581,6 +1726,7 @@ export class AgentController {
1581
1726
  * `/fast` reports that honestly by checking `isFastRegistered()` first.
1582
1727
  */
1583
1728
  setFastEnabled(enabled) {
1729
+ this.settingsRevision += 1;
1584
1730
  this.fast?.setEnabled(enabled);
1585
1731
  }
1586
1732
  getFastConfig() {
@@ -1596,6 +1742,7 @@ export class AgentController {
1596
1742
  */
1597
1743
  setFastConfig(patch) {
1598
1744
  const fast = clampFastConfig({ ...this.config.fast, ...patch });
1745
+ this.settingsRevision += 1;
1599
1746
  this.config = { ...this.config, fast };
1600
1747
  // `onConfigChanged` rebuilds the prompt; when there is no wiring (the tier
1601
1748
  // was never registered) nothing has to be rebuilt, because no block is
@@ -1695,6 +1842,7 @@ export class AgentController {
1695
1842
  * on purpose (§3.2).
1696
1843
  */
1697
1844
  setCompactionEnabled(enabled) {
1845
+ this.settingsRevision += 1;
1698
1846
  this.compaction?.setEnabled(enabled);
1699
1847
  }
1700
1848
  getCompactionConfig() {
@@ -1709,6 +1857,7 @@ export class AgentController {
1709
1857
  */
1710
1858
  setCompactionConfig(patch) {
1711
1859
  const compaction = clampCompactionConfig({ ...this.config.compaction, ...patch });
1860
+ this.settingsRevision += 1;
1712
1861
  this.config = { ...this.config, compaction };
1713
1862
  this.compaction?.onConfigChanged();
1714
1863
  return compaction;
@@ -1757,6 +1906,8 @@ export class AgentController {
1757
1906
  * unbypassable. `replaceMessages` validates NOTHING.
1758
1907
  */
1759
1908
  async compactNow(instructions) {
1909
+ if (this.modelSettingsBlocked)
1910
+ return { ok: false, reason: 'settings_restart_required' };
1760
1911
  if (!this.compaction)
1761
1912
  return { ok: false, reason: 'not_registered' };
1762
1913
  const controller = new AbortController();
@@ -0,0 +1,31 @@
1
+ import type { ModelRef } from '@aragon-agent/core';
2
+ import { type CliConfig } from '../config/schema.js';
3
+ import { type ModelSettingsDraft, type ModelSettingsSaveResult } from '../config/model-profile-store.js';
4
+ /** Fully prepared live state; applying it performs no input parsing or disk access. */
5
+ export interface ModelSettingsSnapshot {
6
+ config: CliConfig;
7
+ model: ModelRef;
8
+ systemPrompt: string;
9
+ }
10
+ /** Narrow controller port keeps persistence independent from the agent implementation. */
11
+ export interface ModelSettingsController {
12
+ getConfig(): CliConfig;
13
+ getSettingsRevision(): number;
14
+ isModelSettingsBusy(): boolean;
15
+ isFastRegistered(): boolean;
16
+ prepareModelSettingsSnapshot(config: CliConfig): ModelSettingsSnapshot;
17
+ applyModelSettingsSnapshot(snapshot: ModelSettingsSnapshot): void;
18
+ applyModelSettingsEffects(): void;
19
+ blockModelSettingsRequests(): void;
20
+ }
21
+ /** Capture both baselines when the editor opens, without migrating or writing anything. */
22
+ export declare function createModelSettingsDraft(live: CliConfig, liveRevision: number): ModelSettingsDraft;
23
+ /** Map stable failure codes to safe UI text; never include parser or SDK error messages. */
24
+ export declare function modelSettingsResultMessage(result: ModelSettingsSaveResult): string;
25
+ /** Validate and prepare everything before the one synchronous, atomic disk commit. */
26
+ export declare function saveModelSettings(input: {
27
+ draft: ModelSettingsDraft;
28
+ controller: ModelSettingsController;
29
+ }): ModelSettingsSaveResult;
30
+ /** Reload the complete model snapshot; invalid disk content never replaces a live session. */
31
+ export declare function reloadModelSettings(controller: ModelSettingsController): ModelSettingsSaveResult;