aegis-desktop 0.8.1 → 0.8.3

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/README.md CHANGED
@@ -1,5 +1,7 @@
1
1
  # AEGIS Desktop
2
2
 
3
+ ![AEGIS Desktop running: the three-class model picker, an agent turn, a gated tool-approval diff, and the unattended work queue](https://raw.githubusercontent.com/aegisinfo/aegiscode-plugin/main/desktop/docs/demo.gif)
4
+
3
5
  A standalone Electron chat app over the [AEGIS](https://aegiscloud.org) API,
4
6
  with an **agentic tool loop**: the model can read, write, and edit files,
5
7
  list directories, glob, grep, run shell commands in a persistent session, and
@@ -25,8 +27,10 @@ Either way, on first launch:
25
27
  3. Pick a model class from the picker at the bottom of the composer.
26
28
 
27
29
  The published package is [`aegis-desktop`](https://www.npmjs.com/package/aegis-desktop)
28
- on npm; this repo is its source. Both classes bill your AEGIS account — either
29
- the pooled margin, or the BYOK handling fee on top of your own provider key.
30
+ on npm; this repo is its source. Two of the three classes bill your AEGIS
31
+ account — either the pooled margin, or the BYOK handling fee on top of your own
32
+ provider key; the free **Local** class runs on your own hardware and bills
33
+ nobody.
30
34
 
31
35
  ## Using it
32
36
 
@@ -49,18 +53,25 @@ with **Settings → "Confirm before running tools"** — on by default.
49
53
 
50
54
  ## Model classes
51
55
 
52
- Pick either of two routes from the model-class picker, switchable
53
- mid-conversation with context intact:
56
+ Pick from three routes on the model-class picker, switchable mid-conversation
57
+ with context intact:
54
58
 
55
59
  | Class | Transport | Key held in | Billed? |
56
60
  |---|---|---|---|
57
61
  | **Aegis Cloud** | `aegiscloud.org` — one entry, **Nexus**; the pool auto-routes across whichever providers are live | main process | yes — pooled margin |
58
62
  | **Bring your own key** | your provider key, relayed by AEGIS — see below | main process | yes — flat AEGIS handling fee |
59
-
60
- Get a free AEGIS key at **https://aegiscloud.org**. The local-endpoint classes
61
- that used to live here — Ollama, and the OpenAI-/Anthropic-compatible direct
62
- lanes — have been removed; the hosted providers they could reach are on the
63
- **Bring your own key** class, which bills the handling fee.
63
+ | **Local** | a model on your own machine or network — your Ollama daemon at `localhost:11434` by default | n/a — no key | no — free, no AEGIS account needed |
64
+
65
+ Get a free AEGIS key at **https://aegiscloud.org** for the Cloud and BYOK
66
+ classes. The generic OpenAI-/Anthropic-compatible direct-dial lanes that used
67
+ to live here were removed for good — those providers are reachable only
68
+ through **Bring your own key**, which bills the handling fee. **Local** is
69
+ narrower: it points at a model on your own machine or your own network, and the
70
+ endpoint check (`desktop/lib/local/local.js`) refuses anything public —
71
+ fail-closed, run when the base URL is saved and again before a request is
72
+ dialled. Your Ollama daemon at `localhost:11434` is the default, and any
73
+ OpenAI-compatible server on that box — llama.cpp, LM Studio, vLLM — works by
74
+ pointing the base URL at it. So there's no vendor to bill and no key to hold.
64
75
 
65
76
  ### Bring your own key (BYOK)
66
77
 
@@ -94,6 +105,26 @@ Note that a **BYOK turn is single-shot** — the relay takes no `tools` paramete
94
105
  so the agentic tool loop below is off for these models. Use the **Aegis Cloud**
95
106
  class when you want file and shell access.
96
107
 
108
+ ## Screenshots
109
+
110
+ Captured from the real app, not mocked up: the stills come from
111
+ `scripts/capture-marketing-shots.mjs` and the demo at the top of this page from
112
+ `scripts/record-demo-gif.mjs`. Both drive the actual renderer against a local
113
+ stub and refuse to publish an asset that fails their assertions.
114
+
115
+ **The model-class picker** — all three routes, switchable mid-conversation:
116
+
117
+ ![The model-class picker listing Aegis Cloud, Bring your own key, and Local](https://raw.githubusercontent.com/aegisinfo/aegiscode-plugin/main/docs/marketing-assets/01-model-class-picker.png)
118
+
119
+ **A completed answer** in the transcript:
120
+
121
+ ![A completed answer in the transcript](https://raw.githubusercontent.com/aegisinfo/aegiscode-plugin/main/docs/marketing-assets/02-answer-complete.png)
122
+
123
+ **The tool-call approval card** with a proposed edit — what blocks `exec`,
124
+ `writeFile` and `editFile` until you allow, allow for the session, or deny:
125
+
126
+ ![The tool-call approval card with a proposed edit](https://raw.githubusercontent.com/aegisinfo/aegiscode-plugin/main/docs/marketing-assets/03-tool-approval-diff.png)
127
+
97
128
  ## Tools available to the model
98
129
 
99
130
  | Tool | What it does |
Binary file
package/build/icon.ico ADDED
Binary file
package/build/icon.png CHANGED
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
package/main.js CHANGED
@@ -480,6 +480,22 @@ async function importForeignMemory(aegis, dir, payload) {
480
480
  };
481
481
  }
482
482
 
483
+ /** Where the packaged/dev build keeps its icon files. Packaged: shipped via
484
+ * electron-builder.yml's `extraResources` into resourcesPath/icons/ (icons
485
+ * live outside asar, since app.icns/.ico aren't otherwise reachable at
486
+ * runtime). Dev: read straight from build/, the same source those resources
487
+ * are copied from. Same split as ae-guix's lib/windows.js. */
488
+ function resolveIconsDir(isPackaged, resourcesPath, dirname) {
489
+ return isPackaged ? path.join(resourcesPath, 'icons') : path.join(dirname, 'build');
490
+ }
491
+
492
+ /** icon.icns on mac, icon.ico on Windows, icon.png everywhere else (Linux). */
493
+ function iconForPlatform(iconsDir, platform) {
494
+ if (platform === 'darwin') return path.join(iconsDir, 'icon.icns');
495
+ if (platform === 'win32') return path.join(iconsDir, 'icon.ico');
496
+ return path.join(iconsDir, 'icon.png');
497
+ }
498
+
483
499
  /** Only http/https may be opened externally — file://, javascript:, etc.
484
500
  * would hand the OS shell an arbitrary URI straight from model output. */
485
501
  function isSafeExternalUrl(url) {
@@ -492,6 +508,50 @@ function isSafeExternalUrl(url) {
492
508
  }
493
509
  }
494
510
 
511
+ /**
512
+ * Whether this machine holds a provider route of the user's OWN — the "bring
513
+ * your own key" half of the welcome block's two pitches.
514
+ *
515
+ * The AEGIS Cloud key is `aegis.apiKey` and is reported separately as
516
+ * `keyConfigured`; a BYOK provider row never appears there (it is written by
517
+ * the Provider settings card through settings.set(provider, {baseURL, key})),
518
+ * so it needs its own reader. A row counts once it carries either a key (the
519
+ * pasted-provider case) or a baseURL (the point-it-at-a-local-model case) —
520
+ * both are the user having answered the question the block was asking.
521
+ *
522
+ * `settings.list()` goes through `get()`, which is the same reader the IPC
523
+ * bridge uses to answer the renderer — it never puts the plaintext key on a
524
+ * row (that would put it one `list()` call away from crossing IPC), only the
525
+ * `configured` boolean it already derived from the key. Reading `row.key`
526
+ * here was always reading a field `get()` doesn't emit, so a pasted-key-only
527
+ * provider row (no baseURL — the common case: OpenAI, Anthropic, DeepSeek)
528
+ * never counted and the block kept nagging a user who had already connected
529
+ * one. Verified live: `store.set('openai', { key })` then `list()` — the row
530
+ * carries `configured: true` and no `key` property at all.
531
+ *
532
+ * Reserved namespaces are excluded for the same reason createModelDispatch
533
+ * filters them: they are the store's own bookkeeping (the AEGIS key, the
534
+ * memory-persist preference), not a provider the user configured.
535
+ *
536
+ * Failure reads as "no", which keeps the block visible — the same
537
+ * fail-toward-showing-it bias the CLI's connectNeeded() documents. Showing a
538
+ * block the user has outgrown is a nag; hiding one a new install needed is a
539
+ * dead end, and only the latter loses the user.
540
+ */
541
+ function providerRouteConfigured(engine) {
542
+ try {
543
+ const list = engine && engine.settings && engine.settings.list;
544
+ if (typeof list !== 'function') return false;
545
+ return (list.call(engine.settings) || []).some((row) => {
546
+ if (!row || !row.provider || isReservedNamespace(row.provider)) return false;
547
+ const baseURL = typeof row.baseURL === 'string' ? row.baseURL.trim() : '';
548
+ return Boolean(row.configured || baseURL);
549
+ });
550
+ } catch {
551
+ return false;
552
+ }
553
+ }
554
+
495
555
  /**
496
556
  * Pure mapping: IPC payload -> shared-client call. No Electron types here, so
497
557
  * tests can drive it with a stub client and a fake ipcMain. `openExternal` is
@@ -499,7 +559,17 @@ function isSafeExternalUrl(url) {
499
559
  * reject), injected by bootstrap() so this module still needs no `electron`
500
560
  * import to stay unit-testable.
501
561
  */
502
- function createIpcDispatch(aegis, dir, persistApiKey, openExternal) {
562
+ function createIpcDispatch(aegis, dir, persistApiKey, openExternal, providerConfigured) {
563
+ // `providerConfigured` is an injected thunk (bootstrap() passes one that reads
564
+ // the settings store) so this module keeps its no-Electron, unit-testable
565
+ // shape; absent in tests and older call sites, where it reads as "no".
566
+ const hasOwnProviderKey = () => {
567
+ try {
568
+ return Boolean(typeof providerConfigured === 'function' && providerConfigured());
569
+ } catch {
570
+ return false;
571
+ }
572
+ };
503
573
  const openExternalFn = openExternal || (() => Promise.reject(new Error('no opener configured')));
504
574
  const dispatch = {
505
575
  status: () => ({
@@ -508,6 +578,12 @@ function createIpcDispatch(aegis, dir, persistApiKey, openExternal) {
508
578
  apiBase: aegis.apiBase,
509
579
  keyConfigured: Boolean(aegis.apiKey),
510
580
  keyMask: maskKey(aegis.apiKey),
581
+ // The welcome block pitches TWO routes (bring your own key, or AEGIS
582
+ // Cloud) and must retire once the user has taken either one. A BYOK
583
+ // provider key never touches aegis.apiKey, so without this the block
584
+ // kept re-pitching "bring your own key" at someone who just did — see
585
+ // applyWelcomeConnect() in renderer/app.js.
586
+ providerConfigured: hasOwnProviderKey(),
511
587
  }),
512
588
 
513
589
  // In-app API key entry (plan rebuild): replace the live client key and
@@ -612,8 +688,8 @@ function withReplyNotify(promise, event, onReplyFinished) {
612
688
  * tests, where the safe no-op default in createIpcDispatch takes over.
613
689
  * `onReplyFinished` is likewise bootstrap()-only: it fires the native
614
690
  * "reply ready" notification when the window is unfocused/hidden. */
615
- function registerIpc(ipcMain, aegis, dir, persistApiKey, openExternal, onReplyFinished) {
616
- const dispatch = createIpcDispatch(aegis, dir, persistApiKey, openExternal);
691
+ function registerIpc(ipcMain, aegis, dir, persistApiKey, openExternal, onReplyFinished, providerConfigured) {
692
+ const dispatch = createIpcDispatch(aegis, dir, persistApiKey, openExternal, providerConfigured);
617
693
  for (const [name, handler] of Object.entries(dispatch)) {
618
694
  if (name === 'chatCompletion') {
619
695
  // Streaming render (D2.1): when the renderer asks for stream, SSE deltas
@@ -2362,7 +2438,8 @@ function bootstrap() {
2362
2438
  dataDir,
2363
2439
  persistApiKey,
2364
2440
  (url) => shell.openExternal(url),
2365
- notifyReplyIfUnfocused
2441
+ notifyReplyIfUnfocused,
2442
+ () => providerRouteConfigured(engine)
2366
2443
  );
2367
2444
  registerModelIpc(
2368
2445
  ipcMain,
@@ -2421,6 +2498,7 @@ function bootstrap() {
2421
2498
  minimizable: false,
2422
2499
  maximizable: false,
2423
2500
  backgroundColor: '#0d1117',
2501
+ icon: iconForPlatform(resolveIconsDir(app.isPackaged, process.resourcesPath, __dirname), process.platform),
2424
2502
  webPreferences: {
2425
2503
  preload: path.join(__dirname, 'preload.js'),
2426
2504
  contextIsolation: true,
@@ -2595,6 +2673,7 @@ function bootstrap() {
2595
2673
  // connected display — that's a window that "opens" but nobody can see
2596
2674
  // or reach.
2597
2675
  const bounds = windowState.clampToDisplay(saved, screen.getAllDisplays(), defaultBounds);
2676
+ const iconsDir = resolveIconsDir(app.isPackaged, process.resourcesPath, __dirname);
2598
2677
 
2599
2678
  const win = new BrowserWindow({
2600
2679
  ...bounds,
@@ -2602,6 +2681,7 @@ function bootstrap() {
2602
2681
  minHeight: 480,
2603
2682
  backgroundColor: '#0d1117',
2604
2683
  title: 'AEGIS Desktop',
2684
+ icon: iconForPlatform(iconsDir, process.platform),
2605
2685
  webPreferences: {
2606
2686
  preload: path.join(__dirname, 'preload.js'),
2607
2687
  contextIsolation: true,
@@ -2736,6 +2816,7 @@ if (electron && electron.app) {
2736
2816
  module.exports = {
2737
2817
  createIpcDispatch,
2738
2818
  registerIpc,
2819
+ providerRouteConfigured,
2739
2820
  IPC_PREFIX,
2740
2821
  MODEL_PREFIX,
2741
2822
  SYNC_PREFIX,
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "aegis-desktop",
3
3
  "productName": "AEGIS Desktop",
4
- "version": "0.8.1",
4
+ "version": "0.8.3",
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",
@@ -33,6 +33,7 @@
33
33
  "prepublishOnly": "npm run predist",
34
34
  "icon": "node scripts/generate-icon.mjs",
35
35
  "shots": "node scripts/capture-marketing-shots.mjs",
36
+ "demo": "node scripts/record-demo-gif.mjs",
36
37
  "dist": "npm run predist && electron-builder",
37
38
  "dist:dir": "npm run predist && electron-builder --dir",
38
39
  "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 bin/aegis.js",
@@ -52,10 +53,13 @@
52
53
  "test:renderer-wiring": "node ../test/renderer-wiring.test.mjs"
53
54
  },
54
55
  "dependencies": {
55
- "electron": "^33.0.0",
56
56
  "electron-updater": "^6.8.9"
57
57
  },
58
58
  "devDependencies": {
59
- "electron-builder": "^25.1.8"
59
+ "electron-builder": "^25.1.8",
60
+ "electron": "^33.0.0"
61
+ },
62
+ "optionalDependencies": {
63
+ "electron": "^33.0.0"
60
64
  }
61
65
  }
package/renderer/app.js CHANGED
@@ -160,6 +160,11 @@ const EFFORT_KEY = 'aegis.effort';
160
160
  const LEGACY_EFFORT_KEY = 'aegis.autonomousEffort';
161
161
  const AUTONOMOUS_WORKERS_KEY = 'aegis.autonomousWorkers';
162
162
  const EXPLORE_KEY = 'aegis.explore';
163
+ // "Don't show this again" on the welcome panel's connect block. Stored here
164
+ // rather than in the settings store because it is a per-machine UI preference,
165
+ // not a credential, and the settings store is what the main process filters and
166
+ // syncs.
167
+ const WELCOME_DISMISS_KEY = 'aegis.welcomeConnectDismissed';
163
168
  // "Work autonomously" (pool_brain worker fan-out, aegis1 services/pool_brain.py)
164
169
  // is only billable/routable through the pooled AEGIS Cloud class.
165
170
  const AUTONOMOUS_CLASS = 'aegis';
@@ -595,6 +600,10 @@ function renderStatus(s) {
595
600
  els.base.textContent = s.apiBase || '–';
596
601
  els.key.textContent = s.keyConfigured ? s.keyMask : 'not set';
597
602
  setConn(s.keyConfigured, s.keyConfigured ? 'key configured' : 'no API key');
603
+ // A key that arrives while the welcome panel is on screen (pasted, or synced
604
+ // from the CLI) retires the connect block; with no key it stays. No-ops when
605
+ // the panel is not mounted.
606
+ applyWelcomeConnect();
598
607
  }
599
608
 
600
609
  // -------------------------------------------------------------- auto-update
@@ -1858,6 +1867,16 @@ function renderWelcome() {
1858
1867
  for (const btn of els.messages.querySelectorAll('.chat-quick-pill')) {
1859
1868
  btn.addEventListener('click', () => quickAction(btn.dataset.quick || ''));
1860
1869
  }
1870
+ // The connect block's three controls. Bound here (not inline, not at boot)
1871
+ // because they only exist in the cloned panel; CSP `script-src 'self'` would
1872
+ // drop an inline handler anyway.
1873
+ const byok = document.getElementById('welcome-byok');
1874
+ if (byok) byok.addEventListener('click', welcomeByok);
1875
+ const cloud = document.getElementById('welcome-cloud');
1876
+ if (cloud) cloud.addEventListener('click', welcomeCloud);
1877
+ const skip = document.getElementById('welcome-dismiss');
1878
+ if (skip) skip.addEventListener('click', dismissWelcomeConnect);
1879
+ applyWelcomeConnect();
1861
1880
  }
1862
1881
 
1863
1882
  /** Drop the welcome panel once real transcript content exists. */
@@ -1866,6 +1885,134 @@ function hideWelcome() {
1866
1885
  if (w) w.remove();
1867
1886
  }
1868
1887
 
1888
+ // --------------------------------------------- welcome: the connect block
1889
+ //
1890
+ // The welcome panel is the only surface a new install sees before it has a
1891
+ // key, and it is where the app has to say what it is and how it gets paid for.
1892
+ // The block is shown ONLY while nothing is connected (no AEGIS key) and the
1893
+ // user has not dismissed it: the panel is re-rendered on every New chat, so an
1894
+ // always-on version would be an advert in the composition window.
1895
+ //
1896
+ // Both routes are real paths into the existing UI, not links out:
1897
+ //
1898
+ // byok — switches the provider class and lands on Provider settings, which
1899
+ // is where the per-provider key rows are built (loadSettings()).
1900
+ // BYOK still needs an AEGIS key for the handling fee (the engine
1901
+ // refuses a keyless BYOK send outright), so when there is none the
1902
+ // same click also focuses the AEGIS key field and says so, rather
1903
+ // than leaving the user at a row whose Save would fail.
1904
+ // cloud — focuses the AEGIS key field in the Status card, where Save/Verify
1905
+ // and the Upgrade/Top up buttons already live. Getting a key is the
1906
+ // one step that happens off-machine, so the hint carries the link
1907
+ // and openExternal is reached through the anchor, never a bare
1908
+ // window.open (CSP `script-src 'self'` drops nothing here, but the
1909
+ // main process is what validates the URL).
1910
+
1911
+ /** Show the connect block only on an unconnected, un-dismissed install. */
1912
+ function applyWelcomeConnect() {
1913
+ const box = document.getElementById('welcome-connect');
1914
+ if (!box) return;
1915
+ let dismissed = false;
1916
+ try {
1917
+ dismissed = localStorage.getItem(WELCOME_DISMISS_KEY) === 'on';
1918
+ } catch {
1919
+ /* storage disabled — treat as not dismissed */
1920
+ }
1921
+ // keyConfigured is null until status is read, so an unknown state keeps the
1922
+ // block visible: on a cold boot the user has no key far more often than not,
1923
+ // and renderStatus() calls this again the moment the truth arrives.
1924
+ box.hidden = dismissed || keyConfigured === true;
1925
+ }
1926
+
1927
+ /** Scroll a sidebar card into view and mark it, so a welcome click has a
1928
+ * visible landing spot. The sidebar is a plain scroll container, so this is
1929
+ * the whole of the "open settings" affordance. */
1930
+ function revealSidebarCard(node) {
1931
+ if (!node) return;
1932
+ const card = node.closest('section.card') || node;
1933
+ try {
1934
+ card.scrollIntoView({ block: 'center', behavior: 'smooth' });
1935
+ } catch {
1936
+ card.scrollIntoView();
1937
+ }
1938
+ card.classList.add('flash');
1939
+ setTimeout(() => card.classList.remove('flash'), 1200);
1940
+ }
1941
+
1942
+ /** "Bring your own key": land the user where a provider key can be typed. */
1943
+ async function welcomeByok() {
1944
+ renderWelcomeHint('byok');
1945
+ const hasByok = Array.from(els.classSelect.options).some((o) => o.value === 'byok');
1946
+ if (hasByok && els.classSelect.value !== 'byok') {
1947
+ els.classSelect.value = 'byok';
1948
+ // The change listener persists the class and reloads the catalog, so the
1949
+ // provider rows appear without duplicating any of that here.
1950
+ els.classSelect.dispatchEvent(new Event('change'));
1951
+ }
1952
+ revealSidebarCard(els.settingsList);
1953
+ // A BYOK turn is refused without an AEGIS key (the handling fee is billed
1954
+ // there), so a keyless user is sent to the key field first — the provider row
1955
+ // cannot succeed on its own.
1956
+ if (keyConfigured === false && els.apiKeyHint) {
1957
+ els.apiKeyHint.textContent =
1958
+ 'BYOK needs an AEGIS key too — the handling fee is billed to it. ' +
1959
+ 'Paste one above, then add your provider key below.';
1960
+ }
1961
+ if (keyConfigured === false && els.apiKeyInput) {
1962
+ els.apiKeyInput.focus();
1963
+ revealSidebarCard(els.apiKeyInput);
1964
+ return;
1965
+ }
1966
+ const row = els.settingsList
1967
+ ? els.settingsList.querySelector('.setting-row input[type="password"]')
1968
+ : null;
1969
+ if (row) row.focus();
1970
+ }
1971
+
1972
+ /** "Connect AEGIS Cloud": land the user on the key field that unlocks it. */
1973
+ function welcomeCloud() {
1974
+ renderWelcomeHint('cloud');
1975
+ revealSidebarCard(els.apiKeyInput);
1976
+ if (els.apiKeyInput) els.apiKeyInput.focus();
1977
+ }
1978
+
1979
+ /** The hint under the key field, rewritten to match the route just clicked.
1980
+ * Uses child nodes rather than textContent so the aegiscloud.org link inside
1981
+ * it survives (same shape as the Model card's needsKey hint). */
1982
+ function renderWelcomeHint(kind) {
1983
+ const el = els.apiKeyHint;
1984
+ if (!el) return;
1985
+ el.textContent = '';
1986
+ if (kind === 'byok') {
1987
+ el.appendChild(document.createTextNode(
1988
+ 'Bring your own key: paste your AEGIS key here (it bills the handling fee), ' +
1989
+ 'then add the provider key in Provider settings. No key yet? ',
1990
+ ));
1991
+ } else {
1992
+ el.appendChild(document.createTextNode(
1993
+ 'Connect AEGIS Cloud: paste your AEGIS key, then Save. Free key at ',
1994
+ ));
1995
+ }
1996
+ const a = document.createElement('a');
1997
+ a.href = GET_AEGIS_KEY_URL;
1998
+ a.target = '_blank';
1999
+ a.rel = 'noreferrer noopener';
2000
+ a.textContent = 'aegiscloud.org';
2001
+ el.appendChild(a);
2002
+ el.appendChild(document.createTextNode('.'));
2003
+ }
2004
+
2005
+ /** Persist "don't show this again" and drop the block for this session. */
2006
+ function dismissWelcomeConnect() {
2007
+ try {
2008
+ localStorage.setItem(WELCOME_DISMISS_KEY, 'on');
2009
+ } catch {
2010
+ /* storage disabled — the block simply comes back next launch */
2011
+ }
2012
+ const box = document.getElementById('welcome-connect');
2013
+ if (box) box.hidden = true;
2014
+ }
2015
+
1869
2016
  // Assistant text is rendered as sanitized markdown (headings, lists, links,
1870
2017
  // highlighted fenced code with a copy button — see renderer/markdown.js);
1871
2018
  // user text always stays plain via textContent, and this is the only place
@@ -315,6 +315,7 @@
315
315
  <div class="glyph">✦</div>
316
316
  <div class="title" id="chat-greeting">Hello</div>
317
317
  </div>
318
+ <div class="sub" id="welcome-tagline">Your intent, AI automated.</div>
318
319
  <div class="sub">
319
320
  Ask anything — or start with one of these.
320
321
  </div>
@@ -347,6 +348,79 @@
347
348
  <span class="qp-ic">⊞</span>Plan a project
348
349
  </button>
349
350
  </div>
351
+ <!-- Why AEGIS Desktop, and the two ways to power it.
352
+ The app itself is MIT and free; what pays for it is a connected
353
+ account — either an AEGIS Cloud key (pooled models, billed to
354
+ the balance in the Status card) or a provider key of your own
355
+ relayed under BYOK, whose handling fee is the other revenue
356
+ line. A new install therefore has to be told, in the app, both
357
+ why it should stay and how to connect; until this panel existed
358
+ the only route to either was a paragraph in the README.
359
+
360
+ Both buttons are real: #welcome-byok switches the provider
361
+ class to byok and drops the user on the Provider settings card,
362
+ #welcome-cloud focuses the AEGIS key field in the Status card.
363
+ The whole block is hidden once a key is configured or the user
364
+ dismisses it (applyWelcomeConnect() in app.js) — a welcome
365
+ panel that reappears on every New chat is an ad, not a welcome. -->
366
+ <div class="welcome-connect" id="welcome-connect" hidden>
367
+ <div class="wc-why">
368
+ <div class="wc-why-title">Why AEGIS Desktop</div>
369
+ <ul class="wc-why-list">
370
+ <li>
371
+ <b>It runs on your machine.</b> Your repos, sessions and keys
372
+ live on this disk — an agent turn only leaves it for the
373
+ model call you configured, and nothing else is uploaded.
374
+ </li>
375
+ <li>
376
+ <b>Your key, your bill.</b> Paste an OpenAI, Anthropic or
377
+ DeepSeek key and pay your provider directly, point it at a
378
+ local model server for free, or use AEGIS Cloud with no
379
+ provider key at all.
380
+ </li>
381
+ <li>
382
+ <b>It does the work, not just the talking.</b> Exec, write and
383
+ edit run against your actual working tree — with the
384
+ autonomous queue draining multi-step jobs while you are away.
385
+ </li>
386
+ <li>
387
+ <b>Open client, paid service.</b> The desktop app is
388
+ MIT-licensed and needs no account to run a local model; AEGIS
389
+ Cloud is what funds it.
390
+ </li>
391
+ </ul>
392
+ </div>
393
+ <div class="wc-paths">
394
+ <button type="button" class="wc-path" id="welcome-byok">
395
+ <span class="wc-ic" aria-hidden="true">🔑</span>
396
+ <span class="wc-body">
397
+ <span class="wc-name">Bring your own key</span>
398
+ <span class="wc-desc">
399
+ Keep the provider plan you already pay for. The key is
400
+ stored on this machine and authenticates to the provider;
401
+ the relay that carries your turn is what bills the AEGIS
402
+ handling fee.
403
+ </span>
404
+ </span>
405
+ <span class="wc-go">Set up →</span>
406
+ </button>
407
+ <button type="button" class="wc-path" id="welcome-cloud">
408
+ <span class="wc-ic" aria-hidden="true">✦</span>
409
+ <span class="wc-body">
410
+ <span class="wc-name">Connect AEGIS Cloud</span>
411
+ <span class="wc-desc">
412
+ One key, no provider account needed. Pooled models, memory
413
+ sync and the autonomous worker fan-out; free tier to start,
414
+ then upgrade or top up from the Status card.
415
+ </span>
416
+ </span>
417
+ <span class="wc-go">Connect →</span>
418
+ </button>
419
+ <button type="button" class="wc-skip" id="welcome-dismiss">
420
+ Don't show this again
421
+ </button>
422
+ </div>
423
+ </div>
350
424
  <div class="hints">
351
425
  Enter to send · Shift+Enter for a newline · the discovery lane adds
352
426
  two extra paths to every answer
@@ -1669,6 +1669,12 @@ label {
1669
1669
  flex-direction: column;
1670
1670
  align-items: center;
1671
1671
  justify-content: center;
1672
+ /* The connect block made this panel tall enough to overflow a short window.
1673
+ Without a scroll container the overflow is unreachable, and `center` alone
1674
+ clips the *top* of an overflowing box, so the greeting disappears. `safe`
1675
+ degrades to plain `center` (the previous behaviour) where unsupported. */
1676
+ justify-content: safe center;
1677
+ overflow-y: auto;
1672
1678
  flex: 1;
1673
1679
  gap: 18px;
1674
1680
  text-align: center;
@@ -1759,6 +1765,216 @@ label {
1759
1765
  line-height: 1.6;
1760
1766
  }
1761
1767
 
1768
+ /* ------------------------------------------- welcome: the connect block
1769
+ The "why AEGIS Desktop / bring your own key / connect AEGIS Cloud" panel
1770
+ cloned into #messages by renderWelcome(). It is the only surface a new
1771
+ install sees before it has a key, so it carries the two revenue routes:
1772
+ BYOK (provider key relayed, AEGIS handling fee billed) and AEGIS Cloud.
1773
+
1774
+ Deliberately wider and left-aligned against the centred greeting above it:
1775
+ the greeting is a title, this is body copy plus two calls to action, and
1776
+ centred paragraphs at this length are unreadable. */
1777
+ .welcome-connect {
1778
+ display: flex;
1779
+ flex-direction: column;
1780
+ gap: 14px;
1781
+ width: 100%;
1782
+ max-width: 620px;
1783
+ margin-top: 8px;
1784
+ padding: 16px 18px;
1785
+ border: 1px solid var(--border);
1786
+ border-radius: 10px;
1787
+ background: var(--surface);
1788
+ text-align: left;
1789
+ animation: wc-in 0.25s ease-out;
1790
+ }
1791
+
1792
+ /* Required, not cosmetic: `display: flex` above outranks the UA sheet's
1793
+ `[hidden] { display: none }`, so without this the `hidden` attribute the
1794
+ dismiss button and applyWelcomeConnect() rely on would be a silent no-op
1795
+ (same trap already documented for .update-banner and .autonomous-controls). */
1796
+ .welcome-connect[hidden] {
1797
+ display: none;
1798
+ }
1799
+
1800
+ @keyframes wc-in {
1801
+ from {
1802
+ opacity: 0;
1803
+ transform: translateY(6px);
1804
+ }
1805
+ to {
1806
+ opacity: 1;
1807
+ transform: none;
1808
+ }
1809
+ }
1810
+
1811
+ .wc-why-title {
1812
+ font-size: 11px;
1813
+ font-weight: 700;
1814
+ text-transform: uppercase;
1815
+ letter-spacing: 0.08em;
1816
+ color: var(--teal);
1817
+ margin-bottom: 8px;
1818
+ font-family: var(--font-display);
1819
+ }
1820
+
1821
+ .wc-why-list {
1822
+ margin: 0;
1823
+ padding: 0;
1824
+ list-style: none;
1825
+ display: flex;
1826
+ flex-direction: column;
1827
+ gap: 7px;
1828
+ }
1829
+
1830
+ .wc-why-list li {
1831
+ position: relative;
1832
+ padding-left: 17px;
1833
+ font-size: 12px;
1834
+ line-height: 1.55;
1835
+ color: var(--text2);
1836
+ }
1837
+
1838
+ .wc-why-list li::before {
1839
+ content: '✦';
1840
+ position: absolute;
1841
+ left: 0;
1842
+ top: 1px;
1843
+ font-size: 10px;
1844
+ color: var(--text3);
1845
+ }
1846
+
1847
+ .wc-why-list b {
1848
+ color: var(--text);
1849
+ font-weight: 600;
1850
+ }
1851
+
1852
+ .wc-paths {
1853
+ display: grid;
1854
+ grid-template-columns: 1fr 1fr;
1855
+ gap: 10px;
1856
+ align-items: stretch;
1857
+ }
1858
+
1859
+ .wc-path {
1860
+ display: flex;
1861
+ flex-direction: column;
1862
+ gap: 8px;
1863
+ text-align: left;
1864
+ padding: 13px 14px;
1865
+ border: 1px solid var(--border2);
1866
+ border-radius: 9px;
1867
+ background: var(--surface2);
1868
+ color: var(--text2);
1869
+ font-family: var(--font-chat);
1870
+ cursor: pointer;
1871
+ transition: all 0.15s;
1872
+ }
1873
+
1874
+ .wc-path:hover,
1875
+ .wc-path:focus-visible {
1876
+ border-color: var(--teal);
1877
+ background: rgba(0, 229, 192, 0.06);
1878
+ outline: none;
1879
+ }
1880
+
1881
+ .wc-ic {
1882
+ font-size: 16px;
1883
+ line-height: 1;
1884
+ }
1885
+
1886
+ .wc-body {
1887
+ display: flex;
1888
+ flex-direction: column;
1889
+ gap: 5px;
1890
+ flex: 1;
1891
+ }
1892
+
1893
+ .wc-name {
1894
+ font-size: 13px;
1895
+ font-weight: 600;
1896
+ color: var(--text);
1897
+ }
1898
+
1899
+ .wc-path:hover .wc-name,
1900
+ .wc-path:focus-visible .wc-name {
1901
+ color: var(--teal);
1902
+ }
1903
+
1904
+ .wc-desc {
1905
+ font-size: 11px;
1906
+ line-height: 1.5;
1907
+ color: var(--text2);
1908
+ }
1909
+
1910
+ .wc-go {
1911
+ font-size: 11px;
1912
+ font-weight: 600;
1913
+ letter-spacing: 0.02em;
1914
+ color: var(--teal);
1915
+ }
1916
+
1917
+ .wc-skip {
1918
+ grid-column: 1 / -1;
1919
+ justify-self: center;
1920
+ border: 0;
1921
+ background: none;
1922
+ padding: 2px 8px;
1923
+ font-family: var(--font-chat);
1924
+ font-size: 11px;
1925
+ color: var(--text3);
1926
+ cursor: pointer;
1927
+ text-decoration: underline;
1928
+ text-underline-offset: 3px;
1929
+ text-decoration-color: var(--border2);
1930
+ }
1931
+
1932
+ .wc-skip:hover {
1933
+ color: var(--text2);
1934
+ }
1935
+
1936
+ /* Sidebar cards are ~280px, so side-by-side path cards stop being readable
1937
+ long before the window is narrow. */
1938
+ @media (max-width: 720px) {
1939
+ .wc-paths {
1940
+ grid-template-columns: 1fr;
1941
+ }
1942
+ }
1943
+
1944
+ /* ------------------------------------------------ sidebar "landing" flash
1945
+ revealSidebarCard() adds .flash for 1.2s after a welcome click scrolls a
1946
+ settings card into view. The class was being added with no rule behind it,
1947
+ so the click had no visible landing spot at all — this is that rule. Kept
1948
+ element-agnostic: revealSidebarCard falls back to the node itself when it
1949
+ is not inside a .card. */
1950
+ .flash {
1951
+ animation: card-flash 1.2s ease-out;
1952
+ }
1953
+
1954
+ @keyframes card-flash {
1955
+ 0% {
1956
+ border-color: var(--teal);
1957
+ box-shadow: 0 0 0 3px rgba(0, 229, 192, 0.18);
1958
+ }
1959
+ 70% {
1960
+ border-color: var(--teal);
1961
+ box-shadow: 0 0 0 3px rgba(0, 229, 192, 0.1);
1962
+ }
1963
+ 100% {
1964
+ box-shadow: none;
1965
+ }
1966
+ }
1967
+
1968
+ @media (prefers-reduced-motion: reduce) {
1969
+ .welcome-connect {
1970
+ animation: none;
1971
+ }
1972
+ .flash {
1973
+ animation: none;
1974
+ border-color: var(--teal);
1975
+ }
1976
+ }
1977
+
1762
1978
  /* ------------------------------------------------- rendered markdown (D?)
1763
1979
  renderer/markdown.js parses assistant text with marked, sanitizes with
1764
1980
  DOMPurify, then hands the sanitized HTML to .body/.flow-body via
package/vendor/aegis.js CHANGED
@@ -46,6 +46,25 @@ function envVar(name) {
46
46
  return v && !/^\$\{[A-Z_]+\}$/.test(v) ? v : '';
47
47
  }
48
48
 
49
+ /**
50
+ * Read a positive millisecond count from the environment, or 0 for "unset".
51
+ *
52
+ * Used only for the two stream watchdogs below. Non-positive and unparseable
53
+ * values are IGNORED rather than honoured — deliberately not the rule the
54
+ * engine's host cooldown uses, where `AEGIS_HOST_COOLDOWN_MS=0` meaning "no
55
+ * cooldown" is a coherent thing to ask for. Here `0` would mean "the stream is
56
+ * dead before its first byte", which is a typo rather than a preference, and
57
+ * silently killing every turn is a far worse failure than ignoring a bad
58
+ * number. A test that genuinely wants a sub-second watchdog passes
59
+ * `idleTimeoutMs` per call (test/autonomous-mode.test.mjs does exactly that).
60
+ */
61
+ function envIdleMs(name) {
62
+ const raw = envVar(name);
63
+ if (!raw) return 0;
64
+ const n = Number(raw);
65
+ return Number.isFinite(n) && n > 0 ? n : 0;
66
+ }
67
+
49
68
  /**
50
69
  * UUID v4 that works everywhere: Web Crypto first (browsers, Node ≥ 19),
51
70
  * then Node's CJS crypto module (Node < 19), then a Math.random fallback for
@@ -895,9 +914,27 @@ const BRAIN_IDLE_TIMEOUT_MS = 15 * 60_000;
895
914
  * that runs the fan-out, so a renamed brain id or a caller that forgot its
896
915
  * flag cannot desynchronise the two. A response with no header (an older
897
916
  * server, or a single-pass call) keeps the caller's budget or the 60s default.
917
+ *
918
+ * ── the two env knobs ───────────────────────────────────────────────────────
919
+ *
920
+ * `AEGIS_SSE_IDLE_TIMEOUT_MS` and `AEGIS_BRAIN_IDLE_TIMEOUT_MS` move those two
921
+ * *defaults* without a code edit: the CLI is a host, not a fork, so before this
922
+ * the only way to give a slow link more room — or to reproduce a stall against
923
+ * a fixed wedged server — was to patch the transport. Same escape hatch the
924
+ * engine gives its host cooldown (`AEGIS_HOST_COOLDOWN_MS`).
925
+ *
926
+ * Both are read at CALL time, not at module load, so a host that sets one after
927
+ * `require()` still gets it, and one that unsets it is back on the shipped
928
+ * default. A per-call `idleTimeoutMs` still outranks both: an explicit number
929
+ * from the caller is a decision about *this* request, while the env var is only
930
+ * a house default. See envIdleMs() for why a bad value is ignored rather than
931
+ * obeyed.
898
932
  */
899
933
  function idleBudgetFor(res, requestedMs) {
900
- const base = Number(requestedMs) > 0 ? Number(requestedMs) : SSE_IDLE_TIMEOUT_MS;
934
+ // Caller override -> env knob -> shipped default.
935
+ const stated = Number(requestedMs);
936
+ const envDefault = envIdleMs('AEGIS_SSE_IDLE_TIMEOUT_MS');
937
+ const base = stated > 0 ? stated : envDefault > 0 ? envDefault : SSE_IDLE_TIMEOUT_MS;
901
938
  let header = '';
902
939
  try {
903
940
  const get = res && res.headers && typeof res.headers.get === 'function' ? res.headers.get.bind(res.headers) : null;
@@ -905,7 +942,13 @@ function idleBudgetFor(res, requestedMs) {
905
942
  } catch {
906
943
  header = ''; // an exotic fetch shim without headers: keep the caller's budget
907
944
  }
908
- return header && String(header).trim() ? Math.max(base, BRAIN_IDLE_TIMEOUT_MS) : base;
945
+ // The fan-out floor is tunable for the same reason the base is — and it has
946
+ // to stay a FLOOR: a server that announced workers=3 is still working, so a
947
+ // smaller base must not cut it off. Raise both if the server's own
948
+ // NEXUS_BRAIN_WORKER_TIMEOUT moves (test/autonomous-mode.test.mjs pins the
949
+ // 600s relationship between them).
950
+ const brainFloor = envIdleMs('AEGIS_BRAIN_IDLE_TIMEOUT_MS') || BRAIN_IDLE_TIMEOUT_MS;
951
+ return header && String(header).trim() ? Math.max(base, brainFloor) : base;
909
952
  }
910
953
 
911
954
  const api = { createClient, envVar, randomUUID, DEFAULT_API_BASE, CLIENT_VERSION, idleBudgetFor, SSE_IDLE_TIMEOUT_MS, BRAIN_IDLE_TIMEOUT_MS };