@aragon-agent/cli 0.6.11 → 0.6.13

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 (115) hide show
  1. package/CHANGELOG.md +10 -1
  2. package/README.md +97 -20
  3. package/dist/agent/controller.d.ts +38 -3
  4. package/dist/agent/controller.js +151 -60
  5. package/dist/agent/model-profile-settings.d.ts +31 -0
  6. package/dist/agent/model-profile-settings.js +199 -0
  7. package/dist/agent/queued-messages.d.ts +20 -0
  8. package/dist/agent/queued-messages.js +46 -0
  9. package/dist/agent/reducer.d.ts +21 -2
  10. package/dist/agent/reducer.js +65 -37
  11. package/dist/cli.js +15 -1
  12. package/dist/commands/builtins.js +83 -42
  13. package/dist/commands/queue-command.d.ts +3 -0
  14. package/dist/commands/queue-command.js +5 -0
  15. package/dist/commands/registry.d.ts +3 -0
  16. package/dist/compaction/child.d.ts +4 -2
  17. package/dist/compaction/child.js +4 -1
  18. package/dist/compaction/compactor.d.ts +7 -2
  19. package/dist/compaction/compactor.js +13 -11
  20. package/dist/compaction/context-command.js +5 -0
  21. package/dist/compaction/wiring.d.ts +4 -3
  22. package/dist/compaction/wiring.js +6 -1
  23. package/dist/config/cli-commands.d.ts +2 -0
  24. package/dist/config/cli-commands.js +16 -4
  25. package/dist/config/load.d.ts +2 -1
  26. package/dist/config/load.js +15 -8
  27. package/dist/config/model-profile-resolution.d.ts +20 -0
  28. package/dist/config/model-profile-resolution.js +81 -0
  29. package/dist/config/model-profile-store.d.ts +67 -0
  30. package/dist/config/model-profile-store.js +153 -0
  31. package/dist/config/model-profiles.d.ts +44 -0
  32. package/dist/config/model-profiles.js +121 -0
  33. package/dist/config/schema.d.ts +9 -0
  34. package/dist/config/store.d.ts +11 -1
  35. package/dist/config/store.js +43 -27
  36. package/dist/fast/resolve.d.ts +2 -1
  37. package/dist/fast/resolve.js +6 -4
  38. package/dist/fast/wiring.d.ts +5 -3
  39. package/dist/fast/wiring.js +8 -4
  40. package/dist/input/enter-sequences.d.ts +11 -10
  41. package/dist/input/enter-sequences.js +29 -27
  42. package/dist/input/stdin-filter.js +93 -28
  43. package/dist/logging/install.d.ts +3 -0
  44. package/dist/logging/install.js +4 -2
  45. package/dist/logging/secret-registry.d.ts +2 -0
  46. package/dist/logging/secret-registry.js +18 -0
  47. package/dist/session/persist.d.ts +6 -7
  48. package/dist/session/persist.js +6 -9
  49. package/dist/session/store.d.ts +4 -1
  50. package/dist/session/validate-session.d.ts +3 -0
  51. package/dist/session/validate-session.js +207 -0
  52. package/dist/team/runtime.d.ts +2 -1
  53. package/dist/team/subagent.d.ts +3 -1
  54. package/dist/team/subagent.js +3 -1
  55. package/dist/ui/ActivityLine.d.ts +6 -70
  56. package/dist/ui/ActivityLine.js +12 -34
  57. package/dist/ui/App.d.ts +1 -0
  58. package/dist/ui/App.js +161 -226
  59. package/dist/ui/BottomStatusRow.d.ts +6 -86
  60. package/dist/ui/BottomStatusRow.js +11 -26
  61. package/dist/ui/Composer.d.ts +18 -84
  62. package/dist/ui/Composer.js +9 -100
  63. package/dist/ui/PromptInput.d.ts +6 -2
  64. package/dist/ui/PromptInput.js +91 -105
  65. package/dist/ui/QueuePanel.d.ts +8 -0
  66. package/dist/ui/QueuePanel.js +9 -0
  67. package/dist/ui/QueueStatusRow.d.ts +24 -0
  68. package/dist/ui/QueueStatusRow.js +51 -0
  69. package/dist/ui/StatusBar.d.ts +15 -149
  70. package/dist/ui/StatusBar.js +27 -112
  71. package/dist/ui/clipboard-task.d.ts +29 -0
  72. package/dist/ui/clipboard-task.js +66 -0
  73. package/dist/ui/clipboard.d.ts +29 -50
  74. package/dist/ui/clipboard.js +141 -75
  75. package/dist/ui/composer-input.d.ts +28 -0
  76. package/dist/ui/composer-input.js +46 -0
  77. package/dist/ui/composer-limits.d.ts +3 -4
  78. package/dist/ui/composer-limits.js +4 -9
  79. package/dist/ui/composer-rows.d.ts +1 -1
  80. package/dist/ui/composer-rows.js +24 -137
  81. package/dist/ui/editor-navigation.d.ts +45 -0
  82. package/dist/ui/editor-navigation.js +85 -0
  83. package/dist/ui/editor-reducer.d.ts +9 -0
  84. package/dist/ui/editor-reducer.js +31 -15
  85. package/dist/ui/enter-frames.d.ts +15 -31
  86. package/dist/ui/enter-frames.js +14 -33
  87. package/dist/ui/interaction-copy.d.ts +77 -0
  88. package/dist/ui/interaction-copy.js +40 -0
  89. package/dist/ui/interaction-hints.d.ts +18 -0
  90. package/dist/ui/interaction-hints.js +92 -0
  91. package/dist/ui/layout/budget.js +1 -1
  92. package/dist/ui/layout/queue-layout.d.ts +19 -0
  93. package/dist/ui/layout/queue-layout.js +26 -0
  94. package/dist/ui/layout/status-layout.d.ts +55 -0
  95. package/dist/ui/layout/status-layout.js +109 -0
  96. package/dist/ui/layout/todo-layout.d.ts +1 -0
  97. package/dist/ui/layout/todo-layout.js +1 -1
  98. package/dist/ui/overlays/HelpOverlay.js +11 -3
  99. package/dist/ui/overlays/ModelProfileEditor.d.ts +12 -0
  100. package/dist/ui/overlays/ModelProfileEditor.js +103 -0
  101. package/dist/ui/overlays/ModelProfilePicker.d.ts +35 -0
  102. package/dist/ui/overlays/ModelProfilePicker.js +167 -0
  103. package/dist/ui/overlays/QueueOverlay.d.ts +22 -0
  104. package/dist/ui/overlays/QueueOverlay.js +79 -0
  105. package/dist/ui/overlays/SettingsScreen.d.ts +9 -2
  106. package/dist/ui/overlays/SettingsScreen.js +347 -3
  107. package/dist/ui/overlays/model-profile-state.d.ts +74 -0
  108. package/dist/ui/overlays/model-profile-state.js +119 -0
  109. package/dist/ui/run-status-row.d.ts +7 -19
  110. package/dist/ui/run-status-row.js +2 -18
  111. package/dist/ui/selection/selection-controller.d.ts +2 -2
  112. package/dist/ui/selection/selection-controller.js +58 -20
  113. package/dist/ui/transcript-text.js +20 -12
  114. package/dist/ui/use-wheel-routing.js +2 -1
  115. package/package.json +2 -2
@@ -4,18 +4,19 @@
4
4
  * /resume /copy /terminal-setup /exit (/quit), plus the /team, /fast and
5
5
  * /todo subsystems.
6
6
  */
7
+ import { modelSettingsResultMessage } from '../agent/model-profile-settings.js';
7
8
  import { existsSync, statSync } from 'node:fs';
8
9
  import { isAbsolute, resolve } from 'node:path';
9
10
  import { resolveOutputTokens } from '@aragon-agent/core';
10
11
  import { clampThinkingLevel, clampTheme, isAdapterProvider, isThemeName, parseMaxTokensInput, ADAPTER_PROVIDERS, DEFAULT_MAX_TOKENS, HARD_MAX_RETRIES, LEGACY_DARK_THEME, THEME_NAMES, THINKING_LEVELS, } from '../config/schema.js';
11
12
  import { computeCost, formatCost, formatDuration, formatTokens } from '../agent/usage.js';
13
+ import { mergePendingEntries } from '../agent/queued-messages.js';
12
14
  import { TODO_FOLLOW_LIMITS } from '../todo/limits.js';
13
15
  import { buildContinuationMessage } from '../todo/follow-through.js';
14
16
  import { MODE_LABEL, nextMode } from '../agent/agent-mode.js';
15
- import { loadConfig } from '../config/load.js';
16
17
  import { loadSession, resolveSessionPath, saveSession } from '../session/persist.js';
17
18
  import { getLogger } from '../logging/logger.js';
18
- import { copyText } from '../ui/clipboard.js';
19
+ import { registerQueueCommand } from './queue-command.js';
19
20
  import { perfCommand } from './perf.js';
20
21
  import { runCompactCommand } from '../compaction/command.js';
21
22
  import { runContextCommand } from '../compaction/context-command.js';
@@ -370,8 +371,16 @@ const COMMANDS = [
370
371
  return;
371
372
  }
372
373
  const persist = (patch) => {
373
- controller.setFastConfig(patch);
374
- ctx.persistConfig({ fast: patch });
374
+ const connection = ['provider', 'model', 'baseUrl'].some((key) => key in patch);
375
+ if (!connection) {
376
+ controller.setFastConfig(patch);
377
+ ctx.persistConfig({ fast: patch });
378
+ return true;
379
+ }
380
+ const result = controller.saveModelSettingsPatch({ fast: patch });
381
+ if (!result.ok)
382
+ ctx.notify('warn', modelSettingsResultMessage(result));
383
+ return result.ok;
375
384
  };
376
385
  if (!verb || verb === 'status') {
377
386
  ctx.notify('info', formatFastStatus(controller));
@@ -379,7 +388,8 @@ const COMMANDS = [
379
388
  }
380
389
  if (verb === 'on' || verb === 'off') {
381
390
  const enabled = verb === 'on';
382
- persist({ enabled });
391
+ if (!persist({ enabled }))
392
+ return;
383
393
  if (!controller.isFastRegistered()) {
384
394
  // The honest branch, and the whole point of splitting by what was
385
395
  // decided at construction (§3.3). It is one sentence, and it keeps
@@ -413,11 +423,13 @@ const COMMANDS = [
413
423
  ctx.notify('warn', `Unknown provider "${provider}". Choose: ${ADAPTER_PROVIDERS.join(', ')}.`);
414
424
  return;
415
425
  }
416
- persist({ provider, model });
426
+ if (!persist({ provider, model }))
427
+ return;
417
428
  ctx.toast('success', `Fast model set to ${provider}:${model}.`);
418
429
  return;
419
430
  }
420
- persist({ model: raw });
431
+ if (!persist({ model: raw }))
432
+ return;
421
433
  ctx.toast('success', `Fast model set to ${raw}.`);
422
434
  return;
423
435
  }
@@ -431,7 +443,8 @@ const COMMANDS = [
431
443
  ctx.notify('warn', `Unknown provider "${raw}". Choose: ${ADAPTER_PROVIDERS.join(', ')}.`);
432
444
  return;
433
445
  }
434
- persist({ provider: raw });
446
+ if (!persist({ provider: raw }))
447
+ return;
435
448
  ctx.toast('success', `Fast provider set to ${raw}.`);
436
449
  return;
437
450
  }
@@ -440,18 +453,20 @@ const COMMANDS = [
440
453
  // (R-13): the user is allowed to run both tiers on one model, and the
441
454
  // review still costs what it costs.
442
455
  const cfg = controller.getConfig();
443
- persist({
456
+ if (!persist({
444
457
  provider: cfg.provider,
445
458
  model: cfg.model,
446
459
  baseUrl: cfg.baseUrl ?? '',
447
- });
460
+ }))
461
+ return;
448
462
  ctx.toast('success', `Fast tier set to ${cfg.provider}:${cfg.model} (same as main).`);
449
463
  return;
450
464
  }
451
465
  if (verb === 'review') {
452
466
  const raw = (value ?? '').trim().toLowerCase();
453
467
  if (raw === 'off') {
454
- persist({ review: false });
468
+ if (!persist({ review: false }))
469
+ return;
455
470
  ctx.toast('success', 'Fast review off.');
456
471
  return;
457
472
  }
@@ -507,7 +522,8 @@ const COMMANDS = [
507
522
  ctx.notify('warn', 'Usage: /fast delegate <on|off>');
508
523
  return;
509
524
  }
510
- persist({ delegate: raw === 'on' });
525
+ if (!persist({ delegate: raw === 'on' }))
526
+ return;
511
527
  ctx.toast('success', `Fast delegation ${raw}.`);
512
528
  return;
513
529
  }
@@ -882,10 +898,16 @@ const COMMANDS = [
882
898
  name: 'reset',
883
899
  description: 'Start a new conversation',
884
900
  run: (ctx) => {
901
+ if (ctx.controller.isRunning()) {
902
+ ctx.notify('warn', 'Cannot reset while the agent is running. Wait for it to stop.');
903
+ return;
904
+ }
885
905
  ctx.controller.clearMessages();
886
906
  ctx.controller.clearAllQueues();
887
907
  ctx.dispatch({ type: 'resetConversation' });
888
908
  ctx.toast('info', 'Started a new conversation.');
909
+ if (ctx.state.pendingSteering.length)
910
+ ctx.notify('warn', `\u5df2\u53d6\u6d88 ${ctx.state.pendingSteering.length} \u6761\u672a\u63a5\u6536\u6d88\u606f`);
889
911
  },
890
912
  },
891
913
  {
@@ -920,7 +942,7 @@ const COMMANDS = [
920
942
  ...(cfg.baseUrl ? { baseUrl: cfg.baseUrl } : {}),
921
943
  },
922
944
  messages: ctx.controller.getMessages(),
923
- entries: ctx.state.entries,
945
+ entries: mergePendingEntries(ctx.state.entries, ctx.state.pendingSteering),
924
946
  // `/resume` restores `messages`, so the belief that justifies the
925
947
  // panel comes back — and the panel has to come back with it (§3.13).
926
948
  todos: ctx.controller.getTodoSnapshot()?.items ?? [],
@@ -936,22 +958,30 @@ const COMMANDS = [
936
958
  name: 'resume',
937
959
  description: 'Load a saved session',
938
960
  run: (ctx) => {
961
+ if (ctx.controller.isRunning() || ctx.controller.isModelSettingsBusy?.()) {
962
+ ctx.notify('warn', 'Cannot resume while running work, team, review, or compaction is busy.');
963
+ return;
964
+ }
939
965
  try {
940
966
  const path = resolveSessionPath(ctx.args, ctx.controller.getCwd());
941
967
  const session = loadSession(path);
968
+ if (ctx.controller.isRunning() || ctx.controller.isModelSettingsBusy?.()) {
969
+ ctx.notify('warn', 'Cannot resume while running work, team, review, or compaction is busy.');
970
+ return;
971
+ }
972
+ // Loading has validated and normalized every external value. Commit
973
+ // synchronously so no new run can start between clearing and restoring.
974
+ ctx.controller.clearAllQueues();
942
975
  ctx.controller.replaceMessages(session.messages);
943
- // IN THE SAME `try`, IMMEDIATELY AFTER `replaceMessages`, so a malformed
944
- // file fails the whole resume rather than half of it — and AN ABSENT
945
- // `todos` CLEARS RATHER THAN SKIPPING (P0-2 / D-22). `replaceMessages`
946
- // has just discarded the conversation the current list belonged to, so
947
- // leaving the rail up produces a panel that disagrees with the model,
948
- // which §1.2 ranks below having no panel at all.
976
+ // Absence clears the previous conversation's list, including old files.
949
977
  ctx.controller.restoreTodos(session.todos ?? []);
950
978
  ctx.dispatch({ type: 'restoreEntries', entries: session.entries });
951
979
  if (session.model?.providerId && session.model?.modelId) {
952
980
  ctx.controller.setModel(session.model.providerId, session.model.modelId, session.model.baseUrl);
953
981
  }
954
982
  ctx.notify('info', `Resumed session from ${path}`);
983
+ if (ctx.state.pendingSteering.length)
984
+ ctx.notify('warn', `\u5df2\u53d6\u6d88 ${ctx.state.pendingSteering.length} \u6761\u672a\u63a5\u6536\u6d88\u606f`);
955
985
  }
956
986
  catch (err) {
957
987
  ctx.notify('error', `Resume failed: ${err instanceof Error ? err.message : String(err)}`);
@@ -961,7 +991,7 @@ const COMMANDS = [
961
991
  {
962
992
  name: 'copy',
963
993
  description: 'Copy the last answer to the clipboard',
964
- run: (ctx) => {
994
+ run: async (ctx) => {
965
995
  const last = [...ctx.state.entries].reverse().find((e) => e.kind === 'assistant');
966
996
  if (!last || last.kind !== 'assistant' || last.text.trim().length === 0) {
967
997
  ctx.notify('warn', 'No assistant message to copy.');
@@ -973,15 +1003,11 @@ const COMMANDS = [
973
1003
  // which the local `pbcopy` never could.
974
1004
  //
975
1005
  // `ctx.writeForeign` and not `stdout.write` — see the field's comment.
976
- const via = copyText(last.text, ctx.writeForeign);
977
- if (via === 'none') {
978
- ctx.toast('warn', 'Clipboard not available.');
1006
+ if (!ctx.requestCopy) {
1007
+ ctx.notify('warn', 'Clipboard coordinator unavailable.');
979
1008
  return;
980
1009
  }
981
- // NAMES THE MECHANISM RATHER THAN CLAIMING SUCCESS: neither path is
982
- // detectable, and tmux without `set -g set-clipboard on` swallows OSC 52
983
- // silently (R-7).
984
- ctx.toast('success', via === 'osc52' ? 'Sent to the terminal clipboard.' : 'Sent to the system clipboard.');
1010
+ await ctx.requestCopy({ text: last.text, lines: last.text.split('\n').length });
985
1011
  },
986
1012
  },
987
1013
  {
@@ -1042,17 +1068,24 @@ const COMMANDS = [
1042
1068
  const lines = [
1043
1069
  'Shift+Enter newline - terminal setup',
1044
1070
  '',
1045
- 'Ctrl+J and Alt+Enter insert a newline in every terminal, with no',
1046
- 'setup. To get a dedicated Shift+Enter key, make the terminal send',
1071
+ 'Ctrl+J inserts a newline when delivered; Alt+Enter may be intercepted.',
1072
+ 'To get a dedicated Shift+Enter key, make the terminal send',
1047
1073
  'the CSI-u sequence \\u001b[13;2u for it:',
1048
1074
  '',
1049
- ' Windows Terminal settings.json, actions array:',
1050
- ' { "keys": "shift+enter", "command": { "action": "sendInput",',
1051
- ' "input": "\\u001b[13;2u" } }',
1075
+ ' Windows Terminal settings.json, merge root-level arrays:',
1076
+ ' "copyOnSelect": false,',
1077
+ ' "actions": [{ "id": "User.AragonNewline", "command":',
1078
+ ' { "action": "sendInput", "input": "\\u001b[13;2u" } }],',
1079
+ ' "keybindings": [{ "keys": "shift+enter", "id": "User.AragonNewline" }]',
1080
+ ' PowerShell runs inside the terminal; configure this binding in',
1081
+ ' Windows Terminal, not in the PowerShell profile. If both keys',
1082
+ ' send CR, the application cannot distinguish Shift+Enter from Enter.',
1052
1083
  ' VS Code terminal keybindings.json:',
1053
1084
  ' { "key": "shift+enter",',
1054
1085
  ' "command": "workbench.action.terminal.sendSequence",',
1086
+ ' "when": "terminalFocus",',
1055
1087
  ' "args": { "text": "\\u001b[13;2u" } }',
1088
+ ' User settings: "terminal.integrated.copyOnSelection": false',
1056
1089
  ' iTerm2 Settings > Profiles > Keys > Key Mappings:',
1057
1090
  ' Shift+Enter -> Send Escape Sequence: [13;2u',
1058
1091
  ' kitty/WezTerm/foot native once keyboard enhancement is on',
@@ -1063,6 +1096,13 @@ const COMMANDS = [
1063
1096
  ' or Alt+Enter',
1064
1097
  '',
1065
1098
  'This command only prints advice; it changes no terminal modes.',
1099
+ 'Host selections (Shift+drag or /mouse off) belong to the terminal.',
1100
+ 'App selections wait for Ctrl+C; only a native tool exit confirms copying.',
1101
+ 'SSH uses OSC 52: the terminal may deny it; sent does not mean confirmed.',
1102
+ 'Without the stdin filter, Delete falls back to Backspace.',
1103
+ 'Without paste support, multiline paste cannot be guaranteed safe.',
1104
+ 'IME confirmation has no composition signal in PTY bytes. If it emits CR,',
1105
+ 'edit externally and use bracketed paste; this host combination needs validation.',
1066
1106
  ];
1067
1107
  ctx.notify('info', lines.join('\n'));
1068
1108
  },
@@ -1090,12 +1130,12 @@ const COMMANDS = [
1090
1130
  ctx.notify('warn', 'Cannot reload the config while a run is in progress.');
1091
1131
  return;
1092
1132
  }
1093
- const config = loadConfig({ cwd: ctx.controller.getCwd() });
1094
- ctx.controller.setModel(config.provider, config.model, config.baseUrl);
1095
- ctx.controller.setThinkingLevel(config.thinkingLevel);
1096
- ctx.controller.setMaxTokens(config.maxTokens);
1097
- ctx.controller.setTheme(config.theme);
1098
- getLogger().reconfigure(config.log);
1133
+ const result = ctx.controller.reloadModelSettings();
1134
+ if (!result.ok) {
1135
+ ctx.notify('warn', modelSettingsResultMessage(result));
1136
+ return;
1137
+ }
1138
+ const config = ctx.controller.getConfig();
1099
1139
  getLogger().info('config', 'reload', { provider: config.provider, model: config.model });
1100
1140
  ctx.toast('success', `Reloaded config: ${config.provider}:${config.model}.`);
1101
1141
  },
@@ -1205,6 +1245,7 @@ const DEFAULT_BG_LOG_ROWS = 40;
1205
1245
  export function registerBuiltinCommands(registry) {
1206
1246
  for (const command of COMMANDS)
1207
1247
  registry.register(command);
1248
+ registerQueueCommand(registry);
1208
1249
  }
1209
1250
  /**
1210
1251
  * Every built-in command name INCLUDING aliases (`quit`), for documentation and
@@ -1214,10 +1255,10 @@ export function registerBuiltinCommands(registry) {
1214
1255
  * `registry.get(name)` instead, because that is the only check that stays
1215
1256
  * correct when commands are registered dynamically (§7.1 / P1-2).
1216
1257
  */
1217
- export const BUILTIN_COMMAND_NAMES = COMMANDS.flatMap((c) => [
1218
- c.name,
1219
- ...(c.aliases ?? []),
1220
- ]).sort();
1258
+ export const BUILTIN_COMMAND_NAMES = ['queue', ...COMMANDS.flatMap((c) => [
1259
+ c.name,
1260
+ ...(c.aliases ?? []),
1261
+ ])].sort();
1221
1262
  // The `copyToClipboard` helper that used to live here moved into
1222
1263
  // `ui/clipboard.ts` (tui-selection-and-scroll-follow §4.4.5), unchanged, so that
1223
1264
  // `/copy` and the drag-select release share ONE clipboard path instead of two
@@ -0,0 +1,3 @@
1
+ import type { CommandRegistry } from './registry.js';
2
+ /** Read-only access to the live receipt-backed queue through the standard overlay port. */
3
+ export declare function registerQueueCommand(registry: CommandRegistry): void;
@@ -0,0 +1,5 @@
1
+ /** Read-only access to the live receipt-backed queue through the standard overlay port. */
2
+ export function registerQueueCommand(registry) {
3
+ registry.register({ name: 'queue', description: '\u67e5\u770b\u5f85\u5904\u7406\u6d88\u606f\u5168\u6587',
4
+ run: (ctx) => { ctx.setOverlay('queue'); } });
5
+ }
@@ -3,6 +3,7 @@
3
3
  * input is routed here instead of to the agent; `//` is the literal-`/` escape.
4
4
  */
5
5
  import type { SubmitMessageOptions } from '../agent/prompt-options.js';
6
+ import type { CopyRequest, CopyRequestResult } from '../ui/clipboard-task.js';
6
7
  import type { AgentController } from '../agent/controller.js';
7
8
  import type { AgentMode } from '../agent/agent-mode.js';
8
9
  import type { NoticeLevel, Overlay, ToastLevel, ViewAction, ViewState } from '../agent/reducer.js';
@@ -10,6 +11,8 @@ import type { PersistedConfig } from '../config/schema.js';
10
11
  import type { FollowThroughBudget } from '../todo/follow-through.js';
11
12
  import type { UpdateCommandPort } from '../update/types.js';
12
13
  export interface CommandContext {
14
+ /** App-owned shared lock and single feedback path for command and selection copies. */
15
+ requestCopy?: (request: CopyRequest) => Promise<CopyRequestResult>;
13
16
  /** Raw argument string after the command word (may be empty). */
14
17
  args: string;
15
18
  controller: AgentController;
@@ -28,13 +28,14 @@
28
28
  * lead's own history had been compacted. The team panel row and the dispatch
29
29
  * report are where a child's internals belong.
30
30
  */
31
+ import type { ModelRole } from '../config/model-profiles.js';
31
32
  import type { AssistantMessage, ContextManager, LLMRequest, Message, ModelInfo, ModelRef, TokenUsage } from '@aragon-agent/core';
32
33
  import type { CliConfig } from '../config/schema.js';
33
34
  export interface ChildCompactionDeps {
34
35
  /** Read LIVE on every call - see `childConfig` below. */
35
36
  getConfig: () => CliConfig;
36
- hasKey: (providerId: string) => boolean;
37
- getApiKey: (providerId: string) => string | undefined;
37
+ hasKey: (providerId: string, role?: ModelRole) => boolean;
38
+ getApiKey: (providerId: string, role?: ModelRole) => string | undefined;
38
39
  getModelInfoFor: (ref: Pick<ModelRef, 'providerId' | 'modelId'>) => ModelInfo;
39
40
  isPricedModel: (ref: Pick<ModelRef, 'providerId' | 'modelId'>) => boolean;
40
41
  /** The lead wiring's own transport, reused (D-10 stays true). */
@@ -50,6 +51,7 @@ export interface ChildCompactionDeps {
50
51
  * exactly on the cap.
51
52
  */
52
53
  export interface ChildContextManagerRequest {
54
+ role?: ModelRole;
53
55
  label: string;
54
56
  /**
55
57
  * The model the child actually runs on.
@@ -28,6 +28,7 @@
28
28
  * lead's own history had been compacted. The team panel row and the dispatch
29
29
  * report are where a child's internals belong.
30
30
  */
31
+ import { resolveFastTier } from '../fast/resolve.js';
31
32
  import { getLogger } from '../logging/logger.js';
32
33
  import { Compactor } from './compactor.js';
33
34
  import { COMPACTION_LIMITS } from './limits.js';
@@ -56,7 +57,7 @@ export function createChildContextManager(req, deps) {
56
57
  ...base,
57
58
  provider: req.model.providerId,
58
59
  model: req.model.modelId,
59
- ...(req.model.baseUrl !== undefined ? { baseUrl: req.model.baseUrl } : {}),
60
+ baseUrl: req.model.baseUrl,
60
61
  compaction: {
61
62
  ...base.compaction,
62
63
  keepRecentTurns: COMPACTION_LIMITS.childKeepRecentTurns,
@@ -71,6 +72,8 @@ export function createChildContextManager(req, deps) {
71
72
  };
72
73
  const compactor = new Compactor({
73
74
  getConfig: childConfig,
75
+ mainRole: req.role ?? 'main',
76
+ resolveFastCandidate: () => resolveFastTier(deps.getConfig(), deps.hasKey),
74
77
  hasKey: deps.hasKey,
75
78
  getApiKey: deps.getApiKey,
76
79
  getModelInfoFor: deps.getModelInfoFor,
@@ -9,16 +9,20 @@
9
9
  * keep, which model summarizes, what the prompt asks for, what happens when the
10
10
  * call fails, and when to stop spending.
11
11
  */
12
+ import type { ModelRole } from '../config/model-profiles.js';
12
13
  import { type Message, type ModelInfo, type ModelRef, type TokenUsage, type AssistantMessage, type CompactionContext, type CompactionOutcome, type CompactionProbe, type ContextManager, type LLMRequest } from '@aragon-agent/core';
13
14
  import type { CliConfig } from '../config/schema.js';
14
15
  import type { NoticeLevel } from '../agent/reducer.js';
16
+ import { resolveFastTier } from '../fast/resolve.js';
15
17
  import { ContextMeter } from './meter.js';
16
18
  import type { CompactionEvent, Pressure } from './types.js';
17
19
  export interface CompactorDeps {
20
+ mainRole?: ModelRole;
21
+ resolveFastCandidate?: () => ReturnType<typeof resolveFastTier>;
18
22
  /** Read LIVE on every call — the controller replaces the object on mutation. */
19
23
  getConfig: () => CliConfig;
20
- hasKey: (providerId: string) => boolean;
21
- getApiKey: (providerId: string) => string | undefined;
24
+ hasKey: (providerId: string, role?: ModelRole) => boolean;
25
+ getApiKey: (providerId: string, role?: ModelRole) => string | undefined;
22
26
  /**
23
27
  * The engine's live history and system prompt.
24
28
  *
@@ -87,6 +91,7 @@ export interface CompactorOptions {
87
91
  }
88
92
  /** The summarizer the next compaction would use. */
89
93
  export interface SummarizerChoice {
94
+ role: ModelRole;
90
95
  ref: ModelRef;
91
96
  /** Whether the fast tier supplied it, for `/compact status` and the card. */
92
97
  fromFastTier: boolean;
@@ -486,20 +486,22 @@ export class Compactor {
486
486
  const { summarizer } = args;
487
487
  if (!summarizer)
488
488
  return { kind: 'failed', reason: 'no_summarizer_model' };
489
- const first = await this.callOnce(summarizer.ref, args);
489
+ const first = await this.callOnce(summarizer, args);
490
490
  if (first.kind === 'ok' || first.kind === 'aborted')
491
491
  return first;
492
492
  // Rung 2 — retry, on the MAIN model when rung 1 was the fast tier.
493
- const mainRef = this.mainRef();
494
- const retryRef = summarizer.fromFastTier && mainRef.modelId !== summarizer.ref.modelId ? mainRef : summarizer.ref;
495
- this.log.warn('summarize_retry', { reason: first.reason, model: retryRef.modelId });
496
- const second = await this.callOnce(retryRef, args);
493
+ const retryChoice = summarizer.fromFastTier
494
+ ? { ref: this.mainRef(), role: this.deps.mainRole ?? 'main', fromFastTier: false }
495
+ : summarizer;
496
+ this.log.warn('summarize_retry', { reason: first.reason, model: retryChoice.ref.modelId });
497
+ const second = await this.callOnce(retryChoice, args);
497
498
  if (second.kind === 'ok' || second.kind === 'aborted')
498
499
  return second;
499
500
  return { kind: 'failed', reason: second.reason };
500
501
  }
501
- async callOnce(ref, args) {
502
- const apiKey = this.deps.getApiKey(ref.providerId);
502
+ async callOnce(choice, args) {
503
+ const { ref, role } = choice;
504
+ const apiKey = this.deps.getApiKey(ref.providerId, role);
503
505
  if (!apiKey)
504
506
  return { kind: 'failed', reason: 'no_api_key' };
505
507
  if (args.ctx.signal.aborted)
@@ -764,14 +766,14 @@ export class Compactor {
764
766
  resolveSummarizer() {
765
767
  const config = this.deps.getConfig();
766
768
  if (config.compaction.useFastTier) {
767
- const tier = resolveFastTier(config, this.deps.hasKey);
769
+ const tier = this.deps.resolveFastCandidate?.() ?? resolveFastTier(config, this.deps.hasKey);
768
770
  if (tier.ok)
769
- return { ref: tier.ref, fromFastTier: true };
771
+ return { ref: tier.ref, role: 'fast', fromFastTier: true };
770
772
  }
771
773
  const ref = this.mainRef();
772
- if (!this.deps.hasKey(ref.providerId))
774
+ if (!this.deps.hasKey(ref.providerId, this.deps.mainRole ?? 'main'))
773
775
  return null;
774
- return { ref, fromFastTier: false };
776
+ return { ref, role: this.deps.mainRole ?? 'main', fromFastTier: false };
775
777
  }
776
778
  /**
777
779
  * The meter this compactor reads, injected or private (R-1).
@@ -45,6 +45,11 @@ export function formatContextReport(ctx) {
45
45
  // one line and answers the same question.
46
46
  lines.push(' includes subagent, fast-tier and compaction spend,');
47
47
  lines.push(' not just this conversation');
48
+ lines.push(' Scope lead occupancy is used / limit in tokens; session input includes caches.');
49
+ lines.push(' Estimate USD session cost, including restored usage; missing prices count as 0.');
50
+ lines.push(' Zero estimated cost does not guarantee free usage or match the bill.');
51
+ lines.push(' Run average booked output-token delta / elapsed time, not instantaneous generation.');
52
+ lines.push(' May include team, fast-tier and compaction output booked during this run.');
48
53
  return lines.join('\n');
49
54
  }
50
55
  /**
@@ -35,6 +35,7 @@
35
35
  * guard is now unreachable for declines and stays for the abort race it was
36
36
  * written for.
37
37
  */
38
+ import type { ModelRole } from '../config/model-profiles.js';
38
39
  import { type AgentEvent, type ContextManager, type Message, type ModelInfo, type ModelRef, type ProviderRegistry, type ProviderRegistryOptions, type TokenUsage } from '@aragon-agent/core';
39
40
  import type { CliConfig } from '../config/schema.js';
40
41
  import type { NoticeLevel } from '../agent/reducer.js';
@@ -44,8 +45,8 @@ import type { CompactionEventListener, CompactionSnapshot } from './types.js';
44
45
  export interface CompactionWiringDeps {
45
46
  /** Read LIVE on every call - the controller replaces the object on mutation. */
46
47
  getConfig: () => CliConfig;
47
- hasKey: (providerId: string) => boolean;
48
- getApiKey: (providerId: string) => string | undefined;
48
+ hasKey: (providerId: string, role?: ModelRole) => boolean;
49
+ getApiKey: (providerId: string, role?: ModelRole) => string | undefined;
49
50
  getModelInfoFor: (ref: Pick<ModelRef, 'providerId' | 'modelId'>) => ModelInfo;
50
51
  isPricedModel: (ref: Pick<ModelRef, 'providerId' | 'modelId'>) => boolean;
51
52
  /**
@@ -158,7 +159,7 @@ export declare class CompactionWiring {
158
159
  isEnabled(): boolean;
159
160
  setEnabled(enabled: boolean): void;
160
161
  /** Re-emit the snapshot after any config mutation, so the chip and the gauge move. */
161
- onConfigChanged(): void;
162
+ onConfigChanged(enabled?: boolean): void;
162
163
  abort(): void;
163
164
  /**
164
165
  * The host replaced or cleared the history (hardening §3.2.3, sites 3 and 4).
@@ -209,7 +209,12 @@ export class CompactionWiring {
209
209
  this.emit({ type: 'snapshot', snapshot: this.snapshot() });
210
210
  }
211
211
  /** Re-emit the snapshot after any config mutation, so the chip and the gauge move. */
212
- onConfigChanged() {
212
+ onConfigChanged(enabled) {
213
+ if (enabled !== undefined && enabled !== this.enabled) {
214
+ this.enabled = enabled;
215
+ if (enabled)
216
+ this.compactor.clearSelfDisable();
217
+ }
213
218
  this.emit({ type: 'snapshot', snapshot: this.snapshot() });
214
219
  }
215
220
  abort() {
@@ -168,6 +168,8 @@ export declare function applyCompactionConfigSet(key: string, value: string): Pa
168
168
  * later pastes a log into an issue.
169
169
  */
170
170
  export declare function warnIfRedactionDisabled(key: string, value: string): void;
171
+ /** Every secret is masked wherever it is printed, `--json` included (R-12). */
172
+ export declare function maskConfig(config: PersistedConfig): Record<string, unknown>;
171
173
  export declare function runConfigGet(key: string): void;
172
174
  export declare function runConfigList(opts?: {
173
175
  json?: boolean;
@@ -13,6 +13,7 @@ import process from 'node:process';
13
13
  import { getConfigBackupPath, getConfigPath, getHomeRoot, getLogsDir, getPromptHistoryPath, getUiStatePath, isHomeOverridden, } from './app-paths.js';
14
14
  import { getHomeMigrationBreadcrumbPath } from './migrate-home.js';
15
15
  import { loadPersistedConfig, updatePersistedConfig } from './store.js';
16
+ import { normalizeModelProfiles, validateModelProfiles } from './model-profiles.js';
16
17
  import { clampFastConfig, clampRetryConfig, clampCompactionConfig, parseThresholdInput, clampUpdateConfig, coercePositiveInt, maskSecret, DEFAULT_FAST_CONFIG, DEFAULT_RETRY_CONFIG, DEFAULT_TEAM_CONFIG, DEFAULT_COMPACTION_CONFIG, DEFAULT_UPDATE_CONFIG, } from './schema.js';
17
18
  import { clampLogLevel } from '../logging/levels.js';
18
19
  /** Config keys under `log.` that `aragon config set` accepts. */
@@ -386,12 +387,23 @@ export function warnIfRedactionDisabled(key, value) {
386
387
  // get / list
387
388
  // ---------------------------------------------------------------------------
388
389
  /** Every secret is masked wherever it is printed, `--json` included (R-12). */
389
- function maskConfig(config) {
390
+ export function maskConfig(config) {
390
391
  const apiKeys = {};
391
392
  for (const [provider, key] of Object.entries(config.apiKeys)) {
392
393
  apiKeys[provider] = maskSecret(key);
393
394
  }
394
- return { ...config, apiKeys };
395
+ const masked = { ...config, apiKeys };
396
+ if (config.modelProfiles !== undefined) {
397
+ if (validateModelProfiles(config.modelProfiles).length)
398
+ masked.modelProfiles = { invalid: true };
399
+ else {
400
+ const profiles = normalizeModelProfiles(config.modelProfiles);
401
+ masked.modelProfiles = { ...profiles, entries: profiles.entries.map((entry) => ({
402
+ ...entry, apiKey: entry.apiKey ? 'Configured' : 'Missing',
403
+ })) };
404
+ }
405
+ }
406
+ return masked;
395
407
  }
396
408
  /**
397
409
  * The human-readable label for a value whose raw form would mislead, or
@@ -503,8 +515,8 @@ export async function runConfigEdit() {
503
515
  JSON.parse(readFileSync(file, 'utf-8'));
504
516
  process.stdout.write(`${file} saved.\n`);
505
517
  }
506
- catch (err) {
507
- process.stderr.write(`${file} is not valid JSON (${err instanceof Error ? err.message : String(err)}).\n` +
518
+ catch {
519
+ process.stderr.write(`${file} is not valid JSON.\n` +
508
520
  `The previous version is still at ${backup}.\n`);
509
521
  process.exitCode = 1;
510
522
  }
@@ -6,6 +6,7 @@
6
6
  * legitimately long single tool run can never be watchdog-killed.
7
7
  */
8
8
  import { type CliConfig } from './schema.js';
9
+ import type { ModelRole } from './model-profiles.js';
9
10
  export interface CliFlags {
10
11
  provider?: string;
11
12
  model?: string;
@@ -245,4 +246,4 @@ export declare function loadConfig(flags?: CliFlags): CliConfig;
245
246
  * Build the `getApiKey(providerId)` resolver the core `Agent` consumes.
246
247
  * The one-shot `--api-key` override wins, but only for the active provider.
247
248
  */
248
- export declare function makeGetApiKey(config: CliConfig): (providerId: string) => string | undefined;
249
+ export declare function makeGetApiKey(config: CliConfig, role?: ModelRole): (providerId: string) => string | undefined;
@@ -9,6 +9,7 @@ import process from 'node:process';
9
9
  import { DEFAULT_CONFIG, IDLE_TIMEOUT_MARGIN_MS, clampAskRounds, clampDensity, clampHumanTimeout, clampCompactionConfig, clampLogConfig, clampFastConfig, clampRetryConfig, clampSkillsConfig, clampTeamConfig, clampTodoConfig, clampBashConfig, clampUpdateConfig, clampTheme, clampThinkingLevel, clampContextWindow, clampMaxTokens, clampMaxRenderInterval, clampScrollResumeMs, clampTranscriptRetain, clampTranscriptWindow, coercePositiveInt, isAutoToken, isSkillsToolPolicyMode, parseThresholdInput, } from './schema.js';
10
10
  import { loadDotenv, readEnvConfig, readEnvDisabledSkills } from './env.js';
11
11
  import { readConfigFile } from './store.js';
12
+ import { projectModelProfiles, resolveModelProfileState, resolveModelRoleKey } from './model-profile-resolution.js';
12
13
  import { getConfigPath } from './app-paths.js';
13
14
  import { setHistoryEnabled } from './prompt-history.js';
14
15
  import { setEntryRetain } from '../agent/entry-limits.js';
@@ -440,11 +441,17 @@ export function loadConfig(flags = {}) {
440
441
  // `.env` must be loaded before reading env overrides.
441
442
  loadDotenv(cwd);
442
443
  const read = readConfigFile();
443
- const file = read.config ?? {};
444
+ const projection = projectModelProfiles(read.config ?? {});
445
+ const file = projection.file;
444
446
  const env = readEnvConfig();
445
447
  if (read.parseError) {
446
448
  getLogger().error('config', 'config_parse_failed', { error: read.parseError });
447
449
  }
450
+ if (projection.issues.length) {
451
+ getLogger().warn('config', 'model_profiles_invalid', {
452
+ error: 'Invalid model profiles; using custom connections. Repair with aragon config edit.',
453
+ });
454
+ }
448
455
  const provider = pick(flags.provider, env.partial.provider, file.provider) ?? DEFAULT_CONFIG.provider;
449
456
  const model = pick(flags.model, env.partial.model, file.model) ?? DEFAULT_CONFIG.model;
450
457
  const baseUrl = pick(flags.baseUrl, env.partial.baseUrl ?? undefined, file.baseUrl ?? undefined);
@@ -543,6 +550,7 @@ export function loadConfig(flags = {}) {
543
550
  // it through the same module-singleton channel `setHistoryEnabled` uses.
544
551
  setEntryRetain(transcriptRetain);
545
552
  const config = {
553
+ modelProfiles: projection.issues.length ? undefined : file.modelProfiles,
546
554
  provider,
547
555
  model,
548
556
  baseUrl,
@@ -605,6 +613,10 @@ export function loadConfig(flags = {}) {
605
613
  unicode: caps.unicode,
606
614
  apiKeyOverride: flags.apiKey && flags.apiKey.trim().length > 0 ? flags.apiKey.trim() : undefined,
607
615
  };
616
+ config.modelProfileState = resolveModelProfileState(config, config.modelProfiles, projection.issues.length > 0);
617
+ if (config.apiKeyOverride && (config.modelProfiles?.mainId || config.modelProfiles?.fastId)) {
618
+ config.apiKeyOverrideTarget = { provider, baseUrl: baseUrl?.trim() || null };
619
+ }
608
620
  recordResolvedConfig(config);
609
621
  return config;
610
622
  }
@@ -646,11 +658,6 @@ function recordResolvedConfig(config) {
646
658
  * Build the `getApiKey(providerId)` resolver the core `Agent` consumes.
647
659
  * The one-shot `--api-key` override wins, but only for the active provider.
648
660
  */
649
- export function makeGetApiKey(config) {
650
- return (providerId) => {
651
- if (config.apiKeyOverride && providerId === config.provider) {
652
- return config.apiKeyOverride;
653
- }
654
- return config.apiKeys[providerId];
655
- };
661
+ export function makeGetApiKey(config, role = 'main') {
662
+ return (providerId) => resolveModelRoleKey({ config, role, providerId });
656
663
  }