aegis-desktop 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/main.js ADDED
@@ -0,0 +1,715 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * AEGIS Desktop — thin Electron main shell (host 2).
4
+ *
5
+ * This process owns the ONLY privileged object in the app: an instance of the
6
+ * shared thin client (../client/aegis.js), which holds the AEGIS_API_KEY read
7
+ * from the environment. The renderer never sees the key; it talks exclusively
8
+ * over a whitelisted, context-isolated IPC surface (see preload.js).
9
+ *
10
+ * Hard boundary: this file is a window + IPC shell. It contains no engine,
11
+ * orchestration, routing, tier, or memory logic — that all lives behind
12
+ * aegiscloud.org. If it grows beyond a thin shell, it is wrong.
13
+ *
14
+ * Layout:
15
+ * - bootstrap() Electron-only: window + IPC registration + lifecycle
16
+ * - createIpcDispatch() pure: method-name -> client call (unit-testable
17
+ * in plain Node, no Electron binary needed)
18
+ */
19
+
20
+ 'use strict';
21
+
22
+ const path = require('node:path');
23
+
24
+ // Dev / CI / smoke-test layout: desktop/ lives inside the repo, so the shared
25
+ // client resolves via ../client/aegis.js. In the packaged app the client is
26
+ // copied to desktop/vendor/aegis.js by the predist step (electron-builder
27
+ // cannot reach outside the app dir), so fall back to that copy. Both files
28
+ // are the same thin transport — never brain logic.
29
+ let sharedClient;
30
+ try {
31
+ sharedClient = require('../client/aegis.js');
32
+ } catch {
33
+ sharedClient = require('./vendor/aegis.js');
34
+ }
35
+ const { createClient } = sharedClient;
36
+
37
+ let electron = null;
38
+ try {
39
+ // In plain Node (CI smoke test, `node --check`) this either throws
40
+ // (module absent) or resolves to the electron binary path string with no
41
+ // `.app` — either way bootstrap() is skipped and the pure exports survive.
42
+ electron = require('electron');
43
+ } catch {
44
+ /* not running under Electron */
45
+ }
46
+
47
+ // LocalEngine wiring (plan P1 §5.2): the model:/sync: surfaces are additive,
48
+ // backed by transport-only modules in desktop/lib/. No brain logic enters here.
49
+ const os = require('node:os');
50
+ const { createLocalEngine } = require('./lib/local/engine.js');
51
+ const { createSettingsStore, isReservedNamespace } = require('./lib/settings.js');
52
+ const ollama = require('./lib/local/ollama.js');
53
+ const providers = require('./lib/local/providers.js');
54
+ const sessionStore = require('./lib/sync/sessions.js');
55
+ const memoryQueue = require('./lib/sync/memory-queue.js');
56
+ // Foreign-memory scanner (shared with client/foreign-memory.js). Same
57
+ // resolution rule as the transport above: the canonical file inside the repo,
58
+ // the predist-staged copy in a packaged app. Never forked logic — so it needs
59
+ // no wrapper module of its own.
60
+ let foreignMemory;
61
+ try {
62
+ foreignMemory = require('../client/foreign-memory.js');
63
+ } catch {
64
+ foreignMemory = require('./vendor/foreign-memory.js');
65
+ }
66
+ // Builtin tool executor for the agent loop (client half of aegiscodex-dev's
67
+ // tool calling). MAIN-process only: it is reachable from the renderer solely
68
+ // through the whitelisted `tools:` IPC surface registered below — see the
69
+ // sandbox note on registerToolsIpc().
70
+ const localTools = require('./lib/local/tools.js');
71
+
72
+ const IPC_PREFIX = 'aegis:';
73
+ const MODEL_PREFIX = 'model:';
74
+ const SYNC_PREFIX = 'sync:';
75
+ const TOOLS_PREFIX = 'tools:';
76
+
77
+ /**
78
+ * Main -> renderer push channel for chat SSE deltas (D2.1 streaming render).
79
+ * The invoke of aegis:chatCompletion still resolves once with the normalised
80
+ * final result; live chunks travel on this single dedicated channel.
81
+ */
82
+ const CHAT_DELTA_CHANNEL = `${IPC_PREFIX}chatDelta`;
83
+
84
+ /**
85
+ * Address one SSE delta to the stream that produced it (D2.2 multi-stream).
86
+ *
87
+ * The renderer's chat flow runs a *set* of concurrent streams — the primary
88
+ * answer plus the horizontal discovery lane — so every chunk must say which
89
+ * sessionId it belongs to. `id` is added last so the routing tag stays
90
+ * authoritative, while `delta` (and any future field the client sends) is
91
+ * copied through untouched: existing single-stream callers that never set a
92
+ * sessionId simply receive `id: undefined`, exactly the shape they see today.
93
+ */
94
+ function taggedChunk(chunk, sessionId) {
95
+ const base = chunk && typeof chunk === 'object' ? chunk : { delta: chunk };
96
+ return { ...base, id: sessionId };
97
+ }
98
+
99
+ const APP_VERSION = require('./package.json').version;
100
+
101
+ /** Never ship a full key to the renderer — only a masked preview. */
102
+ function maskKey(key) {
103
+ if (!key) return null;
104
+ if (key.length <= 10) return 'configured';
105
+ return `${key.slice(0, 9)}\u2026${key.slice(-4)}`;
106
+ }
107
+
108
+ /**
109
+ * Classify a memory-endpoint failure: is this the free-plan cap, or is it just
110
+ * offline? aegis1 answers HTTP 402 `free_session_limit_reached` on every
111
+ * metered memory endpoint (app.py:9229) and the shared client attaches
112
+ * `err.status` / `err.data` (vendor/aegis.js parseResponse) — but
113
+ * `ipcRenderer.invoke` only carries the *message string* across the process
114
+ * boundary. A thrown cap therefore reaches the renderer as the bare text
115
+ * "free_session_limit_reached" with `status`/`data` stripped, which is why the
116
+ * upgrade UI in fetchMemory() never fired. So: detect it here, in main, where
117
+ * the fields still exist, and hand the renderer a plain resolved payload.
118
+ *
119
+ * Returns null for anything that is not a cap (offline, no key, 500, …).
120
+ */
121
+ function upgradeInfo(err) {
122
+ const status = err && err.status;
123
+ const data = (err && err.data) || {};
124
+ const code = typeof data.error === 'string' ? data.error : '';
125
+ if (status !== 402 && code !== 'free_session_limit_reached') return null;
126
+ return {
127
+ url: data.upgradeUrl || 'https://aegiscloud.org/subscribe',
128
+ used: data.sessionsUsed != null ? data.sessionsUsed : null,
129
+ limit: data.freeSessionLimit != null ? data.freeSessionLimit : null,
130
+ code: code || 'free_session_limit_reached',
131
+ };
132
+ }
133
+
134
+ /** Message text for a failure, without assuming the Error shape. */
135
+ function errorText(err) {
136
+ return err && err.message ? err.message : String(err);
137
+ }
138
+
139
+ /**
140
+ * `aegis:memorySave` with the offline-first fallback (plan P3 §7): try the
141
+ * cloud save first; if it fails (no key, offline, transient error) queue the
142
+ * entry in <dir>/memory-queue.json instead of throwing, so the renderer's
143
+ * "remember" affordance never surfaces an error for the no-key case. `dir` is
144
+ * optional — callers that omit it (e.g. the desktop-shell smoke test) simply
145
+ * get the un-queued rejection back, unchanged from before this existed.
146
+ *
147
+ * The cap is the one failure that is deliberately NOT queued: see upgradeInfo.
148
+ * A 402 resolves `{ ok:false, upgrade }` — never thrown, because the fields
149
+ * would not survive the IPC trip — and the entry is left un-stored so the
150
+ * renderer can keep it in the box and point at the subscribe page.
151
+ */
152
+ async function saveMemoryWithQueue(aegis, dir, entry) {
153
+ try {
154
+ return await aegis.memorySave(entry);
155
+ } catch (err) {
156
+ const upgrade = upgradeInfo(err);
157
+ if (upgrade) {
158
+ return { ok: false, queued: 0, saved: 0, upgrade, reason: errorText(err) };
159
+ }
160
+ if (!dir || !entry) throw err;
161
+ memoryQueue.enqueue(dir, entry);
162
+ return { ok: true, queued: true, reason: errorText(err) };
163
+ }
164
+ }
165
+
166
+ /**
167
+ * Read side of the same normalisation. `memorySearch` / `memoryList` run
168
+ * `_memory_sync_access` server-side, so they 402 on exactly the same cap;
169
+ * resolving `{ entries: [], upgrade }` keeps one detection path for all four
170
+ * memory calls instead of four renderer-side branches that can't see `err.status`.
171
+ */
172
+ async function normalizeMemoryRead(promise) {
173
+ try {
174
+ return await promise;
175
+ } catch (err) {
176
+ const upgrade = upgradeInfo(err);
177
+ if (!upgrade) throw err;
178
+ return { entries: [], upgrade };
179
+ }
180
+ }
181
+
182
+ /**
183
+ * `aegis:memoryImport` — scan this machine for other AI tools' memory and,
184
+ * when confirmed, push it into AEGIS cloud memory.
185
+ *
186
+ * Read-only against the foreign stores (client/foreign-memory.js never writes
187
+ * to them). Two-phase by design: a dry run reports counts so the user can see
188
+ * what would land before anything leaves the machine. When a confirmed save
189
+ * can't reach the cloud (no key / offline), entries fall back to the same
190
+ * local memory-queue the single-entry save path uses, so the import is not
191
+ * lost — it flushes on the next "Sync now".
192
+ *
193
+ * The renderer gets counts and a summary string, never the entry bodies:
194
+ * 1000 entries x 2 kB over IPC is pure waste, and the bodies are already
195
+ * either in the cloud or in the queue by the time this resolves.
196
+ */
197
+ async function importForeignMemory(aegis, dir, payload) {
198
+ const opts = payload || {};
199
+ const report = foreignMemory.scan({
200
+ sources: opts.sources,
201
+ limit: opts.limit || 1000,
202
+ });
203
+ const summary = foreignMemory.describe(report);
204
+ const sources = report.sources.map((s) => ({
205
+ id: s.id,
206
+ label: s.label,
207
+ verified: s.verified,
208
+ present: s.present,
209
+ count: s.count,
210
+ skipped: s.skipped,
211
+ files: s.files,
212
+ }));
213
+
214
+ const base = { summary, totals: report.totals, sources };
215
+
216
+ if (!opts.confirm || !report.entries.length) {
217
+ return { ...base, ok: true, dryRun: !opts.confirm, saved: 0, queued: 0 };
218
+ }
219
+
220
+ let saved = 0;
221
+ let queued = 0;
222
+ const errors = [];
223
+ let upgrade = null;
224
+ for (const batch of foreignMemory.chunk(report.entries, 200)) {
225
+ try {
226
+ const data = await aegis.memorySaveBatch(batch);
227
+ saved += (data && data.saved) || batch.length;
228
+ } catch (err) {
229
+ // The cap is not an offline failure: don't queue the batch. The entries
230
+ // are still in the foreign stores, so a later scan re-finds them — but a
231
+ // queued copy would flush-fail forever and, worse, made a mid-import 402
232
+ // report as a silent `queued` count. Report the upgrade instead.
233
+ upgrade = upgradeInfo(err);
234
+ if (!upgrade && dir) {
235
+ // Offline-first, exactly like saveMemoryWithQueue(): keep the entries
236
+ // rather than dropping them. Anything already saved stays saved.
237
+ for (const entry of batch) {
238
+ memoryQueue.enqueue(dir, entry);
239
+ queued += 1;
240
+ }
241
+ }
242
+ errors.push(errorText(err));
243
+ break;
244
+ }
245
+ }
246
+
247
+ return {
248
+ ...base,
249
+ ok: errors.length === 0,
250
+ dryRun: false,
251
+ saved,
252
+ queued,
253
+ upgrade,
254
+ reason: errors[0] || null,
255
+ };
256
+ }
257
+
258
+ /**
259
+ * Pure mapping: IPC payload -> shared-client call. No Electron types here, so
260
+ * tests can drive it with a stub client and a fake ipcMain.
261
+ */
262
+ function createIpcDispatch(aegis, dir, persistApiKey) {
263
+ const dispatch = {
264
+ status: () => ({
265
+ appVersion: APP_VERSION,
266
+ clientVersion: aegis.clientVersion,
267
+ apiBase: aegis.apiBase,
268
+ keyConfigured: Boolean(aegis.apiKey),
269
+ keyMask: maskKey(aegis.apiKey),
270
+ }),
271
+
272
+ // In-app API key entry (plan rebuild): replace the live client key and
273
+ // persist it encrypted at rest via the settings store. The renderer only
274
+ // ever sees the masked preview back — never the raw key.
275
+ setApiKey: (payload) => {
276
+ const key = payload && payload.key;
277
+ aegis.setApiKey(key || '');
278
+ if (persistApiKey) persistApiKey(aegis.apiKey);
279
+ return { keyConfigured: Boolean(aegis.apiKey), keyMask: maskKey(aegis.apiKey) };
280
+ },
281
+
282
+ verifyApiKey: () => aegis.verifyApiKey(),
283
+ tokenBankBalance: () => aegis.tokenBankBalance(),
284
+
285
+ listModels: () => aegis.listModels(),
286
+
287
+ chatCompletion: (payload) =>
288
+ aegis.chatCompletion({
289
+ prompt: payload && payload.prompt,
290
+ system: payload && payload.system,
291
+ model: payload && payload.model,
292
+ mode: payload && payload.mode,
293
+ maxTokens: payload && payload.maxTokens,
294
+ // stream/onStream are forwarded when present; registerIpc() injects
295
+ // the IPC chunk forwarder for the desktop host (see below).
296
+ stream: payload && payload.stream,
297
+ onStream: payload && payload.onStream,
298
+ }),
299
+
300
+ byokStatus: () => aegis.byokStatus(),
301
+ byokSet: (payload) =>
302
+ aegis.byokSet(
303
+ payload && payload.provider,
304
+ payload && payload.apiKey
305
+ ),
306
+
307
+ memorySearch: (payload) =>
308
+ normalizeMemoryRead(
309
+ aegis.memorySearch(payload && payload.query, payload && payload.limit)
310
+ ),
311
+ memorySave: (payload) =>
312
+ saveMemoryWithQueue(aegis, dir, payload && payload.entry),
313
+ memoryList: (payload) =>
314
+ normalizeMemoryRead(aegis.memoryList(payload && payload.limit)),
315
+
316
+ verifyToken: (payload) =>
317
+ aegis.verifyToken(payload && payload.token),
318
+ memoryActivate: (payload) =>
319
+ aegis.memoryActivate(payload && payload.token),
320
+ memoryPull: (payload) =>
321
+ aegis.memoryPull(payload && payload.since),
322
+ memorySaveBatch: (payload) =>
323
+ aegis.memorySaveBatch(payload && payload.entries),
324
+ memoryImport: (payload) =>
325
+ importForeignMemory(aegis, dir, payload),
326
+ importConversation: (payload) =>
327
+ aegis.importConversation(payload || {}),
328
+ };
329
+ return dispatch;
330
+ }
331
+
332
+ /** Register every dispatch method as `aegis:<name>` on ipcMain. `dir` (the
333
+ * user-data dir) is optional and threaded through only for the memorySave
334
+ * offline-queue fallback — see saveMemoryWithQueue(). */
335
+ function registerIpc(ipcMain, aegis, dir, persistApiKey) {
336
+ const dispatch = createIpcDispatch(aegis, dir, persistApiKey);
337
+ for (const [name, handler] of Object.entries(dispatch)) {
338
+ if (name === 'chatCompletion') {
339
+ // Streaming render (D2.1): when the renderer asks for stream, SSE deltas
340
+ // are pushed over CHAT_DELTA_CHANNEL as they arrive while the invoke
341
+ // promise still resolves once with the normalised final result. When the
342
+ // payload does not request stream, behaviour is the plain non-streaming
343
+ // dispatch (what the headless shell test drives directly).
344
+ ipcMain.handle(`${IPC_PREFIX}${name}`, (event, payload) => {
345
+ const opts = { ...(payload || {}) };
346
+ if (!opts.stream) return handler(opts);
347
+ const sender = event && event.sender;
348
+ const forward = (chunk) => {
349
+ if (
350
+ sender &&
351
+ typeof sender.send === 'function' &&
352
+ !sender.isDestroyed()
353
+ ) {
354
+ // Address every delta with the request's sessionId (D2.2): the
355
+ // chat flow can run more than one stream at a time (the primary
356
+ // answer plus the horizontal discovery lane), so an unaddressed
357
+ // broadcast would interleave both replies into one bubble. The
358
+ // renderer's preload filters on `id`; chunks stay shape-compatible
359
+ // ({ delta }) for callers that never set a sessionId.
360
+ sender.send(CHAT_DELTA_CHANNEL, taggedChunk(chunk, opts.sessionId));
361
+ }
362
+ };
363
+ return handler({ ...opts, stream: true, onStream: forward });
364
+ });
365
+ continue;
366
+ }
367
+ ipcMain.handle(`${IPC_PREFIX}${name}`, (_event, payload) => handler(payload));
368
+ }
369
+ return dispatch;
370
+ }
371
+
372
+ /**
373
+ * Resolve the per-user data directory for settings + sessions. Electron gives
374
+ * us app.getPath('userData'); outside Electron (headless smoke tests) fall back
375
+ * to ~/.aegiscode so the pure functions can still be exercised.
376
+ */
377
+ function resolveUserDataDir(app) {
378
+ if (app && typeof app.getPath === 'function') {
379
+ try {
380
+ return app.getPath('userData');
381
+ } catch {
382
+ /* fall through */
383
+ }
384
+ }
385
+ return path.join(os.homedir(), '.aegiscode');
386
+ }
387
+
388
+ /**
389
+ * Wire the real LocalEngine registry: settings store + ollama/providers
390
+ * transports + the shared cloud client. Transport-only — no brain logic.
391
+ */
392
+ function createEngine(aegis, { app, safeStorage, dir: dirOverride } = {}) {
393
+ const dir = dirOverride || resolveUserDataDir(app);
394
+ const settings = createSettingsStore({ dir, safeStorage });
395
+ // Relocate a pre-fix `settings['aegis']` key into the reserved namespace so
396
+ // it stops showing up as a provider. Ciphertext-level, so safe pre-'ready'.
397
+ settings.migrateLegacyAegisKey();
398
+ const engine = createLocalEngine({ aegis, settings, ollama, providers });
399
+ return { engine, sessionsDir: dir, settings };
400
+ }
401
+
402
+ /**
403
+ * Pure mapping: model:<name> -> LocalEngine call (unit-testable in Node).
404
+ */
405
+ function createModelDispatch(engine) {
406
+ return {
407
+ listClasses: () => engine.listClasses(),
408
+ listModels: (payload) => engine.listModels(payload && payload.class),
409
+ chat: (payload) => engine.chat(payload, payload && payload.onStream),
410
+ // The AEGIS key lives in a reserved namespace the store refuses to expose
411
+ // or delete through this surface; the filter is belt-and-braces so a
412
+ // mis-wired store can never hand the renderer a removable AEGIS entry.
413
+ 'settings.get': () =>
414
+ engine.settings.list().filter((s) => !(s && isReservedNamespace(s.provider))),
415
+ 'settings.set': (payload) =>
416
+ engine.settings.set(payload && payload.provider, {
417
+ baseURL: payload && payload.baseURL,
418
+ key: payload && payload.key,
419
+ }),
420
+ 'settings.remove': (payload) =>
421
+ engine.settings.remove(payload && payload.provider),
422
+ cancel: (payload) => engine.cancel(payload && payload.sessionId),
423
+ };
424
+ }
425
+
426
+ /**
427
+ * Pure mapping: sync:<name> -> sessions store call (unit-testable in Node).
428
+ * `push`/`pull` are the P3 cloud-sync surface (plan §7/§8.5): they reuse the
429
+ * shared client's memory-token auth (`aegis.conversationSyncPush/Pull`) — no
430
+ * new credential flow. Offline-first: with no AEGIS key configured, both
431
+ * resolve `{ ok: false, reason }` without ever throwing, and every local
432
+ * flow (save/append/list) keeps working untouched.
433
+ */
434
+ function createSyncDispatch(sessions, dir, aegis) {
435
+ let lastSyncAt = null;
436
+
437
+ function hasCloud() {
438
+ return Boolean(aegis && aegis.apiKey);
439
+ }
440
+
441
+ /** Retry every locally queued memory-save (plan P3 §7 "queue locally and
442
+ * sync later") now that the cloud is reachable. Entries that still fail
443
+ * (e.g. one bad entry among several) stay queued for the next attempt. */
444
+ async function flushMemoryQueue() {
445
+ const queued = memoryQueue.listQueued(dir);
446
+ if (!queued.length) return { flushed: 0, remaining: 0, upgrade: null };
447
+ const remaining = [];
448
+ let flushed = 0;
449
+ let upgrade = null;
450
+ for (let i = 0; i < queued.length; i += 1) {
451
+ try {
452
+ await aegis.memorySave(queued[i]);
453
+ flushed += 1;
454
+ } catch (err) {
455
+ const capped = upgradeInfo(err);
456
+ if (capped) {
457
+ // A 402 is not "try again later": every remaining entry would fail
458
+ // the same way, the queue would never drain, and the paywall would
459
+ // stay invisible behind a "synced" toast. Stop, keep the rest
460
+ // queued for after the upgrade, and surface the cap to the renderer.
461
+ upgrade = capped;
462
+ remaining.push(...queued.slice(i));
463
+ break;
464
+ }
465
+ // Any other failure (offline / transient) keeps the entry queued.
466
+ remaining.push(queued[i]);
467
+ }
468
+ }
469
+ memoryQueue.save(dir, remaining);
470
+ return { flushed, remaining: remaining.length, upgrade };
471
+ }
472
+
473
+ async function push() {
474
+ const pending = sessions.listPending(dir);
475
+ if (!hasCloud()) {
476
+ return { ok: false, queued: pending.length, reason: 'no AEGIS key configured' };
477
+ }
478
+ const memoryFlush = await flushMemoryQueue();
479
+ if (!pending.length) {
480
+ return {
481
+ ok: true,
482
+ queued: 0,
483
+ pushed: 0,
484
+ memoryFlushed: memoryFlush.flushed,
485
+ upgrade: memoryFlush.upgrade,
486
+ };
487
+ }
488
+
489
+ let pushed = 0;
490
+ let lastError = null;
491
+ for (const session of pending) {
492
+ try {
493
+ const result = await aegis.conversationSyncPush({
494
+ session_id: session.id,
495
+ title: session.title,
496
+ messages: session.messages,
497
+ });
498
+ const remoteId = result && (result.session_id || result.id);
499
+ sessions.markSynced(dir, session.id, { remoteId });
500
+ pushed += 1;
501
+ } catch (err) {
502
+ lastError = err;
503
+ }
504
+ }
505
+ const queued = sessions.listPending(dir).length;
506
+ if (pushed) lastSyncAt = Date.now();
507
+ if (pushed === 0 && lastError) {
508
+ return {
509
+ ok: false,
510
+ queued,
511
+ reason: lastError.message || 'push failed',
512
+ memoryFlushed: memoryFlush.flushed,
513
+ upgrade: memoryFlush.upgrade,
514
+ };
515
+ }
516
+ return {
517
+ ok: true,
518
+ queued,
519
+ pushed,
520
+ memoryFlushed: memoryFlush.flushed,
521
+ upgrade: memoryFlush.upgrade,
522
+ };
523
+ }
524
+
525
+ async function pull() {
526
+ if (!hasCloud()) {
527
+ return { ok: false, merged: 0, reason: 'no AEGIS key configured' };
528
+ }
529
+ try {
530
+ const data = await aegis.conversationSyncPull();
531
+ const merged = sessions.mergeRemoteSessions(dir, (data && data.sessions) || []);
532
+ lastSyncAt = Date.now();
533
+ return { ok: true, merged };
534
+ } catch (err) {
535
+ return { ok: false, merged: 0, reason: err && err.message ? err.message : String(err) };
536
+ }
537
+ }
538
+
539
+ return {
540
+ listSessions: () => ({ sessions: sessions.listSessions(dir) }),
541
+ open: (payload) => sessions.getSession(dir, payload && payload.sessionId),
542
+ save: (payload) => sessions.upsertSession(dir, payload || {}),
543
+ append: (payload) =>
544
+ sessions.appendMessage(
545
+ dir,
546
+ payload && payload.sessionId,
547
+ payload && payload.message
548
+ ),
549
+ delete: (payload) =>
550
+ sessions.deleteSession(dir, payload && payload.sessionId),
551
+ push,
552
+ pull,
553
+ status: () => ({
554
+ count: sessions.listSessions(dir).length,
555
+ pending: sessions.listPending(dir).length,
556
+ cloud: hasCloud(),
557
+ lastSyncAt,
558
+ }),
559
+ };
560
+ }
561
+
562
+ /**
563
+ * Register model:<name> and sync:<name> on ipcMain. `model:chat` is always
564
+ * streaming: deltas are pushed over CHAT_DELTA_CHANNEL exactly like the
565
+ * existing `aegis:chatCompletion` special case. `aegis` (the shared client)
566
+ * is optional — headless/offline callers omit it and every sync method falls
567
+ * back to its no-cloud branch.
568
+ *
569
+ * `model:listModels` and `sync:status` also fire a background retry push of
570
+ * any pending sessions (plan §7 "retry on a heartbeat"): fire-and-forget,
571
+ * never awaited, never throws — it just gives queued sessions another
572
+ * chance to sync without a dedicated poller.
573
+ */
574
+ function registerModelIpc(ipcMain, engine, sessionsDir, aegis) {
575
+ const modelDispatch = createModelDispatch(engine);
576
+ const syncDispatch = createSyncDispatch(sessionStore, sessionsDir, aegis);
577
+
578
+ function heartbeatRetry() {
579
+ syncDispatch.push().catch(() => {});
580
+ }
581
+
582
+ for (const [name, handler] of Object.entries(modelDispatch)) {
583
+ if (name === 'chat') {
584
+ ipcMain.handle(`${MODEL_PREFIX}${name}`, (event, payload) => {
585
+ const opts = { ...(payload || {}) };
586
+ const sender = event && event.sender;
587
+ const forward = (chunk) => {
588
+ if (
589
+ sender &&
590
+ typeof sender.send === 'function' &&
591
+ !sender.isDestroyed()
592
+ ) {
593
+ // Two streams can be live at once (main answer + discovery lane),
594
+ // so each delta carries the sessionId it belongs to. See the
595
+ // matching note on the aegis:chatCompletion forwarder.
596
+ sender.send(CHAT_DELTA_CHANNEL, taggedChunk(chunk, opts.sessionId));
597
+ }
598
+ };
599
+ return handler({ ...opts, onStream: forward });
600
+ });
601
+ continue;
602
+ }
603
+ if (name === 'listModels') {
604
+ ipcMain.handle(`${MODEL_PREFIX}${name}`, (_event, payload) => {
605
+ heartbeatRetry();
606
+ return handler(payload);
607
+ });
608
+ continue;
609
+ }
610
+ ipcMain.handle(`${MODEL_PREFIX}${name}`, (_event, payload) => handler(payload));
611
+ }
612
+
613
+ for (const [name, handler] of Object.entries(syncDispatch)) {
614
+ if (name === 'status') {
615
+ ipcMain.handle(`${SYNC_PREFIX}${name}`, (_event, payload) => {
616
+ heartbeatRetry();
617
+ return handler(payload);
618
+ });
619
+ continue;
620
+ }
621
+ ipcMain.handle(`${SYNC_PREFIX}${name}`, (_event, payload) => handler(payload));
622
+ }
623
+
624
+ return { modelDispatch, syncDispatch };
625
+ }
626
+
627
+ // ---------------------------------------------------------------------------
628
+ // Electron-only bootstrap
629
+ // ---------------------------------------------------------------------------
630
+
631
+ function bootstrap() {
632
+ const { app, BrowserWindow, ipcMain, safeStorage } = electron;
633
+
634
+ app.setName('AEGIS Desktop');
635
+
636
+ const aegis = createClient();
637
+ const dataDir = resolveUserDataDir(app);
638
+
639
+ // One settings store backs both the provider settings surface and the AEGIS
640
+ // API key entry. It lives only in the main process and encrypts keys at rest
641
+ // via Electron safeStorage (best-effort base64 when unavailable).
642
+ const { engine, sessionsDir, settings } = createEngine(aegis, {
643
+ app,
644
+ safeStorage,
645
+ dir: dataDir,
646
+ });
647
+
648
+ // Persist the in-app AEGIS key in its own reserved namespace, encrypted —
649
+ // never as a provider named 'aegis' (that coupling let the Settings pane's
650
+ // "Remove" delete the AEGIS key; defect #1).
651
+ const persistApiKey = (key) => settings.setAegisKey(key);
652
+
653
+ registerIpc(ipcMain, aegis, dataDir, persistApiKey);
654
+ registerModelIpc(ipcMain, engine, sessionsDir, aegis);
655
+
656
+ function createWindow() {
657
+ const win = new BrowserWindow({
658
+ width: 1080,
659
+ height: 720,
660
+ minWidth: 720,
661
+ minHeight: 480,
662
+ backgroundColor: '#0d1117',
663
+ title: 'AEGIS Desktop',
664
+ webPreferences: {
665
+ preload: path.join(__dirname, 'preload.js'),
666
+ contextIsolation: true,
667
+ nodeIntegration: false,
668
+ sandbox: true,
669
+ spellcheck: true,
670
+ },
671
+ });
672
+
673
+ win.setMenuBarVisibility(false);
674
+ win.loadFile(path.join(__dirname, 'renderer', 'index.html'));
675
+ return win;
676
+ }
677
+
678
+ app.whenReady().then(() => {
679
+ // Hydrate the client from the persisted key BEFORE the renderer issues any
680
+ // status/model call, so class/model dropdowns populate immediately when a
681
+ // key was saved in-app (safeStorage is usable only after app ready).
682
+ const persistedKey = settings.aegisRawKey();
683
+ if (persistedKey) aegis.setApiKey(persistedKey);
684
+ createWindow();
685
+ });
686
+
687
+ app.on('window-all-closed', () => {
688
+ if (process.platform !== 'darwin') app.quit();
689
+ });
690
+
691
+ app.on('activate', () => {
692
+ if (BrowserWindow.getAllWindows().length === 0) createWindow();
693
+ });
694
+ }
695
+
696
+ if (electron && electron.app) {
697
+ bootstrap();
698
+ }
699
+
700
+ module.exports = {
701
+ createIpcDispatch,
702
+ registerIpc,
703
+ IPC_PREFIX,
704
+ MODEL_PREFIX,
705
+ SYNC_PREFIX,
706
+ CHAT_DELTA_CHANNEL,
707
+ taggedChunk,
708
+ maskKey,
709
+ createModelDispatch,
710
+ createSyncDispatch,
711
+ registerModelIpc,
712
+ createEngine,
713
+ resolveUserDataDir,
714
+ importForeignMemory,
715
+ };