aegiscode 6.3.0 → 6.3.2

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.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "aegiscode",
3
3
  "productName": "AEGIS Code",
4
- "version": "6.3.0",
4
+ "version": "6.3.2",
5
5
  "description": "aegiscode — the command-line version of AEGIS Desktop. The shared tool surface in your shell, over the same thin transport and tool registry as the MCP plugin and the desktop app. Ships transport + UI only; no brain.",
6
6
  "author": {
7
7
  "name": "AEGIS Code",
package/src/app.js CHANGED
@@ -27,6 +27,7 @@ const { GLYPH, VERBS, themeOf, RESET, THEME_TABLE } = require('./theme.js');
27
27
  const { LiveRegion, termWidth, w } = require('./screen.js');
28
28
  const { parseLine, COMMANDS, visibleCommands } = require('./commands.js');
29
29
  const { updateConfig, loadPermissions, loadConfig, configExists } = require('./config.js');
30
+ const { normalizeModelCatalog, pickerEntries, catalogIds } = require('./models.js');
30
31
  const { appendHistory, readSessionTranscript, readOwnSessions } = require('./history.js');
31
32
  const { snapshotCheckpoint } = require('./checkpoint.js');
32
33
  const screens = require('./screens.js');
@@ -86,7 +87,11 @@ function createApp(options = {}) {
86
87
  const commandCtx = {
87
88
  light: opts.light,
88
89
  model: opts.model,
89
- effort: 'high',
90
+ // `null` = "auto": send no effort, so the server sizes the turn from the
91
+ // ask. This is the *budget* control on the pooled class (aegis1 sizes the
92
+ // token ladder from it), which is why it is not defaulted to a rung here —
93
+ // pinning one would make every turn cost what that rung grants.
94
+ effort: null,
90
95
  thinking: false,
91
96
  themeIndex: opts.light ? 2 : 1,
92
97
  vim: false,
@@ -146,6 +151,71 @@ function createApp(options = {}) {
146
151
  }
147
152
  }
148
153
 
154
+ // ── the AEGIS Cloud model catalog ──────────────────────────────────────────
155
+ //
156
+ // `/model` (and the alt+p chord that dispatches it) reads the pinnable ids
157
+ // from the server, and `state().models` is where it looks. That path had no
158
+ // source at all: `commands.js` calls `c.loadModels()` inside a `try {} catch
159
+ // {}`, `c.loadModels` was never defined on either command context, and
160
+ // `buildState()` hardcoded `models: []` — so the TypeError was swallowed and
161
+ // both `/model` and the picker reported "no models advertised" on a perfectly
162
+ // healthy account, forever. The ids themselves are the server's (see
163
+ // models.js), fetched here and cached, because the pool adds and retires
164
+ // providers without a client release.
165
+ const MODEL_CACHE_MS = 5 * 60_000;
166
+ const modelCache = { at: 0, models: [] };
167
+
168
+ /**
169
+ * Fetch (or return the cached) model catalog. Rejects when the account cannot
170
+ * read it at all — no key, offline, or a server error — which callers treat as
171
+ * "nothing to offer" rather than retrying per keystroke.
172
+ * @returns {Promise<Array<{id:string,label:string,note:string}>>}
173
+ */
174
+ async function loadModels({ force = false } = {}) {
175
+ const fresh = modelCache.models.length && Date.now() - modelCache.at < MODEL_CACHE_MS;
176
+ if (!force && fresh) return modelCache.models;
177
+ const data = await client.listModels();
178
+ modelCache.models = normalizeModelCatalog(data && data.models);
179
+ modelCache.at = Date.now();
180
+ return modelCache.models;
181
+ }
182
+
183
+ /**
184
+ * A pinned model id the server does not advertise is a *silent* fallback: the
185
+ * pool answers from its own default with no error, so the pin looks honoured
186
+ * while the reply came from another model — and the cost is attributed to the
187
+ * model that was pinned. Two shipped defaults did exactly that: `sonnet`
188
+ * (written into config.json by onboarding, merged in from DEFAULT_CONFIG, and
189
+ * never advertised by AEGIS Cloud) and any `provider/model` spelling a user
190
+ * typed by hand. Clears such a pin once, says so, and leaves the server's own
191
+ * default in its place.
192
+ *
193
+ * Best-effort and offline-safe: a catalog that cannot be read clears nothing.
194
+ */
195
+ async function validatePinnedModel() {
196
+ const pinned = commandCtx.model;
197
+ if (!pinned) return null;
198
+ let models;
199
+ try {
200
+ models = await loadModels();
201
+ } catch {
202
+ return null;
203
+ }
204
+ if (!models.length) return null;
205
+ if (catalogIds(models).has(String(pinned).toLowerCase())) return null;
206
+ commandCtx.model = null;
207
+ updateConfig({ model: null, currentModelId: null });
208
+ emit(
209
+ render.renderNotice(
210
+ ctx(),
211
+ 'warn',
212
+ `pinned model "${pinned}" is not advertised by AEGIS Cloud — pin cleared, ` +
213
+ 'the pool will choose; /models lists what you can pin.'
214
+ )
215
+ );
216
+ return pinned;
217
+ }
218
+
149
219
  function bannerLines() {
150
220
  return render.renderBanner(ctx(), {
151
221
  width: width(),
@@ -305,6 +375,14 @@ function createApp(options = {}) {
305
375
  model: commandCtx.model || undefined,
306
376
  system: opts.system,
307
377
  maxTokens: opts.maxTokens,
378
+ // `effort` is the pooled budget control, and it travels on every turn
379
+ // — this host only ever runs the 'aegis' class, whose calls are sized
380
+ // server-side from it (aegis1 services/pool_brain.py). It used to be
381
+ // held in the session state and rendered in the status line without
382
+ // ever reaching the wire, so /effort changed the display and nothing
383
+ // about what the turn cost. `null` (auto) is omitted so the server
384
+ // infers the rung from the ask.
385
+ effort: commandCtx.effort || undefined,
308
386
  // false asks the engine for the buffered (non-stream) wire form, so
309
387
  // `--no-stream` and piped runs get a single body rather than SSE.
310
388
  stream: commandCtx.stream !== false,
@@ -517,7 +595,9 @@ function createApp(options = {}) {
517
595
  plan: session.plan || null,
518
596
  account: session.account || null,
519
597
  permissions: { mode: rules.defaultMode, rules },
520
- models: [],
598
+ // The selectable (pickable) catalog, not the raw payload: alias tiers are
599
+ // dropped, live ids only (see models.js pickerEntries).
600
+ models: pickerEntries(modelCache.models),
521
601
  commands: visibleCommands(),
522
602
  transcript: transcript.slice(),
523
603
  sessions: [],
@@ -633,6 +713,10 @@ function createApp(options = {}) {
633
713
  runPrompt: (text) => runPrompt(text),
634
714
  ask: (text) => ask(text),
635
715
  runTool: (name, args) => runTool(name, args),
716
+ // The AEGIS catalog fetch `/model` and alt+p expect (see loadModels).
717
+ // Wired on the app's context so the chatflow's `Object.assign`-based
718
+ // context inherits it too — one definition, both hosts.
719
+ loadModels: (o) => loadModels(o),
636
720
  refreshSpend: () => refreshSpend(),
637
721
  state: () => buildState(),
638
722
  setInput: () => {},
@@ -916,6 +1000,7 @@ function createApp(options = {}) {
916
1000
  TOOLS,
917
1001
  ask: (prompt, o) => ask(prompt, o),
918
1002
  makeCommandContext: () => makeCommandContext(),
1003
+ loadModels: (o) => loadModels(o),
919
1004
  buildState: () => buildState(),
920
1005
  dispatchLine: (line, c) => handleLine(line, c),
921
1006
  refreshSpend: () => refreshSpend(),
@@ -1000,6 +1085,8 @@ function createApp(options = {}) {
1000
1085
  return;
1001
1086
  }
1002
1087
  if (!opts.model && cfg.model) commandCtx.model = cfg.model;
1088
+ // `null` in the config is "auto" and is deliberately not assigned over the
1089
+ // default — there is nothing to restore, because nothing is pinned.
1003
1090
  if (cfg.effort) commandCtx.effort = cfg.effort;
1004
1091
  if (typeof cfg.vim === 'boolean') commandCtx.vim = cfg.vim;
1005
1092
  if (cfg.lastRecap) commandCtx.lastRecap = cfg.lastRecap;
@@ -1037,6 +1124,11 @@ function createApp(options = {}) {
1037
1124
  save: (patch) => updateConfig(patch),
1038
1125
  });
1039
1126
  if (!onboard.ok) return 0;
1127
+ // A stored pin the platform does not advertise routes elsewhere in
1128
+ // silence (see validatePinnedModel) — checked once, here, where the user
1129
+ // can act on it. Not on `-p`: a network round-trip ahead of the first
1130
+ // token would be a startup cost bought for a warning no script watches.
1131
+ await validatePinnedModel();
1040
1132
  // `--continue` must load the last session *before* the loop starts, and
1041
1133
  // it has to be read here rather than captured at construction: the
1042
1134
  // history file is written by the loop itself.
@@ -1071,6 +1163,8 @@ function createApp(options = {}) {
1071
1163
  refreshSpend,
1072
1164
  bannerLines,
1073
1165
  makeHost,
1166
+ loadModels,
1167
+ validatePinnedModel,
1074
1168
  recordTurn,
1075
1169
  persistTurn,
1076
1170
  restorePrefs,
package/src/chatflow.js CHANGED
@@ -164,7 +164,7 @@ const separatorLine = (cols, ctx) => [span(themeOf(ctx).dim, '─'.repeat(cols))
164
164
  /** The idle bottom-left line: which effort level the next turn runs at. */
165
165
  function effortLine(ctx, cols) {
166
166
  const t = themeOf(ctx);
167
- const txt = `● ${ctx.effort || 'high'} · /effort`;
167
+ const txt = `● ${ctx.effort || 'auto'} · /effort`;
168
168
  const pad = ' '.repeat(Math.max(0, cols - [...txt].length - 4));
169
169
  return [span(t.gray, pad + txt)];
170
170
  }
@@ -1111,12 +1111,15 @@ async function runSession(host) {
1111
1111
  return;
1112
1112
  }
1113
1113
  if (type === 'effort') {
1114
- const levels = ['low', 'medium', 'high'];
1115
- const chosen = levels[overlay.sel || 0];
1114
+ // The picker's own order, not a second copy of the level names: this
1115
+ // used to re-declare ['low','medium','high'] here while the picker drew
1116
+ // its own table, so the row a user picked and the value it stored were
1117
+ // resolved through different lists.
1118
+ const chosen = overlays.EFFORT_VALUES[overlay.sel || 0];
1116
1119
  overlay = null;
1117
1120
  ctx.effort = chosen;
1118
1121
  host.updateConfig({ effort: chosen });
1119
- note(`effort: ${chosen}`);
1122
+ note(`effort: ${chosen || 'auto'}`);
1120
1123
  render();
1121
1124
  return;
1122
1125
  }
package/src/commands.js CHANGED
@@ -46,6 +46,7 @@ const { execSync } = require('node:child_process');
46
46
  const { span } = require('./screen.js');
47
47
  const { C, BOLD, BOLD_OFF } = require('./theme.js');
48
48
  const panels = require('./panels.js');
49
+ const overlays = require('./overlays.js');
49
50
  const {
50
51
  updateConfig, loadConfig, loadPermissions, savePermissions, addPermissionRule,
51
52
  DEFAULT_PERMISSIONS, permissionsPath, configPath,
@@ -93,7 +94,13 @@ const CATEGORIES = [
93
94
 
94
95
  const categoryLabel = (id) => (CATEGORIES.find((cat) => cat.id === id) || {}).label || id;
95
96
 
96
- const EFFORT_LEVELS = ['low', 'medium', 'high'];
97
+ // One table, shared with the picker that draws it. The two used to be separate
98
+ // lists — this one a string array, chatflow.js's own copy of the same three
99
+ // strings, and overlays.js's label/note rows — so adding a level meant editing
100
+ // three places, and the index a picker row reported was mapped back to a value
101
+ // through whichever list happened to be in scope. EFFORT_VALUES is the picker's
102
+ // own order, `null` first for "auto".
103
+ const EFFORT_LEVELS = overlays.EFFORT_VALUES;
97
104
 
98
105
  // ── Small handler helpers ─────────────────────────────────────────────────────
99
106
 
@@ -348,12 +355,12 @@ const COMMANDS = [
348
355
  },
349
356
  },
350
357
  {
351
- name: 'effort', args: ['level'], hint: '[low|medium|high]', category: 'model',
352
- desc: 'Set effort level for model usage',
358
+ name: 'effort', args: ['level'], hint: '[auto|low|medium|high]', category: 'model',
359
+ desc: 'Set the token budget the pool sizes each turn from',
353
360
  handler: async (c, args) => {
354
361
  const level = (args.level || '').toLowerCase();
355
- if (level && !EFFORT_LEVELS.includes(level)) {
356
- note(c, `Unknown effort level "${args.level}". Use ${EFFORT_LEVELS.join(', ')}.`);
362
+ if (level && level !== 'auto' && !EFFORT_LEVELS.includes(level)) {
363
+ note(c, `Unknown effort level "${args.level}". Use auto, ${EFFORT_LEVELS.filter(Boolean).join(', ')}.`);
357
364
  c.render();
358
365
  return true;
359
366
  }
@@ -361,9 +368,14 @@ const COMMANDS = [
361
368
  c.openOverlay({ type: 'effort', sel: Math.max(0, EFFORT_LEVELS.indexOf(c.ctx.effort)) });
362
369
  return true;
363
370
  }
364
- c.ctx.effort = level;
365
- c.saveConfig({ effort: level });
366
- note(c, `Effort level: ${level}`);
371
+ // 'auto' is the absence of a pin, not a fourth rung: it is stored as null
372
+ // so nothing is sent and the server sizes the turn from the ask.
373
+ const pinned = level === 'auto' ? null : level;
374
+ c.ctx.effort = pinned;
375
+ c.saveConfig({ effort: pinned });
376
+ note(c, pinned
377
+ ? `Effort level: ${pinned} — the pool sizes this turn's token budget from it`
378
+ : 'Effort level: auto — the pool sizes each turn from the ask');
367
379
  c.render();
368
380
  return true;
369
381
  },
@@ -478,10 +490,25 @@ const COMMANDS = [
478
490
  await loadModels(c);
479
491
  const models = c.state().models || [];
480
492
  if (!models.length) {
481
- note(c, 'No pinnable models advertised /models lists what the server advertises.');
493
+ // Say why, and what unblocks it: an unreachable catalog is almost
494
+ // always a missing key or no network, and "no models advertised"
495
+ // read as "the platform has none" rather than "this client could not
496
+ // ask".
497
+ note(c, c.state().online
498
+ ? 'Could not read the model catalog (offline, or the server refused it) — /models retries.'
499
+ : // The key travels in the environment only (client/aegis.js reads
500
+ // AEGIS_API_KEY); /login is an unavailable command here, so
501
+ // pointing at it would send the user to a refusal.
502
+ 'No API key set, so the model catalog cannot be read — export AEGIS_API_KEY (free at https://aegiscloud.org), then retry /model.');
482
503
  c.render();
483
504
  return true;
484
505
  }
506
+ // A pin that is not in the catalog is not honoured — the pool answers
507
+ // from its own default with no error. Say so where the pin is visible
508
+ // rather than letting the reply look like the pinned model.
509
+ if (c.ctx.model && !models.some((m) => m.id === c.ctx.model)) {
510
+ note(c, `pinned model "${c.ctx.model}" is not in the catalog — the pool will answer with its own default; pick one below.`);
511
+ }
485
512
  // The overlay's own copy promises /model add|remove (overlays.js, a
486
513
  // separate workstream); this build refuses both, so say here how a
487
514
  // model is actually selected.
@@ -516,6 +543,16 @@ const COMMANDS = [
516
543
  // dir, so the write stays inside that dir.
517
544
  c.saveConfig({ model: id, currentModelId: id });
518
545
  note(c, `Pinned model: ${id}`);
546
+ // The server accepts an id it does not advertise and answers from its own
547
+ // default — no error, a different model, and the spend attributed to the
548
+ // id that was pinned. Warn (never refuse: the catalog is cached, and
549
+ // refusing would break a pin made against a server that is briefly
550
+ // unreachable) so the mismatch is visible at the moment it is created.
551
+ await loadModels(c);
552
+ const models = c.state().models || [];
553
+ if (models.length && !models.some((m) => m.id === id)) {
554
+ note(c, `"${id}" is not in the AEGIS Cloud catalog — the pool will answer with its own default. /models lists the real ids.`);
555
+ }
519
556
  c.render();
520
557
  return true;
521
558
  },
package/src/config.js CHANGED
@@ -43,13 +43,28 @@ function permissionsPath() {
43
43
 
44
44
  const DEFAULT_CONFIG = {
45
45
  themeIndex: 1, // Dark mode
46
- model: 'sonnet',
46
+ // No pinned model. This host runs on AEGIS Cloud, whose pinnable ids are the
47
+ // server's (`/models`) — a client-side default here would have to name one,
48
+ // and the one it named (`sonnet`) is not advertised by the platform at all:
49
+ // the pool accepts an unknown id and answers from its own default with no
50
+ // error, so the pin looked honoured while the reply came from another model.
51
+ // Worse, onboarding persists this object on first run (`updateConfig` merges
52
+ // DEFAULT_CONFIG under the patch), so the phantom pin was written to disk for
53
+ // every user who ever completed the trust check. `null` = no pin; the server
54
+ // chooses, and app.js's validatePinnedModel() clears a stored id the catalog
55
+ // does not advertise.
56
+ model: null,
47
57
  // Phase 6: the full model table (seeded from src/models.js MODELS on first
48
58
  // read by pickerModels()). 'currentModelId' mirrors `model` under the
49
59
  // aegiscode- name so /model add/remove/switch stay compatible both ways.
50
60
  models: null,
51
61
  currentModelId: null,
52
- effort: 'high',
62
+ // `null` = no effort pinned, i.e. "auto": each turn is sized by the server
63
+ // from the ask itself (aegis1 services/pool_brain.py estimate_effort). The
64
+ // old 'high' was a *pin* on the top rung of the budget ladder — the most the
65
+ // server can grant — so the CLI's default turn was the most expensive one it
66
+ // could make, and /effort could only ever move it down.
67
+ effort: null,
53
68
  vim: false,
54
69
  lastCwd: '',
55
70
  };
package/src/models.js ADDED
@@ -0,0 +1,123 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * AEGIS Cloud model catalog shaping — the one place `/model`, the alt+p picker
5
+ * and `/models` agree on what a pinnable model is.
6
+ *
7
+ * The ids a user may pin are the *server's*, never ours: the pool adds, renames
8
+ * and retires providers without a client release, so the catalog is fetched
9
+ * (`client.listModels()` → GET /api/v1/models on aegiscloud.org) and this module
10
+ * only normalises the payload. It deliberately invents no id, and has no
11
+ * fallback list: an offline client offers nothing rather than a model name that
12
+ * would silently route somewhere else.
13
+ *
14
+ * The platform lists per-provider ids (`deepseek`, `anthropic`, `groq`, …) plus
15
+ * the pooled-brain tiers. The desktop host collapsed all of that to a single
16
+ * "Nexus" entry because its dropdown *is* the model choice and surfacing
17
+ * per-provider routing invites pinning a provider that happens to be dead right
18
+ * now (see desktop/lib/local/engine.js selectBrainEntry). This host keeps every
19
+ * distinct id — a CLI user pinning `deepseek` explicitly is a legitimate
20
+ * request — but drops the pure-alias duplicates the server itself marks
21
+ * `hidden: true, alias_of: "<id>"` (`aegis-brain`, `nexus-brain-smart`, …),
22
+ * which are the same model advertised under another spelling.
23
+ */
24
+
25
+ /** A pinned id of `null` means "no pin — let the pool choose" (the server's own default). */
26
+ const NO_PIN = null;
27
+
28
+ /**
29
+ * Labels for catalog ids the platform advertises without capabilities or a
30
+ * label of their own — `openai-gpt4o-mini` reads like a typo next to `openai`,
31
+ * and the brain tier is the one id whose *cost shape* a user needs before
32
+ * pinning it: verified live 2026-09-14, `model: "nexus-brain"` streams
33
+ * `pool-brain: 3 workers · effort=high · tier=brain · passes=4`, i.e. four
34
+ * billed provider calls per turn, where a provider id is one.
35
+ */
36
+ const ID_NOTES = Object.freeze({
37
+ 'openai-gpt4o-mini': 'OpenAI gpt-4o-mini, pooled',
38
+ 'anthropic-haiku': 'Anthropic Haiku, pooled',
39
+ 'nexus-brain': 'pooled brain · 3 workers + synthesis',
40
+ });
41
+
42
+ /** One raw entry (string id or object) → a catalog entry, or null when unusable. */
43
+ function normalizeModel(entry) {
44
+ const m = typeof entry === 'string' ? { id: entry } : entry;
45
+ if (!m || typeof m !== 'object') return null;
46
+ const id = typeof m.id === 'string' ? m.id.trim() : '';
47
+ if (!id) return null;
48
+ const aliasOf =
49
+ typeof m.alias_of === 'string' && m.alias_of.trim() ? m.alias_of.trim() : null;
50
+ const capabilities = Array.isArray(m.capabilities)
51
+ ? m.capabilities.filter((c) => typeof c === 'string' && c)
52
+ : [];
53
+ const label =
54
+ (typeof m.label === 'string' && m.label.trim()) ||
55
+ (typeof m.name === 'string' && m.name.trim()) ||
56
+ id;
57
+ // The picker's right-hand column: what this entry *is*. An alias says so —
58
+ // otherwise a user reads five brain tiers and assumes five different models.
59
+ const note = aliasOf
60
+ ? `alias of ${aliasOf}`
61
+ : ID_NOTES[id] || capabilities.join(', ');
62
+ return {
63
+ id,
64
+ label,
65
+ note,
66
+ hidden: m.hidden === true,
67
+ aliasOf,
68
+ capabilities,
69
+ };
70
+ }
71
+
72
+ /** Normalise a raw `/api/v1/models` payload (`{models: [...]}` or the array). */
73
+ function normalizeModelCatalog(raw) {
74
+ const list = Array.isArray(raw) ? raw : Array.isArray(raw && raw.models) ? raw.models : [];
75
+ const out = [];
76
+ const seen = new Set();
77
+ for (const entry of list) {
78
+ const m = normalizeModel(entry);
79
+ if (!m || seen.has(m.id)) continue;
80
+ seen.add(m.id);
81
+ out.push(m);
82
+ }
83
+ return out;
84
+ }
85
+
86
+ /**
87
+ * The entries the picker and `/models` offer: every distinct advertised model,
88
+ * minus the alias duplicates — unless the catalog is *only* aliases, in which
89
+ * case the aliases are all the server advertises and are offered rather than
90
+ * leaving the user with an empty list.
91
+ */
92
+ function pickerEntries(catalog) {
93
+ const all = Array.isArray(catalog) ? catalog.filter(Boolean) : [];
94
+ const distinct = all.filter((m) => !m.aliasOf);
95
+ return distinct.length ? distinct : all;
96
+ }
97
+
98
+ /** The catalog entry for `id`, or null — the "is this a real id?" question. */
99
+ function findModel(catalog, id) {
100
+ const want = typeof id === 'string' ? id.trim() : '';
101
+ if (!want) return null;
102
+ return (Array.isArray(catalog) ? catalog : []).find((m) => m && m.id === want) || null;
103
+ }
104
+
105
+ /**
106
+ * The set of ids the server accepts, lower-cased: a catalog id and a pinned id
107
+ * are the same thing only when they match byte-for-byte, but `/model Nexus-Brain`
108
+ * is a typo a user will make and the server's routing is case-insensitive
109
+ * enough that warning about it would be noise.
110
+ */
111
+ function catalogIds(catalog) {
112
+ return new Set((Array.isArray(catalog) ? catalog : []).map((m) => String((m && m.id) || '').toLowerCase()).filter(Boolean));
113
+ }
114
+
115
+ module.exports = {
116
+ NO_PIN,
117
+ ID_NOTES,
118
+ normalizeModel,
119
+ normalizeModelCatalog,
120
+ pickerEntries,
121
+ findModel,
122
+ catalogIds,
123
+ };
package/src/overlays.js CHANGED
@@ -177,27 +177,38 @@ function renderModelPicker(models, sel = 0, width = 80, height = 24, current = n
177
177
  // ── Effort picker overlay (/effort) ──────────────────────────────────────────
178
178
 
179
179
  const EFFORT_LEVELS = [
180
- { label: 'Low', note: 'Fastest, most efficient' },
180
+ { label: 'Auto', value: null, note: 'Sized per turn from the ask (default)' },
181
+ { label: 'Low', note: 'Fastest, cheapest budget' },
181
182
  { label: 'Medium', note: 'Balanced' },
182
- { label: 'High', note: 'Highest quality, slowest' },
183
+ { label: 'High', note: 'Highest budget, slowest' },
183
184
  ];
184
185
 
186
+ // The picker's order reduced to the values a selection means, so the row a user
187
+ // picks and the value the session stores can never come from two different
188
+ // lists (commands.js validates against this, chatflow.js resolves the overlay
189
+ // through it). `null` is "auto" — no rung pinned.
190
+ const EFFORT_VALUES = EFFORT_LEVELS.map((lv) => (lv.value === undefined ? lv.label.toLowerCase() : lv.value));
191
+
185
192
  /**
186
193
  * @param {number} sel selected index
187
194
  * @param {number} width
188
- * @param {string|null} current the currently-selected effort level
195
+ * @param {string|null} current the currently-selected effort level (null = auto)
189
196
  * @param {Array} [levels] override the level table
190
197
  */
191
198
  function renderEffortPicker(sel = 0, width = 80, current = null, levels = EFFORT_LEVELS) {
192
199
  const lines = [];
193
200
  lines.push(blank(width));
194
201
  lines.push([span('', ' '), span(C.white + BOLD, 'Select effort'), span(BOLD_OFF, '')]);
195
- lines.push([span('', ' '), span(C.gray, 'Controls how much reasoning the model puts into each turn.')]);
202
+ lines.push([span('', ' '), span(C.gray, 'Sets the token budget the pool sizes each turn from.')]);
196
203
  lines.push(blank(width));
197
204
  for (let i = 0; i < levels.length; i++) {
198
205
  const lv = levels[i];
199
206
  const active = i === sel;
200
- const cur = current != null && String(current).toLowerCase() === String(lv.label).toLowerCase();
207
+ // `current == null` is auto, which is the first row's own value — matching
208
+ // on the label alone would never mark the default as the current choice.
209
+ const cur = current == null
210
+ ? lv.value === null
211
+ : String(current).toLowerCase() === String(lv.label).toLowerCase();
201
212
  const left = active ? span(C.lavender, GLYPH.cursor) : span('', ' ');
202
213
  const nameSpan = active ? span(C.lavender, lv.label) : span(C.white, lv.label);
203
214
  const mark = cur ? span(C.green, ' ' + GLYPH.check) : span('', '');
@@ -283,4 +294,6 @@ module.exports = {
283
294
  renderModelPicker,
284
295
  renderEffortPicker,
285
296
  renderResumeList,
297
+ EFFORT_LEVELS,
298
+ EFFORT_VALUES,
286
299
  };
package/src/panels.js CHANGED
@@ -202,7 +202,9 @@ function buildStatus(state, ctx = {}) {
202
202
  add('Working directory', s.cwd);
203
203
  add('Home', s.home);
204
204
  add('Model', s.model);
205
- add('Effort', s.effort);
205
+ // `null` = auto: the pool sizes each turn from the ask, so the row says so
206
+ // rather than vanishing (add() skips empty values).
207
+ add('Effort', s.effort || 'auto');
206
208
  if (s.thinking != null) add('Thinking', s.thinking ? 'on' : 'off');
207
209
  if (s.stream != null) add('Streaming', s.stream === false ? 'off' : 'on');
208
210
  if (s.vim != null) add('Vim keymap', s.vim ? 'on' : 'off');
@@ -278,6 +278,13 @@ function createClient(opts = {}) {
278
278
  * so a reasoning-only stream still counts as unanswered.
279
279
  * - `idleTimeoutMs` override the stalled-stream watchdog for this call
280
280
  * (a worker fan-out is legitimately silent between passes)
281
+ * - `maxTokens` output ceiling. Omitted from the body when absent so the
282
+ * server's own budget applies — on a pooled request that is
283
+ * the `effort` ladder, and a number sent from here caps it.
284
+ * - `effort` / `workers` pooled-brain budget rung and fan-out size.
285
+ * Forwarded only inside `extra`, as the server reads them off
286
+ * the body (aegis1 services/pool_brain.py parse_brain_request)
287
+ * and sizes the budget from them.
281
288
  */
282
289
  async function chatCompletion({
283
290
  prompt,
@@ -298,9 +305,17 @@ function createClient(opts = {}) {
298
305
  // model the server picks its default (no client-invented tier id). `mode`
299
306
  // is a legacy server-side shorthand — forwarded verbatim only when the
300
307
  // caller supplies it, never defaulted, never built into a model id.
308
+ //
309
+ // `max_tokens` is omitted entirely when the caller states none, rather than
310
+ // defaulted here. The server has its own budget ladder (`mode`/`effort` on
311
+ // the pooled path) and treats a body max_tokens as a *ceiling* over it, so
312
+ // an invented 4096 was not a harmless default: it capped every pass of a
313
+ // pooled-brain fan-out at 4096 and overrode the ladder the caller's effort
314
+ // selected. Omitting it is the documented way to say "server default" (see
315
+ // mcp/tools.js's max_tokens description).
301
316
  const body = {
302
317
  messages: buildMessages(messages, system, prompt),
303
- max_tokens: maxTokens || 4096,
318
+ ...(maxTokens ? { max_tokens: maxTokens } : {}),
304
319
  ...(extra || {}),
305
320
  };
306
321
  if (model) body.model = model;
@@ -473,6 +473,17 @@ function createLocalEngine({ aegis, settings, ollama, providers, tools, promptBu
473
473
 
474
474
  async function listModels(cls) {
475
475
  if (cls === 'aegis') {
476
+ // The catalog is behind the account's key: GET /api/v1/models answers
477
+ // `401 {"error":{"message":"No API key"}}` without one (verified against
478
+ // aegiscloud.org). Aegis Cloud is the *default* class, so firing the call
479
+ // anyway painted a raw "listModels failed: … 401" over the default model
480
+ // picker for every user who had just installed the app and not yet
481
+ // pasted a key — the one state where the UI must say what unblocks it and
482
+ // not what went wrong. Report the missing key as a state (`needsKey`) and
483
+ // let the renderer invite the user to connect; nothing else about the
484
+ // class changes, and the model dropdown keeps its "server default (auto)"
485
+ // entry so the class is usable the moment a key lands.
486
+ if (!aegis.apiKey) return { class: cls, models: [], needsKey: true };
476
487
  const data = await aegis.listModels();
477
488
  return { class: cls, models: filterAegisCatalog(normalizeCatalog(data && data.models)) };
478
489
  }
@@ -573,13 +584,18 @@ function createLocalEngine({ aegis, settings, ollama, providers, tools, promptBu
573
584
  // model as the fan-out, one sample instead of four — otherwise
574
585
  // dropping the fan-out would have quietly changed the model too.
575
586
  ...(brainFlag === false ? { mode: 'brain' } : {}),
576
- // Only meaningful (and only sent) alongside a running fan-out — aegis1
577
- // services/pool_brain.py parse_brain_request reads `effort`/
578
- // `workers` straight off the body and clamps them itself
579
- // (EFFORT_LEVELS / MAX_WORKERS), so no client-side validation here.
580
- // Keyed on the effective brain flag, not on `autonomous`: an opted-out
581
- // single pass carries no fan-out tuning it cannot use.
582
- ...(brainFlag === true && opts.effort ? { effort: opts.effort } : {}),
587
+ // `effort` is the budget rung, and it is sent whenever the caller has
588
+ // one — not only alongside a fan-out. The fan-out is triggered by the
589
+ // model id (this class sends ``nexus-brain``), so a caller that never
590
+ // ticked "work autonomously" still ran a pooled call and had no way to
591
+ // say how big it should be: the server fell through to its own default
592
+ // rung. That is how a one-word prompt came to reserve the top of the
593
+ // ladder. A single-pass pass carries it too inert on that path, but
594
+ // it keeps the request honest about what it asked for.
595
+ ...(opts.effort ? { effort: opts.effort } : {}),
596
+ // Only meaningful with a running fan-out — aegis1 services/pool_brain.py
597
+ // parse_brain_request reads `workers` straight off the body and clamps
598
+ // it itself (MAX_WORKERS), so no client-side validation here.
583
599
  ...(brainFlag === true && opts.workers ? { workers: opts.workers } : {}),
584
600
  // The pool forwards `tools` to the provider and returns tool_calls
585
601
  // (aegis1 app.py:7765 → provider, pool_brain synthesis keeps them).