@aragon-agent/cli 0.6.13 → 0.6.15

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 (131) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/README.md +250 -222
  3. package/dist/agent/controller.d.ts +36 -2
  4. package/dist/agent/controller.js +244 -57
  5. package/dist/agent/model-profile-settings.js +11 -2
  6. package/dist/agent/plan-prompt.d.ts +1 -1
  7. package/dist/agent/plan-prompt.js +4 -3
  8. package/dist/agent/project-guidance-prompt.d.ts +4 -0
  9. package/dist/agent/project-guidance-prompt.js +104 -0
  10. package/dist/agent/reducer.d.ts +2 -0
  11. package/dist/agent/reducer.js +5 -0
  12. package/dist/agent/system-prompt.js +4 -0
  13. package/dist/boot/guard.d.ts +3 -3
  14. package/dist/boot/guard.js +3 -3
  15. package/dist/cli.js +33 -15
  16. package/dist/commands/builtins.js +2 -2
  17. package/dist/compaction/child.d.ts +9 -1
  18. package/dist/compaction/child.js +43 -13
  19. package/dist/compaction/command.js +20 -10
  20. package/dist/compaction/compactor.d.ts +33 -204
  21. package/dist/compaction/compactor.js +311 -723
  22. package/dist/compaction/context-command.js +5 -3
  23. package/dist/compaction/digest.d.ts +15 -51
  24. package/dist/compaction/digest.js +113 -148
  25. package/dist/compaction/limits.d.ts +19 -142
  26. package/dist/compaction/limits.js +20 -147
  27. package/dist/compaction/memory-identity.d.ts +16 -0
  28. package/dist/compaction/memory-identity.js +63 -0
  29. package/dist/compaction/memory-input.d.ts +25 -0
  30. package/dist/compaction/memory-input.js +106 -0
  31. package/dist/compaction/memory-render.d.ts +4 -0
  32. package/dist/compaction/memory-render.js +47 -0
  33. package/dist/compaction/memory-validation.d.ts +21 -0
  34. package/dist/compaction/memory-validation.js +268 -0
  35. package/dist/compaction/memory.d.ts +79 -0
  36. package/dist/compaction/memory.js +118 -0
  37. package/dist/compaction/meter.d.ts +14 -10
  38. package/dist/compaction/meter.js +55 -19
  39. package/dist/compaction/operation.d.ts +18 -0
  40. package/dist/compaction/operation.js +68 -0
  41. package/dist/compaction/pressure.d.ts +6 -20
  42. package/dist/compaction/pressure.js +14 -23
  43. package/dist/compaction/summarize-call.d.ts +3 -16
  44. package/dist/compaction/summarize-call.js +20 -20
  45. package/dist/compaction/summary-prompt.d.ts +29 -66
  46. package/dist/compaction/summary-prompt.js +132 -178
  47. package/dist/compaction/types.d.ts +23 -3
  48. package/dist/compaction/wiring.d.ts +10 -25
  49. package/dist/compaction/wiring.js +60 -67
  50. package/dist/config/load.d.ts +3 -1
  51. package/dist/config/load.js +8 -4
  52. package/dist/config/schema.d.ts +3 -3
  53. package/dist/config/schema.js +1 -1
  54. package/dist/exec/events.d.ts +8 -0
  55. package/dist/exec/index.js +13 -6
  56. package/dist/exec/runner.d.ts +8 -0
  57. package/dist/exec/runner.js +26 -2
  58. package/dist/session/persist.d.ts +3 -0
  59. package/dist/session/persist.js +9 -0
  60. package/dist/session/store.js +2 -0
  61. package/dist/session/validate-session.js +34 -0
  62. package/dist/team/subagent.js +8 -0
  63. package/dist/ui/App.js +221 -126
  64. package/dist/ui/BottomStatusRow.d.ts +5 -10
  65. package/dist/ui/BottomStatusRow.js +7 -11
  66. package/dist/ui/Composer.d.ts +4 -2
  67. package/dist/ui/Composer.js +2 -2
  68. package/dist/ui/Header.d.ts +6 -2
  69. package/dist/ui/Header.js +7 -6
  70. package/dist/ui/Logo.d.ts +3 -5
  71. package/dist/ui/Logo.js +4 -8
  72. package/dist/ui/PromptInput.d.ts +3 -1
  73. package/dist/ui/PromptInput.js +35 -42
  74. package/dist/ui/StatusBar.d.ts +6 -0
  75. package/dist/ui/StatusBar.js +8 -7
  76. package/dist/ui/Transcript.js +1 -1
  77. package/dist/ui/capabilities.d.ts +2 -0
  78. package/dist/ui/capabilities.js +6 -0
  79. package/dist/ui/color-runtime.d.ts +5 -0
  80. package/dist/ui/color-runtime.js +29 -0
  81. package/dist/ui/entries/CompactionCard.d.ts +3 -1
  82. package/dist/ui/entries/CompactionCard.js +7 -5
  83. package/dist/ui/frame-differ.js +27 -0
  84. package/dist/ui/frame-observer.js +18 -5
  85. package/dist/ui/glyphs.js +10 -8
  86. package/dist/ui/ink-runtime.d.ts +3 -0
  87. package/dist/ui/ink-runtime.js +30 -0
  88. package/dist/ui/interaction-copy.d.ts +73 -73
  89. package/dist/ui/interaction-copy.js +30 -38
  90. package/dist/ui/interaction-hints.d.ts +8 -1
  91. package/dist/ui/interaction-hints.js +56 -69
  92. package/dist/ui/layout/AppShell.d.ts +6 -2
  93. package/dist/ui/layout/AppShell.js +2 -2
  94. package/dist/ui/layout/OverlayFrame.d.ts +2 -0
  95. package/dist/ui/layout/OverlayFrame.js +23 -14
  96. package/dist/ui/layout/ScrollViewport.d.ts +3 -1
  97. package/dist/ui/layout/ScrollViewport.js +10 -2
  98. package/dist/ui/layout/budget.d.ts +25 -26
  99. package/dist/ui/layout/budget.js +29 -22
  100. package/dist/ui/layout/document-layout.d.ts +2 -2
  101. package/dist/ui/layout/document-layout.js +2 -3
  102. package/dist/ui/layout/queue-layout.js +4 -4
  103. package/dist/ui/layout/status-detail-layout.d.ts +9 -0
  104. package/dist/ui/layout/status-detail-layout.js +56 -0
  105. package/dist/ui/layout/status-layout.d.ts +19 -2
  106. package/dist/ui/layout/status-layout.js +95 -83
  107. package/dist/ui/layout/wrap-rows.d.ts +2 -0
  108. package/dist/ui/layout/wrap-rows.js +21 -7
  109. package/dist/ui/overlays/ConfirmDialog.d.ts +2 -1
  110. package/dist/ui/overlays/ConfirmDialog.js +22 -3
  111. package/dist/ui/overlays/HelpOverlay.js +9 -2
  112. package/dist/ui/overlays/ModelPicker.d.ts +5 -8
  113. package/dist/ui/overlays/ModelPicker.js +44 -10
  114. package/dist/ui/overlays/ModelProfileEditor.js +2 -0
  115. package/dist/ui/overlays/ModelProfilePicker.d.ts +1 -0
  116. package/dist/ui/overlays/ModelProfilePicker.js +4 -2
  117. package/dist/ui/overlays/PlanReviewOverlay.d.ts +2 -1
  118. package/dist/ui/overlays/PlanReviewOverlay.js +7 -4
  119. package/dist/ui/overlays/QuestionOverlay.d.ts +2 -1
  120. package/dist/ui/overlays/QuestionOverlay.js +45 -32
  121. package/dist/ui/overlays/QueueOverlay.js +2 -2
  122. package/dist/ui/overlays/SettingsScreen.d.ts +1 -0
  123. package/dist/ui/overlays/SettingsScreen.js +10 -3
  124. package/dist/ui/render-cache.d.ts +2 -2
  125. package/dist/ui/render-cache.js +4 -3
  126. package/dist/ui/run-metrics.d.ts +23 -0
  127. package/dist/ui/run-metrics.js +27 -0
  128. package/dist/ui/status-feedback.d.ts +33 -0
  129. package/dist/ui/status-feedback.js +36 -0
  130. package/dist/ui/stdout-frame-writer.js +28 -0
  131. package/package.json +2 -2
@@ -1,5 +1,6 @@
1
1
  import { type ModelSettingsSnapshot } from './model-profile-settings.js';
2
2
  import type { ModelSettingsDraft, ModelSettingsPatch, ModelSettingsSaveResult } from '../config/model-profile-store.js';
3
+ import { type CompactionIdentity } from '../compaction/memory-identity.js';
3
4
  /**
4
5
  * AgentController — builds the core `Agent`, wires the built-in toolset, and
5
6
  * exposes a small run/abort/steer surface plus the pre-flight validation that
@@ -123,6 +124,10 @@ export declare class AgentController {
123
124
  private readonly agent;
124
125
  private readonly providerRegistry;
125
126
  private readonly modelRegistry;
127
+ private modelMetadataEnabled;
128
+ private modelMetadataGeneration;
129
+ private modelMetadataAbort;
130
+ private disposed;
126
131
  private readonly tools;
127
132
  private readonly skills;
128
133
  private readonly skillsEnabled;
@@ -184,6 +189,10 @@ export declare class AgentController {
184
189
  private readonly fast;
185
190
  private readonly compaction;
186
191
  private settingsRevision;
192
+ private requestVersion;
193
+ private idleCompaction;
194
+ private restoredCompactionIdentity;
195
+ private readonly compactionBusyListeners;
187
196
  private modelSettingsBlocked;
188
197
  private preparedSystemPrompt;
189
198
  private readonly contextMeter;
@@ -287,6 +296,7 @@ export declare class AgentController {
287
296
  private waitForEngineIdle;
288
297
  /** Prepare before clearing the plan, then enter the engine without yielding. */
289
298
  private startPrompt;
299
+ private prepareUserTurn;
290
300
  /** Cancel pending startup as well as any engine run already in progress. */
291
301
  abort(): void;
292
302
  private stopEngine;
@@ -375,6 +385,11 @@ export declare class AgentController {
375
385
  queueUserMessage(text: string): string;
376
386
  private enqueueSteering;
377
387
  followUp(text: string): void;
388
+ /** Resume queued input without appending a duplicate user message. */
389
+ continue(): Promise<PromptOutcome>;
390
+ /** Pending user receipts survive until Core accepts or the host clears them. */
391
+ hasPendingUserMessages(): boolean;
392
+ private compactionBusyOutcome;
378
393
  isRunning(): boolean;
379
394
  getAgentMode(): AgentMode;
380
395
  /**
@@ -534,6 +549,7 @@ export declare class AgentController {
534
549
  isModelSettingsBusy(): boolean;
535
550
  /** Monotonic revision protects an editor from all intervening settings changes. */
536
551
  getSettingsRevision(): number;
552
+ private advanceSettingsRevision;
537
553
  /** Read-only draft creation; no startup migration or credential copying. */
538
554
  createModelSettingsDraft(): ModelSettingsDraft;
539
555
  /** Save an explicit draft as a single disk/live transaction. */
@@ -592,8 +608,15 @@ export declare class AgentController {
592
608
  clearMessages(): void;
593
609
  /** Read-only view of the live system prompt (regression tests, /debug). */
594
610
  getSystemPrompt(): string;
595
- replaceMessages(messages: Message[]): void;
611
+ replaceMessages(messages: Message[], identity?: CompactionIdentity): void;
596
612
  getMessages(): Message[];
613
+ /** Atomically capture adopted history and its host credential for serialization. */
614
+ getSessionSnapshot(): {
615
+ messages: Message[];
616
+ compactionIdentity?: CompactionIdentity;
617
+ };
618
+ /** Compatibility name for the documented compaction session boundary. */
619
+ getCompactionSessionState(): ReturnType<AgentController['getSessionSnapshot']>;
597
620
  clearAllQueues(): void;
598
621
  getConfig(): CliConfig;
599
622
  getProviderRegistry(): ProviderRegistry;
@@ -608,7 +631,11 @@ export declare class AgentController {
608
631
  * cost table for the model that actually billed" cannot drift from "the cost
609
632
  * table for the session's model" as two separate bodies.
610
633
  */
611
- getModelInfoFor(ref: Pick<ModelRef, 'providerId' | 'modelId'>): ModelInfo;
634
+ getModelInfoFor(ref: Pick<ModelRef, 'providerId' | 'modelId' | 'baseUrl'>): ModelInfo;
635
+ /** Best-effort metadata lookup; never block typing or a model request. */
636
+ refreshModelMetadata(): Promise<void>;
637
+ /** Invalidate an old connection without restarting a lookup on every prompt. */
638
+ private resetModelMetadataRequest;
612
639
  /**
613
640
  * Whether the STATIC table knows this model's price (C-11 / RV-4).
614
641
  *
@@ -751,6 +778,13 @@ export declare class AgentController {
751
778
  ok: boolean;
752
779
  reason?: string;
753
780
  }>;
781
+ /** Local prepare/adopt ownership, available before any network-start event. */
782
+ isCompactionBusy(): boolean;
783
+ /** Cancel local compaction and await ownership release before a conversation switch. */
784
+ cancelCompaction(): Promise<void>;
785
+ /** Subscribe to synchronous ownership transitions; observers cannot break adoption. */
786
+ subscribeCompactionBusy(listener: (busy: boolean) => void): () => void;
787
+ private publishCompactionBusy;
754
788
  /**
755
789
  * Subscribe to the CLI-local compaction event stream.
756
790
  *
@@ -1,5 +1,7 @@
1
1
  import { createModelSettingsDraft, saveModelSettings, reloadModelSettings, } from './model-profile-settings.js';
2
2
  import { randomUUID } from 'node:crypto';
3
+ import { verifyCompactionIdentity } from '../compaction/memory-identity.js';
4
+ import { COMPACTION_LIMITS } from '../compaction/limits.js';
3
5
  import { Agent, initProviders, ModelRegistry, } from '@aragon-agent/core';
4
6
  import { clampCompactionConfig, clampFastConfig, clampRetryConfig, clampTeamConfig, clampTodoConfig, isAdapterProvider, toRetryPolicy, } from '../config/schema.js';
5
7
  import { resolveModelProfileState } from '../config/model-profile-resolution.js';
@@ -51,6 +53,10 @@ export class AgentController {
51
53
  agent;
52
54
  providerRegistry;
53
55
  modelRegistry;
56
+ modelMetadataEnabled = false;
57
+ modelMetadataGeneration = 0;
58
+ modelMetadataAbort = new AbortController();
59
+ disposed = false;
54
60
  tools;
55
61
  skills;
56
62
  skillsEnabled;
@@ -197,6 +203,10 @@ export class AgentController {
197
203
  // and a `Set`.
198
204
  // -----------------------------------------------------------------------
199
205
  settingsRevision = 0;
206
+ requestVersion = 0;
207
+ idleCompaction;
208
+ restoredCompactionIdentity;
209
+ compactionBusyListeners = new Set();
200
210
  modelSettingsBlocked = false;
201
211
  preparedSystemPrompt;
202
212
  contextMeter;
@@ -249,7 +259,7 @@ export class AgentController {
249
259
  */
250
260
  abortRequested = false;
251
261
  /** Only exact acceptance receipts release user messages from reviewer protection. */
252
- pendingUserSteering = new Set();
262
+ pendingUserSteering = new Map();
253
263
  /** Session changes must not make a delayed receipt match a new message. */
254
264
  steeringPrefix = randomUUID();
255
265
  steeringSequence = 0;
@@ -526,18 +536,12 @@ export class AgentController {
526
536
  // EVERY session, including one with compaction off, because that is the one
527
537
  // whose gauge was broken (P1-4 / P0-2).
528
538
  this.contextMeter = new ContextMeter({
539
+ getRequestVersion: () => this.requestVersion,
529
540
  getMessages: () => this.agent.state.messages,
530
541
  getSystemPrompt: () => this.agent.state.systemPrompt,
531
542
  getModelInfo: () => this.getModelInfo(),
532
- // ONE PREDICATE FOR "IS THE DENOMINATOR REAL". `isPricedModel` is
533
- // `modelRegistry.getModel(...) !== undefined`, and `getModelInfoFor` falls
534
- // back to `buildRuntimeModel` - a 128k placeholder AND a zero cost table -
535
- // for exactly the models it answers `false` for. The two are the same
536
- // question about the same static table, not a coincidence.
537
- isWindowKnown: () => this.isPricedModel({
538
- providerId: this.config.provider,
539
- modelId: this.config.model,
540
- }),
543
+ // Context discovery does not imply that the model's price is known.
544
+ isWindowKnown: () => this.getModelInfo().contextWindowSource !== 'fallback',
541
545
  // LIVE, so a settings-screen edit moves the denominator without a relaunch.
542
546
  getWindowOverride: () => this.config.contextWindow,
543
547
  });
@@ -771,7 +775,10 @@ export class AgentController {
771
775
  });
772
776
  }
773
777
  rebuildSystemPrompt() {
774
- this.agent.setSystemPrompt(this.preparedSystemPrompt ?? this.composeSystemPrompt());
778
+ const prompt = this.preparedSystemPrompt ?? this.composeSystemPrompt();
779
+ if (prompt !== this.agent.state.systemPrompt)
780
+ this.idleCompaction?.abort();
781
+ this.agent.setSystemPrompt(prompt);
775
782
  }
776
783
  /** Re-render the prompt from the current skill set and tell the UI. */
777
784
  refreshSkills() {
@@ -802,7 +809,7 @@ export class AgentController {
802
809
  * re-memoizes the palette (P1-1). Does not touch `@aragon-agent/core`.
803
810
  */
804
811
  setTheme(name) {
805
- this.settingsRevision += 1;
812
+ this.advanceSettingsRevision();
806
813
  this.config = { ...this.config, theme: name };
807
814
  }
808
815
  /**
@@ -859,10 +866,14 @@ export class AgentController {
859
866
  * never rejects, so callers must not treat resolution as success.
860
867
  */
861
868
  async prompt(text, options = {}) {
869
+ if (this.isCompactionBusy())
870
+ return this.compactionBusyOutcome();
862
871
  // Capture BEFORE stopping: synchronous abort listeners may cancel this request.
863
872
  if (this.modelSettingsBlocked) {
864
873
  return { status: 'not-started', reason: 'failed' };
865
874
  }
875
+ if (this.modelMetadataEnabled)
876
+ void this.refreshModelMetadata();
866
877
  const request = ++this.startupSequence;
867
878
  try {
868
879
  if (this.agent.state.isRunning) {
@@ -874,6 +885,8 @@ export class AgentController {
874
885
  }
875
886
  if (this.agent.state.isRunning)
876
887
  throw new Error('The previous run is still stopping.');
888
+ if (this.isCompactionBusy())
889
+ return this.compactionBusyOutcome();
877
890
  return await this.startPrompt(text, options, request);
878
891
  }
879
892
  catch (err) {
@@ -909,10 +922,7 @@ export class AgentController {
909
922
  }
910
923
  /** Prepare before clearing the plan, then enter the engine without yielding. */
911
924
  async startPrompt(text, options, request) {
912
- if (this.skillsEnabled)
913
- this.skills.beginUserTurn();
914
- this.askRounds = 0;
915
- this.fast?.setGoal(text);
925
+ this.prepareUserTurn(text);
916
926
  if (request !== this.startupSequence) {
917
927
  return { status: 'not-started', reason: 'cancelled' };
918
928
  }
@@ -920,9 +930,16 @@ export class AgentController {
920
930
  await this.agent.prompt(text);
921
931
  return { status: 'finished' };
922
932
  }
933
+ prepareUserTurn(text) {
934
+ if (this.skillsEnabled)
935
+ this.skills.beginUserTurn();
936
+ this.askRounds = 0;
937
+ this.fast?.setGoal(text);
938
+ }
923
939
  /** Cancel pending startup as well as any engine run already in progress. */
924
940
  abort() {
925
941
  this.startupSequence += 1;
942
+ this.idleCompaction?.abort();
926
943
  this.stopEngine();
927
944
  }
928
945
  stopEngine() {
@@ -1067,11 +1084,11 @@ export class AgentController {
1067
1084
  }
1068
1085
  enqueueSteering(text, user) {
1069
1086
  // Both reviewer and user guidance can activate queued skill frames.
1070
- if (this.skillsEnabled)
1087
+ if (this.skillsEnabled && this.isRunning())
1071
1088
  this.skills.absorbPendingFrames();
1072
1089
  const id = user ? `${this.steeringPrefix}:${++this.steeringSequence}` : undefined;
1073
1090
  if (id !== undefined)
1074
- this.pendingUserSteering.add(id);
1091
+ this.pendingUserSteering.set(id, text);
1075
1092
  try {
1076
1093
  this.agent.steer(text, id);
1077
1094
  }
@@ -1085,6 +1102,30 @@ export class AgentController {
1085
1102
  followUp(text) {
1086
1103
  this.agent.followUp(text);
1087
1104
  }
1105
+ /** Resume queued input without appending a duplicate user message. */
1106
+ async continue() {
1107
+ if (this.isCompactionBusy())
1108
+ return this.compactionBusyOutcome();
1109
+ if (this.modelSettingsBlocked || this.isRunning()) {
1110
+ return { status: 'not-started', reason: 'failed' };
1111
+ }
1112
+ if (this.pendingUserSteering.size > 0) {
1113
+ const request = ++this.startupSequence;
1114
+ this.prepareUserTurn([...this.pendingUserSteering.values()].join('\n\n'));
1115
+ if (request !== this.startupSequence) {
1116
+ return { status: 'not-started', reason: 'cancelled' };
1117
+ }
1118
+ this.todos?.beginUserTurn('new-task');
1119
+ }
1120
+ await this.agent.continue();
1121
+ return { status: 'finished' };
1122
+ }
1123
+ /** Pending user receipts survive until Core accepts or the host clears them. */
1124
+ hasPendingUserMessages() { return this.pendingUserSteering.size > 0; }
1125
+ compactionBusyOutcome() {
1126
+ this.notifyHostFn?.('warn', 'Context compaction is busy; your input was not submitted.');
1127
+ return { status: 'not-started', reason: 'failed' };
1128
+ }
1088
1129
  isRunning() {
1089
1130
  return this.agent.state.isRunning;
1090
1131
  }
@@ -1217,7 +1258,7 @@ export class AgentController {
1217
1258
  * honestly by checking `isTeamRegistered()` first.
1218
1259
  */
1219
1260
  setTeamEnabled(enabled) {
1220
- this.settingsRevision += 1;
1261
+ this.advanceSettingsRevision();
1221
1262
  this.teamEnabled = enabled;
1222
1263
  this.rebuildSystemPrompt();
1223
1264
  }
@@ -1231,7 +1272,7 @@ export class AgentController {
1231
1272
  */
1232
1273
  setTeamConfig(patch) {
1233
1274
  const team = clampTeamConfig({ ...this.config.team, ...patch });
1234
- this.settingsRevision += 1;
1275
+ this.advanceSettingsRevision();
1235
1276
  this.config = { ...this.config, team };
1236
1277
  this.rebuildSystemPrompt();
1237
1278
  return team;
@@ -1257,6 +1298,10 @@ export class AgentController {
1257
1298
  }
1258
1299
  /** Abort every child and clear timers. Idempotent; safe to call twice. */
1259
1300
  dispose() {
1301
+ this.idleCompaction?.abort();
1302
+ this.disposed = true;
1303
+ this.modelMetadataGeneration += 1;
1304
+ this.modelMetadataAbort.abort();
1260
1305
  this.teamRuntime?.dispose();
1261
1306
  this.fast?.dispose();
1262
1307
  this.compaction?.dispose();
@@ -1293,7 +1338,7 @@ export class AgentController {
1293
1338
  * reports that honestly by checking `isTodoRegistered()` first.
1294
1339
  */
1295
1340
  setTodoEnabled(enabled) {
1296
- this.settingsRevision += 1;
1341
+ this.advanceSettingsRevision();
1297
1342
  this.todoEnabled = enabled;
1298
1343
  this.rebuildSystemPrompt();
1299
1344
  }
@@ -1316,7 +1361,7 @@ export class AgentController {
1316
1361
  */
1317
1362
  setTodoConfig(patch) {
1318
1363
  const todo = clampTodoConfig({ ...this.config.todo, ...patch });
1319
- this.settingsRevision += 1;
1364
+ this.advanceSettingsRevision();
1320
1365
  this.config = { ...this.config, todo };
1321
1366
  this.rebuildSystemPrompt();
1322
1367
  return todo;
@@ -1372,7 +1417,7 @@ export class AgentController {
1372
1417
  */
1373
1418
  setRetryConfig(patch) {
1374
1419
  const retry = clampRetryConfig({ ...this.config.retry, ...patch });
1375
- this.settingsRevision += 1;
1420
+ this.advanceSettingsRevision();
1376
1421
  this.config = { ...this.config, retry };
1377
1422
  this.providerRegistry.setRetryPolicy(toRetryPolicy(retry));
1378
1423
  return retry;
@@ -1382,11 +1427,16 @@ export class AgentController {
1382
1427
  // -----------------------------------------------------------------------
1383
1428
  /** True while any request can still use a model or credential snapshot. */
1384
1429
  isModelSettingsBusy() {
1385
- return this.isRunning() || this.isTeamBusy() || this.getCompactionSnapshot().inFlight
1430
+ return this.isRunning() || this.isTeamBusy() || this.isCompactionBusy()
1431
+ || this.getCompactionSnapshot().inFlight
1386
1432
  || this.getFastStatus().snapshot.inFlight;
1387
1433
  }
1388
1434
  /** Monotonic revision protects an editor from all intervening settings changes. */
1389
1435
  getSettingsRevision() { return this.settingsRevision; }
1436
+ advanceSettingsRevision() {
1437
+ this.settingsRevision += 1;
1438
+ this.idleCompaction?.abort();
1439
+ }
1390
1440
  /** Read-only draft creation; no startup migration or credential copying. */
1391
1441
  createModelSettingsDraft() {
1392
1442
  return createModelSettingsDraft(this.config, this.settingsRevision);
@@ -1412,21 +1462,37 @@ export class AgentController {
1412
1462
  }
1413
1463
  /** Apply prepared state synchronously, without rebuilding the controller or its history. */
1414
1464
  applyModelSettingsSnapshot(snapshot) {
1465
+ const connectionChanged = this.config.provider !== snapshot.config.provider
1466
+ || this.config.model !== snapshot.config.model || this.config.baseUrl !== snapshot.config.baseUrl;
1467
+ this.idleCompaction?.abort();
1415
1468
  this.preparedSystemPrompt = snapshot.systemPrompt;
1416
1469
  try {
1417
1470
  this.config = snapshot.config;
1418
- this.settingsRevision += 1;
1471
+ this.advanceSettingsRevision();
1419
1472
  this.agent.setModel(snapshot.model);
1420
1473
  this.agent.setThinkingLevel(snapshot.config.thinkingLevel);
1421
1474
  this.agent.setMaxTokens(snapshot.config.maxTokens);
1475
+ // Credentials may change even when endpoint/model do not. Invalidate
1476
+ // before publishing a window, including when the new profile has no key.
1477
+ if (this.modelMetadataEnabled) {
1478
+ this.resetModelMetadataRequest();
1479
+ this.modelRegistry.clearCache();
1480
+ }
1422
1481
  this.fast?.onConfigChanged(false, snapshot.config.fast.enabled);
1423
1482
  this.compaction?.onConfigChanged(snapshot.config.compaction.enabled);
1424
- this.contextMeter.onWindowChanged();
1483
+ if (connectionChanged) {
1484
+ this.requestVersion += 1;
1485
+ this.contextMeter.onHistoryReplaced();
1486
+ }
1487
+ else
1488
+ this.contextMeter.onWindowChanged();
1425
1489
  this.rebuildSystemPrompt();
1426
1490
  }
1427
1491
  finally {
1428
1492
  this.preparedSystemPrompt = undefined;
1429
1493
  }
1494
+ if (this.modelMetadataEnabled)
1495
+ void this.refreshModelMetadata();
1430
1496
  }
1431
1497
  /** Potentially failing ordinary-setting side effects are isolated from model application. */
1432
1498
  applyModelSettingsEffects() { getLogger().reconfigure(this.config.log); }
@@ -1434,13 +1500,18 @@ export class AgentController {
1434
1500
  blockModelSettingsRequests() {
1435
1501
  this.modelSettingsBlocked = true;
1436
1502
  this.config.modelSettingsRestartRequired = true;
1503
+ this.resetModelMetadataRequest();
1437
1504
  }
1438
1505
  /** The UI uses this before accepting or draining queued prompts. */
1439
1506
  areModelSettingsBlocked() { return this.modelSettingsBlocked; }
1440
1507
  setModel(provider, model, baseUrl) {
1441
1508
  if (this.isModelSettingsBusy())
1442
1509
  throw new Error('Model settings are busy.');
1443
- this.settingsRevision += 1;
1510
+ const connectionChanged = this.config.provider !== provider || this.config.model !== model
1511
+ || this.config.baseUrl !== baseUrl;
1512
+ if (this.modelMetadataEnabled)
1513
+ this.resetModelMetadataRequest();
1514
+ this.advanceSettingsRevision();
1444
1515
  this.config = { ...this.config, provider, model, baseUrl };
1445
1516
  this.config.modelProfileState = resolveModelProfileState(this.config);
1446
1517
  this.agent.setModel({ providerId: provider, modelId: model, ...(baseUrl ? { baseUrl } : {}) });
@@ -1455,32 +1526,39 @@ export class AgentController {
1455
1526
  // so the snapshot the chip and `/compact status` read has to be refreshed
1456
1527
  // for the same reason and at the same moments (the RV-3 lesson).
1457
1528
  this.compaction?.onConfigChanged();
1458
- // THE DENOMINATOR MOVED, NOT THE HISTORY (context-usage-gauge-accuracy §3.4).
1459
- // A re-measure, deliberately NOT a reset: the measured base is still a true
1460
- // statement about the messages, and throwing it away would swap a correct
1461
- // numerator for a whole-history estimate on a common operation. The
1462
- // numerator's calibration is one turn stale until the next `turn_end`
1463
- // (RV-13), which is the trade this call names.
1464
- this.contextMeter.onWindowChanged();
1529
+ if (connectionChanged) {
1530
+ this.requestVersion += 1;
1531
+ this.contextMeter.onHistoryReplaced();
1532
+ }
1533
+ else
1534
+ this.contextMeter.onWindowChanged();
1535
+ if (this.modelMetadataEnabled)
1536
+ void this.refreshModelMetadata();
1465
1537
  }
1466
1538
  setThinkingLevel(level) {
1467
- this.settingsRevision += 1;
1539
+ this.advanceSettingsRevision();
1468
1540
  this.config = { ...this.config, thinkingLevel: level };
1469
1541
  this.agent.setThinkingLevel(level);
1470
1542
  }
1471
1543
  setMaxTokens(value) {
1472
- this.settingsRevision += 1;
1544
+ this.advanceSettingsRevision();
1473
1545
  this.config = { ...this.config, maxTokens: value };
1474
1546
  this.agent.setMaxTokens(value);
1475
1547
  }
1476
1548
  setApiKey(provider, key) {
1477
1549
  // The Agent resolves keys via `resolveKey(this.config)` at call time, so
1478
1550
  // updating the config here is sufficient — no Agent rebuild needed.
1479
- this.settingsRevision += 1;
1551
+ this.advanceSettingsRevision();
1480
1552
  this.config = {
1481
1553
  ...this.config,
1482
1554
  apiKeys: { ...this.config.apiKeys, [provider]: key },
1483
1555
  };
1556
+ if (this.modelMetadataEnabled) {
1557
+ this.resetModelMetadataRequest();
1558
+ this.modelRegistry.clearCache(provider);
1559
+ this.contextMeter.onWindowChanged();
1560
+ void this.refreshModelMetadata();
1561
+ }
1484
1562
  // Rule 5 of §3.2 reads `apiKeys`, so a key edit is a tier event: this is the
1485
1563
  // path that turns `no_key` back into a working tier without a relaunch.
1486
1564
  this.fast?.onConfigChanged();
@@ -1491,7 +1569,7 @@ export class AgentController {
1491
1569
  }
1492
1570
  setCwd(cwd) {
1493
1571
  this.cwd = cwd;
1494
- this.settingsRevision += 1;
1572
+ this.advanceSettingsRevision();
1495
1573
  this.config = { ...this.config, cwd };
1496
1574
  // The project scope moved with the cwd, so rescan BEFORE rebuilding — then
1497
1575
  // let the single prompt entry point emit a prompt that reflects both the new
@@ -1560,6 +1638,8 @@ export class AgentController {
1560
1638
  };
1561
1639
  }
1562
1640
  clearMessages() {
1641
+ this.idleCompaction?.abort();
1642
+ this.restoredCompactionIdentity = undefined;
1563
1643
  this.agent.clearMessages();
1564
1644
  this.startupSequence += 1;
1565
1645
  // INVALIDATION SITE 3 (context-auto-compaction-hardening §3.2.3). The history
@@ -1587,7 +1667,9 @@ export class AgentController {
1587
1667
  getSystemPrompt() {
1588
1668
  return this.agent.state.systemPrompt;
1589
1669
  }
1590
- replaceMessages(messages) {
1670
+ replaceMessages(messages, identity) {
1671
+ this.idleCompaction?.abort();
1672
+ this.restoredCompactionIdentity = identity;
1591
1673
  this.agent.replaceMessages(messages);
1592
1674
  this.startupSequence += 1;
1593
1675
  // INVALIDATION SITE 4 (§3.2.3) - `/resume`, and any future host-side rewrite.
@@ -1600,12 +1682,26 @@ export class AgentController {
1600
1682
  // reading stood until the next completed turn. The DEEP reset also drops
1601
1683
  // `estimateOffset`, because `/resume` can change the model in the same
1602
1684
  // breath (I-8).
1603
- this.compaction?.onHistoryReplaced();
1685
+ this.compaction?.onHistoryReplaced(identity);
1604
1686
  this.contextMeter.onHistoryReplaced();
1605
1687
  }
1606
1688
  getMessages() {
1607
1689
  return this.agent.state.messages;
1608
1690
  }
1691
+ /** Atomically capture adopted history and its host credential for serialization. */
1692
+ getSessionSnapshot() {
1693
+ const messages = this.getMessages();
1694
+ const identity = this.compaction ? this.compaction.getIdentity() : this.restoredCompactionIdentity;
1695
+ if (identity && !verifyCompactionIdentity(messages, identity)) {
1696
+ getLogger().debug('agent', 'invalid_compaction_session_identity');
1697
+ }
1698
+ // Retain invalid evidence so damaged host memory cannot become ordinary text.
1699
+ return { messages, ...(identity ? { compactionIdentity: { ...identity } } : {}) };
1700
+ }
1701
+ /** Compatibility name for the documented compaction session boundary. */
1702
+ getCompactionSessionState() {
1703
+ return this.getSessionSnapshot();
1704
+ }
1609
1705
  clearAllQueues() {
1610
1706
  this.agent.clearAllQueues();
1611
1707
  this.pendingUserSteering.clear();
@@ -1639,9 +1735,38 @@ export class AgentController {
1639
1735
  */
1640
1736
  getModelInfoFor(ref) {
1641
1737
  const found = this.modelRegistry.getModel(ref.providerId, ref.modelId);
1642
- if (found)
1643
- return found;
1644
- return this.modelRegistry.buildRuntimeModel(ref.providerId, ref.modelId);
1738
+ const baseUrl = ref.baseUrl ?? (ref.providerId === this.config.provider && ref.modelId === this.config.model
1739
+ ? this.config.baseUrl : undefined);
1740
+ return {
1741
+ ...(found ?? this.modelRegistry.buildRuntimeModel(ref.providerId, ref.modelId)),
1742
+ ...this.modelRegistry.getContextWindow(ref.providerId, ref.modelId, baseUrl),
1743
+ };
1744
+ }
1745
+ /** Best-effort metadata lookup; never block typing or a model request. */
1746
+ async refreshModelMetadata() {
1747
+ if (this.disposed || this.modelSettingsBlocked)
1748
+ return;
1749
+ this.modelMetadataEnabled = true;
1750
+ const generation = ++this.modelMetadataGeneration;
1751
+ const { provider, baseUrl } = this.config;
1752
+ try {
1753
+ const key = this.resolveKey(provider);
1754
+ if (!key)
1755
+ return;
1756
+ await this.modelRegistry.discoverModels(provider, key, baseUrl, this.modelMetadataAbort.signal);
1757
+ if (this.disposed || generation !== this.modelMetadataGeneration)
1758
+ return;
1759
+ this.contextMeter.onWindowChanged();
1760
+ }
1761
+ catch {
1762
+ // Offline or unsupported discovery leaves catalog/unknown metadata intact.
1763
+ }
1764
+ }
1765
+ /** Invalidate an old connection without restarting a lookup on every prompt. */
1766
+ resetModelMetadataRequest() {
1767
+ this.modelMetadataGeneration += 1;
1768
+ this.modelMetadataAbort.abort();
1769
+ this.modelMetadataAbort = new AbortController();
1645
1770
  }
1646
1771
  /**
1647
1772
  * Whether the STATIC table knows this model's price (C-11 / RV-4).
@@ -1676,7 +1801,7 @@ export class AgentController {
1676
1801
  * `/fast` reports that honestly by checking `isFastRegistered()` first.
1677
1802
  */
1678
1803
  setFastEnabled(enabled) {
1679
- this.settingsRevision += 1;
1804
+ this.advanceSettingsRevision();
1680
1805
  this.fast?.setEnabled(enabled);
1681
1806
  }
1682
1807
  getFastConfig() {
@@ -1692,7 +1817,7 @@ export class AgentController {
1692
1817
  */
1693
1818
  setFastConfig(patch) {
1694
1819
  const fast = clampFastConfig({ ...this.config.fast, ...patch });
1695
- this.settingsRevision += 1;
1820
+ this.advanceSettingsRevision();
1696
1821
  this.config = { ...this.config, fast };
1697
1822
  // `onConfigChanged` rebuilds the prompt; when there is no wiring (the tier
1698
1823
  // was never registered) nothing has to be rebuilt, because no block is
@@ -1792,7 +1917,7 @@ export class AgentController {
1792
1917
  * on purpose (§3.2).
1793
1918
  */
1794
1919
  setCompactionEnabled(enabled) {
1795
- this.settingsRevision += 1;
1920
+ this.advanceSettingsRevision();
1796
1921
  this.compaction?.setEnabled(enabled);
1797
1922
  }
1798
1923
  getCompactionConfig() {
@@ -1807,7 +1932,7 @@ export class AgentController {
1807
1932
  */
1808
1933
  setCompactionConfig(patch) {
1809
1934
  const compaction = clampCompactionConfig({ ...this.config.compaction, ...patch });
1810
- this.settingsRevision += 1;
1935
+ this.advanceSettingsRevision();
1811
1936
  this.config = { ...this.config, compaction };
1812
1937
  this.compaction?.onConfigChanged();
1813
1938
  return compaction;
@@ -1860,18 +1985,80 @@ export class AgentController {
1860
1985
  return { ok: false, reason: 'settings_restart_required' };
1861
1986
  if (!this.compaction)
1862
1987
  return { ok: false, reason: 'not_registered' };
1988
+ if (this.isCompactionBusy() || this.isRunning())
1989
+ return { ok: false, reason: 'busy' };
1863
1990
  const controller = new AbortController();
1864
- const outcome = await this.compaction.compactNow({
1865
- messages: this.agent.state.messages,
1866
- systemPrompt: this.agent.state.systemPrompt,
1867
- model: this.agent.state.model,
1868
- signal: controller.signal,
1869
- ...(instructions ? { instructions } : {}),
1991
+ const epoch = this.startupSequence;
1992
+ const settings = this.settingsRevision;
1993
+ let adopted = false;
1994
+ let timedOut = false;
1995
+ const isCurrent = () => this.idleCompaction === controller && !controller.signal.aborted
1996
+ && epoch === this.startupSequence && settings === this.settingsRevision
1997
+ && !this.isRunning() && !this.disposed;
1998
+ this.idleCompaction = controller;
1999
+ this.publishCompactionBusy(true);
2000
+ const deadline = setTimeout(() => {
2001
+ timedOut = true;
2002
+ controller.abort();
2003
+ }, COMPACTION_LIMITS.operationTimeoutMs);
2004
+ deadline.unref?.();
2005
+ try {
2006
+ // The wiring races cancellation locally and settles before releasing ownership.
2007
+ const outcome = await this.compaction.compactNow({
2008
+ messages: this.agent.state.messages,
2009
+ systemPrompt: this.agent.state.systemPrompt,
2010
+ model: this.agent.state.model,
2011
+ signal: controller.signal,
2012
+ ...(instructions ? { instructions } : {}),
2013
+ isCurrent,
2014
+ adopt: (messages) => {
2015
+ if (!isCurrent())
2016
+ throw new Error('stale_history');
2017
+ this.agent.replaceMessages(messages);
2018
+ adopted = true;
2019
+ },
2020
+ });
2021
+ return outcome.ok || adopted ? { ok: true }
2022
+ : { ok: false, reason: timedOut ? 'timeout' : outcome.reason };
2023
+ }
2024
+ finally {
2025
+ clearTimeout(deadline);
2026
+ if (this.idleCompaction === controller) {
2027
+ this.idleCompaction = undefined;
2028
+ this.publishCompactionBusy(false);
2029
+ }
2030
+ }
2031
+ }
2032
+ /** Local prepare/adopt ownership, available before any network-start event. */
2033
+ isCompactionBusy() { return this.idleCompaction !== undefined; }
2034
+ /** Cancel local compaction and await ownership release before a conversation switch. */
2035
+ cancelCompaction() {
2036
+ if (!this.idleCompaction)
2037
+ return Promise.resolve();
2038
+ return new Promise((resolve) => {
2039
+ const unsubscribe = this.subscribeCompactionBusy((busy) => {
2040
+ if (busy)
2041
+ return;
2042
+ unsubscribe();
2043
+ resolve();
2044
+ });
2045
+ this.abort();
1870
2046
  });
1871
- if (!outcome.ok)
1872
- return { ok: false, reason: outcome.reason };
1873
- this.agent.replaceMessages(outcome.messages);
1874
- return { ok: true };
2047
+ }
2048
+ /** Subscribe to synchronous ownership transitions; observers cannot break adoption. */
2049
+ subscribeCompactionBusy(listener) {
2050
+ this.compactionBusyListeners.add(listener);
2051
+ return () => { this.compactionBusyListeners.delete(listener); };
2052
+ }
2053
+ publishCompactionBusy(busy) {
2054
+ for (const listener of this.compactionBusyListeners) {
2055
+ try {
2056
+ listener(busy);
2057
+ }
2058
+ catch {
2059
+ getLogger().debug('agent', 'compaction_busy_listener_threw');
2060
+ }
2061
+ }
1875
2062
  }
1876
2063
  /**
1877
2064
  * Subscribe to the CLI-local compaction event stream.