aegis-desktop 0.4.4 → 0.5.0

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.
@@ -584,13 +584,18 @@ function createLocalEngine({ aegis, settings, ollama, providers, tools, promptBu
584
584
  // model as the fan-out, one sample instead of four — otherwise
585
585
  // dropping the fan-out would have quietly changed the model too.
586
586
  ...(brainFlag === false ? { mode: 'brain' } : {}),
587
- // Only meaningful (and only sent) alongside a running fan-out — aegis1
588
- // services/pool_brain.py parse_brain_request reads `effort`/
589
- // `workers` straight off the body and clamps them itself
590
- // (EFFORT_LEVELS / MAX_WORKERS), so no client-side validation here.
591
- // Keyed on the effective brain flag, not on `autonomous`: an opted-out
592
- // single pass carries no fan-out tuning it cannot use.
593
- ...(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.
594
599
  ...(brainFlag === true && opts.workers ? { workers: opts.workers } : {}),
595
600
  // The pool forwards `tools` to the provider and returns tool_calls
596
601
  // (aegis1 app.py:7765 → provider, pool_brain synthesis keeps them).
package/lib/settings.js CHANGED
@@ -186,11 +186,25 @@ function createSettingsStore({ dir, safeStorage } = {}) {
186
186
  return decrypt(cfg.key);
187
187
  }
188
188
 
189
+ /**
190
+ * When this host last wrote its own AEGIS key (ISO string, or null).
191
+ *
192
+ * The shared credential file records the same thing, and startup compares the
193
+ * two so the most recently saved key wins. Without a stamp the app cannot tell
194
+ * "I saved this last week" from "the user ran `aegiscode login` a minute ago
195
+ * with a rotated key", and would silently keep presenting the stale one.
196
+ */
197
+ function aegisKeySavedAt() {
198
+ const cfg = load()[AEGIS_KEY_NAMESPACE] || {};
199
+ return cfg.savedAt || null;
200
+ }
201
+
189
202
  function setAegisKey(key) {
190
203
  const data = load();
191
204
  data[AEGIS_KEY_NAMESPACE] = {
192
205
  ...(data[AEGIS_KEY_NAMESPACE] || {}),
193
206
  key: key ? encrypt(key) : null,
207
+ savedAt: key ? new Date().toISOString() : null,
194
208
  };
195
209
  save(data);
196
210
  return aegisKey();
@@ -278,6 +292,7 @@ function createSettingsStore({ dir, safeStorage } = {}) {
278
292
  maskKey,
279
293
  aegisKey,
280
294
  aegisRawKey,
295
+ aegisKeySavedAt,
281
296
  setAegisKey,
282
297
  migrateLegacyAegisKey,
283
298
  quickLauncherConfig,
@@ -1,219 +1,93 @@
1
1
  'use strict';
2
2
 
3
3
  /**
4
- * sessions.js — local conversation persistence (plan P1 §5.1 / P3 §7).
5
- * Every session (any model class) persists to <dir>/sessions.json on each
6
- * message with a crash-safe temp-file rename. Pure Node + injectable dir,
7
- * so it unit-tests without Electron.
4
+ * sessions.js — the desktop's session store, now a thin binding over the
5
+ * SHARED store in `client/session-store.js`.
6
+ *
7
+ * This file used to be the implementation. It was the desktop's private
8
+ * `sessions.json`, while the terminal host kept `~/.aegiscode/history.jsonl`
9
+ * and the MCP plugin could see neither — three hosts, one account, three
10
+ * disjoint views of the conversation. The implementation now lives in
11
+ * `client/session-store.js`, which is the tree all three hosts already bundle,
12
+ * and every call here forwards to it.
13
+ *
14
+ * Two things that matter are deliberately unchanged:
15
+ *
16
+ * * the public API and its argument order (`(dir, …)` first) — `main.js`, the
17
+ * sync dispatch and `test/sync-sessions.test.mjs` call it by those names;
18
+ * * the record shape — `pending`, `seq`, `updatedAt`, `remoteId`,
19
+ * `lastSyncedAt`, `__seq` all keep their meaning, because cloud sync and
20
+ * the renderer's session list read them.
21
+ *
22
+ * The only real change is *where* `dir` points: main.js now passes the shared
23
+ * data dir (see `resolveStoreDir`), so a session typed in the terminal appears
24
+ * in the desktop's list without a network round trip.
25
+ *
26
+ * Resolution mirrors main.js's own pattern for the shared client: `client/` in
27
+ * a source checkout, `vendor/` in the packaged app (electron-builder cannot
28
+ * reach outside the app dir, so predist.mjs stages the file).
8
29
  */
9
30
 
10
- const fs = require('node:fs');
11
- const path = require('node:path');
12
-
13
- function sessionsFile(dir) {
14
- return path.join(dir, 'sessions.json');
15
- }
16
-
17
- function load(dir) {
31
+ const shared = (() => {
18
32
  try {
19
- return JSON.parse(fs.readFileSync(sessionsFile(dir), 'utf8')) || {};
33
+ return require('../../../client/session-store.js');
20
34
  } catch {
21
- return {};
35
+ return require('../../vendor/session-store.js');
22
36
  }
23
- }
24
-
25
- /**
26
- * Monotonic write counter stored in the file's `__seq` field. Every mutation
27
- * bumps it, so sessions written in the same millisecond still order
28
- * deterministically (listSessions sorts by updatedAt, then by seq).
29
- */
30
- function nextSeq(sessions) {
31
- const seq = (typeof sessions.__seq === 'number' ? sessions.__seq : 0) + 1;
32
- sessions.__seq = seq;
33
- return seq;
34
- }
37
+ })();
35
38
 
36
- /**
37
- * Normalize a remote `updated_at`/`updatedAt` to epoch milliseconds. The cloud
38
- * returns an ISO-8601 string (e.g. "2026-07-16T14:20:00"); the local store
39
- * uses `Date.now()` epoch-ms numbers. Comparing the two directly coerces the
40
- * string to NaN, which breaks last-write-wins ordering. ISO strings are
41
- * parsed; epoch-ms numbers pass through; anything else becomes 0.
42
- */
43
- function toEpochMs(value) {
44
- if (typeof value === 'number' && Number.isFinite(value)) return value;
45
- if (typeof value === 'string' && value.trim()) {
46
- const ms = Date.parse(value);
47
- if (Number.isFinite(ms)) return ms;
39
+ const credentials = (() => {
40
+ try {
41
+ return require('../../../client/credentials.js');
42
+ } catch {
43
+ return require('../../vendor/credentials.js');
48
44
  }
49
- return 0;
50
- }
51
-
52
- function atomicWrite(file, data) {
53
- fs.mkdirSync(path.dirname(file), { recursive: true });
54
- const tmp = `${file}.tmp-${process.pid}`;
55
- fs.writeFileSync(tmp, JSON.stringify(data, null, 2));
56
- fs.renameSync(tmp, file);
57
- }
58
-
59
- function save(dir, sessions) {
60
- atomicWrite(sessionsFile(dir), sessions);
61
- }
62
-
63
- /**
64
- * Create or merge a full session record. Any local write (`upsertSession` /
65
- * `appendMessage`) marks the session `pending: true` — it has local content
66
- * the cloud hasn't seen yet. Only `markSynced()` clears the flag, so a push
67
- * that fails (offline/no key) never silently drops the session from the
68
- * retry queue.
69
- */
70
- function upsertSession(dir, session) {
71
- const id = session && session.id;
72
- if (!id) throw new Error('session.id is required');
73
- const sessions = load(dir);
74
- const prev = sessions[id] || { messages: [] };
75
- sessions[id] = { ...prev, ...session, id, pending: true };
76
- if (!sessions[id].updatedAt) sessions[id].updatedAt = Date.now();
77
- sessions[id].seq = nextSeq(sessions);
78
- save(dir, sessions);
79
- return sessions[id];
80
- }
81
-
82
- /** Append one message to a session (crash-safe). */
83
- function appendMessage(dir, sessionId, message) {
84
- if (!sessionId) throw new Error('sessionId is required');
85
- const sessions = load(dir);
86
- const session = sessions[sessionId] || { id: sessionId, messages: [] };
87
- const messages = Array.isArray(session.messages) ? session.messages : [];
88
- session.messages = messages.concat(message);
89
- session.updatedAt = Date.now();
90
- session.pending = true;
91
- session.seq = nextSeq(sessions);
92
- sessions[sessionId] = session;
93
- save(dir, sessions);
94
- return session;
95
- }
96
-
97
- function listSessions(dir) {
98
- const sessions = load(dir);
99
- return Object.values(sessions)
100
- .filter((s) => s && s.id)
101
- .sort(
102
- (a, b) =>
103
- (b.updatedAt || 0) - (a.updatedAt || 0) ||
104
- (b.seq || 0) - (a.seq || 0)
105
- );
106
- }
107
-
108
- function getSession(dir, id) {
109
- return load(dir)[id] || null;
110
- }
111
-
112
- function deleteSession(dir, id) {
113
- const sessions = load(dir);
114
- delete sessions[id];
115
- save(dir, sessions);
116
- return { ok: true };
117
- }
118
-
119
- /** Clear the pending flag after a successful cloud push. `remote.remoteId`,
120
- * when the server assigns its own conversation id, is stashed alongside. */
121
- function markSynced(dir, id, remote) {
122
- const sessions = load(dir);
123
- const session = sessions[id];
124
- if (!session) return null;
125
- session.pending = false;
126
- session.lastSyncedAt = Date.now();
127
- if (remote && remote.remoteId) session.remoteId = remote.remoteId;
128
- session.seq = nextSeq(sessions);
129
- sessions[id] = session;
130
- save(dir, sessions);
131
- return session;
132
- }
133
-
134
- /** Force a session back into the retry queue (e.g. a push that partially failed). */
135
- function markPending(dir, id) {
136
- const sessions = load(dir);
137
- const session = sessions[id];
138
- if (!session) return null;
139
- session.pending = true;
140
- session.seq = nextSeq(sessions);
141
- sessions[id] = session;
142
- save(dir, sessions);
143
- return session;
144
- }
145
-
146
- /** Sessions with local content the cloud hasn't confirmed yet (including
147
- * sessions predating this field, which default to pending). */
148
- function listPending(dir) {
149
- return listSessions(dir).filter((s) => s.pending !== false);
150
- }
45
+ })();
151
46
 
152
47
  /**
153
- * Merge remote conversation-sync records into the local store (pull half of
154
- * sync). Last-write-wins by `updatedAt`, but a local session with unsynced
155
- * edits (`pending`) always wins over the remote copy — it will overwrite the
156
- * remote copy on the next push instead.
48
+ * The directory the shared store lives in: `$AEGISCODE_HOME` or `~/.aegiscode`.
49
+ * `legacyDir` (Electron's userData) is only used to adopt a pre-unification
50
+ * store, never to read sessions from afterwards.
157
51
  */
158
- function mergeRemoteSessions(dir, remoteSessions) {
159
- const list = Array.isArray(remoteSessions) ? remoteSessions : [];
160
- const sessions = load(dir);
161
- let merged = 0;
162
- for (const remote of list) {
163
- const id = remote && (remote.session_id || remote.id);
164
- if (!id) continue;
165
- const local = sessions[id];
166
- const remoteUpdatedAt = toEpochMs(remote.updated_at ?? remote.updatedAt);
167
- if (local && (local.pending || (local.updatedAt || 0) >= remoteUpdatedAt)) {
168
- continue;
169
- }
170
- sessions[id] = {
171
- id,
172
- title: remote.title || (local && local.title) || '',
173
- messages: Array.isArray(remote.messages) ? remote.messages : [],
174
- updatedAt: remoteUpdatedAt || Date.now(),
175
- pending: false,
176
- lastSyncedAt: Date.now(),
177
- remoteId: remote.session_id || remote.id,
178
- };
179
- sessions[id].seq = nextSeq(sessions);
180
- merged += 1;
181
- }
182
- if (merged) save(dir, sessions);
183
- return merged;
52
+ function resolveStoreDir(legacyDir) {
53
+ const dir = credentials.aegisHome();
54
+ if (legacyDir && legacyDir !== dir) adopt(dir, legacyDir);
55
+ return dir;
184
56
  }
185
57
 
186
- /**
187
- * Markdown export (session export, plan: Save as.../Export session). Each
188
- * message becomes a `## Role` heading followed by its content verbatim —
189
- * content is never re-escaped or re-wrapped, so any code fences a message
190
- * already contains (assistant replies routinely have them) survive untouched
191
- * instead of being nested inside an outer fence.
192
- */
193
- function toMarkdown(session) {
194
- const title = (session && (session.title || session.id)) || 'session';
195
- const messages = (session && Array.isArray(session.messages)) ? session.messages : [];
196
- const lines = [`# ${title}`, ''];
197
- for (const message of messages) {
198
- const role = (message && message.role) || 'unknown';
199
- const heading = role.charAt(0).toUpperCase() + role.slice(1);
200
- const content = (message && (message.content || message.text)) || '';
201
- lines.push(`## ${heading}`, '', content, '');
202
- }
203
- return lines.join('\n');
204
- }
58
+ const {
59
+ storeFile,
60
+ load,
61
+ save,
62
+ upsertSession,
63
+ appendMessage,
64
+ recordExchange,
65
+ listSessions,
66
+ getSession,
67
+ deleteSession,
68
+ markSynced,
69
+ markPending,
70
+ listPending,
71
+ mergeRemoteSessions,
72
+ listSummaries,
73
+ readTranscript,
74
+ adopt,
75
+ toMarkdown,
76
+ toJson,
77
+ } = shared;
205
78
 
206
- /** JSON export: the session record as stored, pretty-printed. */
207
- function toJson(session) {
208
- return JSON.stringify(session, null, 2);
209
- }
79
+ /** Kept under its old name — main.js and the tests call `sessionsFile`. */
80
+ const sessionsFile = storeFile;
210
81
 
211
82
  module.exports = {
212
83
  sessionsFile,
84
+ storeFile,
85
+ resolveStoreDir,
213
86
  load,
214
87
  save,
215
88
  upsertSession,
216
89
  appendMessage,
90
+ recordExchange,
217
91
  listSessions,
218
92
  getSession,
219
93
  deleteSession,
@@ -221,6 +95,9 @@ module.exports = {
221
95
  markPending,
222
96
  listPending,
223
97
  mergeRemoteSessions,
98
+ listSummaries,
99
+ readTranscript,
100
+ adopt,
224
101
  toMarkdown,
225
102
  toJson,
226
103
  };
package/main.js CHANGED
@@ -36,6 +36,17 @@ try {
36
36
  }
37
37
  const { createClient } = sharedClient;
38
38
 
39
+ // The shared credential store (`client/credentials.js`) — the same 0600 file
40
+ // the terminal host writes with `aegiscode login` and the MCP plugin reads. The
41
+ // app resolves its own account key through it, so signing in once serves all
42
+ // three hosts; see resolveStartupKey() and persistApiKey() below.
43
+ let credentials;
44
+ try {
45
+ credentials = require('../client/credentials.js');
46
+ } catch {
47
+ credentials = require('./vendor/credentials.js');
48
+ }
49
+
39
50
  let electron = null;
40
51
  try {
41
52
  // In plain Node (CI smoke test, `node --check`) this either throws
@@ -666,14 +677,24 @@ function resolveUserDataDir(app) {
666
677
  * Wire the real LocalEngine registry: settings store + ollama/providers
667
678
  * transports + the shared cloud client. Transport-only — no brain logic.
668
679
  */
669
- function createEngine(aegis, { app, safeStorage, dir: dirOverride } = {}) {
670
- const dir = dirOverride || resolveUserDataDir(app);
671
- const settings = createSettingsStore({ dir, safeStorage });
680
+ function createEngine(aegis, { app, safeStorage, dir: dirOverride, sessionsDir: sessionsOverride } = {}) {
681
+ // Settings (the safeStorage-encrypted key, window state, provider entries)
682
+ // stay in Electron's userData: they are this host's own, and the key is only
683
+ // readable by this process.
684
+ const settingsDir = dirOverride || resolveUserDataDir(app);
685
+ const settings = createSettingsStore({ dir: settingsDir, safeStorage });
672
686
  // Relocate a pre-fix `settings['aegis']` key into the reserved namespace so
673
687
  // it stops showing up as a provider. Ciphertext-level, so safe pre-'ready'.
674
688
  settings.migrateLegacyAegisKey();
675
689
  const engine = createLocalEngine({ aegis, settings, ollama, providers });
676
- return { engine, sessionsDir: dir, settings };
690
+ // Sessions/memory live in the SHARED data dir ($AEGISCODE_HOME or
691
+ // ~/.aegiscode) — the same file the terminal host and the MCP plugin read —
692
+ // so a thread started in either shows up in the other without cloud sync and
693
+ // without a key. resolveStoreDir() also adopts a pre-unification
694
+ // userData/sessions.json, once, when the shared store is still empty, so an
695
+ // upgrade does not look like every conversation was deleted.
696
+ const sessionsDir = sessionsOverride || sessionStore.resolveStoreDir(settingsDir);
697
+ return { engine, sessionsDir, settingsDir, settings };
677
698
  }
678
699
 
679
700
  /**
@@ -1233,7 +1254,7 @@ function bootstrap() {
1233
1254
  else pendingDeepLink = parsed;
1234
1255
  });
1235
1256
 
1236
- const aegis = createClient();
1257
+ const aegis = createClient(credentials.clientOptions());
1237
1258
  const dataDir = resolveUserDataDir(app);
1238
1259
 
1239
1260
  // One settings store backs both the provider settings surface and the AEGIS
@@ -1248,7 +1269,17 @@ function bootstrap() {
1248
1269
  // Persist the in-app AEGIS key in its own reserved namespace, encrypted —
1249
1270
  // never as a provider named 'aegis' (that coupling let the Settings pane's
1250
1271
  // "Remove" delete the AEGIS key; defect #1).
1251
- const persistApiKey = (key) => settings.setAegisKey(key);
1272
+ //
1273
+ // Mirrored into the shared 0600 store as well, so one sign-in covers this app,
1274
+ // `aegiscode` and the MCP plugin instead of each host needing its own. The
1275
+ // app's copy stays the encrypted one; resolveStartupKey() compares their
1276
+ // `savedAt` stamps so whichever host wrote last is the key in force.
1277
+ const persistApiKey = (key) => {
1278
+ const result = settings.setAegisKey(key);
1279
+ if (key) credentials.saveApiKey(key);
1280
+ else credentials.clearApiKey();
1281
+ return result;
1282
+ };
1252
1283
 
1253
1284
  // Native "reply ready" notification: main.js is exactly where every
1254
1285
  // chatCompletion/model:chat call already resolves (withReplyNotify above),
@@ -1565,8 +1596,19 @@ function bootstrap() {
1565
1596
  // Hydrate the client from the persisted key BEFORE the renderer issues any
1566
1597
  // status/model call, so class/model dropdowns populate immediately when a
1567
1598
  // key was saved in-app (safeStorage is usable only after app ready).
1568
- const persistedKey = settings.aegisRawKey();
1569
- if (persistedKey) aegis.setApiKey(persistedKey);
1599
+ //
1600
+ // `$AEGIS_API_KEY` still outranks both stores: createClient() already read
1601
+ // it, and nothing here may shadow an explicit export.
1602
+ const envKey = credentials.normalizeApiKey(process.env[credentials.KEY_ENV]);
1603
+ if (!envKey) {
1604
+ const chosen = credentials.preferNewest(settings.aegisRawKey(), settings.aegisKeySavedAt());
1605
+ if (chosen.key) {
1606
+ aegis.setApiKey(chosen.key);
1607
+ // The shared store is the newer write: bring this host's encrypted copy
1608
+ // up to date so a CLI rotation does not have to be redone in Settings.
1609
+ if (chosen.source === 'shared') settings.setAegisKey(chosen.key);
1610
+ }
1611
+ }
1570
1612
  // Apply whatever was last saved (or the default): ship the shortcut only
1571
1613
  // when app.isPackaged || the settings flag is on — see
1572
1614
  // shouldEnableGlobalShortcut — so a plain `electron .` dev run never
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "aegis-desktop",
3
3
  "productName": "AEGIS Desktop",
4
- "version": "0.4.4",
4
+ "version": "0.5.0",
5
5
  "description": "Thin Electron host for AEGIS — a local chat UI over the shared client/aegis.js transport. Ships transport + UI only; engine logic stays server-side.",
6
6
  "author": {
7
7
  "name": "AEGIS Code",
package/renderer/app.js CHANGED
@@ -69,7 +69,10 @@ const ELEMENT_IDS = {
69
69
  modelPreset: 'model-preset',
70
70
  modelInput: 'model-input',
71
71
  maxTokens: 'max-tokens',
72
+ maxTokensLabel: 'max-tokens-label',
73
+ maxTokensRow: 'max-tokens-row',
72
74
  maxTokensAdaptive: 'max-tokens-adaptive',
75
+ effortRow: 'effort-row',
73
76
  autonomousToggle: 'autonomous-toggle',
74
77
  autonomousToggleWrap: 'autonomous-toggle-wrap',
75
78
  autonomousControls: 'autonomous-controls',
@@ -351,6 +354,35 @@ function updateAutonomousControlsVisibility() {
351
354
  els.autonomousControls.hidden = !(wrapVisible && autonomousEnabled());
352
355
  }
353
356
 
357
+ /**
358
+ * Which budget control applies to the selected class.
359
+ *
360
+ * Aegis Cloud is sized server-side from `effort`; the other three classes take
361
+ * a per-call token ceiling from the dropdown. Showing both at once is what made
362
+ * the token cap untrustworthy on the pooled class — the dropdown was displayed,
363
+ * read on every send, and then raised by the server's effort ladder, so the
364
+ * number beside it was never the budget the call ran on. Exactly one control is
365
+ * on offer now, and it is the one the request actually travels with.
366
+ */
367
+ function updateBudgetControls(cls) {
368
+ const pooled = cls === AUTONOMOUS_CLASS;
369
+ if (els.effortRow) els.effortRow.hidden = !pooled;
370
+ if (els.maxTokensRow) els.maxTokensRow.hidden = pooled;
371
+ if (els.maxTokensLabel) els.maxTokensLabel.hidden = pooled;
372
+ }
373
+
374
+ /**
375
+ * The effort to send, or undefined for the classes the server does not size
376
+ * from it. `auto` means "let the server infer it from the ask" — the same thing
377
+ * the server already does for a request that names no effort, and a deliberate
378
+ * choice rather than the old silent fall-through to the top rung.
379
+ */
380
+ function effortFor(cls) {
381
+ if (cls !== AUTONOMOUS_CLASS) return undefined;
382
+ const value = els.autonomousEffort && els.autonomousEffort.value;
383
+ return value && value !== 'auto' ? value : undefined;
384
+ }
385
+
354
386
  // ---------------------------------------------------------------- UI helpers
355
387
 
356
388
  function setConn(ok, text) {
@@ -1769,6 +1801,7 @@ async function loadModels(cls) {
1769
1801
  els.autonomousToggleWrap.hidden = cls !== AUTONOMOUS_CLASS;
1770
1802
  }
1771
1803
  updateAutonomousControlsVisibility();
1804
+ updateBudgetControls(cls);
1772
1805
 
1773
1806
  const custom = CUSTOM_CLASSES.has(cls);
1774
1807
  els.modelSelect.hidden = custom;
@@ -2274,12 +2307,21 @@ async function send() {
2274
2307
  addMessage('user', prompt);
2275
2308
 
2276
2309
  const ceiling = applyMaxTokensClamp(model);
2277
- const maxTokens = maxTokensAdaptive() ? ceiling : parseInt(els.maxTokens.value, 10) || 4096;
2310
+ // Aegis Cloud takes no token cap from here at all: the server sizes the call
2311
+ // from `effort`, and a number in this position is a per-pass ceiling *over*
2312
+ // that ladder (aegis1 services/pool_brain.py pass_budgets). Sending the
2313
+ // dropdown's value anyway is what made "Max tokens: 4k" beside a turn a
2314
+ // figure the turn never ran on. The row is hidden for this class as well, so
2315
+ // the two controls can never disagree.
2316
+ const maxTokens = cls === AUTONOMOUS_CLASS
2317
+ ? undefined
2318
+ : maxTokensAdaptive() ? ceiling : parseInt(els.maxTokens.value, 10) || 4096;
2278
2319
  const autonomous = cls === AUTONOMOUS_CLASS && autonomousEnabled();
2279
- // Only meaningful (and only sent) alongside `autonomous` — see
2280
- // aegis1 services/pool_brain.py parse_brain_request for the effort/workers
2281
- // clamping this feeds.
2282
- const effort = autonomous ? els.autonomousEffort.value : undefined;
2320
+ // Sent for the pooled class whether or not the fan-out is ticked: the fan-out
2321
+ // is enabled by the model id this class sends, so a turn that never entered
2322
+ // autonomous mode still ran pooled and had no way to say how big it should
2323
+ // be. `undefined` = "auto" = the server infers it from the ask.
2324
+ const effort = effortFor(cls);
2283
2325
  const workers = autonomous ? parseInt(els.autonomousWorkers.value, 10) || undefined : undefined;
2284
2326
  // Reuse the open thread's session id (minted once, on its first message)
2285
2327
  // instead of a fresh one per send — a new id every turn is what made both
@@ -2378,7 +2420,11 @@ async function send() {
2378
2420
  if (data && data.model) bits.push(`model: ${data.model}`);
2379
2421
  else if (model) bits.push(`model: ${model}`);
2380
2422
  bits.push(classLabel(cls));
2381
- if (autonomous) bits.push(`autonomous (${effort}, ${workers || 3}w)`);
2423
+ if (autonomous) bits.push(`autonomous (${effort || 'auto'}, ${workers || 'auto'}w)`);
2424
+ // The budget rung is worth showing even without the fan-out: on the pooled
2425
+ // class it is what sized the call, and a user cannot tell a 16k turn from a
2426
+ // 64k one by looking at the answer.
2427
+ else if (effort) bits.push(`effort: ${effort}`);
2382
2428
  const turnTokens = usageTokens(data && data.usage);
2383
2429
  if (turnTokens != null) bits.push(`tokens: ${turnTokens}`);
2384
2430
  addMessage('assistant', text, bits.join(' · ') || undefined, sessionId, toolLog);
@@ -2459,14 +2505,23 @@ async function init() {
2459
2505
  updateAutonomousControlsVisibility();
2460
2506
  });
2461
2507
 
2462
- // Effort/worker count for the pool_brain fan-out (aegis1
2463
- // services/pool_brain.py parse_brain_request reads `effort`/`workers` off
2464
- // the request body) — opt-in per machine, remembered across restarts.
2508
+ // Budget control for the pooled class: which rung of the server's effort
2509
+ // ladder sizes the call, or "auto" to let the server infer it from the ask
2510
+ // (aegis1 services/pool_brain.py parse_brain_request). Opt-in per machine,
2511
+ // remembered across restarts; a stored value from a build whose list was
2512
+ // short (low/medium/high, no "auto") falls through to the markup's default
2513
+ // rather than assigning an option that no longer exists.
2465
2514
  const savedEffort = localStorage.getItem(AUTONOMOUS_EFFORT_KEY);
2466
- if (savedEffort) els.autonomousEffort.value = savedEffort;
2515
+ if (savedEffort && Array.from(els.autonomousEffort.options).some((o) => o.value === savedEffort)) {
2516
+ els.autonomousEffort.value = savedEffort;
2517
+ }
2467
2518
  els.autonomousEffort.addEventListener('change', () => {
2468
2519
  localStorage.setItem(AUTONOMOUS_EFFORT_KEY, els.autonomousEffort.value);
2469
2520
  });
2521
+ // Worker count for the fan-out. Left empty by default on purpose: an empty
2522
+ // field is what tells the server to size the fan-out from the ask
2523
+ // (parse_brain_request's auto path) instead of the old client-side default of
2524
+ // 3, which made every turn a 4-pass fan-out.
2470
2525
  const savedWorkers = localStorage.getItem(AUTONOMOUS_WORKERS_KEY);
2471
2526
  if (savedWorkers) els.autonomousWorkers.value = savedWorkers;
2472
2527
  els.autonomousWorkers.addEventListener('change', () => {
@@ -2484,6 +2539,11 @@ async function init() {
2484
2539
 
2485
2540
  els.classSelect.addEventListener('change', () => {
2486
2541
  localStorage.setItem(CLASS_KEY, els.classSelect.value);
2542
+ // Apply the budget-control choice immediately rather than waiting for the
2543
+ // model list: loadModels() is async, and until it resolves the previous
2544
+ // class's control would still be on screen — showing a token cap on a
2545
+ // pooled turn, or hiding the effort rung it runs on.
2546
+ updateBudgetControls(els.classSelect.value);
2487
2547
  loadModels(els.classSelect.value);
2488
2548
  });
2489
2549
 
@@ -76,8 +76,8 @@
76
76
  hidden
77
77
  />
78
78
 
79
- <label for="max-tokens">Max tokens</label>
80
- <div class="max-tokens-row">
79
+ <label for="max-tokens" id="max-tokens-label">Max tokens</label>
80
+ <div class="max-tokens-row" id="max-tokens-row">
81
81
  <select id="max-tokens">
82
82
  <option value="1024">1k</option>
83
83
  <option value="4096" selected>4k</option>
@@ -95,6 +95,23 @@
95
95
  </label>
96
96
  </div>
97
97
 
98
+ <!-- The pooled AEGIS Cloud class sizes a request's token budget
99
+ server-side from this ladder (effort -> low/medium/high ->
100
+ 16384/32768/65536 total across the fan-out), so the max-tokens row
101
+ above is hidden for that class instead of being shown and silently
102
+ overridden: the server treats a body max_tokens as a ceiling, and
103
+ an effort rung as the budget. Shown for Ollama and custom
104
+ endpoints, where a per-call ceiling is the only control there is. -->
105
+ <div class="max-tokens-row" id="effort-row" hidden>
106
+ <label for="autonomous-effort">Effort</label>
107
+ <select id="autonomous-effort">
108
+ <option value="low">low</option>
109
+ <option value="medium">medium</option>
110
+ <option value="high">high</option>
111
+ <option value="auto" selected>auto</option>
112
+ </select>
113
+ </div>
114
+
98
115
  <label
99
116
  class="flow-toggle"
100
117
  for="autonomous-toggle"
@@ -106,14 +123,8 @@
106
123
  <span>work autonomously</span>
107
124
  </label>
108
125
  <div id="autonomous-controls" class="autonomous-controls" hidden>
109
- <label for="autonomous-effort">Effort</label>
110
- <select id="autonomous-effort">
111
- <option value="low">low</option>
112
- <option value="medium">medium</option>
113
- <option value="high" selected>high</option>
114
- </select>
115
126
  <label for="autonomous-workers">Workers</label>
116
- <input type="number" id="autonomous-workers" min="1" max="8" step="1" value="3" />
127
+ <input type="number" id="autonomous-workers" min="1" max="8" step="1" />
117
128
  </div>
118
129
  <p class="hint" id="model-hint"></p>
119
130
  </section>