aegis-desktop 0.8.35 → 1.0.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.
package/main.js CHANGED
@@ -182,6 +182,14 @@ const localTools = require('./lib/local/tools.js');
182
182
  // the `queue:` IPC surface registered by registerQueueIpc() below.
183
183
  const queueModule = require('./lib/local/queue.js');
184
184
  const autonomousModule = require('./lib/local/autonomous.js');
185
+ // Auto mode's round-horizon half: the same arithmetic the upstream engine
186
+ // (aegiscodex-dev src/autonomous.js) and the CLI's presenter use, read here by
187
+ // the one host that owns this face's policy. See createModelDispatch.
188
+ const {
189
+ autoContinuePlan,
190
+ autoContinueLimit,
191
+ autoRoundsDefault,
192
+ } = require('./lib/local/autonomous.js');
185
193
 
186
194
  // The Pro boundary for the autonomous queue (Phase 4 of
187
195
  // docs/upgrade-conversion-plan.md). MAIN-process only, no Electron inside, so
@@ -1078,7 +1086,34 @@ function createConfirmModeDispatch(settings) {
1078
1086
  };
1079
1087
  }
1080
1088
 
1081
- /** Register getConfirmMode/setConfirmMode as `aegis:<name>` on ipcMain —
1089
+ /**
1090
+ * Auto mode's other switch: a posture of "don't ask" IS auto mode on this face,
1091
+ * so an explicit setConfirmMode must move the model dispatch's session state
1092
+ * with it. Otherwise the two halves of the mode can be told different things —
1093
+ * the engine stops asking, the round horizon keeps raising its card, and the
1094
+ * chip in the composer claims auto mode is on.
1095
+ *
1096
+ * OFF starts auto mode for this sitting (`setAutoRounds(true)`, which also
1097
+ * retires a boot-time AEGIS_AUTO_ROUNDS=0), ON ends it. A malformed payload
1098
+ * changes nothing, exactly as in createConfirmModeDispatch — including the
1099
+ * session state, so the two cannot drift on a bad message.
1100
+ */
1101
+ function withAutoRoundsSwitch(confirmDispatch, modelDispatch) {
1102
+ const setConfirmMode = confirmDispatch.setConfirmMode;
1103
+ return {
1104
+ ...confirmDispatch,
1105
+ setConfirmMode: (payload) => {
1106
+ const res = setConfirmMode(payload);
1107
+ if (payload && typeof payload.enabled === 'boolean' && modelDispatch && typeof modelDispatch.setAutoRounds === 'function') {
1108
+ modelDispatch.setAutoRounds(payload.enabled === false);
1109
+ }
1110
+ return res;
1111
+ },
1112
+ };
1113
+ }
1114
+
1115
+ /**
1116
+ * Register getConfirmMode/setConfirmMode as `aegis:<name>` on ipcMain —
1082
1117
  * the same ${IPC_PREFIX}<method> convention registerIpc uses for the rest of
1083
1118
  * the renderer's aegis.* bridge (their handlers need nothing but the
1084
1119
  * payload, so the event is dropped exactly like every non-chatCompletion
@@ -1398,11 +1433,149 @@ function readTextFileForPreview(payload) {
1398
1433
  /**
1399
1434
  * Pure mapping: model:<name> -> LocalEngine call (unit-testable in Node).
1400
1435
  */
1401
- function createModelDispatch(engine) {
1402
- return {
1436
+ function createModelDispatch(engine, { env = process.env, settings } = {}) {
1437
+ // ── Auto mode's second half: the round horizon ────────────────────────────
1438
+ //
1439
+ // autoContinuePlan/autoContinueLimit (lib/local/autonomous.js) answer "may
1440
+ // this horizon ask be answered for the user?" — the arithmetic the engine
1441
+ // defines in src/autonomous.js and the ONLY thing separating auto mode in
1442
+ // aegiscodex-dev from auto mode here. The desktop wired the first half (no
1443
+ // approval cards, via the Tool-approvals switch) and nothing at all for this
1444
+ // one, so a desktop turn that hit its round horizon raised the blocking card
1445
+ // on the first hit and every hit after it. That is the exact question auto
1446
+ // mode exists to stop asking: same answer, same click, once per horizon.
1447
+ //
1448
+ // The policy lives HERE rather than in the engine on purpose — the engine's
1449
+ // own comment states the contract ("the hook is optional and the caller owns
1450
+ // the policy"), so the engine keeps asking and each host decides. This host's
1451
+ // answer is the posture the user already set in one place: confirmations OFF
1452
+ // (the same boolean behind the settings switch and the composer's "auto mode"
1453
+ // chip) means auto mode is on, and the tally is per session because that is
1454
+ // the lifetime of the turn it is funding.
1455
+ const autoUsed = new Map(); // sessionId -> automatic continuations spent
1456
+
1457
+ // Auto mode's session START state — the engine's AEGIS_AUTO_ROUNDS=0 opt-out
1458
+ // (src/autonomous.js autoRoundsDefault), read once here rather than on every
1459
+ // call. Re-derived per call it is a VETO, not a start state: with the variable
1460
+ // exported in the environment that launched the app, the Tool-approvals switch
1461
+ // and any `/automode on` from the CLI sharing the store would write the
1462
+ // posture and change nothing — the chip lit, the round card still every
1463
+ // horizon, and no way back to auto mode short of restarting the app. The
1464
+ // engine's own contract is the other one: the env var picks where the session
1465
+ // STARTS, and the toggle moves it freely from there.
1466
+ //
1467
+ // The opt-out is retired by an explicit act, of which there are two here:
1468
+ // setAutoRounds (the switch and the IPC, wired at bootstrap) and a stored
1469
+ // posture that differs from the one this process booted with (a flip made by
1470
+ // another face — the CLI's `/automode` writes the same key).
1471
+ let autoRoundsSession = autoRoundsDefault(env);
1472
+ let bootPosture;
1473
+ let autoRoundsRetired = false;
1474
+
1475
+ /** True only when auto mode is on: the session state AND a posture that says
1476
+ * don't ask. Read per call, never captured: the settings
1477
+ * switch and `/automode` in the CLI both write the same shared key, so a
1478
+ * flip takes effect on the next horizon without a restart.
1479
+ *
1480
+ * `undefined` from every accessor means "this store cannot answer" — and an
1481
+ * unknown posture must NOT be read as auto mode ON: silently extending turns
1482
+ * (and spending billed rounds) on a store we cannot read is the worse
1483
+ * failure, and the pre-change behaviour (show the card) is the safe default.
1484
+ * AEGIS_AUTO_ROUNDS=0 turns it off session-wide, exactly as in the engine. */
1485
+ const autoRoundsOn = () => {
1486
+ const store = settings || (engine && engine.settings) || null;
1487
+ const bool = (v) => (typeof v === 'boolean' ? v : undefined);
1488
+ let posture;
1489
+ try {
1490
+ if (store && typeof store.confirmModeState === 'function') {
1491
+ const st = store.confirmModeState();
1492
+ posture = bool(st && st.enabled);
1493
+ }
1494
+ if (posture === undefined && store && typeof store.getConfirmMode === 'function') {
1495
+ posture = bool(store.getConfirmMode());
1496
+ }
1497
+ } catch {
1498
+ /* an unreadable store is not auto mode — the card stays up */
1499
+ }
1500
+ // A posture that moved since boot means somebody acted (another face wrote
1501
+ // the same key), so the env opt-out has been answered and no longer applies.
1502
+ if (!autoRoundsRetired && posture !== undefined) {
1503
+ if (bootPosture === undefined) bootPosture = posture;
1504
+ else if (posture !== bootPosture) {
1505
+ autoRoundsRetired = true;
1506
+ autoRoundsSession = true;
1507
+ }
1508
+ }
1509
+ if (!autoRoundsSession || !store || posture === undefined) return false;
1510
+ return posture === false;
1511
+ };
1512
+
1513
+ /** Move the session state — the switch (via setConfirmMode at bootstrap) and
1514
+ * any explicit auto-mode request. Turns OFF means the operator asked for
1515
+ * manual NOW, so it sticks against the boot-time opt-out in both
1516
+ * directions. */
1517
+ const setAutoRounds = (v) => {
1518
+ autoRoundsSession = Boolean(v);
1519
+ autoRoundsRetired = true;
1520
+ };
1521
+
1522
+ /**
1523
+ * Wrap the renderer's stream so a round-horizon ask can be answered for the
1524
+ * human while auto mode is on, and forwarded untouched otherwise.
1525
+ *
1526
+ * Three things are load-bearing:
1527
+ *
1528
+ * · the ENGINE is what answers (`respondRoundsAsk`), not a synthetic frame
1529
+ * — the turn is held open on that promise, so the only honest way to skip
1530
+ * the dialog is to resolve it;
1531
+ * · `{ note }` reaches the renderer, because a budget that grew invisibly
1532
+ * is the "why is it still running?" report, and the CLI announces this
1533
+ * same continuation through its transcript note;
1534
+ * · a declined/failed answer FALLS THROUGH to the card. The allowance is
1535
+ * spent, so this is the real ask — the same "then it asks" contract the
1536
+ * CLI and the engine's status line advertise.
1537
+ */
1538
+ const chatWithAutoRounds = (payload) => {
1539
+ const forward = payload && payload.onStream;
1540
+ if (typeof forward !== 'function') return engine.chat(payload, forward);
1541
+ const sessionId = (payload && payload.sessionId) || '';
1542
+ const onStream = (chunk) => {
1543
+ if (chunk && chunk.rounds && chunk.rounds.id && autoRoundsOn()) {
1544
+ const plan = autoContinuePlan({
1545
+ on: true,
1546
+ used: autoUsed.get(sessionId) || 0,
1547
+ limit: autoContinueLimit(env),
1548
+ });
1549
+ autoUsed.set(sessionId, plan.used);
1550
+ if (plan.auto) {
1551
+ const answered =
1552
+ typeof engine.respondRoundsAsk === 'function'
1553
+ ? engine.respondRoundsAsk(chunk.rounds.id, true)
1554
+ : { ok: false };
1555
+ if (answered && answered.ok) {
1556
+ const spent = Number(chunk.rounds.spent) || 0;
1557
+ forward({
1558
+ note:
1559
+ `auto mode: the ${spent}-round limit was reached — continuing automatically ` +
1560
+ `(${plan.used}/${plan.cap}, then it asks).`,
1561
+ });
1562
+ return;
1563
+ }
1564
+ }
1565
+ // Not auto (allowance spent, or the engine could not take an answer):
1566
+ // show the real card. plan.used is 0 in the spent case, so the next
1567
+ // horizon starts a fresh block of automatic continuations — the same
1568
+ // reset the CLI's presenter applies once the human is actually asked.
1569
+ }
1570
+ forward(chunk);
1571
+ };
1572
+ return engine.chat(payload, onStream);
1573
+ };
1574
+
1575
+ const dispatch = {
1403
1576
  listClasses: () => engine.listClasses(),
1404
1577
  listModels: (payload) => engine.listModels(payload && payload.class),
1405
- chat: (payload) => engine.chat(payload, payload && payload.onStream),
1578
+ chat: chatWithAutoRounds,
1406
1579
  // The AEGIS key lives in a reserved namespace the store refuses to expose
1407
1580
  // or delete through this surface; the filter is belt-and-braces so a
1408
1581
  // mis-wired store can never hand the renderer a removable AEGIS entry.
@@ -1415,6 +1588,22 @@ function createModelDispatch(engine) {
1415
1588
  }),
1416
1589
  'settings.remove': (payload) =>
1417
1590
  engine.settings.remove(payload && payload.provider),
1591
+ // The USER's own model declarations (`client/user-models.js`, the file the
1592
+ // CLI's `/model-add` writes). Settings is the only place a desktop user can
1593
+ // add a model the server's catalog does not name, and until this existed the
1594
+ // only ways were a release or hand-editing a JSON file the app never named.
1595
+ //
1596
+ // These three go through the ENGINE rather than the settings store: the
1597
+ // engine's `listModels('byok')` is what applies them, so the write and the
1598
+ // read share one implementation and cannot disagree about the file. Add and
1599
+ // remove THROW on a malformed provider/model id — the renderer shows the
1600
+ // message, which is the honest outcome for a string that would otherwise be
1601
+ // forwarded to a vendor and 404 there.
1602
+ 'userModels.get': () => engine.userModels(),
1603
+ 'userModels.add': (payload) =>
1604
+ engine.addUserModel(payload && payload.provider, payload && payload.model),
1605
+ 'userModels.remove': (payload) =>
1606
+ engine.removeUserModel(payload && payload.provider, payload && payload.model),
1418
1607
  // Persisting memory ("sync finished turns to cloud memory automatically").
1419
1608
  // Reserved namespace, so it is invisible to the provider CRUD above — but
1420
1609
  // unlike a provider it IS reachable deliberately, through these two. The
@@ -1449,6 +1638,25 @@ function createModelDispatch(engine) {
1449
1638
  clearApprovals: (payload) =>
1450
1639
  engine.clearSessionApprovals(payload && payload.sessionId),
1451
1640
  };
1641
+
1642
+ // Auto mode's session state is read and moved DIRECTLY, never over IPC.
1643
+ // Two reasons, and both are load-bearing:
1644
+ //
1645
+ // · every ENUMERABLE key of this object becomes an `aegis:<name>` channel
1646
+ // (registerModelIpc iterates Object.entries), so exporting these would
1647
+ // hand the renderer a way to move auto mode's round half without moving
1648
+ // the posture — the exact two-answers-one-question drift this file's
1649
+ // dispatch exists to prevent. The renderer's switch reaches both halves
1650
+ // through aegis:setConfirmMode (withAutoRoundsSwitch), which is the one
1651
+ // act that means "auto mode" on this face;
1652
+ //
1653
+ // · a signature change to the bridge has to be visible where the bridge is
1654
+ // described, so the count in test/model-dispatch.mjs stays a real check.
1655
+ Object.defineProperties(dispatch, {
1656
+ autoRoundsOn: { value: autoRoundsOn, enumerable: false },
1657
+ setAutoRounds: { value: setAutoRounds, enumerable: false },
1658
+ });
1659
+ return dispatch;
1452
1660
  }
1453
1661
 
1454
1662
  /**
@@ -3141,7 +3349,7 @@ function bootstrap() {
3141
3349
  handleReplyFinished,
3142
3350
  () => providerRouteConfigured(engine)
3143
3351
  );
3144
- registerModelIpc(
3352
+ const modelIpc = registerModelIpc(
3145
3353
  ipcMain,
3146
3354
  engine,
3147
3355
  sessionsDir,
@@ -3156,7 +3364,16 @@ function bootstrap() {
3156
3364
  // is where the settings store lands — the exact tool gate it controls lives
3157
3365
  // in the engine created alongside it (lib/local/engine.js gatedExecuteTool
3158
3366
  // reads settings.getConfirmMode() on every mutating tool call).
3159
- registerConfirmModeIpc(ipcMain, createConfirmModeDispatch(settings));
3367
+ //
3368
+ // …and it is also auto mode's OTHER switch: a posture of "don't ask" is what
3369
+ // auto mode means on this face, so flipping the switch off is an explicit
3370
+ // request for it and must move the session state with it. Without this, an
3371
+ // app launched with AEGIS_AUTO_ROUNDS=0 (a start-manual opt-out, per the
3372
+ // engine) could be switched to auto mode in the UI — approvals off, chip lit
3373
+ // — while the round horizon kept raising its card, because the env var was
3374
+ // re-read as a veto on every horizon. Same escape hatch as `/automode on` in
3375
+ // the CLI, which shares this store.
3376
+ registerConfirmModeIpc(ipcMain, withAutoRoundsSwitch(createConfirmModeDispatch(settings), modelIpc.modelDispatch));
3160
3377
 
3161
3378
  // Funnel instrumentation (Phase 2 of docs/upgrade-conversion-plan.md):
3162
3379
  // aegis:telemetryEvent / aegis:telemetryReport, backed by
@@ -3755,6 +3972,7 @@ module.exports = {
3755
3972
  createQuickLauncherDispatch,
3756
3973
  registerQuickLauncherIpc,
3757
3974
  createConfirmModeDispatch,
3975
+ withAutoRoundsSwitch,
3758
3976
  createTelemetryDispatch,
3759
3977
  registerTelemetryIpc,
3760
3978
  createCovenantDispatch,
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "aegis-desktop",
3
3
  "productName": "AEGIS Desktop",
4
- "version": "0.8.35",
4
+ "version": "1.0.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/preload.js CHANGED
@@ -349,6 +349,18 @@ const models = {
349
349
  }),
350
350
  remove: (provider) => invokeModel('settings.remove', { provider }),
351
351
  },
352
+ // The user's OWN model declarations (client/user-models.js): the file the
353
+ // CLI's `/model-add` writes, reached here so a model the server's catalog
354
+ // does not name can be added from Settings instead of by hand. `get()`
355
+ // resolves `{ dir, file, map, invalid }` — `file` is shown in the UI so the
356
+ // remaining escape hatch (an editor) is discoverable, and `invalid` names any
357
+ // hand-edited row that was skipped. `add`/`remove` reject with the validation
358
+ // message for a malformed provider or model id.
359
+ userModels: {
360
+ get: () => invokeModel('userModels.get'),
361
+ add: (provider, model) => invokeModel('userModels.add', { provider, model }),
362
+ remove: (provider, model) => invokeModel('userModels.remove', { provider, model }),
363
+ },
352
364
  cancel: (sessionId) => invokeModel('cancel', { sessionId }),
353
365
  // Tool-call approval gate: the renderer's approval card calls this to
354
366
  // answer a pending exec/writeFile/editFile request (the approval itself
package/renderer/app.js CHANGED
@@ -3608,6 +3608,30 @@ function renderApprovalCard(row, approval, beforeSelector) {
3608
3608
  return card;
3609
3609
  }
3610
3610
 
3611
+ /**
3612
+ * A turn-level note written by the APP, not by the model (`onDelta`'s
3613
+ * `{ note }` chunk). Rendered inside the assistant row it belongs to, above the
3614
+ * text stream, and kept out of `streamedText` for the reason the branch that
3615
+ * calls this states: the row's text is persisted as the assistant's own message
3616
+ * and pushed to the account's cloud memory, so app-authored prose placed there
3617
+ * would come back on the next turn as the model's previous answer.
3618
+ *
3619
+ * It exists because auto mode's silent round continuations have to be VISIBLE:
3620
+ * a turn whose budget quietly grew is the "why is it still running?" report
3621
+ * this app keeps getting, and the CLI announces the same continuation with the
3622
+ * same wording in its transcript.
3623
+ */
3624
+ function appendTurnNote(row, text, beforeSelector) {
3625
+ if (!row || !text) return null;
3626
+ const note = document.createElement('div');
3627
+ note.className = 'turn-note';
3628
+ note.textContent = text;
3629
+ const before = beforeSelector ? row.querySelector(beforeSelector) : null;
3630
+ if (before) row.insertBefore(note, before);
3631
+ else row.appendChild(note);
3632
+ return note;
3633
+ }
3634
+
3611
3635
  /**
3612
3636
  * Render the round-horizon card (`onDelta`'s `{ rounds: { id, spent,
3613
3637
  * extensions, max, size } }` chunk — see `requestRoundsAsk` in
@@ -4160,6 +4184,12 @@ async function saveLocalBase(baseURL) {
4160
4184
 
4161
4185
  async function loadSettings() {
4162
4186
  els.settingsList.innerHTML = '';
4187
+ // The provider ids the class offers, collected while the byok rows are built.
4188
+ // `/byok`'s own list IS the catalog, so the "add a model" field below can
4189
+ // offer it instead of asking the user to remember a slug — and it can still
4190
+ // accept a typed id, because the very case this row exists for is a provider
4191
+ // whose lineup moved ahead of our catalog.
4192
+ const byokProviderIds = [];
4163
4193
  let settings = [];
4164
4194
  try {
4165
4195
  settings = (await models.settings.get()) || [];
@@ -4198,6 +4228,7 @@ async function loadSettings() {
4198
4228
  const byokProviders = Array.isArray(byokData && byokData.providers) ? byokData.providers : [];
4199
4229
  for (const p of byokProviders) {
4200
4230
  if (!p || !p.id) continue;
4231
+ byokProviderIds.push({ id: p.id, label: p.label || p.id });
4201
4232
  const provider = `byok:${p.id}`;
4202
4233
  const local = settings.find((s) => s.provider === provider) || {
4203
4234
  provider, baseURL: '', configured: false, keyMask: null,
@@ -4234,6 +4265,195 @@ async function loadSettings() {
4234
4265
  } catch {
4235
4266
  /* catalog unreachable (offline, server down) — no rows, and the hint above says why */
4236
4267
  }
4268
+
4269
+ // The user's OWN model declarations (`client/user-models.js`, the file the
4270
+ // CLI's `/model-add` writes). Rendered outside the byok try above so it
4271
+ // survives an unreachable catalog, and it is the ONE way a desktop user can
4272
+ // pin a model the AEGIS catalog does not name — a newly shipped vendor id, a
4273
+ // fine-tune, a preview route — without waiting for a release or hand-editing
4274
+ // a JSON file the app never named. The relay is a pass-through, so anything
4275
+ // the vendor serves for a provider whose key is set is callable.
4276
+ try {
4277
+ await renderUserModelRows(byokProviderIds);
4278
+ } catch (err) {
4279
+ els.settingsHint.textContent =
4280
+ `your own models: ${err && err.message ? err.message : err}`;
4281
+ }
4282
+ }
4283
+
4284
+ /**
4285
+ * The "your own models" block: one add row plus a row per declaration.
4286
+ *
4287
+ * Everything here goes through the engine (`models.userModels.*`), never
4288
+ * through the settings store, because the engine's `listModels('byok')` is what
4289
+ * APPLIES the declarations — one implementation writes the file and one reads
4290
+ * it, so the row a user adds here is the row the picker offers on its next
4291
+ * refresh (loadModels is re-run by the callers below).
4292
+ *
4293
+ * A rejected value does not throw into the console: `add`/`remove` reject with
4294
+ * the validation message (an id the relay would forward to a vendor 404s on the
4295
+ * user's first turn, so refusing it here is the whole value of the check) and it
4296
+ * is printed in the hint line with the file path, which is also the documented
4297
+ * escape hatch for a bulk edit.
4298
+ */
4299
+ async function renderUserModelRows(byokProviderIds) {
4300
+ if (!els.settingsList || !models.userModels) return;
4301
+ const report = await models.userModels.get();
4302
+ if (!report) return;
4303
+ const map = (report && report.map) || {};
4304
+ const ids = (byokProviderIds || []).map((p) => (p && p.id) || '').filter(Boolean);
4305
+
4306
+ const block = document.createElement('div');
4307
+ block.id = 'user-models-block';
4308
+
4309
+ const title = document.createElement('div');
4310
+ title.className = 'setting-name';
4311
+ title.textContent = 'Your own models (BYOK)';
4312
+ block.appendChild(title);
4313
+
4314
+ // ── add: provider + model id + one button
4315
+ const addRow = document.createElement('div');
4316
+ addRow.className = 'setting-row';
4317
+ addRow.dataset.provider = 'byok:__user-model-add';
4318
+
4319
+ const providerInput = document.createElement('input');
4320
+ providerInput.type = 'text';
4321
+ providerInput.className = 'setting-input';
4322
+ providerInput.id = 'user-model-provider';
4323
+ providerInput.placeholder = 'provider (deepseek)';
4324
+ providerInput.autocomplete = 'off';
4325
+ providerInput.spellcheck = false;
4326
+ // A datalist rather than a select: the catalog's ids are a convenience for the
4327
+ // common case, and a typed slug is still allowed for the case this row exists
4328
+ // for (the catalog has not caught up). A select would have made the second one
4329
+ // impossible without a form of its own.
4330
+ if (ids.length) {
4331
+ const list = document.createElement('datalist');
4332
+ list.id = 'user-model-providers';
4333
+ for (const id of ids) {
4334
+ const opt = document.createElement('option');
4335
+ opt.value = id;
4336
+ list.appendChild(opt);
4337
+ }
4338
+ addRow.appendChild(list);
4339
+ providerInput.setAttribute('list', 'user-model-providers');
4340
+ }
4341
+ addRow.appendChild(providerInput);
4342
+
4343
+ const modelInput = document.createElement('input');
4344
+ modelInput.type = 'text';
4345
+ modelInput.className = 'setting-input';
4346
+ modelInput.id = 'user-model-id';
4347
+ modelInput.placeholder = 'model id (deepseek-v4.2-preview)';
4348
+ modelInput.autocomplete = 'off';
4349
+ modelInput.spellcheck = false;
4350
+ addRow.appendChild(modelInput);
4351
+
4352
+ const actions = document.createElement('div');
4353
+ actions.className = 'setting-actions';
4354
+ const addBtn = document.createElement('button');
4355
+ addBtn.type = 'button';
4356
+ addBtn.className = 'ghost-btn';
4357
+ addBtn.id = 'user-model-add';
4358
+ addBtn.textContent = 'Add model';
4359
+ addBtn.addEventListener('click', async () => {
4360
+ const provider = providerInput.value.trim();
4361
+ const model = modelInput.value.trim();
4362
+ els.settingsHint.textContent = 'adding…';
4363
+ try {
4364
+ const res = await models.userModels.add(provider, model);
4365
+ els.settingsHint.textContent = res && res.added === false
4366
+ ? `${res.provider}:${res.model} was already declared.`
4367
+ : `added ${res.provider}:${res.model} — /models and the picker now offer it.`;
4368
+ await loadSettings();
4369
+ await loadModels(els.classSelect.value);
4370
+ } catch (err) {
4371
+ els.settingsHint.textContent = `${err && err.message ? err.message : err}`;
4372
+ }
4373
+ });
4374
+ actions.appendChild(addBtn);
4375
+ addRow.appendChild(actions);
4376
+
4377
+ const hint = document.createElement('div');
4378
+ hint.className = 'setting-status';
4379
+ hint.id = 'user-model-hint';
4380
+ hint.textContent = report.file
4381
+ ? `saved in ${report.file} — the CLI reads the same file (/model-add)`
4382
+ : 'saved beside your AEGIS config — the CLI reads the same file (/model-add)';
4383
+ addRow.appendChild(hint);
4384
+ block.appendChild(addRow);
4385
+
4386
+ // ── one removal row per declaration, provider-major and sorted, so the list
4387
+ // is stable between renders (an order that shuffled would make the Remove
4388
+ // button beside a row a guess).
4389
+ const providers = Object.keys(map).sort();
4390
+ for (const provider of providers) {
4391
+ for (const model of map[provider]) {
4392
+ const row = document.createElement('div');
4393
+ row.className = 'setting-row';
4394
+ row.dataset.provider = `byok:${provider}`;
4395
+
4396
+ const name = document.createElement('div');
4397
+ name.className = 'setting-name';
4398
+ name.textContent = `${provider} — ${model}`;
4399
+ row.appendChild(name);
4400
+
4401
+ const status = document.createElement('div');
4402
+ status.className = 'setting-status';
4403
+ // Declared, but not offered: the server's BYOK catalog lists no row for
4404
+ // this provider, so no key could ever be attached to it and the engine
4405
+ // will not paint it. Saying so here is the difference between "my typo is
4406
+ // being ignored" and "this provider cannot take a key".
4407
+ status.textContent = ids.length && !ids.includes(provider)
4408
+ ? 'no provider row for this id — /byok lists the ones the relay takes'
4409
+ : 'offered in the picker on this machine';
4410
+ row.appendChild(status);
4411
+
4412
+ const rowActions = document.createElement('div');
4413
+ rowActions.className = 'setting-actions';
4414
+ const rmBtn = document.createElement('button');
4415
+ rmBtn.type = 'button';
4416
+ rmBtn.className = 'ghost-btn danger';
4417
+ rmBtn.textContent = 'Remove';
4418
+ rmBtn.addEventListener('click', async () => {
4419
+ els.settingsHint.textContent = 'removing…';
4420
+ try {
4421
+ const res = await models.userModels.remove(provider, model);
4422
+ els.settingsHint.textContent = res && res.removed === false
4423
+ ? `${provider}:${model} was not declared.`
4424
+ : `removed ${provider}:${model}.`;
4425
+ await loadSettings();
4426
+ await loadModels(els.classSelect.value);
4427
+ } catch (err) {
4428
+ els.settingsHint.textContent = `${err && err.message ? err.message : err}`;
4429
+ }
4430
+ });
4431
+ rowActions.appendChild(rmBtn);
4432
+ row.appendChild(rowActions);
4433
+
4434
+ block.appendChild(row);
4435
+ }
4436
+ }
4437
+
4438
+ // A hand-edited file can carry a row this module refuses (a typo, an id with a
4439
+ // space). It is named rather than dropped in silence: otherwise "my model is
4440
+ // nowhere in the picker" has no explanation anywhere in the UI.
4441
+ const invalid = (report && report.invalid) || [];
4442
+ for (const bad of invalid) {
4443
+ const row = document.createElement('div');
4444
+ row.className = 'setting-row';
4445
+ const name = document.createElement('div');
4446
+ name.className = 'setting-name';
4447
+ name.textContent = `${bad.provider || 'models.json'}: ${bad.model == null ? '' : String(bad.model)}`.trim();
4448
+ row.appendChild(name);
4449
+ const status = document.createElement('div');
4450
+ status.className = 'setting-status';
4451
+ status.textContent = `skipped — ${bad.reason}`;
4452
+ row.appendChild(status);
4453
+ block.appendChild(row);
4454
+ }
4455
+
4456
+ els.settingsList.appendChild(block);
4237
4457
  }
4238
4458
 
4239
4459
  async function saveSetting(provider, key) {
@@ -4663,6 +4883,19 @@ async function send() {
4663
4883
  paintStream();
4664
4884
  return;
4665
4885
  }
4886
+ if (chunk && typeof chunk.note === 'string' && chunk.note) {
4887
+ // A turn-level notice that is NOT the model's words — today the only one
4888
+ // is auto mode's "continuing automatically" line (desktop/main.js
4889
+ // chatWithAutoRounds). It is deliberately not folded into
4890
+ // `streamedText`: that string becomes the assistant's message and is
4891
+ // persisted and synced, so an app-authored sentence put there would
4892
+ // re-enter the model's context on the next turn as something it had said.
4893
+ const row = ensurePendingRow();
4894
+ row.classList.remove('pending');
4895
+ appendTurnNote(row, chunk.note, '.body');
4896
+ stickToBottom();
4897
+ return;
4898
+ }
4666
4899
  if (chunk && chunk.approval) {
4667
4900
  const row = ensurePendingRow();
4668
4901
  row.classList.remove('pending');
@@ -1543,6 +1543,20 @@ label {
1543
1543
  color: var(--text2);
1544
1544
  }
1545
1545
 
1546
+ /* An app-authored turn note ({ note } frames — today auto mode's "continuing
1547
+ automatically" line). Muted and unboxed on purpose: it is a statement about
1548
+ the turn's budget, not a question, and it must not read as either of the two
1549
+ cards that ARE questions (approval, round horizon). */
1550
+ .turn-note {
1551
+ font-family: var(--font-mono);
1552
+ font-size: 11px;
1553
+ color: var(--text2);
1554
+ border-left: 2px solid var(--accent);
1555
+ padding: 2px 0 2px 8px;
1556
+ margin: 4px 0;
1557
+ opacity: 0.9;
1558
+ }
1559
+
1546
1560
  .approval-title {
1547
1561
  font-family: var(--font-mono);
1548
1562
  font-size: 11px;