aegis-desktop 1.0.13 → 1.0.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/SOURCE-OFFER.txt +9 -0
  2. package/bin/aegis.js +1 -32
  3. package/lib/account-key.js +1 -56
  4. package/lib/auto.js +1 -430
  5. package/lib/avatar/companion.js +1 -698
  6. package/lib/avatar/cosmetics.js +1 -532
  7. package/lib/avatar/events.js +1 -344
  8. package/lib/avatar/holder-turn.js +1 -350
  9. package/lib/avatar/holders.js +1 -612
  10. package/lib/avatar/identity.js +1 -641
  11. package/lib/avatar/level.js +1 -368
  12. package/lib/avatar/mirror.js +1 -524
  13. package/lib/avatar/persona.js +1 -388
  14. package/lib/avatar/profile.js +1 -977
  15. package/lib/avatar/register.js +1 -307
  16. package/lib/avatar/store.js +1 -472
  17. package/lib/avatar/tiers.js +1 -38
  18. package/lib/avatar/transfer.js +1 -415
  19. package/lib/avatar/turn.js +1 -216
  20. package/lib/avatar/voice.js +1 -328
  21. package/lib/avatar/xp.js +1 -404
  22. package/lib/deep-link.js +1 -120
  23. package/lib/fingerprint.js +2 -347
  24. package/lib/google-connect.js +1 -169
  25. package/lib/local/agents.js +1 -105
  26. package/lib/local/autonomous.js +1 -1045
  27. package/lib/local/claude-pro.js +1 -993
  28. package/lib/local/context.js +1 -95
  29. package/lib/local/engine.js +1 -3228
  30. package/lib/local/entitlement.js +1 -654
  31. package/lib/local/git-scope.js +1 -228
  32. package/lib/local/local.js +1 -2135
  33. package/lib/local/prompt.js +1 -275
  34. package/lib/local/queue.js +1 -560
  35. package/lib/local/session-rounds.js +1 -208
  36. package/lib/local/shell.js +1 -680
  37. package/lib/local/telemetry.js +1 -470
  38. package/lib/local/tools.js +1 -1238
  39. package/lib/local/turn-guard.js +1 -300
  40. package/lib/local/worktree-lock.js +1 -235
  41. package/lib/provider-key-mirror.js +1 -252
  42. package/lib/quick-launcher.js +1 -84
  43. package/lib/settings.js +1 -890
  44. package/lib/sync/memory-queue.js +1 -60
  45. package/lib/sync/persist-gate.js +1 -148
  46. package/lib/sync/sessions.js +1 -106
  47. package/lib/window-state.js +1 -66
  48. package/main.js +3 -4390
  49. package/package.json +3 -5
  50. package/preload.js +3 -628
  51. package/renderer/app.js +1 -6560
  52. package/renderer/avatar/assemble.js +1 -335
  53. package/renderer/avatar/avatar.js +1 -380
  54. package/renderer/avatar/cards.js +1 -141
  55. package/renderer/avatar/hud.js +1 -206
  56. package/renderer/avatar/machine.js +1 -213
  57. package/renderer/avatar/pane.js +1 -439
  58. package/renderer/avatar/parts.js +1 -437
  59. package/renderer/budget.js +1 -168
  60. package/renderer/companion.js +1 -223
  61. package/renderer/diff.js +1 -359
  62. package/renderer/diffview.js +1 -336
  63. package/renderer/fingerprint.js +3 -720
  64. package/renderer/markdown.js +1 -168
  65. package/renderer/provider-toolbar.js +1 -134
  66. package/renderer/providers.js +1 -259
  67. package/renderer/quick.js +1 -191
  68. package/renderer/stream-policy.js +1 -306
  69. package/renderer/transcript-view.js +1 -323
  70. package/renderer/usage.js +1 -1239
  71. package/vendor/aegis.js +1 -2054
  72. package/vendor/attest.js +1 -442
  73. package/vendor/authorship-key.js +1 -410
  74. package/vendor/auto-provision.js +1 -134
  75. package/vendor/brain-catalog.js +1 -268
  76. package/vendor/byok-catalog.js +1 -230
  77. package/vendor/credentials.js +1 -494
  78. package/vendor/device-fingerprint.js +1 -141
  79. package/vendor/drive-connect.js +1 -85
  80. package/vendor/drive-upload.js +1 -174
  81. package/vendor/env-file.js +1 -1273
  82. package/vendor/fingerprint.js +1 -1064
  83. package/vendor/foreign-memory.js +1 -669
  84. package/vendor/google-signin.js +1 -125
  85. package/vendor/hooks.js +1 -430
  86. package/vendor/model-discovery.js +1 -330
  87. package/vendor/platform.js +1 -937
  88. package/vendor/prefs.js +1 -351
  89. package/vendor/presence-constants.js +1 -107
  90. package/vendor/presence.js +1 -438
  91. package/vendor/record-vault.js +3 -1055
  92. package/vendor/session-store.js +1 -548
  93. package/vendor/topup.js +1 -297
  94. package/vendor/touch.js +1 -458
  95. package/vendor/update.js +1 -704
  96. package/vendor/user-models.js +1 -381
package/main.js CHANGED
@@ -1,4393 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  // SPDX-License-Identifier: AGPL-3.0-or-later
3
3
  // Copyright (C) 2026 Niklas Borneklint — AEGIS Code (aegiscode) / Aegiscloud.
4
- // AEGIS Code (aegiscode) — source: https://github.com/aegisinfo/aegiscode-plugin
5
- // Conveying a modified copy, or running one as a network service, owes the
6
- // corresponding source under this licence (§5/§13) — see LICENSING.md.
7
- /**
8
- * AEGIS Desktop — thin Electron main shell (host 2).
9
- *
10
- * This process owns the ONLY privileged object in the app: an instance of the
11
- * shared thin client (../client/aegis.js), which holds the AEGIS_API_KEY read
12
- * from the environment. The renderer never sees the key; it talks exclusively
13
- * over a whitelisted, context-isolated IPC surface (see preload.js).
14
- *
15
- * Hard boundary: this file is a window + IPC shell. It contains no engine,
16
- * orchestration, routing, tier, or memory logic — that all lives behind
17
- * aegiscloud.org. If it grows beyond a thin shell, it is wrong.
18
- *
19
- * Layout:
20
- * - bootstrap() Electron-only: window + IPC registration + lifecycle
21
- * - createIpcDispatch() pure: method-name -> client call (unit-testable
22
- * in plain Node, no Electron binary needed)
23
- */
24
-
25
- 'use strict';
26
-
27
- const path = require('node:path');
28
- const fs = require('node:fs');
29
- const { execFile } = require('node:child_process');
30
- const { pathToFileURL } = require('node:url');
31
-
32
- // Dev / CI / smoke-test layout: desktop/ lives inside the repo, so the shared
33
- // client resolves via ../client/aegis.js. In the packaged app the client is
34
- // copied to desktop/vendor/aegis.js by the predist step (electron-builder
35
- // cannot reach outside the app dir), so fall back to that copy. Both files
36
- // are the same thin transport — never brain logic.
37
- let sharedClient;
38
- try {
39
- sharedClient = require('../client/aegis.js');
40
- } catch {
41
- sharedClient = require('./vendor/aegis.js');
42
- }
43
- const { createClient } = sharedClient;
44
-
45
- // The shared credential store (`client/credentials.js`) — the same 0600 file
46
- // the terminal host writes with `aegiscode login` and the MCP plugin reads. The
47
- // app resolves its own account key through it, so signing in once serves all
48
- // four hosts; see resolveStartupKey() and persistApiKey() below.
49
- let credentials;
50
- try {
51
- credentials = require('../client/credentials.js');
52
- } catch {
53
- credentials = require('./vendor/credentials.js');
54
- }
55
-
56
- // Same auto-provision helper the MCP plugin and the CLI use (see
57
- // client/auto-provision.js) — one implementation so all three hosts ask
58
- // aegis1 for the same account on the same machine, with the same timeout.
59
- let autoProvision;
60
- try {
61
- autoProvision = require('../client/auto-provision.js');
62
- } catch {
63
- autoProvision = require('./vendor/auto-provision.js');
64
- }
65
-
66
- // The shared presence beacon (client/presence.js) — the client half of
67
- // aegis1's `/api/v1/heartbeat` (app.py `heartbeat_v1`), which writes the
68
- // `client_presence` row the admin panel's presence card reads. Started once the
69
- // account key is known (see the beacon block in `app.whenReady`), stopped in
70
- // `will-quit`. Same file the CLI beacons through, so the panel sees one
71
- // vocabulary (`app: 'desktop'` / `'aegiscode'`) and not two.
72
- let sharedPresence;
73
- try {
74
- sharedPresence = require('../client/presence.js');
75
- } catch {
76
- sharedPresence = require('./vendor/presence.js');
77
- }
78
-
79
- // The shared top-up core (client/topup.js) — the same module the terminal host
80
- // drives from `aegiscode topup`. It owns the amount bounds, the payload reading
81
- // and the credit-watch state machine; this host owns only the TIMER (below) and
82
- // the notification, because the window stays open for a payment that may never
83
- // complete. Resolved through the same two-layout require as every other shared
84
- // module: the repo checkout has ../client/, the packaged app has ./vendor/.
85
- let sharedTopup;
86
- try {
87
- sharedTopup = require('../client/topup.js');
88
- } catch {
89
- sharedTopup = require('./vendor/topup.js');
90
- }
91
-
92
- // Google identity + Drive (client/google-signin.js, client/drive-connect.js,
93
- // client/drive-upload.js). google-signin.js already existed for the CLI's
94
- // `connect --google`; this is the first time desktop wires any of it. See
95
- // lib/google-connect.js for how the three are sequenced.
96
- let googleSignIn;
97
- try {
98
- googleSignIn = require('../client/google-signin.js');
99
- } catch {
100
- googleSignIn = require('./vendor/google-signin.js');
101
- }
102
- let driveConnectLib;
103
- try {
104
- driveConnectLib = require('../client/drive-connect.js');
105
- } catch {
106
- driveConnectLib = require('./vendor/drive-connect.js');
107
- }
108
- let driveUploadLib;
109
- try {
110
- driveUploadLib = require('../client/drive-upload.js');
111
- } catch {
112
- driveUploadLib = require('./vendor/drive-upload.js');
113
- }
114
- const { createGoogleConnect } = require('./lib/google-connect.js');
115
-
116
- // The device fingerprint (client/device-fingerprint.js) — the SAME identity the
117
- // auto-provision path registers a device-key against, now also carried in every
118
- // presence beat so one laptop is one machine in the admin panel rather than one
119
- // row per launch. Resolved here rather than left to presence.js's own fallback
120
- // for the same reason the CLI resolves it at its call site: a host that cannot
121
- // state its identity should fail visibly, not substitute a different one.
122
- let sharedFingerprint;
123
- try {
124
- sharedFingerprint = require('../client/device-fingerprint.js');
125
- } catch {
126
- sharedFingerprint = require('./vendor/device-fingerprint.js');
127
- }
128
-
129
- // φ(α): the authorship key and sealed session records (lib/fingerprint.js).
130
- // The service is built with the app's own data dir, so the key it mints is the
131
- // key `aegiscode fingerprint` resolves in the terminal — one identity, two
132
- // hosts, no second store. Everything cryptographic lives in the shared client
133
- // modules it loads; this require only wires the IPC surface in registerIpc()
134
- // below (`fingerprintStatus`/`keygen`/`mint`/`verify`/`backup`).
135
- const { createFingerprintService } = require('./lib/fingerprint.js');
136
-
137
- // The two background automations (lib/auto.js): the auto-drain timer and the
138
- // seal-on-quiet auto-mint. Both are DEFAULT ON — the queue and the seal run
139
- // with no click and no card on the desktop — and both read their switch from
140
- // the settings store's reserved `__autoDrain`/`__autoMint` namespaces on every
141
- // tick, so this require wires the TIMING only: the queue worker still owns the
142
- // lock and lib/fingerprint.js still owns the seal. An explicit `enabled: false`
143
- // in the settings store is what stops either one, and stopping it takes effect
144
- // on the very next tick.
145
- const { createAutoDrain, createAutoMint } = require('./lib/auto.js');
146
-
147
- // The shared `~/.aegiscode/.env` loader (client/env-file.js, staged to
148
- // ./vendor/env-file.js when packaged). It is what makes the key instruction the
149
- // same in every host — "put it in ~/.aegiscode/.env" — because AEGIS_API_KEY is
150
- // already the first entry in the credential resolution order above and a BYOK
151
- // provider's key is read from the matching <PROVIDER>_API_KEY (see
152
- // lib/local/engine.js). Deliberately NOT called at module load: seven test
153
- // files require this file, and a top-level call would let the developer's own
154
- // env file reach a test that asserts no key is configured. bootstrap() below —
155
- // the real app entry — calls it instead.
156
- let envFile = null;
157
- try {
158
- envFile = require('../client/env-file.js');
159
- } catch {
160
- try {
161
- envFile = require('./vendor/env-file.js');
162
- } catch {
163
- /* neither present: the environment is the only source, as before */
164
- }
165
- }
166
-
167
- let electron = null;
168
- try {
169
- // In plain Node (CI smoke test, `node --check`) this either throws
170
- // (module absent) or resolves to the electron binary path string with no
171
- // `.app` — either way bootstrap() is skipped and the pure exports survive.
172
- electron = require('electron');
173
- } catch {
174
- /* not running under Electron */
175
- }
176
-
177
- // LocalEngine wiring (plan P1 §5.2): the model:/sync: surfaces are additive,
178
- // backed by transport-only modules in desktop/lib/. No brain logic enters here.
179
- const os = require('node:os');
180
- const { createLocalEngine } = require('./lib/local/engine.js');
181
- const { createAccountKey, startupKeyOverride } = require('./lib/account-key.js');
182
- const { createSettingsStore, isReservedNamespace } = require('./lib/settings.js');
183
- const sessionStore = require('./lib/sync/sessions.js');
184
- const memoryQueue = require('./lib/sync/memory-queue.js');
185
- const persistGate = require('./lib/sync/persist-gate.js');
186
- // Avatar level authority. The ledger, the replay and the award
187
- // hooks live here in main — a renderer-held counter is a suggestion, not a
188
- // fact, the same reasoning persist-gate.js follows. The pure halves
189
- // (xp.js/level.js/events.js/persona.js) are unchanged and stay IO-free; store.js
190
- // is the only file in lib/avatar/ that touches a disk.
191
- const avatarStore = require('./lib/avatar/store.js');
192
- const windowState = require('./lib/window-state.js');
193
- const deepLink = require('./lib/deep-link.js');
194
- const quickLauncherLib = require('./lib/quick-launcher.js');
195
- // Foreign-memory scanner (shared with client/foreign-memory.js). Same
196
- // resolution rule as the transport above: the canonical file inside the repo,
197
- // the predist-staged copy in a packaged app. Never forked logic — so it needs
198
- // no wrapper module of its own.
199
- let foreignMemory;
200
- try {
201
- foreignMemory = require('../client/foreign-memory.js');
202
- } catch {
203
- foreignMemory = require('./vendor/foreign-memory.js');
204
- }
205
- // Builtin tool executor for the agent loop (client half of the upstream engine's
206
- // tool calling). MAIN-process only: it is reachable from the renderer solely
207
- // through the whitelisted `tools:` IPC surface registered below — see the
208
- // sandbox note on registerToolsIpc().
209
- const localTools = require('./lib/local/tools.js');
210
-
211
- // The local autonomous work queue (commit 96fb64f): a durable task list plus
212
- // the unattended worker that drives the local engine's tool loop. MAIN-process
213
- // only, like the tool executor above — the renderer reaches it solely through
214
- // the `queue:` IPC surface registered by registerQueueIpc() below.
215
- const queueModule = require('./lib/local/queue.js');
216
- const autonomousModule = require('./lib/local/autonomous.js');
217
- // Auto mode's round-horizon half: the same arithmetic the upstream engine
218
- // (aegiscodex-dev src/autonomous.js) and the CLI's presenter use, read here by
219
- // the one host that owns this face's policy. See createModelDispatch.
220
- const {
221
- autoContinuePlan,
222
- autoContinueLimit,
223
- autoRoundsDefault,
224
- } = require('./lib/local/autonomous.js');
225
-
226
- // The Pro boundary for the autonomous queue (Phase 4 of
227
- // docs/upgrade-conversion-plan.md). MAIN-process only, no Electron inside, so
228
- // the gate — including the bypass path — is unit-testable in plain Node:
229
- // desktop/test/queue-entitlement.test.mjs.
230
- const entitlementModule = require('./lib/local/entitlement.js');
231
-
232
- // Funnel instrumentation (Phase 2 of docs/upgrade-conversion-plan.md): the
233
- // allowlist, the opt-in gate and the local-first sink for the two events the
234
- // renderer emits. MAIN-process only — the renderer reaches it solely through
235
- // `aegis:telemetryEvent` (see createTelemetryDispatch below), and the module
236
- // imports no Electron so it is unit-testable in plain Node.
237
- const telemetryModule = require('./lib/local/telemetry.js');
238
- const covenantModule = require('./lib/local/covenant.js');
239
- const { createTelemetry, telemetryEnabled } = telemetryModule;
240
-
241
- const IPC_PREFIX = 'aegis:';
242
- const MODEL_PREFIX = 'model:';
243
- const SYNC_PREFIX = 'sync:';
244
- const TOOLS_PREFIX = 'tools:';
245
- const QUICK_PREFIX = 'quick:';
246
- const QUEUE_PREFIX = 'queue:';
247
- const AUTO_PREFIX = 'auto:';
248
-
249
- /**
250
- * The model classes THIS face offers in its pickers, in registry order.
251
- *
252
- * Narrower than the engine's `CLASSES` registry on purpose. The engine is
253
- * shared with the CLI (`cli/src/deps.js` requires this same file), so a class
254
- * is added or removed once, in `engine.js`, for both hosts — and the CLI does
255
- * ship `pro` (the user's own Claude Code subscription, spawned through the
256
- * `claude` binary). This face does not: the Pro transport hands the turn to a
257
- * headless child that runs its own agent loop and its own permission prompts,
258
- * and this window has no channel to answer those prompts, so selecting it here
259
- * is a class a user can pick and then watch do nothing. Omitted rather than
260
- * listed-and-broken.
261
- *
262
- * Filtering HERE rather than in the registry is the same move the CLI makes in
263
- * `cli/src/engine.js` (`listClasses().filter((c) => HOST_CLASSES.includes(…))`):
264
- * the registry stays the single source of truth for every class that EXISTS,
265
- * and each host names the subset it offers. Removing this filter, or adding a
266
- * class here that `engine.listClasses()` does not return, is a one-line change
267
- * on this line — and the picker is built from this function's output, so a class
268
- * absent here is one no user can select, which is the whole point.
269
- */
270
- const DESKTOP_CLASSES = Object.freeze(['aegis', 'byok', 'local']);
271
-
272
- /**
273
- * Main -> renderer push channel for chat SSE deltas (D2.1 streaming render).
274
- * The invoke of aegis:chatCompletion still resolves once with the normalised
275
- * final result; live chunks travel on this single dedicated channel.
276
- */
277
- const CHAT_DELTA_CHANNEL = `${IPC_PREFIX}chatDelta`;
278
-
279
- /** Main -> renderer pushes for native menu accelerators (Cmd/Ctrl+N,
280
- * Cmd/Ctrl+K) that have no Electron role to bind to — the renderer owns
281
- * "new chat" and "open search" behaviour, so the menu just asks for it. */
282
- const MENU_NEW_CHAT_CHANNEL = `${IPC_PREFIX}menuNewChat`;
283
- const MENU_SEARCH_CHANNEL = `${IPC_PREFIX}menuSearch`;
284
- /** File > Save as… / Export session — same "menu has no state of its own,
285
- * renderer does the work" pattern as the two channels above: the renderer
286
- * knows the open session id, main.js just pings it. */
287
- const MENU_EXPORT_MARKDOWN_CHANNEL = `${IPC_PREFIX}menuExportMarkdown`;
288
- const MENU_EXPORT_JSON_CHANNEL = `${IPC_PREFIX}menuExportJson`;
289
- /** View > Authorship panel. The φ(α) card that used to open this sheet was
290
- * removed from the sidebar (sealing runs in the background now), so the menu
291
- * is what keeps the panel — key, records, vault — reachable at all. Same split
292
- * as the channels above: main has no panel state, it just asks the renderer to
293
- * open the sheet it already owns. */
294
- const MENU_FINGERPRINT_CHANNEL = `${IPC_PREFIX}menuFingerprint`;
295
-
296
- /**
297
- * Main -> renderer push for a resolved aegis:// deep link (D? deep linking).
298
- * Delivered once per link, after the target window has finished loading
299
- * (see sendDeepLinkToWindow) so the renderer's listener is always attached
300
- * before it arrives.
301
- */
302
- const DEEP_LINK_CHANNEL = `${IPC_PREFIX}deepLink`;
303
-
304
- /**
305
- * Main -> renderer push for a quick-launcher answer the user chose to keep
306
- * (D? quick launcher §4). Carries `{ prompt, response, model }`; the main
307
- * window's renderer already owns "add a turn to the open thread" (send()'s
308
- * addMessage + sync.append), so — same "menu pings, renderer acts" pattern as
309
- * MENU_NEW_CHAT_CHANNEL etc. — this just delivers the payload.
310
- */
311
- const QUICK_LAUNCHER_PUSH_CHANNEL = `${IPC_PREFIX}quickLauncherPush`;
312
-
313
- /**
314
- * Main -> renderer push for a queue drain's progress (one event per turn frame:
315
- * start/tool/delta/finish, plus recovered/queued/locked). Deliberately its OWN
316
- * channel rather than CHAT_DELTA_CHANNEL.
317
- *
318
- * A drain runs an unattended turn in a repository, and every one of its frames
319
- * is about that task — its tool calls, its output, its cost. Pushed onto the
320
- * chat delta channel they would be typed into whatever conversation the user
321
- * happens to have open (the preload's sessionId filter would have to be widened
322
- * to let them through at all), so the transcript would grow a second, unattached
323
- * answer nobody asked for. The queue is not a chat surface: the renderer shows
324
- * state and outcomes from this event, and the transcript stays clean.
325
- */
326
- const QUEUE_PROGRESS_CHANNEL = `${QUEUE_PREFIX}progress`;
327
-
328
- /**
329
- * How much task text the queue surface accepts, and how many rounds a queued
330
- * task may take. Both are ceilings on what ONE renderer call can ask the app to
331
- * spend: without them a pasted file would be queued as a task (every round
332
- * re-sends the prompt) and a task could pin the loop open indefinitely.
333
- * `workers` is capped at 8 to match the Model card's own input.
334
- */
335
- const QUEUE_MAX_TASK_CHARS = 8000;
336
- const QUEUE_MAX_ROUNDS = 200;
337
- const QUEUE_MAX_WORKERS = 8;
338
-
339
- /** The efforts the queue accepts; anything else means "let the worker decide"
340
- * (autonomous.js resolveEffort, whose default is high for unattended work). */
341
- const QUEUE_EFFORTS = new Set(['low', 'medium', 'high']);
342
-
343
- /**
344
- * Address one SSE delta to the stream that produced it (D2.2 multi-stream).
345
- *
346
- * The renderer's chat flow runs a *set* of concurrent streams — the primary
347
- * answer plus the horizontal discovery lane — so every chunk must say which
348
- * sessionId it belongs to. `id` is added last so the routing tag stays
349
- * authoritative, while `delta` (and any future field the client sends) is
350
- * copied through untouched: existing single-stream callers that never set a
351
- * sessionId simply receive `id: undefined`, exactly the shape they see today.
352
- */
353
- function taggedChunk(chunk, sessionId) {
354
- const base = chunk && typeof chunk === 'object' ? chunk : { delta: chunk };
355
- return { ...base, id: sessionId };
356
- }
357
-
358
- const APP_VERSION = require('./package.json').version;
359
-
360
- /** Never ship a full key to the renderer — only a masked preview. */
361
- function maskKey(key) {
362
- if (!key) return null;
363
- if (key.length <= 10) return 'configured';
364
- return `${key.slice(0, 9)}\u2026${key.slice(-4)}`;
365
- }
366
-
367
- /**
368
- * Classify a memory-endpoint failure: is this the plan's sync quota, or is it
369
- * just offline? aegis1 answers HTTP 402 `free_session_limit_reached` when a
370
- * WRITE would exceed the plan's synced-token ceiling (free = 1M, pro = 10M).
371
- * Since the quota became token-denominated, reads (search/pull) are always
372
- * served over quota, so only pushes can answer 402 — a pull that 402s is an
373
- * older server.
374
- *
375
- * The shared client attaches `err.status` / `err.data` (vendor/aegis.js
376
- * parseResponse) — but `ipcRenderer.invoke` only carries the *message string*
377
- * across the process boundary. A thrown cap therefore reaches the renderer as
378
- * the bare text "free_session_limit_reached" with `status`/`data` stripped,
379
- * which is why the upgrade UI in fetchMemory() never fired. So: detect it here,
380
- * in main, where the fields still exist, and hand the renderer a plain resolved
381
- * payload.
382
- *
383
- * Returns null for anything that is not a cap (offline, no key, 500, …).
384
- */
385
- function upgradeInfo(err) {
386
- const status = err && err.status;
387
- const data = (err && err.data) || {};
388
- const code = typeof data.error === 'string' ? data.error : '';
389
- if (status !== 402 && code !== 'free_session_limit_reached') return null;
390
- // Quota numbers are TOKENS now (`tokensUsed` / `tokenLimit`). The legacy
391
- // session-named keys are read only as a fallback so this build still shows a
392
- // number against a server that predates the rename.
393
- const used = data.tokensUsed != null
394
- ? data.tokensUsed
395
- : (data.sessionsUsed != null ? data.sessionsUsed : null);
396
- const limit = data.tokenLimit != null
397
- ? data.tokenLimit
398
- : (data.freeSessionLimit != null ? data.freeSessionLimit : null);
399
- return {
400
- // Fallback only — aegis1 sends `upgradeUrl` on every 402. When it does not,
401
- // point at the same `/go/upgrade` indirection the renderer uses (see
402
- // renderer/app.js subscribeUrl()) rather than the bare /subscribe path, so
403
- // the click is still attributed to the desktop channel + a campaign.
404
- url: data.upgradeUrl || 'https://aegiscloud.org/go/upgrade?s=desktop&c=status_card',
405
- used,
406
- limit,
407
- code: code || 'free_session_limit_reached',
408
- };
409
- }
410
-
411
- /** Message text for a failure, without assuming the Error shape. */
412
- function errorText(err) {
413
- return err && err.message ? err.message : String(err);
414
- }
415
-
416
- /**
417
- * Precise classification of a cloud-sync refusal — the desktop port of
418
- * `describeError()` in cli/src/cloudsync.js, so both hosts name a 402/401 the
419
- * same way instead of surfacing whatever sentence the server happened to use.
420
- *
421
- * `push()`/`pull()` used to hand the renderer `err.message` alone, so a
422
- * per-session memory-token 401 or a 402 reached the banner as
423
- * "sync failed: free_session_limit_reached" — a string that reads like a bug
424
- * and names no fix. The shared client attaches `err.status` / `err.data`
425
- * (vendor/aegis.js parseResponse) while the error is still in main, which is
426
- * why the classification happens here.
427
- *
428
- * `kind` is `quota` | `auth` | `error`; `hint` is the actionable half of the
429
- * sentence (what to do about it), null when there is nothing to say.
430
- */
431
- function describeSyncError(err) {
432
- const status =
433
- (err && (err.status || (err.response && err.response.status))) || 0;
434
- const message = errorText(err);
435
- // upgradeInfo() already knows the cap: either a 402 or the server's
436
- // `free_session_limit_reached` code (an older body without a status).
437
- if (upgradeInfo(err)) {
438
- return {
439
- kind: 'quota',
440
- status: status || 402,
441
- message,
442
- hint:
443
- "the plan's synced-token ceiling is reached — free space in the account dashboard or upgrade to keep syncing",
444
- };
445
- }
446
- if (status === 401) {
447
- return {
448
- kind: 'auth',
449
- status,
450
- message,
451
- hint: 'the memory token was refused — re-enter your AEGIS API key to exchange a new one',
452
- };
453
- }
454
- return { kind: 'error', status: status || null, message, hint: null };
455
- }
456
-
457
- /** The banner line for a classified failure: the server's words + the fix. */
458
- function syncErrorReason(info) {
459
- if (!info) return 'sync failed';
460
- return info.hint ? `${info.message} — ${info.hint}` : info.message;
461
- }
462
-
463
- /**
464
- * Result shape for the two billing actions (`aegis:billingCheckout` and
465
- * `aegis:tokenBankTopup`). Both end in a Stripe-hosted checkout URL created
466
- * server-side, and both have failure modes the user has to be able to tell
467
- * apart: the free-plan cap (402), no/invalid API key (401), an amount the
468
- * server rejects (400), Stripe not configured on the server (503
469
- * `setup_required`), and a plain transport/500 error.
470
- *
471
- * The classification happens HERE for the same reason the memory paths
472
- * normalise: `ipcRenderer.invoke` carries only the message STRING across the
473
- * boundary, so `err.status` / `err.data` are gone by the time the renderer
474
- * sees a rejection. The renderer gets a resolved payload instead — never a
475
- * throw it would have to string-match.
476
- */
477
- async function billingResult(run) {
478
- try {
479
- const data = await run();
480
- return {
481
- ok: true,
482
- url: (data && data.url) || null,
483
- status: 0,
484
- reason: null,
485
- setupRequired: false,
486
- upgrade: null,
487
- };
488
- } catch (err) {
489
- const data = (err && err.data) || {};
490
- return {
491
- ok: false,
492
- url: null,
493
- status: (err && err.status) || 0,
494
- reason: errorText(err),
495
- setupRequired: Boolean(data.setup_required),
496
- upgrade: upgradeInfo(err),
497
- };
498
- }
499
- }
500
-
501
- // The shared top-up core (client/topup.js) — the amount bounds, the balance
502
- // reading across aegis1's payload shapes, the unknown-not-zero rule and the
503
- // rising-read state machine. The TERMINAL drives the same file (`aegiscode
504
- // topup`), which is the reason it is shared rather than duplicated here: two
505
- // hosts reading a credit differently is how one surface shows money that the
506
- // other says never arrived.
507
- const {
508
- // `bankAmount` is re-exported below for desktop/test/topup-credit-watch.test.mjs,
509
- // which asserts the payload shapes through this host's surface.
510
- bankAmount,
511
- parseTopupAmount,
512
- startTopup,
513
- createCreditWatch,
514
- TOPUP_POLL_MS,
515
- TOPUP_POLL_TRIES,
516
- } = sharedTopup;
517
-
518
- let topupWatch = null;
519
-
520
- /**
521
- * Watch the account's token bank after a top-up checkout opens, and announce
522
- * the credit in-app when it lands.
523
- *
524
- * WHY THIS EXISTS. The top-up completes server-side — Stripe's webhook credits
525
- * the bank (services/billing_service.py) and the browser landing page is pure
526
- * UI on top of that — but that page (`/topup`) is `@login_required` against a
527
- * browser SESSION, and `POST /api/v1/auth/register-device` mints an account
528
- * whose password is never returned and whose email is `@device.aegiscloud`.
529
- * So the very account auto-provision creates cannot sign in to the page we
530
- * just sent it to: following that link lands on the login wall, and the user
531
- * is left believing the payment failed when the money and the credit are both
532
- * real.
533
- *
534
- * The key that STARTED the checkout is enough to READ the result: the same
535
- * `X-API-Key`/Bearer credential that creates the session also authenticates
536
- * `GET /api/token-bank/balance`. So the desktop confirms the credit itself.
537
- *
538
- * The STATE MACHINE is `createCreditWatch` in client/topup.js, driven by the
539
- * terminal host too — the same baseline, the same unknown-not-zero rule, the
540
- * same rising-read rule, one implementation. What this function adds is the
541
- * SCHEDULER and the notification, and both are host-specific: the timer lives
542
- * HERE and not in the renderer for the reason test/auto.test.mjs asserts
543
- * (timing belongs in the main process), and it means the notice still arrives
544
- * when the window is hidden — the normal case, since payment happens in the
545
- * user's browser.
546
- *
547
- * @param {object} aegis the client whose key created the checkout
548
- * @param {object} [opts]
549
- * @param {number} [opts.intervalMs] poll period (default TOPUP_POLL_MS)
550
- * @param {number} [opts.maxTries] attempts before giving up (default TOPUP_POLL_TRIES)
551
- * @param {function(number):void} [opts.notify] called once with the credited
552
- * amount; defaults to an OS notification. Injectable so a test can observe
553
- * the credit without a desktop runtime.
554
- * @param {function(string):void} [opts.log] optional warning sink
555
- */
556
- function watchTopupCredit(aegis, opts) {
557
- const options = opts || {};
558
- const intervalMs = options.intervalMs || TOPUP_POLL_MS;
559
- const log = options.log;
560
- if (topupWatch) clearInterval(topupWatch);
561
- const watch = createCreditWatch(aegis, options.maxTries ? { maxTries: options.maxTries } : undefined);
562
- const stop = () => {
563
- if (topupWatch) clearInterval(topupWatch);
564
- topupWatch = null;
565
- watch.stop();
566
- };
567
- const announce = (amount) => {
568
- if (typeof options.notify === 'function') {
569
- options.notify(amount);
570
- return;
571
- }
572
- try {
573
- const Ctor = electron && electron.Notification;
574
- if (Ctor && Ctor.isSupported()) {
575
- new Ctor({
576
- title: 'AEGIS Desktop',
577
- body: `Bank credited — €${amount.toFixed(2)} available`,
578
- }).show();
579
- }
580
- } catch {
581
- /* a platform without notifications: the status card still shows it */
582
- }
583
- };
584
- const tick = async () => {
585
- const res = await watch.tick();
586
- if (res.state === 'credited') {
587
- stop();
588
- announce(res.amount);
589
- } else if (res.state === 'expired') {
590
- stop();
591
- if (log) log('top-up: no credit observed within the watch window; stopping');
592
- }
593
- };
594
- topupWatch = setInterval(tick, intervalMs);
595
- // Do not hold the event loop open for a payment that may never complete.
596
- if (topupWatch && typeof topupWatch.unref === 'function') topupWatch.unref();
597
- return { stop, tick, get baseline() { return watch.baseline; } };
598
- }
599
-
600
- /**
601
- * `aegis:memorySave` with the offline-first fallback (plan P3 §7): try the
602
- * cloud save first; if it fails (no key, offline, transient error) queue the
603
- * entry in <dir>/memory-queue.json instead of throwing, so the renderer's
604
- * "remember" affordance never surfaces an error for the no-key case. `dir` is
605
- * optional — callers that omit it (e.g. the desktop-shell smoke test) simply
606
- * get the un-queued rejection back, unchanged from before this existed.
607
- *
608
- * The cap is the one failure that is deliberately NOT queued: see upgradeInfo.
609
- * A 402 resolves `{ ok:false, upgrade }` — never thrown, because the fields
610
- * would not survive the IPC trip — and the entry is left un-stored so the
611
- * renderer can keep it in the box and point at the subscribe page.
612
- *
613
- * Level authority. This is the memory path's front door, so it is
614
- * the first of the award hooks. Two things happen before the write leaves:
615
- *
616
- * 1. The entry is *normalised* (store.js `normalizeMemoryEntry`). aegis1
617
- * upserts on `(user_id, id)` and skips any entry without an `id`/`content`,
618
- * so the renderer's bare `{ text, source }` was silently discarded
619
- * server-side — paying XP for a save that never happened would be exactly
620
- * the fake ledger this phase must not ship. A content-addressed id makes
621
- * the save real and idempotent in one move.
622
- * 2. Only a save that LANDED pays: `memory.saved` (or `memory.reinforced` /
623
- * `memory.corrected`, classified against the refs this process has seen —
624
- * see store.js `observe`) after the cloud accepted it, and `memory.queued`
625
- * for the offline path. A 402 pays nothing and queues nothing.
626
- *
627
- * `avatar` is optional: without it the function behaves exactly as before.
628
- */
629
- async function saveMemoryWithQueue(aegis, dir, entry, avatar) {
630
- const normalized = avatarStore.normalizeMemoryEntry(entry);
631
- try {
632
- const result = await aegis.memorySave(normalized);
633
- if (avatar && normalized && typeof normalized === 'object') {
634
- avatar.awardSave(normalized, { session: normalized.session });
635
- }
636
- return result;
637
- } catch (err) {
638
- const upgrade = upgradeInfo(err);
639
- if (upgrade) {
640
- // Nothing was stored, so nothing is earned — and nothing is queued.
641
- return { ok: false, queued: 0, saved: 0, upgrade, reason: errorText(err) };
642
- }
643
- if (!dir || !entry) throw err;
644
- memoryQueue.enqueue(dir, normalized);
645
- if (avatar && normalized && typeof normalized === 'object') {
646
- avatar.awardQueued(normalized, { session: normalized.session });
647
- }
648
- return { ok: true, queued: true, reason: errorText(err) };
649
- }
650
- }
651
-
652
- /**
653
- * Read side of the same normalisation. Since the sync quota became
654
- * token-denominated, aegis1 no longer refuses reads over quota — it serves
655
- * `memorySearch` / `memoryList` and reports `tokensUsed` / `tokenLimit` in the
656
- * payload. So an exhausted account arrives as a *success*, and the notice has
657
- * to be derived from the fields rather than from a thrown 402. The 402 branch
658
- * stays for an older server that still caps reads.
659
- *
660
- * Both paths resolve the same `upgrade` shape so the renderer keeps one branch.
661
- */
662
- async function normalizeMemoryRead(promise) {
663
- try {
664
- const data = await promise;
665
- const quota = quotaFromPayload(data);
666
- // Entries are deliberately preserved: an over-quota account still owns its
667
- // memory and must see it. Only the notice is added.
668
- return quota ? Object.assign({}, data, { upgrade: quota }) : data;
669
- } catch (err) {
670
- const upgrade = upgradeInfo(err);
671
- if (!upgrade) throw err;
672
- return { entries: [], upgrade };
673
- }
674
- }
675
-
676
- /** Over-quota notice derived from a successful read payload, or null.
677
- * `tokensUsed`/`tokenLimit` come from aegis1 `_token_quota_fields`. */
678
- function quotaFromPayload(data) {
679
- if (!data || typeof data !== 'object') return null;
680
- const used = data.tokensUsed != null ? data.tokensUsed : null;
681
- const limit = data.tokenLimit != null ? data.tokenLimit : null;
682
- if (used == null || limit == null || limit <= 0) return null;
683
- if (used < limit) return null;
684
- return {
685
- // Same indirection as renderer/app.js subscribeUrl('memory_quota'): this is
686
- // the sync-quota surface, and a bare /subscribe here would be an
687
- // unattributable click.
688
- url: 'https://aegiscloud.org/go/upgrade?s=desktop&c=memory_quota',
689
- used,
690
- limit,
691
- code: 'sync_quota_reached',
692
- };
693
- }
694
-
695
- /**
696
- * `aegis:memoryImport` — scan this machine for other AI tools' memory and,
697
- * when confirmed, push it into AEGIS cloud memory.
698
- *
699
- * Read-only against the foreign stores (client/foreign-memory.js never writes
700
- * to them). Two-phase by design: a dry run reports counts so the user can see
701
- * what would land before anything leaves the machine. When a confirmed save
702
- * can't reach the cloud (no key / offline), entries fall back to the same
703
- * local memory-queue the single-entry save path uses, so the import is not
704
- * lost — it flushes on the next "Sync now".
705
- *
706
- * The renderer gets counts and a summary string, never the entry bodies:
707
- * 1000 entries x 2 kB over IPC is pure waste, and the bodies are already
708
- * either in the cloud or in the queue by the time this resolves.
709
- *
710
- * Each batch that the cloud accepted pays `memory.imported` per
711
- * entry, keyed by the import's own source (foreign-memory.js collapses one
712
- * source's entries into ONE `import:<source>` session — aegis1 meters distinct
713
- * session values — so that value is the right per-source key). The replay caps
714
- * a source at 200 paying entries, and store.js caps the lines it writes to the
715
- * same number, so a ten-tool import cannot jump ten levels. Entries that fell
716
- * back to the local queue are queued events, not imports.
717
- */
718
- async function importForeignMemory(aegis, dir, payload, avatar) {
719
- const opts = payload || {};
720
- const report = foreignMemory.scan({
721
- sources: opts.sources,
722
- limit: opts.limit || 1000,
723
- });
724
- const summary = foreignMemory.describe(report);
725
- const sources = report.sources.map((s) => ({
726
- id: s.id,
727
- label: s.label,
728
- verified: s.verified,
729
- present: s.present,
730
- count: s.count,
731
- skipped: s.skipped,
732
- files: s.files,
733
- }));
734
-
735
- const base = { summary, totals: report.totals, sources };
736
-
737
- if (!opts.confirm || !report.entries.length) {
738
- return { ...base, ok: true, dryRun: !opts.confirm, saved: 0, queued: 0 };
739
- }
740
-
741
- let saved = 0;
742
- let queued = 0;
743
- const errors = [];
744
- let upgrade = null;
745
- for (const batch of foreignMemory.chunk(report.entries, 200)) {
746
- try {
747
- const data = await aegis.memorySaveBatch(batch);
748
- saved += (data && data.saved) || batch.length;
749
- if (avatar && batch.length) {
750
- const first = batch[0] || {};
751
- avatar.awardImports(batch, {
752
- source: first.session || first.source || 'foreign-import',
753
- });
754
- }
755
- } catch (err) {
756
- // The cap is not an offline failure: don't queue the batch. The entries
757
- // are still in the foreign stores, so a later scan re-finds them — but a
758
- // queued copy would flush-fail forever and, worse, made a mid-import 402
759
- // report as a silent `queued` count. Report the upgrade instead.
760
- upgrade = upgradeInfo(err);
761
- if (!upgrade && dir) {
762
- // Offline-first, exactly like saveMemoryWithQueue(): keep the entries
763
- // rather than dropping them. Anything already saved stays saved.
764
- for (const entry of batch) {
765
- memoryQueue.enqueue(dir, entry);
766
- queued += 1;
767
- if (avatar) avatar.awardQueued(entry, { session: entry && entry.session });
768
- }
769
- }
770
- errors.push(errorText(err));
771
- break;
772
- }
773
- }
774
-
775
- return {
776
- ...base,
777
- ok: errors.length === 0,
778
- dryRun: false,
779
- saved,
780
- queued,
781
- upgrade,
782
- reason: errors[0] || null,
783
- };
784
- }
785
-
786
- /** Where the packaged/dev build keeps its icon files. Packaged: shipped via
787
- * electron-builder.yml's `extraResources` into resourcesPath/icons/ (icons
788
- * live outside asar, since app.icns/.ico aren't otherwise reachable at
789
- * runtime). Dev: read straight from build/, the same source those resources
790
- * are copied from. Same split as ae-guix's lib/windows.js. */
791
- function resolveIconsDir(isPackaged, resourcesPath, dirname) {
792
- return isPackaged ? path.join(resourcesPath, 'icons') : path.join(dirname, 'build');
793
- }
794
-
795
- /** icon.icns on mac, icon.ico on Windows, icon.png everywhere else (Linux). */
796
- function iconForPlatform(iconsDir, platform) {
797
- if (platform === 'darwin') return path.join(iconsDir, 'icon.icns');
798
- if (platform === 'win32') return path.join(iconsDir, 'icon.ico');
799
- return path.join(iconsDir, 'icon.png');
800
- }
801
-
802
- /** Only http/https may be opened externally — file://, javascript:, etc.
803
- * would hand the OS shell an arbitrary URI straight from model output. */
804
- function isSafeExternalUrl(url) {
805
- if (typeof url !== 'string' || !url) return false;
806
- try {
807
- const parsed = new URL(url);
808
- return parsed.protocol === 'http:' || parsed.protocol === 'https:';
809
- } catch {
810
- return false;
811
- }
812
- }
813
-
814
- /**
815
- * Whether this machine holds a provider route of the user's OWN — the "bring
816
- * your own key" half of the welcome block's two pitches.
817
- *
818
- * The AEGIS Cloud key is `aegis.apiKey` and is reported separately as
819
- * `keyConfigured`; a BYOK provider row never appears there (it is written by
820
- * the Provider settings card through settings.set(provider, {baseURL, key})),
821
- * so it needs its own reader. A row counts once it carries either a key (the
822
- * pasted-provider case) or a baseURL (the point-it-at-a-local-model case) —
823
- * both are the user having answered the question the block was asking.
824
- *
825
- * `settings.list()` goes through `get()`, which is the same reader the IPC
826
- * bridge uses to answer the renderer — it never puts the plaintext key on a
827
- * row (that would put it one `list()` call away from crossing IPC), only the
828
- * `configured` boolean it already derived from the key. Reading `row.key`
829
- * here was always reading a field `get()` doesn't emit, so a pasted-key-only
830
- * provider row (no baseURL — the common case: OpenAI, Anthropic, DeepSeek)
831
- * never counted and the block kept nagging a user who had already connected
832
- * one. Verified live: `store.set('openai', { key })` then `list()` — the row
833
- * carries `configured: true` and no `key` property at all.
834
- *
835
- * Reserved namespaces are excluded for the same reason createModelDispatch
836
- * filters them: they are the store's own bookkeeping (the AEGIS key, the
837
- * memory-persist preference), not a provider the user configured.
838
- *
839
- * Failure reads as "no", which keeps the block visible — the same
840
- * fail-toward-showing-it bias the CLI's connectNeeded() documents. Showing a
841
- * block the user has outgrown is a nag; hiding one a new install needed is a
842
- * dead end, and only the latter loses the user.
843
- */
844
- function providerRouteConfigured(engine) {
845
- try {
846
- const list = engine && engine.settings && engine.settings.list;
847
- if (typeof list !== 'function') return false;
848
- return (list.call(engine.settings) || []).some((row) => {
849
- if (!row || !row.provider || isReservedNamespace(row.provider)) return false;
850
- const baseURL = typeof row.baseURL === 'string' ? row.baseURL.trim() : '';
851
- return Boolean(row.configured || baseURL);
852
- });
853
- } catch {
854
- return false;
855
- }
856
- }
857
-
858
- /**
859
- * Pure mapping: IPC payload -> shared-client call. No Electron types here, so
860
- * tests can drive it with a stub client and a fake ipcMain. `openExternal` is
861
- * the one exception — it is itself just a function (default a harmless
862
- * reject), injected by bootstrap() so this module still needs no `electron`
863
- * import to stay unit-testable.
864
- */
865
- function createIpcDispatch(aegis, dir, persistApiKey, openExternal, providerConfigured, avatar, getVaultBackend) {
866
- // One service for the window's lifetime, bound to the app's data dir — but
867
- // created LAZILY, which is what the comment here always claimed and what the
868
- // code did not do. `createFingerprintService({ dir })` throws on a missing
869
- // dir, and a test or a call site that has no userData yet (every
870
- // `createIpcDispatch(client)` in `test/desktop-shell.mjs`, and any bootstrap
871
- // where `resolveUserDataDir` came back empty) died at construction — taking
872
- // the whole dispatch surface with it, not just the fingerprint panel. The
873
- // proxy defers construction to the first fingerprint call, so a broken shared
874
- // module surfaces as an error in that panel instead of preventing the window
875
- // from opening at all, exactly as documented below.
876
- let fingerprintService = null;
877
- const fingerprint = new Proxy({}, {
878
- get(_target, prop) {
879
- if (!fingerprintService) fingerprintService = createFingerprintService({ dir, getVaultBackend });
880
- const value = fingerprintService[prop];
881
- return typeof value === 'function' ? value.bind(fingerprintService) : value;
882
- },
883
- has(_target, prop) {
884
- if (!fingerprintService) fingerprintService = createFingerprintService({ dir, getVaultBackend });
885
- return prop in fingerprintService;
886
- },
887
- });
888
- // `providerConfigured` is an injected thunk (bootstrap() passes one that reads
889
- // the settings store) so this module keeps its no-Electron, unit-testable
890
- // shape; absent in tests and older call sites, where it reads as "no".
891
- const hasOwnProviderKey = () => {
892
- try {
893
- return Boolean(typeof providerConfigured === 'function' && providerConfigured());
894
- } catch {
895
- return false;
896
- }
897
- };
898
- const openExternalFn = openExternal || (() => Promise.reject(new Error('no opener configured')));
899
- const dispatch = {
900
- status: () => ({
901
- appVersion: APP_VERSION,
902
- clientVersion: aegis.clientVersion,
903
- apiBase: aegis.apiBase,
904
- keyConfigured: Boolean(aegis.apiKey),
905
- keyMask: maskKey(aegis.apiKey),
906
- // The welcome block pitches TWO routes (bring your own key, or AEGIS
907
- // Cloud) and must retire once the user has taken either one. A BYOK
908
- // provider key never touches aegis.apiKey, so without this the block
909
- // kept re-pitching "bring your own key" at someone who just did — see
910
- // applyWelcomeConnect() in renderer/app.js.
911
- providerConfigured: hasOwnProviderKey(),
912
- }),
913
-
914
- // In-app API key entry (plan rebuild): replace the live client key and
915
- // persist it encrypted at rest via the settings store. The renderer only
916
- // ever sees the masked preview back — never the raw key.
917
- // Resolves `{ ok, ... }` / `{ ok: false, error }` rather than throwing —
918
- // same reasoning as the fingerprint handlers below: an IPC rejection
919
- // reaches the renderer as an opaque "Error invoking remote method" with
920
- // the real cause buried in main-process logs, and key entry is the one
921
- // flow every user hits on first run.
922
- setApiKey: (payload) => {
923
- try {
924
- const key = payload && payload.key;
925
- aegis.setApiKey(key || '');
926
- if (persistApiKey) persistApiKey(aegis.apiKey);
927
- return { ok: true, keyConfigured: Boolean(aegis.apiKey), keyMask: maskKey(aegis.apiKey) };
928
- } catch (e) {
929
- return { ok: false, error: (e && e.message) || String(e) };
930
- }
931
- },
932
-
933
- verifyApiKey: async () => {
934
- try {
935
- return { ok: true, ...(await aegis.verifyApiKey()) };
936
- } catch (e) {
937
- return { ok: false, error: (e && e.message) || String(e) };
938
- }
939
- },
940
- tokenBankBalance: () => aegis.tokenBankBalance(),
941
-
942
- // Billing (plan upgrade + token-bank top-up). aegis1 creates a Stripe
943
- // checkout session and answers `{ url }`; the renderer opens that URL
944
- // through the same aegis:openExternal path the upgrade notices use, so a
945
- // payment page is never rendered inside the app's own window. Both
946
- // resolve a shaped result instead of rejecting — see billingResult().
947
- billingCheckout: () => billingResult(() => aegis.billingCheckout()),
948
- // A successful top-up opens Stripe in the user's browser, so the credit
949
- // arrives long after this call returns. Start watching the bank now — the
950
- // account key that created the session is the one credential that can read
951
- // the result, and the auto-provisioned device key has no browser session to
952
- // check it with. See watchTopupCredit().
953
- //
954
- // The AMOUNT and the failure classification come from the shared core
955
- // (client/topup.js), the same one `aegiscode topup` drives: the renderer
956
- // still receives this host's `{ ok, url, status, reason, setupRequired,
957
- // upgrade }` shape (the renderer reads `reason`, not `message`), but the
958
- // bounds and the "which status means what" decision are no longer written
959
- // twice — a client that accepts 1 EUR is a round trip the user pays for
960
- // with the server's rejection.
961
- tokenBankTopup: async (payload) => {
962
- const parsed = parseTopupAmount(payload && payload.amountEur);
963
- if (!parsed.ok) {
964
- return { ok: false, url: null, status: 400, reason: parsed.message, setupRequired: false, upgrade: null };
965
- }
966
- const res = await startTopup(aegis, parsed.amount);
967
- if (res.ok === true && res.url) watchTopupCredit(aegis);
968
- return {
969
- ok: res.ok === true,
970
- url: res.url,
971
- status: res.status,
972
- reason: res.ok ? null : (res.hint ? `${res.message} — ${res.hint}` : res.message),
973
- setupRequired: res.setupRequired,
974
- upgrade: null,
975
- };
976
- },
977
-
978
- listModels: () => aegis.listModels(),
979
-
980
- chatCompletion: (payload) =>
981
- aegis.chatCompletion({
982
- prompt: payload && payload.prompt,
983
- system: payload && payload.system,
984
- model: payload && payload.model,
985
- mode: payload && payload.mode,
986
- maxTokens: payload && payload.maxTokens,
987
- // stream/onStream are forwarded when present; registerIpc() injects
988
- // the IPC chunk forwarder for the desktop host (see below).
989
- stream: payload && payload.stream,
990
- onStream: payload && payload.onStream,
991
- }),
992
-
993
- // φ(α) — the authorship key and the sealed session records, the same ones
994
- // `aegiscode fingerprint` mints in the terminal (lib/fingerprint.js).
995
- // Every handler resolves `{ ok, … }` instead of rejecting, so the renderer
996
- // can show the real message; an IPC rejection reaches it as an opaque
997
- // "Error invoking remote method" with the reason buried in main-process
998
- // logs, and the one error a user is likely to hit (a key file other users
999
- // can read) is exactly the one that must be readable.
1000
- fingerprintStatus: () => fingerprint.status(),
1001
- fingerprintKeygen: (payload) => fingerprint.keygen(payload || {}),
1002
- fingerprintMint: (payload) => fingerprint.mint(payload || {}),
1003
- fingerprintVerify: (payload) => fingerprint.verify(payload || {}),
1004
- fingerprintBackup: (payload) => fingerprint.backup(payload || {}),
1005
- // The vault (client/record-vault.js). `publish` sends the SEALED BYTES to
1006
- // gdrive:AEGIS-AGPL/records and writes the `.anchor.json` link beside the
1007
- // local record; `anchor` checks the copy still matches; `vaultAudit`
1008
- // inspects the whole folder for key material. The key is never publishable —
1009
- // record-vault.js refuses it by path, by shape and by byte comparison, in
1010
- // the shared module rather than in this dispatch, so the CLI inherits the
1011
- // same refusal.
1012
- fingerprintPublish: (payload) => fingerprint.publish(payload || {}),
1013
- fingerprintAnchor: (payload) => fingerprint.anchor(payload || {}),
1014
- fingerprintVault: () => fingerprint.vaultList(),
1015
- fingerprintVaultAudit: () => fingerprint.vaultAudit(),
1016
- byokStatus: () => aegis.byokStatus(),
1017
- byokSet: (payload) =>
1018
- aegis.byokSet(
1019
- payload && payload.provider,
1020
- payload && payload.apiKey
1021
- ),
1022
-
1023
- memorySearch: (payload) =>
1024
- normalizeMemoryRead(
1025
- aegis.memorySearch(payload && payload.query, payload && payload.limit)
1026
- ),
1027
- memorySave: (payload) =>
1028
- saveMemoryWithQueue(aegis, dir, payload && payload.entry, avatar),
1029
- memoryList: (payload) =>
1030
- normalizeMemoryRead(aegis.memoryList(payload && payload.limit)),
1031
-
1032
- verifyToken: (payload) =>
1033
- aegis.verifyToken(payload && payload.token),
1034
- memoryActivate: (payload) =>
1035
- aegis.memoryActivate(payload && payload.token),
1036
- memoryPull: (payload) =>
1037
- aegis.memoryPull(payload && payload.since),
1038
- memorySaveBatch: (payload) =>
1039
- aegis.memorySaveBatch(payload && payload.entries),
1040
- memoryImport: (payload) =>
1041
- importForeignMemory(aegis, dir, payload, avatar),
1042
- importConversation: (payload) =>
1043
- aegis.importConversation(payload || {}),
1044
-
1045
- // Links inside rendered markdown must never navigate the app's own
1046
- // BrowserWindow (that would point the chat UI at an arbitrary model-
1047
- // supplied origin) — they open in the OS default browser instead.
1048
- openExternal: (payload) => {
1049
- const url = payload && payload.url;
1050
- if (!isSafeExternalUrl(url)) {
1051
- return Promise.resolve({ ok: false, reason: 'unsupported URL scheme' });
1052
- }
1053
- return Promise.resolve(openExternalFn(url)).then(
1054
- () => ({ ok: true }),
1055
- (err) => ({ ok: false, reason: errorText(err) })
1056
- );
1057
- },
1058
- };
1059
- return dispatch;
1060
- }
1061
-
1062
- /** Chain `onReplyFinished(event, result)` onto a dispatch promise without
1063
- * changing what it resolves/rejects with — the notifier is best-effort
1064
- * (native-notification side effect) and optional (undefined in every
1065
- * headless test path, where it is simply never called). */
1066
- function withReplyNotify(promise, event, onReplyFinished) {
1067
- if (!onReplyFinished) return promise;
1068
- return promise.then((result) => {
1069
- onReplyFinished(event, result);
1070
- return result;
1071
- });
1072
- }
1073
-
1074
- /** Register every dispatch method as `aegis:<name>` on ipcMain. `dir` (the
1075
- * user-data dir) is optional and threaded through only for the memorySave
1076
- * offline-queue fallback — see saveMemoryWithQueue(). `openExternal` is the
1077
- * real electron.shell.openExternal, injected by bootstrap(); omitted in
1078
- * tests, where the safe no-op default in createIpcDispatch takes over.
1079
- * `onReplyFinished` is likewise bootstrap()-only: it fires the native
1080
- * "reply ready" notification when the window is unfocused/hidden. */
1081
- function registerIpc(ipcMain, aegis, dir, persistApiKey, openExternal, onReplyFinished, providerConfigured, getVaultBackend) {
1082
- const dispatch = createIpcDispatch(aegis, dir, persistApiKey, openExternal, providerConfigured, undefined, getVaultBackend);
1083
- for (const [name, handler] of Object.entries(dispatch)) {
1084
- if (name === 'chatCompletion') {
1085
- // Streaming render (D2.1): when the renderer asks for stream, SSE deltas
1086
- // are pushed over CHAT_DELTA_CHANNEL as they arrive while the invoke
1087
- // promise still resolves once with the normalised final result. When the
1088
- // payload does not request stream, behaviour is the plain non-streaming
1089
- // dispatch (what the headless shell test drives directly).
1090
- ipcMain.handle(`${IPC_PREFIX}${name}`, (event, payload) => {
1091
- const opts = { ...(payload || {}) };
1092
- if (!opts.stream) return withReplyNotify(handler(opts), event, onReplyFinished);
1093
- const sender = event && event.sender;
1094
- const forward = (chunk) => {
1095
- if (
1096
- sender &&
1097
- typeof sender.send === 'function' &&
1098
- !sender.isDestroyed()
1099
- ) {
1100
- // Address every delta with the request's sessionId (D2.2): the
1101
- // chat flow can run more than one stream at a time (the primary
1102
- // answer plus the horizontal discovery lane), so an unaddressed
1103
- // broadcast would interleave both replies into one bubble. The
1104
- // renderer's preload filters on `id`; chunks stay shape-compatible
1105
- // ({ delta }) for callers that never set a sessionId.
1106
- sender.send(CHAT_DELTA_CHANNEL, taggedChunk(chunk, opts.sessionId));
1107
- }
1108
- };
1109
- return withReplyNotify(
1110
- handler({ ...opts, stream: true, onStream: forward }),
1111
- event,
1112
- onReplyFinished
1113
- );
1114
- });
1115
- continue;
1116
- }
1117
- ipcMain.handle(`${IPC_PREFIX}${name}`, (_event, payload) => handler(payload));
1118
- }
1119
- return dispatch;
1120
- }
1121
-
1122
- /**
1123
- * Tool-call approval toggle ("confirm mode"): `aegis:getConfirmMode` /
1124
- * `aegis:setConfirmMode` (see registerConfirmModeIpc below). Pure injection of
1125
- * the settings store, same shape as createQuickLauncherDispatch — so it is
1126
- * unit-testable in plain Node with a stub store, and bootstrap() wires the
1127
- * real one (desktop/lib/settings.js, reserved `__confirmMode` namespace).
1128
- *
1129
- * `{ enabled: true }` (the default) is the historical behaviour: the engine's
1130
- * gate (desktop/lib/local/engine.js gatedExecuteTool) previews and asks before
1131
- * every exec/writeFile/editFile. `false` lets the renderer's Settings switch
1132
- * turn that off for good — the engine reads the flag per tool call, so the
1133
- * change applies to the very next call, no restart.
1134
- */
1135
- function createConfirmModeDispatch(settings) {
1136
- if (!settings) throw new Error('createConfirmModeDispatch requires a settings store');
1137
- return {
1138
- getConfirmMode: () => ({ enabled: settings.getConfirmMode() }),
1139
- setConfirmMode: (payload) => {
1140
- // Absent/undefined means "off"? No — an explicit boolean is required to
1141
- // change anything: a malformed payload must not silently disable the
1142
- // approval gate, so it only ever sets what was clearly asked for.
1143
- const enabled = payload && payload.enabled;
1144
- if (typeof enabled !== 'boolean') return { enabled: settings.getConfirmMode() };
1145
- return { enabled: settings.setConfirmMode(enabled) };
1146
- },
1147
- };
1148
- }
1149
-
1150
- /**
1151
- * Auto mode's other switch: a posture of "don't ask" IS auto mode on this face,
1152
- * so an explicit setConfirmMode must move the model dispatch's session state
1153
- * with it. Otherwise the two halves of the mode can be told different things —
1154
- * the engine stops asking, the round horizon keeps raising its card, and the
1155
- * chip in the composer claims auto mode is on.
1156
- *
1157
- * OFF starts auto mode for this sitting (`setAutoRounds(true)`, which also
1158
- * retires a boot-time AEGIS_AUTO_ROUNDS=0), ON ends it. A malformed payload
1159
- * changes nothing, exactly as in createConfirmModeDispatch — including the
1160
- * session state, so the two cannot drift on a bad message.
1161
- */
1162
- function withAutoRoundsSwitch(confirmDispatch, modelDispatch) {
1163
- const setConfirmMode = confirmDispatch.setConfirmMode;
1164
- return {
1165
- ...confirmDispatch,
1166
- setConfirmMode: (payload) => {
1167
- const res = setConfirmMode(payload);
1168
- if (payload && typeof payload.enabled === 'boolean' && modelDispatch && typeof modelDispatch.setAutoRounds === 'function') {
1169
- modelDispatch.setAutoRounds(payload.enabled === false);
1170
- }
1171
- return res;
1172
- },
1173
- };
1174
- }
1175
-
1176
- /**
1177
- * Register getConfirmMode/setConfirmMode as `aegis:<name>` on ipcMain —
1178
- * the same ${IPC_PREFIX}<method> convention registerIpc uses for the rest of
1179
- * the renderer's aegis.* bridge (their handlers need nothing but the
1180
- * payload, so the event is dropped exactly like every non-chatCompletion
1181
- * method there). */
1182
- function registerConfirmModeIpc(ipcMain, dispatch) {
1183
- for (const [name, handler] of Object.entries(dispatch)) {
1184
- ipcMain.handle(`${IPC_PREFIX}${name}`, (_event, payload) => handler(payload));
1185
- }
1186
- return dispatch;
1187
- }
1188
-
1189
- /**
1190
- * Vault backend preference: `aegis:getVaultBackend` / `aegis:setVaultBackend`.
1191
- * Same shape as createConfirmModeDispatch — unit-testable with a stub store —
1192
- * and registered through the same generic registerConfirmModeIpc, which does
1193
- * nothing confirm-mode-specific; it just wires `{name: handler}` pairs as
1194
- * `aegis:<name>`.
1195
- *
1196
- * `getVaultBackend` answers `{ backend }`, one of
1197
- * desktop/lib/settings.js's VAULT_BACKEND_VALUES ('aegiscloud' | 'drive' |
1198
- * 'off'). `setVaultBackend` refuses an unrecognised value the same way
1199
- * settings.setVaultBackendConfig does (returns the unchanged state rather than
1200
- * writing something the fingerprint service's vaultScope()/vaultDisabled()
1201
- * would not recognise either) — a bad payload cannot silently point the vault
1202
- * somewhere, or at nothing, that the picker never actually offered.
1203
- */
1204
- function createVaultBackendDispatch(settings) {
1205
- if (!settings) throw new Error('createVaultBackendDispatch requires a settings store');
1206
- return {
1207
- getVaultBackend: () => settings.vaultBackendConfig(),
1208
- setVaultBackend: (payload) => settings.setVaultBackendConfig(payload && payload.backend),
1209
- };
1210
- }
1211
-
1212
- /**
1213
- * Funnel instrumentation (Phase 2 of docs/upgrade-conversion-plan.md):
1214
- * `aegis:telemetryEvent` / `aegis:telemetryReport`, backed by the store in
1215
- * lib/local/telemetry.js.
1216
- *
1217
- * Both handlers are FIRE-AND-FORGET by construction. `telemetryEvent` resolves
1218
- * `{ ok }` and never rejects, never throws, and never does anything the caller
1219
- * can observe: the renderer fires an event from the render path, so the worst
1220
- * case here has to be a missing row, never a broken paint.
1221
- *
1222
- * The payload is rebuilt field by field (never spread) and the store re-checks
1223
- * every field against its own allowlist. Two independent gates on purpose —
1224
- * this is the boundary where "no API key, no conversation content, no paths,
1225
- * no model output" has to hold, and one of them is allowed to be wrong.
1226
- */
1227
- function createTelemetryDispatch(telemetry) {
1228
- const store = telemetry || null;
1229
- return {
1230
- telemetryEvent: (payload) => {
1231
- try {
1232
- if (!store || typeof store.record !== 'function') return { ok: false, reason: 'telemetry off' };
1233
- const p = payload && typeof payload === 'object' ? payload : {};
1234
- const accepted = store.record(p.event, { campaign: p.campaign, surface: p.surface });
1235
- return accepted ? { ok: true } : { ok: false, reason: 'not an allowlisted funnel event' };
1236
- } catch (err) {
1237
- // A throw here would cross IPC as a rejected promise; the renderer must
1238
- // not be able to see telemetry fail at all.
1239
- return { ok: false, reason: errorText(err) };
1240
- }
1241
- },
1242
- // The summary path: what this install has buffered, per campaign. Read by
1243
- // the offline report script (scripts/funnel-report.mjs) for the baseline
1244
- // table; there is no server endpoint to ask yet.
1245
- telemetryReport: () => {
1246
- try {
1247
- if (!store || typeof store.report !== 'function') return { enabled: false, transport: 'off' };
1248
- return store.report();
1249
- } catch (err) {
1250
- return { enabled: false, error: errorText(err) };
1251
- }
1252
- },
1253
- };
1254
- }
1255
-
1256
- /** Register telemetryEvent/telemetryReport as `aegis:<name>` — same
1257
- * ${IPC_PREFIX}<method> convention as registerConfirmModeIpc above. */
1258
- /**
1259
- * Pure mapping for the ÆGIS covenant's IPC surface — `aegis:covenantStatus`
1260
- * and `aegis:covenantGapReport`.
1261
- *
1262
- * READ-ONLY BY CONSTRUCTION, and that is the whole point of the file: both
1263
- * handlers return from `covenant.snapshot()` / `covenant.gapReport()`, which
1264
- * are frozen descriptions of the sealed ÆL/1 stream. There is deliberately no
1265
- * `setClause`, no `disable`, no `amend` and no `override` handler here — a
1266
- * renderer cannot name what does not exist, so no IPC payload can edit,
1267
- * waive or take the covenant out of circuit.
1268
- *
1269
- * §9 amendment invariant: ¬M(Λ1) ∧ ¬M(Λ2) ∧ ¬M(Λ4). Honesty, non-harm and
1270
- * accountability are load-bearing and no vote, tier, config or message may
1271
- * remove them — the desktop host included. Changing what is enforced means
1272
- * re-sealing the artifact on disk, which is not reachable from a channel.
1273
- *
1274
- * `covenant` is injected rather than required directly so this is unit-
1275
- * testable with a stub, same convention as createTelemetryDispatch /
1276
- * createExportDispatch.
1277
- */
1278
- function createCovenantDispatch(covenant) {
1279
- return {
1280
- covenantStatus: () => {
1281
- const snapshot = covenant.snapshot();
1282
- return { ok: true, covenant: snapshot, gapReport: covenant.gapReport() };
1283
- },
1284
- covenantGapReport: () => ({ ok: true, gapReport: covenant.gapReport() }),
1285
- };
1286
- }
1287
-
1288
- /** Register covenant dispatch methods as `aegis:<name>`, same convention as
1289
- * registerTelemetryIpc — read-only, see createCovenantDispatch. */
1290
- function registerCovenantIpc(ipcMain, dispatch) {
1291
- for (const [name, handler] of Object.entries(dispatch)) {
1292
- ipcMain.handle(`${IPC_PREFIX}${name}`, (_event, payload) => handler(payload));
1293
- }
1294
- return dispatch;
1295
- }
1296
-
1297
- function registerTelemetryIpc(ipcMain, dispatch) {
1298
- for (const [name, handler] of Object.entries(dispatch)) {
1299
- ipcMain.handle(`${IPC_PREFIX}${name}`, (_event, payload) => handler(payload));
1300
- }
1301
- return dispatch;
1302
- }
1303
-
1304
- /** Turn a session title/id into a filesystem-safe base filename. */
1305
- function safeExportBasename(session) {
1306
- const base = (session && (session.title || session.id)) || 'session';
1307
- return base.replace(/[^\w.-]+/g, '_').slice(0, 80) || 'session';
1308
- }
1309
-
1310
- /**
1311
- * Pure mapping: `aegis:exportSession` -> read the session from the sessions
1312
- * store (lib/sync/sessions.js is the single source of truth for its shape),
1313
- * serialize it, then hand the (defaultPath, filters) to an injected
1314
- * `showSaveDialog` and the resulting path + content to an injected
1315
- * `writeFile`. Both are injected — exactly like `openExternal` on
1316
- * createIpcDispatch — so this is unit-testable with stub functions and needs
1317
- * no Electron import of its own; bootstrap() wires the real
1318
- * dialog.showSaveDialog / fs.promises.writeFile.
1319
- */
1320
- function createExportDispatch(sessions, dir, showSaveDialog, writeFile) {
1321
- return {
1322
- exportSession: async (payload) => {
1323
- const sessionId = payload && payload.sessionId;
1324
- const format = payload && payload.format === 'json' ? 'json' : 'markdown';
1325
- const session = sessionId ? sessions.getSession(dir, sessionId) : null;
1326
- if (!session) return { ok: false, reason: 'session not found' };
1327
-
1328
- const content = format === 'json' ? sessions.toJson(session) : sessions.toMarkdown(session);
1329
- const ext = format === 'json' ? 'json' : 'md';
1330
- const filters = format === 'json'
1331
- ? [{ name: 'JSON', extensions: ['json'] }]
1332
- : [{ name: 'Markdown', extensions: ['md'] }];
1333
-
1334
- const dialogResult = await showSaveDialog({
1335
- defaultPath: `${safeExportBasename(session)}.${ext}`,
1336
- filters,
1337
- });
1338
- if (!dialogResult || dialogResult.canceled || !dialogResult.filePath) {
1339
- return { ok: false, canceled: true };
1340
- }
1341
-
1342
- try {
1343
- await writeFile(dialogResult.filePath, content);
1344
- } catch (err) {
1345
- return { ok: false, reason: errorText(err) };
1346
- }
1347
- return { ok: true, filePath: dialogResult.filePath };
1348
- },
1349
- };
1350
- }
1351
-
1352
- /** Register export dispatch methods as `aegis:<name>`, same convention as
1353
- * registerIpc()/registerUpdateIpc() above. */
1354
- function registerExportIpc(ipcMain, sessions, dir, showSaveDialog, writeFile) {
1355
- const dispatch = createExportDispatch(sessions, dir, showSaveDialog, writeFile);
1356
- for (const [name, handler] of Object.entries(dispatch)) {
1357
- ipcMain.handle(`${IPC_PREFIX}${name}`, (_event, payload) => handler(payload));
1358
- }
1359
- return dispatch;
1360
- }
1361
-
1362
- /**
1363
- * Pure mapping: `quick:<name>` -> quick-launcher state. `opts.isPackaged`,
1364
- * `opts.applyConfig` and `opts.pushToMain` are injected (default no-ops) so
1365
- * this is unit-testable in plain Node, same pattern as createExportDispatch's
1366
- * injected showSaveDialog/writeFile — bootstrap() wires the real
1367
- * globalShortcut-backed applyConfig and window-relaying pushToMain.
1368
- *
1369
- * `applyConfig(cfg)` is expected to (re)register or unregister the global
1370
- * shortcut for the given `{ enabled, shortcut }` and resolve/return
1371
- * `{ active, reason }` — `reason` carries a human-readable cause the one time
1372
- * registration fails (e.g. the accelerator is already claimed by another
1373
- * app), so the renderer's settings card can show it instead of a silent
1374
- * no-op. This module never throws on a failed registration; it degrades to
1375
- * `active:false` and surfaces `reason`.
1376
- */
1377
- function createQuickLauncherDispatch(settings, opts = {}) {
1378
- const isPackaged = Boolean(opts.isPackaged);
1379
- const applyConfig = opts.applyConfig || (() => ({ active: false, reason: null }));
1380
- const pushToMain = opts.pushToMain || (() => ({ ok: false, reason: 'no main window' }));
1381
- let state = { active: false, reason: null };
1382
-
1383
- function status() {
1384
- return { ...settings.quickLauncherConfig(), packaged: isPackaged, ...state };
1385
- }
1386
-
1387
- function setConfig(payload) {
1388
- const saved = settings.setQuickLauncherConfig({
1389
- enabled: payload && payload.enabled,
1390
- shortcut: payload && payload.shortcut,
1391
- });
1392
- state = applyConfig(saved) || { active: false, reason: null };
1393
- return { ...saved, packaged: isPackaged, ...state };
1394
- }
1395
-
1396
- return {
1397
- status,
1398
- setConfig,
1399
- pushToMain: (payload, event) => pushToMain(payload, event),
1400
- };
1401
- }
1402
-
1403
- /** Register quick:<name> on ipcMain. `pushToMain` needs the raw IPC `event`
1404
- * (to hide the launcher window that sent it — see bootstrap()'s
1405
- * pushQuickLauncherResult), so it is special-cased exactly like
1406
- * aegis:chatCompletion is in registerIpc(); every other method drops the
1407
- * event, matching the rest of this file's convention. */
1408
- function registerQuickLauncherIpc(ipcMain, dispatch) {
1409
- for (const [name, handler] of Object.entries(dispatch)) {
1410
- if (name === 'pushToMain') {
1411
- ipcMain.handle(`${QUICK_PREFIX}${name}`, (event, payload) => handler(payload, event));
1412
- continue;
1413
- }
1414
- ipcMain.handle(`${QUICK_PREFIX}${name}`, (_event, payload) => handler(payload));
1415
- }
1416
- return dispatch;
1417
- }
1418
-
1419
- /**
1420
- * Resolve the per-user data directory for settings + sessions. Electron gives
1421
- * us app.getPath('userData'); outside Electron (headless smoke tests) fall back
1422
- * to ~/.aegiscode so the pure functions can still be exercised.
1423
- */
1424
- function resolveUserDataDir(app) {
1425
- if (app && typeof app.getPath === 'function') {
1426
- try {
1427
- return app.getPath('userData');
1428
- } catch {
1429
- /* fall through */
1430
- }
1431
- }
1432
- return path.join(os.homedir(), '.aegiscode');
1433
- }
1434
-
1435
- /**
1436
- * Wire the real LocalEngine registry: the settings store + the shared cloud
1437
- * client. Transport-only — no brain logic. Both shipping classes (aegis, byok)
1438
- * reach their provider through that one client, so there is no second
1439
- * transport to inject.
1440
- */
1441
- function createEngine(aegis, { app, safeStorage, dir: dirOverride, sessionsDir: sessionsOverride } = {}) {
1442
- // Settings (the safeStorage-encrypted key, window state, provider entries)
1443
- // stay in Electron's userData: they are this host's own, and the key is only
1444
- // readable by this process.
1445
- const settingsDir = dirOverride || resolveUserDataDir(app);
1446
- const settings = createSettingsStore({ dir: settingsDir, safeStorage });
1447
- // Relocate a pre-fix `settings['aegis']` key into the reserved namespace so
1448
- // it stops showing up as a provider. Ciphertext-level, so safe pre-'ready'.
1449
- settings.migrateLegacyAegisKey();
1450
- const engine = createLocalEngine({ aegis, settings });
1451
- // Sessions/memory live in the SHARED data dir ($AEGISCODE_HOME or
1452
- // ~/.aegiscode) — the same file the terminal host and the MCP plugin read —
1453
- // so a thread started in either shows up in the other without cloud sync and
1454
- // without a key. resolveStoreDir() also adopts a pre-unification
1455
- // userData/sessions.json, once, when the shared store is still empty, so an
1456
- // upgrade does not look like every conversation was deleted.
1457
- const sessionsDir = sessionsOverride || sessionStore.resolveStoreDir(settingsDir);
1458
- return { engine, sessionsDir, settingsDir, settings };
1459
- }
1460
-
1461
- /** Largest pre-edit read the diff bridge will hand the renderer (bytes). */
1462
- const PREVIEW_READ_MAX_BYTES = 512 * 1024;
1463
-
1464
- /**
1465
- * The contained, capped read behind the renderer's diff preview — the only
1466
- * filesystem capability the renderer has, and the narrowest form of it.
1467
- *
1468
- * The renderer supplies a file plus the cwd of the session it is displaying.
1469
- * Every path is resolved with `realpath` *before* the containment test, so a
1470
- * symlink planted inside the workspace cannot point the read outside it, and
1471
- * containment is judged with `path.relative` rather than a `startsWith` prefix
1472
- * (which would let `/home/neo-evil` pass a naive `/home/neo` check). Anything
1473
- * refused — relative cwd, escape, missing file, directory, non-regular file —
1474
- * returns null, which degrades the preview to a create-style diff instead of
1475
- * throwing onto the render path.
1476
- *
1477
- * @returns {{text: string, truncated: boolean}|null}
1478
- */
1479
- function readTextFileForPreview(payload) {
1480
- try {
1481
- const file = payload && typeof payload.file === 'string' ? payload.file : '';
1482
- const cwd = payload && typeof payload.cwd === 'string' ? payload.cwd : '';
1483
- if (!file || !cwd || !path.isAbsolute(cwd)) return null;
1484
-
1485
- // realpath both sides: resolving the root too keeps this correct on hosts
1486
- // where the workspace itself is reached through a symlink (/tmp, /var).
1487
- const root = fs.realpathSync(cwd);
1488
- const abs = fs.realpathSync(path.resolve(root, file));
1489
- const rel = path.relative(root, abs);
1490
- if (rel === '' || rel.startsWith('..') || path.isAbsolute(rel)) return null;
1491
-
1492
- const st = fs.statSync(abs);
1493
- if (!st.isFile()) return null;
1494
-
1495
- if (st.size <= PREVIEW_READ_MAX_BYTES) {
1496
- return { text: fs.readFileSync(abs, 'utf8'), truncated: false };
1497
- }
1498
- // Oversized: read only the head of the file and cut back to the last
1499
- // newline, so a byte-boundary truncation cannot leave a half-decoded
1500
- // multi-byte sequence at the end of the preview.
1501
- const fd = fs.openSync(abs, 'r');
1502
- try {
1503
- const buf = Buffer.alloc(PREVIEW_READ_MAX_BYTES);
1504
- const n = fs.readSync(fd, buf, 0, PREVIEW_READ_MAX_BYTES, 0);
1505
- let text = buf.toString('utf8', 0, n);
1506
- const nl = text.lastIndexOf('\n');
1507
- if (nl > 0) text = text.slice(0, nl + 1);
1508
- return { text, truncated: true };
1509
- } finally {
1510
- fs.closeSync(fd);
1511
- }
1512
- } catch {
1513
- return null;
1514
- }
1515
- }
1516
-
1517
- /**
1518
- * Pure mapping: model:<name> -> LocalEngine call (unit-testable in Node).
1519
- */
1520
- function createModelDispatch(engine, { env = process.env, settings } = {}) {
1521
- // ── Auto mode's second half: the round horizon ────────────────────────────
1522
- //
1523
- // autoContinuePlan/autoContinueLimit (lib/local/autonomous.js) answer "may
1524
- // this horizon ask be answered for the user?" — the arithmetic the engine
1525
- // defines in src/autonomous.js and the ONLY thing separating auto mode in
1526
- // aegiscodex-dev from auto mode here. The desktop wired the first half (no
1527
- // approval cards, via the Tool-approvals switch) and nothing at all for this
1528
- // one, so a desktop turn that hit its round horizon raised the blocking card
1529
- // on the first hit and every hit after it. That is the exact question auto
1530
- // mode exists to stop asking: same answer, same click, once per horizon.
1531
- //
1532
- // The policy lives HERE rather than in the engine on purpose — the engine's
1533
- // own comment states the contract ("the hook is optional and the caller owns
1534
- // the policy"), so the engine keeps asking and each host decides. This host's
1535
- // answer is the posture the user already set in one place: confirmations OFF
1536
- // (the same boolean behind the settings switch and the composer's "auto mode"
1537
- // chip) means auto mode is on, and the tally is per session because that is
1538
- // the lifetime of the turn it is funding.
1539
- const autoUsed = new Map(); // sessionId -> automatic continuations spent
1540
-
1541
- // Auto mode's session START state — the engine's AEGIS_AUTO_ROUNDS=0 opt-out
1542
- // (src/autonomous.js autoRoundsDefault), read once here rather than on every
1543
- // call. Re-derived per call it is a VETO, not a start state: with the variable
1544
- // exported in the environment that launched the app, the Tool-approvals switch
1545
- // and any `/automode on` from the CLI sharing the store would write the
1546
- // posture and change nothing — the chip lit, the round card still every
1547
- // horizon, and no way back to auto mode short of restarting the app. The
1548
- // engine's own contract is the other one: the env var picks where the session
1549
- // STARTS, and the toggle moves it freely from there.
1550
- //
1551
- // The opt-out is retired by an explicit act, of which there are two here:
1552
- // setAutoRounds (the switch and the IPC, wired at bootstrap) and a stored
1553
- // posture that differs from the one this process booted with (a flip made by
1554
- // another face — the CLI's `/automode` writes the same key).
1555
- let autoRoundsSession = autoRoundsDefault(env);
1556
- let bootPosture;
1557
- let autoRoundsRetired = false;
1558
-
1559
- /** True only when auto mode is on: the session state AND a posture that says
1560
- * don't ask. Read per call, never captured: the settings
1561
- * switch and `/automode` in the CLI both write the same shared key, so a
1562
- * flip takes effect on the next horizon without a restart.
1563
- *
1564
- * `undefined` from every accessor means "this store cannot answer" — and an
1565
- * unknown posture must NOT be read as auto mode ON: silently extending turns
1566
- * (and spending billed rounds) on a store we cannot read is the worse
1567
- * failure, and the pre-change behaviour (show the card) is the safe default.
1568
- * AEGIS_AUTO_ROUNDS=0 turns it off session-wide, exactly as in the engine. */
1569
- const autoRoundsOn = () => {
1570
- const store = settings || (engine && engine.settings) || null;
1571
- const bool = (v) => (typeof v === 'boolean' ? v : undefined);
1572
- let posture;
1573
- try {
1574
- if (store && typeof store.confirmModeState === 'function') {
1575
- const st = store.confirmModeState();
1576
- posture = bool(st && st.enabled);
1577
- }
1578
- if (posture === undefined && store && typeof store.getConfirmMode === 'function') {
1579
- posture = bool(store.getConfirmMode());
1580
- }
1581
- } catch {
1582
- /* an unreadable store is not auto mode — the card stays up */
1583
- }
1584
- // A posture that moved since boot means somebody acted (another face wrote
1585
- // the same key), so the env opt-out has been answered and no longer applies.
1586
- if (!autoRoundsRetired && posture !== undefined) {
1587
- if (bootPosture === undefined) bootPosture = posture;
1588
- else if (posture !== bootPosture) {
1589
- autoRoundsRetired = true;
1590
- autoRoundsSession = true;
1591
- }
1592
- }
1593
- if (!autoRoundsSession || !store || posture === undefined) return false;
1594
- return posture === false;
1595
- };
1596
-
1597
- /** Move the session state — the switch (via setConfirmMode at bootstrap) and
1598
- * any explicit auto-mode request. Turns OFF means the operator asked for
1599
- * manual NOW, so it sticks against the boot-time opt-out in both
1600
- * directions. */
1601
- const setAutoRounds = (v) => {
1602
- autoRoundsSession = Boolean(v);
1603
- autoRoundsRetired = true;
1604
- };
1605
-
1606
- /**
1607
- * Wrap the renderer's stream so a round-horizon ask can be answered for the
1608
- * human while auto mode is on, and forwarded untouched otherwise.
1609
- *
1610
- * Three things are load-bearing:
1611
- *
1612
- * · the ENGINE is what answers (`respondRoundsAsk`), not a synthetic frame
1613
- * — the turn is held open on that promise, so the only honest way to skip
1614
- * the dialog is to resolve it;
1615
- * · `{ note }` reaches the renderer, because a budget that grew invisibly
1616
- * is the "why is it still running?" report, and the CLI announces this
1617
- * same continuation through its transcript note;
1618
- * · a declined/failed answer FALLS THROUGH to the card. The allowance is
1619
- * spent, so this is the real ask — the same "then it asks" contract the
1620
- * CLI and the engine's status line advertise.
1621
- */
1622
- const chatWithAutoRounds = (payload) => {
1623
- const forward = payload && payload.onStream;
1624
- if (typeof forward !== 'function') return engine.chat(payload, forward);
1625
- const sessionId = (payload && payload.sessionId) || '';
1626
- const onStream = (chunk) => {
1627
- if (chunk && chunk.rounds && chunk.rounds.id && autoRoundsOn()) {
1628
- const plan = autoContinuePlan({
1629
- on: true,
1630
- used: autoUsed.get(sessionId) || 0,
1631
- limit: autoContinueLimit(env),
1632
- });
1633
- autoUsed.set(sessionId, plan.used);
1634
- if (plan.auto) {
1635
- const answered =
1636
- typeof engine.respondRoundsAsk === 'function'
1637
- ? engine.respondRoundsAsk(chunk.rounds.id, true)
1638
- : { ok: false };
1639
- if (answered && answered.ok) {
1640
- const spent = Number(chunk.rounds.spent) || 0;
1641
- forward({
1642
- note:
1643
- `auto mode: the ${spent}-round limit was reached — continuing automatically ` +
1644
- `(${plan.used}/${plan.cap}, then it asks).`,
1645
- });
1646
- return;
1647
- }
1648
- }
1649
- // Not auto (allowance spent, or the engine could not take an answer):
1650
- // show the real card. plan.used is 0 in the spent case, so the next
1651
- // horizon starts a fresh block of automatic continuations — the same
1652
- // reset the CLI's presenter applies once the human is actually asked.
1653
- }
1654
- forward(chunk);
1655
- };
1656
- return engine.chat(payload, onStream);
1657
- };
1658
-
1659
- const dispatch = {
1660
- // The picker's source. Filtered through DESKTOP_CLASSES so this face never
1661
- // offers a class it cannot serve; see that constant for why `pro` is
1662
- // omitted here while the engine still ships it for the CLI.
1663
- listClasses: async () => (await engine.listClasses()).filter((c) => DESKTOP_CLASSES.includes(c.class)),
1664
- listModels: (payload) => engine.listModels(payload && payload.class),
1665
- chat: chatWithAutoRounds,
1666
- // The AEGIS key lives in a reserved namespace the store refuses to expose
1667
- // or delete through this surface; the filter is belt-and-braces so a
1668
- // mis-wired store can never hand the renderer a removable AEGIS entry.
1669
- 'settings.get': () =>
1670
- engine.settings.list().filter((s) => !(s && isReservedNamespace(s.provider))),
1671
- 'settings.set': (payload) =>
1672
- engine.settings.set(payload && payload.provider, {
1673
- baseURL: payload && payload.baseURL,
1674
- key: payload && payload.key,
1675
- }),
1676
- 'settings.remove': (payload) =>
1677
- engine.settings.remove(payload && payload.provider),
1678
- // The USER's own model declarations (`client/user-models.js`, the file the
1679
- // CLI's `/model-add` writes). Settings is the only place a desktop user can
1680
- // add a model the server's catalog does not name, and until this existed the
1681
- // only ways were a release or hand-editing a JSON file the app never named.
1682
- //
1683
- // These three go through the ENGINE rather than the settings store: the
1684
- // engine's `listModels('byok')` is what applies them, so the write and the
1685
- // read share one implementation and cannot disagree about the file. Add and
1686
- // remove THROW on a malformed provider/model id — the renderer shows the
1687
- // message, which is the honest outcome for a string that would otherwise be
1688
- // forwarded to a vendor and 404 there.
1689
- 'userModels.get': () => engine.userModels(),
1690
- 'userModels.add': (payload) =>
1691
- engine.addUserModel(payload && payload.provider, payload && payload.model),
1692
- 'userModels.remove': (payload) =>
1693
- engine.removeUserModel(payload && payload.provider, payload && payload.model),
1694
- // Ask the provider's OWN API which models its key reaches — the end of
1695
- // "type the vendor's model id from memory". The key stays in the main
1696
- // process: the renderer passes a provider id and gets back ids, never a
1697
- // credential, which is the same posture as the provider CRUD above.
1698
- //
1699
- // `userModels.addAll` is the bulk write the list makes possible (`/model-add
1700
- // <provider> '*'` in the CLI): a batch where one refused row does not abort
1701
- // the rest, and where the counts are returned so the row can say what
1702
- // actually happened.
1703
- 'userModels.discover': (payload) => engine.discoverModels(payload && payload.provider),
1704
- 'userModels.addAll': (payload) =>
1705
- engine.addUserModels(payload && payload.provider, payload && payload.ids),
1706
- 'userModels.canDiscover': (payload) =>
1707
- Boolean(engine.canDiscoverModels(payload && payload.provider)),
1708
- 'userModels.discoverable': () => engine.discoverableProviders(),
1709
- // Persisting memory ("sync finished turns to cloud memory automatically").
1710
- // Reserved namespace, so it is invisible to the provider CRUD above — but
1711
- // unlike a provider it IS reachable deliberately, through these two. The
1712
- // gate that enforces it lives in lib/sync/persist-gate.js and reads the
1713
- // same file, so a `false` written here stops the next automatic push.
1714
- 'memoryPersist.get': () => engine.settings.memoryPersistState(),
1715
- 'memoryPersist.set': (payload) =>
1716
- engine.settings.setMemoryPersist(Boolean(payload && payload.enabled)),
1717
- cancel: (payload) => engine.cancel(payload && payload.sessionId),
1718
- // Tool-call approval gate (desktop/lib/local/engine.js gatedExecuteTool):
1719
- // the renderer's approval card answers a pending exec/writeFile/editFile
1720
- // request here; newChat() clears a conversation's "allow for this
1721
- // session" grants so a fresh thread never inherits a prior one's.
1722
- respondApproval: (payload) =>
1723
- engine.respondApproval(payload && payload.approvalId, payload && payload.decision),
1724
- // Round-horizon gate (desktop/lib/local/engine.js requestRoundsAsk): the
1725
- // renderer's rounds card answers a pending "fund another block of tool
1726
- // rounds?" ask here. The ask itself arrives as a `{ rounds }` chunk on the
1727
- // same chat() delta stream the approval card rides — see
1728
- // desktop/renderer/app.js renderRoundsCard. An unanswered id is a no-op,
1729
- // same contract as respondApproval.
1730
- //
1731
- // Guarded on the method's existence rather than assuming it: an engine
1732
- // without the ask can never emit a `{ rounds }` chunk, so this branch is
1733
- // unreachable there — but a renderer/window pair that is newer than the
1734
- // engine it drives must get an honest `{ ok: false }` instead of a
1735
- // TypeError inside the IPC handler.
1736
- respondRoundsAsk: (payload) =>
1737
- typeof engine.respondRoundsAsk === 'function'
1738
- ? engine.respondRoundsAsk(payload && payload.roundsAskId, payload && payload.decision)
1739
- : { ok: false },
1740
- clearApprovals: (payload) =>
1741
- engine.clearSessionApprovals(payload && payload.sessionId),
1742
- };
1743
-
1744
- // Auto mode's session state is read and moved DIRECTLY, never over IPC.
1745
- // Two reasons, and both are load-bearing:
1746
- //
1747
- // · every ENUMERABLE key of this object becomes an `aegis:<name>` channel
1748
- // (registerModelIpc iterates Object.entries), so exporting these would
1749
- // hand the renderer a way to move auto mode's round half without moving
1750
- // the posture — the exact two-answers-one-question drift this file's
1751
- // dispatch exists to prevent. The renderer's switch reaches both halves
1752
- // through aegis:setConfirmMode (withAutoRoundsSwitch), which is the one
1753
- // act that means "auto mode" on this face;
1754
- //
1755
- // · a signature change to the bridge has to be visible where the bridge is
1756
- // described, so the count in test/model-dispatch.mjs stays a real check.
1757
- Object.defineProperties(dispatch, {
1758
- autoRoundsOn: { value: autoRoundsOn, enumerable: false },
1759
- setAutoRounds: { value: setAutoRounds, enumerable: false },
1760
- });
1761
- return dispatch;
1762
- }
1763
-
1764
- /**
1765
- * Pure mapping: sync:<name> -> sessions store call (unit-testable in Node).
1766
- * `push`/`pull` are the P3 cloud-sync surface (plan §7/§8.5): they reuse the
1767
- * shared client's memory-token auth (`aegis.conversationSyncPush/Pull`) — no
1768
- * new credential flow. Offline-first: with no AEGIS key configured, both
1769
- * resolve `{ ok: false, reason }` without ever throwing, and every local
1770
- * flow (save/append/list) keeps working untouched.
1771
- */
1772
- function createSyncDispatch(sessions, dir, aegis, settingsDir) {
1773
- let lastSyncAt = null;
1774
-
1775
- function hasCloud() {
1776
- return Boolean(aegis && aegis.apiKey);
1777
- }
1778
-
1779
- /** Retry every locally queued memory-save (plan P3 §7 "queue locally and
1780
- * sync later") now that the cloud is reachable. Entries that still fail
1781
- * (e.g. one bad entry among several) stay queued for the next attempt. */
1782
- async function flushMemoryQueue() {
1783
- const queued = memoryQueue.listQueued(dir);
1784
- if (!queued.length) return { flushed: 0, remaining: 0, upgrade: null };
1785
- const remaining = [];
1786
- let flushed = 0;
1787
- let upgrade = null;
1788
- for (let i = 0; i < queued.length; i += 1) {
1789
- try {
1790
- await aegis.memorySave(queued[i]);
1791
- flushed += 1;
1792
- } catch (err) {
1793
- const capped = upgradeInfo(err);
1794
- if (capped) {
1795
- // A 402 is not "try again later": every remaining entry would fail
1796
- // the same way, the queue would never drain, and the paywall would
1797
- // stay invisible behind a "synced" toast. Stop, keep the rest
1798
- // queued for after the upgrade, and surface the cap to the renderer.
1799
- upgrade = capped;
1800
- remaining.push(...queued.slice(i));
1801
- break;
1802
- }
1803
- // Any other failure (offline / transient) keeps the entry queued.
1804
- remaining.push(queued[i]);
1805
- }
1806
- }
1807
- memoryQueue.save(dir, remaining);
1808
- return { flushed, remaining: remaining.length, upgrade };
1809
- }
1810
-
1811
- async function push() {
1812
- const pending = sessions.listPending(dir);
1813
- if (!hasCloud()) {
1814
- return { ok: false, queued: pending.length, reason: 'no AEGIS key configured' };
1815
- }
1816
- const memoryFlush = await flushMemoryQueue();
1817
- if (!pending.length) {
1818
- return {
1819
- ok: true,
1820
- queued: 0,
1821
- pushed: 0,
1822
- memoryFlushed: memoryFlush.flushed,
1823
- upgrade: memoryFlush.upgrade,
1824
- };
1825
- }
1826
-
1827
- let pushed = 0;
1828
- let lastError = null;
1829
- let lastInfo = null;
1830
- for (const session of pending) {
1831
- try {
1832
- const result = await aegis.conversationSyncPush({
1833
- session_id: session.id,
1834
- title: session.title,
1835
- messages: session.messages,
1836
- });
1837
- const remoteId = result && (result.session_id || result.id);
1838
- sessions.markSynced(dir, session.id, { remoteId });
1839
- pushed += 1;
1840
- } catch (err) {
1841
- lastError = err;
1842
- lastInfo = describeSyncError(err);
1843
- // A cap (402) or a refused memory token (401) fails identically for
1844
- // every later session — same reason the CLI stops there. One request
1845
- // per queued transcript against a refusal that cannot succeed is
1846
- // nothing but noise, so stop. The rest are left untouched (markSynced
1847
- // is never reached for them) and drain after the account is fixed.
1848
- if (lastInfo.kind === 'quota' || lastInfo.kind === 'auth') break;
1849
- }
1850
- }
1851
- const queued = sessions.listPending(dir).length;
1852
- if (pushed) lastSyncAt = Date.now();
1853
- // The session push is the path that hits the free-plan cap most often —
1854
- // every queued transcript re-bills the synced-token ceiling — so its
1855
- // upgrade metadata wins over the memory flush's. Without this, a capped
1856
- // session push answered `upgrade: null` and the renderer's capNotice()
1857
- // (the one place the paywall is visible) never fired.
1858
- const sessionUpgrade = lastError ? upgradeInfo(lastError) : null;
1859
- const upgrade = sessionUpgrade || memoryFlush.upgrade;
1860
- if (pushed === 0 && lastError) {
1861
- return {
1862
- ok: false,
1863
- queued,
1864
- reason: syncErrorReason(lastInfo),
1865
- // Additive: the renderer reads ok/queued/reason/upgrade exactly as
1866
- // before; these name the refusal without string-matching its wording.
1867
- failed: lastInfo ? [lastInfo] : [],
1868
- kind: lastInfo ? lastInfo.kind : 'error',
1869
- status: lastInfo ? lastInfo.status : 0,
1870
- hint: lastInfo ? lastInfo.hint : null,
1871
- memoryFlushed: memoryFlush.flushed,
1872
- upgrade,
1873
- };
1874
- }
1875
- return {
1876
- ok: true,
1877
- queued,
1878
- pushed,
1879
- // A partially failed push is still `ok` (the ones that went out are
1880
- // synced), but the reason is carried so nothing looks silently clean.
1881
- failed: lastInfo ? [lastInfo] : [],
1882
- memoryFlushed: memoryFlush.flushed,
1883
- upgrade,
1884
- };
1885
- }
1886
-
1887
- async function pull() {
1888
- if (!hasCloud()) {
1889
- return { ok: false, merged: 0, reason: 'no AEGIS key configured' };
1890
- }
1891
- try {
1892
- const data = await aegis.conversationSyncPull();
1893
- const merged = sessions.mergeRemoteSessions(dir, (data && data.sessions) || []);
1894
- lastSyncAt = Date.now();
1895
- return { ok: true, merged, failed: [], upgrade: null };
1896
- } catch (err) {
1897
- const info = describeSyncError(err);
1898
- // Same shape as cli/src/cloudsync.js pull(): a classified `failed[]`
1899
- // instead of a bare message to string-match, plus the cap metadata when
1900
- // this was a 402 — the renderer's syncNow() reads `pullResult.upgrade`
1901
- // alongside the push's, so a pull that hits the cap now lights the
1902
- // upgrade banner too.
1903
- return {
1904
- ok: false,
1905
- merged: 0,
1906
- reason: syncErrorReason(info),
1907
- failed: [info],
1908
- kind: info.kind,
1909
- status: info.status,
1910
- hint: info.hint,
1911
- upgrade: upgradeInfo(err),
1912
- };
1913
- }
1914
- }
1915
-
1916
- // The automatic post-turn push (persisting memory for every account). Gated
1917
- // by the `__memoryPersist` preference in settings.json, which the Settings
1918
- // pane writes through the memoryPersist:* IPC below; the gate is read from
1919
- // disk in the main process so the renderer cannot push past it. This must
1920
- // read `settingsDir` (Electron's userData path), NOT `dir` — `dir` here is
1921
- // the shared aegisHome() sessions/credentials directory, a different path
1922
- // from userData in a real install, and the settings store is created with
1923
- // `settingsDir`. Falling back to `dir` only covers callers (tests) that
1924
- // never pass `settingsDir` and use one directory for everything.
1925
- const autoPush = persistGate.createAutoPush({ push, dir: settingsDir || dir });
1926
-
1927
- return {
1928
- auto: () => autoPush.auto(),
1929
- memoryPersistState: () => autoPush.gate(),
1930
- listSessions: () => ({ sessions: sessions.listSessions(dir) }),
1931
- open: (payload) => sessions.getSession(dir, payload && payload.sessionId),
1932
- save: (payload) => sessions.upsertSession(dir, payload || {}),
1933
- append: (payload) =>
1934
- sessions.appendMessage(
1935
- dir,
1936
- payload && payload.sessionId,
1937
- payload && payload.message
1938
- ),
1939
- delete: (payload) =>
1940
- sessions.deleteSession(dir, payload && payload.sessionId),
1941
- push,
1942
- pull,
1943
- status: () => ({
1944
- count: sessions.listSessions(dir).length,
1945
- pending: sessions.listPending(dir).length,
1946
- cloud: hasCloud(),
1947
- lastSyncAt,
1948
- }),
1949
- };
1950
- }
1951
-
1952
- /** First retry delay and the ceiling for the heartbeat push backoff. */
1953
- const HEARTBEAT_RETRY_BASE_MS = 5000;
1954
- const HEARTBEAT_RETRY_MAX_MS = 30000;
1955
-
1956
- /**
1957
- * Backoff for the fire-and-forget heartbeat push (plan §7 "retry on a
1958
- * heartbeat"). `model:listModels` and `sync:status` each fire one, and the
1959
- * renderer polls both — so a permanently failing push (offline, refused
1960
- * memory token, free-plan cap) used to burn a request on every single one,
1961
- * forever, with `.catch(() => {})` swallowing the reason.
1962
- *
1963
- * Now: exponential backoff from `baseMs` to `maxMs`, reset on success, and the
1964
- * last error is retained (`state()`) so a stuck sync is visible on the
1965
- * sync:status payload instead of disappearing into an empty catch.
1966
- *
1967
- * `push` is expected to resolve a `{ ok:false, reason }` result rather than
1968
- * throw (that is createSyncDispatch's contract) — both are counted, because
1969
- * only counting throws would leave the offline case retrying every heartbeat.
1970
- * `now`/`baseMs`/`maxMs` are injectable so the tests can drive the ladder
1971
- * without sleeping.
1972
- */
1973
- function createHeartbeatRetry(push, opts = {}) {
1974
- const now = typeof opts.now === 'function' ? opts.now : () => Date.now();
1975
- const baseMs = Number(opts.baseMs) > 0 ? Number(opts.baseMs) : HEARTBEAT_RETRY_BASE_MS;
1976
- const maxMs = Number(opts.maxMs) > 0 ? Number(opts.maxMs) : HEARTBEAT_RETRY_MAX_MS;
1977
- let failures = 0;
1978
- let nextAttemptAt = 0;
1979
- let lastError = null;
1980
- let inFlight = false;
1981
-
1982
- /** 5s, 10s, 20s, 30s, 30s… — capped, never unbounded. */
1983
- const delayFor = () => Math.min(maxMs, baseMs * 2 ** Math.max(0, failures - 1));
1984
-
1985
- function fail(reason) {
1986
- failures += 1;
1987
- const wait = delayFor();
1988
- lastError = reason || 'push failed';
1989
- nextAttemptAt = now() + wait;
1990
- return { attempted: true, ok: false, error: lastError, failures, retryInMs: wait };
1991
- }
1992
-
1993
- async function run() {
1994
- const t = now();
1995
- if (failures > 0 && t < nextAttemptAt) {
1996
- // Backing off: no request, no state change — the caller keeps whatever
1997
- // reason is already on record.
1998
- return { attempted: false, ok: false, error: lastError, failures, retryInMs: nextAttemptAt - t };
1999
- }
2000
- if (inFlight) {
2001
- return { attempted: false, ok: false, error: lastError, failures, retryInMs: 0 };
2002
- }
2003
- inFlight = true;
2004
- try {
2005
- const result = await push();
2006
- if (result && result.ok === false) {
2007
- return fail(result.reason || result.kind || 'push failed');
2008
- }
2009
- // Success resets the ladder: the next heartbeat goes straight out.
2010
- failures = 0;
2011
- nextAttemptAt = 0;
2012
- lastError = null;
2013
- return { attempted: true, ok: true, error: null, failures: 0, retryInMs: 0, result };
2014
- } catch (err) {
2015
- return fail(errorText(err));
2016
- } finally {
2017
- inFlight = false;
2018
- }
2019
- }
2020
-
2021
- /** Snapshot for the renderer: what failed and how long until the retry. */
2022
- function state() {
2023
- const t = now();
2024
- return {
2025
- failures,
2026
- lastError,
2027
- retryInMs: failures > 0 && nextAttemptAt > t ? nextAttemptAt - t : 0,
2028
- };
2029
- }
2030
-
2031
- return { run, state, recordFailure: (err) => fail(errorText(err)) };
2032
- }
2033
-
2034
- /**
2035
- * Register model:<name> and sync:<name> on ipcMain. `model:chat` is always
2036
- * streaming: deltas are pushed over CHAT_DELTA_CHANNEL exactly like the
2037
- * existing `aegis:chatCompletion` special case. `aegis` (the shared client)
2038
- * is optional — headless/offline callers omit it and every sync method falls
2039
- * back to its no-cloud branch.
2040
- *
2041
- * `model:listModels` and `sync:status` also fire a background retry push of
2042
- * any pending sessions (plan §7 "retry on a heartbeat"): fire-and-forget,
2043
- * never awaited, never throws — it just gives queued sessions another
2044
- * chance to sync without a dedicated poller. The retry is rate-limited by
2045
- * createHeartbeatRetry (exponential backoff, reset on success); its last error
2046
- * rides along on the sync:status result so the renderer can show it.
2047
- *
2048
- * `onReplyFinished`, like the identical param on registerIpc, is
2049
- * bootstrap()-only — it fires the native "reply ready" notification when the
2050
- * window is unfocused/hidden and is never set in the headless test path.
2051
- * `heartbeatOpts` ({ now, baseMs, maxMs }) is for tests only. `settingsDir`
2052
- * is Electron's userData path — where settings.json (and so the
2053
- * `__memoryPersist` preference the auto-push gate reads) actually lives;
2054
- * `sessionsDir` is the separate shared aegisHome() directory and must not be
2055
- * used for that gate. Falls back to `sessionsDir` for callers that don't pass
2056
- * it, i.e. tests that use one directory for everything.
2057
- */
2058
- function registerModelIpc(ipcMain, engine, sessionsDir, aegis, onReplyFinished, heartbeatOpts, settingsDir) {
2059
- const modelDispatch = createModelDispatch(engine);
2060
- const syncDispatch = createSyncDispatch(sessionStore, sessionsDir, aegis, settingsDir);
2061
-
2062
- const heartbeat = createHeartbeatRetry(() => syncDispatch.push(), heartbeatOpts);
2063
-
2064
- function heartbeatRetry() {
2065
- // run() classifies its own failures, so this catch is a last-resort guard
2066
- // (never an empty swallow): the reason is recorded for the banner either way.
2067
- const attempt = heartbeat.run();
2068
- if (attempt && typeof attempt.catch === 'function') {
2069
- attempt.catch((err) => heartbeat.recordFailure(err));
2070
- }
2071
- }
2072
-
2073
- for (const [name, handler] of Object.entries(modelDispatch)) {
2074
- if (name === 'chat') {
2075
- ipcMain.handle(`${MODEL_PREFIX}${name}`, (event, payload) => {
2076
- const opts = { ...(payload || {}) };
2077
- // The rung every renderer sends is low|medium|high; this is the one
2078
- // place a stale window can still hand us the retired `auto` (a page
2079
- // loaded before the select lost that row, or a renderer that forgot to
2080
- // resolve), and a falsy effort is NOT "the engine's default" — the
2081
- // shared engine states the field conditionally, so an absent value
2082
- // means "state no opinion" and the server sizes the turn itself. So it
2083
- // is normalised here rather than passed through: an unknown value
2084
- // becomes `high`, the same floor the engine and the CLI apply, and no
2085
- // turn can reach the wire with an effort nobody chose.
2086
- const effortRung =
2087
- opts.effort === 'low' || opts.effort === 'medium' ? opts.effort : 'high';
2088
- opts.effort = effortRung;
2089
- const sender = event && event.sender;
2090
- const forward = (chunk) => {
2091
- if (
2092
- sender &&
2093
- typeof sender.send === 'function' &&
2094
- !sender.isDestroyed()
2095
- ) {
2096
- // Two streams can be live at once (main answer + discovery lane),
2097
- // so each delta carries the sessionId it belongs to. See the
2098
- // matching note on the aegis:chatCompletion forwarder.
2099
- sender.send(CHAT_DELTA_CHANNEL, taggedChunk(chunk, opts.sessionId));
2100
- }
2101
- };
2102
- return withReplyNotify(
2103
- handler({ ...opts, onStream: forward }),
2104
- event,
2105
- onReplyFinished
2106
- );
2107
- });
2108
- continue;
2109
- }
2110
- if (name === 'listModels') {
2111
- ipcMain.handle(`${MODEL_PREFIX}${name}`, (_event, payload) => {
2112
- heartbeatRetry();
2113
- return handler(payload);
2114
- });
2115
- continue;
2116
- }
2117
- ipcMain.handle(`${MODEL_PREFIX}${name}`, (_event, payload) => handler(payload));
2118
- }
2119
-
2120
- // Diff-preview pre-edit read (preload.js `readTextFile`). Registered with
2121
- // `on` + `event.returnValue`, NOT `handle`: the bridge is `sendSync` because
2122
- // editPreview consumes its reader synchronously and cannot await an invoke
2123
- // promise, and `sendSync` is answered only by an `ipcMain.on` listener.
2124
- // Putting it in modelDispatch above would register a `handle` that sendSync
2125
- // never reaches. Fires on every edit tool's run frame, so the refusal path
2126
- // is the common case (a `writeFile` creating a new file) and must stay cheap.
2127
- ipcMain.on(`${MODEL_PREFIX}readTextFile`, (event, payload) => {
2128
- event.returnValue = readTextFileForPreview(payload);
2129
- });
2130
-
2131
- for (const [name, handler] of Object.entries(syncDispatch)) {
2132
- if (name === 'status') {
2133
- ipcMain.handle(`${SYNC_PREFIX}${name}`, (_event, payload) => {
2134
- heartbeatRetry();
2135
- const result = handler(payload);
2136
- // Additive: `retry` carries the last heartbeat failure and the wait
2137
- // until the next attempt, so a stuck sync is visible instead of
2138
- // swallowed. The local status fields are untouched.
2139
- return result && typeof result === 'object'
2140
- ? { ...result, retry: heartbeat.state() }
2141
- : result;
2142
- });
2143
- continue;
2144
- }
2145
- ipcMain.handle(`${SYNC_PREFIX}${name}`, (_event, payload) => handler(payload));
2146
- }
2147
-
2148
- return { modelDispatch, syncDispatch, heartbeat };
2149
- }
2150
-
2151
- // ─────────────────────────── the local autonomous work queue ───────────────
2152
- //
2153
- // `desktop/lib/local/queue.js` (the durable queue) and
2154
- // `desktop/lib/local/autonomous.js` (the worker that drives the engine's tool
2155
- // loop) shipped with no host using them. This is the desktop host's half: a
2156
- // `queue:` IPC surface, a settings-independent engine for the worker, and the
2157
- // one rule that makes the whole thing safe to leave in a GUI — NOTHING DRAINS
2158
- // UNLESS THE USER ASKED. There is no interval, no drain at startup, and no
2159
- // drain-on-idle anywhere in this file. Draining is spend (a full tool loop on a
2160
- // real model, in the user's checkout); a queue that starts itself when the
2161
- // window is left alone is a feature nobody consented to, so the only callers of
2162
- // `drain`/`proceed` are the two buttons on the renderer's queue card.
2163
-
2164
- /**
2165
- * A local engine for the queue worker: the same tool loop the interactive chat
2166
- * runs, with the tool-approval gate hard OFF.
2167
- *
2168
- * The gate exists to ask a human before exec/writeFile/editFile. A queued task
2169
- * is unattended BY DEFINITION — there is nobody to click — and engine.js's
2170
- * requestApproval() resolves only on a click or on an abort, so a gated engine
2171
- * here would mean a worker parked on an approval card forever (a `running`
2172
- * queue item, a held lock, and no record of why). Gate off is the honest
2173
- * configuration: the worker runs the tools it was asked to run, and the
2174
- * renderer's card says so in as many words.
2175
- */
2176
- function createQueueEngine(aegis, settings) {
2177
- return createLocalEngine({
2178
- aegis,
2179
- settings,
2180
- getConfirmMode: () => false,
2181
- });
2182
- }
2183
-
2184
- /**
2185
- * The second net under createQueueEngine's gate-off engine.
2186
- *
2187
- * If an approval request still reaches a drain — a caller that wired the
2188
- * interactive engine by mistake, or a future engine change — answer it here
2189
- * rather than leave a card nobody can click. The answer is always 'deny': a
2190
- * queued task may decide to run the tools it was configured to run, but it may
2191
- * never silently grant a permission the user reserves for a click. Denying lets
2192
- * the turn continue to its own conclusion, where autonomous.js — which watches
2193
- * the same `{ approval }` frame — settles the task as failed with the reason
2194
- * written down. A hang would have recorded nothing at all.
2195
- */
2196
- function queueEngineWithDeniedApprovals(engine) {
2197
- if (!engine || typeof engine.chat !== 'function') return engine;
2198
- const deny = (chunk) => {
2199
- const approval = chunk && chunk.approval;
2200
- if (!approval || !approval.id) return;
2201
- if (typeof engine.respondApproval !== 'function') return;
2202
- try {
2203
- engine.respondApproval(approval.id, 'deny');
2204
- } catch {
2205
- /* the turn's own error path reports a gate that cannot be answered */
2206
- }
2207
- };
2208
- // The third net under the same rule, for the round-horizon ask. A queued task
2209
- // is unattended, so nobody can fund another block and the answer is always
2210
- // "no" — which is precisely the engine's own non-interactive behaviour (it
2211
- // stops, files the session hold, and names the env knob). Answering `false`
2212
- // rather than leaving the promise pending is what keeps a worker from parking
2213
- // on a card nobody will ever click: an unanswered ask does resolve false
2214
- // (requestRoundsAsk fails safe), but waiting for the abort instead would look
2215
- // like a hung task in the queue for however long the turn ran.
2216
- const stopRounds = (chunk) => {
2217
- const ask = chunk && chunk.rounds;
2218
- if (!ask || !ask.id) return;
2219
- if (typeof engine.respondRoundsAsk !== 'function') return;
2220
- try {
2221
- engine.respondRoundsAsk(ask.id, false);
2222
- } catch {
2223
- /* as above: the turn reports a gate that cannot be answered */
2224
- }
2225
- };
2226
- return {
2227
- ...engine,
2228
- chat(payload, onDelta) {
2229
- return engine.chat(payload, (chunk) => {
2230
- deny(chunk);
2231
- stopRounds(chunk);
2232
- if (typeof onDelta === 'function') onDelta(chunk);
2233
- });
2234
- },
2235
- };
2236
- }
2237
-
2238
- /** True when `p` is an existing directory (the only path check this surface
2239
- * makes: a task's cwd is where its tool loop runs, so a typo there has to be
2240
- * refused now rather than fail minutes later inside a turn's output). */
2241
- function isExistingDir(p) {
2242
- try {
2243
- return fs.statSync(p).isDirectory();
2244
- } catch {
2245
- return false;
2246
- }
2247
- }
2248
-
2249
- /** An integer within [min,max], or null for "not stated". */
2250
- function clampInt(value, min, max) {
2251
- const n = Number(value);
2252
- if (!Number.isInteger(n) || n < min || n > max) return null;
2253
- return n;
2254
- }
2255
-
2256
- /**
2257
- * Pure mapping: `queue:<name>` -> queue.js + autonomous.js. Unit-testable in
2258
- * plain Node with a stub engine and a temp AEGIS_QUEUE_FILE, exactly like
2259
- * createIpcDispatch/createExportDispatch above — `engine` is injected (the
2260
- * real one comes from createQueueEngine in bootstrap()), and every path the
2261
- * queue touches comes from `env`.
2262
- *
2263
- * The methods are thin: `enqueue`/`retry`/`remove`/`clear` are the queue file's
2264
- * own vocabulary, and `drain`/`proceed` are one and many `worker.proceed({max:
2265
- * 1})` ticks. The worker itself owns the lock, the digest, the round horizon
2266
- * and the settle-back-into-the-file write; none of that is re-implemented here.
2267
- *
2268
- * `stop` is the renderer's Stop button. It has to be its own method rather than
2269
- * "cancel the current turn": cancelling a turn leaves the next pending task to
2270
- * start immediately, which is not what a user who just pressed Stop asked for.
2271
- */
2272
- function createQueueDispatch({
2273
- engine,
2274
- env = process.env,
2275
- queue = queueModule,
2276
- autonomous = autonomousModule,
2277
- // Phase 4 of docs/upgrade-conversion-plan.md: the Pro boundary for the queue.
2278
- // Injected so a test can hand in a stub verdict, and defaulted to a REAL
2279
- // gate that probes nothing (an unresolved gate is the free boundary) rather
2280
- // than to "no gate at all" — a caller that forgets to pass one must not get
2281
- // the free fan-out.
2282
- entitlement = entitlementModule.createEntitlement({}),
2283
- log = () => {},
2284
- } = {}) {
2285
- if (!engine || typeof engine.chat !== 'function') {
2286
- throw new Error('createQueueDispatch: a local engine with chat() is required');
2287
- }
2288
-
2289
- // The task the current drain is working, learned from the worker's own
2290
- // start/finish frames, so `stop` can cancel the turn actually in flight.
2291
- let running = null;
2292
- // Set by `stop`, read between ticks by drainAll. Reset when a drain starts.
2293
- let stopRequested = false;
2294
- // This window's drain. queue.js's file lock is what stops a SECOND process;
2295
- // this flag stops a second click in the same window from queueing up a second
2296
- // loop (which would only be refused by the lock after a round trip).
2297
- let draining = false;
2298
-
2299
- const emit = (event) => {
2300
- if (event && event.type === 'start') running = event.taskId;
2301
- if (event && event.type === 'finish') running = null;
2302
- log(event);
2303
- };
2304
-
2305
- const worker = autonomous.createQueueWorker({
2306
- engine: queueEngineWithDeniedApprovals(engine),
2307
- env,
2308
- log: emit,
2309
- // The drain-time half of the gate: a task that asks for the fan-out but
2310
- // belongs to a session that is not entitled to it runs as a SINGLE PASS
2311
- // (autonomous.js says so out loud over a `note` frame). This is what closes
2312
- // the path the enqueue check cannot see — a `singlePass: false` task queued
2313
- // by the CLI or written into queue.jsonl by hand, which is already in the
2314
- // file before main.js is asked anything. Read fresh per task from the cached
2315
- // verdict, so a purchase mid-drain upgrades the very next task.
2316
- canFanOut: () => entitlement.current().entitled,
2317
- });
2318
-
2319
- /** What the renderer paints from: the queue file, the run log, and whether
2320
- * this window is currently draining. Read fresh on every call — the queue is
2321
- * a file several hosts write to, so a cached copy would show a stale list. */
2322
- function snapshot() {
2323
- const items = queue.loadQueue(env);
2324
- return {
2325
- ok: true,
2326
- items,
2327
- pending: queue.pendingCount(items),
2328
- running: items.find((i) => i && i.status === 'running') || null,
2329
- draining,
2330
- stopping: stopRequested,
2331
- defaultCwd: process.cwd(),
2332
- // What a task with no model of its own, on the Aegis Cloud class, actually
2333
- // runs on — a pooled tier id. byok and local tasks always carry an
2334
- // explicit model (see autonomous.js modelRefusal), so there is no
2335
- // equivalent default to report for those classes. The card prints this
2336
- // because "which model is this drain spending on" was previously
2337
- // unanswerable from the UI.
2338
- defaultModel: autonomous.resolveModel({ env }),
2339
- queueFile: queue.queuePath(env),
2340
- runs: queue.readRuns(env, { limit: 20 }),
2341
- // What this session may spend on (Phase 4). The renderer paints the
2342
- // boundary from THIS and decides nothing itself: the fan-out control, the
2343
- // #queue-hint line and the refusal link are all state it was told.
2344
- entitlement: entitlement.current(),
2345
- };
2346
- }
2347
-
2348
- /**
2349
- * Coerce one renderer payload into queue.addTask's opts.
2350
- *
2351
- * There is no shell field on this surface and no path the main process reads
2352
- * or writes on the renderer's behalf: `cwd` is the directory the task's own
2353
- * tool loop runs in, it must be absolute and already exist, and it is passed
2354
- * through unmodified. model/effort/workers/maxRounds are ids, rungs and small
2355
- * integers — never commands — and every one of them is range-checked here so
2356
- * a malformed payload is refused with a reason instead of queued as work that
2357
- * silently runs with defaults.
2358
- */
2359
- function taskOpts(payload) {
2360
- const p = payload || {};
2361
- const task = String(p.task == null ? '' : p.task).trim();
2362
- if (!task) return { error: 'a queued task needs text' };
2363
- if (task.length > QUEUE_MAX_TASK_CHARS) {
2364
- return { error: `task text is too long (${task.length} > ${QUEUE_MAX_TASK_CHARS} chars)` };
2365
- }
2366
- const cwd = String(p.cwd == null ? '' : p.cwd).trim() || process.cwd();
2367
- if (!path.isAbsolute(cwd)) return { error: 'the working directory must be an absolute path' };
2368
- if (!isExistingDir(cwd)) return { error: `not a directory: ${cwd}` };
2369
-
2370
- const cls = autonomous.normalizeClass(p.class);
2371
- const model = String(p.model == null ? '' : p.model).trim().slice(0, 120) || null;
2372
- // Refused HERE so the card gets the reason at the moment of queueing
2373
- // instead of a red row later. queue.addTask enforces the same (model,
2374
- // class) rule for the CLI and for hand-written queue files — see
2375
- // autonomous.js modelRefusal for what each class requires.
2376
- const modelRefusal = autonomous.modelRefusal(model, cls);
2377
- if (modelRefusal) return { error: modelRefusal };
2378
- const effortRaw = String(p.effort == null ? '' : p.effort).trim().toLowerCase();
2379
- return {
2380
- opts: {
2381
- task,
2382
- cwd,
2383
- class: cls,
2384
- model,
2385
- effort: QUEUE_EFFORTS.has(effortRaw) ? effortRaw : null,
2386
- workers: clampInt(p.workers, 1, QUEUE_MAX_WORKERS),
2387
- maxRounds: clampInt(p.maxRounds, 1, QUEUE_MAX_ROUNDS),
2388
- // The pooled-brain fan-out is opt-in (autonomous.js resolveFanout):
2389
- // it costs about workers+1 full reasoning calls on one task, so the
2390
- // card has to ask for it per task. queue.addTask stores the inverse
2391
- // (`singlePass`), which is also the field a hand-written queue file
2392
- // or the CLI uses. Fan-out is an Aegis Cloud mechanism only — a
2393
- // byok/local task that somehow carried `fanout: true` is NOT honoured
2394
- // as a fan-out here (autonomous.js's runTask forces it off for any
2395
- // non-aegis class regardless of this flag), but it is still recorded
2396
- // as the task's own ask rather than silently rewritten, matching
2397
- // resolveFanout's "a wrong pick is reported, not rewritten" rule.
2398
- singlePass: !(p.fanout === true || p.autonomous === true),
2399
- commit: Boolean(p.commit),
2400
- source: 'desktop',
2401
- },
2402
- };
2403
- }
2404
-
2405
- /** `settle`-shaped one-liner per finished task, so the drain's answer carries
2406
- * the outcome of every task it ran rather than only the last one. */
2407
- const summarize = (ran) =>
2408
- (ran || []).map((r) => ({ id: r.id, ok: Boolean(r.ok), error: r.error || null }));
2409
-
2410
- // Note on `commit`: neither drain accepts a commit override from the
2411
- // renderer. Whether a task commits is a property of the TASK (the checkbox it
2412
- // was queued with, carried in the queue file), so `worker.proceed` is called
2413
- // with the flag undefined and autonomous.js falls back to each item's own
2414
- // `commit`. A drain-level flag would let one click rewrite another task's
2415
- // decision.
2416
-
2417
- async function drainOnce() {
2418
- if (draining) return { ok: false, reason: 'a drain is already running in this window', ...snapshot() };
2419
- draining = true;
2420
- stopRequested = false;
2421
- let result;
2422
- try {
2423
- const tick = await worker.proceed({ max: 1 });
2424
- result = tick.locked
2425
- ? { ok: false, locked: true, holder: tick.holder, ran: [] }
2426
- : { ok: true, ran: summarize(tick.ran) };
2427
- } catch (err) {
2428
- // A drain that throws must still leave a readable answer (and a queue
2429
- // that can be inspected), not a rejected IPC promise the renderer would
2430
- // paint as "Invoke failed".
2431
- result = { ok: false, reason: errorText(err), ran: [] };
2432
- }
2433
- draining = false;
2434
- running = null;
2435
- return { ...result, ...snapshot() };
2436
- }
2437
-
2438
- async function drainAll() {
2439
- if (draining) return { ok: false, reason: 'a drain is already running in this window', ...snapshot() };
2440
- draining = true;
2441
- stopRequested = false;
2442
- const ran = [];
2443
- let result;
2444
- try {
2445
- for (;;) {
2446
- // One task per tick through the worker's own proceed: it re-reads the
2447
- // FILE each time (so a task added mid-drain is picked up) and takes and
2448
- // releases the lock around each task, which is what lets Stop land
2449
- // between two tasks instead of after the whole queue.
2450
- const tick = await worker.proceed({ max: 1 });
2451
- if (tick.locked) {
2452
- result = { ok: false, locked: true, holder: tick.holder, ran: summarize(ran) };
2453
- break;
2454
- }
2455
- if (!tick.ran.length) {
2456
- result = { ok: true, stopped: stopRequested, ran: summarize(ran) };
2457
- break;
2458
- }
2459
- ran.push(...tick.ran);
2460
- if (stopRequested) {
2461
- result = { ok: true, stopped: true, ran: summarize(ran) };
2462
- break;
2463
- }
2464
- }
2465
- } catch (err) {
2466
- result = { ok: false, reason: errorText(err), ran: summarize(ran) };
2467
- }
2468
- draining = false;
2469
- running = null;
2470
- return { ...result, ...snapshot() };
2471
- }
2472
-
2473
- return {
2474
- /** Queue one task. */
2475
- enqueue: async (payload) => {
2476
- // Re-probe first (a no-op while the cached verdict is fresh), so a user
2477
- // who bought Pro and came straight back is not refused with a verdict
2478
- // from before they paid.
2479
- await entitlement.refresh();
2480
- // THE GATE. It runs here, in main, on the renderer's own numbers — a
2481
- // renderer that never renders the fan-out control (or one driven from the
2482
- // devtools console) is refused here just the same, because nothing in the
2483
- // renderer is trusted to enforce anything.
2484
- const gate = entitlement.gateFanout({
2485
- workers: clampInt(payload && payload.workers, 1, QUEUE_MAX_WORKERS),
2486
- fanout: Boolean(payload && payload.fanout === true),
2487
- });
2488
- if (!gate.ok) return { ...gate, ...snapshot() };
2489
- const { opts, error } = taskOpts(payload);
2490
- if (error) return { ok: false, reason: error, ...snapshot() };
2491
- const item = queue.addTask(env, opts);
2492
- return { ok: true, item, ...snapshot() };
2493
- },
2494
-
2495
- /** Current state: the list, what is running, what is pending, recent runs. */
2496
- list: () => snapshot(),
2497
-
2498
- /** What this session is entitled to (Phase 4). `force: true` re-probes
2499
- * instead of answering from the cache — the card's "check again" path, and
2500
- * what the renderer calls on boot so the boundary line is never a guess. */
2501
- entitlement: async (payload) => {
2502
- await entitlement.refresh({ force: Boolean(payload && payload.force === true) });
2503
- return { ok: true, ...snapshot() };
2504
- },
2505
-
2506
- /** Drop finished tasks (or everything with `all`). Pending and running tasks
2507
- * are kept — queue.clearQueue's rule, not this surface's. */
2508
- clear: (payload) => ({
2509
- ...queue.clearQueue(env, { all: Boolean(payload && payload.all) }),
2510
- ok: true,
2511
- ...snapshot(),
2512
- }),
2513
-
2514
- /** Put a finished/failed task back in line, keeping its attempt count. */
2515
- retry: (payload) => {
2516
- const id = payload && payload.id;
2517
- const item = queue.retryTask(env, id);
2518
- if (!item) return { ok: false, reason: `no task #${id}`, ...snapshot() };
2519
- return { ok: true, item, ...snapshot() };
2520
- },
2521
-
2522
- /** Remove one task by id. A running task is refused: the worker holds it in
2523
- * memory and writes its outcome back, so deleting it mid-turn would just be
2524
- * re-created by that settle — stop it first. */
2525
- remove: (payload) => {
2526
- const id = payload && payload.id;
2527
- const items = queue.loadQueue(env);
2528
- const item = queue.findTask(items, id);
2529
- if (!item) return { ok: false, reason: `no task #${id}`, ...snapshot() };
2530
- if (item.status === 'running') {
2531
- return { ok: false, reason: `task #${item.id} is running — stop the drain first`, ...snapshot() };
2532
- }
2533
- queue.saveQueue(env, items.filter((i) => Number(i && i.id) !== Number(item.id)));
2534
- return { ok: true, removed: item.id, ...snapshot() };
2535
- },
2536
-
2537
- /** Work exactly one pending task (the card's "Run one"). */
2538
- drain: () => drainOnce(),
2539
-
2540
- /** Work pending tasks until the queue is empty, or Stop is pressed. */
2541
- proceed: () => drainAll(),
2542
-
2543
- /** Stop: cancel the turn in flight AND the loop, so the next pending task
2544
- * does not simply start. */
2545
- stop: (payload) => {
2546
- stopRequested = true;
2547
- const id = payload && payload.id != null ? payload.id : running;
2548
- const cancel = id == null ? null : worker.cancel(id);
2549
- return { ok: true, wasDraining: draining, cancelled: cancel, ...snapshot() };
2550
- },
2551
- };
2552
- }
2553
-
2554
- /**
2555
- * The two opt-in automations, as an IPC surface (`auto:<name>`).
2556
- *
2557
- * lib/auto.js owns the TIMING; this owns the ANSWERS the renderer gets, and
2558
- * deliberately nothing else: it has no timer of its own, it does not read the
2559
- * settings store (the automations do, on every tick), and it does not touch the
2560
- * queue file or a record (the queue worker and lib/fingerprint.js do). Split
2561
- * out as a pure function so a test can drive it with stub automations and a
2562
- * fake ipcMain, exactly like createQueueDispatch.
2563
- *
2564
- * `sealNow` is the panel's explicit "seal this session" shortcut through the
2565
- * AUTOMATION rather than through the fingerprint service directly, so the
2566
- * pending-turn counter is reset by the same call that files the record — a
2567
- * manual seal followed by the timer must not file the same session twice.
2568
- */
2569
- function createAutoDispatch({ autoDrain, autoMint } = {}) {
2570
- if (!autoDrain || typeof autoDrain.state !== 'function') {
2571
- throw new Error('createAutoDispatch: the auto-drain automation is required');
2572
- }
2573
- if (!autoMint || typeof autoMint.state !== 'function') {
2574
- throw new Error('createAutoDispatch: the auto-mint automation is required');
2575
- }
2576
- return {
2577
- /** Both switches and both live states, in one answer. */
2578
- autoStatus: () => ({ ok: true, drain: autoDrain.state(), mint: autoMint.state() }),
2579
- autoSetDrain: (payload) => {
2580
- const p = payload || {};
2581
- const patch = {};
2582
- if (p.enabled != null) patch.enabled = Boolean(p.enabled);
2583
- if (p.intervalMs != null) patch.intervalMs = Number(p.intervalMs);
2584
- try {
2585
- return { ok: true, drain: autoDrain.set(patch) };
2586
- } catch (err) {
2587
- return { ok: false, error: errorText(err) };
2588
- }
2589
- },
2590
- autoSetMint: (payload) => {
2591
- const p = payload || {};
2592
- const patch = {};
2593
- if (p.enabled != null) patch.enabled = Boolean(p.enabled);
2594
- if (p.publish != null) patch.publish = Boolean(p.publish);
2595
- try {
2596
- return { ok: true, mint: autoMint.set(patch) };
2597
- } catch (err) {
2598
- return { ok: false, error: errorText(err) };
2599
- }
2600
- },
2601
- /** Seal the open session now, ahead of the quiet timer. Resets the counter
2602
- * either way: a seal that failed is still not something to retry from a
2603
- * timer that was about to fire on the same turns, and the panel shows the
2604
- * failure. */
2605
- autoSealNow: async (payload) => {
2606
- const p = payload || {};
2607
- if (p.publish != null) autoMint.set({ publish: Boolean(p.publish) });
2608
- try {
2609
- const result = await autoMint.flush();
2610
- autoMint.reset();
2611
- return { ok: true, ...result };
2612
- } catch (err) {
2613
- autoMint.reset();
2614
- return { ok: false, error: errorText(err) };
2615
- }
2616
- },
2617
- };
2618
- }
2619
-
2620
- /**
2621
- * Register the queue surface as `queue:<name>` on ipcMain — the same
2622
- * per-namespace pattern as MODEL_PREFIX/SYNC_PREFIX/QUICK_PREFIX above, with
2623
- * one addition: every handler keeps the raw IPC event, because a drain's
2624
- * progress frames are pushed to the window that ASKED for it, over
2625
- * QUEUE_PROGRESS_CHANNEL (never CHAT_DELTA_CHANNEL — see that constant).
2626
- *
2627
- * Every handler, not just drain/proceed: with the auto-drain switch armed the
2628
- * drain is started by a TIMER, so there is no "window that asked for it" — the
2629
- * sink has to be whichever window last touched this surface (the card that
2630
- * armed the switch, or the one that just read queue.list()). Leaving only
2631
- * drain/proceed to set `target` would make an automatic drain's progress
2632
- * invisible in exactly that case, and an invisible drain reads as a hang.
2633
- */
2634
- function registerQueueIpc(ipcMain, { engine, env, queue, autonomous, entitlement, aegis } = {}) {
2635
- // The window a drain's frames go to. One drain runs at a time process-wide
2636
- // (queue.js's lock), so a single target is enough, and a second window's
2637
- // drain is refused by the lock rather than interleaving two progress streams.
2638
- let target = null;
2639
- // Pro boundary (Phase 4). The signal is the account's own plan, read from the
2640
- // endpoint the app already calls to verify a key — aegis1 `/api/verify-api-key`
2641
- // answers `{ valid, plan, … }` (its docstring says so), and `plan` is the
2642
- // column aegis1's billing_service normalizes its PAID_PLANS against. Read-only,
2643
- // one small POST per TTL, nothing written server-side, no new billing system.
2644
- //
2645
- // What this does NOT cover, stated here so it cannot be forgotten: the CLI's
2646
- // own drain (`aegiscode autonomous`) is a different host with no session to
2647
- // ask, so the fan-out ceiling is enforced for the DESKTOP queue only.
2648
- const resolved = entitlement || entitlementModule.createEntitlement({
2649
- // Phase 6: entitlement is TWO readings, not one. `/api/verify-api-key`
2650
- // answers the plan; `/api/token-bank/balance` answers the prepaid bank, and
2651
- // the bank is the operator's lever — aegis1 app.py: "Pool-key users with
2652
- // token bank balance skip legacy credit checks." Either one clears the
2653
- // wall. A failed balance call leaves the balance UNKNOWN rather than
2654
- // inventing a zero, so a network blip cannot manufacture a paywall prompt;
2655
- // classifyProbe then fails closed on the unknown.
2656
- probe: aegis && typeof aegis.verifyApiKey === 'function'
2657
- ? async () => {
2658
- const verify = await aegis.verifyApiKey();
2659
- const body = verify && typeof verify === 'object' ? { ...verify } : {};
2660
- if (typeof aegis.tokenBankBalance === 'function') {
2661
- try {
2662
- const bank = await aegis.tokenBankBalance();
2663
- if (bank && typeof bank === 'object') {
2664
- if (bank.balance_micros != null) body.balance_micros = bank.balance_micros;
2665
- else if (bank.balance_eur != null) body.balance_eur = bank.balance_eur;
2666
- }
2667
- } catch {
2668
- // Unknown stays unknown. No grant, no wall invented from a blip.
2669
- }
2670
- }
2671
- return body;
2672
- }
2673
- : undefined,
2674
- });
2675
- const dispatch = createQueueDispatch({
2676
- engine,
2677
- env,
2678
- queue,
2679
- autonomous,
2680
- entitlement: resolved,
2681
- log: (event) => {
2682
- if (!target || typeof target.send !== 'function') return;
2683
- if (typeof target.isDestroyed === 'function' && target.isDestroyed()) return;
2684
- target.send(QUEUE_PROGRESS_CHANNEL, event);
2685
- },
2686
- });
2687
- for (const [name, handler] of Object.entries(dispatch)) {
2688
- ipcMain.handle(`${QUEUE_PREFIX}${name}`, (event, payload) => {
2689
- target = (event && event.sender) || null;
2690
- return handler(payload);
2691
- });
2692
- }
2693
- return dispatch;
2694
- }
2695
-
2696
- /**
2697
- * Register the automation surface as `auto:<name>` on ipcMain.
2698
- *
2699
- * Unlike the queue, no handler here needs the IPC event: these calls are
2700
- * answers about a switch, not a stream, and the one long-running one
2701
- * (`sealNow`) resolves its own result. Nothing is pushed to the renderer from
2702
- * this file — the card and the panel re-read status after every action, which
2703
- * is also what makes a stale paint impossible.
2704
- */
2705
- function registerAutoIpc(ipcMain, dispatch) {
2706
- for (const [name, handler] of Object.entries(dispatch)) {
2707
- ipcMain.handle(`${AUTO_PREFIX}${name}`, (_event, payload) => handler(payload));
2708
- }
2709
- return dispatch;
2710
- }
2711
-
2712
- /**
2713
- * Main -> renderer push channel for auto-update status (mirrors
2714
- * CHAT_DELTA_CHANNEL): the renderer's update banner listens here instead of
2715
- * polling `aegis:updateStatus`.
2716
- */
2717
- const UPDATE_STATUS_CHANNEL = `${IPC_PREFIX}updateStatus`;
2718
- const ACCOUNT_STATUS_CHANNEL = `${IPC_PREFIX}accountStatusChanged`;
2719
-
2720
- /** Re-check for a new release every few hours — frequent enough that a
2721
- * long-lived session still notices a release, rare enough to not hammer
2722
- * the GitHub Releases API. */
2723
- const UPDATE_CHECK_INTERVAL_MS = 4 * 60 * 60 * 1000;
2724
-
2725
- /**
2726
- * Auto-update state machine over electron-updater (GitHub Releases provider,
2727
- * configured via desktop/electron-builder.yml `publish:` — see main.js's
2728
- * top-of-file note and that file's comment for the feed this points at).
2729
- *
2730
- * Deliberately inert outside a packaged app: `electron .` in dev has no
2731
- * app-update.yml (electron-builder only writes one at build time), so
2732
- * calling checkForUpdates() there would just throw on every launch. Rather
2733
- * than special-case that error, `isPackaged: false` skips wiring the real
2734
- * autoUpdater entirely and every method resolves a harmless 'disabled'
2735
- * state — dev never touches the network or the update machinery.
2736
- *
2737
- * `autoDownload` stays false: checking happens automatically (on ready and
2738
- * on the interval below), but the multi-hundred-MB download itself only
2739
- * starts when the renderer's banner "Download" button calls download() —
2740
- * see the IPC dispatch below. This keeps every step user-visible and
2741
- * matches the "never auto-restart without consent" rule: quitAndInstall()
2742
- * is likewise only ever invoked by an explicit "Restart to install" click.
2743
- */
2744
-
2745
- /**
2746
- * Update state backed by the npm registry, for installs that are not packaged
2747
- * electron builds (i.e. `npm i -g aegis-desktop`).
2748
- *
2749
- * Mirrors the electron-updater manager's shape exactly so the IPC dispatch and
2750
- * the renderer banner need no branch: status/check/download/quitAndInstall/
2751
- * start. `download` and `quitAndInstall` are no-ops that report the install
2752
- * command instead of acting — npm owns installation here, and silently
2753
- * swapping the package under a running process is not something to do on the
2754
- * user's behalf.
2755
- */
2756
- /**
2757
- * How long to wait before re-checking after a FAILED background check: 1m, 5m,
2758
- * 15m, then hourly.
2759
- *
2760
- * The 4h interval is right for a successful check — the answer changes slowly —
2761
- * and wrong for a failure, whose usual cause (no network yet at login, a VPN
2762
- * still coming up, a one-off registry blip) is gone inside a minute. Before
2763
- * this, a machine that launched before its Wi-Fi associated showed a hard error
2764
- * for the next four hours.
2765
- */
2766
- const UPDATE_RETRY_BACKOFF_MS = [60_000, 5 * 60_000, 15 * 60_000, 60 * 60_000];
2767
-
2768
- /**
2769
- * The sentence the banner shows. Every one of these used to be the single fixed
2770
- * string "registry unreachable" — including a 404 and a 2.5s timeout, neither of
2771
- * which is evidence the registry was unreachable. Only the last two are claims
2772
- * about the network, and only when the socket actually failed.
2773
- */
2774
- function describeRegistryFailure(reason, status, pkg) {
2775
- switch (reason) {
2776
- case 'not-found':
2777
- return `${pkg} is not published on the npm registry (404)`;
2778
- case 'forbidden':
2779
- return `the npm registry refused the request (${status || 'auth'})`;
2780
- case 'malformed':
2781
- return 'the npm registry sent a response this app could not read';
2782
- case 'too-large':
2783
- return 'the npm registry response was too large to read';
2784
- case 'timeout':
2785
- return 'the npm registry did not respond in time';
2786
- case 'network':
2787
- return 'the npm registry could not be reached';
2788
- default:
2789
- return 'the update check did not complete';
2790
- }
2791
- }
2792
-
2793
- function createNpmUpdateManager({
2794
- onStatus,
2795
- now = () => Date.now(),
2796
- fetchImpl,
2797
- setTimeoutImpl = setTimeout,
2798
- clearTimeoutImpl = clearTimeout,
2799
- // Both injectable so the install path is testable without running npm or
2800
- // killing the test runner's own process.
2801
- runUpgrade,
2802
- relaunch,
2803
- } = {}) {
2804
- const PKG = 'aegis-desktop';
2805
- let current = 'unknown';
2806
- try {
2807
- current = require('./package.json').version;
2808
- } catch {
2809
- /* version is cosmetic here */
2810
- }
2811
- // `reason`/`transient`/`attempts`/`retryInMs`/`canDownload` are additive: the
2812
- // banner needs them to decide whether a failure deserves the user's
2813
- // attention at all, and whether there is a button worth offering.
2814
- let state = {
2815
- status: 'idle',
2816
- version: null,
2817
- error: null,
2818
- channel: 'npm',
2819
- reason: null,
2820
- transient: false,
2821
- attempts: 0,
2822
- retryInMs: 0,
2823
- command: null,
2824
- // `plan` is the shared verdict the banner was rendered from. `download()`
2825
- // installs from THIS object rather than re-deriving a command, so the
2826
- // button can only ever run what the banner promised.
2827
- plan: null,
2828
- method: null,
2829
- canDownload: false,
2830
- };
2831
- const set = (patch) => {
2832
- state = { ...state, ...patch };
2833
- if (onStatus) onStatus(state);
2834
- return state;
2835
- };
2836
-
2837
- // Same repo-then-vendor idiom every other shared module here uses.
2838
- let updater = null;
2839
- try {
2840
- updater = require('../client/update.js');
2841
- } catch {
2842
- try {
2843
- updater = require('./vendor/update.js');
2844
- } catch {
2845
- updater = null;
2846
- }
2847
- }
2848
-
2849
- let attempts = 0;
2850
- let retryAt = 0;
2851
- let retryTimer = null;
2852
- let inFlight = null;
2853
- let interval = null;
2854
-
2855
- /**
2856
- * How THIS desktop copy was installed: 'npm' | 'pip' | 'npx' | 'source'.
2857
- *
2858
- * Memoised — it cannot change while the process runs. `entry` is the app
2859
- * directory: an unpackaged run is either a global npm install (a path with
2860
- * `node_modules` in it), a checkout (`desktop/` beside `.git`), or a pip
2861
- * launcher, which declares itself through AEGIS_INSTALL_METHOD because
2862
- * nothing in the filesystem would otherwise say so.
2863
- */
2864
- let methodCache = null;
2865
- function detectMethod() {
2866
- if (methodCache) return methodCache;
2867
- const detect = updater && updater.detectInstallMethod;
2868
- if (typeof detect !== 'function') { methodCache = 'npm'; return methodCache; }
2869
- try {
2870
- methodCache = detect({
2871
- env: process.env,
2872
- argv: process.argv,
2873
- pkg: PKG,
2874
- entry: __dirname,
2875
- fileExists: (rel) => fs.existsSync(path.resolve(__dirname, '..', rel)),
2876
- });
2877
- } catch {
2878
- methodCache = 'npm';
2879
- }
2880
- return methodCache;
2881
- }
2882
-
2883
- /** 1m, 5m, 15m, 60m, 60m… — capped, never unbounded. */
2884
- const delayFor = () =>
2885
- UPDATE_RETRY_BACKOFF_MS[Math.min(Math.max(attempts - 1, 0), UPDATE_RETRY_BACKOFF_MS.length - 1)];
2886
-
2887
- function clearRetry() {
2888
- if (retryTimer) clearTimeoutImpl(retryTimer);
2889
- retryTimer = null;
2890
- }
2891
-
2892
- /** One silent re-check after a transient failure, instead of a 4h wait. */
2893
- function scheduleRetry() {
2894
- clearRetry();
2895
- const delay = delayFor();
2896
- retryAt = now() + delay;
2897
- retryTimer = setTimeoutImpl(() => {
2898
- retryTimer = null;
2899
- check().catch(() => {});
2900
- }, delay);
2901
- if (retryTimer && retryTimer.unref) retryTimer.unref();
2902
- set({ retryInMs: delay });
2903
- }
2904
-
2905
- /**
2906
- * Always a detailed result — and, since AEGISONE (D7), always from the ONE
2907
- * shared check.
2908
- *
2909
- * This function used to ask the registry itself (`fetchLatestDetailed`, with
2910
- * a `fetchLatest` fallback), which made the desktop a second place that knew
2911
- * how to fetch a published version: a second timeout, a second failure
2912
- * taxonomy, and a banner that could disagree with `aegiscode`'s. It now
2913
- * consumes `client/update.js::checkPublished` — the same probe, the same
2914
- * 2.5s timeout and the same `REASONS` as the CLI's notice and `aegis doctor`.
2915
- *
2916
- * `intervalMs: 0` is deliberate: the *schedule* stays this manager's (it
2917
- * polls every `UPDATE_CHECK_INTERVAL_MS` and drives its retry ladder off the
2918
- * result), but the *check* is not re-implemented here. The desktop keeps its
2919
- * applier — `electron-updater` below — and loses its own version fetch.
2920
- */
2921
- async function fetchDetailed() {
2922
- if (fetchImpl) return fetchImpl({ pkg: PKG });
2923
- if (updater && typeof updater.checkPublished === 'function') {
2924
- const res = await updater.checkPublished({ current, pkg: PKG, now: now(), intervalMs: 0 });
2925
- return res
2926
- ? { ok: res.ok, latest: res.latest, reason: res.reason, status: res.status }
2927
- : { ok: false, latest: null, reason: 'unknown', status: null };
2928
- }
2929
- // The shared checker is present but predates `checkPublished`. The vendored
2930
- // copy ships in the same bundle as this file, so this is a repackaging
2931
- // accident rather than a supported older release — and the honest answer is
2932
- // "the checker is unavailable", not a private fetching path that would
2933
- // reintroduce the second check D7 exists to delete.
2934
- return { ok: false, latest: null, reason: 'unavailable', status: null };
2935
- }
2936
-
2937
- const isTransient = (reason, status) => {
2938
- if (typeof updater.isTransientReason === 'function') return updater.isTransientReason(reason, status);
2939
- return reason === 'unknown'; // unclassified: assume retryable, stay quiet
2940
- };
2941
-
2942
- /**
2943
- * `announce: false` (the default) is the background check: a network failure
2944
- * is swallowed and re-tried on the backoff ladder. `announce: true` is a
2945
- * check the user explicitly asked for (the app menu, the banner's Retry
2946
- * button) — they are looking at the answer, so they get it.
2947
- *
2948
- * Coalesced: the interval, the menu, the Retry button and the renderer's
2949
- * initial status poll can all land together, and three registry hits for one
2950
- * question is pointless.
2951
- */
2952
- async function check({ announce = false } = {}) {
2953
- if (!updater) return set({ status: 'disabled', error: 'update checker unavailable' });
2954
- if (inFlight) return inFlight;
2955
-
2956
- inFlight = (async () => {
2957
- // What we already knew, so a transient failure cannot erase it.
2958
- const priorStatus = state.status;
2959
- const priorVersion = state.version;
2960
- set({ status: 'checking', error: null, reason: null, transient: false });
2961
-
2962
- let res;
2963
- try {
2964
- res = await fetchDetailed();
2965
- } catch (err) {
2966
- res = { ok: false, latest: null, reason: 'unknown', status: null, error: errorText(err) };
2967
- }
2968
-
2969
- if (res.ok) {
2970
- attempts = 0;
2971
- retryAt = 0;
2972
- clearRetry();
2973
- if (!updater.isNewer(res.latest, current)) {
2974
- return set({
2975
- status: 'up-to-date', version: res.latest, error: null, reason: null,
2976
- transient: false, attempts: 0, retryInMs: 0,
2977
- });
2978
- }
2979
- // The npm applier's instruction comes from the ONE shared verdict
2980
- // (`updatePlan`), the same call `aegiscode`'s notice and `aegis doctor`
2981
- // render — so the banner cannot offer a command the terminal would not.
2982
- //
2983
- // `method` is this copy's install channel, detected the same way the
2984
- // CLI does. It matters because `npm i -g` against a pip-installed
2985
- // aegis-desktop writes a second copy beside the one the user actually
2986
- // runs: the button would appear to work and change nothing.
2987
- const method = detectMethod();
2988
- const plan = typeof updater.updatePlan === 'function'
2989
- ? updater.updatePlan({ current, latest: res.latest, behind: true, channel: 'npm', pkg: PKG, method })
2990
- : null;
2991
- return set({
2992
- status: 'available',
2993
- version: res.latest,
2994
- error: null,
2995
- reason: null,
2996
- transient: false,
2997
- attempts: 0,
2998
- retryInMs: 0,
2999
- plan,
3000
- method,
3001
- command: (plan && plan.instruction) || `npm i -g ${PKG}@latest`,
3002
- // A channel with an `apply` argv has something this process can run;
3003
- // `npx` and a source checkout do not, and offering a button that
3004
- // silently does nothing is worse than the command alone.
3005
- canDownload: Boolean(plan && plan.apply),
3006
- });
3007
- }
3008
-
3009
- attempts += 1;
3010
- const message = res.error || describeRegistryFailure(res.reason, res.status, PKG);
3011
-
3012
- if (isTransient(res.reason, res.status)) {
3013
- scheduleRetry();
3014
- if (!announce) {
3015
- // Nobody asked. The network was not there yet; that is not news, and
3016
- // an 'available' verdict we already earned must survive it.
3017
- return set({
3018
- status: priorStatus === 'available' ? 'available' : 'unavailable',
3019
- version: priorVersion,
3020
- error: message,
3021
- reason: res.reason,
3022
- transient: true,
3023
- attempts,
3024
- });
3025
- }
3026
- return set({ status: 'error', error: message, reason: res.reason, transient: true, attempts });
3027
- }
3028
-
3029
- // The registry answered and the answer was no (404, 403, unreadable). That
3030
- // is a real condition, it will not fix itself in a minute, and only the
3031
- // user can act on it — so it is shown either way, named accurately.
3032
- clearRetry();
3033
- retryAt = 0;
3034
- return set({
3035
- status: 'error', error: message, reason: res.reason,
3036
- transient: false, attempts, retryInMs: 0,
3037
- });
3038
- })();
3039
-
3040
- try {
3041
- return await inFlight;
3042
- } finally {
3043
- inFlight = null;
3044
- }
3045
- }
3046
-
3047
- /**
3048
- * Run one installer argv and resolve to a status patch. Never rejects.
3049
- *
3050
- * `npm i -g` against a global prefix needs no elevation the way a system
3051
- * package does, but it still writes outside the app dir and it can fail on a
3052
- * permissions wall — so a failure is reported with npm's own last lines
3053
- * rather than a blanket "update failed".
3054
- */
3055
- function runInstall(target) {
3056
- const exec = runUpgrade || ((cmd, args) => new Promise((resolve) => {
3057
- execFile(cmd, args, { timeout: 180_000, windowsHide: true }, (err, stdout, stderr) => {
3058
- if (err) return resolve({ ok: false, output: String(stderr || stdout || err.message || err) });
3059
- resolve({ ok: true, output: String(stdout || '') });
3060
- });
3061
- }));
3062
- return Promise.resolve(exec(target.command, target.args)).then(
3063
- (res) => ({ ok: Boolean(res && res.ok), output: String((res && res.output) || '') }),
3064
- (err) => ({ ok: false, output: errorText(err) }),
3065
- );
3066
- }
3067
-
3068
- return {
3069
- status: () => state,
3070
- check,
3071
- /**
3072
- * The banner's Download button. It INSTALLS.
3073
- *
3074
- * This used to be a no-op that re-set `command` and `canDownload:false`:
3075
- * the button was hidden for every npm install, so the affordance and the
3076
- * action were both missing, and the only way to update the window was to
3077
- * read a shell command off a banner and type it into a different app.
3078
- *
3079
- * It still runs only on an explicit click (nothing here installs on a
3080
- * timer), and it still does not hot-swap the running process: after a
3081
- * successful install the honest state is 'downloaded' — "restart to install"
3082
- * — because the new files are on disk and this process is still the old
3083
- * ones. That is the same promise the packaged electron-updater path makes,
3084
- * so the banner needs no branch.
3085
- */
3086
- download: async () => {
3087
- const target = state.plan && state.plan.apply;
3088
- if (!target) {
3089
- // Nothing to install on this channel (npx fetches fresh every run; a
3090
- // checkout belongs to its git remote). Say who owns it instead of
3091
- // pretending to act.
3092
- return set({
3093
- status: state.status === 'available' ? 'available' : state.status,
3094
- command: (state.plan && state.plan.instruction) || state.command,
3095
- canDownload: false,
3096
- });
3097
- }
3098
- set({ status: 'downloading', error: null, progress: 0 });
3099
- const res = await runInstall(target);
3100
- if (!res.ok) {
3101
- // `transient: false` deliberately: an installer that refused is not a
3102
- // network blip, and the retry ladder must not quietly re-run an npm
3103
- // install every minute.
3104
- return set({
3105
- status: 'error',
3106
- transient: false,
3107
- error: res.output.trim().split('\n').slice(-3).join(' ').trim() || 'the installer failed',
3108
- command: `${target.command} ${target.args.join(' ')}`,
3109
- });
3110
- }
3111
- return set({
3112
- status: 'downloaded',
3113
- version: state.version,
3114
- error: null,
3115
- progress: 100,
3116
- command: `npm i -g ${PKG}@latest`,
3117
- canDownload: false,
3118
- });
3119
- },
3120
- /**
3121
- * "Restart to install". Relaunches the app so the freshly installed files
3122
- * are what runs — `app.relaunch()` before `app.exit()`, the standard pair,
3123
- * because `app.quit()` alone would not start a new process.
3124
- */
3125
- quitAndInstall: () => {
3126
- if (state.status !== 'downloaded') return;
3127
- const again = relaunch || (() => {
3128
- try {
3129
- const { app } = require('electron');
3130
- app.relaunch();
3131
- app.exit(0);
3132
- } catch {
3133
- /* not under Electron (a test or the CLI): nothing to relaunch */
3134
- }
3135
- });
3136
- again();
3137
- },
3138
- start: () => {
3139
- check().catch(() => {});
3140
- interval = setInterval(() => check().catch(() => {}), UPDATE_CHECK_INTERVAL_MS);
3141
- if (interval.unref) interval.unref();
3142
- },
3143
- // Timers are the reason this exists: a retry timer outliving the window
3144
- // would keep a dead check alive past app quit.
3145
- stop: () => {
3146
- clearRetry();
3147
- if (interval) clearInterval(interval);
3148
- interval = null;
3149
- },
3150
- retryState: () => ({ attempts, retryAt, inFlight: !!inFlight, hasTimer: !!retryTimer }),
3151
- };
3152
- }
3153
-
3154
- function createUpdateManager({ autoUpdater, isPackaged, onStatus }) {
3155
- let state = { status: 'idle', version: null, error: null, canDownload: false };
3156
-
3157
- function setState(patch) {
3158
- state = { ...state, ...patch };
3159
- if (onStatus) onStatus(state);
3160
- }
3161
-
3162
- if (!isPackaged || !autoUpdater) {
3163
- // Not a packaged build — which is how this app is INSTALLED FROM NPM, not
3164
- // just how it runs in dev. That case used to resolve a flat 'disabled' and
3165
- // never check anything, so an npm-installed desktop could never learn a
3166
- // new version existed.
3167
- //
3168
- // electron-updater's configured feed is GitHub Releases
3169
- // (electron-builder.yml `publish:`), and there has never been a published
3170
- // release to read — the only one is a draft, invisible to the updater, and
3171
- // the release workflow cannot run while Actions is billing-locked. So the
3172
- // feed the packaged path depends on does not exist, while the registry we
3173
- // actually publish to does.
3174
- //
3175
- // npm is therefore the real update channel. Checking it costs one request
3176
- // a day, reports the version and the exact command, and deliberately does
3177
- // NOT self-install: replacing your own running process mid-session is a
3178
- // different promise from telling you an update exists.
3179
- return createNpmUpdateManager({ onStatus: setState });
3180
- }
3181
-
3182
- autoUpdater.autoDownload = false;
3183
- autoUpdater.autoInstallOnAppQuit = false;
3184
-
3185
- autoUpdater.on('checking-for-update', () => setState({ status: 'checking', error: null }));
3186
- autoUpdater.on('update-available', (info) =>
3187
- setState({ status: 'available', version: info && info.version, error: null, canDownload: true })
3188
- );
3189
- autoUpdater.on('update-not-available', () => setState({ status: 'up-to-date', error: null }));
3190
- autoUpdater.on('download-progress', (progress) =>
3191
- setState({ status: 'downloading', progress: progress && progress.percent })
3192
- );
3193
- autoUpdater.on('update-downloaded', (info) =>
3194
- setState({ status: 'downloaded', version: info && info.version, error: null })
3195
- );
3196
- // Any failure — offline, a malformed feed, a 404 on latest.yml — lands
3197
- // here instead of an unhandled rejection, since electron-updater emits
3198
- // 'error' for background failures that occur outside the promise a
3199
- // check()/download() call is awaiting.
3200
- autoUpdater.on('error', (err) => setState({ status: 'error', error: errorText(err) }));
3201
-
3202
- async function check() {
3203
- try {
3204
- await autoUpdater.checkForUpdates();
3205
- } catch (err) {
3206
- setState({ status: 'error', error: errorText(err) });
3207
- }
3208
- return state;
3209
- }
3210
-
3211
- async function download() {
3212
- try {
3213
- await autoUpdater.downloadUpdate();
3214
- } catch (err) {
3215
- setState({ status: 'error', error: errorText(err) });
3216
- }
3217
- return state;
3218
- }
3219
-
3220
- function quitAndInstall() {
3221
- autoUpdater.quitAndInstall();
3222
- }
3223
-
3224
- function start() {
3225
- check();
3226
- const timer = setInterval(check, UPDATE_CHECK_INTERVAL_MS);
3227
- // Never keep the process alive solely to poll for updates.
3228
- if (timer.unref) timer.unref();
3229
- }
3230
-
3231
- return { status: () => state, check, download, quitAndInstall, start };
3232
- }
3233
-
3234
- /** Pure mapping: IPC payload -> update-manager call, same shape as
3235
- * createIpcDispatch — unit-testable with a stub updateManager. */
3236
- function createUpdateDispatch(updateManager) {
3237
- return {
3238
- // The renderer only ever calls this from an explicit click (the banner's
3239
- // Retry), so it is announced: a user who asks gets a truthful answer about
3240
- // a failure instead of the silence the background check prefers.
3241
- checkForUpdates: () => updateManager.check({ announce: true }),
3242
- downloadUpdate: () => updateManager.download(),
3243
- quitAndInstallUpdate: () => {
3244
- updateManager.quitAndInstall();
3245
- return { ok: true };
3246
- },
3247
- updateStatus: () => Promise.resolve(updateManager.status()),
3248
- };
3249
- }
3250
-
3251
- /** Register update dispatch methods as `aegis:<name>`, same convention as
3252
- * registerIpc() above. */
3253
- function registerUpdateIpc(ipcMain, updateManager) {
3254
- const dispatch = createUpdateDispatch(updateManager);
3255
- for (const [name, handler] of Object.entries(dispatch)) {
3256
- ipcMain.handle(`${IPC_PREFIX}${name}`, (_event, payload) => handler(payload));
3257
- }
3258
- return dispatch;
3259
- }
3260
-
3261
- /** Send a no-payload ping to whichever window currently has focus (falling
3262
- * back to the first open window so a menu click still does something when
3263
- * triggered via a global accelerator with no window focused, e.g. right
3264
- * after 'activate' on mac). Used for the two accelerators that have no
3265
- * built-in Electron role — "new chat" and "search" are renderer state, not
3266
- * something main.js can act on directly. */
3267
- function sendToFocusedWindow(BrowserWindow, channel) {
3268
- const win = BrowserWindow.getFocusedWindow() || BrowserWindow.getAllWindows()[0];
3269
- if (win && !win.isDestroyed()) win.webContents.send(channel);
3270
- }
3271
-
3272
- /**
3273
- * Deliver a resolved deep link to one window over DEEP_LINK_CHANNEL. A cold
3274
- * launch (`aegis://…` handed to the OS while the app wasn't running) reaches
3275
- * this before the renderer script has run `aegis.onDeepLink(...)` — sending
3276
- * immediately would be lost, so a still-loading page gets the payload queued
3277
- * for its first 'did-finish-load' instead of sent right away.
3278
- */
3279
- function sendDeepLinkToWindow(win, parsed) {
3280
- if (!win || win.isDestroyed() || !parsed) return;
3281
- const deliver = () => {
3282
- if (!win.isDestroyed()) win.webContents.send(DEEP_LINK_CHANNEL, parsed);
3283
- };
3284
- if (win.webContents.isLoadingMainFrame()) {
3285
- win.webContents.once('did-finish-load', deliver);
3286
- } else {
3287
- deliver();
3288
- }
3289
- }
3290
-
3291
- /** Application menu: the platform defaults (Edit roles, mac's app/quit
3292
- * items) plus the native-shell accelerators this plan adds — New Chat
3293
- * (Cmd/Ctrl+N), Search (Cmd/Ctrl+K, opens the memory/session search
3294
- * overlay), Reload (Cmd/Ctrl+R) and Toggle DevTools (Cmd/Ctrl+Shift+I) are
3295
- * plain Electron roles that already carry the right accelerator on every
3296
- * platform — plus a manual "Check for Updates…" entry, since the automatic
3297
- * checks in createUpdateManager.start() only run on ready and every few
3298
- * hours, and an About item (mac gets one for free in the app submenu; other
3299
- * platforms get a Help menu — role 'about' shows app.setAboutPanelOptions()
3300
- * cross-platform since Electron 15). */
3301
- function buildAppMenu({ app, Menu, BrowserWindow, updateManager }) {
3302
- const isMac = process.platform === 'darwin';
3303
-
3304
- app.setAboutPanelOptions({
3305
- applicationName: app.getName(),
3306
- applicationVersion: app.getVersion(),
3307
- version: app.getVersion(),
3308
- });
3309
-
3310
- const checkForUpdatesItem = {
3311
- label: 'Check for Updates…',
3312
- // Announced: a menu click is a user asking, so a failure is reported to
3313
- // them instead of being swallowed the way the background check prefers.
3314
- click: () => updateManager.check({ announce: true }),
3315
- };
3316
- const newChatItem = {
3317
- label: 'New Chat',
3318
- accelerator: 'CmdOrCtrl+N',
3319
- click: () => sendToFocusedWindow(BrowserWindow, MENU_NEW_CHAT_CHANNEL),
3320
- };
3321
- const searchItem = {
3322
- label: 'Search…',
3323
- accelerator: 'CmdOrCtrl+K',
3324
- click: () => sendToFocusedWindow(BrowserWindow, MENU_SEARCH_CHANNEL),
3325
- };
3326
- // Neither item knows the open session id — that's renderer state — so, like
3327
- // newChatItem/searchItem above, the click just pings the renderer, which
3328
- // calls window.aegis.exportSession() with its own currentSessionId. "Save
3329
- // as…" writes the human-readable Markdown form; "Export Session…" writes
3330
- // the raw JSON record (round-trippable, e.g. for re-import).
3331
- const saveAsItem = {
3332
- label: 'Save as…',
3333
- accelerator: 'CmdOrCtrl+S',
3334
- click: () => sendToFocusedWindow(BrowserWindow, MENU_EXPORT_MARKDOWN_CHANNEL),
3335
- };
3336
- const exportSessionItem = {
3337
- label: 'Export Session…',
3338
- click: () => sendToFocusedWindow(BrowserWindow, MENU_EXPORT_JSON_CHANNEL),
3339
- };
3340
- const fingerprintItem = {
3341
- label: 'Authorship Panel…',
3342
- click: () => sendToFocusedWindow(BrowserWindow, MENU_FINGERPRINT_CHANNEL),
3343
- };
3344
-
3345
- const template = [
3346
- ...(isMac
3347
- ? [
3348
- {
3349
- label: app.getName(),
3350
- submenu: [
3351
- { role: 'about' },
3352
- checkForUpdatesItem,
3353
- { type: 'separator' },
3354
- { role: 'services' },
3355
- { type: 'separator' },
3356
- { role: 'hide' },
3357
- { role: 'hideOthers' },
3358
- { role: 'unhide' },
3359
- { type: 'separator' },
3360
- { role: 'quit' },
3361
- ],
3362
- },
3363
- ]
3364
- : []),
3365
- {
3366
- label: 'File',
3367
- submenu: [
3368
- newChatItem,
3369
- { type: 'separator' },
3370
- saveAsItem,
3371
- exportSessionItem,
3372
- { type: 'separator' },
3373
- ...(isMac ? [] : [checkForUpdatesItem, { type: 'separator' }]),
3374
- isMac ? { role: 'close' } : { role: 'quit' },
3375
- ],
3376
- },
3377
- {
3378
- label: 'Edit',
3379
- submenu: [
3380
- { role: 'undo' },
3381
- { role: 'redo' },
3382
- { type: 'separator' },
3383
- { role: 'cut' },
3384
- { role: 'copy' },
3385
- { role: 'paste' },
3386
- { role: 'selectAll' },
3387
- ],
3388
- },
3389
- {
3390
- label: 'View',
3391
- submenu: [
3392
- searchItem,
3393
- { type: 'separator' },
3394
- fingerprintItem,
3395
- { type: 'separator' },
3396
- { role: 'reload', accelerator: 'CmdOrCtrl+R' },
3397
- { role: 'forceReload' },
3398
- { type: 'separator' },
3399
- { role: 'toggleDevTools', accelerator: 'CmdOrCtrl+Shift+I' },
3400
- ],
3401
- },
3402
- ...(isMac
3403
- ? []
3404
- : [
3405
- {
3406
- label: 'Help',
3407
- submenu: [{ role: 'about' }],
3408
- },
3409
- ]),
3410
- ];
3411
- return Menu.buildFromTemplate(template);
3412
- }
3413
-
3414
- // ---------------------------------------------------------------------------
3415
- // Electron-only bootstrap
3416
- // ---------------------------------------------------------------------------
3417
-
3418
- function bootstrap() {
3419
- const { app, BrowserWindow, ipcMain, safeStorage, shell, Menu, Notification, screen, dialog, globalShortcut } = electron;
3420
-
3421
- // A container/CI/headless server with no usable GPU crashes Chromium's GPU
3422
- // process in a retry loop that ends in a FATAL exit ("GPU process isn't
3423
- // usable. Goodbye.") — and that happens BEFORE app.whenReady() ever fires,
3424
- // so autoProvisionApiKey() (the free device key + welcome-trial grant)
3425
- // never runs either: `npm install -g aegis-desktop && aegis` on exactly
3426
- // that class of machine registered nothing, with no error a user could
3427
- // act on. Must be called before the app is ready (Electron's own
3428
- // constraint); software rendering is a fine tradeoff for a text/chat UI
3429
- // with no WebGL or video.
3430
- //
3431
- // disableHardwareAcceleration() alone was not enough on a container whose
3432
- // Mesa/VA-API/DRI stack is fully broken rather than merely GPU-less:
3433
- // Chromium still spawned a GPU process for the SwiftShader software path,
3434
- // and that crash-looped to the same FATAL exit, just after a longer delay
3435
- // (measured: survived past 15s, died at ~44s). These three switches tell
3436
- // Chromium to skip GPU-process creation entirely rather than retry it
3437
- // under software rendering — the documented combination for Electron under
3438
- // Docker/CI/headless Linux.
3439
- app.disableHardwareAcceleration();
3440
- app.commandLine.appendSwitch('disable-gpu');
3441
- app.commandLine.appendSwitch('disable-software-rasterizer');
3442
- app.commandLine.appendSwitch('disable-gpu-compositing');
3443
-
3444
- // A second launch (double-clicking the icon again, `aegis` from a second
3445
- // terminal, …) must focus the existing window, not spawn a second process
3446
- // fighting the first over the same on-disk settings/session/queue files —
3447
- // see the 'second-instance' handler below, registered once the window
3448
- // machinery it calls (createWindow/openWindows) exists later in this
3449
- // function (safe: the event only fires well after bootstrap() returns).
3450
- //
3451
- // A second launch is also how an aegis:// link reaches an already-running
3452
- // app on Windows/Linux: the OS starts a second process with the URL on its
3453
- // argv, requestSingleInstanceLock() hands that argv to 'second-instance' on
3454
- // *this* process below, and the second process exits here.
3455
- if (!app.requestSingleInstanceLock()) {
3456
- app.quit();
3457
- return;
3458
- }
3459
-
3460
- app.setName('AEGIS Desktop');
3461
-
3462
- // Read ~/.aegiscode/.env into process.env once, before anything resolves a
3463
- // key (resolveStartupKey() later in this function, and every BYOK provider
3464
- // key in lib/local/engine.js). A variable already exported in the launching
3465
- // shell always wins — see loadEnvFile()'s contract. Failure is never fatal:
3466
- // an absent file is the pre-existing state, not an error.
3467
- if (envFile && typeof envFile.loadEnvFile === 'function') {
3468
- try {
3469
- const envResult = envFile.loadEnvFile();
3470
- if (envResult.loose) {
3471
- console.warn(
3472
- `aegis: ${envResult.file} is readable by other accounts ` +
3473
- `(mode ${envResult.mode.toString(8)}) — consider \`chmod 600\` on it.`
3474
- );
3475
- }
3476
- } catch (e) {
3477
- console.warn(`aegis: could not read ${envFile.envFileFor()}: ${e && e.message}`);
3478
- }
3479
- }
3480
-
3481
- // Register the aegis:// scheme so the OS routes those links to this app.
3482
- // Safe to call unconditionally (idempotent) and before 'ready'.
3483
- app.setAsDefaultProtocolClient(deepLink.PROTOCOL);
3484
-
3485
- // macOS delivers a registered scheme via 'open-url', not argv — must be
3486
- // listened for before 'ready' or an activation during launch is dropped.
3487
- // A cold launch (app not yet running) races this handler against
3488
- // createWindow() below, so the parsed link is stashed and flushed once the
3489
- // first window exists; a warm launch (already running) delivers straight
3490
- // to the focused window, mirroring 'second-instance' below.
3491
- let pendingDeepLink = deepLink.parseDeepLinkArgv(process.argv);
3492
- app.on('open-url', (event, url) => {
3493
- event.preventDefault();
3494
- const parsed = deepLink.parseDeepLinkUrl(url);
3495
- if (!parsed) return;
3496
- const win = BrowserWindow.getFocusedWindow() || BrowserWindow.getAllWindows()[0];
3497
- if (win) sendDeepLinkToWindow(win, parsed);
3498
- else pendingDeepLink = parsed;
3499
- });
3500
-
3501
- const aegis = createClient(credentials.clientOptions());
3502
- const dataDir = resolveUserDataDir(app);
3503
-
3504
- // One settings store backs both the provider settings surface and the AEGIS
3505
- // API key entry. It lives only in the main process and encrypts keys at rest
3506
- // via Electron safeStorage (best-effort base64 when unavailable).
3507
- const { engine, sessionsDir, settingsDir, settings } = createEngine(aegis, {
3508
- app,
3509
- safeStorage,
3510
- dir: dataDir,
3511
- });
3512
-
3513
- // Resolve/auto-provision the account key as early as possible — BEFORE
3514
- // app.whenReady(), not inside it. autoProvisionApiKey() is a plain Node
3515
- // fetch call with no Electron dependency: createClient(credentials.
3516
- // clientOptions()) above already resolved any existing key (env, the
3517
- // shared file, the env-file fallback) onto `aegis.apiKey`, so this is a
3518
- // genuine registerDevice call only on a truly fresh install — the same
3519
- // one-call-is-enough guarantee autoProvisionApiKey() provides to the CLI
3520
- // and the MCP plugin. It persists straight to the plain shared
3521
- // credentials file via credentials.saveApiKey(), not safeStorage.
3522
- //
3523
- // Why this can't wait for 'ready': a container/CI host with a broken
3524
- // graphics stack crashes Chromium's GPU process in a retry loop that can
3525
- // end in a FATAL exit (see disableHardwareAcceleration above) BEFORE
3526
- // whenReady() ever resolves — measured here surviving 15s before dying at
3527
- // 42-44s even with hardware acceleration and GPU compositing disabled.
3528
- // Gating registration on 'ready' meant that exact class of host — exactly
3529
- // what `npm install -g aegis-desktop && aegis` on a headless server lands
3530
- // on — registered nothing and left no trace. Running it here means the
3531
- // account exists and the key is on disk the moment the process starts,
3532
- // independent of whether the window ever manages to open.
3533
- //
3534
- // Not calling settings.setAegisKey() here: safeStorage is not safe to use
3535
- // before 'ready' (see migrateLegacyAegisKey's own comment in desktop/lib/
3536
- // settings.js, the one other place in this codebase that already had to
3537
- // reason about this). The
3538
- // existing app.whenReady() block further down already re-reads the shared
3539
- // store via credentials.preferNewest() and writes the encrypted copy once
3540
- // ready — this just guarantees there is something there for it to find
3541
- // even when 'ready' never comes.
3542
- const accountKey = createAccountKey({
3543
- aegis,
3544
- credentials,
3545
- persistKey: (key) => settings.setAegisKey(key),
3546
- provision: (client, creds) => autoProvision.autoProvisionApiKey(client, creds, { app: 'desktop' }),
3547
- describeFailure: autoProvision.provisionFailureText,
3548
- onChange: (state) => broadcastAccountStatus(state),
3549
- });
3550
-
3551
- // Google sign-in + Drive connect (lib/google-connect.js). Same key store as
3552
- // accountKey above (settings.setAegisKey) — one saved key, whichever door
3553
- // last wrote it — and the same banner: a successful Google sign-in clears
3554
- // whatever "no key" state accountKey was showing, same as a successful
3555
- // register-device retry does.
3556
- const googleConnect = createGoogleConnect({
3557
- aegis,
3558
- credentials,
3559
- persistKey: (key) => settings.setAegisKey(key),
3560
- openUrl: (url) => {
3561
- if (!isSafeExternalUrl(url)) return false;
3562
- shell.openExternal(url);
3563
- return true;
3564
- },
3565
- googleSignIn,
3566
- driveConnect: driveConnectLib,
3567
- driveUpload: driveUploadLib,
3568
- onAccountChange: () => broadcastAccountStatus(accountKey.status()),
3569
- });
3570
- const keyPresentAtStart = Boolean(aegis.apiKey);
3571
- const earlyKeyProvision = autoProvision.autoProvisionApiKey(aegis, credentials, { app: 'desktop' }).then((result) => {
3572
- accountKey.recordStartup(result);
3573
- if (!result.key && !aegis.apiKey) {
3574
- const reason =
3575
- (result.error && (result.error.status || result.error.message || String(result.error))) ||
3576
- 'no key returned';
3577
- console.warn(`auto-provision: no account key from register-device (${reason})`);
3578
- }
3579
- return result;
3580
- });
3581
-
3582
- // A provider key typed in the Settings pane used to reach ONLY this app's
3583
- // encrypted store, so `aegiscode` (the CLI) and the MCP plugin never saw it:
3584
- // same machine, same provider, key asked for twice, and a key entered here
3585
- // was gone from every other host on the next run. Mirror every key write into
3586
- // the shared `~/.aegiscode/.env` — the one file all four hosts read at
3587
- // start-up (loadEnvFile() above; cli/bin/aegiscode.js before the CLI runs) —
3588
- // so "add the key in the app" and "put it in ~/.aegiscode/.env" are the same
3589
- // instruction. The encrypted store stays this app's authority; the file is
3590
- // what makes the key exist for the others. The AEGIS *account* key is
3591
- // deliberately not mirrored: it is already shared via credentials.json, and a
3592
- // second copy is a second authority that can disagree (see the module's
3593
- // header). Wrapped before registerModelIpc below, so every IPC path uses the
3594
- // wrapped store.
3595
- try {
3596
- const { wrapProviderKeyMirror } = require('./lib/provider-key-mirror.js');
3597
- const onWarn = (m) => console.warn(`aegis: ${m}`);
3598
- wrapProviderKeyMirror(settings, envFile, { onWarn });
3599
- if (engine && engine.settings && engine.settings !== settings) {
3600
- wrapProviderKeyMirror(engine.settings, envFile, { onWarn });
3601
- }
3602
- } catch (e) {
3603
- // Never fatal: the store still works, only the cross-host copy is missing.
3604
- console.warn(`aegis: could not wire the shared env-file key mirror: ${e && e.message}`);
3605
- }
3606
-
3607
- // Persist the in-app AEGIS key in its own reserved namespace, encrypted —
3608
- // never as a provider named 'aegis' (that coupling let the Settings pane's
3609
- // "Remove" delete the AEGIS key; defect #1).
3610
- //
3611
- // Mirrored into the shared 0600 store as well, so one sign-in covers this app,
3612
- // `aegiscode` and the MCP plugin instead of each host needing its own. The
3613
- // app's copy stays the encrypted one; resolveStartupKey() compares their
3614
- // `savedAt` stamps so whichever host wrote last is the key in force.
3615
- const persistApiKey = (key) => {
3616
- const result = settings.setAegisKey(key);
3617
- // NOT mirrored into ~/.aegiscode/.env on purpose — the line below already
3618
- // shares this key with every host (credentials.json, 0600, read by the CLI,
3619
- // the MCP plugin and this app). Two stores for one account key is two
3620
- // answers to "which key is in force"; see desktop/lib/provider-key-mirror.js.
3621
- if (key) credentials.saveApiKey(key);
3622
- else credentials.clearApiKey();
3623
- return result;
3624
- };
3625
-
3626
- // Native "reply ready" notification: main.js is exactly where every
3627
- // chatCompletion/model:chat call already resolves (withReplyNotify above),
3628
- // so this is the one place that can tell whether the window that asked for
3629
- // it is still the one in front. Fires only when that window is
3630
- // minimized/hidden/unfocused; a foregrounded chat needs no OS-level nudge.
3631
- // Notification.isSupported() gates platforms/sandboxes with no native
3632
- // notification centre so this never throws.
3633
- function notifyReplyIfUnfocused(event) {
3634
- const sender = event && event.sender;
3635
- const win = sender && BrowserWindow.fromWebContents(sender);
3636
- if (!win || win.isDestroyed()) return;
3637
- if (win.isFocused() && win.isVisible()) return;
3638
- if (!Notification || !Notification.isSupported()) return;
3639
- const note = new Notification({ title: 'AEGIS Desktop', body: 'Reply ready' });
3640
- note.on('click', () => {
3641
- if (win.isDestroyed()) return;
3642
- if (win.isMinimized()) win.restore();
3643
- if (!win.isVisible()) win.show();
3644
- win.focus();
3645
- });
3646
- note.show();
3647
- }
3648
-
3649
- // Both automations (lib/auto.js) are built further down, AFTER the queue and
3650
- // the fingerprint have been wired — they call into both. This `let` is how
3651
- // the reply hook below (which has to be handed to registerIpc up here) can
3652
- // reach one that does not exist yet: it fires when a turn FINISHES, which is
3653
- // always long after bootstrap() has returned, and it is null-guarded so a
3654
- // reply arriving in that theoretical gap is a no-op rather than a TypeError.
3655
- let autoMint = null;
3656
-
3657
- /**
3658
- * The reply hook: the native notification (unchanged) plus the auto-mint's
3659
- * quiet clock. The clock is restarted by every finished turn, never read as
3660
- * "seal now" — lib/auto.js's flush() is what decides, and only once the
3661
- * session has been quiet for its full interval.
3662
- */
3663
- const handleReplyFinished = (event, result) => {
3664
- notifyReplyIfUnfocused(event);
3665
- if (autoMint && typeof autoMint.note === 'function') {
3666
- autoMint.note({ sessionId: (result && result.sessionId) || null });
3667
- }
3668
- };
3669
-
3670
- registerIpc(
3671
- ipcMain,
3672
- aegis,
3673
- dataDir,
3674
- persistApiKey,
3675
- (url) => shell.openExternal(url),
3676
- handleReplyFinished,
3677
- () => providerRouteConfigured(engine),
3678
- () => settings.vaultBackendConfig().backend
3679
- );
3680
- const modelIpc = registerModelIpc(
3681
- ipcMain,
3682
- engine,
3683
- sessionsDir,
3684
- aegis,
3685
- handleReplyFinished,
3686
- undefined,
3687
- settingsDir
3688
- );
3689
-
3690
- // Tool-call approval toggle (Settings → "Confirm before running tools"):
3691
- // aegis:getConfirmMode / aegis:setConfirmMode. Registered here because this
3692
- // is where the settings store lands — the exact tool gate it controls lives
3693
- // in the engine created alongside it (lib/local/engine.js gatedExecuteTool
3694
- // reads settings.getConfirmMode() on every mutating tool call).
3695
- //
3696
- // …and it is also auto mode's OTHER switch: a posture of "don't ask" is what
3697
- // auto mode means on this face, so flipping the switch off is an explicit
3698
- // request for it and must move the session state with it. Without this, an
3699
- // app launched with AEGIS_AUTO_ROUNDS=0 (a start-manual opt-out, per the
3700
- // engine) could be switched to auto mode in the UI — approvals off, chip lit
3701
- // — while the round horizon kept raising its card, because the env var was
3702
- // re-read as a veto on every horizon. Same escape hatch as `/automode on` in
3703
- // the CLI, which shares this store.
3704
- registerConfirmModeIpc(ipcMain, withAutoRoundsSwitch(createConfirmModeDispatch(settings), modelIpc.modelDispatch));
3705
-
3706
- // Vault backend preference (Settings/the authorship panel → Google Drive /
3707
- // AE / No sync): aegis:getVaultBackend / aegis:setVaultBackend. Registered
3708
- // here, beside confirm mode, for the same reason — this is where the
3709
- // settings store lands, and lib/fingerprint.js's vaultScope()/vaultDisabled()
3710
- // read the live value through the getVaultBackend thunks passed to
3711
- // registerIpc and createAutoMint above, so a change here reaches the very
3712
- // next publish/anchor/vault/audit call with no restart.
3713
- registerConfirmModeIpc(ipcMain, createVaultBackendDispatch(settings));
3714
-
3715
- // Funnel instrumentation (Phase 2 of docs/upgrade-conversion-plan.md):
3716
- // aegis:telemetryEvent / aegis:telemetryReport, backed by
3717
- // lib/local/telemetry.js.
3718
- //
3719
- // OFF unless the operator opted in — `AEGIS_TELEMETRY=1` in the environment
3720
- // (or ~/.aegiscode/.env, which the shared loader is read from elsewhere in
3721
- // this file) or `settings.setTelemetryOptIn(true)`. A disabled store is a
3722
- // pure no-op: no directory, no install id, no writes.
3723
- //
3724
- // TRANSPORT: local-first, because aegis1 has no route that accepts
3725
- // client-named funnel events (see the header of lib/local/telemetry.js).
3726
- // Nothing is sent anywhere today; records land in
3727
- // `<userData>/telemetry/funnel.jsonl`, and scripts/funnel-report.mjs turns
3728
- // them into the baseline table. The module's `sender` option is the seam for
3729
- // the server half, unused while no endpoint exists.
3730
- const telemetry = createTelemetry({
3731
- dir: settingsDir,
3732
- enabled: telemetryEnabled(process.env, settings),
3733
- appVersion: (() => {
3734
- try {
3735
- return typeof app.getVersion === 'function' ? app.getVersion() : '';
3736
- } catch {
3737
- return '';
3738
- }
3739
- })(),
3740
- });
3741
- registerTelemetryIpc(ipcMain, createTelemetryDispatch(telemetry));
3742
-
3743
- // ÆGIS covenant (ÆL/1), READ-ONLY: aegis:covenantStatus / aegis:covenantGapReport
3744
- // expose the decoded seal for display. The enforcing half is not here — it is
3745
- // the unconditional pre-action assert inside engine.guardedExecute, which no
3746
- // renderer request, approval or config flag can reach or skip (§9 invariant
3747
- // ¬M(Λ1) ∧ ¬M(Λ2) ∧ ¬M(Λ4)).
3748
- registerCovenantIpc(ipcMain, createCovenantDispatch(covenantModule));
3749
-
3750
- // Local autonomous work queue: queue:<name> on ipcMain, backed by the durable
3751
- // queue file (desktop/lib/local/queue.js — the SAME file the `aegiscode` CLI
3752
- // and the other hosts read) and the unattended worker
3753
- // (desktop/lib/local/autonomous.js).
3754
- //
3755
- // It gets its OWN engine, built with the approval gate off: a queued task has
3756
- // nobody to answer a card, so the interactive engine is the wrong one to hand
3757
- // a worker (see createQueueEngine).
3758
- //
3759
- // Registration alone drains nothing. Two ways a drain starts: the queue
3760
- // card's Run one / Drain all (the card is hidden now, but the IPC and the
3761
- // CLI entry point are the same call), or the background worker below — a
3762
- // PERSISTED setting (settings.js `__autoDrain`), ON unless explicitly
3763
- // switched off, read by lib/auto.js on every tick. There is still no boot
3764
- // drain and no drain-on-idle: the first tick is one interval after start.
3765
- const queueDispatch = registerQueueIpc(ipcMain, {
3766
- engine: createQueueEngine(aegis, settings),
3767
- aegis,
3768
- });
3769
-
3770
- // ── the two opt-in automations (lib/auto.js) ─────────────────────────────
3771
- //
3772
- // Created here because this is the first point where every collaborator
3773
- // exists: settings (the switches), the queue dispatch above (the drain), and
3774
- // the fingerprint service (the seal). What they do NOT get is a vote — both
3775
- // modules re-read the persisted switch on every tick, so flipping the toggle
3776
- // in the card or the panel stops the very next one.
3777
- const autoDrain = createAutoDrain({
3778
- // The SAME entry point the card's "Drain all" button calls
3779
- // (createQueueDispatch's `proceed` -> drainAll). Not a second loop: the
3780
- // worker's own per-task lock, Stop, and the progress frames are all the
3781
- // ones the button already uses.
3782
- proceed: () => queueDispatch.proceed(),
3783
- settings,
3784
- queue: queueModule,
3785
- env: process.env,
3786
- log: (entry) => {
3787
- // Visible in the main-process log, and nowhere else. An automation that
3788
- // fails silently is indistinguishable from one that is switched off.
3789
- if (entry && entry.type !== 'skip') console.log('[auto-drain]', entry.type, entry.reason || entry.error || '');
3790
- },
3791
- });
3792
-
3793
- // The seal. `mint` is lib/fingerprint.js's own — the same call the panel's
3794
- // "Seal this session" makes, reading the same session log through the same
3795
- // shared adapter. The service is built LAZILY for the reason createIpcDispatch
3796
- // states: a broken shared module must surface as an error in the panel, not as
3797
- // a window that never opens. `aiSecret` is never passed, so the AI axis of the
3798
- // record is attested from `$AEGIS_AI_SECRET` in the app's scope by
3799
- // client/attest.js's own rungs — no key enters main.js, the settings store, or
3800
- // the DOM for this path.
3801
- let autoFingerprint = null;
3802
- autoMint = createAutoMint({
3803
- mint: (payload) => {
3804
- if (!autoFingerprint) {
3805
- autoFingerprint = createFingerprintService({
3806
- dir: dataDir,
3807
- getVaultBackend: () => settings.vaultBackendConfig().backend,
3808
- });
3809
- }
3810
- return autoFingerprint.mint(payload || {});
3811
- },
3812
- settings,
3813
- });
3814
-
3815
- registerAutoIpc(ipcMain, createAutoDispatch({ autoDrain, autoMint }));
3816
-
3817
- // Arm the background workers. Both start() calls are reads of a persisted
3818
- // setting that is ON unless it was explicitly switched off (settings.js
3819
- // `__autoDrain`/`__autoMint`), so a default install drains a queued task with
3820
- // no click and seals a quiet session with nothing on screen. Neither start()
3821
- // drains or seals anything by itself: the drain's first tick is one interval
3822
- // away and the mint waits for a finished turn before its quiet clock starts.
3823
- autoDrain.start();
3824
- autoMint.start();
3825
-
3826
- // ---------------------------------------------------------------------
3827
- // Global quick-launcher (D? quick launcher): a frameless, always-on-top,
3828
- // taskbar-hidden popup toggled by a systemwide shortcut, for a one-shot
3829
- // question without switching to (or even seeing) the main window.
3830
- // ---------------------------------------------------------------------
3831
-
3832
- // Set by createWindow() below once the main window exists; read by
3833
- // pushQuickLauncherResult() to know where a "add to chat" push should land.
3834
- // `let` (not `const`) because createWindow() can run more than once
3835
- // (macOS 'activate' with no windows open, a 'second-instance' relaunch).
3836
- let mainWindowRef = null;
3837
- let quickWin = null;
3838
-
3839
- function getOrCreateQuickLauncherWindow() {
3840
- if (quickWin && !quickWin.isDestroyed()) return quickWin;
3841
- quickWin = new BrowserWindow({
3842
- width: 560,
3843
- height: 320,
3844
- show: false,
3845
- frame: false,
3846
- alwaysOnTop: true,
3847
- skipTaskbar: true,
3848
- resizable: false,
3849
- movable: true,
3850
- fullscreenable: false,
3851
- minimizable: false,
3852
- maximizable: false,
3853
- backgroundColor: '#0d1117',
3854
- icon: iconForPlatform(resolveIconsDir(app.isPackaged, process.resourcesPath, __dirname), process.platform),
3855
- webPreferences: {
3856
- preload: path.join(__dirname, 'preload.js'),
3857
- contextIsolation: true,
3858
- nodeIntegration: false,
3859
- sandbox: true,
3860
- },
3861
- });
3862
- quickWin.setMenuBarVisibility(false);
3863
- quickWin.loadFile(path.join(__dirname, 'renderer', 'quick.html'));
3864
-
3865
- // Escape hides it. Caught at the Electron input-event level (rather than
3866
- // a renderer keydown -> IPC round trip) so there is exactly one place —
3867
- // here — that decides what "hide" means, matching the blur handler right
3868
- // below: plain win.hide(), nothing more. Neither path ever calls
3869
- // mainWindowRef.focus() or otherwise touches the main window, so hiding
3870
- // the launcher — by Escape, by blur, or by pressing the toggle shortcut
3871
- // again — can never steal focus from whatever window had it before the
3872
- // launcher opened.
3873
- quickWin.webContents.on('before-input-event', (_event, input) => {
3874
- if (input.type === 'keyDown' && input.key === 'Escape' && !quickWin.isDestroyed()) {
3875
- quickWin.hide();
3876
- }
3877
- });
3878
- // Click-away / Alt-Tab away hides it too — a launcher that stays pinned
3879
- // on screen after the user's attention has moved on is just clutter.
3880
- quickWin.on('blur', () => {
3881
- if (!quickWin.isDestroyed() && quickWin.isVisible()) quickWin.hide();
3882
- });
3883
- quickWin.on('closed', () => {
3884
- quickWin = null;
3885
- });
3886
- return quickWin;
3887
- }
3888
-
3889
- function toggleQuickLauncher() {
3890
- const win = getOrCreateQuickLauncherWindow();
3891
- if (win.isVisible()) {
3892
- win.hide();
3893
- return;
3894
- }
3895
- const cursor = screen.getCursorScreenPoint();
3896
- const displays = screen.getAllDisplays();
3897
- const current = win.getBounds();
3898
- const bounds = quickLauncherLib.computeQuickLauncherBounds({
3899
- cursor,
3900
- displays,
3901
- size: { width: current.width, height: current.height },
3902
- });
3903
- win.setBounds(bounds);
3904
- win.show();
3905
- win.focus();
3906
- }
3907
-
3908
- /**
3909
- * (Re)register the global shortcut for `{ enabled, shortcut }`, or
3910
- * unregister it when this build/config doesn't want one active (dev run,
3911
- * flag off). `globalShortcut.unregisterAll()` is safe here — this app
3912
- * registers no other global accelerators (the menu's Cmd/Ctrl+N etc. are
3913
- * plain Electron Menu roles, local to the focused window, not
3914
- * globalShortcut). Never throws: a taken accelerator or an invalid
3915
- * accelerator string both degrade to `{ active: false, reason }` with a
3916
- * console.warn, exactly per the "gracefully degrading" requirement — a
3917
- * bad shortcut must never crash the app or block it from starting.
3918
- */
3919
- function applyQuickLauncherConfig(cfg) {
3920
- globalShortcut.unregisterAll();
3921
- if (!quickLauncherLib.shouldEnableGlobalShortcut({ isPackaged: app.isPackaged, enabled: cfg.enabled })) {
3922
- return { active: false, reason: null };
3923
- }
3924
- try {
3925
- const ok = globalShortcut.register(cfg.shortcut, toggleQuickLauncher);
3926
- if (!ok) {
3927
- const reason = `"${cfg.shortcut}" could not be registered — it may already be in use by another application.`;
3928
- console.warn(`[quick-launcher] ${reason}`);
3929
- return { active: false, reason };
3930
- }
3931
- return { active: true, reason: null };
3932
- } catch (err) {
3933
- const reason = errorText(err);
3934
- console.warn(`[quick-launcher] failed to register shortcut "${cfg.shortcut}": ${reason}`);
3935
- return { active: false, reason };
3936
- }
3937
- }
3938
-
3939
- /**
3940
- * `quick:pushToMain` — the launcher's "add to chat" keystroke. Hides the
3941
- * launcher window that sent the request (never the reverse: the main
3942
- * window is never hidden), delivers the payload to the main window over
3943
- * QUICK_LAUNCHER_PUSH_CHANNEL (whose handler in renderer/app.js owns
3944
- * actually building the chat turn — same "renderer owns the state" split
3945
- * as every other menu-ping channel in this file), then brings the main
3946
- * window forward so the user lands where the new turn appeared.
3947
- */
3948
- function pushQuickLauncherResult(payload, event) {
3949
- const senderWin = event && event.sender && BrowserWindow.fromWebContents(event.sender);
3950
- if (senderWin && !senderWin.isDestroyed()) senderWin.hide();
3951
- const target =
3952
- mainWindowRef && !mainWindowRef.isDestroyed()
3953
- ? mainWindowRef
3954
- : BrowserWindow.getAllWindows().find((w) => w !== senderWin) || null;
3955
- if (!target) return { ok: false, reason: 'no main window is open' };
3956
- target.webContents.send(QUICK_LAUNCHER_PUSH_CHANNEL, payload || {});
3957
- if (target.isMinimized()) target.restore();
3958
- if (!target.isVisible()) target.show();
3959
- target.focus();
3960
- return { ok: true };
3961
- }
3962
-
3963
- const quickLauncherDispatch = createQuickLauncherDispatch(settings, {
3964
- isPackaged: app.isPackaged,
3965
- applyConfig: applyQuickLauncherConfig,
3966
- pushToMain: pushQuickLauncherResult,
3967
- });
3968
- registerQuickLauncherIpc(ipcMain, quickLauncherDispatch);
3969
-
3970
- // A held global shortcut outlives this app if not released — every quit
3971
- // path (explicit quit, window-all-closed on non-mac, OS shutdown) must
3972
- // free it.
3973
- // The presence beacon (aegis1 /api/v1/heartbeat), held here so the quit path
3974
- // can stop it. `null` until `app.whenReady` starts it: the beacon needs the
3975
- // resolved account key, and a `let` (not a `const`) because a quit can
3976
- // precede a successful start.
3977
- let presenceBeacon = null;
3978
-
3979
- app.on('will-quit', () => {
3980
- // Beacon timers are unref'd, so this is not what keeps the process alive —
3981
- // it is what stops a beat from being sent while Electron tears the process
3982
- // down under it.
3983
- if (presenceBeacon && typeof presenceBeacon.stop === 'function') presenceBeacon.stop();
3984
- globalShortcut.unregisterAll();
3985
- // The update manager's retry/interval timers are unref'd so they never
3986
- // hold the process open, but an explicit quit should still cancel them
3987
- // rather than let one land during teardown.
3988
- if (updateManager && typeof updateManager.stop === 'function') updateManager.stop();
3989
- });
3990
-
3991
- // Unload any local model this app warmed (engine.js `unloadLocalModels` ->
3992
- // local.js `unloadAll`) BEFORE the process actually exits — "closing the
3993
- // app closes the model" is the point, since a model left loaded on the
3994
- // user's own hardware after the app is gone is pure waste with no UI left
3995
- // to even show it's happening. `will-quit` above is synchronous-only and
3996
- // fires too late for this (the process is already tearing down), so this
3997
- // intercepts the FIRST quit request, does the async cleanup, then lets a
3998
- // second, real quit through — the standard Electron pattern for async work
3999
- // on the quit path. Bounded by `unload`'s own per-request timeout inside
4000
- // `unloadAll`, so a dead or slow daemon can never be the reason closing the
4001
- // window hangs.
4002
- let quitCleanly = false;
4003
- app.on('before-quit', (event) => {
4004
- if (quitCleanly || !engine || typeof engine.unloadLocalModels !== 'function') return;
4005
- event.preventDefault();
4006
- Promise.resolve()
4007
- .then(() => engine.unloadLocalModels())
4008
- .catch(() => {})
4009
- .finally(() => {
4010
- quitCleanly = true;
4011
- app.quit();
4012
- });
4013
- });
4014
-
4015
- // electron-updater touches the network and expects a build-time
4016
- // app-update.yml that only exists in a packaged app; requiring it is safe
4017
- // either way, but it's wrapped defensively so a version mismatch or a
4018
- // missing optional native dep degrades to "updates disabled" instead of
4019
- // taking the whole app down.
4020
- let realAutoUpdater = null;
4021
- try {
4022
- realAutoUpdater = require('electron-updater').autoUpdater;
4023
- } catch {
4024
- realAutoUpdater = null;
4025
- }
4026
-
4027
- const openWindows = new Set();
4028
- function broadcastUpdateStatus(state) {
4029
- for (const win of openWindows) {
4030
- if (!win.isDestroyed()) win.webContents.send(UPDATE_STATUS_CHANNEL, state);
4031
- }
4032
- }
4033
- function broadcastAccountStatus(state) {
4034
- for (const win of openWindows) {
4035
- if (!win.isDestroyed()) win.webContents.send(ACCOUNT_STATUS_CHANNEL, state);
4036
- }
4037
- }
4038
-
4039
- const updateManager = createUpdateManager({
4040
- autoUpdater: realAutoUpdater,
4041
- isPackaged: app.isPackaged,
4042
- onStatus: broadcastUpdateStatus,
4043
- });
4044
- registerUpdateIpc(ipcMain, updateManager);
4045
- ipcMain.handle(`${IPC_PREFIX}accountStatus`, () => accountKey.status());
4046
- ipcMain.handle(`${IPC_PREFIX}registerAccount`, () => accountKey.registerAccount());
4047
- // Google sign-in + Drive connect/sync (lib/google-connect.js). Every call
4048
- // resolves `{ ok, … }` / `{ ok: false, error }` rather than throwing, same
4049
- // contract as the fingerprint* handlers below — the renderer shows the
4050
- // actual reason instead of Electron's opaque "Error invoking remote method".
4051
- ipcMain.handle(`${IPC_PREFIX}driveSignIn`, () => googleConnect.signInWithGoogle());
4052
- ipcMain.handle(`${IPC_PREFIX}driveConnect`, () => googleConnect.connectDrive());
4053
- ipcMain.handle(`${IPC_PREFIX}driveStatus`, () => googleConnect.status());
4054
- ipcMain.handle(`${IPC_PREFIX}driveSyncMemory`, () => googleConnect.syncMemoryNow());
4055
- registerExportIpc(
4056
- ipcMain,
4057
- sessionStore,
4058
- sessionsDir,
4059
- (opts) => dialog.showSaveDialog(BrowserWindow.getFocusedWindow(), opts),
4060
- (filePath, content) => fs.promises.writeFile(filePath, content, 'utf8')
4061
- );
4062
- Menu.setApplicationMenu(buildAppMenu({ app, Menu, BrowserWindow, updateManager }));
4063
-
4064
- // The local file this window is allowed to be at — used both to restore a
4065
- // saved position/reload and to recognise (and block) any navigation away
4066
- // from it. pathToFileURL normalises the platform path separators so the
4067
- // will-navigate comparison below works identically on Windows.
4068
- const appUrl = pathToFileURL(path.join(__dirname, 'renderer', 'index.html')).href;
4069
-
4070
- function createWindow() {
4071
- const defaultBounds = { width: 1080, height: 720 };
4072
- const saved = windowState.load(dataDir);
4073
- // A saved position from a monitor that's since been unplugged (or a
4074
- // resolution that shrank) must never place the window off every
4075
- // connected display — that's a window that "opens" but nobody can see
4076
- // or reach.
4077
- const bounds = windowState.clampToDisplay(saved, screen.getAllDisplays(), defaultBounds);
4078
- const iconsDir = resolveIconsDir(app.isPackaged, process.resourcesPath, __dirname);
4079
-
4080
- const win = new BrowserWindow({
4081
- ...bounds,
4082
- minWidth: 720,
4083
- minHeight: 480,
4084
- backgroundColor: '#0d1117',
4085
- title: 'AEGIS Desktop',
4086
- icon: iconForPlatform(iconsDir, process.platform),
4087
- webPreferences: {
4088
- preload: path.join(__dirname, 'preload.js'),
4089
- contextIsolation: true,
4090
- nodeIntegration: false,
4091
- sandbox: true,
4092
- spellcheck: true,
4093
- },
4094
- });
4095
-
4096
- if (saved && saved.isMaximized) win.maximize();
4097
-
4098
- // Popups (window.open, target=_blank, a model-rendered link that isn't
4099
- // routed through the aegis:openExternal IPC method) never get a second
4100
- // BrowserWindow inside this app: an http/https URL goes to the OS
4101
- // browser, everything else (file://, javascript:, …) is dropped — same
4102
- // allowlist as isSafeExternalUrl above.
4103
- win.webContents.setWindowOpenHandler(({ url }) => {
4104
- if (isSafeExternalUrl(url)) shell.openExternal(url);
4105
- return { action: 'deny' };
4106
- });
4107
-
4108
- // In-page navigation is likewise locked to this one local file. Without
4109
- // this, a compromised renderer script (or a link that reaches the
4110
- // BrowserWindow's own navigation instead of window.open) could carry the
4111
- // whole app to an arbitrary origin; http/https targets are hard-blocked
4112
- // here too, exactly like the popup case, and handed to the OS browser.
4113
- win.webContents.on('will-navigate', (navEvent, navigationUrl) => {
4114
- if (navigationUrl === appUrl) return;
4115
- navEvent.preventDefault();
4116
- if (isSafeExternalUrl(navigationUrl)) shell.openExternal(navigationUrl);
4117
- });
4118
-
4119
- // Bounds persistence: debounced on resize/move (dragging fires dozens of
4120
- // events per second — writing a file on every one would be wasteful and
4121
- // would fight the OS for disk I/O mid-drag), and flushed unconditionally
4122
- // on close so the final size/position always lands. getNormalBounds() is
4123
- // used while maximized so un-maximizing next launch restores the pre-
4124
- // maximize rectangle instead of the full-screen one.
4125
- let saveBoundsTimer = null;
4126
- function persistBounds() {
4127
- if (win.isDestroyed()) return;
4128
- const isMaximized = win.isMaximized();
4129
- const rect = isMaximized ? win.getNormalBounds() : win.getBounds();
4130
- windowState.save(dataDir, { ...rect, isMaximized });
4131
- }
4132
- function scheduleBoundsSave() {
4133
- if (saveBoundsTimer) clearTimeout(saveBoundsTimer);
4134
- saveBoundsTimer = setTimeout(persistBounds, 500);
4135
- }
4136
- win.on('resize', scheduleBoundsSave);
4137
- win.on('move', scheduleBoundsSave);
4138
- win.on('close', () => {
4139
- if (saveBoundsTimer) clearTimeout(saveBoundsTimer);
4140
- persistBounds();
4141
- });
4142
-
4143
- win.loadFile(path.join(__dirname, 'renderer', 'index.html'));
4144
- openWindows.add(win);
4145
- mainWindowRef = win;
4146
- win.on('closed', () => {
4147
- openWindows.delete(win);
4148
- if (mainWindowRef === win) mainWindowRef = null;
4149
- });
4150
- return win;
4151
- }
4152
-
4153
- app.whenReady().then(async () => {
4154
- // Hydrate the client from the persisted key BEFORE the renderer issues any
4155
- // status/model call, so class/model dropdowns populate immediately when a
4156
- // key was saved in-app (safeStorage is usable only after app ready).
4157
- //
4158
- // `$AEGIS_API_KEY` still outranks both stores: createClient() already read
4159
- // it, and nothing here may shadow an explicit export.
4160
- const envKey = credentials.normalizeApiKey(process.env[credentials.KEY_ENV]);
4161
- if (!envKey) {
4162
- // Wait for the pre-ready attempt above rather than racing a second
4163
- // registerDevice call: autoProvisionApiKey() already ran (or is still
4164
- // running) the instant this process started, specifically so it is
4165
- // not gated on 'ready' — see the earlyKeyProvision comment above for
4166
- // why (a container with a broken graphics stack can crash before
4167
- // 'ready' ever fires). Swallowing a keyless outcome here was how a
4168
- // machine could sit absent from the admin panel's user list AND its
4169
- // presence card with no trace anywhere — the server now logs every
4170
- // registration attempt, and earlyKeyProvision's own .then() already
4171
- // warns on failure, so there is nothing further to report if it
4172
- // didn't land.
4173
- const early = await earlyKeyProvision;
4174
- // A key minted just now only means "no key was saved yet" — a key the user
4175
- // saved in Settings before the shared store mirrored it would otherwise be
4176
- // overwritten by the device's fresh account on this first start.
4177
- const override = startupKeyOverride({
4178
- mintedKey: keyPresentAtStart ? '' : early.key,
4179
- hostKey: credentials.normalizeApiKey(settings.aegisRawKey()),
4180
- });
4181
- if (override) {
4182
- credentials.saveApiKey(override);
4183
- aegis.setApiKey(override);
4184
- } else {
4185
- const chosen = credentials.preferNewest(settings.aegisRawKey(), settings.aegisKeySavedAt());
4186
- if (chosen.key) {
4187
- aegis.setApiKey(chosen.key);
4188
- // The shared store is the newer write: bring this host's encrypted copy
4189
- // up to date so a CLI rotation does not have to be redone in Settings.
4190
- if (chosen.source === 'shared') settings.setAegisKey(chosen.key);
4191
- }
4192
- }
4193
- }
4194
- // ── presence beacon ───────────────────────────────────────────────────
4195
- //
4196
- // aegis1's `/api/v1/heartbeat` (`heartbeat_v1`), the client half of the
4197
- // admin panel's presence card: which app, which build, which OS, how many
4198
- // machines, and whether a drain is running. Started HERE because it needs
4199
- // the account key and this is the first point where the key is final —
4200
- // resolved from env/the shared store, or auto-provisioned just above — so
4201
- // the very first beat carries the account the app is actually signed in
4202
- // with rather than a stale one. Stopped in `will-quit`.
4203
- //
4204
- // `install` is the machine fingerprint (client/device-fingerprint.js), the
4205
- // SAME identity the device-registration path uses: the panel's "how many
4206
- // machines" is only true if every beat from one laptop carries one id.
4207
- //
4208
- // A keyless app gets an inert store from presence.js — no timer, no
4209
- // request — so an offline or signed-out launch is silent rather than a 401
4210
- // loop. A beacon failure must never block the window from opening.
4211
- try {
4212
- if (presenceBeacon && typeof presenceBeacon.stop === 'function') presenceBeacon.stop();
4213
- presenceBeacon = sharedPresence.createPresence({
4214
- apiKey: aegis.apiKey,
4215
- // 'desktop' is one of the names aegis1's PRESENCE_APPS keeps; anything
4216
- // else it does not recognise is stored as 'other' with a 200 and no
4217
- // error anywhere. See client/presence-constants.js.
4218
- app: 'desktop',
4219
- version: app.getVersion(),
4220
- platform: process.platform,
4221
- install: sharedFingerprint.machineFingerprint(),
4222
- // Read at SEND time: the panel wants the queue as it is now, not the
4223
- // depth it happened to have at launch. Same queue file the CLI drains
4224
- // (`desktop/lib/local/queue.js` — see registerQueueIpc below).
4225
- queueDepth: () => {
4226
- try {
4227
- const items = queueModule.readQueue(queueModule.queuePath());
4228
- return items.filter((t) => t && t.status === 'pending').length;
4229
- } catch {
4230
- return 0;
4231
- }
4232
- },
4233
- // The desktop card's drain is renderer-driven today; there is no
4234
- // always-on worker in main to report, so this stays false rather than
4235
- // claiming a drain that is not running.
4236
- autonomous: false,
4237
- log: (level, message) => {
4238
- if (level !== 'debug') console.warn(message);
4239
- },
4240
- });
4241
- presenceBeacon.start();
4242
- } catch (err) {
4243
- console.warn('presence: beacon not started', err && err.message);
4244
- }
4245
- // One-way backfill of the cross-host key mirror. wrapProviderKeyMirror
4246
- // (above) is a WRITE-TIME hook: it only fires when a key is saved through
4247
- // this store, so every row that predates the hook — or was saved with the
4248
- // key field left blank ("keep the stored one", which mirrors nothing by
4249
- // design) — still lives only in this app's encrypted store and is invisible
4250
- // to the CLI and the MCP plugin. Running the reconcile here closes that gap
4251
- // once per launch, in the store -> file direction only, and skips any
4252
- // variable the shared file already carries. After app 'ready', because
4253
- // safeStorage (which decrypts the stored key) is unusable before then, and
4254
- // deliberately before createWindow() so the CLI's next run — not the next
4255
- // Save — is when the key becomes visible.
4256
- //
4257
- // Never fatal: the keys are already saved, and a failed copy to the shared
4258
- // file is a smaller problem than a window that does not open.
4259
- try {
4260
- const { backfillProviderKeys } = require('./lib/provider-key-mirror.js');
4261
- const onWarn = (m) => console.warn(`aegis: ${m}`);
4262
- const result = backfillProviderKeys(settings, envFile, { onWarn });
4263
- if (result.mirrored.length) {
4264
- console.warn(
4265
- `aegis: shared ${result.mirrored.join(', ')} key(s) with the other hosts `
4266
- + `(${envFile.envFileFor()})`
4267
- );
4268
- }
4269
- if (engine && engine.settings && engine.settings !== settings) {
4270
- backfillProviderKeys(engine.settings, envFile, { onWarn });
4271
- }
4272
- } catch (e) {
4273
- console.warn(`aegis: could not backfill the shared env-file key mirror: ${e && e.message}`);
4274
- }
4275
- // Apply whatever was last saved (or the default): ship the shortcut only
4276
- // when app.isPackaged || the settings flag is on — see
4277
- // shouldEnableGlobalShortcut — so a plain `electron .` dev run never
4278
- // grabs a systemwide hotkey unless the developer opted in from Settings.
4279
- // Must run after 'ready' — globalShortcut throws before then.
4280
- quickLauncherDispatch.setConfig(settings.quickLauncherConfig());
4281
- const win = createWindow();
4282
- updateManager.start();
4283
- // Cold-launch deep link (Linux/Windows argv, or a pre-ready macOS
4284
- // 'open-url'): the window didn't exist when it was parsed above, so
4285
- // deliver it now that one does.
4286
- if (pendingDeepLink) sendDeepLinkToWindow(win, pendingDeepLink);
4287
- });
4288
-
4289
- app.on('window-all-closed', () => {
4290
- if (process.platform !== 'darwin') app.quit();
4291
- });
4292
-
4293
- app.on('activate', () => {
4294
- if (BrowserWindow.getAllWindows().length === 0) createWindow();
4295
- });
4296
-
4297
- // Second launch while already running: focus (and restore/show) the
4298
- // existing window instead of leaving the new process to just exit having
4299
- // done nothing visible. `argv` is the second process's argv — on
4300
- // Windows/Linux this is how an aegis:// link reaches an already-running
4301
- // app (see the 'open-url'/macOS comment above); the URL arrives as a bare
4302
- // positional entry (the Linux argv quirk deepLink.extractDeepLinkUrl scans
4303
- // for), not a named flag.
4304
- app.on('second-instance', (_event, argv) => {
4305
- const win = BrowserWindow.getAllWindows()[0];
4306
- const parsed = deepLink.parseDeepLinkArgv(argv);
4307
- if (!win) {
4308
- const created = createWindow();
4309
- if (parsed) sendDeepLinkToWindow(created, parsed);
4310
- return;
4311
- }
4312
- if (win.isMinimized()) win.restore();
4313
- if (!win.isVisible()) win.show();
4314
- win.focus();
4315
- if (parsed) sendDeepLinkToWindow(win, parsed);
4316
- });
4317
- }
4318
-
4319
- if (electron && electron.app) {
4320
- bootstrap();
4321
- }
4322
-
4323
- module.exports = {
4324
- createIpcDispatch,
4325
- registerIpc,
4326
- providerRouteConfigured,
4327
- IPC_PREFIX,
4328
- MODEL_PREFIX,
4329
- SYNC_PREFIX,
4330
- watchTopupCredit,
4331
- bankAmount,
4332
- TOPUP_POLL_MS,
4333
- TOPUP_POLL_TRIES,
4334
- CHAT_DELTA_CHANNEL,
4335
- MENU_NEW_CHAT_CHANNEL,
4336
- MENU_SEARCH_CHANNEL,
4337
- MENU_EXPORT_MARKDOWN_CHANNEL,
4338
- MENU_EXPORT_JSON_CHANNEL,
4339
- MENU_FINGERPRINT_CHANNEL,
4340
- DEEP_LINK_CHANNEL,
4341
- UPDATE_STATUS_CHANNEL,
4342
- UPDATE_CHECK_INTERVAL_MS,
4343
- taggedChunk,
4344
- maskKey,
4345
- createModelDispatch,
4346
- DESKTOP_CLASSES,
4347
- readTextFileForPreview,
4348
- PREVIEW_READ_MAX_BYTES,
4349
- createSyncDispatch,
4350
- registerModelIpc,
4351
- createHeartbeatRetry,
4352
- describeSyncError,
4353
- HEARTBEAT_RETRY_BASE_MS,
4354
- HEARTBEAT_RETRY_MAX_MS,
4355
- createEngine,
4356
- resolveUserDataDir,
4357
- importForeignMemory,
4358
- isSafeExternalUrl,
4359
- createUpdateManager,
4360
- createNpmUpdateManager,
4361
- createUpdateDispatch,
4362
- describeRegistryFailure,
4363
- UPDATE_RETRY_BACKOFF_MS,
4364
- registerUpdateIpc,
4365
- buildAppMenu,
4366
- createExportDispatch,
4367
- registerExportIpc,
4368
- safeExportBasename,
4369
- sendDeepLinkToWindow,
4370
- QUICK_PREFIX,
4371
- QUICK_LAUNCHER_PUSH_CHANNEL,
4372
- createQuickLauncherDispatch,
4373
- registerQuickLauncherIpc,
4374
- createConfirmModeDispatch,
4375
- createVaultBackendDispatch,
4376
- withAutoRoundsSwitch,
4377
- createTelemetryDispatch,
4378
- registerTelemetryIpc,
4379
- createCovenantDispatch,
4380
- registerCovenantIpc,
4381
- registerConfirmModeIpc,
4382
- QUEUE_PREFIX,
4383
- QUEUE_PROGRESS_CHANNEL,
4384
- QUEUE_MAX_TASK_CHARS,
4385
- QUEUE_MAX_ROUNDS,
4386
- createQueueEngine,
4387
- queueEngineWithDeniedApprovals,
4388
- createQueueDispatch,
4389
- registerQueueIpc,
4390
- AUTO_PREFIX,
4391
- createAutoDispatch,
4392
- registerAutoIpc,
4393
- };
4
+ "use strict";const path=require("node:path"),fs=require("node:fs"),{execFile:execFile}=require("node:child_process"),{pathToFileURL:pathToFileURL}=require("node:url");let sharedClient;try{sharedClient=require("../client/aegis.js")}catch{sharedClient=require("./vendor/aegis.js")}const{createClient:createClient}=sharedClient;let credentials,autoProvision,sharedPresence,sharedTopup,googleSignIn,driveConnectLib,driveUploadLib;try{credentials=require("../client/credentials.js")}catch{credentials=require("./vendor/credentials.js")}try{autoProvision=require("../client/auto-provision.js")}catch{autoProvision=require("./vendor/auto-provision.js")}try{sharedPresence=require("../client/presence.js")}catch{sharedPresence=require("./vendor/presence.js")}try{sharedTopup=require("../client/topup.js")}catch{sharedTopup=require("./vendor/topup.js")}try{googleSignIn=require("../client/google-signin.js")}catch{googleSignIn=require("./vendor/google-signin.js")}try{driveConnectLib=require("../client/drive-connect.js")}catch{driveConnectLib=require("./vendor/drive-connect.js")}try{driveUploadLib=require("../client/drive-upload.js")}catch{driveUploadLib=require("./vendor/drive-upload.js")}const{createGoogleConnect:createGoogleConnect}=require("./lib/google-connect.js");let sharedFingerprint;try{sharedFingerprint=require("../client/device-fingerprint.js")}catch{sharedFingerprint=require("./vendor/device-fingerprint.js")}const{createFingerprintService:createFingerprintService}=require("./lib/fingerprint.js"),{createAutoDrain:createAutoDrain,createAutoMint:createAutoMint}=require("./lib/auto.js");let envFile=null;try{envFile=require("../client/env-file.js")}catch{try{envFile=require("./vendor/env-file.js")}catch{}}let electron=null;try{electron=require("electron")}catch{}const os=require("node:os"),{createLocalEngine:createLocalEngine}=require("./lib/local/engine.js"),{createAccountKey:createAccountKey,startupKeyOverride:startupKeyOverride}=require("./lib/account-key.js"),{createSettingsStore:createSettingsStore,isReservedNamespace:isReservedNamespace}=require("./lib/settings.js"),sessionStore=require("./lib/sync/sessions.js"),memoryQueue=require("./lib/sync/memory-queue.js"),persistGate=require("./lib/sync/persist-gate.js"),avatarStore=require("./lib/avatar/store.js"),windowState=require("./lib/window-state.js"),deepLink=require("./lib/deep-link.js"),quickLauncherLib=require("./lib/quick-launcher.js");let foreignMemory;try{foreignMemory=require("../client/foreign-memory.js")}catch{foreignMemory=require("./vendor/foreign-memory.js")}const localTools=require("./lib/local/tools.js"),queueModule=require("./lib/local/queue.js"),autonomousModule=require("./lib/local/autonomous.js"),{autoContinuePlan:autoContinuePlan,autoContinueLimit:autoContinueLimit,autoRoundsDefault:autoRoundsDefault}=require("./lib/local/autonomous.js"),entitlementModule=require("./lib/local/entitlement.js"),telemetryModule=require("./lib/local/telemetry.js"),covenantModule=require("./lib/local/covenant.js"),{createTelemetry:createTelemetry,telemetryEnabled:telemetryEnabled}=telemetryModule,IPC_PREFIX="aegis:",MODEL_PREFIX="model:",SYNC_PREFIX="sync:",TOOLS_PREFIX="tools:",QUICK_PREFIX="quick:",QUEUE_PREFIX="queue:",AUTO_PREFIX="auto:",DESKTOP_CLASSES=Object.freeze(["aegis","byok","local"]),CHAT_DELTA_CHANNEL=`${IPC_PREFIX}chatDelta`,MENU_NEW_CHAT_CHANNEL=`${IPC_PREFIX}menuNewChat`,MENU_SEARCH_CHANNEL=`${IPC_PREFIX}menuSearch`,MENU_EXPORT_MARKDOWN_CHANNEL=`${IPC_PREFIX}menuExportMarkdown`,MENU_EXPORT_JSON_CHANNEL=`${IPC_PREFIX}menuExportJson`,MENU_FINGERPRINT_CHANNEL=`${IPC_PREFIX}menuFingerprint`,MENU_GOOGLE_DRIVE_CHANNEL=`${IPC_PREFIX}menuGoogleDrive`,DEEP_LINK_CHANNEL=`${IPC_PREFIX}deepLink`,QUICK_LAUNCHER_PUSH_CHANNEL=`${IPC_PREFIX}quickLauncherPush`,QUEUE_PROGRESS_CHANNEL=`${QUEUE_PREFIX}progress`,QUEUE_MAX_TASK_CHARS=8e3,QUEUE_MAX_ROUNDS=200,QUEUE_MAX_WORKERS=8,QUEUE_EFFORTS=new Set(["low","medium","high"]);function taggedChunk(e,t){return{...e&&"object"==typeof e?e:{delta:e},id:t}}const APP_VERSION=require("./package.json").version;function maskKey(e){return e?e.length<=10?"configured":`${e.slice(0,9)}…${e.slice(-4)}`:null}function upgradeInfo(e){const t=e&&e.status,n=e&&e.data||{},r="string"==typeof n.error?n.error:"";if(402!==t&&"free_session_limit_reached"!==r)return null;const o=null!=n.tokensUsed?n.tokensUsed:null!=n.sessionsUsed?n.sessionsUsed:null,s=null!=n.tokenLimit?n.tokenLimit:null!=n.freeSessionLimit?n.freeSessionLimit:null;return{url:n.upgradeUrl||"https://aegiscloud.org/go/upgrade?s=desktop&c=status_card",used:o,limit:s,code:r||"free_session_limit_reached"}}function errorText(e){return e&&e.message?e.message:String(e)}function isAbortShapedError(e){return!(!e||"object"!=typeof e||!0!==e.aegisCancelled&&"AbortError"!==e.name&&"ABORT_ERR"!==e.code)}function abortedTurnPayload(e){return{aborted:!0,status:0,code:"ABORT_ERR",message:errorText(e),choices:[{message:{content:""}}]}}function describeSyncError(e){const t=e&&(e.status||e.response&&e.response.status)||0,n=errorText(e);return upgradeInfo(e)?{kind:"quota",status:t||402,message:n,hint:"the plan's synced-token ceiling is reached — free space in the account dashboard or upgrade to keep syncing"}:401===t?{kind:"auth",status:t,message:n,hint:"the memory token was refused — re-enter your AEGIS API key to exchange a new one"}:{kind:"error",status:t||null,message:n,hint:null}}function syncErrorReason(e){return e?e.hint?`${e.message} — ${e.hint}`:e.message:"sync failed"}async function billingResult(e){try{const t=await e();return{ok:!0,url:t&&t.url||null,status:0,reason:null,setupRequired:!1,upgrade:null}}catch(e){const t=e&&e.data||{};return{ok:!1,url:null,status:e&&e.status||0,reason:errorText(e),setupRequired:Boolean(t.setup_required),upgrade:upgradeInfo(e)}}}const{bankAmount:bankAmount,parseTopupAmount:parseTopupAmount,startTopup:startTopup,createCreditWatch:createCreditWatch,TOPUP_POLL_MS:TOPUP_POLL_MS,TOPUP_POLL_TRIES:TOPUP_POLL_TRIES}=sharedTopup;let topupWatch=null;function watchTopupCredit(e,t){const n=t||{},r=n.intervalMs||TOPUP_POLL_MS,o=n.log;topupWatch&&clearInterval(topupWatch);const s=createCreditWatch(e,n.maxTries?{maxTries:n.maxTries}:void 0),a=()=>{topupWatch&&clearInterval(topupWatch),topupWatch=null,s.stop()},i=async()=>{const e=await s.tick();"credited"===e.state?(a(),(e=>{if("function"!=typeof n.notify)try{const t=electron&&electron.Notification;t&&t.isSupported()&&new t({title:"AEGIS Desktop",body:`Bank credited — €${e.toFixed(2)} available`}).show()}catch{}else n.notify(e)})(e.amount)):"expired"===e.state&&(a(),o&&o("top-up: no credit observed within the watch window; stopping"))};return topupWatch=setInterval(i,r),topupWatch&&"function"==typeof topupWatch.unref&&topupWatch.unref(),{stop:a,tick:i,get baseline(){return s.baseline}}}async function saveMemoryWithQueue(e,t,n,r){const o=avatarStore.normalizeMemoryEntry(n);try{const t=await e.memorySave(o);return r&&o&&"object"==typeof o&&r.awardSave(o,{session:o.session}),t}catch(e){const s=upgradeInfo(e);if(s)return{ok:!1,queued:0,saved:0,upgrade:s,reason:errorText(e)};if(!t||!n)throw e;return memoryQueue.enqueue(t,o),r&&o&&"object"==typeof o&&r.awardQueued(o,{session:o.session}),{ok:!0,queued:!0,reason:errorText(e)}}}async function normalizeMemoryRead(e){try{const t=await e,n=quotaFromPayload(t);return n?Object.assign({},t,{upgrade:n}):t}catch(e){const t=upgradeInfo(e);if(!t)throw e;return{entries:[],upgrade:t}}}function quotaFromPayload(e){if(!e||"object"!=typeof e)return null;const t=null!=e.tokensUsed?e.tokensUsed:null,n=null!=e.tokenLimit?e.tokenLimit:null;return null==t||null==n||n<=0||t<n?null:{url:"https://aegiscloud.org/go/upgrade?s=desktop&c=memory_quota",used:t,limit:n,code:"sync_quota_reached"}}async function importForeignMemory(e,t,n,r){const o=n||{},s=foreignMemory.scan({sources:o.sources,limit:o.limit||1e3}),a=foreignMemory.describe(s),i=s.sources.map((e=>({id:e.id,label:e.label,verified:e.verified,present:e.present,count:e.count,skipped:e.skipped,files:e.files}))),c={summary:a,totals:s.totals,sources:i};if(!o.confirm||!s.entries.length)return{...c,ok:!0,dryRun:!o.confirm,saved:0,queued:0};let u=0,l=0;const d=[];let p=null;for(const n of foreignMemory.chunk(s.entries,200))try{const t=await e.memorySaveBatch(n);if(u+=t&&t.saved||n.length,r&&n.length){const e=n[0]||{};r.awardImports(n,{source:e.session||e.source||"foreign-import"})}}catch(e){if(p=upgradeInfo(e),!p&&t)for(const e of n)memoryQueue.enqueue(t,e),l+=1,r&&r.awardQueued(e,{session:e&&e.session});d.push(errorText(e));break}return{...c,ok:0===d.length,dryRun:!1,saved:u,queued:l,upgrade:p,reason:d[0]||null}}function resolveIconsDir(e,t,n){return e?path.join(t,"icons"):path.join(n,"build")}function iconForPlatform(e,t){return"darwin"===t?path.join(e,"icon.icns"):"win32"===t?path.join(e,"icon.ico"):path.join(e,"icon.png")}function isSafeExternalUrl(e){if("string"!=typeof e||!e)return!1;try{const t=new URL(e);return"http:"===t.protocol||"https:"===t.protocol}catch{return!1}}function providerRouteConfigured(e){try{const t=e&&e.settings&&e.settings.list;return"function"==typeof t&&(t.call(e.settings)||[]).some((e=>{if(!e||!e.provider||isReservedNamespace(e.provider))return!1;const t="string"==typeof e.baseURL?e.baseURL.trim():"";return Boolean(e.configured||t)}))}catch{return!1}}function createIpcDispatch(e,t,n,r,o,s,a){let i=null;const c=new Proxy({},{get(e,n){i||(i=createFingerprintService({dir:t,getVaultBackend:a}));const r=i[n];return"function"==typeof r?r.bind(i):r},has:(e,n)=>(i||(i=createFingerprintService({dir:t,getVaultBackend:a})),n in i)}),u=()=>{try{return Boolean("function"==typeof o&&o())}catch{return!1}},l=r||(()=>Promise.reject(new Error("no opener configured")));return{status:()=>({appVersion:APP_VERSION,clientVersion:e.clientVersion,apiBase:e.apiBase,keyConfigured:Boolean(e.apiKey),keyMask:maskKey(e.apiKey),providerConfigured:u()}),setApiKey:t=>{try{const r=t&&t.key;return e.setApiKey(r||""),n&&n(e.apiKey),{ok:!0,keyConfigured:Boolean(e.apiKey),keyMask:maskKey(e.apiKey)}}catch(e){return{ok:!1,error:e&&e.message||String(e)}}},verifyApiKey:async()=>{try{return{ok:!0,...await e.verifyApiKey()}}catch(e){return{ok:!1,error:e&&e.message||String(e)}}},tokenBankBalance:()=>e.tokenBankBalance(),billingCheckout:()=>billingResult((()=>e.billingCheckout())),tokenBankTopup:async t=>{const n=parseTopupAmount(t&&t.amountEur);if(!n.ok)return{ok:!1,url:null,status:400,reason:n.message,setupRequired:!1,upgrade:null};const r=await startTopup(e,n.amount);return!0===r.ok&&r.url&&watchTopupCredit(e),{ok:!0===r.ok,url:r.url,status:r.status,reason:r.ok?null:r.hint?`${r.message} — ${r.hint}`:r.message,setupRequired:r.setupRequired,upgrade:null}},listModels:()=>e.listModels(),chatCompletion:t=>e.chatCompletion({prompt:t&&t.prompt,system:t&&t.system,model:t&&t.model,mode:t&&t.mode,maxTokens:t&&t.maxTokens,stream:t&&t.stream,onStream:t&&t.onStream}),fingerprintStatus:()=>c.status(),fingerprintKeygen:e=>c.keygen(e||{}),fingerprintMint:e=>c.mint(e||{}),fingerprintVerify:e=>c.verify(e||{}),fingerprintBackup:e=>c.backup(e||{})
5
+ // gdrive:AEGIS-AGPL/records and writes the `.anchor.json` link beside the
6
+ ,fingerprintPublish:e=>c.publish(e||{}),fingerprintAnchor:e=>c.anchor(e||{}),fingerprintVault:()=>c.vaultList(),fingerprintVaultAudit:()=>c.vaultAudit(),byokStatus:()=>e.byokStatus(),byokSet:t=>e.byokSet(t&&t.provider,t&&t.apiKey),memorySearch:t=>normalizeMemoryRead(e.memorySearch(t&&t.query,t&&t.limit)),memorySave:n=>saveMemoryWithQueue(e,t,n&&n.entry,s),memoryList:t=>normalizeMemoryRead(e.memoryList(t&&t.limit)),verifyToken:t=>e.verifyToken(t&&t.token),memoryActivate:t=>e.memoryActivate(t&&t.token),memoryPull:t=>e.memoryPull(t&&t.since),memorySaveBatch:t=>e.memorySaveBatch(t&&t.entries),memoryImport:n=>importForeignMemory(e,t,n,s),importConversation:t=>e.importConversation(t||{}),openExternal:e=>{const t=e&&e.url;return isSafeExternalUrl(t)?Promise.resolve(l(t)).then((()=>({ok:!0})),(e=>({ok:!1,reason:errorText(e)}))):Promise.resolve({ok:!1,reason:"unsupported URL scheme"})}}}function withReplyNotify(e,t,n){return n?e.then((e=>(n(t,e),e))):e}function registerIpc(e,t,n,r,o,s,a,i){const c=createIpcDispatch(t,n,r,o,a,void 0,i);for(const[t,n]of Object.entries(c))"chatCompletion"!==t?e.handle(`${IPC_PREFIX}${t}`,((e,t)=>n(t))):e.handle(`${IPC_PREFIX}${t}`,((e,t)=>{const r={...t||{}};if(!r.stream)return withReplyNotify(n(r),e,s);const o=e&&e.sender;return withReplyNotify(n({...r,stream:!0,onStream:e=>{o&&"function"==typeof o.send&&!o.isDestroyed()&&o.send(CHAT_DELTA_CHANNEL,taggedChunk(e,r.sessionId))}}),e,s)}));return c}function createConfirmModeDispatch(e){if(!e)throw new Error("createConfirmModeDispatch requires a settings store");return{getConfirmMode:()=>({enabled:e.getConfirmMode()}),setConfirmMode:t=>{const n=t&&t.enabled;return"boolean"!=typeof n?{enabled:e.getConfirmMode()}:{enabled:e.setConfirmMode(n)}}}}function withAutoRoundsSwitch(e,t){const n=e.setConfirmMode;return{...e,setConfirmMode:e=>{const r=n(e);return e&&"boolean"==typeof e.enabled&&t&&"function"==typeof t.setAutoRounds&&t.setAutoRounds(!1===e.enabled),r}}}function registerConfirmModeIpc(e,t){for(const[n,r]of Object.entries(t))e.handle(`${IPC_PREFIX}${n}`,((e,t)=>r(t)));return t}function createVaultBackendDispatch(e){if(!e)throw new Error("createVaultBackendDispatch requires a settings store");return{getVaultBackend:()=>e.vaultBackendConfig(),setVaultBackend:t=>e.setVaultBackendConfig(t&&t.backend)}}function createTelemetryDispatch(e){const t=e||null;return{telemetryEvent:e=>{try{if(!t||"function"!=typeof t.record)return{ok:!1,reason:"telemetry off"};const n=e&&"object"==typeof e?e:{};return t.record(n.event,{campaign:n.campaign,surface:n.surface})?{ok:!0}:{ok:!1,reason:"not an allowlisted funnel event"}}catch(e){return{ok:!1,reason:errorText(e)}}},telemetryReport:()=>{try{return t&&"function"==typeof t.report?t.report():{enabled:!1,transport:"off"}}catch(e){return{enabled:!1,error:errorText(e)}}}}}function createCovenantDispatch(e){return{covenantStatus:()=>({ok:!0,covenant:e.snapshot(),gapReport:e.gapReport()}),covenantGapReport:()=>({ok:!0,gapReport:e.gapReport()})}}function registerCovenantIpc(e,t){for(const[n,r]of Object.entries(t))e.handle(`${IPC_PREFIX}${n}`,((e,t)=>r(t)));return t}function registerTelemetryIpc(e,t){for(const[n,r]of Object.entries(t))e.handle(`${IPC_PREFIX}${n}`,((e,t)=>r(t)));return t}function safeExportBasename(e){return(e&&(e.title||e.id)||"session").replace(/[^\w.-]+/g,"_").slice(0,80)||"session"}function createExportDispatch(e,t,n,r){return{exportSession:async o=>{const s=o&&o.sessionId,a=o&&"json"===o.format?"json":"markdown",i=s?e.getSession(t,s):null;if(!i)return{ok:!1,reason:"session not found"};const c="json"===a?e.toJson(i):e.toMarkdown(i),u="json"===a?"json":"md",l="json"===a?[{name:"JSON",extensions:["json"]}]:[{name:"Markdown",extensions:["md"]}],d=await n({defaultPath:`${safeExportBasename(i)}.${u}`,filters:l});if(!d||d.canceled||!d.filePath)return{ok:!1,canceled:!0};try{await r(d.filePath,c)}catch(e){return{ok:!1,reason:errorText(e)}}return{ok:!0,filePath:d.filePath}}}}function registerExportIpc(e,t,n,r,o){const s=createExportDispatch(t,n,r,o);for(const[t,n]of Object.entries(s))e.handle(`${IPC_PREFIX}${t}`,((e,t)=>n(t)));return s}function createQuickLauncherDispatch(e,t={}){const n=Boolean(t.isPackaged),r=t.applyConfig||(()=>({active:!1,reason:null})),o=t.pushToMain||(()=>({ok:!1,reason:"no main window"}));let s={active:!1,reason:null};return{status:function(){return{...e.quickLauncherConfig(),packaged:n,...s}},setConfig:function(t){const o=e.setQuickLauncherConfig({enabled:t&&t.enabled,shortcut:t&&t.shortcut});return s=r(o)||{active:!1,reason:null},{...o,packaged:n,...s}},pushToMain:(e,t)=>o(e,t)}}function registerQuickLauncherIpc(e,t){for(const[n,r]of Object.entries(t))"pushToMain"!==n?e.handle(`${QUICK_PREFIX}${n}`,((e,t)=>r(t))):e.handle(`${QUICK_PREFIX}${n}`,((e,t)=>r(t,e)));return t}function resolveUserDataDir(e){if(e&&"function"==typeof e.getPath)try{return e.getPath("userData")}catch{}return path.join(os.homedir(),".aegiscode")}function createEngine(e,{app:t,safeStorage:n,dir:r,sessionsDir:o}={}){const s=r||resolveUserDataDir(t),a=createSettingsStore({dir:s,safeStorage:n});return a.migrateLegacyAegisKey(),{engine:createLocalEngine({aegis:e,settings:a}),sessionsDir:o||sessionStore.resolveStoreDir(s),settingsDir:s,settings:a}}const PREVIEW_READ_MAX_BYTES=524288;function readTextFileForPreview(e){try{const t=e&&"string"==typeof e.file?e.file:"",n=e&&"string"==typeof e.cwd?e.cwd:"";if(!t||!n||!path.isAbsolute(n))return null;const r=fs.realpathSync(n),o=fs.realpathSync(path.resolve(r,t)),s=path.relative(r,o);if(""===s||s.startsWith("..")||path.isAbsolute(s))return null;const a=fs.statSync(o);if(!a.isFile())return null;if(a.size<=PREVIEW_READ_MAX_BYTES)return{text:fs.readFileSync(o,"utf8"),truncated:!1};const i=fs.openSync(o,"r");try{const e=Buffer.alloc(PREVIEW_READ_MAX_BYTES),t=fs.readSync(i,e,0,PREVIEW_READ_MAX_BYTES,0);let n=e.toString("utf8",0,t);const r=n.lastIndexOf("\n");return r>0&&(n=n.slice(0,r+1)),{text:n,truncated:!0}}finally{fs.closeSync(i)}}catch{return null}}function createModelDispatch(e,{env:t=process.env,settings:n}={}){const r=new Map;let o,s=autoRoundsDefault(t),a=!1;const i=()=>{const t=n||e&&e.settings||null,r=e=>"boolean"==typeof e?e:void 0;let i;try{if(t&&"function"==typeof t.confirmModeState){const e=t.confirmModeState();i=r(e&&e.enabled)}void 0===i&&t&&"function"==typeof t.getConfirmMode&&(i=r(t.getConfirmMode()))}catch{}return a||void 0===i||(void 0===o?o=i:i!==o&&(a=!0,s=!0)),!(!s||!t||void 0===i)&&!1===i},c={listClasses:async()=>(await e.listClasses()).filter((e=>DESKTOP_CLASSES.includes(e.class))),listModels:t=>e.listModels(t&&t.class),chat:n=>{const o=n&&n.onStream;if("function"!=typeof o)return e.chat(n,o);const s=n&&n.sessionId||"";return e.chat(n,(n=>{if(n&&n.rounds&&n.rounds.id&&i()){const a=autoContinuePlan({on:!0,used:r.get(s)||0,limit:autoContinueLimit(t)});if(r.set(s,a.used),a.auto){const t="function"==typeof e.respondRoundsAsk?e.respondRoundsAsk(n.rounds.id,!0):{ok:!1};if(t&&t.ok){const e=Number(n.rounds.spent)||0;return void o({note:`auto mode: the ${e}-round limit was reached — continuing automatically (${a.used}/${a.cap}, then it asks).`})}}}o(n)}))},"settings.get":()=>e.settings.list().filter((e=>!(e&&isReservedNamespace(e.provider)))),"settings.set":t=>e.settings.set(t&&t.provider,{baseURL:t&&t.baseURL,key:t&&t.key}),"settings.remove":t=>e.settings.remove(t&&t.provider),"userModels.get":()=>e.userModels(),"userModels.add":t=>e.addUserModel(t&&t.provider,t&&t.model),"userModels.remove":t=>e.removeUserModel(t&&t.provider,t&&t.model),"userModels.discover":t=>e.discoverModels(t&&t.provider),"userModels.addAll":t=>e.addUserModels(t&&t.provider,t&&t.ids),"userModels.canDiscover":t=>Boolean(e.canDiscoverModels(t&&t.provider)),"userModels.discoverable":()=>e.discoverableProviders(),"memoryPersist.get":()=>e.settings.memoryPersistState(),"memoryPersist.set":t=>e.settings.setMemoryPersist(Boolean(t&&t.enabled)),cancel:t=>e.cancel(t&&t.sessionId),respondApproval:t=>e.respondApproval(t&&t.approvalId,t&&t.decision),respondRoundsAsk:t=>"function"==typeof e.respondRoundsAsk?e.respondRoundsAsk(t&&t.roundsAskId,t&&t.decision):{ok:!1},clearApprovals:t=>e.clearSessionApprovals(t&&t.sessionId)};return Object.defineProperties(c,{autoRoundsOn:{value:i,enumerable:!1},setAutoRounds:{value:e=>{s=Boolean(e),a=!0},enumerable:!1}}),c}function createSyncDispatch(e,t,n,r){let o=null;function s(){return Boolean(n&&n.apiKey)}async function a(){const r=e.listPending(t);if(!s())return{ok:!1,queued:r.length,reason:"no AEGIS key configured"};const a=await async function(){const e=memoryQueue.listQueued(t);if(!e.length)return{flushed:0,remaining:0,upgrade:null};const r=[];let o=0,s=null;for(let t=0;t<e.length;t+=1)try{await n.memorySave(e[t]),o+=1}catch(n){const o=upgradeInfo(n);if(o){s=o,r.push(...e.slice(t));break}r.push(e[t])}return memoryQueue.save(t,r),{flushed:o,remaining:r.length,upgrade:s}}();if(!r.length)return{ok:!0,queued:0,pushed:0,memoryFlushed:a.flushed,upgrade:a.upgrade};let i=0,c=null,u=null;for(const o of r)try{const r=await n.conversationSyncPush({session_id:o.id,title:o.title,messages:o.messages}),s=r&&(r.session_id||r.id);e.markSynced(t,o.id,{remoteId:s}),i+=1}catch(e){if(c=e,u=describeSyncError(e),"quota"===u.kind||"auth"===u.kind)break}const l=e.listPending(t).length;i&&(o=Date.now());const d=(c?upgradeInfo(c):null)||a.upgrade;return 0===i&&c?{ok:!1,queued:l,reason:syncErrorReason(u),failed:u?[u]:[],kind:u?u.kind:"error",status:u?u.status:0,hint:u?u.hint:null,memoryFlushed:a.flushed,upgrade:d}:{ok:!0,queued:l,pushed:i,failed:u?[u]:[],memoryFlushed:a.flushed,upgrade:d}}const i=persistGate.createAutoPush({push:a,dir:r||t});return{auto:()=>i.auto(),memoryPersistState:()=>i.gate(),listSessions:()=>({sessions:e.listSessions(t)}),open:n=>e.getSession(t,n&&n.sessionId),save:n=>e.upsertSession(t,n||{}),append:n=>e.appendMessage(t,n&&n.sessionId,n&&n.message),delete:n=>e.deleteSession(t,n&&n.sessionId),push:a,pull:async function(){if(!s())return{ok:!1,merged:0,reason:"no AEGIS key configured"};try{const r=await n.conversationSyncPull(),s=e.mergeRemoteSessions(t,r&&r.sessions||[]);return o=Date.now(),{ok:!0,merged:s,failed:[],upgrade:null}}catch(e){const t=describeSyncError(e);return{ok:!1,merged:0,reason:syncErrorReason(t),failed:[t],kind:t.kind,status:t.status,hint:t.hint,upgrade:upgradeInfo(e)}}},status:()=>({count:e.listSessions(t).length,pending:e.listPending(t).length,cloud:s(),lastSyncAt:o})}}const HEARTBEAT_RETRY_BASE_MS=5e3,HEARTBEAT_RETRY_MAX_MS=3e4;function createHeartbeatRetry(e,t={}){const n="function"==typeof t.now?t.now:()=>Date.now(),r=Number(t.baseMs)>0?Number(t.baseMs):HEARTBEAT_RETRY_BASE_MS,o=Number(t.maxMs)>0?Number(t.maxMs):HEARTBEAT_RETRY_MAX_MS;let s=0,a=0,i=null,c=!1;function u(e){s+=1;const t=Math.min(o,r*2**Math.max(0,s-1));return i=e||"push failed",a=n()+t,{attempted:!0,ok:!1,error:i,failures:s,retryInMs:t}}return{run:async function(){const t=n();if(s>0&&t<a)return{attempted:!1,ok:!1,error:i,failures:s,retryInMs:a-t};if(c)return{attempted:!1,ok:!1,error:i,failures:s,retryInMs:0};c=!0;try{const t=await e();return t&&!1===t.ok?u(t.reason||t.kind||"push failed"):(s=0,a=0,i=null,{attempted:!0,ok:!0,error:null,failures:0,retryInMs:0,result:t})}catch(e){return u(errorText(e))}finally{c=!1}},state:function(){const e=n();return{failures:s,lastError:i,retryInMs:s>0&&a>e?a-e:0}},recordFailure:e=>u(errorText(e))}}function registerModelIpc(e,t,n,r,o,s,a){const i=createModelDispatch(t),c=createSyncDispatch(sessionStore,n,r,a),u=createHeartbeatRetry((()=>c.push()),s);function l(){const e=u.run();e&&"function"==typeof e.catch&&e.catch((e=>u.recordFailure(e)))}for(const[t,n]of Object.entries(i))"chat"!==t?"listModels"!==t?e.handle(`${MODEL_PREFIX}${t}`,((e,t)=>n(t))):e.handle(`${MODEL_PREFIX}${t}`,((e,t)=>(l(),n(t)))):e.handle(`${MODEL_PREFIX}${t}`,((e,t)=>{const r={...t||{}},s=!0===r.bestEffort;delete r.bestEffort;const a="low"===r.effort||"medium"===r.effort?r.effort:"high";r.effort=a;const i=e&&e.sender,c=r.class||"model",u=n({...r,onStream:e=>{i&&"function"==typeof i.send&&!i.isDestroyed()&&i.send(CHAT_DELTA_CHANNEL,taggedChunk(e,r.sessionId))}});return s?u.then((t=>(o&&o(e,t),t)),(e=>{const t=e&&"number"==typeof e.status?e.status:0,n=e&&e.code||"";return console.warn(`[model] background ${c} turn produced no answer: ${errorText(e)}${t?` (status ${t})`:""}${n?` [${n}]`:""}`),{bestEffortFailed:!0,status:t,code:n||void 0,message:errorText(e),choices:[{message:{content:""}}]}})):withReplyNotify(u,e,o).catch((e=>{if(!isAbortShapedError(e))throw e;return console.warn(`[model] ${c} turn stopped before it finished: ${errorText(e)} [ABORT_ERR]`),abortedTurnPayload(e)}))}));e.on(`${MODEL_PREFIX}readTextFile`,((e,t)=>{e.returnValue=readTextFileForPreview(t)}));for(const[t,n]of Object.entries(c))"status"!==t?e.handle(`${SYNC_PREFIX}${t}`,((e,t)=>n(t))):e.handle(`${SYNC_PREFIX}${t}`,((e,t)=>{l();const r=n(t);return r&&"object"==typeof r?{...r,retry:u.state()}:r}));return{modelDispatch:i,syncDispatch:c,heartbeat:u}}function createQueueEngine(e,t){return createLocalEngine({aegis:e,settings:t,getConfirmMode:()=>!1})}function queueEngineWithDeniedApprovals(e){return e&&"function"==typeof e.chat?{...e,chat:(t,n)=>e.chat(t,(t=>{(t=>{const n=t&&t.approval;if(n&&n.id&&"function"==typeof e.respondApproval)try{e.respondApproval(n.id,"deny")}catch{}})(t),(t=>{const n=t&&t.rounds;if(n&&n.id&&"function"==typeof e.respondRoundsAsk)try{e.respondRoundsAsk(n.id,!1)}catch{}})(t),"function"==typeof n&&n(t)}))}:e}function isExistingDir(e){try{return fs.statSync(e).isDirectory()}catch{return!1}}function clampInt(e,t,n){const r=Number(e);return!Number.isInteger(r)||r<t||r>n?null:r}function createQueueDispatch({engine:e,env:t=process.env,queue:n=queueModule,autonomous:r=autonomousModule,entitlement:o=entitlementModule.createEntitlement({}),log:s=(()=>{})}={}){if(!e||"function"!=typeof e.chat)throw new Error("createQueueDispatch: a local engine with chat() is required");let a=null,i=!1,c=!1;const u=r.createQueueWorker({engine:queueEngineWithDeniedApprovals(e),env:t,log:e=>{e&&"start"===e.type&&(a=e.taskId),e&&"finish"===e.type&&(a=null),s(e)},canFanOut:()=>o.current().entitled});function l(){const e=n.loadQueue(t);return{ok:!0,items:e,pending:n.pendingCount(e),running:e.find((e=>e&&"running"===e.status))||null,draining:c,stopping:i,defaultCwd:process.cwd(),defaultModel:r.resolveModel({env:t}),queueFile:n.queuePath(t),runs:n.readRuns(t,{limit:20}),entitlement:o.current()}}const d=e=>(e||[]).map((e=>({id:e.id,ok:Boolean(e.ok),error:e.error||null})));return{enqueue:async e=>{await o.refresh();const s=o.gateFanout({workers:clampInt(e&&e.workers,1,QUEUE_MAX_WORKERS),fanout:Boolean(e&&!0===e.fanout)});if(!s.ok)return{...s,...l()};const{opts:a,error:i}=function(e){const t=e||{},n=String(null==t.task?"":t.task).trim();if(!n)return{error:"a queued task needs text"};if(n.length>QUEUE_MAX_TASK_CHARS)return{error:`task text is too long (${n.length} > ${QUEUE_MAX_TASK_CHARS} chars)`};const o=String(null==t.cwd?"":t.cwd).trim()||process.cwd();if(!path.isAbsolute(o))return{error:"the working directory must be an absolute path"};if(!isExistingDir(o))return{error:`not a directory: ${o}`};const s=r.normalizeClass(t.class),a=String(null==t.model?"":t.model).trim().slice(0,120)||null,i=r.modelRefusal(a,s);if(i)return{error:i};const c=String(null==t.effort?"":t.effort).trim().toLowerCase();return{opts:{task:n,cwd:o,class:s,model:a,effort:QUEUE_EFFORTS.has(c)?c:null,workers:clampInt(t.workers,1,QUEUE_MAX_WORKERS),maxRounds:clampInt(t.maxRounds,1,QUEUE_MAX_ROUNDS),singlePass:!(!0===t.fanout||!0===t.autonomous),commit:Boolean(t.commit),source:"desktop"}}}(e);return i?{ok:!1,reason:i,...l()}:{ok:!0,item:n.addTask(t,a),...l()}},list:()=>l(),entitlement:async e=>(await o.refresh({force:Boolean(e&&!0===e.force)}),{ok:!0,...l()}),clear:e=>({...n.clearQueue(t,{all:Boolean(e&&e.all)}),ok:!0,...l()}),retry:e=>{const r=e&&e.id,o=n.retryTask(t,r);return o?{ok:!0,item:o,...l()}:{ok:!1,reason:`no task #${r}`,...l()}},remove:e=>{const r=e&&e.id,o=n.loadQueue(t),s=n.findTask(o,r);return s?"running"===s.status?{ok:!1,reason:`task #${s.id} is running — stop the drain first`,...l()}:(n.saveQueue(t,o.filter((e=>Number(e&&e.id)!==Number(s.id)))),{ok:!0,removed:s.id,...l()}):{ok:!1,reason:`no task #${r}`,...l()}},drain:()=>async function(){if(c)return{ok:!1,reason:"a drain is already running in this window",...l()};let e;c=!0,i=!1;try{const t=await u.proceed({max:1});e=t.locked?{ok:!1,locked:!0,holder:t.holder,ran:[]}:{ok:!0,ran:d(t.ran)}}catch(t){e={ok:!1,reason:errorText(t),ran:[]}}return c=!1,a=null,{...e,...l()}}(),proceed:()=>async function(){if(c)return{ok:!1,reason:"a drain is already running in this window",...l()};c=!0,i=!1;const e=[];let t;try{for(;;){const n=await u.proceed({max:1});if(n.locked){t={ok:!1,locked:!0,holder:n.holder,ran:d(e)};break}if(!n.ran.length){t={ok:!0,stopped:i,ran:d(e)};break}if(e.push(...n.ran),i){t={ok:!0,stopped:!0,ran:d(e)};break}}}catch(n){t={ok:!1,reason:errorText(n),ran:d(e)}}return c=!1,a=null,{...t,...l()}}(),stop:e=>{i=!0;const t=e&&null!=e.id?e.id:a,n=null==t?null:u.cancel(t);return{ok:!0,wasDraining:c,cancelled:n,...l()}}}}function createAutoDispatch({autoDrain:e,autoMint:t}={}){if(!e||"function"!=typeof e.state)throw new Error("createAutoDispatch: the auto-drain automation is required");if(!t||"function"!=typeof t.state)throw new Error("createAutoDispatch: the auto-mint automation is required");return{autoStatus:()=>({ok:!0,drain:e.state(),mint:t.state()}),autoSetDrain:t=>{const n=t||{},r={};null!=n.enabled&&(r.enabled=Boolean(n.enabled)),null!=n.intervalMs&&(r.intervalMs=Number(n.intervalMs));try{return{ok:!0,drain:e.set(r)}}catch(e){return{ok:!1,error:errorText(e)}}},autoSetMint:e=>{const n=e||{},r={};null!=n.enabled&&(r.enabled=Boolean(n.enabled)),null!=n.publish&&(r.publish=Boolean(n.publish));try{return{ok:!0,mint:t.set(r)}}catch(e){return{ok:!1,error:errorText(e)}}},autoSealNow:async e=>{const n=e||{};null!=n.publish&&t.set({publish:Boolean(n.publish)});try{const e=await t.flush();return t.reset(),{ok:!0,...e}}catch(e){return t.reset(),{ok:!1,error:errorText(e)}}}}}function registerQueueIpc(e,{engine:t,env:n,queue:r,autonomous:o,entitlement:s,aegis:a}={}){let i=null;const c=createQueueDispatch({engine:t,env:n,queue:r,autonomous:o,entitlement:s||entitlementModule.createEntitlement({probe:a&&"function"==typeof a.verifyApiKey?async()=>{const e=await a.verifyApiKey(),t=e&&"object"==typeof e?{...e}:{};if("function"==typeof a.tokenBankBalance)try{const e=await a.tokenBankBalance();e&&"object"==typeof e&&(null!=e.balance_micros?t.balance_micros=e.balance_micros:null!=e.balance_eur&&(t.balance_eur=e.balance_eur))}catch{}return t}:void 0}),log:e=>{i&&"function"==typeof i.send&&("function"==typeof i.isDestroyed&&i.isDestroyed()||i.send(QUEUE_PROGRESS_CHANNEL,e))}});for(const[t,n]of Object.entries(c))e.handle(`${QUEUE_PREFIX}${t}`,((e,t)=>(i=e&&e.sender||null,n(t))));return c}function registerAutoIpc(e,t){for(const[n,r]of Object.entries(t))e.handle(`${AUTO_PREFIX}${n}`,((e,t)=>r(t)));return t}const UPDATE_STATUS_CHANNEL=`${IPC_PREFIX}updateStatus`,ACCOUNT_STATUS_CHANNEL=`${IPC_PREFIX}accountStatusChanged`,UPDATE_CHECK_INTERVAL_MS=144e5,UPDATE_RETRY_BACKOFF_MS=[6e4,3e5,9e5,36e5];function describeRegistryFailure(e,t,n){switch(e){case"not-found":return`${n} is not published on the npm registry (404)`;case"forbidden":return`the npm registry refused the request (${t||"auth"})`;case"malformed":return"the npm registry sent a response this app could not read";case"too-large":return"the npm registry response was too large to read";case"timeout":return"the npm registry did not respond in time";case"network":return"the npm registry could not be reached";default:return"the update check did not complete"}}function createNpmUpdateManager({onStatus:e,packaged:t=!1,now:n=(()=>Date.now()),fetchImpl:r,setTimeoutImpl:o=setTimeout,clearTimeoutImpl:s=clearTimeout,runUpgrade:a,relaunch:i}={}){const c="aegis-desktop";let u="unknown";try{u=require("./package.json").version}catch{}let l={status:"idle",version:null,error:null,channel:"npm",reason:null,transient:!1,attempts:0,retryInMs:0,command:null,plan:null,method:null,canDownload:!1};const d=t=>(l={...l,...t},e&&e(l),l);let p=null;try{p=require("../client/update.js")}catch{try{p=require("./vendor/update.js")}catch{p=null}}let f=0,g=0,h=null,m=null,y=null,E=null;function k(){h&&s(h),h=null}async function _({announce:e=!1}={}){if(!p)return d({status:"disabled",error:"update checker unavailable"});if(m)return m;m=(async()=>{const s=l.status,a=l.version;let i;d({status:"checking",error:null,reason:null,transient:!1});try{i=await async function(){if(r)return r({pkg:c});if(p&&"function"==typeof p.checkPublished){const e=await p.checkPublished({current:u,pkg:c,now:n(),intervalMs:0});return e?{ok:e.ok,latest:e.latest,reason:e.reason,status:e.status}:{ok:!1,latest:null,reason:"unknown",status:null}}return{ok:!1,latest:null,reason:"unavailable",status:null}}()}catch(e){i={ok:!1,latest:null,reason:"unknown",status:null,error:errorText(e)}}if(i.ok){if(f=0,g=0,k(),!p.isNewer(i.latest,u))return d({status:"up-to-date",version:i.latest,error:null,reason:null,transient:!1,attempts:0,retryInMs:0});const e=function(){if(E)return E;const e=p&&p.detectInstallMethod;if("function"!=typeof e)return E="npm",E;try{E=e({env:process.env,argv:process.argv,pkg:c,entry:__dirname,fileExists:e=>fs.existsSync(path.resolve(__dirname,"..",e))})}catch{E="npm"}return E}(),n="function"==typeof p.updatePlan?p.updatePlan({current:u,latest:i.latest,behind:!0,channel:"npm",pkg:c,method:e}):null;return d({status:"available",version:i.latest,error:null,reason:null,transient:!1,attempts:0,retryInMs:0,plan:n,method:e,command:t?`${i.latest} is published — reinstall AEGIS Desktop from the installer to pick it up`:n&&n.instruction||`npm i -g ${c}@latest`,commandIsSentence:t||Boolean(n&&!n.apply),canDownload:Boolean(n&&n.apply)&&!t})}f+=1;const m=i.error||describeRegistryFailure(i.reason,i.status,c);return y=i.reason,v=i.status,("function"==typeof p.isTransientReason?p.isTransientReason(y,v):"unknown"===y)?(function(){k();const e=UPDATE_RETRY_BACKOFF_MS[Math.min(Math.max(f-1,0),UPDATE_RETRY_BACKOFF_MS.length-1)];g=n()+e,h=o((()=>{h=null,_().catch((()=>{}))}),e),h&&h.unref&&h.unref(),d({retryInMs:e})}(),d(e?{status:"error",error:m,reason:i.reason,transient:!0,attempts:f}:{status:"available"===s?"available":"unavailable",version:a,error:m,reason:i.reason,transient:!0,attempts:f})):(k(),g=0,d({status:"error",error:m,reason:i.reason,transient:!1,attempts:f,retryInMs:0}));var y,v})();try{return await m}finally{m=null}}return{status:()=>l,check:_,download:async()=>{const e=!t&&l.plan&&l.plan.apply;if(!e)return d({status:"available"===l.status?"available":l.status,command:l.plan&&l.plan.instruction||l.command,canDownload:!1});d({status:"downloading",error:null,progress:0});const n=await function(e){const t=a||((e,t)=>new Promise((n=>{execFile(e,t,{timeout:18e4,windowsHide:!0},((e,t,r)=>{if(e)return n({ok:!1,output:String(r||t||e.message||e)});n({ok:!0,output:String(t||"")})}))}))),n=!a&&p&&"function"==typeof p.installSpawn?p.installSpawn(e,{env:process.env}):null,r=n?n.command:e.command,o=n?n.args:e.args;return Promise.resolve(t(r,o)).then((e=>({ok:Boolean(e&&e.ok),output:String(e&&e.output||"")})),(e=>({ok:!1,output:errorText(e)})))}(e);return n.ok?d({status:"downloaded",version:l.version,error:null,progress:100,command:`npm i -g ${c}@latest`,canDownload:!1}):d({status:"error",transient:!1,error:n.output.trim().split("\n").slice(-3).join(" ").trim()||"the installer failed",command:`${e.command} ${e.args.join(" ")}`})},quitAndInstall:()=>{"downloaded"===l.status&&(i||(()=>{try{const{app:e}=require("electron");e.relaunch(),e.exit(0)}catch{}}))()},start:()=>{_().catch((()=>{})),y=setInterval((()=>_().catch((()=>{}))),UPDATE_CHECK_INTERVAL_MS),y.unref&&y.unref()},stop:()=>{k(),y&&clearInterval(y),y=null},retryState:()=>({attempts:f,retryAt:g,inFlight:!!m,hasTimer:!!h})}}function createUpdateManager({isPackaged:e,onStatus:t}){let n={status:"idle",version:null,error:null,canDownload:!1};return createNpmUpdateManager({onStatus:function(e){n={...n,...e},t&&t(n)},packaged:Boolean(e)})}function createUpdateDispatch(e){return{checkForUpdates:()=>e.check({announce:!0}),downloadUpdate:()=>e.download(),quitAndInstallUpdate:()=>(e.quitAndInstall(),{ok:!0}),updateStatus:()=>Promise.resolve(e.status())}}function registerUpdateIpc(e,t){const n=createUpdateDispatch(t);for(const[t,r]of Object.entries(n))e.handle(`${IPC_PREFIX}${t}`,((e,t)=>r(t)));return n}function sendToFocusedWindow(e,t){const n=e.getFocusedWindow()||e.getAllWindows()[0];n&&!n.isDestroyed()&&n.webContents.send(t)}function sendDeepLinkToWindow(e,t){if(!e||e.isDestroyed()||!t)return;const n=()=>{e.isDestroyed()||e.webContents.send(DEEP_LINK_CHANNEL,t)};e.webContents.isLoadingMainFrame()?e.webContents.once("did-finish-load",n):n()}function buildAppMenu({app:e,Menu:t,BrowserWindow:n,updateManager:r,loggedIn:o}){const s="darwin"===process.platform;e.setAboutPanelOptions({applicationName:e.getName(),applicationVersion:e.getVersion(),version:e.getVersion()});const a={label:"Check for Updates…",click:()=>r.check({announce:!0})},i={label:"New Chat",accelerator:"CmdOrCtrl+N",click:()=>sendToFocusedWindow(n,MENU_NEW_CHAT_CHANNEL)},c={label:"Search…",accelerator:"CmdOrCtrl+K",click:()=>sendToFocusedWindow(n,MENU_SEARCH_CHANNEL)},u={label:"Save as…",accelerator:"CmdOrCtrl+S",click:()=>sendToFocusedWindow(n,MENU_EXPORT_MARKDOWN_CHANNEL)},l={label:"Export Session…",click:()=>sendToFocusedWindow(n,MENU_EXPORT_JSON_CHANNEL)},d={label:"Authorship Panel…",click:()=>sendToFocusedWindow(n,MENU_FINGERPRINT_CHANNEL)},p={label:"Google Drive Backup…",enabled:Boolean(o),click:()=>sendToFocusedWindow(n,MENU_GOOGLE_DRIVE_CHANNEL)},f=[...s?[{label:e.getName(),submenu:[{role:"about"},a,{type:"separator"},{role:"services"},{type:"separator"},{role:"hide"},{role:"hideOthers"},{role:"unhide"},{type:"separator"},{role:"quit"}]}]:[],{label:"File",submenu:[i,{type:"separator"},u,l,{type:"separator"},...s?[]:[a,{type:"separator"}],s?{role:"close"}:{role:"quit"}]},{label:"Edit",submenu:[{role:"undo"},{role:"redo"},{type:"separator"},{role:"cut"},{role:"copy"},{role:"paste"},{role:"selectAll"}]},{label:"View",submenu:[c,{type:"separator"},d,p,{type:"separator"},{role:"reload",accelerator:"CmdOrCtrl+R"},{role:"forceReload"},{type:"separator"},{role:"toggleDevTools",accelerator:"CmdOrCtrl+Shift+I"}]},...s?[]:[{label:"Help",submenu:[{role:"about"}]}]];return t.buildFromTemplate(f)}function bootstrap(){const{app:e,BrowserWindow:t,ipcMain:n,safeStorage:r,shell:o,Menu:s,Notification:a,screen:i,dialog:c,globalShortcut:u}=electron;if(e.disableHardwareAcceleration(),e.commandLine.appendSwitch("disable-gpu"),e.commandLine.appendSwitch("disable-software-rasterizer"),e.commandLine.appendSwitch("disable-gpu-compositing"),e.commandLine.appendSwitch("disable-features","UsePortalAccentColor"),process.argv.some((e=>/^--(log-level|enable-logging)/.test(e)))||process.env.ELECTRON_ENABLE_LOGGING||e.commandLine.appendSwitch("log-level","3"),!e.requestSingleInstanceLock())return void e.quit();if(e.setName("AEGIS Desktop"),envFile&&"function"==typeof envFile.loadEnvFile)try{const e=envFile.loadEnvFile();e.loose&&console.warn(`aegis: ${e.file} is readable by other accounts (mode ${e.mode.toString(8)}) — consider \`chmod 600\` on it.`)}catch(e){console.warn(`aegis: could not read ${envFile.envFileFor()}: ${e&&e.message}`)}e.setAsDefaultProtocolClient(deepLink.PROTOCOL);let l=deepLink.parseDeepLinkArgv(process.argv);e.on("open-url",((e,n)=>{e.preventDefault();const r=deepLink.parseDeepLinkUrl(n);if(!r)return;const o=t.getFocusedWindow()||t.getAllWindows()[0];o?sendDeepLinkToWindow(o,r):l=r}));const d=createClient(credentials.clientOptions()),p=resolveUserDataDir(e),{engine:f,sessionsDir:g,settingsDir:h,settings:m}=createEngine(d,{app:e,safeStorage:r,dir:p}),y=createAccountKey({aegis:d,credentials:credentials,persistKey:e=>m.setAegisKey(e),provision:(e,t)=>autoProvision.autoProvisionApiKey(e,t,{app:"desktop"}),describeFailure:autoProvision.provisionFailureText,onChange:e=>U(e)}),E=createGoogleConnect({aegis:d,credentials:credentials,persistKey:e=>m.setAegisKey(e),openUrl:e=>!!isSafeExternalUrl(e)&&(o.openExternal(e),!0),googleSignIn:googleSignIn,driveConnect:driveConnectLib,driveUpload:driveUploadLib,onAccountChange:()=>U(y.status())}),k=Boolean(d.apiKey),_=autoProvision.autoProvisionApiKey(d,credentials,{app:"desktop"}).then((e=>{if(y.recordStartup(e),!e.key&&!d.apiKey){const t=e.error&&(e.error.status||e.error.message||String(e.error))||"no key returned";console.warn(`auto-provision: no account key from register-device (${t})`)}return e}));try{const{wrapProviderKeyMirror:e}=require("./lib/provider-key-mirror.js"),t=e=>console.warn(`aegis: ${e}`);e(m,envFile,{onWarn:t}),f&&f.settings&&f.settings!==m&&e(f.settings,envFile,{onWarn:t})}catch(e){console.warn(`aegis: could not wire the shared env-file key mirror: ${e&&e.message}`)}let v=null;const A=(e,n)=>{!function(e){const n=e&&e.sender,r=n&&t.fromWebContents(n);if(!r||r.isDestroyed())return;if(r.isFocused()&&r.isVisible())return;if(!a||!a.isSupported())return;const o=new a({title:"AEGIS Desktop",body:"Reply ready"});o.on("click",(()=>{r.isDestroyed()||(r.isMinimized()&&r.restore(),r.isVisible()||r.show(),r.focus())})),o.show()}(e),v&&"function"==typeof v.note&&v.note({sessionId:n&&n.sessionId||null})};registerIpc(n,d,p,(e=>{const t=m.setAegisKey(e);return e?credentials.saveApiKey(e):credentials.clearApiKey(),t}),(e=>o.openExternal(e)),A,(()=>providerRouteConfigured(f)),(()=>m.vaultBackendConfig().backend));const b=registerModelIpc(n,f,g,d,A,void 0,h);registerConfirmModeIpc(n,withAutoRoundsSwitch(createConfirmModeDispatch(m),b.modelDispatch)),registerConfirmModeIpc(n,createVaultBackendDispatch(m)),registerTelemetryIpc(n,createTelemetryDispatch(createTelemetry({dir:h,enabled:telemetryEnabled(process.env,m),appVersion:(()=>{try{return"function"==typeof e.getVersion?e.getVersion():""}catch{return""}})()}))),registerCovenantIpc(n,createCovenantDispatch(covenantModule));const S=registerQueueIpc(n,{engine:createQueueEngine(d,m),aegis:d}),C=createAutoDrain({proceed:()=>S.proceed(),settings:m,queue:queueModule,env:process.env,log:e=>{e&&"skip"!==e.type&&console.log("[auto-drain]",e.type,e.reason||e.error||"")}});let R=null;v=createAutoMint({mint:e=>(R||(R=createFingerprintService({dir:p,getVaultBackend:()=>m.vaultBackendConfig().backend})),R.mint(e||{})),settings:m}),registerAutoIpc(n,createAutoDispatch({autoDrain:C,autoMint:v})),C.start(),v.start();let w=null,I=null;function P(){const n=(I&&!I.isDestroyed()||(I=new t({width:560,height:320,show:!1,frame:!1,alwaysOnTop:!0,skipTaskbar:!0,resizable:!1,movable:!0,fullscreenable:!1,minimizable:!1,maximizable:!1,backgroundColor:"#0d1117",icon:iconForPlatform(resolveIconsDir(e.isPackaged,process.resourcesPath,__dirname),process.platform),webPreferences:{preload:path.join(__dirname,"preload.js"),contextIsolation:!0,nodeIntegration:!1,sandbox:!0}}),I.setMenuBarVisibility(!1),I.loadFile(path.join(__dirname,"renderer","quick.html")),I.webContents.on("before-input-event",((e,t)=>{"keyDown"!==t.type||"Escape"!==t.key||I.isDestroyed()||I.hide()})),I.on("blur",(()=>{!I.isDestroyed()&&I.isVisible()&&I.hide()})),I.on("closed",(()=>{I=null}))),I);if(n.isVisible())return void n.hide();const r=i.getCursorScreenPoint(),o=i.getAllDisplays(),s=n.getBounds(),a=quickLauncherLib.computeQuickLauncherBounds({cursor:r,displays:o,size:{width:s.width,height:s.height}});n.setBounds(a),n.show(),n.focus()}const M=createQuickLauncherDispatch(m,{isPackaged:e.isPackaged,applyConfig:function(t){if(u.unregisterAll(),!quickLauncherLib.shouldEnableGlobalShortcut({isPackaged:e.isPackaged,enabled:t.enabled}))return{active:!1,reason:null};try{if(!u.register(t.shortcut,P)){const e=`"${t.shortcut}" could not be registered — it may already be in use by another application.`;return console.warn(`[quick-launcher] ${e}`),{active:!1,reason:e}}return{active:!0,reason:null}}catch(e){const n=errorText(e);return console.warn(`[quick-launcher] failed to register shortcut "${t.shortcut}": ${n}`),{active:!1,reason:n}}},pushToMain:function(e,n){const r=n&&n.sender&&t.fromWebContents(n.sender);r&&!r.isDestroyed()&&r.hide();const o=w&&!w.isDestroyed()?w:t.getAllWindows().find((e=>e!==r))||null;return o?(o.webContents.send(QUICK_LAUNCHER_PUSH_CHANNEL,e||{}),o.isMinimized()&&o.restore(),o.isVisible()||o.show(),o.focus(),{ok:!0}):{ok:!1,reason:"no main window is open"}}});registerQuickLauncherIpc(n,M);let T=null;e.on("will-quit",(()=>{T&&"function"==typeof T.stop&&T.stop(),u.unregisterAll(),F&&"function"==typeof F.stop&&F.stop()}));let N=!1;e.on("before-quit",(t=>{!N&&f&&"function"==typeof f.unloadLocalModels&&(t.preventDefault(),Promise.resolve().then((()=>f.unloadLocalModels())).catch((()=>{})).finally((()=>{N=!0,e.quit()})))}));const D=new Set;function U(e){for(const t of D)t.isDestroyed()||t.webContents.send(ACCOUNT_STATUS_CHANNEL,e);L()}function L(){s.setApplicationMenu(buildAppMenu({app:e,Menu:s,BrowserWindow:t,updateManager:F,loggedIn:Boolean(d.apiKey)}))}const F=createUpdateManager({isPackaged:e.isPackaged,onStatus:function(e){for(const t of D)t.isDestroyed()||t.webContents.send(UPDATE_STATUS_CHANNEL,e)}});registerUpdateIpc(n,F),n.handle(`${IPC_PREFIX}accountStatus`,(()=>y.status())),n.handle(`${IPC_PREFIX}registerAccount`,(()=>y.registerAccount())),n.handle(`${IPC_PREFIX}driveSignIn`,(()=>E.signInWithGoogle())),n.handle(`${IPC_PREFIX}driveConnect`,(()=>E.connectDrive())),n.handle(`${IPC_PREFIX}driveStatus`,(()=>E.status())),n.handle(`${IPC_PREFIX}driveSyncMemory`,(()=>E.syncMemoryNow())),registerExportIpc(n,sessionStore,g,(e=>c.showSaveDialog(t.getFocusedWindow(),e)),((e,t)=>fs.promises.writeFile(e,t,"utf8"))),L();const q=pathToFileURL(path.join(__dirname,"renderer","index.html")).href;function O(){const n=windowState.load(p),r=windowState.clampToDisplay(n,i.getAllDisplays(),{width:1080,height:720}),s=resolveIconsDir(e.isPackaged,process.resourcesPath,__dirname),a=new t({...r,minWidth:720,minHeight:480,backgroundColor:"#0d1117",title:"AEGIS Desktop",icon:iconForPlatform(s,process.platform),webPreferences:{preload:path.join(__dirname,"preload.js"),contextIsolation:!0,nodeIntegration:!1,sandbox:!0,spellcheck:!0}});n&&n.isMaximized&&a.maximize(),a.webContents.setWindowOpenHandler((({url:e})=>(isSafeExternalUrl(e)&&o.openExternal(e),{action:"deny"}))),a.webContents.on("will-navigate",((e,t)=>{t!==q&&(e.preventDefault(),isSafeExternalUrl(t)&&o.openExternal(t))}));let c=null;function u(){if(a.isDestroyed())return;const e=a.isMaximized(),t=e?a.getNormalBounds():a.getBounds();windowState.save(p,{...t,isMaximized:e})}function l(){c&&clearTimeout(c),c=setTimeout(u,500)}return a.on("resize",l),a.on("move",l),a.on("close",(()=>{c&&clearTimeout(c),u()})),a.loadFile(path.join(__dirname,"renderer","index.html")),D.add(a),w=a,a.on("closed",(()=>{D.delete(a),w===a&&(w=null)})),a}e.whenReady().then((async()=>{if(!credentials.normalizeApiKey(process.env[credentials.KEY_ENV])){const e=await _,t=startupKeyOverride({mintedKey:k?"":e.key,hostKey:credentials.normalizeApiKey(m.aegisRawKey())});if(t)credentials.saveApiKey(t),d.setApiKey(t);else{const e=credentials.preferNewest(m.aegisRawKey(),m.aegisKeySavedAt());e.key&&(d.setApiKey(e.key),"shared"===e.source&&m.setAegisKey(e.key))}}try{T&&"function"==typeof T.stop&&T.stop(),T=sharedPresence.createPresence({apiKey:d.apiKey,app:"desktop",version:e.getVersion(),platform:process.platform,install:sharedFingerprint.machineFingerprint(),queueDepth:()=>{try{return queueModule.readQueue(queueModule.queuePath()).filter((e=>e&&"pending"===e.status)).length}catch{return 0}},autonomous:!1,log:(e,t)=>{"debug"!==e&&console.warn(t)}}),T.start()}catch(e){console.warn("presence: beacon not started",e&&e.message)}try{const{backfillProviderKeys:e}=require("./lib/provider-key-mirror.js"),t=e=>console.warn(`aegis: ${e}`),n=e(m,envFile,{onWarn:t});n.mirrored.length&&console.warn(`aegis: shared ${n.mirrored.join(", ")} key(s) with the other hosts (${envFile.envFileFor()})`),f&&f.settings&&f.settings!==m&&e(f.settings,envFile,{onWarn:t})}catch(e){console.warn(`aegis: could not backfill the shared env-file key mirror: ${e&&e.message}`)}M.setConfig(m.quickLauncherConfig());const t=O();F.start(),l&&sendDeepLinkToWindow(t,l)})),e.on("window-all-closed",(()=>{"darwin"!==process.platform&&e.quit()})),e.on("activate",(()=>{0===t.getAllWindows().length&&O()})),e.on("second-instance",((e,n)=>{const r=t.getAllWindows()[0],o=deepLink.parseDeepLinkArgv(n);if(r)r.isMinimized()&&r.restore(),r.isVisible()||r.show(),r.focus(),o&&sendDeepLinkToWindow(r,o);else{const e=O();o&&sendDeepLinkToWindow(e,o)}}))}electron&&electron.app&&bootstrap(),module.exports={createIpcDispatch:createIpcDispatch,registerIpc:registerIpc,providerRouteConfigured:providerRouteConfigured,IPC_PREFIX:IPC_PREFIX,MODEL_PREFIX:MODEL_PREFIX,SYNC_PREFIX:SYNC_PREFIX,watchTopupCredit:watchTopupCredit,bankAmount:bankAmount,TOPUP_POLL_MS:TOPUP_POLL_MS,TOPUP_POLL_TRIES:TOPUP_POLL_TRIES,CHAT_DELTA_CHANNEL:CHAT_DELTA_CHANNEL,MENU_NEW_CHAT_CHANNEL:MENU_NEW_CHAT_CHANNEL,MENU_SEARCH_CHANNEL:MENU_SEARCH_CHANNEL,MENU_EXPORT_MARKDOWN_CHANNEL:MENU_EXPORT_MARKDOWN_CHANNEL,MENU_EXPORT_JSON_CHANNEL:MENU_EXPORT_JSON_CHANNEL,MENU_FINGERPRINT_CHANNEL:MENU_FINGERPRINT_CHANNEL,MENU_GOOGLE_DRIVE_CHANNEL:MENU_GOOGLE_DRIVE_CHANNEL,DEEP_LINK_CHANNEL:DEEP_LINK_CHANNEL,UPDATE_STATUS_CHANNEL:UPDATE_STATUS_CHANNEL,UPDATE_CHECK_INTERVAL_MS:UPDATE_CHECK_INTERVAL_MS,taggedChunk:taggedChunk,maskKey:maskKey,createModelDispatch:createModelDispatch,DESKTOP_CLASSES:DESKTOP_CLASSES,readTextFileForPreview:readTextFileForPreview,PREVIEW_READ_MAX_BYTES:PREVIEW_READ_MAX_BYTES,createSyncDispatch:createSyncDispatch,registerModelIpc:registerModelIpc,createHeartbeatRetry:createHeartbeatRetry,describeSyncError:describeSyncError,HEARTBEAT_RETRY_BASE_MS:HEARTBEAT_RETRY_BASE_MS,HEARTBEAT_RETRY_MAX_MS:HEARTBEAT_RETRY_MAX_MS,createEngine:createEngine,resolveUserDataDir:resolveUserDataDir,importForeignMemory:importForeignMemory,isSafeExternalUrl:isSafeExternalUrl,createUpdateManager:createUpdateManager,createNpmUpdateManager:createNpmUpdateManager,createUpdateDispatch:createUpdateDispatch,describeRegistryFailure:describeRegistryFailure,UPDATE_RETRY_BACKOFF_MS:UPDATE_RETRY_BACKOFF_MS,registerUpdateIpc:registerUpdateIpc,buildAppMenu:buildAppMenu,createExportDispatch:createExportDispatch,registerExportIpc:registerExportIpc,safeExportBasename:safeExportBasename,sendDeepLinkToWindow:sendDeepLinkToWindow,QUICK_PREFIX:QUICK_PREFIX,QUICK_LAUNCHER_PUSH_CHANNEL:QUICK_LAUNCHER_PUSH_CHANNEL,createQuickLauncherDispatch:createQuickLauncherDispatch,registerQuickLauncherIpc:registerQuickLauncherIpc,createConfirmModeDispatch:createConfirmModeDispatch,createVaultBackendDispatch:createVaultBackendDispatch,withAutoRoundsSwitch:withAutoRoundsSwitch,createTelemetryDispatch:createTelemetryDispatch,registerTelemetryIpc:registerTelemetryIpc,createCovenantDispatch:createCovenantDispatch,registerCovenantIpc:registerCovenantIpc,registerConfirmModeIpc:registerConfirmModeIpc,QUEUE_PREFIX:QUEUE_PREFIX,QUEUE_PROGRESS_CHANNEL:QUEUE_PROGRESS_CHANNEL,QUEUE_MAX_TASK_CHARS:QUEUE_MAX_TASK_CHARS,QUEUE_MAX_ROUNDS:QUEUE_MAX_ROUNDS,createQueueEngine:createQueueEngine,queueEngineWithDeniedApprovals:queueEngineWithDeniedApprovals,createQueueDispatch:createQueueDispatch,registerQueueIpc:registerQueueIpc,AUTO_PREFIX:AUTO_PREFIX,createAutoDispatch:createAutoDispatch,registerAutoIpc:registerAutoIpc};