aegis-desktop 0.8.8 → 0.8.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/README.md +6 -0
  2. package/lib/avatar/turn.js +1 -1
  3. package/lib/local/autonomous.js +2 -2
  4. package/lib/local/engine.js +91 -39
  5. package/lib/provider-key-mirror.js +136 -0
  6. package/main.js +29 -0
  7. package/package.json +5 -4
  8. package/renderer/app.js +40 -1
  9. package/renderer/fonts/4iCs6KVjbNBYlgoKcQ72j00.woff2 +0 -0
  10. package/renderer/fonts/4iCs6KVjbNBYlgoKcg72j00.woff2 +0 -0
  11. package/renderer/fonts/4iCs6KVjbNBYlgoKcw72j00.woff2 +0 -0
  12. package/renderer/fonts/4iCs6KVjbNBYlgoKew72j00.woff2 +0 -0
  13. package/renderer/fonts/4iCs6KVjbNBYlgoKfA72j00.woff2 +0 -0
  14. package/renderer/fonts/4iCs6KVjbNBYlgoKfw72.woff2 +0 -0
  15. package/renderer/fonts/4iCu6KVjbNBYlgoKej70l0k.woff2 +0 -0
  16. package/renderer/fonts/4iCu6KVjbNBYlgoKej73l0mwFg.woff2 +0 -0
  17. package/renderer/fonts/4iCu6KVjbNBYlgoKej74l0mwFg.woff2 +0 -0
  18. package/renderer/fonts/4iCu6KVjbNBYlgoKej75l0mwFg.woff2 +0 -0
  19. package/renderer/fonts/4iCu6KVjbNBYlgoKej76l0mwFg.woff2 +0 -0
  20. package/renderer/fonts/4iCu6KVjbNBYlgoKej7wl0mwFg.woff2 +0 -0
  21. package/renderer/fonts/4iCv6KVjbNBYlgoCjC3js2yNL4U.woff2 +0 -0
  22. package/renderer/fonts/4iCv6KVjbNBYlgoCjC3jsGyN.woff2 +0 -0
  23. package/renderer/fonts/4iCv6KVjbNBYlgoCjC3jtGyNL4U.woff2 +0 -0
  24. package/renderer/fonts/4iCv6KVjbNBYlgoCjC3jvGyNL4U.woff2 +0 -0
  25. package/renderer/fonts/4iCv6KVjbNBYlgoCjC3jvWyNL4U.woff2 +0 -0
  26. package/renderer/fonts/4iCv6KVjbNBYlgoCjC3jvmyNL4U.woff2 +0 -0
  27. package/renderer/fonts/4iCv6KVjbNBYlgoCxCvjs2yNL4U.woff2 +0 -0
  28. package/renderer/fonts/4iCv6KVjbNBYlgoCxCvjsGyN.woff2 +0 -0
  29. package/renderer/fonts/4iCv6KVjbNBYlgoCxCvjtGyNL4U.woff2 +0 -0
  30. package/renderer/fonts/4iCv6KVjbNBYlgoCxCvjvGyNL4U.woff2 +0 -0
  31. package/renderer/fonts/4iCv6KVjbNBYlgoCxCvjvWyNL4U.woff2 +0 -0
  32. package/renderer/fonts/4iCv6KVjbNBYlgoCxCvjvmyNL4U.woff2 +0 -0
  33. package/renderer/fonts/8vIH7w4qzmVxm25L9Hz_.woff2 +0 -0
  34. package/renderer/fonts/8vIH7w4qzmVxm2BL9A.woff2 +0 -0
  35. package/renderer/fonts/8vIH7w4qzmVxm2NL9Hz_.woff2 +0 -0
  36. package/renderer/fonts/NOTICE.md +57 -0
  37. package/renderer/fonts/aFTR7PB1QTsUX8KYvrGyEY2tbZX9.woff2 +0 -0
  38. package/renderer/fonts/aFTR7PB1QTsUX8KYvrGyEYOtbQ.woff2 +0 -0
  39. package/renderer/fonts/aFTR7PB1QTsUX8KYvumzEY2tbZX9.woff2 +0 -0
  40. package/renderer/fonts/aFTR7PB1QTsUX8KYvumzEYOtbQ.woff2 +0 -0
  41. package/renderer/fonts/aFTU7PB1QTsUX8KYthSQBLyM.woff2 +0 -0
  42. package/renderer/fonts/aFTU7PB1QTsUX8KYthqQBA.woff2 +0 -0
  43. package/renderer/fonts/aFTW7PB1QTsUX8KYth-gBqSI.woff2 +0 -0
  44. package/renderer/fonts/aFTW7PB1QTsUX8KYth-gCKSIUKw.woff2 +0 -0
  45. package/renderer/fonts/fonts.css +368 -0
  46. package/renderer/index.html +6 -7
  47. package/renderer/quick.css +28 -3
  48. package/renderer/quick.html +4 -1
  49. package/renderer/style.css +14 -2
  50. package/vendor/brain-catalog.js +188 -0
  51. package/vendor/byok-catalog.js +144 -0
  52. package/vendor/env-file.js +398 -11
package/README.md CHANGED
@@ -326,3 +326,9 @@ host over the same engine.
326
326
  [`aegis-desktop`](https://www.npmjs.com/package/aegis-desktop) is built from a
327
327
  `git subtree split` of it — same code, published without the rest of the
328
328
  monorepo. Edits land here and are synced out; nothing is authored elsewhere.
329
+
330
+ ---
331
+
332
+ Built by **Niklas Borneklint** — [aegiscloud.org](https://aegiscloud.org) · [@aegisinfo](https://github.com/aegisinfo)
333
+
334
+ Part of the ÆGIS ecosystem.
@@ -107,7 +107,7 @@ function recallPolicy(caps, opts = {}) {
107
107
  * - the pooled brain tier (`nexus-brain` and the `-smart`/`-neo` spellings the
108
108
  * server serves as aliases), which is not one model at all: the server fans
109
109
  * the turn out to reasoning workers plus a synthesis pass. That is the one
110
- * id the Aegis Cloud class offers (engine.js filterAegisCatalog), so an
110
+ * id the Aegis Cloud class offers (engine.js offerableCatalog), so an
111
111
  * Aegis Cloud turn satisfies the floor by construction.
112
112
  */
113
113
  const REASONING_MODEL_RE = /^(?:deepseek-(?:v4(?:\.\d+)?-(?:flash|pro)|flash|pro|reasoner)|(?:nexus|aegis)-brain(?:-(?:smart|neo))?)$/;
@@ -84,7 +84,7 @@ function writtenPath(tool) {
84
84
  * The default AEGIS Cloud model for autonomous work: the pooled brain, which
85
85
  * is the tier the server can fan out to multiple reasoning workers and
86
86
  * synthesise. `nexus-brain` is the canonical id the catalog itself prefers
87
- * (the other tier spellings are aliases of it — see filterAegisCatalog in
87
+ * (the other tier spellings are aliases of it — see offerableCatalog in
88
88
  * engine.js).
89
89
  *
90
90
  * The model id is only the tier. It is NOT what makes an autonomous task
@@ -108,7 +108,7 @@ const DEFAULT_MODEL = 'nexus-brain';
108
108
  * the pool auto-route across whichever providers hold a live key.
109
109
  *
110
110
  * The accept-list is therefore the pooled-brain tier family — the one entry
111
- * engine.js's filterAegisCatalog offers for the Aegis Cloud class, plus the
111
+ * engine.js's offerableCatalog offers for the Aegis Cloud class, plus the
112
112
  * `-smart`/`-neo` tier spellings the server still serves as aliases of it. This
113
113
  * mirrors selectBrainEntry() there rather than re-deriving "anything starting
114
114
  * with nexus-": `nexus-fast` is not a tier the catalog has ever served, and
@@ -76,6 +76,63 @@ try {
76
76
  }
77
77
  }
78
78
 
79
+ /**
80
+ * The shared pooled-brain selection rule (`client/brain-catalog.js`), resolved
81
+ * the same two ways as every other shared module: repo-relative from a source
82
+ * checkout, and this app's own staged tree (`desktop/vendor/brain-catalog.js`,
83
+ * flattened by scripts/predist.mjs because a packaged app cannot reach outside
84
+ * its own directory). The CLI resolves the same file its own way, through
85
+ * `cli/src/sharedpaths.js`. Both hosts must agree on which entry stands for the
86
+ * pool, so there is deliberately no fallback implementation here — a silently
87
+ * divergent local copy is the exact bug this module removes. When neither
88
+ * layout has it, require() throws at load time naming the file, rather than the
89
+ * host quietly offering a different model list than its sibling.
90
+ */
91
+ function requireSharedBrain() {
92
+ const candidates = [
93
+ () => require('../../../client/brain-catalog.js'),
94
+ () => require('../../vendor/brain-catalog.js'),
95
+ ];
96
+ let last = null;
97
+ for (const load of candidates) {
98
+ try {
99
+ return load();
100
+ } catch (err) {
101
+ last = err;
102
+ }
103
+ }
104
+ throw last;
105
+ }
106
+
107
+ /**
108
+ * The shared BYOK model additions (`client/byok-catalog.js`), resolved exactly
109
+ * the two ways above and for the same reason: the CLI vendors THIS file
110
+ * (cli/scripts/predist.mjs) and requires the same module through
111
+ * `cli/src/sharedpaths.js`, so a copy-per-host here is how the terminal and the
112
+ * window would come to offer one account two different provider model lists —
113
+ * the defect class this repo keeps paying for.
114
+ *
115
+ * The module is pure and returns a NEW array, so a caller may apply it to a
116
+ * payload it did not build. It never invents a provider: a provider absent from
117
+ * the server's catalog stays absent, because a row that accepts a vendor key
118
+ * must correspond to a slug the relay actually takes.
119
+ */
120
+ function requireSharedByokCatalog() {
121
+ const candidates = [
122
+ () => require('../../../client/byok-catalog.js'),
123
+ () => require('../../vendor/byok-catalog.js'),
124
+ ];
125
+ let last = null;
126
+ for (const load of candidates) {
127
+ try {
128
+ return load();
129
+ } catch (err) {
130
+ last = err;
131
+ }
132
+ }
133
+ throw last;
134
+ }
135
+
79
136
  /**
80
137
  * How long a turn waits for the working-tree lock before running anyway.
81
138
  *
@@ -244,48 +301,36 @@ function normalizeCatalog(models) {
244
301
  }
245
302
 
246
303
  /**
247
- * The Aegis Cloud catalog (`/api/v1/models`) lists every backend the pool can
248
- * reach: per-provider ids (`openai`, `anthropic`, `groq`, `gemini`, ...) and
249
- * six pooled-brain tier ids (`{aegis,nexus}-brain[-smart|-neo]`) that all run
250
- * the same worker pool on the same backend model. None of that is a human's
251
- * model choice — which providers currently hold a valid key is an ops detail
252
- * (today: deepseek/anthropic/groq; openai and gemini drift in and out), and
253
- * surfacing it invites picking a provider that happens to be dead right now.
254
- * The pool already auto-routes across whichever providers are live, so the
255
- * desktop dropdown offers exactly one entry for the "aegis" class: the
256
- * collapsed "Nexus" brain — never the raw provider list.
304
+ * The Aegis Cloud (pooled) dropdown offers Nexus, and only Nexus.
305
+ *
306
+ * The catalog (`/api/v1/models`) lists per-provider ids (`deepseek`,
307
+ * `anthropic`, `groq`, `openai`, ...) alongside five pooled-brain tier
308
+ * spellings (`{aegis,nexus}-brain[-smart|-neo]`) that all route the same
309
+ * worker pool. This host briefly offered the full list — the per-provider ids
310
+ * included — on the theory that a funded account should be able to pin the
311
+ * exact model it pays for. Niklas's explicit instruction is narrower: AEGIS
312
+ * Cloud offers Nexus only, no per-provider pin, so this is back on
313
+ * `filterAegisCatalog` — the shared rule's single-row collapse.
314
+ *
315
+ * The rule itself lives in `client/brain-catalog.js`, shared with the CLI, so
316
+ * there is exactly one copy of it: two copies is how this host and the
317
+ * terminal previously came to offer the same account two different model
318
+ * lists. `filterAegisCatalog` collapses the five brain-tier spellings to the
319
+ * one row labelled Nexus; a payload with no brain tier (the BYOK listing) is
320
+ * unaffected — see the desktop's byok branch below, which still offers every
321
+ * provider id the account holds a key for. Resolved the two ways this repo
322
+ * resolves every shared module — repo-relative in a checkout, and this app's
323
+ * staged vendor/ tree.
257
324
  */
258
- const NEXUS_BRAIN_IDS = Object.freeze(['nexus-brain', 'aegis-brain']);
259
- const NEXUS_LABEL = 'Nexus';
325
+ const { filterAegisCatalog } = requireSharedBrain();
260
326
 
261
327
  /**
262
- * Pick the one entry standing in for the pooled brain. The server serves
263
- * `nexus-brain` as the canonical tier (`hidden: false`, no `alias_of`) and
264
- * marks every other spelling — `aegis-brain` and the `-smart`/`-neo` tiers —
265
- * as `hidden: true, alias_of: "nexus-brain"`. Prefer the canonical id so the
266
- * request travels on the name the server owns; fall back to the alias, then to
267
- * any tier whose `alias_of` names the brain, so a renamed or trimmed catalog
268
- * still resolves to something selectable instead of silently emptying the
269
- * dropdown (the previous fixed-id lookup returned [] if `aegis-brain` was ever
270
- * retired, leaving the class with no model to choose).
328
+ * The BYOK additions, applied to the server's provider catalog below. Same
329
+ * one-copy rule as the brain rule above: the terminal requires this same module
330
+ * (cli/src/commands.js, through sharedpaths.js), so neither host can offer a
331
+ * provider row the other does not.
271
332
  */
272
- function selectBrainEntry(models) {
273
- return (
274
- models.find((m) => NEXUS_BRAIN_IDS.includes(m.id) && !m.hidden && m.alias_of === undefined)
275
- || models.find((m) => NEXUS_BRAIN_IDS.includes(m.id))
276
- || models.find((m) => m.alias_of && NEXUS_BRAIN_IDS.includes(m.alias_of))
277
- || null
278
- );
279
- }
280
-
281
- function filterAegisCatalog(models) {
282
- const nexus = selectBrainEntry(models);
283
- if (!nexus) return [];
284
- // Drop the alias bookkeeping: this entry *is* the selection, so the renderer
285
- // must never treat it as a hidden alias and filter it back out.
286
- const { hidden, alias_of, ...rest } = nexus;
287
- return [{ ...rest, label: NEXUS_LABEL }];
288
- }
333
+ const { withByokAdditions } = requireSharedByokCatalog();
289
334
 
290
335
  // ── Agent-loop helpers ──────────────────────────────────────────────────────
291
336
 
@@ -790,7 +835,14 @@ function createLocalEngine({
790
835
  let fee = null;
791
836
  try {
792
837
  const data = await aegis.byokProviders();
793
- providers = (data && data.providers) || [];
838
+ // The server's catalog, plus the ids it does not name but the vendor
839
+ // serves and the relay will forward (`client/byok-catalog.js`, shared
840
+ // with the CLI so both hosts offer the same rows). Applied HERE, at the
841
+ // single point where the payload enters this engine, so every consumer
842
+ // of `providers` — the model list built just below, the renderer's
843
+ // per-provider Settings rows, and the CLI's own `/byok` listing when it
844
+ // runs on this engine — sees one list instead of three opinions.
845
+ providers = withByokAdditions((data && data.providers) || []);
794
846
  // The handling fee AEGIS adds on top of the caller's vendor bill. It is
795
847
  // the server's own published rate (services/pricing.price_byok_call) and
796
848
  // is passed through untouched — never re-derived here, because a client
@@ -0,0 +1,136 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * provider-key-mirror.js — make a provider key saved in the DESKTOP visible to
5
+ * every other host on the same machine.
6
+ *
7
+ * The desktop's Provider settings rows persist into its own encrypted settings
8
+ * store (`desktop/lib/settings.js` -> `settings.json` under the app's userData
9
+ * dir). That store is private to this app: `aegiscode` (the CLI) and the MCP
10
+ * plugin never read it, and they cannot — it is an encrypted Electron store in
11
+ * another process's data directory. So a key added in the desktop was invisible
12
+ * to the CLI on the next run: same machine, same provider, key asked for twice.
13
+ *
14
+ * The fix is not to teach every host to open an encrypted Electron store. It is
15
+ * to write the key where all four hosts already look at start-up —
16
+ * `~/.aegiscode/.env`, through the ONE shared writer (`client/env-file.js`
17
+ * `setEnvValue`). After this, "add your provider key in the app" and "put it in
18
+ * ~/.aegiscode/.env" are the same instruction, which is the point.
19
+ *
20
+ * Policy, deliberately narrow:
21
+ *
22
+ * - PROVIDER keys only. The AEGIS *account* key is already shared through
23
+ * `credentials.json` (`client/credentials.js`), which the CLI, the MCP
24
+ * plugin and the desktop all read. A second copy in the env file would be a
25
+ * second authority that can silently disagree with the first — exactly the
26
+ * "which one won?" failure env-file.js refuses for duplicate lines. So
27
+ * `setAegisKey()` is deliberately NOT wrapped.
28
+ * - The variable name comes from `envFile.envVarFor(provider)`, the same
29
+ * mapping the CLI's model picker uses (`keyForModelId`), and must end in
30
+ * `_API_KEY`. A name we cannot map is a name we do not invent: an
31
+ * unrecognised provider leaves the file untouched rather than growing an
32
+ * unrelated variable.
33
+ * - Removing a key only touches the file if the file actually carries that
34
+ * variable, so "Remove" on a provider the file never knew about does not
35
+ * add a stray `OPENAI_API_KEY=` line to a hand-written file.
36
+ * - A failure to mirror is REPORTED and swallowed. The key IS saved (the
37
+ * encrypted store has it); only the cross-host copy failed, and refusing
38
+ * the whole save over that would throw away the key the user just typed.
39
+ */
40
+
41
+ /** Reserved namespaces (`__aegis`, `__quickLauncher`, …) are the store's own
42
+ * bookkeeping, not providers — never a key variable. */
43
+ const RESERVED_PREFIX = '__';
44
+
45
+ /** Every provider key variable in this product family ends this way. */
46
+ const KEY_VAR_SUFFIX = /_API_KEY$/;
47
+
48
+ /**
49
+ * Write one provider key into the shared env file.
50
+ *
51
+ * @param {object} envFile client/env-file.js (injected: this module must stay
52
+ * unit-testable without touching the real home dir)
53
+ * @param {string} provider provider id, e.g. 'openai'
54
+ * @param {string} key the key; '' means "clear it"
55
+ * @param {object} [opts] `onWarn(message)`, `envOpts` forwarded to env-file
56
+ * (its `{ file, env }` seam, used by tests)
57
+ * @returns {object|null} env-file's own result, or null when nothing was
58
+ * mirrored (no mapping, reserved row, absent variable)
59
+ */
60
+ function mirrorProviderKeyToEnvFile(envFile, provider, key, opts = {}) {
61
+ const warn = typeof opts.onWarn === 'function' ? opts.onWarn : () => {};
62
+ const envOpts = opts.envOpts || {};
63
+ if (!envFile || typeof envFile.setEnvValue !== 'function') return null;
64
+ if (typeof provider !== 'string' || !provider) return null;
65
+ if (provider.startsWith(RESERVED_PREFIX)) return null;
66
+
67
+ const name = typeof envFile.envVarFor === 'function' ? envFile.envVarFor(provider) : '';
68
+ if (!name || !KEY_VAR_SUFFIX.test(name)) return null;
69
+
70
+ try {
71
+ if (key) return envFile.setEnvValue(name, key, envOpts);
72
+ const status =
73
+ typeof envFile.keyStatus === 'function' ? envFile.keyStatus(name, envOpts) : null;
74
+ if (!status || !status.inFile) return null;
75
+ if (typeof envFile.clearEnvValue === 'function') {
76
+ return envFile.clearEnvValue(name, envOpts);
77
+ }
78
+ // An older vendored env-file without a remover. Writing an empty value is
79
+ // NOT a removal — `setEnvValue` refuses an empty value outright, so that
80
+ // call returned an error and left the stale line in place. Unset the live
81
+ // variable and say plainly that the file still carries it.
82
+ if (envOpts.env) delete envOpts.env[name];
83
+ else delete process.env[name];
84
+ warn(
85
+ `removed ${provider}'s key in the app, but ${name} is still in the shared env file — `
86
+ + 'this build has no clearEnvValue; delete the line by hand',
87
+ );
88
+ return null;
89
+ } catch (e) {
90
+ warn(`could not mirror ${provider}'s key into the shared env file: ${e && e.message}`);
91
+ return null;
92
+ }
93
+ }
94
+
95
+ /**
96
+ * Wrap a provider-settings store so every key saved or removed through it is
97
+ * also written to the shared env file.
98
+ *
99
+ * Wrapping the store (rather than each caller) is the whole point: the Settings
100
+ * pane, the welcome flow's provider rows and any future caller all funnel
101
+ * through `store.set` / `store.remove`, so there is exactly one place that can
102
+ * forget to mirror — and this is it.
103
+ *
104
+ * @param {object} store a settings store (settings.get/set/remove/list)
105
+ * @param {object} envFile client/env-file.js
106
+ * @param {object} [opts] `onWarn`, `envOpts` (see above)
107
+ * @returns {object} the same store, wrapped
108
+ */
109
+ function wrapProviderKeyMirror(store, envFile, opts = {}) {
110
+ if (!store || typeof store.set !== 'function') return store;
111
+ const mirror = (provider, key) => mirrorProviderKeyToEnvFile(envFile, provider, key, opts);
112
+
113
+ const rawSet = store.set.bind(store);
114
+ const rawRemove = typeof store.remove === 'function' ? store.remove.bind(store) : null;
115
+
116
+ store.set = (provider, cfg) => {
117
+ const res = rawSet(provider, cfg);
118
+ // A row with no key is a base-URL/local-daemon row: there is no secret to
119
+ // share, and mirroring would write an empty variable into a file every host
120
+ // sources. `cfg.key` undefined OR empty both mean "no key supplied"
121
+ // (a blank field in the pane means "keep the stored one").
122
+ if (cfg && typeof cfg.key === 'string' && cfg.key) mirror(provider, cfg.key);
123
+ return res;
124
+ };
125
+
126
+ if (rawRemove) {
127
+ store.remove = (provider) => {
128
+ const res = rawRemove(provider);
129
+ mirror(provider, '');
130
+ return res;
131
+ };
132
+ }
133
+ return store;
134
+ }
135
+
136
+ module.exports = { mirrorProviderKeyToEnvFile, wrapProviderKeyMirror };
package/main.js CHANGED
@@ -2600,6 +2600,31 @@ function bootstrap() {
2600
2600
  dir: dataDir,
2601
2601
  });
2602
2602
 
2603
+ // A provider key typed in the Settings pane used to reach ONLY this app's
2604
+ // encrypted store, so `aegiscode` (the CLI) and the MCP plugin never saw it:
2605
+ // same machine, same provider, key asked for twice, and a key entered here
2606
+ // was gone from every other host on the next run. Mirror every key write into
2607
+ // the shared `~/.aegiscode/.env` — the one file all four hosts read at
2608
+ // start-up (loadEnvFile() above; cli/bin/aegiscode.js before the CLI runs) —
2609
+ // so "add the key in the app" and "put it in ~/.aegiscode/.env" are the same
2610
+ // instruction. The encrypted store stays this app's authority; the file is
2611
+ // what makes the key exist for the others. The AEGIS *account* key is
2612
+ // deliberately not mirrored: it is already shared via credentials.json, and a
2613
+ // second copy is a second authority that can disagree (see the module's
2614
+ // header). Wrapped before registerModelIpc below, so every IPC path uses the
2615
+ // wrapped store.
2616
+ try {
2617
+ const { wrapProviderKeyMirror } = require('./lib/provider-key-mirror.js');
2618
+ const onWarn = (m) => console.warn(`aegis: ${m}`);
2619
+ wrapProviderKeyMirror(settings, envFile, { onWarn });
2620
+ if (engine && engine.settings && engine.settings !== settings) {
2621
+ wrapProviderKeyMirror(engine.settings, envFile, { onWarn });
2622
+ }
2623
+ } catch (e) {
2624
+ // Never fatal: the store still works, only the cross-host copy is missing.
2625
+ console.warn(`aegis: could not wire the shared env-file key mirror: ${e && e.message}`);
2626
+ }
2627
+
2603
2628
  // Persist the in-app AEGIS key in its own reserved namespace, encrypted —
2604
2629
  // never as a provider named 'aegis' (that coupling let the Settings pane's
2605
2630
  // "Remove" delete the AEGIS key; defect #1).
@@ -2610,6 +2635,10 @@ function bootstrap() {
2610
2635
  // `savedAt` stamps so whichever host wrote last is the key in force.
2611
2636
  const persistApiKey = (key) => {
2612
2637
  const result = settings.setAegisKey(key);
2638
+ // NOT mirrored into ~/.aegiscode/.env on purpose — the line below already
2639
+ // shares this key with every host (credentials.json, 0600, read by the CLI,
2640
+ // the MCP plugin and this app). Two stores for one account key is two
2641
+ // answers to "which key is in force"; see desktop/lib/provider-key-mirror.js.
2613
2642
  if (key) credentials.saveApiKey(key);
2614
2643
  else credentials.clearApiKey();
2615
2644
  return result;
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "aegis-desktop",
3
3
  "productName": "AEGIS Desktop",
4
- "version": "0.8.8",
5
- "description": "Thin Electron host for AEGIS \u2014 a local chat UI over the shared client/aegis.js transport. Ships transport + UI only; engine logic stays server-side.",
4
+ "version": "0.8.10",
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",
8
8
  "email": "nborneklint@gmail.com"
@@ -32,7 +32,7 @@
32
32
  "demo": "node scripts/record-demo-gif.mjs",
33
33
  "dist": "npm run predist && electron-builder",
34
34
  "dist:dir": "npm run predist && electron-builder --dir",
35
- "check": "node --check main.js && node --check preload.js && node --check renderer/app.js && node --check renderer/quick.js && node --check renderer/budget.js && node --check renderer/usage.js && node --check renderer/stream-policy.js && node --check renderer/transcript-view.js && node --check renderer/diff.js && node --check renderer/diffview.js && node --check renderer/markdown.js && node --check renderer/vendor/aegis-highlight.js && node --check scripts/predist.mjs && node --check scripts/generate-icon.mjs && node --check lib/local/local.js && node --check lib/local/context.js && node --check lib/local/engine.js && node --check lib/local/session-rounds.js && node --check lib/local/tools.js && node --check lib/local/prompt.js && node --check lib/local/shell.js && node --check lib/local/agents.js && node --check lib/local/queue.js && node --check lib/local/autonomous.js && node --check lib/settings.js && node --check lib/sync/sessions.js && node --check lib/sync/memory-queue.js && node --check lib/window-state.js && node --check lib/deep-link.js && node --check lib/quick-launcher.js && node --check lib/avatar/tiers.js && node --check lib/avatar/xp.js && node --check lib/avatar/level.js && node --check lib/avatar/events.js && node --check lib/avatar/persona.js && node --check bin/aegis.js",
35
+ "check": "node --check main.js && node --check preload.js && node --check renderer/app.js && node --check renderer/quick.js && node --check renderer/budget.js && node --check renderer/usage.js && node --check renderer/stream-policy.js && node --check renderer/transcript-view.js && node --check renderer/diff.js && node --check renderer/diffview.js && node --check renderer/markdown.js && node --check renderer/vendor/aegis-highlight.js && node --check scripts/predist.mjs && node --check scripts/generate-icon.mjs && node --check scripts/bundle-fonts.mjs && node --check lib/local/local.js && node --check lib/local/context.js && node --check lib/local/engine.js && node --check lib/local/session-rounds.js && node --check lib/local/tools.js && node --check lib/local/prompt.js && node --check lib/local/shell.js && node --check lib/local/agents.js && node --check lib/local/queue.js && node --check lib/local/autonomous.js && node --check lib/settings.js && node --check lib/sync/sessions.js && node --check lib/sync/memory-queue.js && node --check lib/window-state.js && node --check lib/deep-link.js && node --check lib/quick-launcher.js && node --check lib/avatar/tiers.js && node --check lib/avatar/xp.js && node --check lib/avatar/level.js && node --check lib/avatar/events.js && node --check lib/avatar/persona.js && node --check bin/aegis.js",
36
36
  "test:shell": "node ../test/desktop-shell.mjs",
37
37
  "test:model": "node ../test/model-dispatch.mjs",
38
38
  "test:budget": "node ../test/budget.test.mjs",
@@ -52,7 +52,8 @@
52
52
  "test:avatar-level": "node test/avatar-level.test.mjs",
53
53
  "test:avatar-events": "node test/avatar-events.test.mjs",
54
54
  "test:avatar-tiers": "node test/avatar-tiers.test.mjs",
55
- "test:avatar-persona": "node test/avatar-persona.test.mjs"
55
+ "test:avatar-persona": "node test/avatar-persona.test.mjs",
56
+ "test:renderer-fonts": "node test/renderer-fonts.test.mjs"
56
57
  },
57
58
  "dependencies": {
58
59
  "electron-updater": "^6.8.9"
package/renderer/app.js CHANGED
@@ -2236,6 +2236,38 @@ function applyWelcomeConnect() {
2236
2236
  box.hidden = dismissed || keyConfigured === true || providerConfigured === true;
2237
2237
  }
2238
2238
 
2239
+ /**
2240
+ * Land the user on the ONE provider row a just-picked byok model needs a key
2241
+ * for — the per-selection counterpart to `welcomeByok()`'s whole-class reveal.
2242
+ *
2243
+ * Before this, picking an unconfigured row in the model dropdown (deliberately
2244
+ * still offered — see `loadModels()`'s `needsProviderKey` hint, which engine.js
2245
+ * documents as "the catalog answers with no key at all, hiding the row would
2246
+ * hide the answer to which key to get") did nothing: the class-level hint only
2247
+ * fires when NO provider is configured, so a machine with one working key and
2248
+ * nine unconfigured ones got no help picking a row among the nine — just a
2249
+ * turn that fails downstream at the relay with no picker-side context. This
2250
+ * is the CLI's `collectKeyFor` (chatflow.js), for this host: same question
2251
+ * (does this row need a key this machine does not have), answered by pointing
2252
+ * at where to type it rather than reading it inline, because that is how this
2253
+ * app's Settings pane already works for every other key.
2254
+ *
2255
+ * `dataset.provider` on a byok row is `byok:<id>` (see the Provider settings
2256
+ * build loop above), not the bare provider id `modelMeta` rows carry.
2257
+ */
2258
+ function focusProviderKeyRow(provider) {
2259
+ if (!els.settingsList || !provider) return;
2260
+ const row = els.settingsList.querySelector(
2261
+ `.setting-row[data-provider="byok:${provider}"] input[type="password"]`
2262
+ );
2263
+ revealSidebarCard(els.settingsList);
2264
+ if (row) row.focus();
2265
+ if (els.modelHint) {
2266
+ const base = els.modelHint.textContent.replace(/ — no .* key saved; add it below\.$/, '');
2267
+ els.modelHint.textContent = `${base} — no ${provider} key saved; add it below.`;
2268
+ }
2269
+ }
2270
+
2239
2271
  /** Scroll a sidebar card into view and mark it, so a welcome click has a
2240
2272
  * visible landing spot. The sidebar is a plain scroll container, so this is
2241
2273
  * the whole of the "open settings" affordance. */
@@ -4088,13 +4120,20 @@ async function init() {
4088
4120
  });
4089
4121
 
4090
4122
  els.modelSelect.addEventListener('change', () => {
4123
+ const meta = modelMeta.get(els.modelSelect.value);
4091
4124
  // Display-only (see budget.js): the model's own advertised output limit,
4092
4125
  // which is never the number this app puts on a request.
4093
- const ceiling = maxTokensCeiling(modelMeta.get(els.modelSelect.value));
4126
+ const ceiling = maxTokensCeiling(meta);
4094
4127
  const base = els.modelHint.textContent.replace(/ · max output: [\d,]+$/, '');
4095
4128
  els.modelHint.textContent =
4096
4129
  ceiling < FLAT_CEILING ? `${base} · max output: ${ceiling.toLocaleString()}` : base;
4097
4130
  updateBudgetControls(els.classSelect.value);
4131
+ // Picking a byok row this machine holds no key for: point at where the
4132
+ // key goes, right now, instead of a turn that fails at the relay with no
4133
+ // picker-side context. See focusProviderKeyRow's doc for why this exists.
4134
+ if (els.classSelect.value === 'byok' && meta && meta.provider && meta.configured === false) {
4135
+ focusProviderKeyRow(meta.provider);
4136
+ }
4098
4137
  });
4099
4138
 
4100
4139
  // The typed model tag IS the selection while the box is visible (see
@@ -0,0 +1,57 @@
1
+ # Vendored webfonts
2
+
3
+ Committed verbatim, like `renderer/vendor/` — no CDN and no network at run time.
4
+ Loaded by `renderer/index.html` and `renderer/quick.html` under a
5
+ `font-src 'self'` CSP.
6
+
7
+ Generated by `desktop/scripts/bundle-fonts.mjs`; do not edit the `.woff2` files
8
+ or `fonts.css` by hand. To refresh the set:
9
+
10
+ node desktop/scripts/bundle-fonts.mjs # rewrite
11
+ node desktop/scripts/bundle-fonts.mjs --check # verify against the CDN (needs network)
12
+
13
+ All three families are licensed under the **SIL Open Font License 1.1**, which
14
+ permits bundling and redistribution. The copyright lines Google Fonts serves
15
+ with them:
16
+
17
+ - `Syne` — © 2018 The Syne Project Authors
18
+ (<https://github.com/bonjour-monde/fonderie>), OFL 1.1. Display face used for
19
+ the wordmark, headings and UI chrome (`--font-display`).
20
+ - `DM Mono` — © 2020 The DM Mono Project Authors
21
+ (<https://github.com/googlefonts/dm-mono>), OFL 1.1. Monospace face for code,
22
+ diffs and metrics (`--font-mono`).
23
+ - `Ubuntu` — © 2010-2011 Canonical Ltd.
24
+ (<https://github.com/googlefonts/UbuntuFont>), OFL 1.1. Chat body text
25
+ (`--font-chat`).
26
+
27
+ The OFL is a per-file licence; the full text ships with each upstream project at
28
+ the links above and is not duplicated here.
29
+
30
+ ## Why these are vendored rather than fetched
31
+
32
+ The `<link>` to `fonts.googleapis.com` that used to sit in `index.html` made the
33
+ app's typography depend on the network and on the host. Online it drew the three
34
+ faces above. Offline — or behind a blocker, or on a machine whose font resolver
35
+ disagreed — the fallback chain `'Segoe UI', system-ui, sans-serif` resolved to a
36
+ *different* font on each platform: SF Pro on macOS, Segoe UI on Windows, whatever
37
+ fontconfig offered on Linux. Three visibly different products from one stylesheet.
38
+
39
+ Serving the same bytes from the app directory makes the rendering identical on
40
+ every platform and lets the CSP drop its external origins entirely. This is the
41
+ desktop counterpart of the terminal-side policy in `cli/src/caps.js`, which
42
+ likewise refuses to let the host change what is drawn.
43
+
44
+ Note the honest limits. These three families cover latin, latin-ext, cyrillic,
45
+ cyrillic-ext, greek and greek-ext — and **not** vietnamese: Google Fonts serves
46
+ no vietnamese subset for Syne, DM Mono or Ubuntu, and `latin-ext` stops at
47
+ U+1E9F and resumes at U+1EF2, so the core Vietnamese diacritic block
48
+ U+1EA0–U+1EF1 falls through to a host font. `desktop/scripts/bundle-fonts.mjs`
49
+ records the same gap, and `desktop/test/renderer-fonts.test.mjs` asserts it, so
50
+ the day Google adds the subset the test fails loudly instead of the gap
51
+ silently persisting.
52
+
53
+ Glyphs outside the covered set — CJK, and colour emoji in particular — are also
54
+ still drawn by a host font and remain platform-specific. `Apple Color Emoji`,
55
+ `Segoe UI Emoji` and `Noto Color Emoji` are not interchangeable and are not
56
+ redistributable here, so that divergence cannot be removed by bundling; it is
57
+ inherent to the platform.