@aragon-agent/cli 0.6.23 → 0.6.24

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 (42) hide show
  1. package/README.md +20 -2
  2. package/dist/agent/controller.d.ts +11 -0
  3. package/dist/agent/controller.js +33 -3
  4. package/dist/agent/model-profile-settings.js +29 -2
  5. package/dist/commands/builtins.js +87 -3
  6. package/dist/compaction/child.d.ts +15 -0
  7. package/dist/compaction/child.js +14 -3
  8. package/dist/compaction/compactor.js +1 -0
  9. package/dist/compaction/context-command.js +10 -1
  10. package/dist/compaction/meter.d.ts +9 -0
  11. package/dist/compaction/meter.js +16 -1
  12. package/dist/compaction/pressure.d.ts +7 -0
  13. package/dist/compaction/pressure.js +5 -0
  14. package/dist/compaction/types.d.ts +11 -0
  15. package/dist/compaction/wiring.js +1 -0
  16. package/dist/config/cli-commands.d.ts +1 -1
  17. package/dist/config/cli-commands.js +12 -0
  18. package/dist/config/load.js +24 -6
  19. package/dist/config/model-profile-resolution.d.ts +8 -0
  20. package/dist/config/model-profile-resolution.js +13 -6
  21. package/dist/config/model-profile-store.d.ts +4 -2
  22. package/dist/config/model-profiles.d.ts +13 -0
  23. package/dist/config/model-profiles.js +18 -2
  24. package/dist/config/schema.d.ts +61 -0
  25. package/dist/config/schema.js +36 -0
  26. package/dist/team/model.d.ts +88 -0
  27. package/dist/team/model.js +136 -0
  28. package/dist/team/report.d.ts +11 -0
  29. package/dist/team/report.js +24 -1
  30. package/dist/team/subagent.js +37 -21
  31. package/dist/team/task-tool.d.ts +25 -6
  32. package/dist/team/task-tool.js +15 -9
  33. package/dist/ui/App.js +5 -0
  34. package/dist/ui/overlays/ModelProfileEditor.js +18 -4
  35. package/dist/ui/overlays/ModelProfilePicker.d.ts +14 -2
  36. package/dist/ui/overlays/ModelProfilePicker.js +51 -10
  37. package/dist/ui/overlays/QuestionOverlay.js +47 -6
  38. package/dist/ui/overlays/SettingsScreen.d.ts +9 -0
  39. package/dist/ui/overlays/SettingsScreen.js +119 -17
  40. package/dist/ui/overlays/model-profile-state.d.ts +53 -4
  41. package/dist/ui/overlays/model-profile-state.js +111 -14
  42. package/package.json +2 -2
@@ -18,6 +18,7 @@ import { createBuiltinTools } from '../tools/index.js';
18
18
  import { pickActivityArgs, sanitizeActivity } from './activity.js';
19
19
  import { TEAM_LIMITS, TEAM_SUBAGENT_TOOL_NAMES } from './limits.js';
20
20
  import { isRetryableStreamError } from './retry.js';
21
+ import { resolveSubagentKey, resolveSubagentModel } from './model.js';
21
22
  import { makeTeamSend, makeTeamWait, withMailboxTail } from './comm-tools.js';
22
23
  import { buildSubagentBlock } from './prompt.js';
23
24
  /** The default factory: a real `Agent` from `@aragon-agent/core`. */
@@ -162,25 +163,34 @@ export function createSubagent(spec, deps, hooks) {
162
163
  let agentRef = null;
163
164
  const getAgent = () => agentRef;
164
165
  const tools = buildSubagentTools(spec, deps, getAgent);
165
- // THE CHILD'S MODEL IS THE LEAD'S MODEL, READ FROM THE DISPATCH-TIME CONFIG
166
- // (main-agent parity). Same provider, same model id, same base URL, same
167
- // thinking level: a delegation is never a downgrade, and the only model a
168
- // child may surprise its lead with is one a settings-screen edit made live
169
- // between two dispatches.
166
+ // WHICH MODEL A CHILD RUNS IS A USER SETTING, RESOLVED FROM THE
167
+ // DISPATCH-TIME CONFIG through the one authority (subagent-model §3.4).
168
+ // `team.model: 'main'` - the default - is the historical main-agent parity
169
+ // exactly: same provider, model, base URL and thinking level as the lead.
170
+ // `'fast'` / `'profile'` are the user's explicit, visible, reversible cost
171
+ // switches, and EVERY failure mode (tier off, profile deleted, profile key
172
+ // missing) degrades back to the main connection with a disclosed reason
173
+ // rather than failing the dispatch (D-4). The MODEL itself still cannot
174
+ // choose: `normalizeSubagentSpecs` drops a stale `model` key (D-1).
170
175
  //
171
176
  // `maxTokens` inherits `deps.config.maxTokens` below - the same ambition the
172
- // lead sends, clamped by the same adapter, so an inherited large value
173
- // degrades to the right number rather than to an HTTP 400.
174
- const resolved = {
175
- ref: {
176
- providerId: deps.config.provider,
177
- modelId: deps.config.model,
178
- ...(deps.config.baseUrl ? { baseUrl: deps.config.baseUrl } : {}),
179
- },
180
- thinkingLevel: deps.config.thinkingLevel,
181
- role: 'main',
182
- };
183
- const model = resolved.ref;
177
+ // lead sends, clamped by Core's `resolveOutputTokens` against the CHILD
178
+ // model's own ceiling, so an inherited large value degrades to the right
179
+ // number rather than to an HTTP 400.
180
+ const resolution = resolveSubagentModel(deps.config, (id, role) => Boolean(deps.getApiKey(id, role)));
181
+ const model = resolution.ref;
182
+ // THE CHILD'S OWN KEY CLOSURE, PROFILE-AWARE (§3.3 / I-4). A bound profile's
183
+ // dedicated key is visible ONLY inside this closure - never in env, config
184
+ // `apiKeys` or a log line - while every other provider resolves through the
185
+ // lead's resolver at `resolution.keyRole`, which is what keeps a fast child
186
+ // entitled to the fast-role key and a main child to the main one.
187
+ const childGetApiKey = (id) => resolveSubagentKey(deps.config, resolution, id, (providerId, role) => deps.getApiKey(providerId, role));
188
+ // The boolean twin, for the child's compaction summarizer (V-4): the child's
189
+ // own resolution first, the lead layer by role second. Forwarding only
190
+ // `deps.getApiKey` would hide a profile-dedicated key from `resolveSummarizer`
191
+ // and silently disable the child's compaction - the exact gap the req-level
192
+ // resolvers on `ChildContextManagerRequest` exist to close.
193
+ const childHasKey = (id, role) => Boolean(childGetApiKey(id) ?? deps.getApiKey(id, role));
184
194
  // HOISTED TO A LOCAL, AND THAT IS WHAT MAKES W3 POSSIBLE AT ALL. This was built
185
195
  // INLINE inside the `agentFactory({...})` argument, so there was nothing for the
186
196
  // child manager's `getSystemPrompt` accessor to close over - and the accessor
@@ -216,7 +226,12 @@ export function createSubagent(spec, deps, hooks) {
216
226
  const contextManager = deps.contextManagerFor?.({
217
227
  label: spec.label,
218
228
  model,
219
- role: resolved.role ?? 'main',
229
+ // keyRole (V-4): a fast child's summarizer resolves its main choice with
230
+ // role='fast', so a fast-only key is visible to it; profile keys reach it
231
+ // through the req-level resolvers below instead.
232
+ role: resolution.keyRole,
233
+ getApiKey: childGetApiKey,
234
+ hasKey: childHasKey,
220
235
  getMessages: () => agentRef?.state.messages ?? [],
221
236
  getSystemPrompt: () => systemPrompt,
222
237
  onCompacted: () => {
@@ -227,12 +242,13 @@ export function createSubagent(spec, deps, hooks) {
227
242
  systemPrompt,
228
243
  model,
229
244
  tools,
230
- thinkingLevel: resolved.thinkingLevel,
245
+ thinkingLevel: resolution.thinkingLevel,
231
246
  // The LEAD's registry instance, REUSED (P2-2). It holds adapter state, and
232
247
  // constructing five of them per dispatch is pure waste.
233
248
  providerRegistry: deps.providerRegistry,
234
- // The lead's closure, so a settings-screen key edit reaches children too.
235
- getApiKey: (id) => deps.getApiKey(id, resolved.role ?? 'main'),
249
+ // The child's own profile-aware closure, so a settings-screen key edit
250
+ // reaches children too and a bound profile's key reaches ONLY them.
251
+ getApiKey: childGetApiKey,
236
252
  ...(deps.config.maxTokens !== undefined ? { maxTokens: deps.config.maxTokens } : {}),
237
253
  timeouts: {
238
254
  toolTimeout: deps.config.toolTimeoutMs,
@@ -20,14 +20,17 @@
20
20
  * in the schema would behave differently depending on whether `ajv`, an OPTIONAL
21
21
  * dependency of core, happened to install.
22
22
  *
23
- * EVERY CHILD RUNS THE LEAD'S OWN MODEL (main-agent parity): same provider,
24
- * model, thinking level and token ambition, resolved from the LIVE config at
25
- * dispatch. There is no per-child `model` property and no cheap tier to pick -
26
- * a delegation is never a downgrade, and the fast tier reaches children the
27
- * same way it reaches the lead (compaction summaries, per-child reviews).
23
+ * WHICH MODEL A CHILD RUNS IS THE USER'S SETTING, NOT THE MODEL'S (D-1):
24
+ * `team.model` defaults to the lead's own connection (main-agent parity) and
25
+ * can name the fast tier or a profile; there is still no per-child `model`
26
+ * property, so a delegation is never a self-downgrade. Resolution lives in
27
+ * `team/model.ts` and is re-read from the LIVE config at every dispatch; the
28
+ * fast tier otherwise reaches children the way it reaches the lead
29
+ * (compaction summaries, per-child reviews).
28
30
  */
29
31
  import { type AgentTool } from '@aragon-agent/core';
30
32
  import type { ModelCost } from '@aragon-agent/core';
33
+ import type { SubagentModelResolution } from './model.js';
31
34
  import type { TeamRuntime } from './runtime.js';
32
35
  export interface TaskToolDeps {
33
36
  runtime: TeamRuntime;
@@ -37,8 +40,24 @@ export interface TaskToolDeps {
37
40
  /** Whether a usable key exists for the active provider right now (P2-3). */
38
41
  hasApiKey: () => boolean;
39
42
  activeProvider: () => string;
40
- /** The lead's cost table, so the report header states real spend (R-5). */
43
+ /**
44
+ * The cost table of the model children ACTUALLY run (subagent-model §3.4).
45
+ *
46
+ * The controller resolves `team.model` live and prices `modelCost` at the
47
+ * EFFECTIVE model's table: with the default `main` mode this is exactly the
48
+ * lead's own table, so the report's spend line is byte-identical to the
49
+ * parity behavior; with `fast` / `profile` it is that model's table, and an
50
+ * UNPRICED one yields `undefined` so the header says `pricing unknown`
51
+ * rather than fabricating `$0.00` (C-11 / RV-4).
52
+ */
41
53
  modelCost: () => ModelCost | undefined;
54
+ /**
55
+ * The dispatch-time subagent model resolution, for the report's disclosure
56
+ * line (D-4): rendered ONLY when children did not run the main model, and
57
+ * carrying the fallback reason when they could not. OPTIONAL so existing
58
+ * construction sites compile; absent simply omits the line.
59
+ */
60
+ subagentModel?: () => SubagentModelResolution;
42
61
  /**
43
62
  * The FAST tier's cost table, so the report's supervisor spend line states
44
63
  * real spend (subagent-overseer-v2 D-7 / R-P1-2). OPTIONAL and returning
@@ -20,11 +20,13 @@
20
20
  * in the schema would behave differently depending on whether `ajv`, an OPTIONAL
21
21
  * dependency of core, happened to install.
22
22
  *
23
- * EVERY CHILD RUNS THE LEAD'S OWN MODEL (main-agent parity): same provider,
24
- * model, thinking level and token ambition, resolved from the LIVE config at
25
- * dispatch. There is no per-child `model` property and no cheap tier to pick -
26
- * a delegation is never a downgrade, and the fast tier reaches children the
27
- * same way it reaches the lead (compaction summaries, per-child reviews).
23
+ * WHICH MODEL A CHILD RUNS IS THE USER'S SETTING, NOT THE MODEL'S (D-1):
24
+ * `team.model` defaults to the lead's own connection (main-agent parity) and
25
+ * can name the fast tier or a profile; there is still no per-child `model`
26
+ * property, so a delegation is never a self-downgrade. Resolution lives in
27
+ * `team/model.ts` and is re-read from the LIVE config at every dispatch; the
28
+ * fast tier otherwise reaches children the way it reaches the lead
29
+ * (compaction summaries, per-child reviews).
28
30
  */
29
31
  import { errorResult, textResult } from '@aragon-agent/core';
30
32
  import { TEAM_LIMITS } from './limits.js';
@@ -39,8 +41,9 @@ const TEAM_OFF_REFUSAL = 'Team mode is off for this session. Do this work yourse
39
41
  */
40
42
  const TASK_DESCRIPTION = 'Run several independent subagents in parallel and get one combined report. Each ' +
41
43
  'subagent is a fresh agent with the same tools and working directory but NO memory of ' +
42
- 'this conversation - its prompt must be self-contained. Each runs on the same model ' +
43
- 'you are running on. Use this when a task splits ' +
44
+ 'this conversation - its prompt must be self-contained. Each runs on the model the ' +
45
+ 'user configured for subagents (the main model by default). Use this when a task ' +
46
+ 'splits ' +
44
47
  'into 2 or more parts that do not depend on each other\'s output, especially reading or ' +
45
48
  'searching several areas at once. Do NOT use it when one part needs another part\'s ' +
46
49
  'result (do those yourself, in order), when two parts would edit the same file, or for ' +
@@ -120,13 +123,16 @@ export function createTaskTool(deps) {
120
123
  // A cancellation or a partial result rendered as a tool FAILURE invites
121
124
  // the model to retry the whole fan-out, which is the most expensive
122
125
  // possible reaction (the same reasoning as `ask_user`'s cancelled
123
- // shape, and D-14 one level up). Every child ran the lead's own model,
124
- // so the lead's cost table is the one honest price for the total.
126
+ // shape, and D-14 one level up). `modelCost` is priced at the model
127
+ // children ACTUALLY ran - the lead's own table under the default
128
+ // `main` mode - so the total stays one honest price.
125
129
  const cost = deps.modelCost();
126
130
  const fastCost = deps.fastModelCost?.();
131
+ const subagentModel = deps.subagentModel?.();
127
132
  return textResult(buildDispatchReport(outcome, {
128
133
  ...(cost ? { cost } : {}),
129
134
  ...(fastCost ? { fastCost } : {}),
135
+ ...(subagentModel ? { subagentModel } : {}),
130
136
  }));
131
137
  },
132
138
  };
package/dist/ui/App.js CHANGED
@@ -2144,6 +2144,11 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
2144
2144
  // `auto`, not an empty string: an empty field reads as "unset", which
2145
2145
  // is indistinguishable from AUTO and is not a state this can be in.
2146
2146
  maxTokens: cfg.maxTokens ? String(cfg.maxTokens) : 'auto',
2147
+ // BOTH HALVES, seeded AND read back (§6.3): the sibling comment above
2148
+ // records what omitting either one costs. `auto` mirrors the row's
2149
+ // empty-field reading; a bound Main profile hides the row entirely
2150
+ // and the profile editor seeds from the entry instead.
2151
+ contextWindow: cfg.contextWindow === null ? 'auto' : String(cfg.contextWindow),
2147
2152
  apiKey: '',
2148
2153
  logLevel: cfg.log.level,
2149
2154
  // The four fast rows, seeded through the pure mapper so the screen and
@@ -9,8 +9,13 @@ import { stripEnterFrames } from '../enter-frames.js';
9
9
  import { pickGlyphs } from '../glyphs.js';
10
10
  import { profileEndpointSummary, useProfileScroll } from './ModelProfilePicker.js';
11
11
  import { finishProfileEditor } from './model-profile-state.js';
12
- const FIELDS = ['name', 'provider', 'model', 'baseUrl', 'credentialMode', 'apiKey'];
13
- const LABELS = ['Name', 'Provider', 'Model', 'Base URL', 'Credential mode', 'API key'];
12
+ // `Context window` sits right after Model: the window is an attribute of the
13
+ // model+endpoint the two rows above name. 14 characters, inside the 18-column
14
+ // label budget `LABELS.map(...padEnd(18))` enforces below.
15
+ const FIELDS = ['name', 'provider', 'model', 'contextWindow', 'baseUrl', 'credentialMode',
16
+ 'apiKey'];
17
+ const LABELS = ['Name', 'Provider', 'Model', 'Context window', 'Base URL', 'Credential mode',
18
+ 'API key'];
14
19
  function useEditorInput(props, navigation) {
15
20
  const { editor, dispatch } = props;
16
21
  const { index, setIndex, retain, count } = navigation;
@@ -83,8 +88,11 @@ function buildEditorRows(props, navigation) {
83
88
  : editor.credentialMode === 'keep' ? 'Configured (unchanged)' : 'Shared / environment';
84
89
  if (field === 'credentialMode')
85
90
  return editor.credentialMode;
86
- return editor[field] || (field === 'name' ? '(defaults to model)' : field === 'baseUrl'
87
- ? '(provider default)' : '(required)');
91
+ // `(auto)` EXPLICITLY, or an empty window field would fall through to
92
+ // `(required)` - and AUTO is this field's natural state, never a gap.
93
+ return editor[field] || (field === 'name' ? '(defaults to model)'
94
+ : field === 'contextWindow' ? '(auto)' : field === 'baseUrl'
95
+ ? '(provider default)' : '(required)');
88
96
  });
89
97
  const lines = LABELS.map((label, at) => `${label.padEnd(18)}${values[at]}`);
90
98
  if (retain)
@@ -92,6 +100,12 @@ function buildEditorRows(props, navigation) {
92
100
  lines.push('Apply to settings draft', 'Cancel editing');
93
101
  const rows = lines.map((line, at) => _jsxs(Text, { wrap: "truncate", color: at === index ? props.theme.accent : props.theme.muted, children: [at === index ? `${glyphs.caret} ` : ' ', line] }, at));
94
102
  rows.push(_jsxs(Text, { wrap: "truncate", color: props.theme.muted, children: ["Target: ", editor.provider, " ", profileEndpointSummary(editor.baseUrl)] }, "target"));
103
+ // HONESTY FOR THE FAST-ONLY ENTRY (§6.5): a window saved on a profile that
104
+ // only ever serves the Fast role does not take effect today, and saying so
105
+ // beats letting the user believe it does.
106
+ if (props.users === 'Fast') {
107
+ rows.push(_jsx(Text, { wrap: "truncate", color: props.theme.muted, children: "Window applies when this profile is bound as Main." }, "window-scope"));
108
+ }
95
109
  return rows;
96
110
  }
97
111
  /** Replacement key input starts empty; retention across targets is explicit. */
@@ -1,7 +1,8 @@
1
1
  import React from 'react';
2
- import type { ModelProfile, ModelProfilesConfig, ModelRole } from '../../config/model-profiles.js';
2
+ import type { ModelProfile, ModelProfilesConfig } from '../../config/model-profiles.js';
3
3
  import type { Theme } from '../theme.js';
4
4
  import type { TermCapabilities } from '../capabilities.js';
5
+ import { type ProfilePickerRole } from './model-profile-state.js';
5
6
  export interface ProfileFrameProps {
6
7
  isActive?: boolean;
7
8
  theme: Theme;
@@ -19,11 +20,22 @@ export declare function profileEndpointSummary(baseUrl: string | null): string;
19
20
  interface ModelProfilePickerProps extends ProfileFrameProps {
20
21
  profiles: ModelProfilesConfig;
21
22
  savedProfiles?: ModelProfilesConfig;
22
- role: ModelRole;
23
+ /** Which role this page selects for; `'subagent'` swaps the built-in rows (D-5). */
24
+ role: ProfilePickerRole;
23
25
  manager?: boolean;
24
26
  error?: string;
25
27
  apiKeys: Record<string, string | undefined>;
26
28
  customProfile?: ModelProfile;
29
+ /**
30
+ * The selection id for a role the library has no `${role}Id` key for: the
31
+ * subagent projection (`Same as main` = null, fast = the sentinel, profile
32
+ * = its id). Takes precedence over the `${role}Id` lookup when present.
33
+ */
34
+ selectedIdOverride?: string | null;
35
+ /** The subagent binding, for the reference suffix on profile labels (3.7). */
36
+ subagentProfileId?: string | null;
37
+ /** Whether to annotate the built-in `Fast tier model` row with `(fast tier off)`. */
38
+ fastTierOff?: boolean;
27
39
  onSelect: (id: string | null) => void;
28
40
  onCreate: () => void;
29
41
  onEdit: (profile: ModelProfile) => void;
@@ -6,7 +6,7 @@ import { pickGlyphs } from '../glyphs.js';
6
6
  import { OverlayFrame, overlayBodyRows } from '../layout/OverlayFrame.js';
7
7
  import { stripPasteFrames } from '../paste-frames.js';
8
8
  import { stripEnterFrames } from '../enter-frames.js';
9
- import { profileUsers } from './model-profile-state.js';
9
+ import { profileUsers, SUBAGENT_FAST_ROW_ID } from './model-profile-state.js';
10
10
  /** Move focus into view while accepting App's independent wheel/page scrolling. */
11
11
  export function useProfileScroll(props, focus) {
12
12
  const [offset, setOffset] = useState(0);
@@ -62,7 +62,9 @@ function usePickerState(props) {
62
62
  const entries = props.profiles.entries.filter((entry) => `${entry.name} ${entry.provider} ${entry.model}`.toLocaleLowerCase()
63
63
  .includes(query.toLocaleLowerCase()));
64
64
  const actions = props.manager ? ['New', 'Edit', 'Duplicate', 'Delete'] : ['Create profile'];
65
- const prefix = props.manager ? 0 : 1;
65
+ // THE SUBAGENT PICKER swaps the `Current custom` row for TWO built-in rows:
66
+ // `Same as main` (selection id null) and `Fast tier model` (the sentinel).
67
+ const prefix = props.role === 'subagent' ? 2 : props.manager ? 0 : 1;
66
68
  const count = entries.length + prefix + actions.length;
67
69
  const cursor = Math.min(index, count - 1);
68
70
  const focused = entries[cursor - prefix];
@@ -76,7 +78,18 @@ function usePickerState(props) {
76
78
  function usePickerInput(props, state) {
77
79
  const { cursor, focused, selected, actions, prefix, entries, count, searching, searchable, setIndex, setQuery, setSearching } = state;
78
80
  function activate() {
79
- if (!props.manager && cursor === 0) {
81
+ if (props.role === 'subagent') {
82
+ // Built-in rows first: `Same as main`, then `Fast tier model`.
83
+ if (cursor === 0) {
84
+ props.onSelect(null);
85
+ return;
86
+ }
87
+ if (cursor === 1) {
88
+ props.onSelect(SUBAGENT_FAST_ROW_ID);
89
+ return;
90
+ }
91
+ }
92
+ else if (!props.manager && cursor === 0) {
80
93
  props.onSelect(null);
81
94
  return;
82
95
  }
@@ -138,11 +151,26 @@ function usePickerInput(props, state) {
138
151
  else if (!key.ctrl && !key.meta && input.toLowerCase() === 'e' && focused) {
139
152
  props.onEdit(focused);
140
153
  }
154
+ // `d` deletes the focused entry (D-7), symmetric with `e`: same guards,
155
+ // same no-ops on the built-in and action rows (`focused` is undefined
156
+ // there), and the `searching` early-return above keeps `d` a query
157
+ // character while typing a search.
158
+ else if (!key.ctrl && !key.meta && input.toLowerCase() === 'd' && focused) {
159
+ props.onDelete(focused.id);
160
+ }
141
161
  });
142
162
  }
163
+ /** The selection id this page marks with `[Selected]`, by role (6.2). */
164
+ function currentSelectionId(props) {
165
+ if (props.selectedIdOverride !== undefined)
166
+ return props.selectedIdOverride;
167
+ if (props.role === 'subagent')
168
+ return undefined;
169
+ return props.profiles[`${props.role}Id`];
170
+ }
143
171
  function profilePickerLabel(props, entry) {
144
- const saved = profileUsers(props.savedProfiles, entry.id);
145
- const checked = props.profiles[`${props.role}Id`] === entry.id ? '[Selected] ' : '';
172
+ const saved = profileUsers(props.savedProfiles, entry.id, props.subagentProfileId);
173
+ const checked = currentSelectionId(props) === entry.id ? '[Selected] ' : '';
146
174
  const duplicate = props.profiles.entries.some((other) => other.id !== entry.id
147
175
  && other.name === entry.name);
148
176
  const identity = duplicate ? `#${uniqueId(entry, props.profiles.entries)} ` : '';
@@ -154,18 +182,30 @@ function buildPickerRows(props, state) {
154
182
  const { entries, actions, cursor, searching, searchable, query, focused, selected } = state;
155
183
  const glyphs = pickGlyphs(props.caps);
156
184
  const lines = [];
157
- if (!props.manager)
185
+ if (props.role === 'subagent') {
186
+ // TWO BUILT-IN ROWS (D-5), marked with the same `[Selected]` prefix the
187
+ // profile rows use; the fast row annotates an unusable tier honestly.
188
+ const current = props.selectedIdOverride ?? null;
189
+ lines.push(`${current === null ? '[Selected] ' : ''}Same as main`);
190
+ lines.push(`${current === SUBAGENT_FAST_ROW_ID ? '[Selected] ' : ''}Fast tier model`
191
+ + `${props.fastTierOff === true ? ' (fast tier off)' : ''}`);
192
+ }
193
+ else if (!props.manager)
158
194
  lines.push('Current custom');
159
195
  lines.push(...entries.map((entry) => profilePickerLabel(props, entry)));
160
196
  lines.push(...actions);
161
197
  const rows = lines.map((line, row) => _jsxs(Text, { wrap: "truncate", color: row === cursor ? props.theme.accent : props.theme.muted, children: [`${row === cursor && !searching ? glyphs.caret : ' '} `, clipProfileText(line, props.cols - 8)] }, row));
162
198
  if (searchable)
163
199
  rows.unshift(_jsxs(Text, { wrap: "truncate", color: props.theme.primary, children: [searching ? `${glyphs.caret} ` : ' ', "Search: ", query || '(/ to search)'] }, "search"));
164
- const detail = !props.manager && cursor === 0 ? props.customProfile : focused ?? selected;
200
+ // The subagent picker has no `Current custom` row to describe; its
201
+ // built-in rows already say everything there is to say.
202
+ const detail = props.role === 'subagent' ? focused
203
+ : !props.manager && cursor === 0 ? props.customProfile : focused ?? selected;
165
204
  if (detail) {
166
205
  rows.push(_jsxs(Text, { wrap: "truncate", color: props.theme.muted, children: [detail.provider, ":", detail.model || '(not set)', " ", host(detail), detail === props.customProfile ? '' : ` #${detail.id}`] }, "detail"));
167
206
  rows.push(_jsx(Text, { wrap: "truncate", color: props.theme.muted, children: detail.apiKey ? 'Profile key: Configured'
168
207
  : props.apiKeys[detail.provider] ? 'Shared / environment: Configured' : 'Missing API key' }, "key"));
208
+ rows.push(_jsx(Text, { wrap: "truncate", color: props.theme.muted, children: `Window: ${detail.contextWindow ?? 'auto'}` }, "window"));
169
209
  }
170
210
  return rows;
171
211
  }
@@ -174,7 +214,8 @@ export function ModelProfilePicker(props) {
174
214
  const state = usePickerState(props);
175
215
  usePickerInput(props, state);
176
216
  return _jsx(OverlayFrame, { ...props, title: props.error ?? (props.manager ? 'Manage profiles'
177
- : `${props.role === 'main' ? 'Main' : 'Fast'} profile`), rows: buildPickerRows(props, state), scrollOffset: state.searching ? 0 : state.offset, hint: state.searching ? 'Type search | Enter done | Esc back'
178
- : props.manager ? 'Enter action | e edit | Esc back'
179
- : 'Enter select | e edit | Esc back' });
217
+ : props.role === 'subagent' ? 'Subagent model'
218
+ : `${props.role === 'main' ? 'Main' : 'Fast'} profile`), rows: buildPickerRows(props, state), scrollOffset: state.searching ? 0 : state.offset, hint: state.searching ? 'Type search | Enter done | Esc back'
219
+ : props.manager ? 'Enter action | e edit | d delete | Esc back'
220
+ : 'Enter select | e edit | d delete | Esc back' });
180
221
  }
@@ -38,6 +38,21 @@ export function QuestionOverlay({ isActive = true, questions, maxRows, cols, the
38
38
  const [phase, setPhase] = useState('choosing');
39
39
  const [cursors, setCursors] = useState(() => initialCursors(questions));
40
40
  const [selections, setSelections] = useState(() => initialSelections(questions));
41
+ /**
42
+ * THE PER-QUESTION EXPLICIT-MARK BIT (V-1 / D-8).
43
+ *
44
+ * `initialSelections` PRE-SELECTS the recommended option on EVERY question
45
+ * (or the first option when there is none), so "the selection set is empty"
46
+ * is unreachable and can never be the test for "did the user choose?". The
47
+ * only reliable discriminator is the fact itself: the user pressed SPACE on
48
+ * this question. Without it, a user who only MOVES the cursor and presses
49
+ * Enter would submit the RECOMMENDED option instead of the highlighted one -
50
+ * a silent regression of the wizard's most common path. The bit survives
51
+ * Left/Right navigation exactly like `selections` and `cursors` do, and a
52
+ * single-choice mark is idempotent (no un-mark; Enter always has at least
53
+ * the marked item to submit).
54
+ */
55
+ const [markedSteps, setMarkedSteps] = useState(() => questions.map(() => false));
41
56
  const [customs, setCustoms] = useState(() => questions.map(() => ''));
42
57
  const [draft, setDraft] = useState('');
43
58
  const [offset, setOffset] = useState(0);
@@ -56,6 +71,7 @@ export function QuestionOverlay({ isActive = true, questions, maxRows, cols, the
56
71
  return next;
57
72
  }));
58
73
  const chooseAt = (index, option) => setSelections((prev) => prev.map((set, i) => (i === index ? new Set([option]) : set)));
74
+ const markAt = (index) => setMarkedSteps((prev) => prev.map((marked, i) => (i === index ? true : marked)));
59
75
  const buildAnswers = () => questions.map((q, i) => {
60
76
  const chosen = [...(selections[i] ?? new Set())].sort((a, b) => a - b);
61
77
  const labels = chosen.map((index) => q.options[index]?.label ?? '').filter((l) => l.length > 0);
@@ -159,8 +175,21 @@ export function QuestionOverlay({ isActive = true, questions, maxRows, cols, the
159
175
  goBack();
160
176
  return;
161
177
  }
162
- if (input === ' ' && question.allowMultiple) {
163
- toggleAt(step, cursor);
178
+ // SPACE, BY QUESTION SHAPE (D-8). The strict `input === ' '` keeps a
179
+ // multi-character paste frame from ever matching (R-8); the Other row is
180
+ // a no-op in both shapes because it needs TEXT, and Enter is its key.
181
+ if (input === ' ' && !question.options[cursor]?.isOther) {
182
+ if (question.allowMultiple) {
183
+ toggleAt(step, cursor);
184
+ return;
185
+ }
186
+ // SINGLE CHOICE: mark the highlighted item and STAY - the terminal
187
+ // convention of "space selects, enter confirms". The mark is recorded
188
+ // both in the selection set (so `[x]` renders) and in the explicit
189
+ // mark bit (so a later Enter submits THIS item even after the cursor
190
+ // has moved on).
191
+ chooseAt(step, cursor);
192
+ markAt(step);
164
193
  return;
165
194
  }
166
195
  if (key.return) {
@@ -170,8 +199,15 @@ export function QuestionOverlay({ isActive = true, questions, maxRows, cols, the
170
199
  setPhase('typingOther');
171
200
  return;
172
201
  }
173
- if (!question.allowMultiple)
174
- chooseAt(step, cursor);
202
+ // A single-choice Enter submits the CURSOR item unless the user has
203
+ // explicitly space-marked this question, in which case the marked item
204
+ // wins even if the cursor has since moved (V-1's regression guard:
205
+ // cursor + Enter alone keeps today's behavior, recommended preselect
206
+ // and all).
207
+ if (!question.allowMultiple) {
208
+ if (!markedSteps[step])
209
+ chooseAt(step, cursor);
210
+ }
175
211
  else if (!(selections[step] ?? new Set()).has(cursor))
176
212
  toggleAt(step, cursor);
177
213
  advance();
@@ -183,7 +219,9 @@ export function QuestionOverlay({ isActive = true, questions, maxRows, cols, the
183
219
  ? `type your answer ${glyphs.midDot} ${glyphs.enterKey} commit ${glyphs.midDot} ${glyphs.arrowLeft} back ${glyphs.midDot} esc cancel`
184
220
  : phase === 'review'
185
221
  ? `${glyphs.enterKey} submit ${glyphs.midDot} ${glyphs.arrowLeft} back ${glyphs.midDot} esc cancel`
186
- : `${glyphs.arrowUp}${glyphs.arrowDown} choose ${glyphs.midDot} ${glyphs.enterKey} next${question.allowMultiple ? ` ${glyphs.midDot} space toggle` : ''} ${glyphs.midDot} ${glyphs.arrowLeft} back ${glyphs.midDot} esc cancel`;
222
+ : question.options[cursor]?.isOther
223
+ ? `${glyphs.arrowUp}${glyphs.arrowDown} choose ${glyphs.midDot} ${glyphs.enterKey} to type ${glyphs.midDot} ${glyphs.arrowLeft} back ${glyphs.midDot} esc cancel`
224
+ : `${glyphs.arrowUp}${glyphs.arrowDown} choose ${glyphs.midDot} space select ${glyphs.midDot} ${glyphs.enterKey} next ${glyphs.midDot} ${glyphs.arrowLeft} back ${glyphs.midDot} esc cancel`;
187
225
  const rowTexts = [];
188
226
  const optionStarts = [];
189
227
  const push = (text) => { rowTexts.push(...wrapToRows(text, Math.max(1, cols - 4))); };
@@ -208,8 +246,11 @@ export function QuestionOverlay({ isActive = true, questions, maxRows, cols, the
208
246
  setOffset(previous => focus < previous ? focus : focus >= previous + height
209
247
  ? focus - height + 1 : previous);
210
248
  }, [focus, maxRows, cols, step, phase]);
249
+ // `space select` shows for BOTH shapes (AC-10) - 42 characters on the
250
+ // single-choice shape without `cancel`, a deliberate 5-column growth inside
251
+ // the `cols <= 48` budget this hint exists for (V-12).
211
252
  const shortHint = phase === 'typingOther' ? 'Enter commit | Left back | Esc cancel'
212
253
  : phase === 'review' ? 'Enter submit | Left back | Esc cancel'
213
- : question.allowMultiple ? '^v choose Space toggle Enter next Esc' : 'Up/Down choose Enter next Esc cancel';
254
+ : '^v choose Space select Enter next Esc';
214
255
  return _jsx(OverlayFrame, { title: `${title}${position}`, hint: cols <= 48 ? shortHint : hint, maxRows: maxRows, cols: cols, theme: theme, caps: caps, rows: rowTexts.map((line, index) => _jsx(Text, { wrap: "truncate", children: line || ' ' }, index)), scrollOffset: offset, onScrollClamp: setOffset });
215
256
  }
@@ -36,6 +36,15 @@ export interface SettingsValues {
36
36
  */
37
37
  liveToolOutput: 'on' | 'off';
38
38
  maxTokens: string;
39
+ /**
40
+ * The top-level context-window override, as text (profile-context-window
41
+ * §6.3). EDITED ONLY WHILE MAIN IS UNBOUND: once a profile is bound as Main
42
+ * the profile owns the denominator, this row hides, and the editor becomes
43
+ * the only surface that writes the key - two entries on one key can only
44
+ * drift. Empty renders as `auto` because an empty field IS AUTO here, the
45
+ * reading `maxTokens` established.
46
+ */
47
+ contextWindow: string;
39
48
  apiKey: string;
40
49
  /**
41
50
  * Only the LEVEL is editable here. The rest of `LogConfig` — and above all