aegis-desktop 0.8.17 → 0.8.18

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.
@@ -307,35 +307,32 @@ function normalizeCatalog(models) {
307
307
  /**
308
308
  * The Aegis Cloud (pooled) dropdown.
309
309
  *
310
- * It offers NO aegiscloud model. The catalog (`/api/v1/models`) lists
311
- * per-provider ids (`deepseek`, `anthropic`, `groq`, `openai`, ...) alongside
312
- * five pooled-brain tier spellings (`{aegis,nexus}-brain[-smart|-neo]`) that all
313
- * route the same worker pool, and this host must offer neither: "omit the
314
- * aegiscloud models" is the instruction, and a host that omitted the brain row
315
- * while leaving `deepseek` on screen would have kept exactly the per-provider
316
- * pin the Aegis Cloud policy is about. The class stays selectable and its
317
- * dropdown keeps the "server default (auto)" row, so the pooled lane still runs
318
- * — it just advertises nothing to pin.
310
+ * It offers the server's whole catalog (`/api/v1/models`): the pooled brain
311
+ * first, under its one name (Nexus), then every per-provider id the account can
312
+ * pin (`deepseek`, `anthropic`, `groq`, `openai`, ...).
313
+ *
314
+ * Two narrower shapes were shipped here and both are reverted, because both
315
+ * produced the same report — a user asking where the models went. First the
316
+ * class collapsed to a single "Nexus" row (`filterAegisCatalog`), which ended
317
+ * the two-host disagreement about the brain's five tier spellings by removing
318
+ * the choice: a funded account had `deepseek` advertised to it and no way to
319
+ * pin it. Then it offered nothing at all — `omitAegisCloudModels` composed with
320
+ * that collapse returns `[]`, because the omission removes the only entry the
321
+ * collapse looks for. The rule both hosts call now is `offerableCatalog`:
322
+ * everything the server advertises, the tier spellings folded into the one row
323
+ * they point at.
319
324
  *
320
325
  * What is NOT changed: the account key, and everything the key is *for* — the
321
326
  * pooled turn itself, cloud memory sync, and the BYOK handling fee that is
322
- * billed to this account (see the byok branch below, which still offers every
323
- * provider id the server's catalog names). Omitting a model list is not
324
- * disconnecting the lane.
325
- *
326
- * The omission itself is one shared rule (`omitAegisCloudModels`, and the
327
- * collapse it is composed with) in `client/brain-catalog.js`, shared with the
328
- * CLI: two copies is how this host and the terminal previously came to offer
329
- * the same account two different model lists. Resolved the two ways this repo
330
- * resolves every shared module — repo-relative in a checkout, and this app's
331
- * staged vendor/ tree.
327
+ * billed to this account (see the byok branch below).
332
328
  *
333
- * `filterAegisCatalog` is kept in the composition rather than dropped for the
334
- * shorter `models: []`: a payload with no brain tier at all (a self-hosted or
335
- * trimmed deployment) must still yield nothing here rather than leaking its
336
- * per-provider ids, and that is precisely the case the collapse already answers.
329
+ * The list itself is one shared rule in `client/brain-catalog.js`, shared with
330
+ * the CLI: two copies is how this host and the terminal previously came to
331
+ * offer the same account two different model lists. Resolved the two ways this
332
+ * repo resolves every shared module — repo-relative in a checkout, and this
333
+ * app's staged vendor/ tree.
337
334
  */
338
- const { filterAegisCatalog, omitAegisCloudModels } = requireSharedBrain();
335
+ const { offerableCatalog } = requireSharedBrain();
339
336
 
340
337
  /**
341
338
  * The BYOK additions, applied to the server's provider catalog below. Same
@@ -855,20 +852,16 @@ function createLocalEngine({
855
852
  // entry so the class is usable the moment a key lands.
856
853
  if (!aegis.apiKey) return { class: cls, models: [], needsKey: true };
857
854
  const data = await aegis.listModels();
858
- // No aegiscloud model is offered — see the rule above. The class, the
859
- // auto row and the pooled turn are untouched; only the pinnable list is
860
- // emptied, which is why `needsKey` keeps its meaning and the renderer's
861
- // hint for this class is unchanged.
855
+ // The whole catalog, aliases folded — see the rule above. What the list
856
+ // contains does not touch `needsKey` or the renderer's per-class hint.
862
857
  return {
863
858
  class: cls,
864
- // Omit FIRST, then collapse: the omission has to see the payload's own
865
- // `alias_of` bookkeeping, which the collapse deliberately strips (a
866
- // collapsed row IS the selection, so a renderer must not filter it back
867
- // out as a hidden alias). Filtering a collapsed row instead would leave
868
- // a renamed tier — `nexus-brain-v2`, an alias OF the brain under a
869
- // spelling this release has never seen — on screen, which is the one
870
- // case the rule exists to catch.
871
- models: filterAegisCatalog(omitAegisCloudModels(normalizeCatalog(data && data.models))),
859
+ // One call, because the folding has to see the payload's own `alias_of`
860
+ // bookkeeping: a pre-collapsed row IS the selection, so a renderer must
861
+ // not filter it back out as a hidden alias, and folding what has already
862
+ // been folded would leave a renamed tier (`nexus-brain-v2`, an alias OF
863
+ // the brain under a spelling this release has never seen) on screen.
864
+ models: offerableCatalog(normalizeCatalog(data && data.models)),
872
865
  };
873
866
  }
874
867
  if (cls === 'byok') {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "aegis-desktop",
3
3
  "productName": "AEGIS Desktop",
4
- "version": "0.8.17",
4
+ "version": "0.8.18",
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
@@ -161,6 +161,19 @@ const ELEMENT_IDS = {
161
161
  queueAutoHint: 'queue-auto-hint',
162
162
  queueBoundary: 'queue-boundary',
163
163
  queueUpgrade: 'queue-upgrade',
164
+ // The two-key pop-up (index.html #keys-overlay, driven by applyKeysPopup()).
165
+ keysOverlay: 'keys-overlay',
166
+ keysBackdrop: 'keys-backdrop',
167
+ keysClose: 'keys-close',
168
+ keysCount: 'keys-count',
169
+ keysRowAegis: 'keys-row-aegis',
170
+ keysRowByok: 'keys-row-byok',
171
+ keysAegisState: 'keys-aegis-state',
172
+ keysByokState: 'keys-byok-state',
173
+ keysGotoAegis: 'keys-goto-aegis',
174
+ keysGotoByok: 'keys-goto-byok',
175
+ keysHint: 'keys-hint',
176
+ keysDismiss: 'keys-dismiss',
164
177
  };
165
178
 
166
179
  const els = {};
@@ -895,6 +908,10 @@ function renderStatus(s) {
895
908
  // from the CLI) retires the connect block; with no key it stays. No-ops when
896
909
  // the panel is not mounted.
897
910
  applyWelcomeConnect();
911
+ // And the two-key pop-up's gate: a status read is the only thing that knows
912
+ // either fact, so this is where the sheet is opened (a key still missing) or
913
+ // retired (both present). No-ops while the markup is absent.
914
+ applyKeysPopup();
898
915
  }
899
916
 
900
917
  // -------------------------------------------------------------- auto-update
@@ -1828,6 +1845,9 @@ const memoryView = {
1828
1845
  * instead. Confusing the two is how a save would read a hidden textarea. */
1829
1846
  function overlayOpen() {
1830
1847
  if (memoryOverlayOpen()) return true;
1848
+ // The two-key pop-up is a sheet on the same layer, so Escape belongs to it
1849
+ // first while it is up — the same rule the memory inspector follows.
1850
+ if (keysOverlayOpen()) return true;
1831
1851
  // Ask the PANEL, not the module.
1832
1852
  //
1833
1853
  // `window.aegisFingerprint` is desktop/renderer/fingerprint.js, whose exports
@@ -1858,6 +1878,12 @@ function memoryOverlayOpen() {
1858
1878
  return !!els.memoryOverlay && !els.memoryOverlay.hidden;
1859
1879
  }
1860
1880
 
1881
+ /** The two-key pop-up alone. Same shape as memoryOverlayOpen(): the element,
1882
+ * not a module variable, so a sheet closed by any path reads as closed. */
1883
+ function keysOverlayOpen() {
1884
+ return !!els.keysOverlay && !els.keysOverlay.hidden;
1885
+ }
1886
+
1861
1887
  /** The φ(α) panel handle, set by init() when desktop/renderer/fingerprint.js is
1862
1888
  * loaded. Kept here rather than inside fingerprint.js so the Escape policy
1863
1889
  * above can ask it whether it is open without reaching into its internals. */
@@ -2590,6 +2616,162 @@ function dismissWelcomeConnect() {
2590
2616
  if (box) box.hidden = true;
2591
2617
  }
2592
2618
 
2619
+ // ------------------------------------------- boot: the two-key pop-up
2620
+ //
2621
+ // The welcome block above pitches the ROUTES ("connect AEGIS Cloud", "bring
2622
+ // your own key") and retires the moment either one is taken. This sheet is the
2623
+ // other question, and the one nothing on screen used to answer: this app and
2624
+ // the `aegiscode` CLI on the command line are ONE engine over ONE store, so a
2625
+ // user who added only one of the two keys gets a surface that refuses turns
2626
+ // with no explanation of the other half.
2627
+ //
2628
+ // AEGIS key — `aegis:<...>` in ~/.aegiscode/.env (AEGIS_API_KEY), the
2629
+ // account key. The CLI writes it with `/key`, this app with
2630
+ // the Status card's field. Every turn on both surfaces needs
2631
+ // it: BYOK sends are relayed through AEGIS and bill a handling
2632
+ // fee to it, so a keyless BYOK send is refused outright.
2633
+ // BYOK key — a `byok:<provider>` row in the shared settings store. The
2634
+ // CLI writes it with `/byok-key <provider>`, this app with the
2635
+ // per-provider row in Provider settings. It is the free path:
2636
+ // your own provider account, on either surface.
2637
+ //
2638
+ // Both are reported by the main process in the status payload — keyConfigured
2639
+ // (also read here for the top-up guard) and providerConfigured (main.js
2640
+ // providerRouteConfigured(), documented at desktop/lib/provider-key-mirror.js)
2641
+ // — so the sheet is driven by one read, not by a second probe of its own.
2642
+ //
2643
+ // When it opens: on the status payload, while EITHER key is missing. That is
2644
+ // the ask the copy makes ("add both — both surfaces run"), so retiring it on
2645
+ // the first key would contradict the sentence the user just read. It is a
2646
+ // pop-up and therefore dismissible: `aegis.keysPopupDismissed` is persisted
2647
+ // and read on every repaint, exactly like the welcome block's flag, because a
2648
+ // sheet that reappears after "don't show this again" is a nag, not onboarding.
2649
+ // While the key state is still unknown (null, i.e. status has not been read)
2650
+ // it stays closed: a cold boot has no key far more often than not, but opening
2651
+ // a sheet on a guess is how the app would flash a pop-up at someone who is
2652
+ // fully configured. renderStatus() calls the gate again the moment the truth
2653
+ // arrives, which is what actually opens it.
2654
+
2655
+ /** The dismiss flag. Read, not remembered — the sheet is re-gated on every
2656
+ * status repaint, so a module variable alone would bring it back. */
2657
+ const KEYS_POPUP_DISMISS_KEY = 'aegis.keysPopupDismissed';
2658
+
2659
+ /** True while either key is still missing — the sheet's whole question. Both
2660
+ * facts default to "missing" for an unknown state, matching the fail-toward-
2661
+ * showing-it bias the welcome block and the CLI's connectNeeded() share; the
2662
+ * null check is in the gate below, not here, so this stays a plain predicate
2663
+ * about two booleans. */
2664
+ function keysPopupNeeded() {
2665
+ return keyConfigured !== true || providerConfigured !== true;
2666
+ }
2667
+
2668
+ /** Paint the two state lines and the count. Never the keys themselves — only
2669
+ * "set" / "not set", the same rule the φ(α) panel follows. */
2670
+ function renderKeysPopup() {
2671
+ const aegisSet = keyConfigured === true;
2672
+ const byokSet = providerConfigured === true;
2673
+ if (els.keysAegisState) els.keysAegisState.textContent = aegisSet ? 'set' : 'not set';
2674
+ if (els.keysByokState) els.keysByokState.textContent = byokSet ? 'set' : 'not set';
2675
+ if (els.keysRowAegis) els.keysRowAegis.classList.toggle('set', aegisSet);
2676
+ if (els.keysRowByok) els.keysRowByok.classList.toggle('set', byokSet);
2677
+ if (els.keysCount) els.keysCount.textContent = `${(aegisSet ? 1 : 0) + (byokSet ? 1 : 0)} / 2`;
2678
+ if (els.keysHint) {
2679
+ els.keysHint.textContent =
2680
+ aegisSet && byokSet
2681
+ ? 'Both keys are on this machine — both surfaces run.'
2682
+ : `Missing: ${[aegisSet ? null : 'the AEGIS key', byokSet ? null : 'your provider (BYOK) key']
2683
+ .filter(Boolean)
2684
+ .join(' and ')}. Add it above, then both AEGIS Desktop and the aegiscode CLI run.`;
2685
+ }
2686
+ }
2687
+
2688
+ /** Show the sheet. Idempotent, so the gate can call it on every repaint. */
2689
+ function openKeysPopup() {
2690
+ if (!els.keysOverlay) return;
2691
+ renderKeysPopup();
2692
+ if (!els.keysOverlay.hidden) return;
2693
+ els.keysOverlay.hidden = false;
2694
+ document.body.classList.add('keys-open');
2695
+ }
2696
+
2697
+ function closeKeysPopup() {
2698
+ if (!els.keysOverlay) return;
2699
+ els.keysOverlay.hidden = true;
2700
+ document.body.classList.remove('keys-open');
2701
+ }
2702
+
2703
+ /** True while a turn is in flight — the one fact that outranks this sheet.
2704
+ *
2705
+ * Why this exists, and why it is a refusal rather than a lower z-index: the
2706
+ * sheet is composed into overlayOpen(), so while it is up Escape belongs to it
2707
+ * and is spent closing it — that is the contract every other sheet in this
2708
+ * file follows, and it is right for a sheet the USER opened. This one opens
2709
+ * ITSELF, on a status read, whenever a key is missing. So on a fresh or
2710
+ * half-configured install it is up before the first token, and Escape during a
2711
+ * streaming turn would close a pop-up instead of stopping the turn: the user's
2712
+ * only keyboard route to interrupting a runaway turn, silently spent on a
2713
+ * sheet they never asked for. It was found exactly that way — the headless
2714
+ * smoke run stopped failing to stop, with `overlayOpen: true` in the recorded
2715
+ * escape decision.
2716
+ *
2717
+ * An advisory sheet must yield to the turn, so the gate never opens one while
2718
+ * a turn is running, and the send path closes any that is already up. Nothing
2719
+ * is lost: the sheet is re-gated on the next status read after the turn ends,
2720
+ * which is a repaint that happens anyway. */
2721
+ function turnInFlight() {
2722
+ return Boolean(pendingSessionId);
2723
+ }
2724
+
2725
+ /** The gate: called on every status read (and once after the key panel is
2726
+ * painted). Dismissed wins; an unknown key state defers to the next read. */
2727
+ function applyKeysPopup() {
2728
+ const box = document.getElementById('keys-overlay');
2729
+ if (!box) return;
2730
+ let dismissed = false;
2731
+ try {
2732
+ dismissed = localStorage.getItem(KEYS_POPUP_DISMISS_KEY) === 'on';
2733
+ } catch {
2734
+ /* storage disabled — treat as not dismissed, same as the welcome block */
2735
+ }
2736
+ if (dismissed || keyConfigured === null) {
2737
+ box.hidden = true;
2738
+ return;
2739
+ }
2740
+ // Repaint before deciding: the two rows and the count describe the keys that
2741
+ // exist right now, and a repaint is what retires the last missing one — so a
2742
+ // sheet that has just become fully configured cannot close still reading
2743
+ // "1 / 2" if it is opened again this session.
2744
+ renderKeysPopup();
2745
+ // A running turn outranks the ask. Close on the way past rather than merely
2746
+ // declining to open, so a sheet already on screen when the turn began cannot
2747
+ // keep the next Escape for itself (see turnInFlight()).
2748
+ if (turnInFlight()) {
2749
+ closeKeysPopup();
2750
+ return;
2751
+ }
2752
+ if (keysPopupNeeded()) openKeysPopup();
2753
+ else closeKeysPopup();
2754
+ }
2755
+
2756
+ /** "Don't show this again": persisted, then dropped for this session. */
2757
+ function dismissKeysPopup() {
2758
+ try {
2759
+ localStorage.setItem(KEYS_POPUP_DISMISS_KEY, 'on');
2760
+ } catch {
2761
+ /* storage disabled — the sheet simply comes back next launch */
2762
+ }
2763
+ closeKeysPopup();
2764
+ }
2765
+
2766
+ /** Which key is missing, in the sheet's own words — used as the hint under the
2767
+ * field the user is dropped on, so the two surfaces say the same thing. */
2768
+ function keysMissingLine() {
2769
+ const missing = [];
2770
+ if (keyConfigured !== true) missing.push('the AEGIS key');
2771
+ if (providerConfigured !== true) missing.push('your provider (BYOK) key');
2772
+ return missing.join(' and ');
2773
+ }
2774
+
2593
2775
  // Assistant text is rendered as sanitized markdown (headings, lists, links,
2594
2776
  // highlighted fenced code with a copy button — see renderer/markdown.js);
2595
2777
  // user text always stays plain via textContent, and this is the only place
@@ -3956,6 +4138,12 @@ async function send() {
3956
4138
  if (!currentSessionId) currentSessionId = newSessionId();
3957
4139
  const sessionId = currentSessionId;
3958
4140
  pendingSessionId = sessionId;
4141
+ // The two-key sheet yields to the turn it would otherwise sit in front of:
4142
+ // Escape has to reach the interrupt, not a pop-up (see turnInFlight()). This
4143
+ // is the moment a turn begins, so it is the moment to retire one already up —
4144
+ // the gate alone would only stop it REopening, which leaves a sheet opened at
4145
+ // boot covering the whole stream.
4146
+ closeKeysPopup();
3959
4147
 
3960
4148
  // Snapshot prior turns for the model — the new prompt travels separately
3961
4149
  // as `prompt` and providers.js appends it after `messages` on the wire.
@@ -4491,6 +4679,37 @@ async function init() {
4491
4679
  renderMemoryOverlay();
4492
4680
  });
4493
4681
  }
4682
+
4683
+ // ── the two-key pop-up ───────────────────────────────────────────────────
4684
+ // Same `?`-guards as the inspector above, for the same reason: the markup is
4685
+ // optional and a missing node must degrade to "no sheet", not a boot crash.
4686
+ // Both "go to" buttons land on the field the key is actually typed into, via
4687
+ // the existing affordances (revealSidebarCard / welcomeByok), so this sheet
4688
+ // adds no second way to store a key — it only says which two exist.
4689
+ if (els.keysClose) els.keysClose.addEventListener('click', closeKeysPopup);
4690
+ if (els.keysBackdrop) els.keysBackdrop.addEventListener('click', closeKeysPopup);
4691
+ if (els.keysDismiss) els.keysDismiss.addEventListener('click', dismissKeysPopup);
4692
+ if (els.keysGotoAegis) {
4693
+ els.keysGotoAegis.addEventListener('click', () => {
4694
+ closeKeysPopup();
4695
+ revealSidebarCard(els.apiKeyInput);
4696
+ if (els.apiKeyInput) els.apiKeyInput.focus();
4697
+ if (els.apiKeyHint) {
4698
+ els.apiKeyHint.textContent =
4699
+ `Missing: ${keysMissingLine()}. This is the AEGIS account key — the same one ` +
4700
+ 'the CLI reads from ~/.aegiscode/.env, so pasting it here configures both.';
4701
+ }
4702
+ });
4703
+ }
4704
+ if (els.keysGotoByok) {
4705
+ els.keysGotoByok.addEventListener('click', () => {
4706
+ closeKeysPopup();
4707
+ // Reuses the welcome block's BYOK route rather than repeating it: it
4708
+ // switches the provider class, reveals the Provider settings card and
4709
+ // focuses the first key row — the same landing spot, one implementation.
4710
+ welcomeByok();
4711
+ });
4712
+ }
4494
4713
  // ── the autonomous queue card ────────────────────────────────────────────
4495
4714
  // The queue half of this file (window.queue -> main.js registerQueueIpc ->
4496
4715
  // desktop/lib/local/queue.js + autonomous.js) shipped with every handler
@@ -4592,6 +4811,14 @@ async function init() {
4592
4811
  // both are somehow up.
4593
4812
  isOverlayOpen: overlayOpen,
4594
4813
  onOverlayEscape: () => {
4814
+ // The keys sheet is declared right here in this file, so it is closed
4815
+ // first and needs no handle lookup: while it is up it is the outermost
4816
+ // sheet, and falling through to the memory overlay would close a panel
4817
+ // the user is not looking at.
4818
+ if (keysOverlayOpen()) {
4819
+ closeKeysPopup();
4820
+ return;
4821
+ }
4595
4822
  // Same handle, same reason as overlayOpen(): the module has no `close`,
4596
4823
  // the mounted panel does. Guessed at the module here too, which threw for
4597
4824
  // the same reason — and an overlay can only be closed by its own sheet's
@@ -745,6 +745,88 @@
745
745
  </div>
746
746
  </div>
747
747
 
748
+ <!-- The two-key pop-up. Same sheet as the memory inspector and the φ(α)
749
+ panel (the classes are shared on purpose: one translucent layer, one
750
+ Escape policy), and it exists because the app and the CLI are one engine
751
+ over one store: AEGIS Desktop reads the very `aegis:<...>` account key
752
+ and the very `byok:<provider>` rows `aegiscode` on the command line
753
+ writes, so BOTH keys are what make BOTH surfaces run.
754
+
755
+ Markup is static (no <template>) because it is opened by
756
+ openKeysPopup() from app.js on the status payload, not cloned into the
757
+ transcript. Every id below is declared in app.js's ELEMENT_IDS map and
758
+ toggled by applyKeysPopup(); the unit test slices those functions out of
759
+ app.js and drives them over a fake DOM, so a rename in either place
760
+ fails loudly. Nothing here ever displays a key — only "set" / "not set",
761
+ the same rule the φ(α) panel follows. -->
762
+ <div class="memory-overlay keys-overlay" id="keys-overlay" hidden>
763
+ <div class="memory-backdrop" id="keys-backdrop"></div>
764
+ <div
765
+ class="memory-panel keys-panel"
766
+ role="dialog"
767
+ aria-modal="true"
768
+ aria-labelledby="keys-panel-title"
769
+ >
770
+ <header class="memory-panel-head">
771
+ <h2 id="keys-panel-title">Add both keys — both surfaces run</h2>
772
+ <span class="memory-count" id="keys-count">0 / 2</span>
773
+ <div class="spacer"></div>
774
+ <button type="button" id="keys-close" class="ghost-btn" title="Close (Esc)">
775
+ Close
776
+ </button>
777
+ </header>
778
+
779
+ <p class="hint" id="keys-lead">
780
+ AEGIS Desktop and the <code>aegiscode</code> CLI are the same engine over
781
+ the same store on this disk. Two keys turn on both: the AEGIS account key
782
+ (your bill, and the pooled models) and your own provider key (BYOK). Add
783
+ both and either surface runs — until then, a turn on the missing one is
784
+ refused with the same reason in both places.
785
+ </p>
786
+
787
+ <div class="keys-rows">
788
+ <div class="keys-row" id="keys-row-aegis">
789
+ <span class="keys-state" id="keys-aegis-state">not set</span>
790
+ <div class="keys-body">
791
+ <b>AEGIS key</b> — the account key, written to <code>~/.aegiscode/.env</code>
792
+ (<code>AEGIS_API_KEY</code>) by the CLI's <code>/key</code> and by the
793
+ field in the Status card. Without it both surfaces refuse a send.
794
+ </div>
795
+ <button type="button" id="keys-goto-aegis" class="ghost-btn">
796
+ Paste it here
797
+ </button>
798
+ </div>
799
+ <div class="keys-row" id="keys-row-byok">
800
+ <span class="keys-state" id="keys-byok-state">not set</span>
801
+ <div class="keys-body">
802
+ <b>Your own provider key (BYOK)</b> — a <code>byok:&lt;provider&gt;</code>
803
+ row in the shared settings store, written by the CLI's
804
+ <code>/byok-key &lt;provider&gt;</code> and by the Provider settings card
805
+ here. It runs the free path on your own account, on both surfaces.
806
+ </div>
807
+ <button type="button" id="keys-goto-byok" class="ghost-btn">Add it here</button>
808
+ </div>
809
+ </div>
810
+
811
+ <p class="hint" id="keys-hint"></p>
812
+
813
+ <div class="keys-actions">
814
+ <a
815
+ class="ghost-btn"
816
+ id="keys-get"
817
+ href="https://aegiscloud.org/key?s=desktop"
818
+ target="_blank"
819
+ rel="noreferrer noopener"
820
+ >
821
+ Get a free AEGIS key →
822
+ </a>
823
+ <button type="button" id="keys-dismiss" class="ghost-btn">
824
+ Don't show this again
825
+ </button>
826
+ </div>
827
+ </div>
828
+ </div>
829
+
748
830
  <script src="budget.js"></script>
749
831
  <script src="usage.js"></script>
750
832
  <script src="stream-policy.js"></script>
@@ -2443,3 +2443,77 @@ body.is-scrolled .composer {
2443
2443
  .diff-block .diff-code {
2444
2444
  white-space: pre;
2445
2445
  }
2446
+
2447
+ /* ---------------------------------------------------------- the keys pop-up
2448
+ The two-key sheet (#keys-overlay, markup in index.html, driven by
2449
+ applyKeysPopup() in app.js). It reuses the memory inspector's translucent
2450
+ layer and panel, so only the row layout is new here.
2451
+
2452
+ The `[hidden]` rule is not decoration: `hidden` is in the markup and the
2453
+ gate turns it on and off, but a class selector on the overlay
2454
+ (.memory-overlay sets a display) outranks the UA stylesheet's
2455
+ `[hidden] { display:none }`, so without this the pop-up would be on screen
2456
+ from the first paint no matter what the gate decided — the exact defect
2457
+ fixed once already for .welcome-connect. */
2458
+ #keys-overlay[hidden] {
2459
+ display: none;
2460
+ }
2461
+
2462
+ body.keys-open {
2463
+ overflow: hidden;
2464
+ }
2465
+
2466
+ .keys-panel {
2467
+ max-width: 720px;
2468
+ }
2469
+
2470
+ .keys-rows {
2471
+ display: flex;
2472
+ flex-direction: column;
2473
+ gap: 10px;
2474
+ /* padding, not flex-grow: the panel already scrolls (memory-panel). */
2475
+ padding: 4px 0;
2476
+ }
2477
+
2478
+ .keys-row {
2479
+ display: grid;
2480
+ grid-template-columns: 78px 1fr auto;
2481
+ align-items: center;
2482
+ gap: 10px;
2483
+ padding: 10px 12px;
2484
+ border: 1px solid var(--line, #2a2f3a);
2485
+ border-radius: 10px;
2486
+ }
2487
+
2488
+ /* A row whose key is already on the machine reads as done and drops back, so
2489
+ the one still missing is the one the eye lands on. The state line under the
2490
+ title counts the same two facts, so they cannot disagree. */
2491
+ .keys-row.set {
2492
+ opacity: 0.7;
2493
+ }
2494
+
2495
+ .keys-state {
2496
+ font-family: var(--font-mono);
2497
+ font-size: 12px;
2498
+ text-align: center;
2499
+ padding: 2px 6px;
2500
+ border-radius: 6px;
2501
+ color: #9aa4b2;
2502
+ }
2503
+
2504
+ .keys-row.set .keys-state {
2505
+ color: #7ee0a0;
2506
+ }
2507
+
2508
+ .keys-body {
2509
+ font-size: 13px;
2510
+ line-height: 1.45;
2511
+ }
2512
+
2513
+ .keys-actions {
2514
+ display: flex;
2515
+ align-items: center;
2516
+ gap: 10px;
2517
+ flex-wrap: wrap;
2518
+ padding-top: 6px;
2519
+ }
@@ -18,10 +18,18 @@
18
18
  *
19
19
  * Resolution order, first hit wins:
20
20
  *
21
- * 1. `AEGIS_API_KEY` — the environment, so CI and an explicit export keep
22
- * working and nothing written here can shadow them.
23
- * 2. `credentials.json` in the data dir, mode 0600. The writable store.
24
- * 3. `config.json`'s `aegiscloud.api_key` / `memory.token` — the shape an
21
+ * 1. `AEGIS_API_KEY` **exported by the launching shell** — so CI and an
22
+ * explicit export keep working.
23
+ * 2. `credentials.json` in the data dir, mode 0600. The writable store, and
24
+ * the record of a key the user deliberately saved (`aegiscode login`, the
25
+ * CLI's `/key`, the desktop's Settings pane).
26
+ * 3. `AEGIS_API_KEY` from `~/.aegiscode/.env` — the documented place to put a
27
+ * key, but a FALLBACK rather than a decision: no save stamp, shared by
28
+ * every host, hand-edited. Both rungs read the same variable, which is why
29
+ * `isFromEnvFile()` exists — without it a leftover line in that file
30
+ * outranked rung 2 forever and a freshly saved key was silently ignored
31
+ * while every host reported it as saved. See `resolveApiKey`.
32
+ * 4. `config.json`'s `aegiscloud.api_key` / `memory.token` — the shape an
25
33
  * earlier AEGIS CLI left in the same data dir. Read, never written, and
26
34
  * never deleted: it is another product's file and the user's key is in
27
35
  * it. When one is found it is *also* copied into credentials.json so the
@@ -189,21 +197,79 @@ function adoptLegacy(dir) {
189
197
  return { adopted: Object.keys(patch) };
190
198
  }
191
199
 
200
+ /**
201
+ * Was this environment value put there by `~/.aegiscode/.env`, rather than
202
+ * exported by the launching shell?
203
+ *
204
+ * The env object is passed through: provenance is a property of the object the
205
+ * value was loaded INTO, so a caller resolving from an injected `{ env }` (every
206
+ * test, and any host that scopes its environment) must be answered about that
207
+ * object rather than about `process.env`.
208
+ *
209
+ * Loaded lazily and defensively: this module is deliberately dependency-free
210
+ * (it is copied into hosts that bundle no other file), so an absent env-file
211
+ * module simply means "nothing is file-sourced" — which is the pre-existing
212
+ * behaviour, not a failure.
213
+ */
214
+ function fromEnvFile(name, value, env) {
215
+ try {
216
+ const envFile = require('./env-file.js');
217
+ return (
218
+ typeof envFile.isFromEnvFile === 'function' &&
219
+ envFile.isFromEnvFile(name, value, env ? { env } : undefined)
220
+ );
221
+ } catch {
222
+ return false;
223
+ }
224
+ }
225
+
192
226
  /**
193
227
  * The key to use, and where it came from.
194
228
  *
195
- * @returns {{key:string, source:'env'|'credentials'|'config'|'none', from:string}}
229
+ * Order, and the one asymmetry that matters:
230
+ *
231
+ * 1. `AEGIS_API_KEY` **exported by the launching shell** — unchanged, and
232
+ * still first, so CI and an explicit `AEGIS_API_KEY=… aegiscode` keep
233
+ * working.
234
+ * 2. `credentials.json` — a key the user explicitly saved with
235
+ * `aegiscode login`, the CLI's `/key`, or the desktop's Settings pane.
236
+ * 3. `AEGIS_API_KEY` **from `~/.aegiscode/.env`** — the documented place to
237
+ * put a key, but a fallback rather than a decision: it carries no save
238
+ * stamp and the file is shared by every host and hand-edited by users.
239
+ * 4. `config.json`'s legacy `aegiscloud.api_key`.
240
+ *
241
+ * Rungs 1 and 3 are the same variable, which is exactly the bug this ordering
242
+ * fixes. `env-file.js` copies the file into `process.env` at host start-up, so a
243
+ * leftover line — a rotated key that was never cleaned up, a value from an
244
+ * older install, a provider row mirrored into the wrong name — became
245
+ * indistinguishable from a deliberate export and permanently outranked the file
246
+ * the user had just written. The symptom is the one that gets reported as
247
+ * "it says the key is saved but it is not using it": `aegiscode login` prints
248
+ * `key saved`, the desktop prints `saved (aegis_••••1234)`, `key status` reads
249
+ * back the new key — and every request authenticates with the stale one, so the
250
+ * account check 401s and nothing names the cause. `env-file.js`'s own contract
251
+ * is that the file is "a convenience for the common case, not a way for a stale
252
+ * file to shadow an explicit" setting; `isFromEnvFile()` is what makes that
253
+ * true for a save as well as for an export.
254
+ *
255
+ * @returns {{key:string, source:'env'|'credentials'|'envfile'|'config'|'none', from:string}}
196
256
  */
197
257
  function resolveApiKey(o = {}) {
198
258
  const env = o.env || process.env;
199
259
  const dir = o.dir;
200
260
  const fromEnv = normalizeApiKey(env && env[KEY_ENV]);
201
- if (fromEnv) return { key: fromEnv, source: 'env', from: KEY_ENV };
261
+ // An explicit export wins outright. A value the env FILE supplied does not:
262
+ // it is held back to rung 3 so an explicit save outranks it.
263
+ if (fromEnv && !fromEnvFile(KEY_ENV, env && env[KEY_ENV], env)) {
264
+ return { key: fromEnv, source: 'env', from: KEY_ENV };
265
+ }
202
266
 
203
267
  const creds = readCredentials(dir);
204
268
  const stored = normalizeApiKey(creds.aegisApiKey);
205
269
  if (stored) return { key: stored, source: 'credentials', from: credentialsPath(dir) };
206
270
 
271
+ if (fromEnv) return { key: fromEnv, source: 'envfile', from: envFileLabel(env, dir) };
272
+
207
273
  const legacy = readLegacyConfig(dir).apiKey;
208
274
  if (legacy) return { key: legacy, source: 'config', from: configPath(dir) };
209
275
 
@@ -318,10 +384,30 @@ function clientOptions(o = {}) {
318
384
  const SOURCE_LABEL = {
319
385
  env: `${KEY_ENV}`,
320
386
  credentials: 'saved key file',
387
+ envfile: `~/.aegiscode/.env (${KEY_ENV})`,
321
388
  config: 'config.json (aegis CLI)',
322
389
  none: 'not set',
323
390
  };
324
391
 
392
+ /**
393
+ * The env file a value came from, for the `envfile` status line. Resolved
394
+ * through env-file.js when it is present so the answer is the real path
395
+ * (`$AEGISCODE_HOME/.env` included) rather than a guess.
396
+ */
397
+ function envFileLabel(env, dir) {
398
+ try {
399
+ const envFile = require('./env-file.js');
400
+ if (typeof envFile.envFileFor === 'function') return envFile.envFileFor(dir);
401
+ } catch {}
402
+ return envFileForFallback(dir);
403
+ }
404
+
405
+ /** `~/.aegiscode/.env`, built here so the label survives a host that ships no
406
+ * env-file module (same rule aegisHome() exists for). */
407
+ function envFileForFallback(dir) {
408
+ return path.join(dir || aegisHome(), '.env');
409
+ }
410
+
325
411
  function sourceLabel(source) {
326
412
  return SOURCE_LABEL[source] || SOURCE_LABEL.none;
327
413
  }
@@ -377,6 +463,8 @@ module.exports = {
377
463
  adoptLegacy,
378
464
  resolveApiKey,
379
465
  hasApiKey,
466
+ fromEnvFile,
467
+ envFileForFallback,
380
468
  saveApiKey,
381
469
  clearApiKey,
382
470
  resolveMemoryToken,
@@ -262,9 +262,67 @@ function parseEnvText(text) {
262
262
  * `loaded` — names taken from the file. `kept` — names the environment
263
263
  * already had, left alone (a real export always wins).
264
264
  */
265
+ /**
266
+ * Provenance of every value this process took from `~/.aegiscode/.env`.
267
+ *
268
+ * Keyed off the env object itself (a WeakMap, so an injected `{ env }` in a
269
+ * test is tracked without leaking and `process.env` needs no extra property).
270
+ * See `isFromEnvFile`.
271
+ */
272
+ const ENV_SOURCED = new WeakMap();
273
+
274
+ function sourcedFrom(env) {
275
+ let map = ENV_SOURCED.get(env);
276
+ if (!map) {
277
+ map = new Map();
278
+ ENV_SOURCED.set(env, map);
279
+ }
280
+ return map;
281
+ }
282
+
283
+ /**
284
+ * Was `name`'s CURRENT value in `env` one this process took from the shared env
285
+ * file, rather than something the launching shell exported?
286
+ *
287
+ * This is the distinction the file's own rule 1 depends on and could not
288
+ * previously express. The file is documented as a *convenience for the common
289
+ * case, not a way for a stale file to shadow an explicit export* — but once a
290
+ * value is copied into `process.env` the two are the same thing, so every
291
+ * env-first reader (`client/credentials.js`) treated a line in the file exactly
292
+ * like a deliberate export and let it win forever. Concretely: a user runs
293
+ * `aegiscode login`, the key is written to `credentials.json` (0600) and
294
+ * reported as saved, and every request since then authenticates with whatever
295
+ * `AEGIS_API_KEY=` line the file still carries — the "it says saved but the key
296
+ * is wrong" report. A real export still wins; a file value is a fallback.
297
+ *
298
+ * `value` is checked as well as the name: a shell export that replaced the
299
+ * loaded value is no longer file-sourced, and neither is a later `setEnvValue`
300
+ * (which applies the value it just wrote and marks it accordingly).
301
+ *
302
+ * @param {string} name
303
+ * @param {string} [value] the current value, to confirm it is still the one the
304
+ * file provided. Omit to ask about the name alone.
305
+ * @param {object} [o] `env`, for tests and injected environments
306
+ */
307
+ function isFromEnvFile(name, value, o = {}) {
308
+ const env = o.env || process.env;
309
+ const map = ENV_SOURCED.get(env);
310
+ if (!map || !map.has(name)) return false;
311
+ if (value === undefined) return true;
312
+ return map.get(name) === String(value);
313
+ }
314
+
265
315
  function loadEnvFile(o = {}) {
266
316
  const env = o.env || process.env;
267
317
  const file = o.file || envFileFor(o.dir);
318
+ // Names this process took from the FILE, with the value it took. Kept so a
319
+ // consumer can tell the two sources apart: rule 1 above makes the file a
320
+ // fallback, but the moment its value lands in `process.env` it is otherwise
321
+ // indistinguishable from an explicit `AEGIS_API_KEY=… aegiscode` export, and
322
+ // `client/credentials.js`'s resolution order is env-first. Without this
323
+ // marker a leftover line in a file every host sources at start-up outranks
324
+ // the key the user explicitly saved — see `isFromEnvFile()` below.
325
+ const sourced = sourcedFrom(env);
268
326
  const result = {
269
327
  ok: false, file, loaded: [], kept: [], refused: [], reason: '', loose: false, mode: 0,
270
328
  };
@@ -296,8 +354,14 @@ function loadEnvFile(o = {}) {
296
354
  }
297
355
  if (env[name] === undefined || env[name] === '') {
298
356
  env[name] = value;
357
+ // Provenance: this value is the FILE's, not the launching shell's. An
358
+ // env-first reader must treat it as the fallback the file is documented
359
+ // to be, not as an explicit export that outranks a saved key.
360
+ sourced.set(name, value);
299
361
  result.loaded.push(name);
300
362
  } else {
363
+ // A real export wins (rule 1) and is deliberately NOT marked: it is the
364
+ // one source allowed to outrank `credentials.json`.
301
365
  result.kept.push(name);
302
366
  }
303
367
  }
@@ -615,7 +679,12 @@ function setEnvValue(name, value, o = {}) {
615
679
  out.changed = false;
616
680
  out.replaced = true;
617
681
  out.mode = previousMode || SECRET_FILE_MODE;
618
- if (o.apply !== false) env[name] = secret;
682
+ if (o.apply !== false) {
683
+ env[name] = secret;
684
+ // Applied, and the file carries it: file-sourced, so an env-first reader
685
+ // treats it as the fallback it is (see isFromEnvFile).
686
+ sourcedFrom(env).set(name, secret);
687
+ }
619
688
  return out;
620
689
  }
621
690
 
@@ -640,7 +709,11 @@ function setEnvValue(name, value, o = {}) {
640
709
  out.replaced = replaced;
641
710
  out.mode = SECRET_FILE_MODE;
642
711
  out.tightened = !out.created && previousMode !== 0 && (previousMode & 0o077) !== 0;
643
- if (o.apply !== false) env[name] = secret;
712
+ if (o.apply !== false) {
713
+ env[name] = secret;
714
+ // Just written to the file and applied here: file-sourced (see above).
715
+ sourcedFrom(env).set(name, secret);
716
+ }
644
717
  return out;
645
718
  }
646
719
 
@@ -810,16 +883,91 @@ function keyStatus(name, o = {}) {
810
883
  *
811
884
  * @param {string} modelId
812
885
  * @param {string} [cls] 'byok' | 'aegis' | anything else
813
- * @returns {{env:string, provider:string, pooled:boolean}}
886
+ * @returns {{env:string, provider:string, vendor:string, pooled:boolean}}
814
887
  */
815
888
  function keyForModelId(modelId, cls) {
816
889
  const id = String(modelId == null ? '' : modelId).trim();
817
890
  const byok = String(cls || '').toLowerCase() === 'byok' || id.includes(':');
818
891
  if (byok && id.includes(':')) {
819
892
  const provider = id.slice(0, id.indexOf(':')).trim();
820
- return { env: envVarFor(provider), provider, pooled: false };
893
+ return { env: envVarFor(provider), provider, vendor: provider, pooled: false };
894
+ }
895
+ // A pooled id is served by the pool, so the credential the ROUTE needs is the
896
+ // account key — but the id may still NAME the vendor underneath it
897
+ // (`deepseek`, `anthropic-haiku`, `openai-gpt4o-mini`). That vendor is what
898
+ // `vendor` reports, and it is not the same question as `env`: it is the
899
+ // provider key a user may also want on this machine, which the picker asks
900
+ // for after the account key. `provider` stays '' for pooled on purpose — the
901
+ // byok call path reads it as "which provider's settings row to address", and
902
+ // a pooled row has none.
903
+ return { env: ACCOUNT_ENV_VAR, provider: '', vendor: providerForModelId(id, cls), pooled: true };
904
+ }
905
+
906
+ /**
907
+ * Vendors whose slug is a namespace a model id may be spelled under.
908
+ *
909
+ * Deliberately a *table* and not a pattern: the derivation below is a claim that
910
+ * a key for this vendor exists, so an id whose first segment is not on this list
911
+ * derives NO vendor (and the picker asks only for the account key) rather than
912
+ * inventing a variable name like `NEXUS_API_KEY` for the pool's own namespace.
913
+ * The list is the platform's providers as its own BYOK catalog spells them
914
+ * (aegis1 services/nexus_provider/catalog.py MODEL_CATALOG) plus the ids this
915
+ * module already re-spells in ALIASES above — keep it in step with that catalog,
916
+ * not with whatever vendor a user might mention.
917
+ */
918
+ const VENDOR_SLUGS = Object.freeze([
919
+ 'openai',
920
+ 'anthropic',
921
+ 'deepseek',
922
+ 'google',
923
+ 'gemini',
924
+ 'groq',
925
+ 'mistral',
926
+ 'openrouter',
927
+ 'together',
928
+ 'together-ai',
929
+ 'xai',
930
+ 'x-ai',
931
+ 'grok',
932
+ 'huggingface',
933
+ 'fireworks',
934
+ 'voyage-ai',
935
+ 'azure-openai',
936
+ ]);
937
+
938
+ /** The multi-segment slugs, longest first, so `azure-openai-x` matches the full slug. */
939
+ const VENDOR_COMPOUNDS = Object.freeze(
940
+ VENDOR_SLUGS.filter((s) => s.includes('-')).sort((a, b) => b.length - a.length)
941
+ );
942
+
943
+ /**
944
+ * The vendor a model id names, or '' — `deepseek-flash` → `deepseek`,
945
+ * `nexus-brain` → '', `deepseek:deepseek-v4-flash` → `deepseek`.
946
+ *
947
+ * The one question the picker needs to answer "is there a SECOND key worth
948
+ * asking for on this row?" A pooled row is billed to the AEGIS account, so its
949
+ * route needs the account key and nothing else — but when the row is named
950
+ * after a vendor whose key this machine could hold (`deepseek`), asking for that
951
+ * key too is what lets the account actually call the model instead of the pool
952
+ * failing a turn with "no provider key". That is the gap this closes:
953
+ * aegis-key present, models listed, and every turn still failing because the
954
+ * vendor key the models route through was never collected.
955
+ *
956
+ * @param {string} modelId
957
+ * @param {string} [cls] unused by the derivation; accepted so a caller can pass
958
+ * the same (id, cls) pair it passes `keyForModelId` without branching.
959
+ * @returns {string} the vendor slug, or '' when the id names none this module
960
+ * knows a variable for.
961
+ */
962
+ function providerForModelId(modelId, cls) { // eslint-disable-line no-unused-vars
963
+ const id = String(modelId == null ? '' : modelId).trim().toLowerCase();
964
+ if (!id) return '';
965
+ if (id.includes(':')) return id.slice(0, id.indexOf(':')).trim();
966
+ for (const slug of VENDOR_COMPOUNDS) {
967
+ if (id === slug || id.startsWith(slug + '-')) return slug;
821
968
  }
822
- return { env: ACCOUNT_ENV_VAR, provider: '', pooled: true };
969
+ const first = id.split('-')[0];
970
+ return VENDOR_SLUGS.includes(first) ? first : '';
823
971
  }
824
972
 
825
973
  module.exports = {
@@ -831,7 +979,15 @@ module.exports = {
831
979
  envVarFor,
832
980
  parseEnvText,
833
981
  loadEnvFile,
982
+ // Provenance of what loadEnvFile put in the environment: a value taken from
983
+ // the FILE is a fallback, not an explicit export, and the env-first readers
984
+ // (client/credentials.js) must be able to tell the two apart.
985
+ isFromEnvFile,
834
986
  providerKeyFromEnv,
987
+ // The vendor half of "what key does this model id need": the account key is
988
+ // the route's credential, this is the provider key underneath it.
989
+ providerForModelId,
990
+ VENDOR_SLUGS,
835
991
  isWritableName,
836
992
  quoteEnvValue,
837
993
  upsertEnvText,