@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
@@ -18,6 +18,7 @@
18
18
  * at construction is a tier that can be describing a provider the session no
19
19
  * longer uses.
20
20
  */
21
+ import type { ModelRole } from '../config/model-profiles.js';
21
22
  import { type CliConfig } from '../config/schema.js';
22
23
  import type { FastTier } from './types.js';
23
24
  /**
@@ -29,7 +30,7 @@ import type { FastTier } from './types.js';
29
30
  * `--api-key` override. Injected rather than reimplemented, because a second
30
31
  * key resolver is a second answer to "why does it say no key?".
31
32
  */
32
- export declare function resolveFastTier(config: CliConfig, hasKey: (providerId: string) => boolean): FastTier;
33
+ export declare function resolveFastTier(config: CliConfig, hasKey: (providerId: string, role?: ModelRole) => boolean): FastTier;
33
34
  /**
34
35
  * The one-line notice for a tier the user switched on that cannot resolve, or
35
36
  * `null` when there is nothing worth saying.
@@ -47,7 +47,7 @@ export function resolveFastTier(config, hasKey) {
47
47
  // different settings.
48
48
  if (!isAdapterProvider(provider))
49
49
  return { ok: false, reason: 'no_adapter' };
50
- if (!hasKey(provider))
50
+ if (!hasKey(provider, 'fast'))
51
51
  return { ok: false, reason: 'no_key' };
52
52
  // 6. A fast tier on the SAME provider inherits the session's base URL: a user
53
53
  // pointing the CLI at a gateway means both models, not one. A fast tier on
@@ -56,9 +56,11 @@ export function resolveFastTier(config, hasKey) {
56
56
  const explicitBaseUrl = fast.baseUrl.trim();
57
57
  const baseUrl = explicitBaseUrl.length > 0
58
58
  ? explicitBaseUrl
59
- : provider === config.provider
60
- ? config.baseUrl
61
- : undefined;
59
+ : config.modelProfiles?.fastId && !config.modelProfileState?.fast.invalid
60
+ ? undefined
61
+ : provider === config.provider
62
+ ? config.baseUrl
63
+ : undefined;
62
64
  const ref = {
63
65
  providerId: provider,
64
66
  modelId,
@@ -30,6 +30,7 @@
30
30
  * inside its own loop with an error naming neither the tier nor the setting that
31
31
  * caused it (`agent-loop.ts:140-145`).
32
32
  */
33
+ import type { ModelRole } from '../config/model-profiles.js';
33
34
  import { type AgentEvent, type AssistantMessage, type LLMRequest, type ModelRef, type ThinkingLevel } from '@aragon-agent/core';
34
35
  import type { CliConfig } from '../config/schema.js';
35
36
  import type { NoticeLevel } from '../agent/reducer.js';
@@ -38,8 +39,8 @@ export interface FastWiringDeps {
38
39
  /** Read LIVE on every call - the controller replaces the object on mutation. */
39
40
  getConfig: () => CliConfig;
40
41
  /** `AgentController.hasApiKey`, so there is exactly one key resolver. */
41
- hasKey: (providerId: string) => boolean;
42
- getApiKey: (providerId: string) => string | undefined;
42
+ hasKey: (providerId: string, role?: ModelRole) => boolean;
43
+ getApiKey: (providerId: string, role?: ModelRole) => string | undefined;
43
44
  /** Whether the static price table knows this model (C-11 / RV-4). */
44
45
  isPricedModel: (ref: Pick<ModelRef, 'providerId' | 'modelId'>) => boolean;
45
46
  /** The lead agent's event stream and steering surface. */
@@ -145,7 +146,7 @@ export declare class FastWiring {
145
146
  * resolved model id and a prompt naming a model the tier will not use is worse
146
147
  * than no prompt at all.
147
148
  */
148
- onConfigChanged(): void;
149
+ onConfigChanged(refreshPrompt?: boolean, enabled?: boolean): void;
149
150
  /**
150
151
  * The startup notice for a configuration mistake that is otherwise invisible
151
152
  * (§3.3). Raised once, at construction, through the controller's `notify`.
@@ -162,6 +163,7 @@ export declare class FastWiring {
162
163
  resolveTier(tier: FastTierName): {
163
164
  ref: ModelRef;
164
165
  thinkingLevel: ThinkingLevel;
166
+ role?: ModelRole;
165
167
  };
166
168
  /** The resolved fast `ModelRef`, or `null` - for per-tier cost lookup (§3.6). */
167
169
  fastRef(): ModelRef | null;
@@ -114,7 +114,7 @@ export class FastWiring {
114
114
  getTier: () => this.tier,
115
115
  getConfig: () => this.deps.getConfig().fast,
116
116
  available: () => this.available(),
117
- getApiKey: deps.getApiKey,
117
+ getApiKey: (id) => deps.getApiKey(id, 'fast'),
118
118
  emit: (event) => this.emit(event),
119
119
  // The reviewer announces its own self-disable with a `tier_changed`, and
120
120
  // only the wiring can build the snapshot that carries it.
@@ -202,7 +202,9 @@ export class FastWiring {
202
202
  * resolved model id and a prompt naming a model the tier will not use is worse
203
203
  * than no prompt at all.
204
204
  */
205
- onConfigChanged() {
205
+ onConfigChanged(refreshPrompt = true, enabled) {
206
+ if (enabled !== undefined)
207
+ this.enabled = enabled;
206
208
  const next = resolveFastTier(this.deps.getConfig(), this.deps.hasKey);
207
209
  const before = this.tier;
208
210
  this.tier = next;
@@ -219,7 +221,8 @@ export class FastWiring {
219
221
  this.deps.notify('warn', text);
220
222
  }
221
223
  }
222
- this.deps.onPromptChanged();
224
+ if (refreshPrompt)
225
+ this.deps.onPromptChanged();
223
226
  this.emit({ type: 'tier_changed', snapshot: this.snapshot() });
224
227
  }
225
228
  /**
@@ -243,7 +246,7 @@ export class FastWiring {
243
246
  resolveTier(tier) {
244
247
  const config = this.deps.getConfig();
245
248
  if (tier === 'fast' && this.delegationAvailable() && this.tier.ok) {
246
- return { ref: this.tier.ref, thinkingLevel: this.tier.thinkingLevel };
249
+ return { ref: this.tier.ref, thinkingLevel: this.tier.thinkingLevel, role: 'fast' };
247
250
  }
248
251
  return {
249
252
  ref: {
@@ -252,6 +255,7 @@ export class FastWiring {
252
255
  ...(config.baseUrl ? { baseUrl: config.baseUrl } : {}),
253
256
  },
254
257
  thinkingLevel: config.thinkingLevel,
258
+ role: 'main',
255
259
  };
256
260
  }
257
261
  /** The resolved fast `ModelRef`, or `null` - for per-tier cost lookup (§3.6). */
@@ -38,16 +38,17 @@ export interface EnterSequenceSpec {
38
38
  * `'\r'` rather than to a newline.
39
39
  */
40
40
  export declare const ENTER_SEQUENCES: readonly EnterSequenceSpec[];
41
- /**
42
- * Rewrite every recognized Enter-family sequence to its normalized token.
43
- *
44
- * ONE ANCHORED REGEX, not a per-sequence scan and not a state machine: the
45
- * CSI-u family shares one shape and the two literal Alt+Enter encodings cannot
46
- * nest inside it, so a single left-to-right pass is unambiguous. `\x1b[13;9u`
47
- * and other unknown modifiers are deliberately left untouched -- rewriting an
48
- * unrecognized sequence would be guessing at a terminal dialect we have not
49
- * seen.
50
- */
41
+ export type EnterSequenceSegment = {
42
+ kind: 'text';
43
+ text: string;
44
+ } | {
45
+ kind: 'newline';
46
+ } | {
47
+ kind: 'submit';
48
+ };
49
+ /** Split explicit keys using the recognition table; bare CR/LF remain text. */
50
+ export declare function splitEnterSequences(text: string): EnterSequenceSegment[];
51
+ /** Normalize explicit Enter keys without changing unknown sequences. */
51
52
  export declare function rewriteEnterSequences(text: string): string;
52
53
  /**
53
54
  * Length of the trailing run of `text` that is a proper prefix of an
@@ -30,6 +30,7 @@ import { ENTER_NEWLINE_FRAME } from './limits.js';
30
30
  * `'\r'` rather than to a newline.
31
31
  */
32
32
  export const ENTER_SEQUENCES = [
33
+ { seq: '\x1b[13;1u', meaning: 'enter', to: '\r' },
33
34
  {
34
35
  seq: '\x1b[13;2u',
35
36
  meaning: 'shift+enter',
@@ -88,34 +89,35 @@ export const ENTER_SEQUENCES = [
88
89
  * up to 6 of them.
89
90
  */
90
91
  const MAX_ENTER_PREFIX = Math.max(...ENTER_SEQUENCES.map((s) => s.seq.length)) - 1;
91
- /** `\x1b[13;2u` .. `\x1b[13;8u`, or `\x1b[13u` when the modifier is absent. */
92
- const CSI_U_ENTER = /\x1b\[13(?:;[2-8])?u/g;
93
- /**
94
- * Rewrite every recognized Enter-family sequence to its normalized token.
95
- *
96
- * ONE ANCHORED REGEX, not a per-sequence scan and not a state machine: the
97
- * CSI-u family shares one shape and the two literal Alt+Enter encodings cannot
98
- * nest inside it, so a single left-to-right pass is unambiguous. `\x1b[13;9u`
99
- * and other unknown modifiers are deliberately left untouched -- rewriting an
100
- * unrecognized sequence would be guessing at a terminal dialect we have not
101
- * seen.
102
- */
92
+ /** Split explicit keys using the recognition table; bare CR/LF remain text. */
93
+ export function splitEnterSequences(text) {
94
+ const segments = [];
95
+ let start = 0;
96
+ let index = 0;
97
+ while (index < text.length) {
98
+ const match = text[index] === '\x1b'
99
+ ? ENTER_SEQUENCES.find((spec) => text.startsWith(spec.seq, index)) : undefined;
100
+ if (!match) {
101
+ index += 1;
102
+ continue;
103
+ }
104
+ if (index > start)
105
+ segments.push({ kind: 'text', text: text.slice(start, index) });
106
+ segments.push({ kind: match.to === '\r' ? 'submit' : 'newline' });
107
+ index += match.seq.length;
108
+ start = index;
109
+ }
110
+ if (start < text.length)
111
+ segments.push({ kind: 'text', text: text.slice(start) });
112
+ return segments;
113
+ }
114
+ /** Normalize explicit Enter keys without changing unknown sequences. */
103
115
  export function rewriteEnterSequences(text) {
104
- if (!text.includes('\x1b'))
105
- return text;
106
- // ONE PASS, TWO TARGETS: a MODIFIED Enter is a newline intent, while the
107
- // modifier-less CSI-u Enter a leftover kitty mode emits is a PLAIN Enter
108
- // and must still submit (`\r`). The `;` is the discriminator the table
109
- // above records in each entry's `to` field.
110
- let out = text.replace(CSI_U_ENTER, (seq) => (seq.includes(';') ? ENTER_NEWLINE_FRAME : '\r'));
111
- // Only the two literal Alt+Enter encodings remain. `\x1b\n` is checked
112
- // before `\x1b\r` is replaced; they share no bytes, so order is free, but
113
- // splitting the escapes keeps each replacement self-evident.
114
- if (out.includes('\x1b\n'))
115
- out = out.split('\x1b\n').join(ENTER_NEWLINE_FRAME);
116
- if (out.includes('\x1b\r'))
117
- out = out.split('\x1b\r').join(ENTER_NEWLINE_FRAME);
118
- return out;
116
+ return splitEnterSequences(text).map((segment) => {
117
+ if (segment.kind === 'text')
118
+ return segment.text;
119
+ return segment.kind === 'submit' ? '\r' : ENTER_NEWLINE_FRAME;
120
+ }).join('');
119
121
  }
120
122
  /** True when `s` is a PROPER prefix of some recognized sequence (never whole). */
121
123
  function isEnterPrefix(s) {
@@ -33,9 +33,9 @@
33
33
  */
34
34
  import { PassThrough } from 'node:stream';
35
35
  import { MAX_PENDING_MOUSE_CHARS, splitMouseEvents, isMousePrefix, } from './mouse-events.js';
36
- import { PASTE_ASSEMBLY_MAX_MS, PASTE_BURST_MS, PASTE_CLOSE, PASTE_MAX_BYTES, PASTE_OPEN, formatPasteSize, } from './limits.js';
36
+ import { ENTER_NEWLINE_FRAME, PASTE_ASSEMBLY_MAX_MS, PASTE_BURST_MS, PASTE_CLOSE, PASTE_MAX_BYTES, PASTE_OPEN, formatPasteSize, } from './limits.js';
37
37
  import { PASTE_BEGIN_MARK, PASTE_END_MARK, classifyChunk, sanitisePaste, trailingPastePrefixLength, } from './paste-parse.js';
38
- import { rewriteEnterSequences, trailingEnterPrefixLength } from './enter-sequences.js';
38
+ import { splitEnterSequences, trailingEnterPrefixLength } from './enter-sequences.js';
39
39
  /**
40
40
  * How long a possible-prefix tail is held before being flushed as text.
41
41
  *
@@ -89,7 +89,18 @@ export function createStdinFilter(real, features, bridge, onWarn) {
89
89
  let burstTimer = null;
90
90
  let assemblyTimer = null;
91
91
  let disposed = false;
92
+ let finished = false;
92
93
  let paste = { kind: 'idle' };
94
+ let discardBracketed = false;
95
+ let outputBatch = null;
96
+ const writeInput = (text) => {
97
+ if (disposed || outputBatch?.refused)
98
+ return;
99
+ if (outputBatch)
100
+ outputBatch.chunks.push(text);
101
+ else
102
+ wrapper.write(text);
103
+ };
93
104
  // --- The surface Ink reads off the stream it is handed. ------------------
94
105
  // `isRawModeSupported()` reads `props.stdin.isTTY` (App.js:34), so this has
95
106
  // to be a DELEGATING GETTER rather than a snapshot: a stdin that is not a TTY
@@ -122,6 +133,8 @@ export function createStdinFilter(real, features, bridge, onWarn) {
122
133
  };
123
134
  /** `.unref()` so a held tail can never keep the process alive on its own. */
124
135
  const arm = (fn, ms) => {
136
+ if (finished || disposed)
137
+ return null;
125
138
  const t = setTimeout(fn, ms);
126
139
  t.unref?.();
127
140
  return t;
@@ -143,6 +156,10 @@ export function createStdinFilter(real, features, bridge, onWarn) {
143
156
  if (disposed)
144
157
  return;
145
158
  if (overflow || bytes > PASTE_MAX_BYTES) {
159
+ if (outputBatch?.refused)
160
+ return;
161
+ if (outputBatch)
162
+ outputBatch.refused = true;
146
163
  const text = `Paste too large (${formatPasteSize(bytes)}); limit is ` +
147
164
  `${formatPasteSize(PASTE_MAX_BYTES)}. Nothing was inserted.`;
148
165
  onWarn?.(text);
@@ -153,7 +170,7 @@ export function createStdinFilter(real, features, bridge, onWarn) {
153
170
  if (payload.length === 0)
154
171
  return;
155
172
  // ONE `write()` call, at the position the paste occupied in the stream.
156
- wrapper.write(PASTE_OPEN + payload + PASTE_CLOSE);
173
+ writeInput(PASTE_OPEN + payload + PASTE_CLOSE);
157
174
  };
158
175
  const flushBurst = () => {
159
176
  burstTimer = clearTimer(burstTimer);
@@ -169,6 +186,12 @@ export function createStdinFilter(real, features, bridge, onWarn) {
169
186
  return;
170
187
  const { body, bytes, overflow } = paste;
171
188
  paste = { kind: 'idle' };
189
+ if (discardBracketed) {
190
+ discardBracketed = false;
191
+ if (outputBatch)
192
+ outputBatch.refused = true;
193
+ return;
194
+ }
172
195
  if (reason === 'timeout') {
173
196
  // I-4: an unterminated marker must never wedge input. Emitting what
174
197
  // arrived is strictly better than holding the keyboard hostage.
@@ -190,20 +213,9 @@ export function createStdinFilter(real, features, bridge, onWarn) {
190
213
  * those would corrupt their data silently. v1 covered only Tier 1, which left
191
214
  * the loss in place for exactly the terminals Tier 2 exists to serve (P1-7).
192
215
  */
193
- const handleOutsidePaste = (text, holdTail) => {
216
+ const handleText = (text, holdTail) => {
194
217
  if (text.length === 0)
195
218
  return;
196
- // ENTER-FAMILY REWRITE, AND IT IS FIRST FOR A REASON
197
- // (tui-shift-enter-copy-queue 3.4). The Alt+Enter encoding contains a CR,
198
- // and `classifyChunk`'s primary rule -- "longer than one character AND
199
- // contains a line break" -- would buffer that two-byte key as a Tier 2
200
- // paste body whose sanitiser then strips the ESC, silently swallowing the
201
- // keystroke. After the rewrite the frame is NUL-led and classifies as
202
- // `keys` under both rules. The cost is that an UNBRACKETED paste whose
203
- // body happens to contain the literal Alt+Enter bytes loses that one
204
- // pair's newline; a bracketed body never reaches this function at all
205
- // (I-3), and losing two bytes of a heuristic tier beats eating the key.
206
- text = rewriteEnterSequences(text);
207
219
  if (paste.kind === 'burst') {
208
220
  paste = { kind: 'burst', body: paste.body + text, bytes: paste.bytes + byteLength(text) };
209
221
  burstTimer = clearTimer(burstTimer);
@@ -216,15 +228,16 @@ export function createStdinFilter(real, features, bridge, onWarn) {
216
228
  burstTimer = arm(flushBurst, PASTE_BURST_MS);
217
229
  return;
218
230
  }
219
- // Step 0: with `mouse` false this degenerates to "pass the text on", which
220
- // is byte-identical to the unwrapped stream.
231
+ // Ink conflates DEL Backspace with CSI Delete. Normalize only keyboard
232
+ // text, after burst classification, preserving ESC for Alt+Backspace.
233
+ text = text.replace(/\x7f/g, '\x08');
221
234
  if (!features.mouse) {
222
- wrapper.write(text);
235
+ writeInput(text);
223
236
  return;
224
237
  }
225
238
  const split = splitMouseEvents(text);
226
239
  if (split.text.length > 0)
227
- wrapper.write(split.text);
240
+ writeInput(split.text);
228
241
  for (const event of split.events)
229
242
  emit(event);
230
243
  if (split.pending.length === 0)
@@ -236,10 +249,23 @@ export function createStdinFilter(real, features, bridge, onWarn) {
236
249
  if (holdTail)
237
250
  pending += split.pending;
238
251
  else
239
- wrapper.write(split.pending);
252
+ writeInput(split.pending);
253
+ };
254
+ const handleOutsidePaste = (text, holdTail) => {
255
+ const segments = splitEnterSequences(text);
256
+ for (let index = 0; index < segments.length; index += 1) {
257
+ const segment = segments[index];
258
+ if (segment.kind === 'text') {
259
+ handleText(segment.text, holdTail && index === segments.length - 1);
260
+ continue;
261
+ }
262
+ // A key terminates the heuristic paste before its internal frame is written.
263
+ flushBurst();
264
+ writeInput(segment.kind === 'submit' ? '\r' : ENTER_NEWLINE_FRAME);
265
+ }
240
266
  };
241
267
  const appendBracketed = (text) => {
242
- if (paste.kind !== 'bracketed' || text.length === 0)
268
+ if (paste.kind !== 'bracketed' || text.length === 0 || discardBracketed)
243
269
  return;
244
270
  const bytes = paste.bytes + byteLength(text);
245
271
  const overflow = paste.overflow || bytes > PASTE_MAX_BYTES;
@@ -315,13 +341,39 @@ export function createStdinFilter(real, features, bridge, onWarn) {
315
341
  handleOutsidePaste(text, false);
316
342
  };
317
343
  const onData = (chunk) => {
318
- if (disposed)
344
+ if (disposed || finished)
319
345
  return;
320
346
  flushTimer = clearTimer(flushTimer);
321
347
  const decoded = typeof chunk === 'string' ? chunk : chunk.toString('utf8');
322
348
  const carried = pending;
323
349
  pending = '';
324
- feed(carried + decoded);
350
+ // A rejected paste must not leave earlier text or a later Enter executable.
351
+ const batch = { chunks: [], refused: false };
352
+ outputBatch = batch;
353
+ try {
354
+ feed(carried + decoded.replace(/\u0000/g, ''));
355
+ }
356
+ finally {
357
+ outputBatch = null;
358
+ }
359
+ if (batch.refused) {
360
+ burstTimer = clearTimer(burstTimer);
361
+ if (paste.kind === 'bracketed') {
362
+ // Keep consuming an unfinished body so its next chunk cannot become keys.
363
+ discardBracketed = true;
364
+ paste = { kind: 'bracketed', body: '', bytes: 0, overflow: false };
365
+ }
366
+ else {
367
+ pending = '';
368
+ paste = { kind: 'idle' };
369
+ assemblyTimer = clearTimer(assemblyTimer);
370
+ }
371
+ return;
372
+ }
373
+ if (disposed || finished)
374
+ return;
375
+ for (const text of batch.chunks)
376
+ wrapper.write(text);
325
377
  if (paste.kind === 'bracketed' || pending.length === 0)
326
378
  return;
327
379
  if (pending.length > MAX_PENDING_CHARS) {
@@ -337,11 +389,24 @@ export function createStdinFilter(real, features, bridge, onWarn) {
337
389
  // P2-7: a closed stdin must still terminate Ink's reader, or it waits forever
338
390
  // on a stream that will never produce another byte.
339
391
  const onEnd = () => {
340
- flushBracketed('timeout');
341
- flushBurst();
342
- flushPending();
343
- if (!wrapper.writableEnded)
344
- wrapper.end();
392
+ if (finished || disposed)
393
+ return;
394
+ finished = true;
395
+ flushTimer = clearTimer(flushTimer);
396
+ burstTimer = clearTimer(burstTimer);
397
+ assemblyTimer = clearTimer(assemblyTimer);
398
+ const tail = pending;
399
+ pending = '';
400
+ if (paste.kind === 'bracketed') {
401
+ appendBracketed(tail);
402
+ flushBracketed('timeout');
403
+ }
404
+ else {
405
+ // The pending prefix belongs to the currently open burst, if any.
406
+ handleOutsidePaste(tail, false);
407
+ flushBurst();
408
+ }
409
+ wrapper.end();
345
410
  };
346
411
  // `setEncoding('utf8')` on the REAL stream so Node's StringDecoder handles
347
412
  // multi-byte boundaries for us -- the scan works on characters, and a UTF-8
@@ -144,6 +144,9 @@ export interface AgentEventSource {
144
144
  */
145
145
  export type AgentLogEvent = {
146
146
  type: 'agent_start';
147
+ } | {
148
+ type: 'steering_accepted';
149
+ ids: readonly string[];
147
150
  } | {
148
151
  type: 'agent_end';
149
152
  messages: unknown[];
@@ -42,7 +42,7 @@ import { readConfigFile } from '../config/store.js';
42
42
  import { clampLogConfig } from '../config/schema.js';
43
43
  import { clampLogLevel } from './levels.js';
44
44
  import { Logger, setActiveLogger } from './logger.js';
45
- import { registerSecretsFrom } from './secret-registry.js';
45
+ import { registerProfileSecrets, registerSecretsFrom } from './secret-registry.js';
46
46
  /** POSIX signal numbers for the `128 + signo` exit-code convention. */
47
47
  const SIGNAL_NUMBERS = { SIGINT: 2, SIGTERM: 15, SIGHUP: 1 };
48
48
  let screenRestore = () => { };
@@ -213,7 +213,9 @@ export function installLogging(opts = {}) {
213
213
  // Registration site 1 (§4.4.3): whatever the config file already holds. Sites
214
214
  // 2, 3, 4 and 5 add the env, the merged view plus `--api-key`, the settings
215
215
  // screen, and every persisted write respectively.
216
- registerSecretsFrom(readConfigFile().config?.apiKeys ?? undefined);
216
+ const persisted = readConfigFile().config;
217
+ registerSecretsFrom(persisted?.apiKeys ?? undefined);
218
+ registerProfileSecrets(persisted);
217
219
  const warning = getHomeResolutionWarning();
218
220
  if (warning)
219
221
  logger.warn('cli', 'home_resolution_fallback', { reason: warning });
@@ -18,6 +18,8 @@
18
18
  export declare function registerSecret(value: string | undefined | null): void;
19
19
  /** Convenience for the `apiKeys` maps every registration site happens to hold. */
20
20
  export declare function registerSecretsFrom(keys: Record<string, string | null | undefined> | undefined): void;
21
+ /** Register dedicated credentials in untrusted parsed config before schema validation. */
22
+ export declare function registerProfileSecrets(value: unknown): void;
21
23
  /**
22
24
  * Every known secret, LONGEST FIRST.
23
25
  *
@@ -39,6 +39,24 @@ export function registerSecretsFrom(keys) {
39
39
  for (const value of Object.values(keys))
40
40
  registerSecret(value);
41
41
  }
42
+ /** Register dedicated credentials in untrusted parsed config before schema validation. */
43
+ export function registerProfileSecrets(value) {
44
+ if (!value || typeof value !== 'object')
45
+ return;
46
+ const profiles = value.modelProfiles;
47
+ if (!profiles || typeof profiles !== 'object')
48
+ return;
49
+ const entries = profiles.entries;
50
+ if (!Array.isArray(entries))
51
+ return;
52
+ for (const entry of entries) {
53
+ if (!entry || typeof entry !== 'object')
54
+ continue;
55
+ const key = entry.apiKey;
56
+ if (typeof key === 'string')
57
+ registerSecret(key);
58
+ }
59
+ }
42
60
  /**
43
61
  * Every known secret, LONGEST FIRST.
44
62
  *
@@ -39,14 +39,13 @@ export interface SessionMeta {
39
39
  export interface SavedSession {
40
40
  version: number;
41
41
  savedAt: number;
42
- model: ModelRef;
42
+ model?: ModelRef;
43
43
  messages: Message[];
44
44
  entries: Entry[];
45
45
  /**
46
46
  * The live todo list at save time (todo-plan-execution §3.13).
47
47
  *
48
- * `SESSION_VERSION` STAYS 1: the field is additive and optional, `loadSession`
49
- * validates only that `messages` and `entries` are arrays, and an older file
48
+ * `SESSION_VERSION` STAYS 1: the field is additive and optional, and an older file
50
49
  * simply yields `undefined` — which `restoreTodos` treats as "clear", the
51
50
  * right answer for a session that predates the feature (D-20 / D-22).
52
51
  */
@@ -56,8 +55,8 @@ export interface SavedSession {
56
55
  *
57
56
  * `SESSION_VERSION` STAYS 1 for the reason `todos?` records above, and this is
58
57
  * the precedent that file's own comments already set: the field is additive
59
- * and optional, and `loadSession` validates only array-ness of `messages` /
60
- * `entries`. A TUI `/save` file simply has no `meta`, which is exactly how
58
+ * and optional. Boundary validation preserves it without changing its shape.
59
+ * A TUI `/save` file simply has no `meta`, which is exactly how
61
60
  * `sessions list` tells the two apart.
62
61
  */
63
62
  meta?: SessionMeta;
@@ -88,8 +87,8 @@ export declare function saveSession(filePath: string, data: {
88
87
  * Settle any team entry that was saved mid-dispatch (team-subagents §5.3 / P1-5),
89
88
  * and any todo card that was saved mid-run (todo-plan-execution §3.8 / C-5).
90
89
  *
91
- * `saveSession` writes `entries` verbatim and `loadSession` validates only
92
- * array-ness, so a session saved while a dispatch was in flight resumes with a
90
+ * Structural validation cannot settle live state: a session saved while a
91
+ * dispatch was in flight can contain a valid
93
92
  * `kind: 'team'` entry claiming `active: true` while nothing is running. TWO
94
93
  * things then go wrong at once: the card spins forever, and `Transcript`'s
95
94
  * settled boundary is MONOTONIC — an entry that never settles never reaches
@@ -8,6 +8,7 @@
8
8
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
9
9
  import { dirname, isAbsolute, join, resolve } from 'node:path';
10
10
  import { getSessionsDir } from '../config/store.js';
11
+ import { validateSession } from './validate-session.js';
11
12
  const SESSION_VERSION = 1;
12
13
  /** Resolve a `/save` target: absolute/relative path, or a name in the data dir. */
13
14
  export function resolveSessionPath(nameOrPath, cwd) {
@@ -109,8 +110,8 @@ export function saveSession(filePath, data) {
109
110
  * Settle any team entry that was saved mid-dispatch (team-subagents §5.3 / P1-5),
110
111
  * and any todo card that was saved mid-run (todo-plan-execution §3.8 / C-5).
111
112
  *
112
- * `saveSession` writes `entries` verbatim and `loadSession` validates only
113
- * array-ness, so a session saved while a dispatch was in flight resumes with a
113
+ * Structural validation cannot settle live state: a session saved while a
114
+ * dispatch was in flight can contain a valid
114
115
  * `kind: 'team'` entry claiming `active: true` while nothing is running. TWO
115
116
  * things then go wrong at once: the card spins forever, and `Transcript`'s
116
117
  * settled boundary is MONOTONIC — an entry that never settles never reaches
@@ -196,7 +197,7 @@ export function normalizeLoadedEntries(entries) {
196
197
  // THE SEVENTH CLAUSE (tui-shift-enter-copy-queue 5.2.5). Core's steering
197
198
  // queue is memory-only, so a `queued` entry on disk is a promise this
198
199
  // process can no longer keep: the message was never accepted into any
199
- // conversation. Downgrade to a WARN NOTICE carrying the first line
200
+ // conversation. Downgrade to a WARN NOTICE carrying the full text
200
201
  // rather than to a `user` entry -- pretending it was sent is the lie
201
202
  // that loses the user's words with a straight face.
202
203
  //
@@ -204,12 +205,11 @@ export function normalizeLoadedEntries(entries) {
204
205
  // live transcript, and a fresh id here would collide with the next
205
206
  // `e<n>` the reducer allocates.
206
207
  if (entry.kind === 'queued') {
207
- const firstLine = entry.text.split('\n', 1)[0] ?? '';
208
208
  const downgraded = {
209
209
  id: entry.id,
210
210
  kind: 'notice',
211
211
  level: 'warn',
212
- text: `Queued but never sent: ${firstLine}`,
212
+ text: `Queued but never sent: ${entry.text}`,
213
213
  };
214
214
  return downgraded;
215
215
  }
@@ -218,9 +218,6 @@ export function normalizeLoadedEntries(entries) {
218
218
  }
219
219
  export function loadSession(filePath) {
220
220
  const raw = readFileSync(filePath, 'utf-8');
221
- const parsed = JSON.parse(raw);
222
- if (!parsed || !Array.isArray(parsed.messages) || !Array.isArray(parsed.entries)) {
223
- throw new Error('Invalid session file (missing messages/entries).');
224
- }
221
+ const parsed = validateSession(JSON.parse(raw));
225
222
  return { ...parsed, entries: normalizeLoadedEntries(parsed.entries) };
226
223
  }
@@ -18,6 +18,7 @@
18
18
  * command the docs recommend. `list` and `prune` therefore see only files
19
19
  * carrying `meta.id`; `show` and `rm` accept any id, because the user named it.
20
20
  */
21
+ import type { ModelRef } from '@aragon-agent/core';
21
22
  import { type SavedSession, type SessionMeta } from './persist.js';
22
23
  /**
23
24
  * The id is a FILENAME under the user's home directory, and the caller supplying
@@ -84,7 +85,9 @@ export declare function readSessionFile(path: string): SavedSession | null;
84
85
  export declare function resolveSessionRef(idOrPath: string, cwd: string): string | null;
85
86
  export interface WriteSessionInput {
86
87
  id: string;
87
- session: Omit<SavedSession, 'version' | 'savedAt' | 'meta'>;
88
+ session: Omit<SavedSession, 'version' | 'savedAt' | 'meta' | 'model'> & {
89
+ model: ModelRef;
90
+ };
88
91
  meta: SessionMeta;
89
92
  }
90
93
  /**
@@ -0,0 +1,3 @@
1
+ import type { SavedSession } from './persist.js';
2
+ /** Return a prepared copy, preserving extensions; throw with the corrupt field path. */
3
+ export declare function validateSession(value: unknown): SavedSession;