aegis-desktop 0.8.11 → 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.
@@ -0,0 +1,327 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * The desktop's φ(α) surface: the same keys and the same records the terminal
5
+ * host mints, driven over IPC instead of argv.
6
+ *
7
+ * `main.js` builds one of these in `createIpcDispatch` and exposes four
8
+ * handlers (`fingerprintStatus`, `fingerprintKeygen`, `fingerprintMint`,
9
+ * `fingerprintVerify` + `fingerprintBackup`), which `preload.js` forwards. The
10
+ * cryptography is NOT here — it is `client/{fingerprint,authorship-key,attest}.js`,
11
+ * the same files the CLI calls, so a record minted in the app verifies in the
12
+ * terminal and vice versa. A second implementation would be the whole bug this
13
+ * arrangement exists to prevent, and `test/fingerprint-interop.test.mjs` is
14
+ * what holds the engine side of that too.
15
+ *
16
+ * ## Publishing (the vault) and the one thing that never goes there
17
+ *
18
+ * A record can be published to the shared vault (`fingerprintPublish` →
19
+ * `client/record-vault.js`, default `gdrive:AEGIS-AGPL`), which writes the
20
+ * *sealed bytes* unchanged plus a `.anchor.json` **link beside the local copy**
21
+ * — so "where is this record published?" is answerable later, from the record.
22
+ * The ownership key is NOT publishable and is not offered: the vault module
23
+ * refuses a key-shaped file by name, by shape and by comparing the bytes against
24
+ * the resolved key, and this service never passes one. The GUI is the host most
25
+ * likely to be asked to "just back the key up to Drive", so the refusal is
26
+ * enforced in the shared module rather than left as a rule in this comment.
27
+ *
28
+ * ## Why it takes `dir` instead of reading `$AEGIS_HOME`
29
+ *
30
+ * The app already owns a data directory (`app.getPath('userData')` in the
31
+ * packaged app, the repo's `.aegiscode` in dev) and every other store the app
32
+ * touches lives in it. A key that resolved to a *different* directory than the
33
+ * one `aegiscode login` writes credentials to would be a second, invisible
34
+ * identity — the exact failure the shared credential store was built to avoid.
35
+ * So the directory is injected, and `AEGIS_HOME` is set from it for the shared
36
+ * modules: one store, both hosts, no ambient guesswork.
37
+ *
38
+ * ## Errors are values, not throws
39
+ *
40
+ * Every method returns `{ ok, … }` or `{ ok: false, error }`. An IPC handler
41
+ * that rejects gives the renderer an opaque `Error: Error invoking remote
42
+ * method` string with the real message buried in main-process logs; a returned
43
+ * error is a message the UI can show verbatim, which matters most for the one
44
+ * error a user is likely to hit ("this key file is readable by other users").
45
+ */
46
+
47
+ const fs = require('node:fs');
48
+ const path = require('node:path');
49
+
50
+ /**
51
+ * Resolve a shared client module by existence: repo checkout first, then the
52
+ * packaged app's flattened `vendor/` (electron-builder cannot reach outside the
53
+ * app directory — see scripts/predist.mjs, which must stage all four).
54
+ */
55
+ function resolveShared(name) {
56
+ const candidates = [
57
+ path.join(__dirname, '..', '..', 'client', name), // dev / CI: repo root
58
+ path.join(__dirname, '..', 'vendor', name), // packaged: desktop/vendor
59
+ ];
60
+ for (const candidate of candidates) {
61
+ if (fs.existsSync(candidate)) return candidate;
62
+ }
63
+ throw new Error(
64
+ `aegis-desktop: cannot find client/${name} — expected it beside desktop/ in the repo ` +
65
+ 'or under desktop/vendor/ in a build. Run `npm run predist` in desktop/.',
66
+ );
67
+ }
68
+
69
+ function load() {
70
+ const fp = require(resolveShared('fingerprint.js'));
71
+ return {
72
+ fp,
73
+ authorship: require(resolveShared('authorship-key.js')),
74
+ attest: require(resolveShared('attest.js')),
75
+ vault: require(resolveShared('record-vault.js')),
76
+ };
77
+ }
78
+
79
+ function slug(value, max = 80) {
80
+ return (
81
+ String(value || 'session')
82
+ .replace(/[^A-Za-z0-9._-]+/g, '_')
83
+ .replace(/^[._-]+/, '')
84
+ .slice(0, max) || 'session'
85
+ );
86
+ }
87
+
88
+ /**
89
+ * @param {object} o
90
+ * @param {string} o.dir the app's data directory (userData in a package)
91
+ * @param {object} [o.env] process env, injectable for tests
92
+ */
93
+ function createFingerprintService({ dir, env } = {}) {
94
+ if (!dir) throw new Error('aegis-desktop: createFingerprintService needs a data dir');
95
+ // One store: the shared modules read AEGIS_HOME and nothing else.
96
+ const scope = { ...(env || process.env), AEGIS_HOME: (env || process.env).AEGIS_HOME || dir };
97
+
98
+ const recordPath = (record, session) =>
99
+ path.join(dir, 'fingerprints', `${slug(session)}.${String(record.createdAt).replace(/[:.]/g, '-')}.json`);
100
+
101
+ return {
102
+ /** Which key is in use, and where it came from. Never returns the key. */
103
+ status() {
104
+ try {
105
+ const { fp, authorship, vault } = load();
106
+ const key = authorship.resolveKey({ argv: [], env: scope });
107
+ const history = path.join(authorship.dataDir(scope), 'history.jsonl');
108
+ const target = vault.vaultTarget(scope);
109
+ return {
110
+ ok: true,
111
+ configured: Boolean(key.secret),
112
+ keySource: key.source,
113
+ keyPath: key.path,
114
+ keyFingerprint: key.secret ? fp.keyFingerprint(key.secret) : null,
115
+ publicKey: key.public,
116
+ defaultKeyPath: authorship.defaultKeyPath(scope),
117
+ historyPath: history,
118
+ historyPresent: fs.existsSync(history),
119
+ dataDir: authorship.dataDir(scope),
120
+ // Where a publish goes, so the panel can say it before the user asks
121
+ // for one — and so a misconfigured root is visible, not discovered by
122
+ // a record landing in the wrong folder.
123
+ vault: { remote: target.remote, root: target.root, recordsDir: `${target.root}/${vault.RECORDS_DIR}` },
124
+ };
125
+ } catch (e) {
126
+ return { ok: false, error: (e && e.message) || String(e) };
127
+ }
128
+ },
129
+
130
+ /** Mint a private key. `force` replaces one, which orphans its records. */
131
+ keygen({ out = null, force = false } = {}) {
132
+ try {
133
+ const { authorship } = load();
134
+ const argv = [];
135
+ if (out) argv.push('--out', out);
136
+ if (force) argv.push('--force');
137
+ const r = authorship.keygen({ argv, env: scope });
138
+ return { ok: true, ...r };
139
+ } catch (e) {
140
+ return { ok: false, error: (e && e.message) || String(e) };
141
+ }
142
+ },
143
+
144
+ /**
145
+ * Seal a session as a record.
146
+ *
147
+ * `turns` may be passed in (the renderer's own view of the session) or left
148
+ * out, in which case the shared adapter reads the session log — same code
149
+ * path as the CLI, so a GUI record and a terminal record of the same
150
+ * session carry the same behaviour digest — and the same AI digest, from the
151
+ * same turns.
152
+ */
153
+ mint({ history = null, session = null, turns = null, label = null, account = null,
154
+ model = null, tools = null, codeRoots = null, codeDirty = null, out = null,
155
+ createdAt = null, publish = false, aiSecret = undefined, aiModel = null } = {}) {
156
+ try {
157
+ const { attest, authorship } = load();
158
+ const roots = Array.isArray(codeRoots) ? codeRoots.filter(Boolean) : [];
159
+ const r = attest.mintSession({
160
+ argv: [],
161
+ env: scope,
162
+ history: history || path.join(authorship.dataDir(scope), 'history.jsonl'),
163
+ session: session || undefined,
164
+ turns: turns || undefined,
165
+ label: label || undefined,
166
+ account: account || undefined,
167
+ model: model || undefined,
168
+ tools: Array.isArray(tools) ? tools : undefined,
169
+ code: roots.length ? attest.collectCode({ roots }) : [],
170
+ codeDirty: typeof codeDirty === 'boolean' ? codeDirty : null,
171
+ // The AI axis. `undefined` walks the shared rungs (`--ai-secret` never
172
+ // applies here; `$AEGIS_AI_SECRET` in the app's scope does), and an
173
+ // explicit null means "no credential — mint the axis as unobserved".
174
+ // The authorship key is never borrowed for this: it says who sealed the
175
+ // record, not which mind answered.
176
+ aiSecret,
177
+ aiModel: aiModel === null ? undefined : aiModel,
178
+ // Pinning the time is how the CLI and the app mint the *same* record
179
+ // for the same session (see client/attest.js). Left null, the record
180
+ // is stamped now, which is what an ordinary seal wants.
181
+ createdAt: createdAt || undefined,
182
+ });
183
+ const file = path.resolve(out || recordPath(r.record, session || 'last'));
184
+ fs.mkdirSync(path.dirname(file), { recursive: true });
185
+ fs.writeFileSync(file, JSON.stringify(r.record, null, 2) + '\n');
186
+
187
+ // Sealing and travelling are separate acts: `publish` is opt-in, and a
188
+ // failed publish NEVER unseals the record — the local file is already
189
+ // written and the error is reported beside it, because the seal is the
190
+ // evidence and the copy is the convenience.
191
+ let vault = null;
192
+ let vaultError = null;
193
+ if (publish) {
194
+ try {
195
+ const key = authorship.resolveKey({ argv: [], env: scope });
196
+ vault = load().vault.publish({ file, argv: [], env: scope, secret: key.secret });
197
+ } catch (e) {
198
+ vaultError = (e && e.message) || String(e);
199
+ }
200
+ }
201
+ return {
202
+ ok: true,
203
+ record: r.record,
204
+ out: file,
205
+ keySource: r.keySource,
206
+ public: r.public,
207
+ vault,
208
+ vaultError,
209
+ };
210
+ } catch (e) {
211
+ return { ok: false, error: (e && e.message) || String(e) };
212
+ }
213
+ },
214
+
215
+ /**
216
+ * Publish a sealed record to the vault, and link the local copy to it.
217
+ *
218
+ * `record` is accepted as well as `file` so the panel can publish what it
219
+ * just minted without a round trip through the filesystem — but the
220
+ * preferred path is the file, because the sha256 in the anchor is then about
221
+ * the bytes on disk rather than a re-serialisation of them.
222
+ */
223
+ publish({ file = null, record = null, dryRun = false } = {}) {
224
+ try {
225
+ const { authorship, vault } = load();
226
+ const key = authorship.resolveKey({ argv: [], env: scope });
227
+ return {
228
+ ok: true,
229
+ ...vault.publish({ file, record, argv: [], env: scope, secret: key.secret, dryRun }),
230
+ };
231
+ } catch (e) {
232
+ return { ok: false, error: (e && e.message) || String(e) };
233
+ }
234
+ },
235
+
236
+ /**
237
+ * Is the published copy still the record that was sealed?
238
+ *
239
+ * Reports the three claims separately (`present`, `unchanged`, `attested`),
240
+ * because "the file is gone" and "the file was replaced" and "the key that
241
+ * sealed it has moved on" are three different situations and a single
242
+ * boolean would flatten them into one that hides the interesting case.
243
+ */
244
+ anchor({ file = null, record = null, anchor = null } = {}) {
245
+ try {
246
+ const { authorship, vault } = load();
247
+ if (!file && !record && !anchor) return { ok: false, error: 'anchor needs a record or an anchor' };
248
+ const key = authorship.resolveKey({ argv: [], env: scope });
249
+ return { ok: true, ...vault.checkAnchor({ file, record, anchor, argv: [], env: scope, key }) };
250
+ } catch (e) {
251
+ return { ok: false, error: (e && e.message) || String(e) };
252
+ }
253
+ },
254
+
255
+ /** What is in the vault. */
256
+ vaultList() {
257
+ try {
258
+ const { vault } = load();
259
+ return { ok: true, ...vault.list({ argv: [], env: scope }) };
260
+ } catch (e) {
261
+ return { ok: false, error: (e && e.message) || String(e) };
262
+ }
263
+ },
264
+
265
+ /**
266
+ * Inspect the vault for key material — including the `keys/` copy that was
267
+ * already there before publishing was restricted. A finding is a fact about
268
+ * the vault, not about this app, so it is returned rather than acted on.
269
+ */
270
+ vaultAudit() {
271
+ try {
272
+ const { authorship, vault } = load();
273
+ const key = authorship.resolveKey({ argv: [], env: scope });
274
+ return { ok: true, ...vault.audit({ argv: [], env: scope, secret: key.secret || null }) };
275
+ } catch (e) {
276
+ return { ok: false, error: (e && e.message) || String(e) };
277
+ }
278
+ },
279
+
280
+ /**
281
+ * Recompute a record's attestation. Accepts a path or the record itself, so
282
+ * the renderer can verify what it just minted without a round trip through
283
+ * the filesystem.
284
+ */
285
+ verify({ file = null, record = null } = {}) {
286
+ try {
287
+ const { fp, authorship } = load();
288
+ let body = record;
289
+ if (!body) {
290
+ if (!file) return { ok: false, error: 'verify needs a record file or the record itself' };
291
+ body = JSON.parse(fs.readFileSync(file, 'utf8'));
292
+ }
293
+ const key = authorship.resolveKey({ argv: [], env: scope });
294
+ if (!key.secret) {
295
+ return { ok: false, error: 'no authorship key — run keygen first', reason: 'no key' };
296
+ }
297
+ const v = fp.verify(body, key.secret);
298
+ return {
299
+ ok: v.ok,
300
+ reason: v.reason,
301
+ id: v.id,
302
+ credentialMatch: v.credentialMatch,
303
+ keySource: key.source,
304
+ recordedKeyFingerprint: body.keyFingerprint || null,
305
+ };
306
+ } catch (e) {
307
+ return { ok: false, error: (e && e.message) || String(e) };
308
+ }
309
+ },
310
+
311
+ /** Copy the key out. Refuses a destination inside a non-ignored git tree. */
312
+ backup({ to = null, name = null, allowGit = false } = {}) {
313
+ try {
314
+ if (!to) return { ok: false, error: 'backup needs a destination directory' };
315
+ const { authorship } = load();
316
+ const argv = ['--to', to];
317
+ if (name) argv.push('--name', name);
318
+ if (allowGit) argv.push('--allow-git');
319
+ return { ok: true, ...authorship.backup({ argv, env: scope }) };
320
+ } catch (e) {
321
+ return { ok: false, error: (e && e.message) || String(e) };
322
+ }
323
+ },
324
+ };
325
+ }
326
+
327
+ module.exports = { createFingerprintService, resolveShared, slug };
@@ -322,6 +322,7 @@ function autonomousDirective(rounds = DEFAULT_ROUNDS) {
322
322
  'Work the task end to end:',
323
323
  '1. Plan in one line, then start acting in the same turn — never end a turn on a plan.',
324
324
  '2. Read only what you need to make the change (no fishing through the repo).',
325
+ '2b. If this demand has been made before, establish whether it was met: check recalled memory, the queue file, PLAN.md, and git history for the earlier attempt. When it was fixed and the problem is back, state how it was fixed then and why it regressed, and fix that cause — a blind re-fix is a regression, not a resolution.',
325
326
  '3. Make the change with Write/Edit/Bash, then VERIFY it: re-read the result and run the relevant test/command.',
326
327
  '4. If verification fails, fix it and verify again — loop until it passes or you are genuinely blocked.',
327
328
  '5. Do not ask for permission, do not hand the work back, do not stop at "let me check…".',
@@ -31,6 +31,12 @@ const MAIN_CHAT_PROMPT =
31
31
  `- Use tools silently. A one-line reason is enough; do not narrate your plan as a story. ` +
32
32
  `- Never claim what a tool found or what a command returned before the tool actually runs. ` +
33
33
  ` Report only the results you really received. ` +
34
+ `- Before you start a fix or feature the user asks for, check whether they have asked for ` +
35
+ ` it before and whether it was already delivered: look in recalled memory, ` +
36
+ ` ~/.aegiscode/queue.jsonl, PLAN.md/notes, and \`git log\` for the same demand. If it was ` +
37
+ ` resolved before and the problem is back, report how it was resolved (the commit, file, or ` +
38
+ ` mechanism that fixed it) and why it regressed, then fix the cause — never re-fix blind as ` +
39
+ ` if the request were new. If it was never met, say so plainly before starting. ` +
34
40
  `- Match the response to the request. A greeting, a question about something you already ` +
35
41
  ` know, or a request for an opinion is answered in plain words with NO tools. Only reach ` +
36
42
  ` for a tool when the answer genuinely depends on something in this repo or on this ` +
package/lib/settings.js CHANGED
@@ -92,7 +92,59 @@ const AVATAR_NAMESPACE = '__avatar';
92
92
  * main.js telemetryEnabled() through getTelemetryOptIn(). */
93
93
  const TELEMETRY_NAMESPACE = '__telemetry';
94
94
 
95
- /** Namespaces the provider-config surface must never see or mutate. */
95
+ /** Reserved namespace for the auto-drain opt-in:
96
+ * `{ enabled, intervalMs }` — app-level, not a provider.
97
+ *
98
+ * DEFAULT OFF, and unlike every preference above that default is the design
99
+ * rather than a convention: this is the one setting in the app that causes a
100
+ * full tool loop to run on a real model in the user's checkout with nobody
101
+ * watching. desktop/main.js and desktop/preload.js both used to state the
102
+ * absolute rule — "there is no interval, no drain at startup, and no
103
+ * drain-on-idle anywhere" — and that rule is still what an install that never
104
+ * touches this namespace gets. What changed (user request, phase A of the
105
+ * automation work) is that the rule became a DEFAULT instead of an absolute:
106
+ * an explicit `enabled: true` here is the only thing that arms a timer, the
107
+ * toggle that writes it is in the queue card, and `lib/auto.js` refuses to
108
+ * run a tick when the queue is empty or another host already holds the drain
109
+ * lock. Nothing secret lives here, so no encryption — same shape as
110
+ * `__confirmMode`/`__memoryPersist` above. */
111
+ const AUTO_DRAIN_NAMESPACE = '__autoDrain';
112
+
113
+ /** Reserved namespace for the auto-mint (seal-on-quiet) opt-in:
114
+ * `{ enabled, publish }` — app-level, not a provider.
115
+ *
116
+ * DEFAULT OFF for the same reason as auto-drain, and `publish` is a second,
117
+ * independent opt-in: sealing writes a local record, whereas publishing sends
118
+ * those bytes to the vault over `rclone` and writes an anchor beside them. An
119
+ * automatic network copy is never implied by an automatic seal.
120
+ *
121
+ * The AI half of the record resolves through the shared rungs in
122
+ * client/attest.js (`$AEGIS_AI_SECRET`/`$AEGIS_AI_MODEL` in the app scope) —
123
+ * this namespace deliberately does NOT hold a credential, because a settings
124
+ * file that can carry a provider key is a second place to leak one. */
125
+ const AUTO_MINT_NAMESPACE = '__autoMint';
126
+
127
+ /** How often an armed auto-drain looks at the queue. One minute is the
128
+ * compromise the two failure modes set: a shorter interval makes the desktop
129
+ * a busy-waiter over a file several hosts write (each tick stats the queue and
130
+ * the lock), and a longer one means a task queued in the card sits visibly
131
+ * idle. The worker does the rest of the waiting itself. */
132
+ const AUTO_DRAIN_INTERVAL_DEFAULT_MS = 60_000;
133
+
134
+ /** Floor for `intervalMs`. Below this the timer is a poll loop, not a drain
135
+ * cadence: nothing about a queue whose tasks take minutes to run changes
136
+ * fifteen times a second, and the tick does file I/O. */
137
+ const AUTO_DRAIN_INTERVAL_MIN_MS = 15_000;
138
+
139
+ /** Ceiling for `intervalMs` — half an hour. A hand-edited settings file asking
140
+ * for a day is a typo, not a preference. */
141
+ const AUTO_DRAIN_INTERVAL_MAX_MS = 30 * 60_000;
142
+
143
+ /** Reserved namespaces: the provider-config surface must never see or mutate
144
+ * any of these. The two automation switches are here for the SAME reason the
145
+ * others are — `set()`/`remove()`/`list()` operate on provider rows, and a
146
+ * preference parked in that namespace would be listed as a provider and
147
+ * deletable with "Remove". */
96
148
  const RESERVED_NAMESPACES = Object.freeze([
97
149
  AEGIS_KEY_NAMESPACE,
98
150
  LEGACY_AEGIS_NAMESPACE,
@@ -101,6 +153,8 @@ const RESERVED_NAMESPACES = Object.freeze([
101
153
  MEMORY_PERSIST_NAMESPACE,
102
154
  AVATAR_NAMESPACE,
103
155
  TELEMETRY_NAMESPACE,
156
+ AUTO_DRAIN_NAMESPACE,
157
+ AUTO_MINT_NAMESPACE,
104
158
  ]);
105
159
 
106
160
  /** Motion modes the renderer understands (`system` = follow the OS). */
@@ -451,6 +505,93 @@ function createSettingsStore({ dir, safeStorage } = {}) {
451
505
  return avatarState();
452
506
  }
453
507
 
508
+ // --- Auto-drain / auto-mint: reserved namespaces, DEFAULT OFF ------------
509
+ // The two opt-in automations (lib/auto.js). Both live in their own top-level
510
+ // namespace, both are booted OFF, and both keep the "off" and "never set"
511
+ // cases distinguishable through a `source` field — the same shape
512
+ // memoryPersistState()/avatarState() use above, for the same reason: a UI
513
+ // that cannot tell "you turned it off" from "this build never had it" shows
514
+ // the wrong sentence after an upgrade.
515
+ //
516
+ // `intervalMs` is clamped rather than trusted: it comes back through IPC from
517
+ // a renderer and off disk from a hand-edited file, and a timer is the one
518
+ // place where a hostile or fat-fingered number costs real CPU.
519
+
520
+ /** A usable tick interval, or the default. Out-of-range reads as unset. */
521
+ function clampAutoDrainInterval(value) {
522
+ const n = Number(value);
523
+ if (!Number.isFinite(n)) return AUTO_DRAIN_INTERVAL_DEFAULT_MS;
524
+ const ms = Math.round(n);
525
+ if (ms < AUTO_DRAIN_INTERVAL_MIN_MS || ms > AUTO_DRAIN_INTERVAL_MAX_MS) {
526
+ return AUTO_DRAIN_INTERVAL_DEFAULT_MS;
527
+ }
528
+ return ms;
529
+ }
530
+
531
+ function getAutoDrain() {
532
+ const cfg = load()[AUTO_DRAIN_NAMESPACE] || {};
533
+ return {
534
+ // Unset === false. This is the opposite of confirm mode/memory persist,
535
+ // and it is the whole point of the feature: nothing drains itself until
536
+ // someone says so in as many words.
537
+ enabled: cfg.enabled === true,
538
+ intervalMs: clampAutoDrainInterval(cfg.intervalMs),
539
+ };
540
+ }
541
+
542
+ function autoDrainState() {
543
+ const cfg = load()[AUTO_DRAIN_NAMESPACE] || {};
544
+ return {
545
+ ...getAutoDrain(),
546
+ source: cfg.enabled === undefined ? 'default' : 'config',
547
+ };
548
+ }
549
+
550
+ function setAutoDrain(patch = {}) {
551
+ const current = getAutoDrain();
552
+ const p = patch && typeof patch === 'object' ? patch : {};
553
+ const data = load();
554
+ data[AUTO_DRAIN_NAMESPACE] = {
555
+ enabled: p.enabled === undefined ? current.enabled : Boolean(p.enabled),
556
+ intervalMs:
557
+ p.intervalMs === undefined
558
+ ? current.intervalMs
559
+ : clampAutoDrainInterval(p.intervalMs),
560
+ };
561
+ save(data);
562
+ return autoDrainState();
563
+ }
564
+
565
+ function getAutoMint() {
566
+ const cfg = load()[AUTO_MINT_NAMESPACE] || {};
567
+ return {
568
+ enabled: cfg.enabled === true,
569
+ // Independent of `enabled` on purpose: an automatic seal must not drag
570
+ // an automatic upload along with it.
571
+ publish: cfg.publish === true,
572
+ };
573
+ }
574
+
575
+ function autoMintState() {
576
+ const cfg = load()[AUTO_MINT_NAMESPACE] || {};
577
+ return {
578
+ ...getAutoMint(),
579
+ source: cfg.enabled === undefined ? 'default' : 'config',
580
+ };
581
+ }
582
+
583
+ function setAutoMint(patch = {}) {
584
+ const current = getAutoMint();
585
+ const p = patch && typeof patch === 'object' ? patch : {};
586
+ const data = load();
587
+ data[AUTO_MINT_NAMESPACE] = {
588
+ enabled: p.enabled === undefined ? current.enabled : Boolean(p.enabled),
589
+ publish: p.publish === undefined ? current.publish : Boolean(p.publish),
590
+ };
591
+ save(data);
592
+ return autoMintState();
593
+ }
594
+
454
595
  return {
455
596
  file,
456
597
  get,
@@ -476,6 +617,13 @@ function createSettingsStore({ dir, safeStorage } = {}) {
476
617
  getAvatar,
477
618
  avatarState,
478
619
  setAvatar,
620
+ getAutoDrain,
621
+ autoDrainState,
622
+ setAutoDrain,
623
+ getAutoMint,
624
+ autoMintState,
625
+ setAutoMint,
626
+ clampAutoDrainInterval,
479
627
  };
480
628
  }
481
629
 
@@ -488,6 +636,11 @@ module.exports = {
488
636
  MEMORY_PERSIST_NAMESPACE,
489
637
  AVATAR_NAMESPACE,
490
638
  TELEMETRY_NAMESPACE,
639
+ AUTO_DRAIN_NAMESPACE,
640
+ AUTO_MINT_NAMESPACE,
641
+ AUTO_DRAIN_INTERVAL_DEFAULT_MS,
642
+ AUTO_DRAIN_INTERVAL_MIN_MS,
643
+ AUTO_DRAIN_INTERVAL_MAX_MS,
491
644
  AVATAR_MOTION_MODES,
492
645
  DEFAULT_QUICK_LAUNCHER_SHORTCUT,
493
646
  RESERVED_NAMESPACES,