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 +40 -9
- package/build/icon.icns +0 -0
- package/build/icon.ico +0 -0
- package/build/icon.png +0 -0
- package/build/icons/1024x1024.png +0 -0
- package/build/icons/128x128.png +0 -0
- package/build/icons/16x16.png +0 -0
- package/build/icons/256x256.png +0 -0
- package/build/icons/32x32.png +0 -0
- package/build/icons/48x48.png +0 -0
- package/build/icons/512x512.png +0 -0
- package/build/icons/64x64.png +0 -0
- package/main.js +85 -4
- package/package.json +7 -3
- package/renderer/app.js +147 -0
- package/renderer/index.html +74 -0
- package/renderer/style.css +216 -0
- package/vendor/aegis.js +45 -2
package/README.md
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# AEGIS Desktop
|
|
2
2
|
|
|
3
|
+

|
|
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.
|
|
29
|
-
the pooled margin, or the BYOK handling fee on top of your own
|
|
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
|
|
53
|
-
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
+

|
|
118
|
+
|
|
119
|
+
**A completed answer** in the transcript:
|
|
120
|
+
|
|
121
|
+

|
|
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
|
+

|
|
127
|
+
|
|
97
128
|
## Tools available to the model
|
|
98
129
|
|
|
99
130
|
| Tool | What it does |
|
package/build/icon.icns
ADDED
|
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.
|
|
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
|
package/renderer/index.html
CHANGED
|
@@ -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
|
package/renderer/style.css
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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 };
|