aegis-desktop 0.6.1 → 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.
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) => {
@@ -2181,7 +2275,7 @@ function bootstrap() {
2181
2275
  // One settings store backs both the provider settings surface and the AEGIS
2182
2276
  // API key entry. It lives only in the main process and encrypts keys at rest
2183
2277
  // via Electron safeStorage (best-effort base64 when unavailable).
2184
- const { engine, sessionsDir, settings } = createEngine(aegis, {
2278
+ const { engine, sessionsDir, settingsDir, settings } = createEngine(aegis, {
2185
2279
  app,
2186
2280
  safeStorage,
2187
2281
  dir: dataDir,
@@ -2233,7 +2327,15 @@ function bootstrap() {
2233
2327
  (url) => shell.openExternal(url),
2234
2328
  notifyReplyIfUnfocused
2235
2329
  );
2236
- registerModelIpc(ipcMain, engine, sessionsDir, aegis, notifyReplyIfUnfocused);
2330
+ registerModelIpc(
2331
+ ipcMain,
2332
+ engine,
2333
+ sessionsDir,
2334
+ aegis,
2335
+ notifyReplyIfUnfocused,
2336
+ undefined,
2337
+ settingsDir
2338
+ );
2237
2339
 
2238
2340
  // Tool-call approval toggle (Settings → "Confirm before running tools"):
2239
2341
  // aegis:getConfirmMode / aegis:setConfirmMode. Registered here because this
@@ -2611,6 +2713,8 @@ module.exports = {
2611
2713
  taggedChunk,
2612
2714
  maskKey,
2613
2715
  createModelDispatch,
2716
+ readTextFileForPreview,
2717
+ PREVIEW_READ_MAX_BYTES,
2614
2718
  createSyncDispatch,
2615
2719
  registerModelIpc,
2616
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.1",
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/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",
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"
package/preload.js CHANGED
@@ -102,6 +102,26 @@ const api = {
102
102
  billingCheckout: () => invoke('billingCheckout'),
103
103
  tokenBankTopup: (amountEur) => invoke('tokenBankTopup', { amountEur }),
104
104
  listModels: () => invoke('listModels'),
105
+ // Pre-edit file read for the diff preview (renderer/deriveDiff). A Write's
106
+ // preview needs the file's contents as they were *before* the executor
107
+ // overwrites them, and editPreview consumes a reader synchronously — so this
108
+ // one method is sentSync, not invoke. It is the narrowest possible widening
109
+ // of the bridge: main constrains the path to inside `cwd` and caps the read
110
+ // (main.js readTextFileForPreview), so the renderer can only ever reach files
111
+ // the session it is displaying is already allowed to edit. Returns
112
+ // `{ text, truncated }` or null; a bridge failure reports null rather than
113
+ // throwing onto the render path, which degrades the diff to a create-style
114
+ // "all additions" block.
115
+ readTextFile: (file, cwd) => {
116
+ try {
117
+ return ipcRenderer.sendSync(MODEL_PREFIX + 'readTextFile', {
118
+ file,
119
+ cwd,
120
+ });
121
+ } catch {
122
+ return null;
123
+ }
124
+ },
105
125
  // Streaming chat (D2.1): pass an onDelta callback to receive SSE chunks as
106
126
  // they arrive (pushed from main over aegis:chatDelta). The invoke promise
107
127
  // resolves once with the normalised final result. Without a callback the
@@ -149,6 +169,11 @@ const api = {
149
169
  // call. Both resolve `{ enabled }`.
150
170
  getConfirmMode: () => invoke('getConfirmMode'),
151
171
  setConfirmMode: (enabled) => invoke('setConfirmMode', { enabled: Boolean(enabled) }),
172
+ // Persisting memory (the desktop counterpart of the CLI's `memoryPersist`):
173
+ // whether a finished turn is pushed to cloud memory automatically. Default
174
+ // ON; `source` distinguishes a stored preference from that default.
175
+ getMemoryPersist: () => invoke('memoryPersist.get'),
176
+ setMemoryPersist: (enabled) => invoke('memoryPersist.set', { enabled: Boolean(enabled) }),
152
177
 
153
178
  // Auto-update (electron-updater over GitHub Releases — see main.js
154
179
  // createUpdateManager). check/download resolve the same status shape the
@@ -276,6 +301,12 @@ const sync = {
276
301
  push: () => invokeSync('push'),
277
302
  pull: () => invokeSync('pull'),
278
303
  status: () => invokeSync('status'),
304
+ // The automatic post-turn push ("persisting memory"). Gate-checked in main
305
+ // against the `__memoryPersist` preference, so calling this on every turn is
306
+ // cheap when persistence is off — it returns `{ skipped: true }` without
307
+ // opening a request. `memoryPersistState()` reports the gate itself.
308
+ auto: () => invokeSync('auto'),
309
+ memoryPersistState: () => invokeSync('memoryPersistState'),
279
310
  };
280
311
 
281
312
  // Quick launcher surface: loaded by BOTH renderer/index.html (the settings
package/renderer/app.js CHANGED
@@ -1940,7 +1940,7 @@ async function spawnPath(card, spec) {
1940
1940
  // tool executes. This line is retrospective by design (see
1941
1941
  // toolActivityLabel), so acting on the run frame too would print every
1942
1942
  // tool twice — once when it starts, once when it finishes.
1943
- if (chunk.tool.phase === 'run') return;
1943
+ if (chunk.tool.phase === 'run') { captureDiffPreview(chunk.tool); return; }
1944
1944
  appendToolActivity(card, chunk.tool, 'flow-tools', '.flow-body');
1945
1945
  return;
1946
1946
  }
@@ -2116,6 +2116,54 @@ function setBusy(busy, { cancellable } = {}) {
2116
2116
  */
2117
2117
  const TOOL_LABEL_MAX = 72;
2118
2118
 
2119
+ /**
2120
+ * Host tool names whose calls render as a collapsible diff block instead of a
2121
+ * bare line. The engine's own spellings (`Edit`/`Write`/`MultiEdit`) are
2122
+ * included so a frame from a newer engine renders the same rather than falling
2123
+ * back to a plain row.
2124
+ */
2125
+ const EDIT_TOOL_NAMES = new Set(['editFile', 'writeFile', 'Edit', 'Write', 'MultiEdit']);
2126
+
2127
+ /**
2128
+ * Diff previews captured on a tool's `phase: 'run'` frame, keyed by tool id.
2129
+ * The run frame fires strictly before the executor writes, which is the only
2130
+ * moment a `writeFile`'s pre-edit contents can still be read — so the preview
2131
+ * is built there and replayed when the matching `done` frame arrives.
2132
+ */
2133
+ const toolPreviews = new Map();
2134
+
2135
+ /**
2136
+ * The pre-edit read a `writeFile`/`Write` preview needs. The renderer runs
2137
+ * sandboxed (main.js sets contextIsolation + sandbox, so there is no
2138
+ * `node:fs`), so the read goes over the preload bridge's synchronous
2139
+ * `readTextFile`. Main confines the path to the session cwd it is handed, so
2140
+ * `cwd` must travel with the call — without it main has nothing to contain
2141
+ * against and refuses, which degrades the write to a create-style diff (all
2142
+ * additions). `editFile` carries its own old_string and needs no read, so it
2143
+ * always renders a full diff regardless.
2144
+ */
2145
+ function hostReadFile(file, cwd) {
2146
+ try {
2147
+ const bridge = typeof window !== 'undefined' ? window.aegis : null;
2148
+ if (bridge && typeof bridge.readTextFile === 'function') {
2149
+ return bridge.readTextFile(file, cwd);
2150
+ }
2151
+ } catch {
2152
+ // A bridge that throws must not break the render path.
2153
+ }
2154
+ return undefined;
2155
+ }
2156
+
2157
+ /** Build and stash the diff preview for an edit tool when its run frame arrives. */
2158
+ function captureDiffPreview(tool) {
2159
+ if (!tool || !EDIT_TOOL_NAMES.has(tool.name) || tool.id == null) return;
2160
+ // editPreview calls readFile(file) with no cwd, so bind the frame's cwd here.
2161
+ const cwd = tool.cwd;
2162
+ const readFile = (file) => hostReadFile(file, cwd);
2163
+ const preview = editPreview(tool.name, tool.args, { cwd, readFile });
2164
+ if (preview) toolPreviews.set(tool.id, preview);
2165
+ }
2166
+
2119
2167
  /**
2120
2168
  * One display line for a completed tool call (`onDelta`'s `{ tool: {name,
2121
2169
  * args, ok} }` chunk — see desktop/lib/local/engine.js). Fires after the tool
@@ -2141,7 +2189,12 @@ function toolActivityLabel(tool) {
2141
2189
  return `→ ${name}${shown ? ` ${shown}` : ''} ${mark}`;
2142
2190
  }
2143
2191
 
2144
- /** Append one tool-activity line to `row`, creating the container on first use. */
2192
+ /**
2193
+ * Append one tool-activity line to `row`, creating the container on first use.
2194
+ * An edit tool whose preview was captured on its run frame renders as a
2195
+ * collapsible diff block in place of the bare line; every other tool keeps the
2196
+ * plain `→ name path ✓` row.
2197
+ */
2145
2198
  function appendToolActivity(row, tool, containerClass, beforeSelector) {
2146
2199
  if (!row) return;
2147
2200
  let toolsEl = row.querySelector(`.${containerClass}`);
@@ -2152,6 +2205,15 @@ function appendToolActivity(row, tool, containerClass, beforeSelector) {
2152
2205
  if (before) row.insertBefore(toolsEl, before);
2153
2206
  else row.appendChild(toolsEl);
2154
2207
  }
2208
+ const preview = tool && tool.id != null ? toolPreviews.get(tool.id) : null;
2209
+ if (preview) {
2210
+ toolPreviews.delete(tool.id); // one-shot: a stale id must not replay on a retry
2211
+ const block = renderDiffBlock(preview, document);
2212
+ if (block) {
2213
+ toolsEl.appendChild(block);
2214
+ return toolsEl;
2215
+ }
2216
+ }
2155
2217
  const line = document.createElement('div');
2156
2218
  line.textContent = toolActivityLabel(tool);
2157
2219
  toolsEl.appendChild(line);
@@ -2695,6 +2757,41 @@ async function refreshSyncStatus() {
2695
2757
  /** Explicit "Sync now" — push pending sessions, then pull remote ones.
2696
2758
  * Offline-first: sync.push()/sync.pull() resolve `{ ok:false, reason }`
2697
2759
  * rather than throwing, so this only hits the catch on an unexpected error. */
2760
+ /**
2761
+ * Persisting memory after a turn: ask main to push what just finished.
2762
+ *
2763
+ * Deliberately unawaited — the answer is already on screen and in the local
2764
+ * session store, so cloud memory is strictly extra and the composer must come
2765
+ * back the moment the answer lands, not after a round trip. The decision to
2766
+ * push at all is main's (lib/sync/persist-gate.js reads the `__memoryPersist`
2767
+ * preference off disk and returns `{ skipped: true }` when it is off); this is
2768
+ * a request, not a gate, which is why no preference is read here.
2769
+ *
2770
+ * It cannot throw — createAutoPush resolves on every failure — but the
2771
+ * `.catch` stays: an unhandled rejection in the renderer is how a background
2772
+ * convenience turns into a fatal.
2773
+ */
2774
+ function autoPersistTurn() {
2775
+ if (typeof sync.auto !== 'function') return;
2776
+ sync
2777
+ .auto()
2778
+ .then((result) => {
2779
+ // Same notice as syncNow(): the cap is the one sync outcome the user
2780
+ // must see rather than have silently absorbed.
2781
+ if (result && result.upgrade) {
2782
+ capNotice(
2783
+ els.sessionsHint,
2784
+ result.upgrade,
2785
+ 'queued memory is waiting on the free-plan cap',
2786
+ 'Upgrade to sync it →'
2787
+ );
2788
+ }
2789
+ })
2790
+ .catch(() => {
2791
+ /* persistence is non-fatal */
2792
+ });
2793
+ }
2794
+
2698
2795
  async function syncNow() {
2699
2796
  els.syncNow.disabled = true;
2700
2797
  els.sessionsHint.textContent = 'syncing…';
@@ -2936,7 +3033,7 @@ async function send() {
2936
3033
  // row. Ignored here, and ignored *before* `toolLog.push` — a run frame
2937
3034
  // counted as a completed tool would inflate the turn summary's tool
2938
3035
  // count for a call that hasn't run yet.
2939
- if (chunk.tool.phase === 'run') return;
3036
+ if (chunk.tool.phase === 'run') { captureDiffPreview(chunk.tool); return; }
2940
3037
  toolLog.push(chunk.tool);
2941
3038
  const row = ensurePendingRow();
2942
3039
  row.classList.remove('pending');
@@ -2985,6 +3082,9 @@ async function send() {
2985
3082
  /* persistence is non-fatal */
2986
3083
  }
2987
3084
 
3085
+ // Persisting memory: the finished turn goes to the cloud on its own now.
3086
+ autoPersistTurn();
3087
+
2988
3088
  // The AI's second path: not awaited — the lane streams beside the thread
2989
3089
  // while the composer goes straight back to the user (chat flow D2.2).
2990
3090
  if (exploreEnabled() && text !== '(empty response)') {
@@ -3005,6 +3105,10 @@ async function send() {
3005
3105
  } catch {
3006
3106
  /* persistence is non-fatal */
3007
3107
  }
3108
+ // A stopped turn is persisted too, and on purpose: this is the case a
3109
+ // round-horizon stop produces (the model was cut off mid-work), and the
3110
+ // partial transcript is exactly what the next turn needs restored.
3111
+ autoPersistTurn();
3008
3112
  } else {
3009
3113
  addMessage(
3010
3114
  'assistant',