aegis-desktop 0.8.12 → 0.8.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/main.js CHANGED
@@ -57,6 +57,49 @@ try {
57
57
  autoProvision = require('./vendor/auto-provision.js');
58
58
  }
59
59
 
60
+ // The shared presence beacon (client/presence.js) — the client half of
61
+ // aegis1's `/api/v1/heartbeat` (app.py `heartbeat_v1`), which writes the
62
+ // `client_presence` row the admin panel's presence card reads. Started once the
63
+ // account key is known (see the beacon block in `app.whenReady`), stopped in
64
+ // `will-quit`. Same file the CLI beacons through, so the panel sees one
65
+ // vocabulary (`app: 'desktop'` / `'aegiscode'`) and not two.
66
+ let sharedPresence;
67
+ try {
68
+ sharedPresence = require('../client/presence.js');
69
+ } catch {
70
+ sharedPresence = require('./vendor/presence.js');
71
+ }
72
+
73
+ // The device fingerprint (client/device-fingerprint.js) — the SAME identity the
74
+ // auto-provision path registers a device-key against, now also carried in every
75
+ // presence beat so one laptop is one machine in the admin panel rather than one
76
+ // row per launch. Resolved here rather than left to presence.js's own fallback
77
+ // for the same reason the CLI resolves it at its call site: a host that cannot
78
+ // state its identity should fail visibly, not substitute a different one.
79
+ let sharedFingerprint;
80
+ try {
81
+ sharedFingerprint = require('../client/device-fingerprint.js');
82
+ } catch {
83
+ sharedFingerprint = require('./vendor/device-fingerprint.js');
84
+ }
85
+
86
+ // φ(α): the authorship key and sealed session records (lib/fingerprint.js).
87
+ // The service is built with the app's own data dir, so the key it mints is the
88
+ // key `aegiscode fingerprint` resolves in the terminal — one identity, two
89
+ // hosts, no second store. Everything cryptographic lives in the shared client
90
+ // modules it loads; this require only wires the IPC surface in registerIpc()
91
+ // below (`fingerprintStatus`/`keygen`/`mint`/`verify`/`backup`).
92
+ const { createFingerprintService } = require('./lib/fingerprint.js');
93
+
94
+ // The two opt-in automations (lib/auto.js): the auto-drain timer and the
95
+ // seal-on-quiet auto-mint. Both are DEFAULT OFF and read their switch from the
96
+ // settings store's reserved `__autoDrain`/`__autoMint` namespaces on every tick,
97
+ // so this require wires the TIMING only — the queue worker still owns the lock
98
+ // and lib/fingerprint.js still owns the seal. See the header of lib/auto.js for
99
+ // why the "nothing drains unless the user asked" rule became a default rather
100
+ // than an absolute.
101
+ const { createAutoDrain, createAutoMint } = require('./lib/auto.js');
102
+
60
103
  // The shared `~/.aegiscode/.env` loader (client/env-file.js, staged to
61
104
  // ./vendor/env-file.js when packaged). It is what makes the key instruction the
62
105
  // same in every host — "put it in ~/.aegiscode/.env" — because AEGIS_API_KEY is
@@ -148,6 +191,7 @@ const SYNC_PREFIX = 'sync:';
148
191
  const TOOLS_PREFIX = 'tools:';
149
192
  const QUICK_PREFIX = 'quick:';
150
193
  const QUEUE_PREFIX = 'queue:';
194
+ const AUTO_PREFIX = 'auto:';
151
195
 
152
196
  /**
153
197
  * Main -> renderer push channel for chat SSE deltas (D2.1 streaming render).
@@ -638,6 +682,11 @@ function providerRouteConfigured(engine) {
638
682
  * import to stay unit-testable.
639
683
  */
640
684
  function createIpcDispatch(aegis, dir, persistApiKey, openExternal, providerConfigured, avatar) {
685
+ // One service for the window's lifetime, bound to the app's data dir. It is
686
+ // created lazily inside its handlers rather than here, so a broken shared
687
+ // module surfaces as an error in the fingerprint panel instead of preventing
688
+ // the window from opening at all.
689
+ const fingerprint = createFingerprintService({ dir });
641
690
  // `providerConfigured` is an injected thunk (bootstrap() passes one that reads
642
691
  // the settings store) so this module keeps its no-Electron, unit-testable
643
692
  // shape; absent in tests and older call sites, where it reads as "no".
@@ -701,6 +750,29 @@ function createIpcDispatch(aegis, dir, persistApiKey, openExternal, providerConf
701
750
  onStream: payload && payload.onStream,
702
751
  }),
703
752
 
753
+ // φ(α) — the authorship key and the sealed session records, the same ones
754
+ // `aegiscode fingerprint` mints in the terminal (lib/fingerprint.js).
755
+ // Every handler resolves `{ ok, … }` instead of rejecting, so the renderer
756
+ // can show the real message; an IPC rejection reaches it as an opaque
757
+ // "Error invoking remote method" with the reason buried in main-process
758
+ // logs, and the one error a user is likely to hit (a key file other users
759
+ // can read) is exactly the one that must be readable.
760
+ fingerprintStatus: () => fingerprint.status(),
761
+ fingerprintKeygen: (payload) => fingerprint.keygen(payload || {}),
762
+ fingerprintMint: (payload) => fingerprint.mint(payload || {}),
763
+ fingerprintVerify: (payload) => fingerprint.verify(payload || {}),
764
+ fingerprintBackup: (payload) => fingerprint.backup(payload || {}),
765
+ // The vault (client/record-vault.js). `publish` sends the SEALED BYTES to
766
+ // gdrive:AEGIS-AGPL/records and writes the `.anchor.json` link beside the
767
+ // local record; `anchor` checks the copy still matches; `vaultAudit`
768
+ // inspects the whole folder for key material. The key is never publishable —
769
+ // record-vault.js refuses it by path, by shape and by byte comparison, in
770
+ // the shared module rather than in this dispatch, so the CLI inherits the
771
+ // same refusal.
772
+ fingerprintPublish: (payload) => fingerprint.publish(payload || {}),
773
+ fingerprintAnchor: (payload) => fingerprint.anchor(payload || {}),
774
+ fingerprintVault: () => fingerprint.vaultList(),
775
+ fingerprintVaultAudit: () => fingerprint.vaultAudit(),
704
776
  byokStatus: () => aegis.byokStatus(),
705
777
  byokSet: (payload) =>
706
778
  aegis.byokSet(
@@ -726,7 +798,7 @@ function createIpcDispatch(aegis, dir, persistApiKey, openExternal, providerConf
726
798
  memorySaveBatch: (payload) =>
727
799
  aegis.memorySaveBatch(payload && payload.entries),
728
800
  memoryImport: (payload) =>
729
- importForeignMemory(aegis, dir, payload),
801
+ importForeignMemory(aegis, dir, payload, avatar),
730
802
  importConversation: (payload) =>
731
803
  aegis.importConversation(payload || {}),
732
804
 
@@ -1943,12 +2015,85 @@ function createQueueDispatch({
1943
2015
  };
1944
2016
  }
1945
2017
 
2018
+ /**
2019
+ * The two opt-in automations, as an IPC surface (`auto:<name>`).
2020
+ *
2021
+ * lib/auto.js owns the TIMING; this owns the ANSWERS the renderer gets, and
2022
+ * deliberately nothing else: it has no timer of its own, it does not read the
2023
+ * settings store (the automations do, on every tick), and it does not touch the
2024
+ * queue file or a record (the queue worker and lib/fingerprint.js do). Split
2025
+ * out as a pure function so a test can drive it with stub automations and a
2026
+ * fake ipcMain, exactly like createQueueDispatch.
2027
+ *
2028
+ * `sealNow` is the panel's explicit "seal this session" shortcut through the
2029
+ * AUTOMATION rather than through the fingerprint service directly, so the
2030
+ * pending-turn counter is reset by the same call that files the record — a
2031
+ * manual seal followed by the timer must not file the same session twice.
2032
+ */
2033
+ function createAutoDispatch({ autoDrain, autoMint } = {}) {
2034
+ if (!autoDrain || typeof autoDrain.state !== 'function') {
2035
+ throw new Error('createAutoDispatch: the auto-drain automation is required');
2036
+ }
2037
+ if (!autoMint || typeof autoMint.state !== 'function') {
2038
+ throw new Error('createAutoDispatch: the auto-mint automation is required');
2039
+ }
2040
+ return {
2041
+ /** Both switches and both live states, in one answer. */
2042
+ autoStatus: () => ({ ok: true, drain: autoDrain.state(), mint: autoMint.state() }),
2043
+ autoSetDrain: (payload) => {
2044
+ const p = payload || {};
2045
+ const patch = {};
2046
+ if (p.enabled != null) patch.enabled = Boolean(p.enabled);
2047
+ if (p.intervalMs != null) patch.intervalMs = Number(p.intervalMs);
2048
+ try {
2049
+ return { ok: true, drain: autoDrain.set(patch) };
2050
+ } catch (err) {
2051
+ return { ok: false, error: errorText(err) };
2052
+ }
2053
+ },
2054
+ autoSetMint: (payload) => {
2055
+ const p = payload || {};
2056
+ const patch = {};
2057
+ if (p.enabled != null) patch.enabled = Boolean(p.enabled);
2058
+ if (p.publish != null) patch.publish = Boolean(p.publish);
2059
+ try {
2060
+ return { ok: true, mint: autoMint.set(patch) };
2061
+ } catch (err) {
2062
+ return { ok: false, error: errorText(err) };
2063
+ }
2064
+ },
2065
+ /** Seal the open session now, ahead of the quiet timer. Resets the counter
2066
+ * either way: a seal that failed is still not something to retry from a
2067
+ * timer that was about to fire on the same turns, and the panel shows the
2068
+ * failure. */
2069
+ autoSealNow: async (payload) => {
2070
+ const p = payload || {};
2071
+ if (p.publish != null) autoMint.set({ publish: Boolean(p.publish) });
2072
+ try {
2073
+ const result = await autoMint.flush();
2074
+ autoMint.reset();
2075
+ return { ok: true, ...result };
2076
+ } catch (err) {
2077
+ autoMint.reset();
2078
+ return { ok: false, error: errorText(err) };
2079
+ }
2080
+ },
2081
+ };
2082
+ }
2083
+
1946
2084
  /**
1947
2085
  * Register the queue surface as `queue:<name>` on ipcMain — the same
1948
2086
  * per-namespace pattern as MODEL_PREFIX/SYNC_PREFIX/QUICK_PREFIX above, with
1949
- * one addition: drain/proceed need the raw IPC event, because their progress
1950
- * frames are pushed to the window that ASKED for the drain, over
2087
+ * one addition: every handler keeps the raw IPC event, because a drain's
2088
+ * progress frames are pushed to the window that ASKED for it, over
1951
2089
  * QUEUE_PROGRESS_CHANNEL (never CHAT_DELTA_CHANNEL — see that constant).
2090
+ *
2091
+ * Every handler, not just drain/proceed: with the auto-drain switch armed the
2092
+ * drain is started by a TIMER, so there is no "window that asked for it" — the
2093
+ * sink has to be whichever window last touched this surface (the card that
2094
+ * armed the switch, or the one that just read queue.list()). Leaving only
2095
+ * drain/proceed to set `target` would make an automatic drain's progress
2096
+ * invisible in exactly that case, and an invisible drain reads as a hang.
1952
2097
  */
1953
2098
  function registerQueueIpc(ipcMain, { engine, env, queue, autonomous, entitlement, aegis } = {}) {
1954
2099
  // The window a drain's frames go to. One drain runs at a time process-wide
@@ -2004,14 +2149,26 @@ function registerQueueIpc(ipcMain, { engine, env, queue, autonomous, entitlement
2004
2149
  },
2005
2150
  });
2006
2151
  for (const [name, handler] of Object.entries(dispatch)) {
2007
- if (name === 'drain' || name === 'proceed') {
2008
- ipcMain.handle(`${QUEUE_PREFIX}${name}`, (event, payload) => {
2009
- target = (event && event.sender) || null;
2010
- return handler(payload);
2011
- });
2012
- continue;
2013
- }
2014
- ipcMain.handle(`${QUEUE_PREFIX}${name}`, (_event, payload) => handler(payload));
2152
+ ipcMain.handle(`${QUEUE_PREFIX}${name}`, (event, payload) => {
2153
+ target = (event && event.sender) || null;
2154
+ return handler(payload);
2155
+ });
2156
+ }
2157
+ return dispatch;
2158
+ }
2159
+
2160
+ /**
2161
+ * Register the automation surface as `auto:<name>` on ipcMain.
2162
+ *
2163
+ * Unlike the queue, no handler here needs the IPC event: these calls are
2164
+ * answers about a switch, not a stream, and the one long-running one
2165
+ * (`sealNow`) resolves its own result. Nothing is pushed to the renderer from
2166
+ * this file — the card and the panel re-read status after every action, which
2167
+ * is also what makes a stale paint impossible.
2168
+ */
2169
+ function registerAutoIpc(ipcMain, dispatch) {
2170
+ for (const [name, handler] of Object.entries(dispatch)) {
2171
+ ipcMain.handle(`${AUTO_PREFIX}${name}`, (_event, payload) => handler(payload));
2015
2172
  }
2016
2173
  return dispatch;
2017
2174
  }
@@ -2707,13 +2864,34 @@ function bootstrap() {
2707
2864
  note.show();
2708
2865
  }
2709
2866
 
2867
+ // Both automations (lib/auto.js) are built further down, AFTER the queue and
2868
+ // the fingerprint have been wired — they call into both. This `let` is how
2869
+ // the reply hook below (which has to be handed to registerIpc up here) can
2870
+ // reach one that does not exist yet: it fires when a turn FINISHES, which is
2871
+ // always long after bootstrap() has returned, and it is null-guarded so a
2872
+ // reply arriving in that theoretical gap is a no-op rather than a TypeError.
2873
+ let autoMint = null;
2874
+
2875
+ /**
2876
+ * The reply hook: the native notification (unchanged) plus the auto-mint's
2877
+ * quiet clock. The clock is restarted by every finished turn, never read as
2878
+ * "seal now" — lib/auto.js's flush() is what decides, and only once the
2879
+ * session has been quiet for its full interval.
2880
+ */
2881
+ const handleReplyFinished = (event, result) => {
2882
+ notifyReplyIfUnfocused(event);
2883
+ if (autoMint && typeof autoMint.note === 'function') {
2884
+ autoMint.note({ sessionId: (result && result.sessionId) || null });
2885
+ }
2886
+ };
2887
+
2710
2888
  registerIpc(
2711
2889
  ipcMain,
2712
2890
  aegis,
2713
2891
  dataDir,
2714
2892
  persistApiKey,
2715
2893
  (url) => shell.openExternal(url),
2716
- notifyReplyIfUnfocused,
2894
+ handleReplyFinished,
2717
2895
  () => providerRouteConfigured(engine)
2718
2896
  );
2719
2897
  registerModelIpc(
@@ -2721,7 +2899,7 @@ function bootstrap() {
2721
2899
  engine,
2722
2900
  sessionsDir,
2723
2901
  aegis,
2724
- notifyReplyIfUnfocused,
2902
+ handleReplyFinished,
2725
2903
  undefined,
2726
2904
  settingsDir
2727
2905
  );
@@ -2775,10 +2953,67 @@ function bootstrap() {
2775
2953
  //
2776
2954
  // It gets its OWN engine, built with the approval gate off: a queued task has
2777
2955
  // nobody to answer a card, so the interactive engine is the wrong one to hand
2778
- // a worker (see createQueueEngine). Registration alone drains nothing — the
2779
- // queue runs only when the renderer's card asks it to, which is the whole
2780
- // gate: no timer, no boot drain, no idle drain.
2781
- registerQueueIpc(ipcMain, { engine: createQueueEngine(aegis, settings), aegis });
2956
+ // a worker (see createQueueEngine).
2957
+ //
2958
+ // Registration alone drains nothing. There are now exactly two ways a drain
2959
+ // starts, and both are visible in the UI: a click on the card's
2960
+ // Run one / Drain all, or the card's "drain automatically" switch — a
2961
+ // PERSISTED, DEFAULT-OFF setting (settings.js `__autoDrain`) read by
2962
+ // lib/auto.js on every tick. There is still no boot drain and no
2963
+ // drain-on-idle; the interval only exists while the switch is on.
2964
+ const queueDispatch = registerQueueIpc(ipcMain, {
2965
+ engine: createQueueEngine(aegis, settings),
2966
+ aegis,
2967
+ });
2968
+
2969
+ // ── the two opt-in automations (lib/auto.js) ─────────────────────────────
2970
+ //
2971
+ // Created here because this is the first point where every collaborator
2972
+ // exists: settings (the switches), the queue dispatch above (the drain), and
2973
+ // the fingerprint service (the seal). What they do NOT get is a vote — both
2974
+ // modules re-read the persisted switch on every tick, so flipping the toggle
2975
+ // in the card or the panel stops the very next one.
2976
+ const autoDrain = createAutoDrain({
2977
+ // The SAME entry point the card's "Drain all" button calls
2978
+ // (createQueueDispatch's `proceed` -> drainAll). Not a second loop: the
2979
+ // worker's own per-task lock, Stop, and the progress frames are all the
2980
+ // ones the button already uses.
2981
+ proceed: () => queueDispatch.proceed(),
2982
+ settings,
2983
+ queue: queueModule,
2984
+ env: process.env,
2985
+ log: (entry) => {
2986
+ // Visible in the main-process log, and nowhere else. An automation that
2987
+ // fails silently is indistinguishable from one that is switched off.
2988
+ if (entry && entry.type !== 'skip') console.log('[auto-drain]', entry.type, entry.reason || entry.error || '');
2989
+ },
2990
+ });
2991
+
2992
+ // The seal. `mint` is lib/fingerprint.js's own — the same call the panel's
2993
+ // "Seal this session" makes, reading the same session log through the same
2994
+ // shared adapter. The service is built LAZILY for the reason createIpcDispatch
2995
+ // states: a broken shared module must surface as an error in the panel, not as
2996
+ // a window that never opens. `aiSecret` is never passed, so the AI axis of the
2997
+ // record is attested from `$AEGIS_AI_SECRET` in the app's scope by
2998
+ // client/attest.js's own rungs — no key enters main.js, the settings store, or
2999
+ // the DOM for this path.
3000
+ let autoFingerprint = null;
3001
+ autoMint = createAutoMint({
3002
+ mint: (payload) => {
3003
+ if (!autoFingerprint) autoFingerprint = createFingerprintService({ dir: dataDir });
3004
+ return autoFingerprint.mint(payload || {});
3005
+ },
3006
+ settings,
3007
+ });
3008
+
3009
+ registerAutoIpc(ipcMain, createAutoDispatch({ autoDrain, autoMint }));
3010
+
3011
+ // Arm whatever the user left switched on — and nothing else. Both start()
3012
+ // calls are reads of a persisted, default-off setting: on a default install
3013
+ // the drain timer is not created at all and the mint has no pending turns, so
3014
+ // the queue still waits for a click and no session is ever sealed by itself.
3015
+ autoDrain.start();
3016
+ autoMint.start();
2782
3017
 
2783
3018
  // ---------------------------------------------------------------------
2784
3019
  // Global quick-launcher (D? quick launcher): a frameless, always-on-top,
@@ -2927,7 +3162,17 @@ function bootstrap() {
2927
3162
  // A held global shortcut outlives this app if not released — every quit
2928
3163
  // path (explicit quit, window-all-closed on non-mac, OS shutdown) must
2929
3164
  // free it.
3165
+ // The presence beacon (aegis1 /api/v1/heartbeat), held here so the quit path
3166
+ // can stop it. `null` until `app.whenReady` starts it: the beacon needs the
3167
+ // resolved account key, and a `let` (not a `const`) because a quit can
3168
+ // precede a successful start.
3169
+ let presenceBeacon = null;
3170
+
2930
3171
  app.on('will-quit', () => {
3172
+ // Beacon timers are unref'd, so this is not what keeps the process alive —
3173
+ // it is what stops a beat from being sent while Electron tears the process
3174
+ // down under it.
3175
+ if (presenceBeacon && typeof presenceBeacon.stop === 'function') presenceBeacon.stop();
2931
3176
  globalShortcut.unregisterAll();
2932
3177
  // The update manager's retry/interval timers are unref'd so they never
2933
3178
  // hold the process open, but an explicit quit should still cancel them
@@ -3089,6 +3334,57 @@ function bootstrap() {
3089
3334
  if (result.key) settings.setAegisKey(result.key);
3090
3335
  }
3091
3336
  }
3337
+ // ── presence beacon ───────────────────────────────────────────────────
3338
+ //
3339
+ // aegis1's `/api/v1/heartbeat` (`heartbeat_v1`), the client half of the
3340
+ // admin panel's presence card: which app, which build, which OS, how many
3341
+ // machines, and whether a drain is running. Started HERE because it needs
3342
+ // the account key and this is the first point where the key is final —
3343
+ // resolved from env/the shared store, or auto-provisioned just above — so
3344
+ // the very first beat carries the account the app is actually signed in
3345
+ // with rather than a stale one. Stopped in `will-quit`.
3346
+ //
3347
+ // `install` is the machine fingerprint (client/device-fingerprint.js), the
3348
+ // SAME identity the device-registration path uses: the panel's "how many
3349
+ // machines" is only true if every beat from one laptop carries one id.
3350
+ //
3351
+ // A keyless app gets an inert store from presence.js — no timer, no
3352
+ // request — so an offline or signed-out launch is silent rather than a 401
3353
+ // loop. A beacon failure must never block the window from opening.
3354
+ try {
3355
+ if (presenceBeacon && typeof presenceBeacon.stop === 'function') presenceBeacon.stop();
3356
+ presenceBeacon = sharedPresence.createPresence({
3357
+ apiKey: aegis.apiKey,
3358
+ // 'desktop' is one of the names aegis1's PRESENCE_APPS keeps; anything
3359
+ // else it does not recognise is stored as 'other' with a 200 and no
3360
+ // error anywhere. See client/presence-constants.js.
3361
+ app: 'desktop',
3362
+ version: app.getVersion(),
3363
+ platform: process.platform,
3364
+ install: sharedFingerprint.machineFingerprint(),
3365
+ // Read at SEND time: the panel wants the queue as it is now, not the
3366
+ // depth it happened to have at launch. Same queue file the CLI drains
3367
+ // (`desktop/lib/local/queue.js` — see registerQueueIpc below).
3368
+ queueDepth: () => {
3369
+ try {
3370
+ const items = queueModule.readQueue(queueModule.queuePath());
3371
+ return items.filter((t) => t && t.status === 'pending').length;
3372
+ } catch {
3373
+ return 0;
3374
+ }
3375
+ },
3376
+ // The desktop card's drain is renderer-driven today; there is no
3377
+ // always-on worker in main to report, so this stays false rather than
3378
+ // claiming a drain that is not running.
3379
+ autonomous: false,
3380
+ log: (level, message) => {
3381
+ if (level !== 'debug') console.warn(message);
3382
+ },
3383
+ });
3384
+ presenceBeacon.start();
3385
+ } catch (err) {
3386
+ console.warn('presence: beacon not started', err && err.message);
3387
+ }
3092
3388
  // Apply whatever was last saved (or the default): ship the shortcut only
3093
3389
  // when app.isPackaged || the settings flag is on — see
3094
3390
  // shouldEnableGlobalShortcut — so a plain `electron .` dev run never
@@ -3196,4 +3492,7 @@ module.exports = {
3196
3492
  queueEngineWithDeniedApprovals,
3197
3493
  createQueueDispatch,
3198
3494
  registerQueueIpc,
3495
+ AUTO_PREFIX,
3496
+ createAutoDispatch,
3497
+ registerAutoIpc,
3199
3498
  };
package/package.json CHANGED
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "name": "aegis-desktop",
3
3
  "productName": "AEGIS Desktop",
4
- "version": "0.8.12",
4
+ "version": "0.8.13",
5
5
  "description": "Thin Electron host for AEGIS — a local chat UI over the shared client/aegis.js transport. Ships transport + UI only; engine logic stays server-side.",
6
6
  "author": {
7
7
  "name": "AEGIS Code",
8
8
  "email": "nborneklint@gmail.com"
9
9
  },
10
- "license": "MIT",
10
+ "license": "AGPL-3.0-or-later",
11
11
  "main": "main.js",
12
12
  "homepage": "https://aegiscloud.org",
13
13
  "bin": {
package/preload.js CHANGED
@@ -18,6 +18,7 @@ const MODEL_PREFIX = 'model:';
18
18
  const SYNC_PREFIX = 'sync:';
19
19
  const QUICK_PREFIX = 'quick:';
20
20
  const QUEUE_PREFIX = 'queue:';
21
+ const AUTO_PREFIX = 'auto:';
21
22
  const QUEUE_PROGRESS_CHANNEL = `${QUEUE_PREFIX}progress`;
22
23
  const CHAT_DELTA_CHANNEL = `${IPC_PREFIX}chatDelta`;
23
24
  const UPDATE_STATUS_CHANNEL = `${IPC_PREFIX}updateStatus`;
@@ -63,6 +64,13 @@ function invokeQueue(name, payload) {
63
64
  );
64
65
  }
65
66
 
67
+ function invokeAuto(name, payload) {
68
+ return ipcRenderer.invoke(
69
+ AUTO_PREFIX + name,
70
+ payload === undefined ? undefined : payload
71
+ );
72
+ }
73
+
66
74
  /**
67
75
  * Build the chatDelta listener for one streaming request.
68
76
  *
@@ -162,6 +170,26 @@ const api = {
162
170
  // isSafeExternalUrl for the http/https-only allowlist.
163
171
  openExternal: (url) => invoke('openExternal', { url }),
164
172
 
173
+ // φ(α) — the authorship key and sealed session records (main.js
174
+ // createIpcDispatch -> lib/fingerprint.js). Every call resolves
175
+ // `{ ok, … }`/`{ ok: false, error }` rather than throwing, so the UI shows the
176
+ // actual reason (e.g. "this key file is readable by other users") instead of
177
+ // Electron's opaque "Error invoking remote method". The key itself is never
178
+ // returned in any of these payloads — only its derived 12-hex fingerprint.
179
+ fingerprintStatus: () => invoke('fingerprintStatus'),
180
+ fingerprintKeygen: (payload) => invoke('fingerprintKeygen', payload || {}),
181
+ fingerprintMint: (payload) => invoke('fingerprintMint', payload || {}),
182
+ fingerprintVerify: (payload) => invoke('fingerprintVerify', payload || {}),
183
+ fingerprintBackup: (payload) => invoke('fingerprintBackup', payload || {}),
184
+ // The vault: publish a sealed record (default gdrive:AEGIS-AGPL/records),
185
+ // check the published copy against the link, list it, audit it for key
186
+ // material. `publish` is also a flag on fingerprintMint (`{ publish: true }`),
187
+ // which seals and travels in one call. No handler takes or returns the key.
188
+ fingerprintPublish: (payload) => invoke('fingerprintPublish', payload || {}),
189
+ fingerprintAnchor: (payload) => invoke('fingerprintAnchor', payload || {}),
190
+ fingerprintVault: () => invoke('fingerprintVault'),
191
+ fingerprintVaultAudit: () => invoke('fingerprintVaultAudit'),
192
+
165
193
  // Tool-call approval toggle (Settings → "Confirm before running tools"):
166
194
  // main.js createConfirmModeDispatch persists it in the settings store's
167
195
  // reserved `__confirmMode` namespace, and the engine's gate
@@ -384,10 +412,16 @@ const quickLauncher = {
384
412
  // task's own tool loop runs in — and main.js refuses it unless it is an
385
413
  // absolute path that already exists; this file never joins, reads or writes
386
414
  // a path itself.
387
- // 2. No drain that starts by itself. `drain`/`proceed` are invoked only from
388
- // the queue card's two buttons. Draining spends money on a real model in a
389
- // real checkout, so the trigger is a click and nothing else — main.js has
390
- // no timer to call it, and this file has no default.
415
+ // 2. No drain that starts by itself UNLESS the persisted switch is armed.
416
+ // `drain`/`proceed` above are invoked only from the queue card's two
417
+ // buttons; the timer that can call one without a click lives in
418
+ // desktop/lib/auto.js and is armed by exactly one thing — the
419
+ // `aegis.auto.setDrain({ enabled: true })` call the card's toggle makes
420
+ // below, which writes settings.js's reserved `__autoDrain` namespace. It
421
+ // is OFF in a default install, it is re-read on every tick, and switching
422
+ // it off stops the very next one. Draining spends money on a real model in
423
+ // a real checkout, so that switch is persisted and visible rather than
424
+ // implied — but it is also not hidden from this file's users any more.
391
425
  const queue = {
392
426
  // Queue one task. `payload` is { task, cwd, model, effort, workers, maxRounds,
393
427
  // commit }; main.js range-checks every field and answers
@@ -427,6 +461,51 @@ const queue = {
427
461
  },
428
462
  };
429
463
 
464
+ /**
465
+ * The two opt-in automations (desktop/lib/auto.js via main.js
466
+ * registerAutoIpc): the auto-drain timer over the queue, and the seal-on-quiet
467
+ * timer over the current session.
468
+ *
469
+ * Same discipline as the queue surface above: every field is named and coerced
470
+ * HERE, so the renderer cannot smuggle an extra key into a settings namespace
471
+ * that main.js reserved (`__autoDrain` / `__autoMint`), and no path, secret or
472
+ * free-form string crosses. In particular there is NO field for an AI secret:
473
+ * the AI half of a sealed record is attested from `$AEGIS_AI_SECRET` in the
474
+ * app's own scope (client/attest.js), so no key can be handed in from the DOM
475
+ * even by a mistake in main.js.
476
+ *
477
+ * Both switches are OFF until one of these calls says otherwise, and the only
478
+ * caller is a toggle the user clicked — see the queue section's note 2 above.
479
+ */
480
+ const auto = {
481
+ // { ok, drain: { enabled, intervalMs, armed, busy, ticks, drains, last, lastSkip }, mint: { … } }
482
+ status: () => invokeAuto('autoStatus'),
483
+ // Persist the drain switch and re-arm to match. `intervalMs` is clamped in
484
+ // settings.js (15s–30min); the renderer sends what the user picked and does
485
+ // not decide the floor.
486
+ setDrain: (patch) => {
487
+ const p = patch || {};
488
+ const out = { enabled: Boolean(p.enabled) };
489
+ if (p.intervalMs != null && Number.isFinite(Number(p.intervalMs))) {
490
+ out.intervalMs = Number(p.intervalMs);
491
+ }
492
+ return invokeAuto('autoSetDrain', out);
493
+ },
494
+ // Persist the auto-seal switch. `publish` is independent of `enabled`: a
495
+ // failed publish never unseals anything, so the two are separate answers.
496
+ setMint: (patch) => {
497
+ const p = patch || {};
498
+ return invokeAuto('autoSetMint', {
499
+ enabled: Boolean(p.enabled),
500
+ publish: Boolean(p.publish),
501
+ });
502
+ },
503
+ // Seal the open session now, through the same automation the timer uses — so
504
+ // the pending-turn counter is reset by the same call and a session is never
505
+ // sealed twice.
506
+ sealNow: (publish) => invokeAuto('autoSealNow', { publish: Boolean(publish) }),
507
+ };
508
+
430
509
  /**
431
510
  * Task ids are the queue file's positive integers (queue.js nextId). A retry or
432
511
  * a remove with anything else is answered locally instead of being sent as a
@@ -470,3 +549,4 @@ contextBridge.exposeInMainWorld('models', Object.freeze(models));
470
549
  contextBridge.exposeInMainWorld('sync', Object.freeze(sync));
471
550
  contextBridge.exposeInMainWorld('quickLauncher', Object.freeze(quickLauncher));
472
551
  contextBridge.exposeInMainWorld('queue', Object.freeze(queue));
552
+ contextBridge.exposeInMainWorld('auto', Object.freeze(auto));