aegis-desktop 0.6.1 → 0.7.1

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/README.md CHANGED
@@ -45,6 +45,88 @@ queue that flushes on each "Sync now" or heartbeat retry. The **remember**
45
45
  button on any assistant reply pins that message to cross-machine memory —
46
46
  queued locally if you're offline.
47
47
 
48
+ ## Autonomous queue
49
+
50
+ The unattended work queue, shared with the CLI: tasks are appended to
51
+ `~/.aegiscode/queue.jsonl` and drained later, one at a time, with tool approval
52
+ **disabled** — there is nobody there to click an approval card. The desktop
53
+ sidebar's queue card and `aegiscode autonomous` are two views of the same file.
54
+
55
+ ```bash
56
+ aegiscode autonomous add "fix the flaky retry test" --cwd ~/repo --commit
57
+ aegiscode autonomous list
58
+ aegiscode autonomous run # drain one task, then stop
59
+ aegiscode autonomous proceed --max 3 # drain up to three
60
+ aegiscode autonomous reconcile --auto # queue the next unfinished PLAN.md phase, then drain it
61
+ aegiscode autonomous retry <id> # put a finished task back
62
+ aegiscode autonomous clear --all # empty the queue
63
+ ```
64
+
65
+ A task's text is stored verbatim — `add "fix --json in the parser"` is a task,
66
+ not a flag. Drains commit only the paths that task's own tool layer wrote, so a
67
+ drain never sweeps a peer's in-flight edits into your commit.
68
+
69
+ ### Aegis Cloud only
70
+
71
+ A queued task runs on the pooled brain — **`nexus-brain`** (alias
72
+ `aegis-brain`) — for the same reason the Claude Code plugin is cloud-only: the
73
+ queue hands work to a loop with no human in it, and the pooled class is the one
74
+ the server can route, budget, and bill on its own. A task that *states* another
75
+ model id is refused where you can still see it, instead of failing minutes into
76
+ a drain as an opaque server error:
77
+
78
+ | Where the model was stated | What happens |
79
+ |---|---|
80
+ | `autonomous add --model <id>` | refused, with the reason, at add time — nothing is queued |
81
+ | A hand-edited `queue.jsonl` | refused pre-flight by the worker (`ms: 0`), before any turn is billed |
82
+ | `AEGIS_MODEL=<id>` in the environment | **ignored for queue runs**, and reported as a note — that variable is shared with the interactive surfaces, which *do* run direct providers |
83
+ | nothing stated | `nexus-brain` |
84
+
85
+ ### What a queued task costs
86
+
87
+ The queue has two shapes, and the cheap one is the default:
88
+
89
+ | | Single pass (default) | Fan-out (opt in) |
90
+ |---|---|---|
91
+ | Provider calls | 1 | `workers` + 1 (investigation passes, then synthesis) |
92
+ | Effort rung | `medium` | `high` |
93
+ | How to ask for it | nothing — it is the default | `AEGIS_AUTONOMOUS_FANOUT=1` |
94
+
95
+ An earlier version sent **every** queued task as a fan-out at the priciest rung:
96
+ one queued line could become several reasoning calls plus a synthesis, all at
97
+ `high`. Making the fan-out opt-in, and letting effort follow the shape of the
98
+ task rather than always topping out, removes the worker multiplication and
99
+ roughly halves the budget on a one-line task. `--effort` (or
100
+ `AEGIS_AUTONOMOUS_EFFORT`) still wins outright, and `--workers N` is only sent
101
+ when you are actually fanning out.
102
+
103
+ > **Known gap:** there is no `--fanout` flag yet — the opt-in is the environment
104
+ > variable or `singlePass: false` in the task record. `--single-pass` still
105
+ > parses, but it now agrees with the default instead of overriding it.
106
+
107
+ ### A turn that runs out of rounds keeps its work
108
+
109
+ The tool loop runs against a round horizon: 24 rounds for an interactive turn
110
+ (`AEGIS_CHAT_MAX_ROUNDS`), 40 for a queued one
111
+ (`AEGIS_AUTONOMOUS_MAX_ROUNDS`). A model that reached it mid-turn used to lose
112
+ everything it had assembled, because the cap was *turn* state.
113
+
114
+ The horizon is now **session state**, held in `lib/local/session-rounds.js`. When
115
+ a turn stops at the cap the interruption is filed against the session, and the
116
+ next message in that conversation is prefixed with a continuation preamble —
117
+ *cut off after N tool rounds; continue, do not restart* — along with
118
+ `max(4, ⌈horizon/4⌉)` extra rounds so re-orientation does not eat the new
119
+ horizon. The resume is announced in the transcript, so it is visible rather
120
+ than silent.
121
+
122
+ - In-memory and **process-local** (30-minute TTL, 64 entries) — it never leaves
123
+ the process and never touches disk.
124
+ - Claiming an entry **consumes** it: one resume per interruption, so a chain of
125
+ interruptions is a chain of deliberate asks, never an automatic loop.
126
+ - A **stated** horizon always wins; the ledger only pads its own default.
127
+ - A caller that mints a fresh session key every turn adopts the most recent held
128
+ entry (bounded to 10 minutes) instead of losing the work.
129
+
48
130
  ## Keyboard shortcuts
49
131
 
50
132
  ### In the main window
@@ -124,6 +206,9 @@ main.js Electron main process — window + IPC shell only
124
206
  preload.js Context-isolated IPC bridge exposed to the renderer
125
207
  renderer/ UI (vanilla JS, no framework)
126
208
  lib/local/ Model classes, providers, agentic tool loop, prompt
209
+ lib/local/queue.js The shared work queue (~/.aegiscode/queue.jsonl)
210
+ lib/local/autonomous.js The unattended worker — directive, digest, commits
211
+ lib/local/session-rounds.js Session-scoped tool-round ledger (in-memory)
127
212
  lib/sync/ Local session/memory persistence + sync queue
128
213
  vendor/aegis.js The AEGIS transport client (thin — no engine logic)
129
214
  bin/aegis.js `aegis` CLI entry point for the global npm install
@@ -55,6 +55,15 @@ const gitScope = require('./git-scope.js');
55
55
  /** Default tool-round horizon for an unattended turn (matches the engine's). */
56
56
  const DEFAULT_ROUNDS = 40;
57
57
 
58
+ /**
59
+ * Consecutive tool-round-horizon stops a queued task gets before runOne gives
60
+ * up on it as unable to converge and settles it 'error' instead of 'pending'.
61
+ * Without this bound a task that never finishes would keep proceed()'s drain
62
+ * loop picking it back up forever — proceed() defaults to no --max at all, so
63
+ * nothing else would ever stop it.
64
+ */
65
+ const MAX_ROUND_STOPS = 3;
66
+
58
67
  /**
59
68
  * The engine tools that report the file they write, and the argument holding
60
69
  * it. This set is the attribution record for a task's commit: `exec` is absent
@@ -326,7 +335,8 @@ function autonomousDirective(rounds = DEFAULT_ROUNDS) {
326
335
  */
327
336
  function digestLine(item, result) {
328
337
  const task = String(item.task || '').replace(/\s+/g, ' ').trim().slice(0, 120);
329
- const mark = result && result.ok ? '✓' : '✗';
338
+ const paused = Boolean(result && result.ok && result.stoppedOnRounds);
339
+ const mark = paused ? '⏸' : result && result.ok ? '✓' : '✗';
330
340
  const files = (result && Array.isArray(result.files) && result.files.slice(0, 6)) || [];
331
341
  const where = files.length ? ` — touched: ${files.join(', ')}${result.files.length > files.length ? ', …' : ''}` : '';
332
342
  const why = !result || result.ok ? '' : ` — ${String((result && result.error) || 'failed').slice(0, 120)}`;
@@ -616,13 +626,41 @@ function createQueueWorker({ engine, env = process.env, log = () => {}, git = gi
616
626
  const items = queue.loadQueue(env);
617
627
  const claimed = queue.markRunning(items, item.id, { now: now() });
618
628
  if (!claimed) return { ok: false, error: `unknown task #${item.id}`, carry };
629
+ const priorRoundStops = Number(claimed.roundStops) || 0;
619
630
  queue.saveQueue(env, items);
620
631
 
621
- const result = await runTask(claimed, { carry, commit });
632
+ const raw = await runTask(claimed, { carry, commit });
633
+
634
+ // A task that hit its tool-round horizon has NOT finished — engine.js's
635
+ // session ledger (session-rounds.js) is holding the rest of the work
636
+ // under this task's stable session id (sessionIdFor(item.id)), ready to
637
+ // inject a continuation preamble the moment this item is dispatched
638
+ // again. Settling it 'done' here (the old behavior) threw that away: the
639
+ // queue believed the task was finished, so nothing ever sent the next
640
+ // turn that would have consumed the resume, and a human had to notice
641
+ // the output was incomplete and re-queue the whole task from scratch.
642
+ //
643
+ // Left 'pending' instead, so proceed()'s own drain loop picks it straight
644
+ // back up — unless it has now failed to converge MAX_ROUND_STOPS times in
645
+ // a row, at which point looping on it forever (an unattended proceed()
646
+ // with no --max has no other cap at all) is worse than a visible error.
647
+ const hitRoundCap = Boolean(raw.ok && raw.stoppedOnRounds);
648
+ const roundStops = hitRoundCap ? priorRoundStops + 1 : 0;
649
+ const exhausted = hitRoundCap && roundStops > MAX_ROUND_STOPS;
650
+ const result = exhausted
651
+ ? {
652
+ ...raw,
653
+ ok: false,
654
+ error:
655
+ `stopped at its tool-round horizon ${roundStops} times in a row without finishing — ` +
656
+ 'raise AEGIS_AUTONOMOUS_MAX_ROUNDS or split the task into smaller ones',
657
+ }
658
+ : raw;
659
+ const status = exhausted ? 'error' : hitRoundCap ? 'pending' : result.ok ? 'done' : 'error';
622
660
 
623
661
  const after = queue.loadQueue(env);
624
662
  queue.settle(after, claimed.id, {
625
- status: result.ok ? 'done' : 'error',
663
+ status,
626
664
  result: {
627
665
  ok: result.ok,
628
666
  output: result.output || '',
@@ -634,13 +672,15 @@ function createQueueWorker({ engine, env = process.env, log = () => {}, git = gi
634
672
  error: result.ok ? null : result.error,
635
673
  now: now(),
636
674
  });
675
+ const settled = queue.findTask(after, claimed.id);
676
+ if (settled) settled.roundStops = roundStops;
637
677
  queue.saveQueue(env, after);
638
678
  queue.appendRun(env, {
639
679
  id: claimed.id,
640
680
  task: claimed.task,
641
681
  cwd: claimed.cwd,
642
682
  model: resolveModel({ model: claimed.model, env }),
643
- status: result.ok ? 'done' : 'error',
683
+ status: status === 'pending' ? 'stopped' : status,
644
684
  at: new Date(now()).toISOString(),
645
685
  ms: result.ms,
646
686
  usage: result.usage || null,
@@ -722,6 +762,7 @@ function commitMessage(item) {
722
762
  module.exports = {
723
763
  DEFAULT_MODEL,
724
764
  DEFAULT_ROUNDS,
765
+ MAX_ROUND_STOPS,
725
766
  AEGIS_MODEL_IDS,
726
767
  isAegisModel,
727
768
  modelRefusal,
@@ -945,6 +945,11 @@ function createLocalEngine({ aegis, settings, ollama, providers, tools, promptBu
945
945
 
946
946
  const system = (payload && payload.system) || buildSystemPrompt(envFor(payload));
947
947
  const history = Array.isArray(payload && payload.messages) ? payload.messages.filter(Boolean).slice() : [];
948
+ // Index into `history` of everything THIS turn added as opposed to what the
949
+ // caller brought with it. Reset after the resume rehydration below, so a
950
+ // restored transcript is never re-recorded as if this turn had produced it
951
+ // (which would double it on every interruption in a chain).
952
+ let historyStart = history.length;
948
953
  let prompt = (payload && payload.prompt) || '';
949
954
 
950
955
  // Lazily start ONE shell session for this turn; the exec tool shares it so
@@ -1053,6 +1058,26 @@ function createLocalEngine({ aegis, settings, ollama, providers, tools, promptBu
1053
1058
  }
1054
1059
  if (resumed) {
1055
1060
  const preamble = sessionRounds.resumePreamble(resumed);
1061
+ // Restore the interrupted turn's transcript, because the preamble alone
1062
+ // says HOW MUCH was done, not WHAT. The two callers need different
1063
+ // halves of it, so `session-rounds` files both (see `record`):
1064
+ //
1065
+ // · a caller who brings no conversation of its own — the queue
1066
+ // worker's fresh dispatch; autonomous.js never sends `messages` —
1067
+ // has nothing, so it gets the FULL transcript;
1068
+ // · an interactive chat already carries the prior turns (and the
1069
+ // "stopped at N rounds" note), so it gets only what THIS turn added
1070
+ // that the caller never saw: the tool calls and their results. Full
1071
+ // restore here would duplicate the caller's own text.
1072
+ //
1073
+ // The old `history.length === 0` guard did the first and not the second,
1074
+ // so the resume it was written for a queue task never reached the
1075
+ // interactive sessions this feature is actually used from.
1076
+ if (history.length === 0) {
1077
+ if (Array.isArray(resumed.messages) && resumed.messages.length) history.push(...resumed.messages);
1078
+ } else if (Array.isArray(resumed.added) && resumed.added.length) {
1079
+ history.push(...resumed.added);
1080
+ }
1056
1081
  // Round 1's ask travels as `prompt`; a follow-up dispatch (and every
1057
1082
  // turn that skips the shorthand) has to receive it through history.
1058
1083
  if (prompt) prompt = `${preamble}\n\n${prompt}`;
@@ -1117,6 +1142,11 @@ function createLocalEngine({ aegis, settings, ollama, providers, tools, promptBu
1117
1142
  prompt = '';
1118
1143
  };
1119
1144
 
1145
+ // From here on, everything pushed into `history` is this turn's own work:
1146
+ // the restored transcript above (if any) belongs to the turn it came from,
1147
+ // and re-recording it would double the ledger on every interruption.
1148
+ historyStart = history.length;
1149
+
1120
1150
  for (;;) {
1121
1151
  // Stop and SAY so. A turn that reaches its horizon has usually done
1122
1152
  // real work; ending silently would paint an empty answer over it,
@@ -1136,6 +1166,14 @@ function createLocalEngine({ aegis, settings, ollama, providers, tools, promptBu
1136
1166
  tokens: turnUsage.total_tokens || 0,
1137
1167
  note,
1138
1168
  chain: resumed ? resumed.interruptions : 0,
1169
+ // The actual tool-call/tool-result transcript built up this turn —
1170
+ // everything folded/pushed into `history` by completed rounds — so
1171
+ // a resume can restore real memory of the work, not just a count
1172
+ // of it (see the `resumed.messages` rehydration above).
1173
+ messages: history,
1174
+ // …and only what this turn itself added, for the caller that
1175
+ // already has the rest of `history` in its own conversation.
1176
+ added: history.slice(historyStart),
1139
1177
  });
1140
1178
  if (rootOnDelta) rootOnDelta({ delta: `\n\n${note}` });
1141
1179
  return withTurnUsage({
@@ -266,6 +266,10 @@ function retryTask(env, id, opts = {}) {
266
266
  item.updated = opts.now || Date.now();
267
267
  delete item.error;
268
268
  delete item.pid;
269
+ // A manual retry is a fresh deliberate ask, so it gets its own full run of
270
+ // MAX_ROUND_STOPS chances (autonomous.js) rather than inheriting whatever
271
+ // count a previous, unrelated failure left behind.
272
+ delete item.roundStops;
269
273
  saveQueue(env, items);
270
274
  return item;
271
275
  }
@@ -13,7 +13,8 @@
13
13
  * This makes the cap *session* state instead of turn state:
14
14
  *
15
15
  * - a turn that reaches its horizon records the unfinished work against the
16
- * session key (rounds spent, tokens, the note the user saw);
16
+ * session key (rounds spent, tokens, the note the user saw, and the
17
+ * tool-call transcript itself — see `record`'s `messages`);
17
18
  * - the next turn in that session takes the record — consuming it, so one
18
19
  * resume per interruption and a chain of interruptions is a chain of
19
20
  * deliberate asks, never an automatic loop — and gets a continuation
@@ -126,8 +127,17 @@ function adopt({ now = Date.now(), maxAgeMs = 10 * 60 * 1000 } = {}) {
126
127
  * File an interruption. `chain` carries the count forward from the entry this
127
128
  * turn resumed, so `interruptions` reports how many times one job has been cut
128
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.
129
139
  */
130
- function record(key, { rounds, tokens, note, chain } = {}, now = Date.now()) {
140
+ function record(key, { rounds, tokens, note, chain, messages, added } = {}, now = Date.now()) {
131
141
  prune(now);
132
142
  const prior = entries.get(key);
133
143
  const entry = {
@@ -136,6 +146,13 @@ function record(key, { rounds, tokens, note, chain } = {}, now = Date.now()) {
136
146
  note: typeof note === 'string' ? note : '',
137
147
  at: now,
138
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() : [],
139
156
  };
140
157
  entry.interruptions += 1;
141
158
  entries.delete(key);
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
+ };