aegis-desktop 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,205 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Session-scoped tool-round ledger.
5
+ *
6
+ * The engine's tool-round cap used to be a wall. A turn that reached it ended
7
+ * mid-investigation with `[stopped at 24 tool rounds…]`, and because the
8
+ * accounting lived only for the duration of that call, the next turn in the
9
+ * same conversation started from zero: it knew nothing about the horizon that
10
+ * had just cut it off, so a long job could be truncated by the cap forever
11
+ * without ever being finished.
12
+ *
13
+ * This makes the cap *session* state instead of turn state:
14
+ *
15
+ * - a turn that reaches its horizon records the unfinished work against the
16
+ * session key (rounds spent, tokens, the note the user saw, and the
17
+ * tool-call transcript itself — see `record`'s `messages`);
18
+ * - the next turn in that session takes the record — consuming it, so one
19
+ * resume per interruption and a chain of interruptions is a chain of
20
+ * deliberate asks, never an automatic loop — and gets a continuation
21
+ * preamble plus a small round bonus, so re-orientation does not eat the
22
+ * new horizon before any work happens;
23
+ * - an entry nobody comes back for expires after TTL_MS.
24
+ *
25
+ * State is module-level, so it outlives a turn, a `send()` and whichever
26
+ * engine instance the desktop happens to be using. It never leaves the
27
+ * process and is never written to disk.
28
+ */
29
+
30
+ /** How long an unclaimed interruption stays resumable (30 min). */
31
+ const TTL_MS = 30 * 60 * 1000;
32
+ /** Bound on live entries: a long-lived app must not grow without limit. */
33
+ const MAX_ENTRIES = 64;
34
+
35
+ const entries = new Map(); // key -> { rounds, tokens, note, at, interruptions }
36
+ const historyKeys = new WeakMap(); // history[] -> key
37
+ let historyKeySeq = 0;
38
+
39
+ /** Drop expired entries, then the oldest ones past the bound. */
40
+ function prune(now = Date.now()) {
41
+ for (const [key, entry] of entries) {
42
+ if (now - entry.at > TTL_MS) entries.delete(key);
43
+ }
44
+ while (entries.size > MAX_ENTRIES) {
45
+ // Map iteration is insertion-ordered and `record` re-inserts on write, so
46
+ // the first key is the oldest.
47
+ const oldest = entries.keys().next();
48
+ if (oldest.done) break;
49
+ entries.delete(oldest.value);
50
+ }
51
+ return entries.size;
52
+ }
53
+
54
+ /**
55
+ * The key an interruption is filed under. A caller that states its session
56
+ * (desktop payloads carry an id) gets an exact match; otherwise the working
57
+ * directory, then the identity of the history array, then a single shared
58
+ * bucket. `adopt` covers the remaining case — a caller that mints a fresh key
59
+ * every turn.
60
+ */
61
+ function keyFor(payload, history) {
62
+ const p = payload && typeof payload === 'object' ? payload : {};
63
+ const stated = p.sessionKey || p.sessionId || p.chatId || p.threadId || p.conversationId;
64
+ if (typeof stated === 'string' && stated.trim()) return `s:${stated.trim()}`;
65
+ if (typeof p.cwd === 'string' && p.cwd.trim()) return `cwd:${p.cwd.trim()}`;
66
+ if (history && typeof history === 'object') {
67
+ let key = historyKeys.get(history);
68
+ if (!key) {
69
+ historyKeySeq += 1;
70
+ key = `hist-${historyKeySeq}`;
71
+ historyKeys.set(history, key);
72
+ }
73
+ return key;
74
+ }
75
+ return 'default';
76
+ }
77
+
78
+ /**
79
+ * Extra rounds granted to a resuming turn. Deliberately small and bounded: the
80
+ * point of resuming is to *continue* work that already exists, not to hand out
81
+ * a second full horizon (every round re-sends the whole conversation, so this
82
+ * is the cheapest way to make the cap non-fatal).
83
+ */
84
+ function bonus(base) {
85
+ const n = Number.isFinite(base) && base > 0 ? base : 0;
86
+ return Math.max(4, Math.ceil(n / 4));
87
+ }
88
+
89
+ /** Peek without consuming — the entry stays resumable. */
90
+ function peek(key, now = Date.now()) {
91
+ prune(now);
92
+ return entries.get(key) || null;
93
+ }
94
+
95
+ /** Claim the interruption for `key`, if any. Consuming: a second call gets null. */
96
+ function take(key, now = Date.now()) {
97
+ prune(now);
98
+ const entry = entries.get(key);
99
+ if (!entry) return null;
100
+ entries.delete(key);
101
+ return entry;
102
+ }
103
+
104
+ /**
105
+ * Claim the most recent interruption filed under ANY key. This is the bridge
106
+ * for a caller whose key does not survive between turns: the next turn on this
107
+ * process adopts the held work rather than losing it. Bounded by `maxAgeMs` so
108
+ * an unrelated new conversation is not handed yesterday's job.
109
+ */
110
+ function adopt({ now = Date.now(), maxAgeMs = 10 * 60 * 1000 } = {}) {
111
+ prune(now);
112
+ let bestKey = null;
113
+ let best = null;
114
+ for (const [key, entry] of entries) {
115
+ if (now - entry.at > maxAgeMs) continue;
116
+ if (!best || entry.at > best.at) {
117
+ best = entry;
118
+ bestKey = key;
119
+ }
120
+ }
121
+ if (!best) return null;
122
+ entries.delete(bestKey);
123
+ return { key: bestKey, entry: best };
124
+ }
125
+
126
+ /**
127
+ * File an interruption. `chain` carries the count forward from the entry this
128
+ * turn resumed, so `interruptions` reports how many times one job has been cut
129
+ * off — visible in the note and useful when deciding to raise the horizon.
130
+ *
131
+ * `messages` is the interrupted turn's own tool-call transcript (its live
132
+ * `history` array at the moment it hit the horizon) — real memory of what was
133
+ * already done, not just a rounds/tokens count of it. Without it a caller
134
+ * that supplies no conversation of its own (a queue task's fresh dispatch)
135
+ * resumes with an empty history: the model is told "continue, don't restart"
136
+ * with nothing to continue FROM, which is a cold start wearing a note.
137
+ * Stored as a defensive copy — the caller's `history` array keeps being
138
+ * mutated by the turn that is returning it.
139
+ */
140
+ function record(key, { rounds, tokens, note, chain, messages, added } = {}, now = Date.now()) {
141
+ prune(now);
142
+ const prior = entries.get(key);
143
+ const entry = {
144
+ rounds: Number.isFinite(rounds) ? rounds : 0,
145
+ tokens: Number.isFinite(tokens) ? tokens : 0,
146
+ note: typeof note === 'string' ? note : '',
147
+ at: now,
148
+ interruptions: (Number.isFinite(chain) ? chain : prior && prior.interruptions) || 0,
149
+ messages: Array.isArray(messages) ? messages.slice() : [],
150
+ // And what the interrupted turn added on TOP of what its caller already
151
+ // had. A resuming caller who brings its own conversation (an interactive
152
+ // chat) must get this half, not `messages` — pushing the full transcript
153
+ // into a history that already holds its first half duplicates the user's
154
+ // own turns back to the model.
155
+ added: Array.isArray(added) ? added.slice() : [],
156
+ };
157
+ entry.interruptions += 1;
158
+ entries.delete(key);
159
+ entries.set(key, entry); // re-insert so Map order stays age order
160
+ prune(now);
161
+ return entry;
162
+ }
163
+
164
+ /** The instruction that turns a cold start into a continuation. */
165
+ function resumePreamble(entry) {
166
+ const rounds = entry && entry.rounds ? entry.rounds : 'the previous turn\'s';
167
+ const tokens = entry && entry.tokens ? ` (${entry.tokens.toLocaleString()} tokens)` : '';
168
+ return (
169
+ `[session resume] The previous turn in this conversation was cut off at ${rounds} tool rounds${tokens} ` +
170
+ 'because it reached the round horizon, so the work it started is unfinished. Continue that work now: ' +
171
+ 'do not restart, do not re-do steps that already completed, and do not treat the interruption as a ' +
172
+ 'failure to report. Pick up the next unfinished step and finish with a written answer.'
173
+ );
174
+ }
175
+
176
+ /** Read-only snapshot for the UI (and for tests). */
177
+ function state(now = Date.now()) {
178
+ prune(now);
179
+ return {
180
+ size: entries.size,
181
+ ttlMs: TTL_MS,
182
+ maxEntries: MAX_ENTRIES,
183
+ entries: [...entries].map(([key, entry]) => ({ key, ...entry })),
184
+ };
185
+ }
186
+
187
+ /** Test hook: forget everything. */
188
+ function reset() {
189
+ entries.clear();
190
+ return entries.size;
191
+ }
192
+
193
+ module.exports = {
194
+ TTL_MS,
195
+ MAX_ENTRIES,
196
+ keyFor,
197
+ bonus,
198
+ peek,
199
+ take,
200
+ adopt,
201
+ record,
202
+ resumePreamble,
203
+ state,
204
+ reset,
205
+ };
package/lib/settings.js CHANGED
@@ -54,12 +54,21 @@ const DEFAULT_QUICK_LAUNCHER_SHORTCUT = 'CmdOrCtrl+Shift+Space';
54
54
  * top-level namespace — never inside a provider's `cfg[provider]` object. */
55
55
  const CONFIRM_MODE_NAMESPACE = '__confirmMode';
56
56
 
57
+ /** Reserved namespace for the persisting-memory preference: `{ enabled }` —
58
+ * app-level, not a provider. This is the desktop's counterpart to the CLI's
59
+ * `memoryPersist` config key, and the gate `lib/sync/persist-gate.js` reads
60
+ * before any automatic cloud push. Default is ON (an absent namespace means
61
+ * persistence is on, which is the shipped decision); an explicit `false` is
62
+ * what turns it off. Nothing secret lives here, so no encryption. */
63
+ const MEMORY_PERSIST_NAMESPACE = '__memoryPersist';
64
+
57
65
  /** Namespaces the provider-config surface must never see or mutate. */
58
66
  const RESERVED_NAMESPACES = Object.freeze([
59
67
  AEGIS_KEY_NAMESPACE,
60
68
  LEGACY_AEGIS_NAMESPACE,
61
69
  QUICK_LAUNCHER_NAMESPACE,
62
70
  CONFIRM_MODE_NAMESPACE,
71
+ MEMORY_PERSIST_NAMESPACE,
63
72
  ]);
64
73
 
65
74
  /** True for the AEGIS-key namespace(s) — provider CRUD must refuse these. */
@@ -282,6 +291,36 @@ function createSettingsStore({ dir, safeStorage } = {}) {
282
291
  return getConfirmMode();
283
292
  }
284
293
 
294
+ // --- Persisting memory: reserved namespace, plain preference -------------
295
+ // The desktop's half of "persisting memory for every account": whether a
296
+ // finished turn is pushed to cloud memory automatically. ON when unset —
297
+ // an install that never touched the toggle behaves like cli/, whose
298
+ // `memoryPersist` also defaults to on. `memoryPersistState()` reports the
299
+ // SOURCE as well as the value so "off" and "never set" stay distinguishable;
300
+ // lib/sync/persist-gate.js reads the same namespace straight off disk, and
301
+ // the two must agree on the default.
302
+
303
+ function getMemoryPersist() {
304
+ const cfg = load()[MEMORY_PERSIST_NAMESPACE] || {};
305
+ return cfg.enabled === undefined ? true : Boolean(cfg.enabled);
306
+ }
307
+
308
+ /** `{ enabled, source }` — mirrors cli/src/cloudsync.js memoryPersistState(). */
309
+ function memoryPersistState() {
310
+ const cfg = load()[MEMORY_PERSIST_NAMESPACE] || {};
311
+ return {
312
+ enabled: getMemoryPersist(),
313
+ source: cfg.enabled === undefined ? 'default' : 'config',
314
+ };
315
+ }
316
+
317
+ function setMemoryPersist(enabled) {
318
+ const data = load();
319
+ data[MEMORY_PERSIST_NAMESPACE] = { enabled: Boolean(enabled) };
320
+ save(data);
321
+ return memoryPersistState();
322
+ }
323
+
285
324
  return {
286
325
  file,
287
326
  get,
@@ -299,6 +338,9 @@ function createSettingsStore({ dir, safeStorage } = {}) {
299
338
  setQuickLauncherConfig,
300
339
  getConfirmMode,
301
340
  setConfirmMode,
341
+ getMemoryPersist,
342
+ memoryPersistState,
343
+ setMemoryPersist,
302
344
  };
303
345
  }
304
346
 
@@ -308,6 +350,7 @@ module.exports = {
308
350
  LEGACY_AEGIS_NAMESPACE,
309
351
  QUICK_LAUNCHER_NAMESPACE,
310
352
  CONFIRM_MODE_NAMESPACE,
353
+ MEMORY_PERSIST_NAMESPACE,
311
354
  DEFAULT_QUICK_LAUNCHER_SHORTCUT,
312
355
  RESERVED_NAMESPACES,
313
356
  isReservedNamespace,
@@ -0,0 +1,120 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * persist-gate.js — the desktop half of "persisting memory for every account".
5
+ *
6
+ * `cli/` flips this default in config.json (`memoryPersist`, migrated off the
7
+ * legacy `cloudSync` key). The desktop has no config.json, so its preference
8
+ * lives in the same settings.json the Settings pane already writes, under the
9
+ * reserved `__memoryPersist` namespace — and it is read HERE, from disk, by the
10
+ * main process that performs the push.
11
+ *
12
+ * Two reasons the gate is not taken from the renderer:
13
+ *
14
+ * 1. The decision has to be enforceable in the main process whatever the
15
+ * renderer believes. A renderer-held flag is a suggestion; the process
16
+ * that opens the socket decides.
17
+ * 2. `createSyncDispatch()` / createAutoPush() are unit-tested in plain Node,
18
+ * where no renderer and no Electron exist.
19
+ *
20
+ * Default is ON, matching cli/. `source` keeps "the user turned this off" and
21
+ * "nobody has been asked yet" tellable apart — the whole point of shipping a
22
+ * default is that the absence of a stored key is not the same as a stored no.
23
+ */
24
+
25
+ const fs = require('node:fs');
26
+ const path = require('node:path');
27
+
28
+ const { MEMORY_PERSIST_NAMESPACE, SETTINGS_FILE } = require('../settings.js');
29
+
30
+ /** The settings file the store writes (`createSettingsStore` uses the same
31
+ * `path.join(dir, SETTINGS_FILE)`, so both agree on one source of truth).
32
+ * A caller that already holds the file path may pass it directly. */
33
+ function settingsFile(dir) {
34
+ const p = dir || '';
35
+ return p.endsWith(SETTINGS_FILE) ? p : path.join(p, SETTINGS_FILE);
36
+ }
37
+
38
+ function readSettings(dir) {
39
+ try {
40
+ const data = JSON.parse(fs.readFileSync(settingsFile(dir), 'utf8'));
41
+ return data && typeof data === 'object' ? data : {};
42
+ } catch {
43
+ return {};
44
+ }
45
+ }
46
+
47
+ /**
48
+ * `{ enabled, source }`. `source` is 'config' only when the file actually
49
+ * carries the key — an explicit `false` survives, an absent namespace is ON.
50
+ */
51
+ function gateState(dir) {
52
+ const cfg = readSettings(dir)[MEMORY_PERSIST_NAMESPACE];
53
+ if (!cfg || cfg.enabled === undefined) return { enabled: true, source: 'default' };
54
+ return { enabled: Boolean(cfg.enabled), source: 'config' };
55
+ }
56
+
57
+ /**
58
+ * The post-turn push, gated.
59
+ *
60
+ * `push` is `createSyncDispatch`'s own drain, so this inherits the queue
61
+ * fallback for free: a push flushes `memory-queue.json` before it touches the
62
+ * session list, which is exactly the "offline now, sync later" behaviour the
63
+ * manual button had and the automatic path must not lose.
64
+ *
65
+ * Coalesced: a turn that ends while a drain is still in flight does not open a
66
+ * second one. `push()` snapshots `sessions.listPending(dir)`, so a concurrent
67
+ * call would race the same rows; the newest session instead goes out on the
68
+ * next trigger (the following turn's auto push, or "Sync now"). One turn of
69
+ * delay is the correct trade against two writers marking the same session
70
+ * synced.
71
+ */
72
+ function createAutoPush({ push, dir, gate } = {}) {
73
+ if (typeof push !== 'function') throw new Error('createAutoPush requires a push()');
74
+ const readGate = typeof gate === 'function' ? gate : () => gateState(dir);
75
+ let inFlight = null;
76
+
77
+ async function run() {
78
+ const state = readGate();
79
+ if (!state.enabled) {
80
+ return {
81
+ ok: true,
82
+ skipped: true,
83
+ gate: state,
84
+ reason: 'persisting memory is off',
85
+ };
86
+ }
87
+ try {
88
+ const result = await push();
89
+ return Object.assign({}, result, { skipped: false, gate: state });
90
+ } catch (err) {
91
+ // A chat turn must never fail because a background push did. `push()`
92
+ // already resolves `{ ok:false, … }` for its expected failures; this
93
+ // catches the unexpected so the caller's fire-and-forget cannot become
94
+ // an unhandled rejection.
95
+ return {
96
+ ok: false,
97
+ skipped: false,
98
+ gate: state,
99
+ reason: (err && err.message) || String(err),
100
+ };
101
+ }
102
+ }
103
+
104
+ function auto() {
105
+ if (inFlight) return inFlight;
106
+ inFlight = run().finally(() => {
107
+ inFlight = null;
108
+ });
109
+ return inFlight;
110
+ }
111
+
112
+ return { auto, gate: () => readGate(), state: () => readGate() };
113
+ }
114
+
115
+ module.exports = {
116
+ settingsFile,
117
+ readSettings,
118
+ gateState,
119
+ createAutoPush,
120
+ };
package/main.js CHANGED
@@ -66,6 +66,7 @@ const ollama = require('./lib/local/ollama.js');
66
66
  const providers = require('./lib/local/providers.js');
67
67
  const sessionStore = require('./lib/sync/sessions.js');
68
68
  const memoryQueue = require('./lib/sync/memory-queue.js');
69
+ const persistGate = require('./lib/sync/persist-gate.js');
69
70
  const windowState = require('./lib/window-state.js');
70
71
  const deepLink = require('./lib/deep-link.js');
71
72
  const quickLauncherLib = require('./lib/quick-launcher.js');
@@ -829,6 +830,62 @@ function createEngine(aegis, { app, safeStorage, dir: dirOverride, sessionsDir:
829
830
  return { engine, sessionsDir, settingsDir, settings };
830
831
  }
831
832
 
833
+ /** Largest pre-edit read the diff bridge will hand the renderer (bytes). */
834
+ const PREVIEW_READ_MAX_BYTES = 512 * 1024;
835
+
836
+ /**
837
+ * The contained, capped read behind the renderer's diff preview — the only
838
+ * filesystem capability the renderer has, and the narrowest form of it.
839
+ *
840
+ * The renderer supplies a file plus the cwd of the session it is displaying.
841
+ * Every path is resolved with `realpath` *before* the containment test, so a
842
+ * symlink planted inside the workspace cannot point the read outside it, and
843
+ * containment is judged with `path.relative` rather than a `startsWith` prefix
844
+ * (which would let `/home/neo-evil` pass a naive `/home/neo` check). Anything
845
+ * refused — relative cwd, escape, missing file, directory, non-regular file —
846
+ * returns null, which degrades the preview to a create-style diff instead of
847
+ * throwing onto the render path.
848
+ *
849
+ * @returns {{text: string, truncated: boolean}|null}
850
+ */
851
+ function readTextFileForPreview(payload) {
852
+ try {
853
+ const file = payload && typeof payload.file === 'string' ? payload.file : '';
854
+ const cwd = payload && typeof payload.cwd === 'string' ? payload.cwd : '';
855
+ if (!file || !cwd || !path.isAbsolute(cwd)) return null;
856
+
857
+ // realpath both sides: resolving the root too keeps this correct on hosts
858
+ // where the workspace itself is reached through a symlink (/tmp, /var).
859
+ const root = fs.realpathSync(cwd);
860
+ const abs = fs.realpathSync(path.resolve(root, file));
861
+ const rel = path.relative(root, abs);
862
+ if (rel === '' || rel.startsWith('..') || path.isAbsolute(rel)) return null;
863
+
864
+ const st = fs.statSync(abs);
865
+ if (!st.isFile()) return null;
866
+
867
+ if (st.size <= PREVIEW_READ_MAX_BYTES) {
868
+ return { text: fs.readFileSync(abs, 'utf8'), truncated: false };
869
+ }
870
+ // Oversized: read only the head of the file and cut back to the last
871
+ // newline, so a byte-boundary truncation cannot leave a half-decoded
872
+ // multi-byte sequence at the end of the preview.
873
+ const fd = fs.openSync(abs, 'r');
874
+ try {
875
+ const buf = Buffer.alloc(PREVIEW_READ_MAX_BYTES);
876
+ const n = fs.readSync(fd, buf, 0, PREVIEW_READ_MAX_BYTES, 0);
877
+ let text = buf.toString('utf8', 0, n);
878
+ const nl = text.lastIndexOf('\n');
879
+ if (nl > 0) text = text.slice(0, nl + 1);
880
+ return { text, truncated: true };
881
+ } finally {
882
+ fs.closeSync(fd);
883
+ }
884
+ } catch {
885
+ return null;
886
+ }
887
+ }
888
+
832
889
  /**
833
890
  * Pure mapping: model:<name> -> LocalEngine call (unit-testable in Node).
834
891
  */
@@ -849,6 +906,14 @@ function createModelDispatch(engine) {
849
906
  }),
850
907
  'settings.remove': (payload) =>
851
908
  engine.settings.remove(payload && payload.provider),
909
+ // Persisting memory ("sync finished turns to cloud memory automatically").
910
+ // Reserved namespace, so it is invisible to the provider CRUD above — but
911
+ // unlike a provider it IS reachable deliberately, through these two. The
912
+ // gate that enforces it lives in lib/sync/persist-gate.js and reads the
913
+ // same file, so a `false` written here stops the next automatic push.
914
+ 'memoryPersist.get': () => engine.settings.memoryPersistState(),
915
+ 'memoryPersist.set': (payload) =>
916
+ engine.settings.setMemoryPersist(Boolean(payload && payload.enabled)),
852
917
  cancel: (payload) => engine.cancel(payload && payload.sessionId),
853
918
  // Tool-call approval gate (desktop/lib/local/engine.js gatedExecuteTool):
854
919
  // the renderer's approval card answers a pending exec/writeFile/editFile
@@ -869,7 +934,7 @@ function createModelDispatch(engine) {
869
934
  * resolve `{ ok: false, reason }` without ever throwing, and every local
870
935
  * flow (save/append/list) keeps working untouched.
871
936
  */
872
- function createSyncDispatch(sessions, dir, aegis) {
937
+ function createSyncDispatch(sessions, dir, aegis, settingsDir) {
873
938
  let lastSyncAt = null;
874
939
 
875
940
  function hasCloud() {
@@ -1013,7 +1078,20 @@ function createSyncDispatch(sessions, dir, aegis) {
1013
1078
  }
1014
1079
  }
1015
1080
 
1081
+ // The automatic post-turn push (persisting memory for every account). Gated
1082
+ // by the `__memoryPersist` preference in settings.json, which the Settings
1083
+ // pane writes through the memoryPersist:* IPC below; the gate is read from
1084
+ // disk in the main process so the renderer cannot push past it. This must
1085
+ // read `settingsDir` (Electron's userData path), NOT `dir` — `dir` here is
1086
+ // the shared aegisHome() sessions/credentials directory, a different path
1087
+ // from userData in a real install, and the settings store is created with
1088
+ // `settingsDir`. Falling back to `dir` only covers callers (tests) that
1089
+ // never pass `settingsDir` and use one directory for everything.
1090
+ const autoPush = persistGate.createAutoPush({ push, dir: settingsDir || dir });
1091
+
1016
1092
  return {
1093
+ auto: () => autoPush.auto(),
1094
+ memoryPersistState: () => autoPush.gate(),
1017
1095
  listSessions: () => ({ sessions: sessions.listSessions(dir) }),
1018
1096
  open: (payload) => sessions.getSession(dir, payload && payload.sessionId),
1019
1097
  save: (payload) => sessions.upsertSession(dir, payload || {}),
@@ -1135,11 +1213,16 @@ function createHeartbeatRetry(push, opts = {}) {
1135
1213
  * `onReplyFinished`, like the identical param on registerIpc, is
1136
1214
  * bootstrap()-only — it fires the native "reply ready" notification when the
1137
1215
  * window is unfocused/hidden and is never set in the headless test path.
1138
- * `heartbeatOpts` ({ now, baseMs, maxMs }) is for tests only.
1216
+ * `heartbeatOpts` ({ now, baseMs, maxMs }) is for tests only. `settingsDir`
1217
+ * is Electron's userData path — where settings.json (and so the
1218
+ * `__memoryPersist` preference the auto-push gate reads) actually lives;
1219
+ * `sessionsDir` is the separate shared aegisHome() directory and must not be
1220
+ * used for that gate. Falls back to `sessionsDir` for callers that don't pass
1221
+ * it, i.e. tests that use one directory for everything.
1139
1222
  */
1140
- function registerModelIpc(ipcMain, engine, sessionsDir, aegis, onReplyFinished, heartbeatOpts) {
1223
+ function registerModelIpc(ipcMain, engine, sessionsDir, aegis, onReplyFinished, heartbeatOpts, settingsDir) {
1141
1224
  const modelDispatch = createModelDispatch(engine);
1142
- const syncDispatch = createSyncDispatch(sessionStore, sessionsDir, aegis);
1225
+ const syncDispatch = createSyncDispatch(sessionStore, sessionsDir, aegis, settingsDir);
1143
1226
 
1144
1227
  const heartbeat = createHeartbeatRetry(() => syncDispatch.push(), heartbeatOpts);
1145
1228
 
@@ -1187,6 +1270,17 @@ function registerModelIpc(ipcMain, engine, sessionsDir, aegis, onReplyFinished,
1187
1270
  ipcMain.handle(`${MODEL_PREFIX}${name}`, (_event, payload) => handler(payload));
1188
1271
  }
1189
1272
 
1273
+ // Diff-preview pre-edit read (preload.js `readTextFile`). Registered with
1274
+ // `on` + `event.returnValue`, NOT `handle`: the bridge is `sendSync` because
1275
+ // editPreview consumes its reader synchronously and cannot await an invoke
1276
+ // promise, and `sendSync` is answered only by an `ipcMain.on` listener.
1277
+ // Putting it in modelDispatch above would register a `handle` that sendSync
1278
+ // never reaches. Fires on every edit tool's run frame, so the refusal path
1279
+ // is the common case (a `writeFile` creating a new file) and must stay cheap.
1280
+ ipcMain.on(`${MODEL_PREFIX}readTextFile`, (event, payload) => {
1281
+ event.returnValue = readTextFileForPreview(payload);
1282
+ });
1283
+
1190
1284
  for (const [name, handler] of Object.entries(syncDispatch)) {
1191
1285
  if (name === 'status') {
1192
1286
  ipcMain.handle(`${SYNC_PREFIX}${name}`, (_event, payload) => {
@@ -1357,6 +1451,11 @@ function createQueueDispatch({
1357
1451
  draining,
1358
1452
  stopping: stopRequested,
1359
1453
  defaultCwd: process.cwd(),
1454
+ // What a task with no model of its own actually runs on. The queue is
1455
+ // Aegis Cloud only, so this is a pooled tier id — and the card prints it,
1456
+ // because "which model is this drain spending on" was previously
1457
+ // unanswerable from the UI.
1458
+ defaultModel: autonomous.resolveModel({ env }),
1360
1459
  queueFile: queue.queuePath(env),
1361
1460
  runs: queue.readRuns(env, { limit: 20 }),
1362
1461
  };
@@ -1385,6 +1484,13 @@ function createQueueDispatch({
1385
1484
  if (!isExistingDir(cwd)) return { error: `not a directory: ${cwd}` };
1386
1485
 
1387
1486
  const model = String(p.model == null ? '' : p.model).trim().slice(0, 120) || null;
1487
+ // Aegis Cloud only (autonomous.js modelRefusal): the worker's requests go
1488
+ // out as `class: 'aegis'`, so a model the pool does not serve is a task that
1489
+ // fails inside a drain. Refused HERE so the card gets the reason at the
1490
+ // moment of queueing instead of a red row later. queue.addTask enforces the
1491
+ // same rule for the CLI and for hand-written queue files.
1492
+ const modelRefusal = model ? autonomous.modelRefusal(model) : '';
1493
+ if (modelRefusal) return { error: modelRefusal };
1388
1494
  const effortRaw = String(p.effort == null ? '' : p.effort).trim().toLowerCase();
1389
1495
  return {
1390
1496
  opts: {
@@ -1394,6 +1500,12 @@ function createQueueDispatch({
1394
1500
  effort: QUEUE_EFFORTS.has(effortRaw) ? effortRaw : null,
1395
1501
  workers: clampInt(p.workers, 1, QUEUE_MAX_WORKERS),
1396
1502
  maxRounds: clampInt(p.maxRounds, 1, QUEUE_MAX_ROUNDS),
1503
+ // The pooled-brain fan-out is opt-in (autonomous.js resolveFanout):
1504
+ // it costs about workers+1 full reasoning calls on one task, so the
1505
+ // card has to ask for it per task. queue.addTask stores the inverse
1506
+ // (`singlePass`), which is also the field a hand-written queue file
1507
+ // or the CLI uses.
1508
+ singlePass: !(p.fanout === true || p.autonomous === true),
1397
1509
  commit: Boolean(p.commit),
1398
1510
  source: 'desktop',
1399
1511
  },
@@ -2163,7 +2275,7 @@ function bootstrap() {
2163
2275
  // One settings store backs both the provider settings surface and the AEGIS
2164
2276
  // API key entry. It lives only in the main process and encrypts keys at rest
2165
2277
  // via Electron safeStorage (best-effort base64 when unavailable).
2166
- const { engine, sessionsDir, settings } = createEngine(aegis, {
2278
+ const { engine, sessionsDir, settingsDir, settings } = createEngine(aegis, {
2167
2279
  app,
2168
2280
  safeStorage,
2169
2281
  dir: dataDir,
@@ -2215,7 +2327,15 @@ function bootstrap() {
2215
2327
  (url) => shell.openExternal(url),
2216
2328
  notifyReplyIfUnfocused
2217
2329
  );
2218
- registerModelIpc(ipcMain, engine, sessionsDir, aegis, notifyReplyIfUnfocused);
2330
+ registerModelIpc(
2331
+ ipcMain,
2332
+ engine,
2333
+ sessionsDir,
2334
+ aegis,
2335
+ notifyReplyIfUnfocused,
2336
+ undefined,
2337
+ settingsDir
2338
+ );
2219
2339
 
2220
2340
  // Tool-call approval toggle (Settings → "Confirm before running tools"):
2221
2341
  // aegis:getConfirmMode / aegis:setConfirmMode. Registered here because this
@@ -2593,6 +2713,8 @@ module.exports = {
2593
2713
  taggedChunk,
2594
2714
  maskKey,
2595
2715
  createModelDispatch,
2716
+ readTextFileForPreview,
2717
+ PREVIEW_READ_MAX_BYTES,
2596
2718
  createSyncDispatch,
2597
2719
  registerModelIpc,
2598
2720
  createHeartbeatRetry,
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "aegis-desktop",
3
3
  "productName": "AEGIS Desktop",
4
- "version": "0.6.0",
4
+ "version": "0.7.0",
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",
@@ -29,7 +29,7 @@
29
29
  "icon": "node scripts/generate-icon.mjs",
30
30
  "dist": "npm run predist && electron-builder",
31
31
  "dist:dir": "npm run predist && electron-builder --dir",
32
- "check": "node --check main.js && node --check preload.js && node --check renderer/app.js && node --check renderer/quick.js && node --check renderer/budget.js && node --check renderer/usage.js && node --check renderer/stream-policy.js && node --check renderer/transcript-view.js && node --check renderer/markdown.js && node --check renderer/vendor/aegis-highlight.js && node --check scripts/predist.mjs && node --check scripts/generate-icon.mjs && node --check lib/local/context.js && node --check lib/local/providers.js && node --check lib/local/ollama.js && node --check lib/local/engine.js && node --check lib/local/tools.js && node --check lib/local/prompt.js && node --check lib/local/shell.js && node --check lib/local/agents.js && node --check lib/local/queue.js && node --check lib/local/autonomous.js && node --check lib/settings.js && node --check lib/sync/sessions.js && node --check lib/sync/memory-queue.js && node --check lib/window-state.js && node --check lib/deep-link.js && node --check lib/quick-launcher.js && node --check bin/aegis.js",
32
+ "check": "node --check main.js && node --check preload.js && node --check renderer/app.js && node --check renderer/quick.js && node --check renderer/budget.js && node --check renderer/usage.js && node --check renderer/stream-policy.js && node --check renderer/transcript-view.js && node --check renderer/diff.js && node --check renderer/diffview.js && node --check renderer/markdown.js && node --check renderer/vendor/aegis-highlight.js && node --check scripts/predist.mjs && node --check scripts/generate-icon.mjs && node --check lib/local/context.js && node --check lib/local/providers.js && node --check lib/local/ollama.js && node --check lib/local/engine.js && node --check lib/local/session-rounds.js && node --check lib/local/tools.js && node --check lib/local/prompt.js && node --check lib/local/shell.js && node --check lib/local/agents.js && node --check lib/local/queue.js && node --check lib/local/autonomous.js && node --check lib/settings.js && node --check lib/sync/sessions.js && node --check lib/sync/memory-queue.js && node --check lib/window-state.js && node --check lib/deep-link.js && node --check lib/quick-launcher.js && node --check bin/aegis.js",
33
33
  "test:shell": "node ../test/desktop-shell.mjs",
34
34
  "test:model": "node ../test/model-dispatch.mjs",
35
35
  "test:budget": "node ../test/budget.test.mjs",
@@ -39,6 +39,8 @@
39
39
  "test:highlight": "node ../test/aegis-highlight.test.mjs",
40
40
  "test:markdown": "node ../test/markdown.test.mjs",
41
41
  "test:deep-link": "node test/deep-link.test.mjs",
42
+ "test:renderer-diff": "node test/renderer-diff.test.mjs",
43
+ "test:preview-read": "node ../test/desktop-preview-read.test.mjs",
42
44
  "test:stream-policy": "node ../test/stream-policy.test.mjs",
43
45
  "test:renderer-dom": "node ../test/renderer-dom.test.mjs",
44
46
  "test:renderer-wiring": "node ../test/renderer-wiring.test.mjs"