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.
- package/SOURCE-OFFER.txt +9 -0
- package/bin/aegis.js +1 -32
- package/lib/account-key.js +1 -56
- package/lib/auto.js +1 -430
- package/lib/avatar/companion.js +1 -698
- package/lib/avatar/cosmetics.js +1 -532
- package/lib/avatar/events.js +1 -344
- package/lib/avatar/holder-turn.js +1 -350
- package/lib/avatar/holders.js +1 -612
- package/lib/avatar/identity.js +1 -641
- package/lib/avatar/level.js +1 -368
- package/lib/avatar/mirror.js +1 -524
- package/lib/avatar/persona.js +1 -388
- package/lib/avatar/profile.js +1 -977
- package/lib/avatar/register.js +1 -307
- package/lib/avatar/store.js +1 -472
- package/lib/avatar/tiers.js +1 -38
- package/lib/avatar/transfer.js +1 -415
- package/lib/avatar/turn.js +1 -216
- package/lib/avatar/voice.js +1 -328
- package/lib/avatar/xp.js +1 -404
- package/lib/deep-link.js +1 -120
- package/lib/fingerprint.js +2 -347
- package/lib/google-connect.js +1 -169
- package/lib/local/agents.js +1 -105
- package/lib/local/autonomous.js +1 -1045
- package/lib/local/claude-pro.js +1 -993
- package/lib/local/context.js +1 -95
- package/lib/local/engine.js +1 -3228
- package/lib/local/entitlement.js +1 -654
- package/lib/local/git-scope.js +1 -228
- package/lib/local/local.js +1 -2135
- package/lib/local/prompt.js +1 -275
- package/lib/local/queue.js +1 -560
- package/lib/local/session-rounds.js +1 -208
- package/lib/local/shell.js +1 -680
- package/lib/local/telemetry.js +1 -470
- package/lib/local/tools.js +1 -1238
- package/lib/local/turn-guard.js +1 -300
- package/lib/local/worktree-lock.js +1 -235
- package/lib/provider-key-mirror.js +1 -252
- package/lib/quick-launcher.js +1 -84
- package/lib/settings.js +1 -890
- package/lib/sync/memory-queue.js +1 -60
- package/lib/sync/persist-gate.js +1 -148
- package/lib/sync/sessions.js +1 -106
- package/lib/window-state.js +1 -66
- package/main.js +3 -4390
- package/package.json +3 -5
- package/preload.js +3 -628
- package/renderer/app.js +1 -6560
- package/renderer/avatar/assemble.js +1 -335
- package/renderer/avatar/avatar.js +1 -380
- package/renderer/avatar/cards.js +1 -141
- package/renderer/avatar/hud.js +1 -206
- package/renderer/avatar/machine.js +1 -213
- package/renderer/avatar/pane.js +1 -439
- package/renderer/avatar/parts.js +1 -437
- package/renderer/budget.js +1 -168
- package/renderer/companion.js +1 -223
- package/renderer/diff.js +1 -359
- package/renderer/diffview.js +1 -336
- package/renderer/fingerprint.js +3 -720
- package/renderer/markdown.js +1 -168
- package/renderer/provider-toolbar.js +1 -134
- package/renderer/providers.js +1 -259
- package/renderer/quick.js +1 -191
- package/renderer/stream-policy.js +1 -306
- package/renderer/transcript-view.js +1 -323
- package/renderer/usage.js +1 -1239
- package/vendor/aegis.js +1 -2054
- package/vendor/attest.js +1 -442
- package/vendor/authorship-key.js +1 -410
- package/vendor/auto-provision.js +1 -134
- package/vendor/brain-catalog.js +1 -268
- package/vendor/byok-catalog.js +1 -230
- package/vendor/credentials.js +1 -494
- package/vendor/device-fingerprint.js +1 -141
- package/vendor/drive-connect.js +1 -85
- package/vendor/drive-upload.js +1 -174
- package/vendor/env-file.js +1 -1273
- package/vendor/fingerprint.js +1 -1064
- package/vendor/foreign-memory.js +1 -669
- package/vendor/google-signin.js +1 -125
- package/vendor/hooks.js +1 -430
- package/vendor/model-discovery.js +1 -330
- package/vendor/platform.js +1 -937
- package/vendor/prefs.js +1 -351
- package/vendor/presence-constants.js +1 -107
- package/vendor/presence.js +1 -438
- package/vendor/record-vault.js +3 -1055
- package/vendor/session-store.js +1 -548
- package/vendor/topup.js +1 -297
- package/vendor/touch.js +1 -458
- package/vendor/update.js +1 -704
- 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
|
-
//
|
|
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};
|