@aragon-agent/cli 0.6.14 → 0.6.16

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 +14 -0
  2. package/README.md +295 -238
  3. package/dist/agent/controller.d.ts +37 -1
  4. package/dist/agent/controller.js +195 -45
  5. package/dist/agent/plan-prompt.d.ts +1 -1
  6. package/dist/agent/plan-prompt.js +4 -3
  7. package/dist/agent/project-guidance-prompt.d.ts +4 -0
  8. package/dist/agent/project-guidance-prompt.js +104 -0
  9. package/dist/agent/reducer.d.ts +2 -0
  10. package/dist/agent/reducer.js +4 -0
  11. package/dist/agent/system-prompt.js +4 -0
  12. package/dist/cli.js +49 -2
  13. package/dist/commands/builtins.js +26 -28
  14. package/dist/compaction/child.d.ts +9 -1
  15. package/dist/compaction/child.js +39 -13
  16. package/dist/compaction/command.js +20 -10
  17. package/dist/compaction/compactor.d.ts +33 -204
  18. package/dist/compaction/compactor.js +311 -726
  19. package/dist/compaction/context-command.js +14 -3
  20. package/dist/compaction/digest.d.ts +15 -51
  21. package/dist/compaction/digest.js +113 -148
  22. package/dist/compaction/limits.d.ts +19 -142
  23. package/dist/compaction/limits.js +20 -147
  24. package/dist/compaction/memory-identity.d.ts +16 -0
  25. package/dist/compaction/memory-identity.js +63 -0
  26. package/dist/compaction/memory-input.d.ts +25 -0
  27. package/dist/compaction/memory-input.js +106 -0
  28. package/dist/compaction/memory-render.d.ts +4 -0
  29. package/dist/compaction/memory-render.js +47 -0
  30. package/dist/compaction/memory-validation.d.ts +21 -0
  31. package/dist/compaction/memory-validation.js +268 -0
  32. package/dist/compaction/memory.d.ts +79 -0
  33. package/dist/compaction/memory.js +118 -0
  34. package/dist/compaction/meter.d.ts +14 -10
  35. package/dist/compaction/meter.js +53 -19
  36. package/dist/compaction/operation.d.ts +18 -0
  37. package/dist/compaction/operation.js +68 -0
  38. package/dist/compaction/pressure.d.ts +5 -20
  39. package/dist/compaction/pressure.js +13 -23
  40. package/dist/compaction/summarize-call.d.ts +3 -16
  41. package/dist/compaction/summarize-call.js +20 -20
  42. package/dist/compaction/summary-prompt.d.ts +29 -66
  43. package/dist/compaction/summary-prompt.js +132 -178
  44. package/dist/compaction/types.d.ts +27 -4
  45. package/dist/compaction/wiring.d.ts +10 -25
  46. package/dist/compaction/wiring.js +56 -66
  47. package/dist/config/app-paths.d.ts +10 -0
  48. package/dist/config/app-paths.js +12 -0
  49. package/dist/config/env.d.ts +2 -1
  50. package/dist/config/env.js +13 -1
  51. package/dist/config/load.d.ts +9 -0
  52. package/dist/config/load.js +14 -0
  53. package/dist/config/model-windows.d.ts +91 -0
  54. package/dist/config/model-windows.js +181 -0
  55. package/dist/config/schema.d.ts +18 -1
  56. package/dist/config/schema.js +2 -1
  57. package/dist/exec/events.d.ts +8 -0
  58. package/dist/exec/index.js +13 -6
  59. package/dist/exec/runner.d.ts +8 -0
  60. package/dist/exec/runner.js +26 -2
  61. package/dist/input/csiu-keys.d.ts +66 -0
  62. package/dist/input/csiu-keys.js +170 -0
  63. package/dist/input/keyboard-enhancement.d.ts +42 -0
  64. package/dist/input/keyboard-enhancement.js +65 -0
  65. package/dist/input/stdin-filter.d.ts +9 -0
  66. package/dist/input/stdin-filter.js +45 -9
  67. package/dist/input/win32-input-mode.d.ts +61 -0
  68. package/dist/input/win32-input-mode.js +251 -0
  69. package/dist/session/persist.d.ts +3 -0
  70. package/dist/session/persist.js +9 -0
  71. package/dist/session/store.js +2 -0
  72. package/dist/session/validate-session.js +34 -0
  73. package/dist/team/subagent.js +8 -0
  74. package/dist/ui/App.js +218 -116
  75. package/dist/ui/BottomStatusRow.d.ts +5 -10
  76. package/dist/ui/BottomStatusRow.js +7 -11
  77. package/dist/ui/Composer.d.ts +4 -2
  78. package/dist/ui/Composer.js +2 -2
  79. package/dist/ui/Header.d.ts +6 -2
  80. package/dist/ui/Header.js +7 -6
  81. package/dist/ui/PromptInput.d.ts +3 -1
  82. package/dist/ui/PromptInput.js +32 -41
  83. package/dist/ui/StatusBar.d.ts +6 -0
  84. package/dist/ui/StatusBar.js +8 -7
  85. package/dist/ui/Transcript.js +1 -1
  86. package/dist/ui/entries/CompactionCard.d.ts +3 -1
  87. package/dist/ui/entries/CompactionCard.js +7 -5
  88. package/dist/ui/frame-differ.js +27 -0
  89. package/dist/ui/frame-observer.js +18 -5
  90. package/dist/ui/interaction-copy.d.ts +73 -73
  91. package/dist/ui/interaction-copy.js +30 -38
  92. package/dist/ui/interaction-hints.d.ts +8 -1
  93. package/dist/ui/interaction-hints.js +56 -69
  94. package/dist/ui/layout/AppShell.d.ts +6 -2
  95. package/dist/ui/layout/AppShell.js +2 -2
  96. package/dist/ui/layout/OverlayFrame.d.ts +2 -0
  97. package/dist/ui/layout/OverlayFrame.js +23 -14
  98. package/dist/ui/layout/ScrollViewport.d.ts +3 -1
  99. package/dist/ui/layout/ScrollViewport.js +10 -2
  100. package/dist/ui/layout/budget.d.ts +25 -26
  101. package/dist/ui/layout/budget.js +29 -22
  102. package/dist/ui/layout/document-layout.d.ts +2 -2
  103. package/dist/ui/layout/document-layout.js +2 -3
  104. package/dist/ui/layout/queue-layout.js +4 -4
  105. package/dist/ui/layout/status-detail-layout.d.ts +9 -0
  106. package/dist/ui/layout/status-detail-layout.js +56 -0
  107. package/dist/ui/layout/status-layout.d.ts +19 -2
  108. package/dist/ui/layout/status-layout.js +95 -84
  109. package/dist/ui/layout/wrap-rows.d.ts +2 -0
  110. package/dist/ui/layout/wrap-rows.js +21 -7
  111. package/dist/ui/overlays/ConfirmDialog.d.ts +2 -1
  112. package/dist/ui/overlays/ConfirmDialog.js +22 -3
  113. package/dist/ui/overlays/HelpOverlay.js +9 -2
  114. package/dist/ui/overlays/ModelPicker.d.ts +5 -8
  115. package/dist/ui/overlays/ModelPicker.js +44 -10
  116. package/dist/ui/overlays/ModelProfileEditor.js +2 -0
  117. package/dist/ui/overlays/ModelProfilePicker.d.ts +1 -0
  118. package/dist/ui/overlays/ModelProfilePicker.js +4 -2
  119. package/dist/ui/overlays/PlanReviewOverlay.d.ts +2 -1
  120. package/dist/ui/overlays/PlanReviewOverlay.js +7 -4
  121. package/dist/ui/overlays/QuestionOverlay.d.ts +2 -1
  122. package/dist/ui/overlays/QuestionOverlay.js +45 -32
  123. package/dist/ui/overlays/QueueOverlay.js +2 -2
  124. package/dist/ui/overlays/SettingsScreen.d.ts +1 -0
  125. package/dist/ui/overlays/SettingsScreen.js +10 -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,40 +1,4 @@
1
- /**
2
- * `CompactionWiring` — the controller-side glue for context compaction
3
- * (context-auto-compaction §3.2.1 / §5.2).
4
- *
5
- * ASCII ONLY: `src/compaction/**` is inside the glyph scanner's scope.
6
- *
7
- * CONSTRUCT BEFORE THE `Agent`, ATTACH AFTER IT (C-12 / D-19), and the split is
8
- * the whole reason this class has an `attach` method at all:
9
- *
10
- * - The PORT has to be INSIDE the `Agent`'s constructor argument, because
11
- * `AgentConfig.contextManager` has no setter and the loop reads it from the
12
- * loop context.
13
- * - The WIRING has to SUBSCRIBE to the `Agent`, because `compaction_end` is
14
- * where the engine's `applied` verdict arrives and `agent_start` is what
15
- * resets the per-run cap.
16
- *
17
- * `AgentController` cannot do both in one step: `new Agent({...})` runs before
18
- * every optional subsystem, precisely because `FastWiring`'s constructor
19
- * subscribes. So THIS CONSTRUCTOR MUST NOT SUBSCRIBE. A field assigned after the
20
- * `new Agent` call and spread into it conditionally produces a build where the
21
- * feature is registered, enabled, and SILENTLY ABSENT - the exact shape of
22
- * failure `controller.ts`'s `toolOutputs` comment documents.
23
- *
24
- * `manager()` returns a STABLE object for the controller's lifetime, whose two
25
- * methods delegate to a compactor that reads config LIVE - which is what lets
26
- * `/compact off`, `/compact threshold 0.8` and a settings-screen save take effect
27
- * mid-session without touching the `Agent`.
28
- *
29
- * DECLINED COMPACTIONS PRODUCE NO CLI EVENTS (quiet-noop §3.6). A `pressure`
30
- * compaction that changed nothing, called nothing and spent nothing never emits
31
- * `compaction_start`, so `onCompactorEvent` never opens a card and `settlePending`
32
- * finds neither a held record nor a skeleton to synthesize. CORE still emits its
33
- * own pair for every attempt, which is why `settlePending` still runs - it just
34
- * refreshes the snapshot and returns. `onCompactorEvent`'s `if (!this.open)`
35
- * guard is now unreachable for declines and stays for the abort race it was
36
- * written for.
37
- */
1
+ import { shouldCompactAt } from './pressure.js';
38
2
  import { DEFAULT_RETRY_POLICY, estimatePromptTokens, initProviders, validateHistory, } from '@aragon-agent/core';
39
3
  import { getLogger } from '../logging/logger.js';
40
4
  import { mintRunId, writeArchive } from './archive.js';
@@ -126,6 +90,7 @@ export class CompactionWiring {
126
90
  * key, not an identity.
127
91
  */
128
92
  runId = mintRunId();
93
+ overflowNotified = false;
129
94
  constructor(deps) {
130
95
  this.deps = deps;
131
96
  this.enabled = deps.getConfig().compaction.enabled;
@@ -167,7 +132,14 @@ export class CompactionWiring {
167
132
  // setter and `contextManager` is never reassigned; the LIVE switch is inside
168
133
  // `shouldCompact`, not in the identity of this object.
169
134
  this.port = {
170
- shouldCompact: (probe) => this.enabled && this.compactor.shouldCompact(probe),
135
+ shouldCompact: (probe) => {
136
+ if (!this.enabled)
137
+ return false;
138
+ const allowed = this.compactor.shouldCompact(probe);
139
+ if (probe.trigger === 'overflow' && !allowed)
140
+ this.notifyOverflow();
141
+ return allowed;
142
+ },
171
143
  compact: (ctx) => this.compactor.compact(ctx),
172
144
  };
173
145
  }
@@ -193,6 +165,7 @@ export class CompactionWiring {
193
165
  }
194
166
  dispose() {
195
167
  this.compactor.abort();
168
+ this.compactor.clearPendingManual();
196
169
  this.unsubscribe?.();
197
170
  this.unsubscribe = null;
198
171
  this.listeners.clear();
@@ -205,6 +178,8 @@ export class CompactionWiring {
205
178
  }
206
179
  setEnabled(enabled) {
207
180
  this.enabled = enabled;
181
+ if (!enabled)
182
+ this.compactor.abort();
208
183
  // Turning it back on is the user overriding guard 4, which is the only way
209
184
  // out of a self-disable short of a restart.
210
185
  if (enabled)
@@ -215,6 +190,8 @@ export class CompactionWiring {
215
190
  onConfigChanged(enabled) {
216
191
  if (enabled !== undefined && enabled !== this.enabled) {
217
192
  this.enabled = enabled;
193
+ if (!enabled)
194
+ this.compactor.abort();
218
195
  if (enabled)
219
196
  this.compactor.clearSelfDisable();
220
197
  }
@@ -244,8 +221,19 @@ export class CompactionWiring {
244
221
  * for such a session. A deep reset is idempotent, so both firing costs
245
222
  * nothing; deleting the controller's copy "to de-duplicate" reintroduces P0-2.
246
223
  */
247
- onHistoryReplaced() {
248
- this.meter.onHistoryReplaced();
224
+ onHistoryReplaced(identity) {
225
+ this.compactor.onHistoryReplaced(identity);
226
+ }
227
+ getIdentity() { return this.compactor.getIdentity(); }
228
+ notifyOverflow() {
229
+ const p = this.meter.current();
230
+ if (this.overflowNotified || !Number.isFinite(p.occupied) || p.occupied < 0 ||
231
+ !Number.isFinite(p.contextWindow) || p.contextWindow <= 0 ||
232
+ shouldCompactAt(p, this.deps.getConfig().compaction.threshold))
233
+ return;
234
+ this.overflowNotified = true;
235
+ this.deps.notify('warn', 'Context limit reached before the configured compaction threshold. ' +
236
+ 'Use /compact or correct contextWindow.');
249
237
  }
250
238
  /**
251
239
  * The per-child manager factory, or `null` when children get none
@@ -269,7 +257,7 @@ export class CompactionWiring {
269
257
  // SINK (AC-H12). It is real money on the summarizer's price table, and a
270
258
  // background repair inside a background worker is exactly the spend a
271
259
  // user would otherwise never see.
272
- onUsage: (usage) => this.emit({ type: 'usage', usage }),
260
+ onUsage: (usage, billing) => this.emit({ type: 'usage', usage, ...billing }),
273
261
  });
274
262
  }
275
263
  // -----------------------------------------------------------------------
@@ -284,28 +272,13 @@ export class CompactionWiring {
284
272
  clearPendingManual() {
285
273
  this.compactor.clearPendingManual();
286
274
  }
287
- /**
288
- * The IDLE `/compact` path (§4.4 / D-25).
289
- *
290
- * IT VALIDATES BEFORE IT SPLICES, AND THAT IS NOT OPTIONAL (P1-7). When the
291
- * agent is idle there is no loop, so §3.3 step 5 does not run — and the only
292
- * way to replace the history is `Agent.replaceMessages`, which validates
293
- * nothing. D-4 declares that gate unbypassable and R-2 calls an invalid splice
294
- * the worst possible outcome, so this path mirrors the engine's steps 3-5
295
- * exactly, INCLUDING the failure vocabulary: the card, the log and
296
- * `/compact status` cannot tell the two paths apart.
297
- *
298
- * No watchdog handling is needed — `runLoopWithLifecycle`'s `finally` already
299
- * called `watchdog.stop()`, so nothing is armed while idle.
300
- *
301
- * ITS CALLER MUST ADOPT `messages` — THE ARCHIVE IS ALREADY WRITTEN BY THE
302
- * TIME THIS RETURNS (hardening RV-7). This path settles `applied: true` here,
303
- * one statement before the caller splices, so the "never archive a live
304
- * history" rule holds only because that caller applies unconditionally. That is
305
- * a fact about one call site rather than an invariant, which is why it is
306
- * written down where it can be checked.
307
- */
275
+ /** Prepare, validate and synchronously adopt before publishing a successful verdict. */
308
276
  async compactNow(args) {
277
+ if (!this.enabled)
278
+ return { ok: false, reason: 'disabled' };
279
+ if (this.compactor.sessionTotals().inFlight)
280
+ return { ok: false, reason: 'already_running' };
281
+ this.compactor.onRunStart();
309
282
  this.compactor.queueManual(args.instructions);
310
283
  // BOTH SIDES FROM CORE'S OWN ESTIMATOR, exactly as the in-loop path gets them
311
284
  // from `compaction_end` (§5.1). There is no engine event here to supply them,
@@ -342,6 +315,19 @@ export class CompactionWiring {
342
315
  this.settlePending({ applied: false, reason, tokensBefore, tokensAfter: tokensBefore });
343
316
  return { ok: false, reason };
344
317
  }
318
+ if (args.signal.aborted || args.isCurrent?.() === false || !args.adopt) {
319
+ const reason = args.signal.aborted ? 'aborted' : 'stale_history';
320
+ this.settlePending({ applied: false, reason, tokensBefore, tokensAfter: tokensBefore });
321
+ return { ok: false, reason };
322
+ }
323
+ try {
324
+ args.adopt(outcome.messages);
325
+ }
326
+ catch (error) {
327
+ const reason = `adoption_failed: ${error instanceof Error ? error.message : String(error)}`;
328
+ this.settlePending({ applied: false, reason, tokensBefore, tokensAfter: tokensBefore });
329
+ return { ok: false, reason };
330
+ }
345
331
  this.settlePending({
346
332
  applied: true,
347
333
  tokensBefore,
@@ -387,6 +373,7 @@ export class CompactionWiring {
387
373
  ...(totals.declined > 0 ? { declined: totals.declined } : {}),
388
374
  tokensReclaimed: totals.tokensReclaimed,
389
375
  usage: totals.usage,
376
+ costUsd: totals.costUsd,
390
377
  pricingUnknown: totals.pricingUnknown,
391
378
  inFlight: totals.inFlight,
392
379
  selfDisabled: totals.selfDisabled,
@@ -460,9 +447,13 @@ export class CompactionWiring {
460
447
  onAgentEvent(event) {
461
448
  if (event.type === 'agent_start') {
462
449
  this.compactor.onRunStart();
450
+ this.overflowNotified = false;
463
451
  return;
464
452
  }
453
+ if (event.type === 'agent_end')
454
+ this.compactor.onRunEnd();
465
455
  if (event.type === 'turn_end') {
456
+ this.meter.onTurnEnd(event.usage);
466
457
  // THE MEASUREMENT IS THE METER'S (context-usage-gauge-accuracy §3.3.4). It
467
458
  // subscribes to `turn_end` on the same stream and recomputes the offset,
468
459
  // the prefix length and the pressure there; the two calls that used to
@@ -501,6 +492,7 @@ export class CompactionWiring {
501
492
  // engine cannot report `true` without a `replace` outcome, which means the
502
493
  // compactor's own record already exists — so its zeroed counts are never
503
494
  // rendered.
495
+ this.compactor.settleOperation(verdict);
504
496
  const record = this.pending ?? this.skeletonRecord();
505
497
  this.pending = null;
506
498
  this.open = null;
@@ -533,7 +525,6 @@ export class CompactionWiring {
533
525
  mode: verdict.applied ? record.mode : 'none',
534
526
  };
535
527
  if (verdict.applied) {
536
- this.compactor.recordApplied(settled.tokensBefore, settled.tokensAfter);
537
528
  // SITE 1 OF THE FOUR INVALIDATION SITES (hardening §3.2.3), and THE LOAD-
538
529
  // BEARING LINE OF THE P0-1 FIX (context-usage-gauge-accuracy I-9).
539
530
  //
@@ -565,9 +556,8 @@ export class CompactionWiring {
565
556
  * archive a history that is still live" - and the two paths reach that verdict
566
557
  * at different moments relative to the splice. In loop, the ENGINE decides
567
558
  * after `validateHistory`, so this runs AFTER adoption. On the manual path the
568
- * WIRING decides and `compactNow`'s caller adopts on the next statement, so
569
- * this runs BEFORE adoption; `compactNow`'s doc comment carries that obligation
570
- * for its one caller (RV-7).
559
+ * WIRING invokes the host adoption callback before settling, so both paths
560
+ * archive only histories that have actually been replaced.
571
561
  *
572
562
  * BEST-EFFORT AND SYNCHRONOUS-BUT-GUARDED. `writeArchive` never throws and
573
563
  * logs its own failure; a failed archive never affects the compaction, and it
@@ -12,6 +12,7 @@
12
12
  * ~/.aragon-agent/
13
13
  * ├── config.json persisted config (0600 on POSIX)
14
14
  * ├── config.json.bak `aragon config edit` pre-edit backup
15
+ * ├── model-windows.json per-model context windows, user-edited
15
16
  * ├── prompt-history.jsonl submitted prompts, append-only (0600 on POSIX)
16
17
  * ├── state.json UI bookkeeping scalars (0600 on POSIX)
17
18
  * ├── update-state.json auto-update coordination (0600 on POSIX)
@@ -72,6 +73,15 @@ export declare function getHomeRoot(): string;
72
73
  export declare function getConfigPath(): string;
73
74
  /** Written by `aragon config edit` before spawning the editor. */
74
75
  export declare function getConfigBackupPath(): string;
76
+ /**
77
+ * `<home>/model-windows.json` — per-model context windows the user declares by
78
+ * hand for ids no table knows (`kimi-k3`, a gateway alias, a self-hosted model).
79
+ *
80
+ * READ-ONLY from the application's side: nothing ever writes or creates it, so
81
+ * a user's file cannot be clobbered by an upgrade and its absence is not an
82
+ * error. `config/model-windows.ts` owns the format and the lookup.
83
+ */
84
+ export declare function getModelWindowsPath(): string;
75
85
  /**
76
86
  * `<home>/prompt-history.jsonl` — the prompts the user submitted, one JSON
77
87
  * object per line. Deliberately NOT under `logs/`: that directory is governed
@@ -12,6 +12,7 @@
12
12
  * ~/.aragon-agent/
13
13
  * ├── config.json persisted config (0600 on POSIX)
14
14
  * ├── config.json.bak `aragon config edit` pre-edit backup
15
+ * ├── model-windows.json per-model context windows, user-edited
15
16
  * ├── prompt-history.jsonl submitted prompts, append-only (0600 on POSIX)
16
17
  * ├── state.json UI bookkeeping scalars (0600 on POSIX)
17
18
  * ├── update-state.json auto-update coordination (0600 on POSIX)
@@ -129,6 +130,17 @@ export function getConfigPath() {
129
130
  export function getConfigBackupPath() {
130
131
  return join(resolution.root, 'config.json.bak');
131
132
  }
133
+ /**
134
+ * `<home>/model-windows.json` — per-model context windows the user declares by
135
+ * hand for ids no table knows (`kimi-k3`, a gateway alias, a self-hosted model).
136
+ *
137
+ * READ-ONLY from the application's side: nothing ever writes or creates it, so
138
+ * a user's file cannot be clobbered by an upgrade and its absence is not an
139
+ * error. `config/model-windows.ts` owns the format and the lookup.
140
+ */
141
+ export function getModelWindowsPath() {
142
+ return join(resolution.root, 'model-windows.json');
143
+ }
132
144
  /**
133
145
  * `<home>/prompt-history.jsonl` — the prompts the user submitted, one JSON
134
146
  * object per line. Deliberately NOT under `logs/`: that directory is governed
@@ -7,7 +7,8 @@
7
7
  * - Overrides: ARAGON_PROVIDER, ARAGON_MODEL, ARAGON_BASE_URL, ARAGON_THINKING,
8
8
  * ARAGON_SHOW_THINKING,
9
9
  * ARAGON_MAX_TOKENS (a number, or `auto` / `0` for the per-model ceiling),
10
- * ARAGON_THEME, ARAGON_MOUSE, ARAGON_PASTE, ARAGON_PLAN, ARAGON_TEAM,
10
+ * ARAGON_THEME, ARAGON_MOUSE, ARAGON_PASTE, ARAGON_KEYBOARD_ENHANCEMENT,
11
+ * ARAGON_PLAN, ARAGON_TEAM,
11
12
  * ARAGON_TEAM_MAX, ARAGON_RETRY, ARAGON_RETRY_MAX, ARAGON_FAST,
12
13
  * ARAGON_FAST_PROVIDER, ARAGON_FAST_MODEL, ARAGON_FAST_BASE_URL,
13
14
  * ARAGON_LOG_LEVEL,
@@ -7,7 +7,8 @@
7
7
  * - Overrides: ARAGON_PROVIDER, ARAGON_MODEL, ARAGON_BASE_URL, ARAGON_THINKING,
8
8
  * ARAGON_SHOW_THINKING,
9
9
  * ARAGON_MAX_TOKENS (a number, or `auto` / `0` for the per-model ceiling),
10
- * ARAGON_THEME, ARAGON_MOUSE, ARAGON_PASTE, ARAGON_PLAN, ARAGON_TEAM,
10
+ * ARAGON_THEME, ARAGON_MOUSE, ARAGON_PASTE, ARAGON_KEYBOARD_ENHANCEMENT,
11
+ * ARAGON_PLAN, ARAGON_TEAM,
11
12
  * ARAGON_TEAM_MAX, ARAGON_RETRY, ARAGON_RETRY_MAX, ARAGON_FAST,
12
13
  * ARAGON_FAST_PROVIDER, ARAGON_FAST_MODEL, ARAGON_FAST_BASE_URL,
13
14
  * ARAGON_LOG_LEVEL,
@@ -151,6 +152,17 @@ export function readEnvConfig() {
151
152
  if (paste !== undefined && paste.length > 0) {
152
153
  partial.paste = paste === '1' || paste === 'true' || paste === 'on' || paste === 'yes';
153
154
  }
155
+ // ARAGON_KEYBOARD_ENHANCEMENT=0 stops the Shift+Enter mode push and its
156
+ // translation -- the one-env revert to pre-enhancement input handling.
157
+ // Parsed by the POSITIVE list, exactly as its three neighbours above are.
158
+ const keyboardEnhancement = process.env.ARAGON_KEYBOARD_ENHANCEMENT?.trim().toLowerCase();
159
+ if (keyboardEnhancement !== undefined && keyboardEnhancement.length > 0) {
160
+ partial.keyboardEnhancement =
161
+ keyboardEnhancement === '1' ||
162
+ keyboardEnhancement === 'true' ||
163
+ keyboardEnhancement === 'on' ||
164
+ keyboardEnhancement === 'yes';
165
+ }
154
166
  // ARAGON_SCROLL_RESUME_MS=<n> — idle delay before a paused viewport returns to
155
167
  // the newest line; `0` disables auto-resume. THE ONLY CHANNEL BESIDES THE
156
168
  // CONFIG FILE, because §6.1 gives this key no CLI flag on purpose. `load.ts`
@@ -59,6 +59,15 @@ export interface CliFlags {
59
59
  * `false` on every run that passed no flag at all.
60
60
  */
61
61
  paste?: boolean;
62
+ /**
63
+ * `--keyboard-enhancement` ⇒ true, `--no-keyboard-enhancement` ⇒ false,
64
+ * absent ⇒ undefined.
65
+ *
66
+ * DECLARED AS A PAIR and carried through `toFlags`, for the reason the
67
+ * `paste` note above records: the key is persisted and defaults to
68
+ * `true`, so a lone negative flag must not read as silence.
69
+ */
70
+ keyboardEnhancement?: boolean;
62
71
  /** `--no-skills` ⇒ false. Turns the whole skill subsystem off (§7.5). */
63
72
  skills?: boolean;
64
73
  /** `--skill <name>` ×N — force Level 2 injection for this run. */
@@ -78,6 +78,19 @@ function resolvePaste(flags, env, file) {
78
78
  return file.paste;
79
79
  return DEFAULT_CONFIG.paste;
80
80
  }
81
+ /**
82
+ * Keyboard enhancement: flag > env > file > default `true`. Shaped exactly
83
+ * like `resolvePaste` above, for the reason its own note gives.
84
+ */
85
+ function resolveKeyboardEnhancement(flags, env, file) {
86
+ if (flags.keyboardEnhancement !== undefined)
87
+ return flags.keyboardEnhancement;
88
+ if (env.keyboardEnhancement !== undefined)
89
+ return env.keyboardEnhancement;
90
+ if (file.keyboardEnhancement !== undefined)
91
+ return file.keyboardEnhancement;
92
+ return DEFAULT_CONFIG.keyboardEnhancement;
93
+ }
81
94
  /**
82
95
  * Idle scroll-resume delay: env › file › default 5000, clamped to [0, 120000].
83
96
  *
@@ -585,6 +598,7 @@ export function loadConfig(flags = {}, options = {}) {
585
598
  mouse: resolveMouse(flags, env.partial, file),
586
599
  mouseSelect: resolveMouseSelect(flags, env.partial, file),
587
600
  paste: resolvePaste(flags, env.partial, file),
601
+ keyboardEnhancement: resolveKeyboardEnhancement(flags, env.partial, file),
588
602
  scrollResumeMs: resolveScrollResumeMs(env.partial, file),
589
603
  // From `<home>/state.json`, not the config file — see `ui-state.ts`.
590
604
  submitCount: readSubmitCount(),
@@ -0,0 +1,91 @@
1
+ /**
2
+ * User-declared context windows, read from `~/.aragon-agent/model-windows.json`
3
+ * (path owned by `app-paths.ts`).
4
+ *
5
+ * WHY THIS FILE EXISTS. `ModelRegistry.getContextWindow` resolves a window from
6
+ * API discovery, the builtin model lists and the ten-entry catalog - and for
7
+ * every other id (a gateway alias, a self-hosted model, a release too new for
8
+ * the table) it returns the explicitly-untrusted 128k `fallback`. The gauge
9
+ * then renders `?` forever, by design: it refuses to divide by a guessed
10
+ * denominator. The one number that can make the denominator real is the user's
11
+ * own, and `config.json`'s `contextWindow` key can only hold ONE of it. This
12
+ * file is the per-model form of the same assertion.
13
+ *
14
+ * THE APPLICATION NEVER WRITES IT. A file the user owns by hand cannot be
15
+ * clobbered by an upgrade, and its absence is the ordinary case, not an error.
16
+ *
17
+ * ASCII ONLY: `src/config/**` is inside the glyph scanner's scope
18
+ * (`glyphs.test.ts::inScope`).
19
+ */
20
+ /** Structural bounds only; there is no policy surface to tune here. */
21
+ export interface ModelWindowsLimits {
22
+ /** Entries beyond this many are dropped, in file order. */
23
+ readonly maxEntries: number;
24
+ /** Keys longer than this are dropped rather than trusted as ids. */
25
+ readonly maxKeyLength: number;
26
+ /** A window below the smallest real context (1k) is a typo, not a limit. */
27
+ readonly minWindow: number;
28
+ /** A window above 1e9 tokens is also a typo; no model ships that today. */
29
+ readonly maxWindow: number;
30
+ }
31
+ export declare const MODEL_WINDOWS_LIMITS: ModelWindowsLimits;
32
+ /** Normalized model id -> declared window. Keys are stored ALREADY normalized. */
33
+ export type ModelWindowTable = ReadonlyMap<string, number>;
34
+ /**
35
+ * Same normalization core's `catalogModelId` applies, restated here because it
36
+ * is not part of Core's public surface and a second exporter would be a
37
+ * dependency on an internal. Keep the two in step: a date-stamped id
38
+ * (`kimi-k3-20260716`) and its bare form must resolve to one entry.
39
+ */
40
+ export declare function normalizeModelKey(modelId: string): string;
41
+ /** What a parse produced, and whether the FILE itself was at fault. */
42
+ export interface ModelWindowsParse {
43
+ table: ModelWindowTable;
44
+ /**
45
+ * True only when the text could not be understood at all (bad JSON, wrong
46
+ * top-level type). A valid file with zero USABLE entries is `false`: the
47
+ * user wrote JSON, the values were the problem, and every dead value was
48
+ * already dropped rather than trusted.
49
+ */
50
+ malformed: boolean;
51
+ }
52
+ /**
53
+ * Parse already-decoded text into a table. NEVER THROWS - a hand-edited file
54
+ * is the rule here, not the exception, and a typo must cost the user one dead
55
+ * entry, not the session. Two accepted shapes:
56
+ *
57
+ * { "version": 1, "windows": { "kimi-k3": 1048576 } }
58
+ * { "kimi-k3": 1048576 } (bare map, same effect)
59
+ *
60
+ * The wrapper exists so the format can grow fields without a migration; the
61
+ * bare map is accepted because the first shape a user types is the simple one.
62
+ * A leading UTF-8 BOM is tolerated: `readFileSync(..., 'utf8')` does not strip
63
+ * it, `JSON.parse` cannot parse it, and Notepad writes it by default.
64
+ */
65
+ export declare function parseModelWindowsResult(text: string): ModelWindowsParse;
66
+ /** The table alone, for callers that do not care why it might be empty. */
67
+ export declare function parseModelWindows(text: string): ModelWindowTable;
68
+ /**
69
+ * mtime-cached lookup over the file.
70
+ *
71
+ * WHY A STAT PER LOOKUP RATHER THAN A LOAD AT STARTUP: `getModelInfoFor` is
72
+ * called on every measurement, and the meter's `refreshValidity` re-reads the
73
+ * window precisely so a moved denominator (a `/model` switch, a live config
74
+ * edit) re-measures without a relaunch. A user saving this file mid-session
75
+ * gets the same treatment - `statSync` is microseconds, and the read itself
76
+ * only happens when the stamp actually changes.
77
+ *
78
+ * THE STAMP IS mtimeMs+size, so a rewrite that lands in the SAME millisecond
79
+ * with the SAME byte count serves the previous table once. Squeezing that
80
+ * window shut would need a content hash per lookup, which is exactly the cost
81
+ * the stamp exists to avoid; the next differing write clears it regardless.
82
+ */
83
+ export declare class ModelWindows {
84
+ private readonly path;
85
+ private table;
86
+ private stamp;
87
+ constructor(path?: string);
88
+ /** The declared window for `modelId`, or `undefined` when nobody declared one. */
89
+ lookup(modelId: string): number | undefined;
90
+ private refresh;
91
+ }
@@ -0,0 +1,181 @@
1
+ /**
2
+ * User-declared context windows, read from `~/.aragon-agent/model-windows.json`
3
+ * (path owned by `app-paths.ts`).
4
+ *
5
+ * WHY THIS FILE EXISTS. `ModelRegistry.getContextWindow` resolves a window from
6
+ * API discovery, the builtin model lists and the ten-entry catalog - and for
7
+ * every other id (a gateway alias, a self-hosted model, a release too new for
8
+ * the table) it returns the explicitly-untrusted 128k `fallback`. The gauge
9
+ * then renders `?` forever, by design: it refuses to divide by a guessed
10
+ * denominator. The one number that can make the denominator real is the user's
11
+ * own, and `config.json`'s `contextWindow` key can only hold ONE of it. This
12
+ * file is the per-model form of the same assertion.
13
+ *
14
+ * THE APPLICATION NEVER WRITES IT. A file the user owns by hand cannot be
15
+ * clobbered by an upgrade, and its absence is the ordinary case, not an error.
16
+ *
17
+ * ASCII ONLY: `src/config/**` is inside the glyph scanner's scope
18
+ * (`glyphs.test.ts::inScope`).
19
+ */
20
+ import { readFileSync, statSync } from 'node:fs';
21
+ import { getLogger } from '../logging/logger.js';
22
+ import { getModelWindowsPath } from './app-paths.js';
23
+ export const MODEL_WINDOWS_LIMITS = {
24
+ maxEntries: 512,
25
+ maxKeyLength: 200,
26
+ minWindow: 1_024,
27
+ maxWindow: 1_000_000_000,
28
+ };
29
+ /**
30
+ * Same normalization core's `catalogModelId` applies, restated here because it
31
+ * is not part of Core's public surface and a second exporter would be a
32
+ * dependency on an internal. Keep the two in step: a date-stamped id
33
+ * (`kimi-k3-20260716`) and its bare form must resolve to one entry.
34
+ */
35
+ export function normalizeModelKey(modelId) {
36
+ return modelId.trim().toLowerCase()
37
+ .replace(/^(models|openai|anthropic|google)\//, '')
38
+ .replace(/-(?:\d{4}-\d{2}-\d{2}|\d{8})$/, '');
39
+ }
40
+ function isWindowValue(value) {
41
+ return typeof value === 'number' && Number.isSafeInteger(value) &&
42
+ value >= MODEL_WINDOWS_LIMITS.minWindow && value <= MODEL_WINDOWS_LIMITS.maxWindow;
43
+ }
44
+ /**
45
+ * Parse already-decoded text into a table. NEVER THROWS - a hand-edited file
46
+ * is the rule here, not the exception, and a typo must cost the user one dead
47
+ * entry, not the session. Two accepted shapes:
48
+ *
49
+ * { "version": 1, "windows": { "kimi-k3": 1048576 } }
50
+ * { "kimi-k3": 1048576 } (bare map, same effect)
51
+ *
52
+ * The wrapper exists so the format can grow fields without a migration; the
53
+ * bare map is accepted because the first shape a user types is the simple one.
54
+ * A leading UTF-8 BOM is tolerated: `readFileSync(..., 'utf8')` does not strip
55
+ * it, `JSON.parse` cannot parse it, and Notepad writes it by default.
56
+ */
57
+ export function parseModelWindowsResult(text) {
58
+ const body = text.replace(/^\uFEFF/, '');
59
+ let parsed;
60
+ try {
61
+ parsed = JSON.parse(body);
62
+ }
63
+ catch {
64
+ return { table: new Map(), malformed: true };
65
+ }
66
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
67
+ return { table: new Map(), malformed: true };
68
+ }
69
+ const record = parsed;
70
+ const source = record.windows !== undefined && record.windows !== null &&
71
+ typeof record.windows === 'object' && !Array.isArray(record.windows)
72
+ ? record.windows
73
+ : record;
74
+ const table = new Map();
75
+ for (const [rawKey, rawValue] of Object.entries(source)) {
76
+ const key = normalizeModelKey(rawKey);
77
+ if (key.length === 0 || key.length > MODEL_WINDOWS_LIMITS.maxKeyLength)
78
+ continue;
79
+ if (!isWindowValue(rawValue))
80
+ continue;
81
+ table.set(key, rawValue);
82
+ if (table.size >= MODEL_WINDOWS_LIMITS.maxEntries)
83
+ break;
84
+ }
85
+ return { table, malformed: false };
86
+ }
87
+ /** The table alone, for callers that do not care why it might be empty. */
88
+ export function parseModelWindows(text) {
89
+ return parseModelWindowsResult(text).table;
90
+ }
91
+ /**
92
+ * Decode the raw bytes the way a Windows text editor may have saved them.
93
+ *
94
+ * `readFileSync(path, 'utf8')` alone fails THREE ways on this machine's most
95
+ * common editors: a UTF-8 BOM survives into the string and kills `JSON.parse`,
96
+ * and Notepad's "Unicode" saves are UTF-16LE (or BE), which decode as NUL-laden
97
+ * mojibake. All three are silent feature-death for exactly the user who just
98
+ * tried to configure it, so the sniff happens before any parse.
99
+ */
100
+ function decodeModelWindowsFile(bytes) {
101
+ if (bytes.length >= 2 && bytes[0] === 0xff && bytes[1] === 0xfe) {
102
+ return bytes.subarray(2).toString('utf16le');
103
+ }
104
+ if (bytes.length >= 2 && bytes[0] === 0xfe && bytes[1] === 0xff) {
105
+ // Big-endian UTF-16: Node has no utf16be decoder, so swap pairs in place
106
+ // on a COPY and reuse the little-endian decoder.
107
+ const swapped = Buffer.from(bytes.subarray(2));
108
+ for (let i = 0; i + 1 < swapped.length; i += 2) {
109
+ const high = swapped[i];
110
+ swapped[i] = swapped[i + 1];
111
+ swapped[i + 1] = high;
112
+ }
113
+ return swapped.toString('utf16le');
114
+ }
115
+ if (bytes.length >= 3 && bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf) {
116
+ return bytes.subarray(3).toString('utf8');
117
+ }
118
+ return bytes.toString('utf8');
119
+ }
120
+ /**
121
+ * mtime-cached lookup over the file.
122
+ *
123
+ * WHY A STAT PER LOOKUP RATHER THAN A LOAD AT STARTUP: `getModelInfoFor` is
124
+ * called on every measurement, and the meter's `refreshValidity` re-reads the
125
+ * window precisely so a moved denominator (a `/model` switch, a live config
126
+ * edit) re-measures without a relaunch. A user saving this file mid-session
127
+ * gets the same treatment - `statSync` is microseconds, and the read itself
128
+ * only happens when the stamp actually changes.
129
+ *
130
+ * THE STAMP IS mtimeMs+size, so a rewrite that lands in the SAME millisecond
131
+ * with the SAME byte count serves the previous table once. Squeezing that
132
+ * window shut would need a content hash per lookup, which is exactly the cost
133
+ * the stamp exists to avoid; the next differing write clears it regardless.
134
+ */
135
+ export class ModelWindows {
136
+ path;
137
+ table = new Map();
138
+ stamp = null;
139
+ constructor(path = getModelWindowsPath()) {
140
+ this.path = path;
141
+ }
142
+ /** The declared window for `modelId`, or `undefined` when nobody declared one. */
143
+ lookup(modelId) {
144
+ this.refresh();
145
+ return this.table.get(normalizeModelKey(modelId));
146
+ }
147
+ refresh() {
148
+ let stamp;
149
+ try {
150
+ const stats = statSync(this.path);
151
+ stamp = { mtimeMs: stats.mtimeMs, size: stats.size };
152
+ }
153
+ catch (error) {
154
+ // A deleted file (ENOENT) is a decision: drop the entries. Any OTHER
155
+ // failure (EACCES, an antivirus lock) is transient, and blanking every
156
+ // declared window because a scanner held the file open for 50 ms would
157
+ // swing the gauge to `?` for a reason the user cannot see - keep the
158
+ // last good table and try again on the next lookup.
159
+ if (error.code === 'ENOENT') {
160
+ this.stamp = null;
161
+ this.table = new Map();
162
+ }
163
+ return;
164
+ }
165
+ if (stamp.mtimeMs === this.stamp?.mtimeMs && stamp.size === this.stamp.size)
166
+ return;
167
+ let bytes;
168
+ try {
169
+ bytes = readFileSync(this.path);
170
+ }
171
+ catch {
172
+ return; // Appeared in the stat, gone in the read: try again next lookup.
173
+ }
174
+ const result = parseModelWindowsResult(decodeModelWindowsFile(bytes));
175
+ if (result.malformed) {
176
+ getLogger().warn('config', 'model-windows.json could not be parsed; ignoring it until it is fixed', { path: this.path });
177
+ }
178
+ this.table = result.table;
179
+ this.stamp = stamp;
180
+ }
181
+ }
@@ -577,7 +577,7 @@ export interface CompactionConfig {
577
577
  * SESSION'S OWN MODEL, and the first compaction is a ~30 k-token call on it.
578
578
  */
579
579
  useFastTier: boolean;
580
- /** What happens when summarization fails outright. See §3.7 rungs 3 / 4. */
580
+ /** Legacy truncate remains readable; all failures now preserve history. */
581
581
  onFailure: CompactionFailureMode;
582
582
  /**
583
583
  * Give `task` children their own compaction
@@ -786,6 +786,14 @@ export interface PersistedConfig {
786
786
  * asked for; a kill switch is what a bug report needs.
787
787
  */
788
788
  paste: boolean;
789
+ /**
790
+ * Ask the terminal to report modified keys distinctly so Shift+Enter can
791
+ * insert a newline (win32-input-mode on Windows, kitty disambiguate +
792
+ * modifyOtherKeys elsewhere). A KILL SWITCH in the exact sense `paste`
793
+ * above records: the encoding tables are structural and live in
794
+ * `input/win32-input-mode.ts` / `input/csiu-keys.ts`.
795
+ */
796
+ keyboardEnhancement: boolean;
789
797
  /** Idle ms before a paused viewport returns to the newest line; `0` disables. */
790
798
  scrollResumeMs: number;
791
799
  /** Start every session in PLAN mode (plan-mode §4.4). */
@@ -971,6 +979,15 @@ export interface CliConfig {
971
979
  * reach none of them.
972
980
  */
973
981
  paste: boolean;
982
+ /**
983
+ * Whether this session may push the keyboard-enhancement modes.
984
+ *
985
+ * IT MUST EXIST HERE AND NOT ONLY IN `PersistedConfig`, for the reason
986
+ * `paste` above records: `runInteractive` reads it -- where `config`
987
+ * is a `CliConfig` -- to decide the filter's `enhancedKeys` feature
988
+ * and the `enableKeyboardEnhancement` push.
989
+ */
990
+ keyboardEnhancement: boolean;
974
991
  /** Idle ms before a paused viewport returns to the newest line; `0` disables. */
975
992
  scrollResumeMs: number;
976
993
  /**