@aragon-agent/cli 0.6.2 → 0.6.3

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 (118) hide show
  1. package/CHANGELOG.md +216 -0
  2. package/README.md +202 -0
  3. package/dist/agent/controller.d.ts +74 -1
  4. package/dist/agent/controller.js +200 -1
  5. package/dist/agent/headless.d.ts +8 -0
  6. package/dist/agent/headless.js +47 -0
  7. package/dist/agent/reducer.d.ts +120 -0
  8. package/dist/agent/reducer.js +201 -2
  9. package/dist/cli.js +224 -5
  10. package/dist/commands/builtins.js +73 -23
  11. package/dist/commands/registry.d.ts +27 -0
  12. package/dist/compaction/archive.d.ts +175 -0
  13. package/dist/compaction/archive.js +267 -0
  14. package/dist/compaction/child.d.ts +97 -0
  15. package/dist/compaction/child.js +113 -0
  16. package/dist/compaction/command.d.ts +41 -0
  17. package/dist/compaction/command.js +342 -0
  18. package/dist/compaction/compactor.d.ts +268 -0
  19. package/dist/compaction/compactor.js +689 -0
  20. package/dist/compaction/digest.d.ts +62 -0
  21. package/dist/compaction/digest.js +164 -0
  22. package/dist/compaction/limits.d.ts +165 -0
  23. package/dist/compaction/limits.js +172 -0
  24. package/dist/compaction/pressure.d.ts +135 -0
  25. package/dist/compaction/pressure.js +167 -0
  26. package/dist/compaction/summarize-call.d.ts +70 -0
  27. package/dist/compaction/summarize-call.js +86 -0
  28. package/dist/compaction/summary-prompt.d.ts +85 -0
  29. package/dist/compaction/summary-prompt.js +197 -0
  30. package/dist/compaction/tail-budget.d.ts +52 -0
  31. package/dist/compaction/tail-budget.js +67 -0
  32. package/dist/compaction/types.d.ts +179 -0
  33. package/dist/compaction/types.js +22 -0
  34. package/dist/compaction/wiring.d.ts +255 -0
  35. package/dist/compaction/wiring.js +556 -0
  36. package/dist/config/app-paths.d.ts +14 -0
  37. package/dist/config/app-paths.js +16 -0
  38. package/dist/config/cli-commands.d.ts +41 -0
  39. package/dist/config/cli-commands.js +77 -1
  40. package/dist/config/env.d.ts +4 -1
  41. package/dist/config/env.js +92 -2
  42. package/dist/config/load.d.ts +31 -0
  43. package/dist/config/load.js +63 -1
  44. package/dist/config/schema.d.ts +159 -0
  45. package/dist/config/schema.js +137 -0
  46. package/dist/config/store.js +17 -1
  47. package/dist/config/ui-state.d.ts +28 -1
  48. package/dist/config/ui-state.js +9 -0
  49. package/dist/exec/events.d.ts +50 -1
  50. package/dist/exec/runner.js +42 -0
  51. package/dist/input/mouse-events.d.ts +31 -5
  52. package/dist/input/mouse-events.js +86 -27
  53. package/dist/input/stdin-mouse-filter.d.ts +8 -3
  54. package/dist/logging/install.d.ts +51 -1
  55. package/dist/logging/install.js +28 -0
  56. package/dist/logging/logger.d.ts +1 -1
  57. package/dist/session/persist.js +19 -0
  58. package/dist/team/report.js +7 -1
  59. package/dist/team/runtime.d.ts +10 -0
  60. package/dist/team/runtime.js +7 -0
  61. package/dist/team/subagent.d.ts +31 -1
  62. package/dist/team/subagent.js +42 -15
  63. package/dist/team/types.d.ts +8 -0
  64. package/dist/ui/ActivityLine.d.ts +19 -1
  65. package/dist/ui/ActivityLine.js +10 -2
  66. package/dist/ui/App.d.ts +48 -1
  67. package/dist/ui/App.js +294 -13
  68. package/dist/ui/Composer.d.ts +13 -1
  69. package/dist/ui/Composer.js +2 -2
  70. package/dist/ui/PromptInput.d.ts +34 -1
  71. package/dist/ui/PromptInput.js +54 -3
  72. package/dist/ui/StatusBar.d.ts +40 -0
  73. package/dist/ui/StatusBar.js +13 -4
  74. package/dist/ui/TeamPanel.js +7 -1
  75. package/dist/ui/Transcript.d.ts +21 -1
  76. package/dist/ui/Transcript.js +32 -2
  77. package/dist/ui/clipboard.d.ts +52 -0
  78. package/dist/ui/clipboard.js +91 -0
  79. package/dist/ui/entries/CompactionCard.d.ts +75 -0
  80. package/dist/ui/entries/CompactionCard.js +158 -0
  81. package/dist/ui/frame-differ.d.ts +46 -0
  82. package/dist/ui/frame-differ.js +94 -10
  83. package/dist/ui/gauge.d.ts +16 -1
  84. package/dist/ui/gauge.js +25 -6
  85. package/dist/ui/glyphs.d.ts +11 -0
  86. package/dist/ui/glyphs.js +2 -0
  87. package/dist/ui/layout/ScrollViewport.d.ts +43 -6
  88. package/dist/ui/layout/ScrollViewport.js +128 -21
  89. package/dist/ui/layout/budget.d.ts +10 -3
  90. package/dist/ui/layout/budget.js +10 -3
  91. package/dist/ui/layout/follow-state.d.ts +137 -0
  92. package/dist/ui/layout/follow-state.js +150 -0
  93. package/dist/ui/layout/virtual-window.d.ts +11 -0
  94. package/dist/ui/layout/virtual-window.js +34 -0
  95. package/dist/ui/overlays/HelpOverlay.js +6 -1
  96. package/dist/ui/overlays/SettingsScreen.d.ts +47 -1
  97. package/dist/ui/overlays/SettingsScreen.js +60 -1
  98. package/dist/ui/palettes.d.ts +16 -0
  99. package/dist/ui/palettes.js +6 -0
  100. package/dist/ui/screen.d.ts +22 -0
  101. package/dist/ui/screen.js +70 -8
  102. package/dist/ui/selection/highlight.d.ts +39 -0
  103. package/dist/ui/selection/highlight.js +64 -0
  104. package/dist/ui/selection/screen-mirror.d.ts +24 -0
  105. package/dist/ui/selection/screen-mirror.js +35 -0
  106. package/dist/ui/selection/selection-controller.d.ts +88 -0
  107. package/dist/ui/selection/selection-controller.js +282 -0
  108. package/dist/ui/selection/selection.d.ts +90 -0
  109. package/dist/ui/selection/selection.js +223 -0
  110. package/dist/ui/stdout-frame-writer.d.ts +33 -0
  111. package/dist/ui/stdout-frame-writer.js +15 -0
  112. package/dist/ui/theme.d.ts +11 -0
  113. package/dist/ui/theme.js +2 -0
  114. package/dist/ui/transcript-text.js +23 -0
  115. package/dist/ui/use-startup-notices.d.ts +27 -1
  116. package/dist/ui/use-startup-notices.js +43 -11
  117. package/dist/ui/use-wheel-routing.js +8 -0
  118. package/package.json +4 -2
@@ -11,6 +11,7 @@
11
11
  * synthesizes a generic error notice (a swallowed throw is never a no-op).
12
12
  */
13
13
  import process from 'node:process';
14
+ import { occupiedTokens } from '../compaction/pressure.js';
14
15
  import { computeCost } from './usage.js';
15
16
  import { ENTRY_LIMITS, appendBounded, entryRetain, trimEntries } from './entry-limits.js';
16
17
  /** Default auto-dismiss window for a toast. */
@@ -46,11 +47,14 @@ export function initialViewState(seed = {}) {
46
47
  todos: null,
47
48
  retry: null,
48
49
  fast: null,
50
+ compaction: null,
51
+ contextTokensEstimated: false,
49
52
  seq: 0,
50
53
  teamEntryId: undefined,
51
54
  todoEntryId: undefined,
52
55
  retryEntryId: undefined,
53
56
  fastEntryId: undefined,
57
+ compactionEntryId: undefined,
54
58
  streamingId: undefined,
55
59
  turnProduced: false,
56
60
  errorNoticed: false,
@@ -223,6 +227,17 @@ function reduceStreamEvent(se) {
223
227
  return [];
224
228
  }
225
229
  }
230
+ /**
231
+ * The opening words of the `context_overflow` banner.
232
+ *
233
+ * A SHARED CONSTANT RATHER THAN TWO LITERALS, because `compactionStart` matches
234
+ * on it to rewrite the notice a successful reactive recovery would otherwise
235
+ * leave behind (P1-4). Two spellings that must agree, with nothing checking that
236
+ * they do, is how that rewrite silently stops matching — at which point the red
237
+ * "start over" banner comes back and only on the recovery path, which is the one
238
+ * place nobody re-tests.
239
+ */
240
+ export const CONTEXT_OVERFLOW_NOTICE_PREFIX = 'Context length exceeded';
226
241
  /** Turn a provider error into a friendly banner, mapping LLMError.errorType. */
227
242
  export function formatStreamError(error) {
228
243
  const errorType = error.errorType;
@@ -235,7 +250,13 @@ export function formatStreamError(error) {
235
250
  case 'overloaded':
236
251
  return `Provider overloaded - please retry shortly. (${base})`;
237
252
  case 'context_overflow':
238
- return `Context length exceeded - start a new conversation with /reset. (${base})`;
253
+ // `/compact` IS NAMED FIRST, and adding it is the second half of P1-4's
254
+ // fix. This remedy list predates context compaction and was, until it
255
+ // existed, complete: throwing the conversation away really was the only
256
+ // exit. It is not any more, and a banner that offers only `/reset` now
257
+ // tells the user to discard a session the very next line of code may be
258
+ // about to save.
259
+ return `${CONTEXT_OVERFLOW_NOTICE_PREFIX} - try /compact, or start over with /reset. (${base})`;
239
260
  case 'network_error':
240
261
  return `Network error reaching the provider. (${base})`;
241
262
  case 'timeout':
@@ -295,6 +316,11 @@ function appendEntry(state, entries, entry) {
295
316
  // `fastEntryId` IS PART OF THIS LIST for the reason the note above gives:
296
317
  // a fifth live card left out of it is a card I-L1-1 does not cover.
297
318
  state.fastEntryId,
319
+ // And `compactionEntryId` is the sixth. Same reason, and it is the one
320
+ // whose absence would be hardest to notice: a compaction card is rare, so
321
+ // a `mapEntry` that silently stopped matching would be found by a user
322
+ // rather than by a test.
323
+ state.compactionEntryId,
298
324
  ].filter((id) => typeof id === 'string');
299
325
  for (const id of live) {
300
326
  if (!trimmed.entries.some((e) => e.id === id)) {
@@ -584,7 +610,17 @@ export function viewReducer(state, action) {
584
610
  ...state,
585
611
  streamingId: undefined,
586
612
  turnProduced: true,
587
- contextTokens: action.usage.inputTokens + action.usage.outputTokens,
613
+ // ONE FUNCTION, SHARED WITH THE COMPACTION TRIGGER (§3.4.1 / R-11 /
614
+ // AC-3). The old expression — `inputTokens + outputTokens` — silently
615
+ // UNDER-reports for anyone behind a caching gateway, because Anthropic
616
+ // reports `input_tokens` EXCLUDING cached tokens and puts them in two
617
+ // separate fields. That was a live defect in the number this gauge shows;
618
+ // it becomes a much worse one the moment a trigger fires on a different
619
+ // number from the one the user is watching.
620
+ contextTokens: occupiedTokens(action.usage),
621
+ // A MEASUREMENT SUPERSEDES A DERIVATION (§6.2). `compactionEnd` writes an
622
+ // estimate so the bar falls immediately; this is where it stops being one.
623
+ contextTokensEstimated: false,
588
624
  usageTotal: {
589
625
  inputTokens: state.usageTotal.inputTokens + action.usage.inputTokens,
590
626
  outputTokens: state.usageTotal.outputTokens + action.usage.outputTokens,
@@ -713,6 +749,12 @@ export function viewReducer(state, action) {
713
749
  // holds, it is a projection of an entry that has just been erased.
714
750
  retryEntryId: undefined,
715
751
  retry: null,
752
+ // Same reasoning again for the compaction card: the entry is gone, so the
753
+ // id must go with it or `compactionEnd` would `mapEntry` an id that no
754
+ // longer exists. `compaction` STAYS — unlike `retry` it is not a
755
+ // projection of an entry, it is the live state of a subsystem that is
756
+ // still running, and `/clear` clears the screen rather than the session.
757
+ compactionEntryId: undefined,
716
758
  };
717
759
  case 'resetConversation':
718
760
  return {
@@ -731,6 +773,11 @@ export function viewReducer(state, action) {
731
773
  todoEntryId: undefined,
732
774
  retryEntryId: undefined,
733
775
  retry: null,
776
+ compactionEntryId: undefined,
777
+ // `contextTokens` is zeroed above, so the flag that says it is a
778
+ // DERIVATION has to go with it — otherwise a fresh conversation renders
779
+ // `~0%` and the tilde is a lie about a number that is simply zero.
780
+ contextTokensEstimated: false,
734
781
  };
735
782
  case 'setOverlay':
736
783
  return { ...state, overlay: action.overlay };
@@ -957,6 +1004,158 @@ export function viewReducer(state, action) {
957
1004
  };
958
1005
  case 'fastTier':
959
1006
  return { ...state, fast: action.snapshot };
1007
+ // --- Context compaction (context-auto-compaction §5.5) ----------------
1008
+ case 'compactionStart': {
1009
+ const { id, seq } = nextId(state);
1010
+ let entries = state.entries;
1011
+ // RESPONSIBILITY 1 — SEAL ANY LIVE `streamingId` ENTRY, defensively.
1012
+ // C-8's failure mode, and reachable here: `provider.ts` records that
1013
+ // Anthropic can deliver an error IN-STREAM, after the connection is open
1014
+ // and therefore after text deltas have already been emitted. An entry that
1015
+ // never settles pins `Transcript`'s monotonic boundary and re-renders the
1016
+ // tail forever.
1017
+ if (state.streamingId) {
1018
+ entries = mapEntry(entries, state.streamingId, (e) => e.kind === 'assistant' ? { ...e, streaming: false, ...sealThinkingMs(e) } : e);
1019
+ }
1020
+ // RESPONSIBILITY 2 — REWRITE A TRAILING `context_overflow` ERROR NOTICE
1021
+ // (P1-4). `agent-loop.ts` emits `message_update` for EVERY stream event
1022
+ // before the `error` branch throws, and `reduceStreamEvent`'s `case 'error'`
1023
+ // turns that into a `level: 'error'` notice reading "Context length
1024
+ // exceeded". So the HAPPY PATH of the reactive recovery would otherwise end
1025
+ // with a red banner telling the user to throw away the session we are one
1026
+ // frame from saving.
1027
+ //
1028
+ // IT REWRITES RATHER THAN REMOVES, because `<Static>` cannot un-print an
1029
+ // entry it has already drawn — and it matches ONLY when that notice is the
1030
+ // TAIL entry, so it can never touch an older, genuine error. If the
1031
+ // recovery then fails, the ladder's own notice or the re-thrown 400 is what
1032
+ // the user is left with, so neither direction lies.
1033
+ if (action.trigger === 'overflow') {
1034
+ const tail = entries[entries.length - 1];
1035
+ if (tail &&
1036
+ tail.kind === 'notice' &&
1037
+ tail.level === 'error' &&
1038
+ tail.text.startsWith(CONTEXT_OVERFLOW_NOTICE_PREFIX)) {
1039
+ entries = mapEntry(entries, tail.id, (e) => e.kind === 'notice'
1040
+ ? { ...e, level: 'info', text: 'Context window exceeded - compacting and retrying.' }
1041
+ : e);
1042
+ }
1043
+ }
1044
+ const entry = {
1045
+ id,
1046
+ kind: 'compaction',
1047
+ index: action.index,
1048
+ trigger: action.trigger,
1049
+ mode: 'none',
1050
+ applied: false,
1051
+ messagesBefore: 0,
1052
+ messagesAfter: 0,
1053
+ tokensBefore: 0,
1054
+ tokensAfter: 0,
1055
+ model: action.model,
1056
+ startedAt: Date.now(),
1057
+ live: true,
1058
+ };
1059
+ return {
1060
+ ...state,
1061
+ seq,
1062
+ ...appendEntry(state, entries, entry),
1063
+ compactionEntryId: id,
1064
+ streamingId: undefined,
1065
+ };
1066
+ }
1067
+ case 'compactionEnd': {
1068
+ const { record } = action;
1069
+ const settle = (e) => e.kind === 'compaction'
1070
+ ? {
1071
+ ...e,
1072
+ index: record.index,
1073
+ trigger: record.trigger,
1074
+ mode: record.mode,
1075
+ applied: record.applied,
1076
+ ...(record.reason !== undefined ? { reason: record.reason } : {}),
1077
+ messagesBefore: record.messagesBefore,
1078
+ messagesAfter: record.messagesAfter,
1079
+ tokensBefore: record.tokensBefore,
1080
+ tokensAfter: record.tokensAfter,
1081
+ ...(record.summary !== undefined ? { summary: record.summary } : {}),
1082
+ ...(record.tailRelief ? { tailRelief: record.tailRelief } : {}),
1083
+ model: record.model,
1084
+ durationMs: record.durationMs,
1085
+ // SETTLED. `Transcript`'s boundary is monotonic, so a card that
1086
+ // never settles is re-rendered on every frame for the rest of the
1087
+ // session (C-8).
1088
+ live: false,
1089
+ }
1090
+ : e;
1091
+ // THE SECOND BRANCH IS NOT DEFENSIVE PADDING, exactly as `fastEnd`'s is
1092
+ // not: the IDLE `/compact` path settles a record for a card this session
1093
+ // may never have opened, and "it did nothing" must never be the observable
1094
+ // outcome (R-8 / §6.4).
1095
+ if (state.compactionEntryId) {
1096
+ return {
1097
+ ...state,
1098
+ entries: mapEntry(state.entries, state.compactionEntryId, settle),
1099
+ compactionEntryId: undefined,
1100
+ };
1101
+ }
1102
+ const { id, seq } = nextId(state);
1103
+ const entry = {
1104
+ id,
1105
+ kind: 'compaction',
1106
+ index: record.index,
1107
+ trigger: record.trigger,
1108
+ mode: record.mode,
1109
+ applied: record.applied,
1110
+ ...(record.reason !== undefined ? { reason: record.reason } : {}),
1111
+ messagesBefore: record.messagesBefore,
1112
+ messagesAfter: record.messagesAfter,
1113
+ tokensBefore: record.tokensBefore,
1114
+ tokensAfter: record.tokensAfter,
1115
+ ...(record.summary !== undefined ? { summary: record.summary } : {}),
1116
+ ...(record.tailRelief ? { tailRelief: record.tailRelief } : {}),
1117
+ model: record.model,
1118
+ durationMs: record.durationMs,
1119
+ live: false,
1120
+ };
1121
+ return { ...state, seq, ...appendEntry(state, state.entries, entry) };
1122
+ }
1123
+ case 'compactionUsage':
1124
+ // `usageTotal` AND NOTHING ELSE, exactly as `teamUsage` and `fastUsage`. In
1125
+ // particular NOT `contextTokens`: that gauge shows the LEAD's context
1126
+ // occupancy against the lead model's window, and a summarization is a
1127
+ // separate conversation with a possibly different model entirely.
1128
+ return {
1129
+ ...state,
1130
+ usageTotal: {
1131
+ inputTokens: state.usageTotal.inputTokens + action.usage.inputTokens,
1132
+ outputTokens: state.usageTotal.outputTokens + action.usage.outputTokens,
1133
+ costUsd: state.usageTotal.costUsd + action.costDelta,
1134
+ },
1135
+ };
1136
+ case 'compactionSnapshot':
1137
+ return { ...state, compaction: action.snapshot };
1138
+ /**
1139
+ * THE GAUGE MUST FALL IMMEDIATELY (§6.2 / AC-5).
1140
+ *
1141
+ * `contextTokens` is otherwise only written at `turnEnd`, so a compaction
1142
+ * that took the session from 92 % to 24 % would leave the bar at 92 % until
1143
+ * the next turn completes — and the user would reasonably conclude that
1144
+ * nothing happened. This is a small change and it is the difference between
1145
+ * the feature feeling real and feeling broken.
1146
+ *
1147
+ * The value arrives ALREADY CALIBRATED from `App` (§6.2 / P1-11): it replaces
1148
+ * a MEASUREMENT that included the tool schemas, and core's raw estimator
1149
+ * never counts them, so writing the raw figure would make the bar fall too
1150
+ * far and then visibly climb again on the next `turn_end` for no reason the
1151
+ * user can see.
1152
+ */
1153
+ case 'contextTokensEstimated':
1154
+ return {
1155
+ ...state,
1156
+ contextTokens: Math.max(0, Math.round(action.tokens)),
1157
+ contextTokensEstimated: true,
1158
+ };
960
1159
  // --- API retry (llm-api-retry-backoff §6.4) ---------------------------
961
1160
  case 'retryScheduled': {
962
1161
  const snapshot = {
package/dist/cli.js CHANGED
@@ -23,11 +23,11 @@ import { formatMigrationNotice, migrateLegacyState } from './config/migrate-lega
23
23
  import { formatHomeMigrationNotice, migrateToHome } from './config/migrate-home.js';
24
24
  import { migrateStateOutOfConfig } from './config/migrate-state-out-of-config.js';
25
25
  import { runHistoryCommand } from './config/history-commands.js';
26
- import { FAST_CONFIG_SET_KEYS, LOG_CONFIG_SET_KEYS, RETRY_CONFIG_SET_KEYS, TEAM_CONFIG_SET_KEYS, UPDATE_CONFIG_SET_KEYS, applyFastConfigSet, applyLogConfigSet, applyRetryConfigSet, applyTeamConfigSet, applyUpdateConfigSet, runConfigEdit, runConfigGet, runConfigHome, runConfigList, warnIfRedactionDisabled, } from './config/cli-commands.js';
26
+ import { COMPACTION_CONFIG_SET_KEYS, FAST_CONFIG_SET_KEYS, LOG_CONFIG_SET_KEYS, RETRY_CONFIG_SET_KEYS, TEAM_CONFIG_SET_KEYS, UPDATE_CONFIG_SET_KEYS, applyCompactionConfigSet, applyFastConfigSet, applyLogConfigSet, applyRetryConfigSet, applyTeamConfigSet, applyUpdateConfigSet, runConfigEdit, runConfigGet, runConfigHome, runConfigList, warnIfRedactionDisabled, } from './config/cli-commands.js';
27
27
  import { attachAgentEvents, attachTeamEvents, installLogging, setScreenRestore, setSignalTerminator, } from './logging/install.js';
28
28
  import { getLogger } from './logging/logger.js';
29
29
  import { runLogsCommand } from './logging/cli-commands.js';
30
- import { ADAPTER_PROVIDERS, clampDensity, clampTheme, clampThinkingLevel, clampAskRounds, clampHumanTimeout, clampSkillsIntegrity, clampSkillsToolPolicy, clampMaxRenderInterval, clampTranscriptRetain, clampTranscriptWindow, coercePositiveInt, isAdapterProvider, parseMaxTokensInput, DEFAULT_MAX_RENDER_INTERVAL_MS, DEFAULT_MAX_TOKENS, DEFAULT_PLAN_HUMAN_TIMEOUT_MS, DEFAULT_PLAN_MAX_ASK_ROUNDS, DEFAULT_SKILLS_CONFIG, DEFAULT_TRANSCRIPT_RETAIN, DEFAULT_TRANSCRIPT_WINDOW, } from './config/schema.js';
30
+ import { ADAPTER_PROVIDERS, clampDensity, clampTheme, clampThinkingLevel, clampAskRounds, clampHumanTimeout, clampSkillsIntegrity, clampSkillsToolPolicy, clampMaxRenderInterval, clampScrollResumeMs, clampTranscriptRetain, clampTranscriptWindow, coercePositiveInt, isAdapterProvider, parseMaxTokensInput, DEFAULT_MAX_RENDER_INTERVAL_MS, DEFAULT_MAX_TOKENS, DEFAULT_PLAN_HUMAN_TIMEOUT_MS, DEFAULT_PLAN_MAX_ASK_ROUNDS, DEFAULT_SCROLL_RESUME_MS, DEFAULT_SKILLS_CONFIG, DEFAULT_TRANSCRIPT_RETAIN, DEFAULT_TRANSCRIPT_WINDOW, } from './config/schema.js';
31
31
  import { makeGetApiKey } from './config/load.js';
32
32
  import { AgentController, DENY_ALL_APPROVAL } from './agent/controller.js';
33
33
  import { runSkillsCommand } from './skills/cli-commands.js';
@@ -52,6 +52,9 @@ import { readExitSnapshot } from './ui/exit-snapshot.js';
52
52
  import { renderTranscriptText } from './ui/transcript-text.js';
53
53
  import { detectCapabilities } from './ui/capabilities.js';
54
54
  import { pickGlyphs } from './ui/glyphs.js';
55
+ import { getTheme } from './ui/theme.js';
56
+ import { copyText } from './ui/clipboard.js';
57
+ import { createSelectionController, } from './ui/selection/selection-controller.js';
55
58
  import { DENY_ALL_HUMAN_INPUT } from './tools/human-input.js';
56
59
  // A VALUE import, and the one deliberate exception to the paragraph above
57
60
  // (cli-auto-update-hardening P2-7). `boot/guard.js` statically pulls
@@ -105,6 +108,11 @@ function toFlags(opts) {
105
108
  // Without this line the flag never reaches `CliFlags` and `--no-mouse` is
106
109
  // inert — a failure with no compile error and no runtime error (P1-2).
107
110
  mouse: opts.mouse,
111
+ // Site three of three for `--mouse-select` / `--no-mouse-select` — NINE
112
+ // features, one trap. A flag needs the `.option()` declaration below, a field
113
+ // on `CliFlags`, AND a line here; `config.test.ts` is the regression pin that
114
+ // exists because `--no-mouse` shipped inert once already.
115
+ mouseSelect: opts.mouseSelect,
108
116
  skills: opts.skills,
109
117
  skill: opts.skill,
110
118
  skillsYes: opts.skillsYes,
@@ -171,6 +179,15 @@ function toFlags(opts) {
171
179
  // means `--no-update` reports nothing, does nothing, and leaves the updater
172
180
  // running.
173
181
  update: opts.update,
182
+ // Site three of three for `--compaction` / `--no-compaction` — NINE
183
+ // features, one trap. A flag needs the `.option()` declaration below, a field
184
+ // on `CliFlags`, AND a line here; miss this one and the flag is inert with no
185
+ // compile error and no runtime error. It bites here for the same reason it
186
+ // does one field up: this is the KILL SWITCH for a feature that spends the
187
+ // user's money on a summarizer, so a missing line means `--no-compaction`
188
+ // reports nothing, does nothing, and leaves compaction armed.
189
+ compaction: opts.compaction,
190
+ compactionThreshold: opts.compactionThreshold,
174
191
  };
175
192
  }
176
193
  // ---------------------------------------------------------------------------
@@ -421,6 +438,20 @@ function runInteractive(flags, extras = {}) {
421
438
  ? tryCreateMouseFilter(process.stdin, (reason) => logger.warn('cli', 'mouse_filter_failed', { reason }))
422
439
  : null;
423
440
  const disposeMouse = () => mouseFilter?.dispose();
441
+ // --- Drag-select (tui-selection-and-scroll-follow §4.4). ------------------
442
+ //
443
+ // ONE EXPRESSION, READ BY FOUR THINGS, and that is the same discipline
444
+ // `wantMouse` above follows for the same reason: the `?1002h` write, the
445
+ // controller, the differ's `decorate` hook and the startup notice must never
446
+ // disagree about whether drag-select is running. With it false the session is
447
+ // byte-identical to a pre-feature build — no extra escape on the wire (AC-8),
448
+ // no controller, no `decorate`, no mirror.
449
+ const wantSelect = mouseFilter !== null && config.mouseSelect;
450
+ const selectionBridge = {
451
+ controller: null,
452
+ onCopied: null,
453
+ requestRedraw: null,
454
+ };
424
455
  // --- Frame differ (tui-input-flicker-fix §3.2 / §4.2). -------------------
425
456
  //
426
457
  // FULL-SCREEN ONLY, and that is invariant I-3 rather than caution: inline mode
@@ -443,6 +474,25 @@ function runInteractive(flags, extras = {}) {
443
474
  // edge inside the differ: a notice per fallback would be a repaint
444
475
  // storm triggered by a repaint problem (§5.6).
445
476
  onFirstFallback: () => console.warn(FRAME_FALLBACK_NOTICE),
477
+ // ABSENT ENTIRELY without drag-select, not a no-op function: the
478
+ // differ's identity path then allocates nothing and copies nothing,
479
+ // which is what makes `mouseSelect: false` a statement about the code
480
+ // rather than about how cheap an empty function is.
481
+ ...(wantSelect
482
+ ? {
483
+ decorate: (lines) => selectionBridge.controller?.decorate(lines) ?? lines,
484
+ // P1-7: a pass-through frame writes the RAW Ink frame verbatim,
485
+ // so the highlight is wiped off the terminal while the
486
+ // controller still believes it is painted. Deferred out of the
487
+ // current stack because this fires from inside `transform`,
488
+ // which is inside Ink's own `stdout.write`.
489
+ onInvalidate: () => {
490
+ const controller = selectionBridge.controller;
491
+ if (controller)
492
+ setImmediate(() => controller.clear());
493
+ },
494
+ }
495
+ : {}),
446
496
  }))
447
497
  : null;
448
498
  // `null` when no writer was built, so `/perf` can say "diff render off" rather
@@ -478,8 +528,9 @@ function runInteractive(flags, extras = {}) {
478
528
  if (mode === 'fullscreen') {
479
529
  // `mouse` here means "a filter is installed", NOT "the user wants mouse
480
530
  // support" — see `AltScreenOptions`. `screen.ts` must never derive it for
481
- // itself, or a later refactor re-opens the gap I-8 closes.
482
- screen = enterAltScreen(process.stdout, { mouse: mouseFilter !== null });
531
+ // itself, or a later refactor re-opens the gap I-8 closes. `motion` carries
532
+ // the same discipline for `?1002h`: already decided, never re-derived.
533
+ screen = enterAltScreen(process.stdout, { mouse: mouseFilter !== null, motion: wantSelect });
483
534
  const restore = () => screen?.restore();
484
535
  // Four idempotent restore paths (§4.4). The signal hook is NOT redundant:
485
536
  // - `waitUntilExit().then()` is a microtask and may never be reached when
@@ -516,6 +567,115 @@ function runInteractive(flags, extras = {}) {
516
567
  // `CI` is excluded on top of the two TTY tests because a CI runner with a pty
517
568
  // allocated is otherwise a machine that silently mutates its own global
518
569
  // toolchain mid-pipeline (D-11).
570
+ // --- The foreign-write door (§4.4.5). ------------------------------------
571
+ //
572
+ // Where bytes that are OURS but are NOT a frame go — today, the OSC 52
573
+ // clipboard sequence. THREE SHAPES, ONE DOOR, and the third one is the reason
574
+ // this is a resolved value rather than an inline arrow:
575
+ //
576
+ // * with the differ in front of stdout it MUST be `frameWriter.writeForeign`,
577
+ // which invalidates the cache and skips the fallback accounting — through
578
+ // the proxy the same bytes are an unrecognised chunk, which is
579
+ // `passThrough(true)`, which prints `FRAME_FALLBACK_NOTICE` at a user who
580
+ // did nothing but copy something (P1-6);
581
+ // * without one — `--no-diff-render`, and inline mode, which never builds a
582
+ // differ at all — stdout IS the real stream, so a direct write is both safe
583
+ // and what §4.4.5 prescribes. OSC 52 emits no newline, so Ink's own line
584
+ // accounting is untouched and there is nothing to invalidate;
585
+ // * on a non-TTY there is no terminal to accept it, so the door is ABSENT.
586
+ //
587
+ // It must never be a function that accepts a chunk and writes NOWHERE.
588
+ // `copyText` reports `'osc52'` whenever its door took the text, so a silent
589
+ // no-op door — which `frameWriter?.writeForeign(chunk)` is under
590
+ // `--no-diff-render` — makes the toast name a mechanism that was never
591
+ // attempted. Naming the mechanism honestly is the entire reason that return
592
+ // value exists (R-7), and a door that lies is worse than no door.
593
+ const writeForeign = frameWriter
594
+ ? (text) => frameWriter.writeForeign(text)
595
+ : process.stdout.isTTY
596
+ ? (text) => {
597
+ try {
598
+ process.stdout.write(text);
599
+ }
600
+ catch {
601
+ // A closed or broken stdout must never turn a copy into a crash.
602
+ }
603
+ }
604
+ : null;
605
+ // --- The selection controller (§4.4). ------------------------------------
606
+ //
607
+ // BUILT AFTER `enterAltScreen` AND BEFORE `render()`. After, because it must
608
+ // not exist on a session that never asked the terminal to report motion;
609
+ // before, because `decorate` is read by the differ from the very first frame
610
+ // Ink produces.
611
+ //
612
+ // `mouseCaptured` is the run-time answer for `/mouse`, and it lives here rather
613
+ // than inside `screen.ts`'s closure because `App` needs to READ it and the
614
+ // handle's own flag is private (it has to be — `restore()` reads it, and
615
+ // exposing a setter would be a second way to get the two out of step).
616
+ let mouseCaptured = mouseFilter !== null;
617
+ if (wantSelect && mouseFilter) {
618
+ selectionBridge.controller = createSelectionController({
619
+ source: mouseFilter.source,
620
+ // The frame writer is absent under `--no-diff-render`, where there is no
621
+ // cache to repaint from at all; the controller then falls back to asking
622
+ // `App` for a React redraw on every drag flush, which is slower and
623
+ // correct. That is rung 2 of the fail-safe ladder doing its job.
624
+ repaint: () => frameWriter?.repaint() ?? false,
625
+ requestRedraw: () => selectionBridge.requestRedraw?.(),
626
+ // `?? undefined` and not a wrapper: `copyText` skips OSC 52 entirely when
627
+ // it has no door, and reports `'native'`/`'none'` accordingly.
628
+ copy: (text) => copyText(text, writeForeign ?? undefined),
629
+ onCopied: (via, lines, chars) => selectionBridge.onCopied?.(via, lines, chars),
630
+ // Read at PAINT time, not captured: `/theme` rebuilds the theme mid-session.
631
+ theme: () => getTheme(config.theme, detectCapabilities(process.env, process.stdout)),
632
+ caps: detectCapabilities(process.env, process.stdout),
633
+ cols: () => process.stdout.columns ?? 80,
634
+ // The STATIC half of the gate. The dynamic half — "no overlay is open" —
635
+ // belongs to `App`, which owns that state and calls `setEnabled`.
636
+ isSelectable: () => mouseCaptured,
637
+ });
638
+ }
639
+ const disposeSelection = () => {
640
+ selectionBridge.controller?.dispose();
641
+ selectionBridge.controller = null;
642
+ };
643
+ /**
644
+ * The terminal-facing services `App` cannot build for itself (§4.4).
645
+ *
646
+ * PRESENT IN INLINE MODE TOO, and only for the foreign-write door: §4.4.5
647
+ * routes `/copy` through `ui/clipboard.ts` precisely so it gains OSC 52 — the
648
+ * one mechanism that reaches the clipboard of the machine the USER is sitting
649
+ * at over SSH — and gating the whole bridge on full-screen would have handed
650
+ * that back only to full-screen users while the command is available in both.
651
+ * Every OTHER field stays absent there, so the inline tree is unchanged: there
652
+ * is no filter, hence no `setMouseCapture` and no `ctx.mouse`; `mouseSelect`
653
+ * is `wantSelect`, which is already false without one; and there is no
654
+ * controller, so no `selection`.
655
+ */
656
+ const terminalBridge = mode === 'fullscreen' || writeForeign
657
+ ? {
658
+ mouseSelect: wantSelect,
659
+ ...(mouseFilter
660
+ ? {
661
+ setMouseCapture: (on) => {
662
+ screen?.setMouseCapture(on);
663
+ mouseCaptured = on;
664
+ // A released mouse cannot be dragging, and a selection left
665
+ // painted over a screen the terminal is now selecting on its
666
+ // own would be two highlights claiming the same rows.
667
+ if (!on)
668
+ selectionBridge.controller?.clear();
669
+ },
670
+ isMouseCaptured: () => mouseCaptured,
671
+ }
672
+ : {}),
673
+ // The same door the controller copies through, so `/copy` and a
674
+ // drag-release can never disagree about which stream OSC 52 goes to.
675
+ ...(writeForeign ? { writeForeign } : {}),
676
+ ...(selectionBridge.controller ? { selection: selectionBridge } : {}),
677
+ }
678
+ : undefined;
519
679
  const updateBridge = { service: null, onAttach: null };
520
680
  let updateService = null;
521
681
  const updateEligible = !!process.stdout.isTTY &&
@@ -528,7 +688,7 @@ function runInteractive(flags, extras = {}) {
528
688
  // the same tree on every machine — reading `process.versions.node` from
529
689
  // a component would make the notice appear or vanish according to which
530
690
  // Node the developer happened to have installed.
531
- vtInputWarning: vtInputSupported ? undefined : { nodeVersion: process.versions.node }, updateBridge: updateEligible ? updateBridge : undefined }),
691
+ vtInputWarning: vtInputSupported ? undefined : { nodeVersion: process.versions.node }, updateBridge: updateEligible ? updateBridge : undefined, terminal: terminalBridge }),
532
692
  // Full-screen owns console.* itself (I-4): Ink's patchConsole writes
533
693
  // straight to stdout and permanently shifts the fixed frame's accounting.
534
694
  {
@@ -597,6 +757,11 @@ function runInteractive(flags, extras = {}) {
597
757
  .waitUntilExit()
598
758
  .then(() => {
599
759
  screen?.restore();
760
+ // BEFORE `disposeMouse()`, which clears the listener set it subscribed to,
761
+ // and after `restore()` for the reason that call records: the terminal must
762
+ // stop reporting first, and anything already in flight is still consumed by
763
+ // a live filter.
764
+ disposeSelection();
600
765
  disposeMouse();
601
766
  // BOTH BRANCHES, the same discipline `controller.dispose()` and
602
767
  // `updateService?.dispose()` already follow. It drops the `'resize'`
@@ -621,6 +786,11 @@ function runInteractive(flags, extras = {}) {
621
786
  })
622
787
  .catch(() => {
623
788
  screen?.restore();
789
+ // BEFORE `disposeMouse()`, which clears the listener set it subscribed to,
790
+ // and after `restore()` for the reason that call records: the terminal must
791
+ // stop reporting first, and anything already in flight is still consumed by
792
+ // a live filter.
793
+ disposeSelection();
624
794
  disposeMouse();
625
795
  disposeFrameWriter();
626
796
  controller.dispose();
@@ -737,6 +907,11 @@ const CONFIG_SET_KEYS = new Set([
737
907
  // falls through every case, writes nothing, and still prints
738
908
  // `Set mouse = false` (P1-2) -- exactly what happened to `density`/`hints`.
739
909
  'mouse',
910
+ // Both cased in the switch below, for the reason the note above `mouse`
911
+ // records: a key listed here but absent there writes nothing and still prints
912
+ // `Set mouseSelect = false`.
913
+ 'mouseSelect',
914
+ 'scrollResumeMs',
740
915
  'historyEnabled',
741
916
  'planModeDefault',
742
917
  'planModeMaxAskRounds',
@@ -763,6 +938,11 @@ const CONFIG_SET_KEYS = new Set([
763
938
  ...FAST_CONFIG_SET_KEYS,
764
939
  // `update.*` keys are handled by `applyUpdateConfigSet`, same reasoning again.
765
940
  ...UPDATE_CONFIG_SET_KEYS,
941
+ // `compaction.*` keys are handled by `applyCompactionConfigSet`, same
942
+ // reasoning again — and this set is what makes the kill switch reachable from
943
+ // a script at all: `config list` enumerates the section dynamically and so
944
+ // shows these keys whether they are here or not, while `config set` does not.
945
+ ...COMPACTION_CONFIG_SET_KEYS,
766
946
  ]);
767
947
  /**
768
948
  * `aragon config set maxTokens <n|auto|default>`.
@@ -849,6 +1029,18 @@ function runConfigSet(key, value) {
849
1029
  process.stdout.write(`Set ${key} = ${String(merged.update[field])}\n`);
850
1030
  return;
851
1031
  }
1032
+ const compactionPatch = applyCompactionConfigSet(key, value);
1033
+ if (compactionPatch) {
1034
+ // Echoed from the STORED value, so `config set compaction.threshold 0.2`
1035
+ // prints the clamped 0.5 rather than reporting a number that was never
1036
+ // written — the rule `team.*`, `retry.*`, `fast.*` and `update.*` each state
1037
+ // above. It matters most for `warnThreshold`, which is clamped against the
1038
+ // stored `threshold` and so can move for a reason the caller cannot see.
1039
+ const merged = updatePersistedConfig(compactionPatch);
1040
+ const field = key.slice('compaction.'.length);
1041
+ process.stdout.write(`Set ${key} = ${String(merged.compaction[field])}\n`);
1042
+ return;
1043
+ }
852
1044
  const patch = {};
853
1045
  switch (key) {
854
1046
  case 'provider':
@@ -914,6 +1106,16 @@ function runConfigSet(key, value) {
914
1106
  case 'mouse':
915
1107
  patch.mouse = value === 'true' || value === '1';
916
1108
  break;
1109
+ case 'mouseSelect':
1110
+ patch.mouseSelect = value === 'true' || value === '1';
1111
+ break;
1112
+ // Clamped rather than rejected, for the same reason `planModeDefault` below
1113
+ // records: hardening only the READ path leaves a bad value on disk that
1114
+ // reverts to the default on every launch, which presents as "my setting
1115
+ // won't stick". `0` is a legal value here and its documented off switch.
1116
+ case 'scrollResumeMs':
1117
+ patch.scrollResumeMs = clampScrollResumeMs(value, DEFAULT_SCROLL_RESUME_MS);
1118
+ break;
917
1119
  // Turning this off stops new prompts being recorded; it does NOT delete
918
1120
  // what is already in `prompt-history.jsonl` (that is `aragon history clear`).
919
1121
  case 'historyEnabled':
@@ -1172,6 +1374,13 @@ function buildProgram() {
1172
1374
  // Positive form first, same tri-state reason as `--fullscreen` above.
1173
1375
  .option('--mouse', 'Wheel scrolls the transcript (full-screen mode)')
1174
1376
  .option('--no-mouse', 'Leave the mouse to the terminal (the wheel does nothing)')
1377
+ // Positive form FIRST, for the tri-state reason `--fullscreen` records
1378
+ // below: `mouseSelect` is persisted and defaults to `true`, so a lone
1379
+ // `--no-mouse-select` would make commander default `opts.mouseSelect` to
1380
+ // `true` — indistinguishable from silence, and silently overriding a stored
1381
+ // `false` on every run that passes no flag at all.
1382
+ .option('--mouse-select', 'Drag with the mouse to select text; releasing copies it')
1383
+ .option('--no-mouse-select', 'Keep wheel scrolling, but leave drag-select off')
1175
1384
  // Declaring `--fullscreen` BEFORE `--no-fullscreen` keeps the default
1176
1385
  // `undefined` instead of `true`, which is what makes the tri-state work:
1177
1386
  // "no opinion" must stay distinguishable from "force it".
@@ -1228,6 +1437,16 @@ function buildProgram() {
1228
1437
  // feature that installs software would quietly un-set itself (C-11).
1229
1438
  .option('--update', 'Check for and install updates in the background (the default)')
1230
1439
  .option('--no-update', 'Disable auto-update for this session')
1440
+ // BOTH FORMS, positive first, for the reason recorded eight times above —
1441
+ // and this pair shares `--update`'s polarity, which is the worst one.
1442
+ // `compaction.enabled` is PERSISTED and defaults to `true`, so a lone
1443
+ // `--no-compaction` would make commander default `opts.compaction` to `true`
1444
+ // and silently overwrite a stored `false` on EVERY run that passed no flag at
1445
+ // all: the kill switch for a feature that spends the user's money on a
1446
+ // summarizer would quietly un-set itself (C-13 / P1-5).
1447
+ .option('--compaction', 'Compact the conversation when the context window fills (the default)')
1448
+ .option('--no-compaction', 'Never compact; a full context window ends the run as before')
1449
+ .option('--compaction-threshold <n>', 'Occupancy that triggers compaction for this run: 0.9 or 90% (0.5-0.95)')
1231
1450
  .option('--no-skills', 'Disable the skill system entirely (no catalog, no skill tools)')
1232
1451
  .option('--skill <name>', 'Force-load a skill at Level 2 for this run (repeatable)', (value, previous = []) => [...previous, value])
1233
1452
  .option('--skills-yes', 'Approve skill installs for this run (CI / headless)')