aegis-desktop 0.8.29 → 0.8.32

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.
@@ -97,48 +97,100 @@ function writtenPath(tool) {
97
97
  const DEFAULT_MODEL = 'nexus-brain';
98
98
 
99
99
  /**
100
- * THE QUEUE RUNS ON AEGIS CLOUD, AND NOTHING ELSE.
100
+ * THE QUEUE RUNS ON WHATEVER CLASS THE TASK NAMES — aegis, byok, or local —
101
+ * the same three classes engine.js's interactive turns serve (engine.js's
102
+ * exported `CLASSES`). This module does not `require('./engine.js')` to read
103
+ * that list itself (same reason queue.js stays engine-free): duplicating the
104
+ * three names here keeps both modules testable with no Electron, no network,
105
+ * and no model.
101
106
  *
102
- * A queued task is billed to the AEGIS pool and every turn it makes goes out
103
- * with `class: 'aegis'` (see runTask below) — the pool is the only backend this
104
- * worker can reach. So a model id here has to be one the *pool* serves. A
105
- * direct-provider id is not a cheaper option the queue could fall back to; it
106
- * is a request the pool cannot honour, or worse, a per-provider spelling
107
- * (`anthropic`, `groq`, …) that quietly pins one upstream instead of letting
108
- * the pool auto-route across whichever providers hold a live key.
107
+ * This used to be Aegis Cloud only, because runTask sent every turn out with
108
+ * a hardcoded `class: 'aegis'` — a byok or local model id would have gone out
109
+ * under the wrong transport and failed at the server, or worse, silently
110
+ * picked up the wrong provider's key. Now `class` travels with the task
111
+ * (`item.class`, defaulted to 'aegis' for every task that predates this field
112
+ * — the CLI, a hand-written queue file, the old desktop card), and runTask
113
+ * forwards it verbatim instead of overwriting it.
109
114
  *
110
- * The accept-list is therefore the pooled-brain tier family — the one entry
111
- * engine.js's offerableCatalog offers for the Aegis Cloud class, plus the
112
- * `-smart`/`-neo` tier spellings the server still serves as aliases of it. This
113
- * mirrors selectBrainEntry() there rather than re-deriving "anything starting
114
- * with nexus-": `nexus-fast` is not a tier the catalog has ever served, and
115
- * accepting a made-up id means a queued task that fails at the server after
116
- * being picked up, or runs on a tier nobody chose.
115
+ * Each class validates differently, because each has a different notion of
116
+ * "no model stated is fine":
117
+ * - 'aegis' has a real default (the pooled brain), so a blank model is
118
+ * accepted and resolveModel fills it in. The accept-list is the
119
+ * pooled-brain tier family — the one entry engine.js's offerableCatalog
120
+ * offers for the Aegis Cloud class, plus the `-smart`/`-neo` tier
121
+ * spellings the server still serves as aliases of it (mirrors
122
+ * selectBrainEntry() there rather than re-deriving "anything starting
123
+ * with nexus-": `nexus-fast` is not a tier the catalog has ever served).
124
+ * - 'byok' and 'local' have no such default: a blank byok model cannot be
125
+ * "whichever provider the pool picks" (there is no pool), and a blank
126
+ * local model cannot be "whichever model is pulled" when the answer
127
+ * depends on what the user's own daemon happens to have. Both REQUIRE an
128
+ * explicit model, refused by name instead of silently resolving to
129
+ * something nobody chose.
117
130
  */
131
+ const MODEL_CLASSES = Object.freeze(['aegis', 'byok', 'local']);
132
+
118
133
  const AEGIS_MODEL_RE = /^(?:nexus|aegis)-brain(?:-(?:smart|neo))?$/;
119
134
  const AEGIS_MODEL_IDS = Object.freeze(['nexus-brain', 'aegis-brain']);
120
135
 
121
- /** True when `id` names an AEGIS Cloud pooled-brain tier (the queue's only models). */
136
+ /** True when `id` names an AEGIS Cloud pooled-brain tier. */
122
137
  function isAegisModel(id) {
123
138
  return AEGIS_MODEL_RE.test(String(id == null ? '' : id).trim());
124
139
  }
125
140
 
126
141
  /**
127
- * Why a STATED model id cannot be queued, or '' when it can. Blank is not a
128
- * refusal — "no pick" is the default model, which resolveModel supplies.
142
+ * Normalize a task's stated class to one this module (and engine.js) knows.
143
+ * Blank or absent — every task queued before this field existed — defaults to
144
+ * 'aegis', the only class the queue ever ran before today.
145
+ */
146
+ function normalizeClass(cls) {
147
+ const stated = String(cls == null ? '' : cls).trim().toLowerCase();
148
+ return stated || 'aegis';
149
+ }
150
+
151
+ /**
152
+ * Whether `id` is shaped "<provider>:<model>" — the only form engine.js's
153
+ * byok class accepts (splitByokModel). Not re-deriving the live provider list
154
+ * here (that is a catalog call, `listModels('byok')`, this module has no
155
+ * business caching) — just enough of a shape check that a malformed id is
156
+ * refused at the door instead of failing splitByokModel at the server with no
157
+ * context.
158
+ */
159
+ function looksLikeByokModel(id) {
160
+ const s = String(id == null ? '' : id).trim();
161
+ const i = s.indexOf(':');
162
+ return i > 0 && i < s.length - 1;
163
+ }
164
+
165
+ /**
166
+ * Why a STATED (model, class) pair cannot be queued, or '' when it can.
129
167
  *
130
- * The message names the pool, because the failure it prevents ("queued on
131
- * claude-sonnet-4, ran on — or was billed to — something else") is invisible
132
- * otherwise: `class: 'aegis'` would be sent with an id the server does not
133
- * serve, and the task would come back as an opaque error minutes later.
168
+ * `cls` defaults to 'aegis' when omitted, so every existing caller of
169
+ * `modelRefusal(id)` keeps its old behaviour verbatim.
134
170
  */
135
- function modelRefusal(id) {
171
+ function modelRefusal(id, cls) {
136
172
  const stated = String(id == null ? '' : id).trim();
137
- if (!stated || isAegisModel(stated)) return '';
138
- return (
139
- `the autonomous queue runs Aegis Cloud models only (${DEFAULT_MODEL}, ` +
140
- `${AEGIS_MODEL_IDS.join('/')} aliases); "${stated}" is not one`
141
- );
173
+ const normalized = normalizeClass(cls);
174
+ if (!MODEL_CLASSES.includes(normalized)) {
175
+ return `"${cls}" is not a model class the queue serves (${MODEL_CLASSES.join('/')})`;
176
+ }
177
+ if (normalized === 'aegis') {
178
+ if (!stated || isAegisModel(stated)) return '';
179
+ return (
180
+ `the autonomous queue's Aegis Cloud class runs pooled-brain models only (${DEFAULT_MODEL}, ` +
181
+ `${AEGIS_MODEL_IDS.join('/')} aliases); "${stated}" is not one`
182
+ );
183
+ }
184
+ if (normalized === 'byok') {
185
+ if (!stated) return 'a byok task needs a model, as "<provider>:<model>" (pick one from /models)';
186
+ if (!looksLikeByokModel(stated)) {
187
+ return `"${stated}" is not a byok model id — it must be "<provider>:<model>" (pick one from /models)`;
188
+ }
189
+ return '';
190
+ }
191
+ // normalized === 'local'
192
+ if (!stated) return 'a local task needs a model — the tag of whatever is pulled into the local daemon';
193
+ return '';
142
194
  }
143
195
 
144
196
  /** Phrase match for "work autonomously" in a prompt or a queued task. */
@@ -239,10 +291,14 @@ function withRoundHorizon(rounds, env, fn) {
239
291
  * task over it would break drains for a reason that is not the task's
240
292
  * fault; a fallback to the pooled brain keeps the drain honest and on-cloud.
241
293
  */
242
- function resolveModel({ model, env } = {}) {
243
- const e = env || process.env;
294
+ function resolveModel({ model, env, cls } = {}) {
244
295
  const picked = String(model || '').trim();
296
+ // byok/local have no cloud default to fall back to — modelRefusal already
297
+ // guaranteed a non-empty, shaped model at queue time, so the task's own pick
298
+ // is the only answer for either class.
299
+ if (normalizeClass(cls) !== 'aegis') return picked;
245
300
  if (picked) return picked;
301
+ const e = env || process.env;
246
302
  const fromEnv = String(e.AEGIS_AUTONOMOUS_MODEL || e.AEGIS_MODEL || '').trim();
247
303
  return isAegisModel(fromEnv) ? fromEnv : DEFAULT_MODEL;
248
304
  }
@@ -255,7 +311,8 @@ function resolveModel({ model, env } = {}) {
255
311
  * it: the queue falls back to the pool, and the one place that says so is this
256
312
  * string, which runTask emits as a note and the desktop card shows.
257
313
  */
258
- function ignoredEnvModel({ model, env } = {}) {
314
+ function ignoredEnvModel({ model, env, cls } = {}) {
315
+ if (normalizeClass(cls) !== 'aegis') return ''; // AEGIS_AUTONOMOUS_MODEL is an Aegis-only pin
259
316
  if (String(model || '').trim()) return ''; // the task's own pick is what counts
260
317
  const e = env || process.env;
261
318
  const fromEnv = String(e.AEGIS_AUTONOMOUS_MODEL || e.AEGIS_MODEL || '').trim();
@@ -462,16 +519,23 @@ function createQueueWorker({
462
519
  // pool does not serve: billed work if it happens to be a per-provider
463
520
  // spelling, an opaque server error otherwise. Failing here names the model
464
521
  // and the allowed ones, and costs nothing.
465
- const refusal = modelRefusal(item.model);
522
+ const cls = normalizeClass(item.class);
523
+ const refusal = modelRefusal(item.model, cls);
466
524
  if (refusal) {
467
- const failed = { ok: false, error: refusal, model: item.model, ms: 0 };
525
+ const failed = { ok: false, error: refusal, model: item.model, class: cls, ms: 0 };
468
526
  emit({ type: 'finish', taskId: item.id, ok: false, result: failed });
469
527
  return failed;
470
528
  }
471
- const model = resolveModel({ model: item.model, env });
472
- const ignored = ignoredEnvModel({ model: item.model, env });
529
+ const model = resolveModel({ model: item.model, env, cls });
530
+ const ignored = ignoredEnvModel({ model: item.model, env, cls });
473
531
  if (ignored) emit({ type: 'note', taskId: item.id, note: ignored });
474
- const fanoutAsked = resolveFanout(item, env);
532
+ // The pooled-brain fan-out is an Aegis Cloud mechanism (aegis1
533
+ // services/pool_brain.py) — engine.js itself only honours `autonomous`
534
+ // under `cls === 'aegis'` (engine.js:1524), so a byok/local task asking for
535
+ // it is forced to a plain single pass HERE rather than relying on that
536
+ // silent no-op, which would otherwise size the task's effort/round
537
+ // reporting as if a fan-out it never got had been granted.
538
+ const fanoutAsked = cls === 'aegis' && resolveFanout(item, env);
475
539
  // The clamp. `canFanOut()` is the entitlement answer (true everywhere
476
540
  // except a non-Pro desktop session), and it can only ever take a fan-out
477
541
  // AWAY: a task the session is not entitled to fan out runs as one plain
@@ -526,14 +590,14 @@ function createQueueWorker({
526
590
  const wantCommit = commit === undefined ? Boolean(item.commit) : Boolean(commit);
527
591
  const before = wantCommit ? safe(() => git.gitStatusSnapshot(cwd), null) : null;
528
592
 
529
- emit({ type: 'start', taskId: item.id, model, cwd, rounds, fanout, effort });
593
+ emit({ type: 'start', taskId: item.id, model, class: cls, cwd, rounds, fanout, effort });
530
594
  const started = now();
531
595
  let result;
532
596
  try {
533
597
  const res = await withRoundHorizon(rounds, env, () =>
534
598
  engine.chat(
535
599
  {
536
- class: 'aegis',
600
+ class: cls,
537
601
  model,
538
602
  prompt: taskPrompt(item, { carry, rounds }),
539
603
  // The pooled-brain fan-out ("fan out this turn" in the GUI chat
@@ -831,8 +895,11 @@ module.exports = {
831
895
  DEFAULT_MODEL,
832
896
  DEFAULT_ROUNDS,
833
897
  MAX_ROUND_STOPS,
898
+ MODEL_CLASSES,
834
899
  AEGIS_MODEL_IDS,
835
900
  isAegisModel,
901
+ normalizeClass,
902
+ looksLikeByokModel,
836
903
  modelRefusal,
837
904
  WRITE_TOOLS,
838
905
  writtenPath,
@@ -968,6 +968,19 @@ function createLocalEngine({
968
968
  // reverse its verdict. An approval that could waive Λ2 would be the exact
969
969
  // capture K3 describes. To change what is permitted, the covenant text
970
970
  // itself has to be re-sealed — which no runtime input can do.
971
+ //
972
+ // DO NOT MOVE THIS BELOW A SHORT-CIRCUIT. It sits above every `return` of
973
+ // the confirmation gate on purpose (ON8/D8 rule 2): `gatedExecuteTool`
974
+ // short-circuits on `!confirmModeEnabled()`, on a remembered session
975
+ // "allow", and on a `/permissions` skip — and since ON8 the confirm-mode
976
+ // preference is the SHARED store's, i.e. the same value the terminal face
977
+ // reads, and `--dangerously-skip-permissions` can arrive in that store as
978
+ // well. A refactor that hoists this below any of those three would make
979
+ // the covenant waivable by a setting a user can write, which is the one
980
+ // thing D8 rule 2 forbids outright. The frozen classes
981
+ // (`write`/`shell`/`network`/`offDevice`, always `'ask'`; see
982
+ // lib/avatar/level.js `LEVEL_INDEPENDENT_APPROVALS`) have the same floor
983
+ // and are asserted beside this one by one/test/permissions.test.mjs.
971
984
  const cv = covenant.preActionAssert({ tool: name, args: args || {} });
972
985
  if (!cv.allowed) {
973
986
  // Non-silent (never a quiet no-op) and bound for Λ4 accountability.
@@ -28,17 +28,37 @@
28
28
  * THE SIGNAL (see the report / the module's `probe`): main injects a probe that
29
29
  * calls `aegis.verifyApiKey()` — aegis1 `/api/verify-api-key`, which its own
30
30
  * docstring describes as "Verify a shared api_key and return plan/email/
31
- * memory_token". The plan field is the account's stored plan, and aegis1 owns
32
- * the vocabulary for it (`services/billing_service.py`: `PAID_PLANS = ("pro",)`
33
- * plus the legacy aliases that normalize onto it). This module MIRRORS that
34
- * vocabulary rather than inventing one; there is no new billing anything here,
35
- * no new endpoint, and no write to the server.
31
+ * memory_token". The plan field is the account's plan, and aegis1 owns the
32
+ * vocabulary for it (`services/billing_service.py`: `TIERS = ("free", "pro",
33
+ * "unlimited")`, `PAID_PLANS = ("pro", "unlimited")`, plus the legacy aliases
34
+ * that normalize onto one of them). This module MIRRORS that vocabulary rather
35
+ * than inventing one; there is no new billing anything here, no new endpoint,
36
+ * and no write to the server. The probe prefers the server's `plan_effective`
37
+ * (its own `is_pro()` verdict, trial included) over the stored `plan` string
38
+ * when it is present, because a welcome-trial account is a real Unlimited
39
+ * entitlement that `users.plan` does not record — see `classifyProbe`.
40
+ *
41
+ * THE BUG THIS VOCABULARY COST (fixed here). `PAID_PLANS` read `("pro",)`,
42
+ * mirroring the retired €2 line and MISSING `unlimited` — the €9/mo tier that
43
+ * is the only one sold. So `normalizePlan('unlimited')` fell through to `free`,
44
+ * and a paying Unlimited subscriber with a spent-out bank was handed the
45
+ * free-tier boundary: the fan-out refused, the cloud drain clamped to one local
46
+ * worker, and a "top up your balance" prompt — for an account whose plan is
47
+ * precisely what pays for the drain. aegis1's own `LEGACY_PLAN_ALIASES` carries
48
+ * the matching warning from the other side ("Deliberately NOT an alias:
49
+ * `unlimited` is a live tier you can subscribe to, so it must resolve through
50
+ * TIERS"), and its `test_tier_vocabulary` pins `PAID_PLANS` at two entries.
51
+ * Two implementations of "is this account paid" had already drifted once; this
52
+ * was the drift.
36
53
  *
37
54
  * PHASE 6 — THE SECOND SIGNAL, THE BALANCE. The plan is only half of how this
38
55
  * product charges. aegis1's actual paywall is the Token Bank running dry: the
39
56
  * balance is prepaid EUR the OPERATOR credits by hand, and `app.py` states the
40
57
  * consequence plainly — "Pool-key users with token bank balance skip legacy
41
- * credit checks." So entitlement here is `pro || balance > 0`, and when the
58
+ * credit checks." So entitlement here is `paid_plan || balance > 0` — and the
59
+ * `paid_plan` half is what carries an Unlimited subscriber whose bank is empty;
60
+ * the bank is an OVERAGE lever, never the thing that decides whether a paying
61
+ * account may use the feature it pays for. When the
42
62
  * bank reads zero the wall names the fix that exists (a top-up, campaign
43
63
  * `topup_bank`) instead of a plan checkout the user may already own. Nothing in
44
64
  * this module grants a balance, expires one, or auto-charges: the operator
@@ -64,8 +84,33 @@
64
84
  /** The free-tier fan-out ceiling: one worker per task, i.e. a plain turn. */
65
85
  const FREE_MAX_WORKERS = 1;
66
86
 
67
- /** Aegis1's paid tier. MIRROR of services/billing_service.py `PAID_PLANS`. */
68
- const PAID_PLANS = Object.freeze(['pro']);
87
+ /**
88
+ * Aegis1's paid tiers. MIRROR of services/billing_service.py
89
+ * `PAID_PLANS = ("pro", "unlimited")` — pinned literal on purpose, because this
90
+ * is the line that has to move whenever the server sells another tier.
91
+ *
92
+ * `unlimited` (€9/mo) is the tier ON SALE and the one a paying customer almost
93
+ * certainly owns; `pro` is the grandfathered €2 line, kept because its
94
+ * subscribers keep their entitlements. Reading only `pro` was not a narrower
95
+ * version of this gate, it was a gate pointing at the wrong tier: every
96
+ * Unlimited account fell to the free boundary. `starter` stays out — aegis1
97
+ * itself calls that one free.
98
+ */
99
+ const PAID_PLANS = Object.freeze(['pro', 'unlimited']);
100
+
101
+ /**
102
+ * What each paid tier is CALLED, so a surface that names the account's plan
103
+ * names the one the server granted. The CLI has read the tier off the server's
104
+ * plan string since 6443a60 (`cli/src/entitlement.js::paymentView`) after it
105
+ * told a €9 subscriber they owned the retired €2 plan; this is the same value,
106
+ * resolved once in the shared module both hosts read.
107
+ */
108
+ const TIER_NAMES = Object.freeze({ pro: 'Pro', unlimited: 'Unlimited' });
109
+
110
+ /** The display name for a plan string, or null when it is not a paid tier. */
111
+ function tierName(plan) {
112
+ return TIER_NAMES[normalizePlan(plan)] || null;
113
+ }
69
114
 
70
115
  /**
71
116
  * MIRROR of services/billing_service.py `LEGACY_PLAN_ALIASES`: every stored
@@ -251,11 +296,20 @@ function promptUrlFor(host) {
251
296
  * card on the next refresh and long enough that a drain never probes per task. */
252
297
  const DEFAULT_TTL_MS = 60 * 1000;
253
298
 
254
- /** Collapse any stored/alias plan string onto a tier, exactly as aegis1 does. */
299
+ /** Collapse any stored/alias plan string onto a tier, exactly as aegis1 does.
300
+ *
301
+ * Aegis1 checks its live `TIERS` BEFORE the alias table (`normalize_plan`), so
302
+ * an alias can never shadow a tier it also names — and `unlimited` is a live
303
+ * tier. It is therefore returned as itself and never collapsed onto `pro`: the
304
+ * two grant different entitlements server-side (the monthly Unlimited
305
+ * allowance and the BYOK fee waiver are `is_unlimited()`-gated), and telling a
306
+ * €9 customer they own the retired €2 plan is the mistake commit 6443a60
307
+ * exists to record.
308
+ */
255
309
  function normalizePlan(plan) {
256
310
  const raw = String(plan == null ? '' : plan).trim().toLowerCase();
257
311
  if (!raw) return 'free';
258
- if (PAID_PLANS.includes(raw)) return 'pro';
312
+ if (PAID_PLANS.includes(raw)) return raw;
259
313
  const aliased = LEGACY_PLAN_ALIASES[raw];
260
314
  return aliased || 'free';
261
315
  }
@@ -265,9 +319,9 @@ function normalizePlan(plan) {
265
319
  * drift into two different instructions. */
266
320
  const ASK_EXAMPLE = 'Do X autonomously';
267
321
 
268
- /** True only for a tier aegis1 itself would call paid. */
322
+ /** True only for a tier aegis1 itself would call paid (`pro` or `unlimited`). */
269
323
  function isPaidPlan(plan) {
270
- return normalizePlan(plan) === 'pro';
324
+ return normalizePlan(plan) !== 'free';
271
325
  }
272
326
 
273
327
  /**
@@ -367,7 +421,19 @@ function classifyProbe(result, error) {
367
421
  const body = result && typeof result === 'object' ? result : {};
368
422
  const bal = balanceOf(body);
369
423
  if (body.valid === false) return { source: 'no-key', plan: 'free', degraded: false, ...bal };
370
- const stated = String(body.plan == null ? '' : body.plan).trim();
424
+ // WHICH plan field. `plan_effective` is aegis1's own entitlement verdict —
425
+ // the same predicate its `is_pro()` gates pooled calls on, so it counts the
426
+ // 30-day welcome trial that never writes `users.plan` (see app.py's
427
+ // register_device: the trial lives in `subscriptions`). The stored `plan`
428
+ // column cannot express that account, so an installing user read as `free`
429
+ // here and only the €12 signup credit kept the gate open — spend it, or fail
430
+ // to receive it, and a real Unlimited account was refused the feature. The
431
+ // stored string is still the fallback: an older server that predates the
432
+ // field must keep working, and it is the same value when nothing is on trial.
433
+ const rawPlan = body.plan_effective != null && String(body.plan_effective).trim()
434
+ ? body.plan_effective
435
+ : body.plan;
436
+ const stated = String(rawPlan == null ? '' : rawPlan).trim();
371
437
  if (!stated) {
372
438
  // A key that verified but carried no plan: aegis1's own default for such an
373
439
  // account is `free` (the column default), and a missing field is not proof
@@ -411,6 +477,8 @@ function createEntitlement({
411
477
  balanceZero: false,
412
478
  wall: 'plan',
413
479
  plan: 'free',
480
+ tier: null,
481
+ tierName: null,
414
482
  planRaw: null,
415
483
  maxWorkers: FREE_MAX_WORKERS,
416
484
  source: 'unresolved',
@@ -424,12 +492,20 @@ function createEntitlement({
424
492
  let inflight = null;
425
493
 
426
494
  function fromProbe({ source, plan, degraded, planRaw, balanceKnown = false, balanceMicros = null }) {
427
- const pro = isPaidPlan(plan);
495
+ // The tier itself, not a paid/not-paid bit: `unlimited` and `pro` are both
496
+ // paid and must stay distinguishable, because the surfaces that name the
497
+ // account's plan (the desktop's queue boundary, the CLI's `/billing`) name
498
+ // the one the server granted. `pro` below keeps its original meaning — "the
499
+ // PLAN is paid for", as opposed to a free plan carried by a funded bank.
500
+ const tier = normalizePlan(plan);
501
+ const pro = tier !== 'free';
428
502
  const banked = balanceKnown && Number.isFinite(balanceMicros) && balanceMicros > 0;
429
503
  // TWO independent ways to be paid for: a paid plan, or a funded bank. The
430
504
  // operator controls the second by hand (aegis1 admin `add_funds`), which is
431
505
  // the lever this wall is built around — nothing here grants, times out or
432
- // auto-charges; it only reads the number the operator set.
506
+ // auto-charges; it only reads the number the operator set. The FIRST way is
507
+ // the one a paying subscriber holds, and it does not weaken when their bank
508
+ // runs dry: the balance is overage, the subscription is the entitlement.
433
509
  const entitled = pro || banked;
434
510
  // The bank was READABLE and EMPTY — the operator's own signal that this
435
511
  // account is now paying, not the absence of a plan.
@@ -447,7 +523,12 @@ function createEntitlement({
447
523
  balanceMicros: balanceKnown ? balanceMicros : null,
448
524
  balanceZero,
449
525
  wall,
450
- plan: pro ? 'pro' : 'free',
526
+ // The tier a paid PLAN grants, else `free`. A funded bank on a free plan
527
+ // is still `free` here — the bank is not a tier, and naming one would put
528
+ // a plan on the account the server never granted.
529
+ plan: pro ? tier : 'free',
530
+ tier: pro ? tier : null,
531
+ tierName: pro ? (TIER_NAMES[tier] || null) : null,
451
532
  planRaw: planRaw == null ? null : String(planRaw),
452
533
  // A paid session has no ceiling of its own here: main.js still clamps the
453
534
  // renderer's number to QUEUE_MAX_WORKERS, which stays the only upper bound.
@@ -543,6 +624,8 @@ module.exports = {
543
624
  createEntitlement,
544
625
  normalizePlan,
545
626
  isPaidPlan,
627
+ tierName,
628
+ TIER_NAMES,
546
629
  classifyProbe,
547
630
  fanoutRefusal,
548
631
  balanceRefusal,
@@ -176,34 +176,38 @@ function upsert(items, entry) {
176
176
 
177
177
  /**
178
178
  * Queue a task. `cwd` is where the work happens (the worker runs the turn
179
- * with it as the tool loop's working directory), `model` is the AEGIS Cloud
180
- * model id to run it on, and `commit` asks the worker to commit what the task
181
- * changed (scoped — see autonomous.js).
179
+ * with it as the tool loop's working directory), `class` + `model` are the
180
+ * same pair engine.js's interactive turns use ('aegis'/'byok'/'local' and the
181
+ * model id that class expects), and `commit` asks the worker to commit what
182
+ * the task changed (scoped — see autonomous.js).
182
183
  *
183
- * AEGIS CLOUD ONLY, REFUSED AT THE DOOR. The worker sends every task out as
184
- * `class: 'aegis'`, so a model the pool does not serve cannot run here at all —
185
- * it is a task that fails minutes into a drain, after being picked up, with an
186
- * opaque server error. `model: null` stays legal: that is "no pick", and
187
- * autonomous.resolveModel fills in the pooled brain. Anything else has to pass
188
- * that module's own accept-list, so the CLI (`aegiscode autonomous add
189
- * --model`), the desktop card and a hand-written queue file are all held to the
190
- * same rule. Required lazily: queue.js is a dependency of autonomous.js, and
191
- * this check must not turn that into a load-order cycle.
184
+ * REFUSED AT THE DOOR, not three stages into a drain. `class` defaults to
185
+ * 'aegis' (every task queued before this field existed), and the (model,
186
+ * class) pair has to pass autonomous.js's own accept-list — the CLI
187
+ * (`aegiscode autonomous add --model`), the desktop card and a hand-written
188
+ * queue file are all held to the same rule, so a bad row fails here with a
189
+ * reason instead of coming back as an opaque server error after being picked
190
+ * up. `model: null` stays legal for the 'aegis' class only: that is "no
191
+ * pick", and autonomous.resolveModel fills in the pooled brain. 'byok' and
192
+ * 'local' have no such default and are refused if blank — see
193
+ * autonomous.modelRefusal. Required lazily: queue.js is a dependency of
194
+ * autonomous.js, and this check must not turn that into a load-order cycle.
192
195
  */
193
196
  function addTask(env, opts = {}) {
194
197
  const task = String(opts.task == null ? '' : opts.task).trim();
195
198
  if (!task) throw new Error('queue: a task needs text');
199
+ const autonomousMod = require('./autonomous.js');
200
+ const cls = autonomousMod.normalizeClass(opts.class);
196
201
  const statedModel = String(opts.model == null ? '' : opts.model).trim();
197
- if (statedModel) {
198
- const refusal = require('./autonomous.js').modelRefusal(statedModel);
199
- if (refusal) throw new Error(`queue: ${refusal}`);
200
- }
202
+ const refusal = autonomousMod.modelRefusal(statedModel, cls);
203
+ if (refusal) throw new Error(`queue: ${refusal}`);
201
204
  const items = loadQueue(env);
202
205
  const now = opts.now || Date.now();
203
206
  const entry = {
204
207
  id: nextId(items),
205
208
  task,
206
209
  cwd: opts.cwd || process.cwd(),
210
+ class: cls,
207
211
  model: statedModel || null,
208
212
  effort: opts.effort || null,
209
213
  workers: Number.isInteger(opts.workers) ? opts.workers : null,
package/lib/settings.js CHANGED
@@ -270,6 +270,15 @@ function createSettingsStore({ dir, safeStorage, prefsDir, env } = {}) {
270
270
  try {
271
271
  return prefs.adopt({ dir: sharedDir, legacyDir: dir });
272
272
  } catch (err) {
273
+ // Best-effort, but NOT silent (AEGISONE ON8). Swallowing this throw is how
274
+ // `prefs.adopt()`'s refusal to write its own ledger went unnoticed — the
275
+ // migration failed for every install that had something to adopt, and the
276
+ // store reported a healthy `adopted: false`. A failed migration is a
277
+ // preference a user set that this launch did not honor, so it is reported
278
+ // where a developer will see it; a store must still open either way.
279
+ if (process.env.AEGISCODE_DEBUG || process.env.AEGIS_HIST_DEBUG) {
280
+ process.stderr.write(`aegis-desktop: shared-preference adoption failed: ${err.message}\n`);
281
+ }
273
282
  return { adopted: false, keys: [], reason: `adoption failed: ${err.message}` };
274
283
  }
275
284
  })();
@@ -525,26 +534,50 @@ function createSettingsStore({ dir, safeStorage, prefsDir, env } = {}) {
525
534
  return quickLauncherConfig();
526
535
  }
527
536
 
528
- // --- Confirm mode: reserved namespace, plain (unencrypted) preference ----
529
- // "Confirm before running tools" — the ON/OFF switch for the renderer's
530
- // tool-call approval gate (desktop/lib/local/engine.js gatedExecuteTool).
531
- // ON (default, and the behaviour shipped so far) means every mutating tool
532
- // call — exec / writeFile / editFile — is previewed and approved by the user
533
- // first. OFF means the engine runs them straight through, like a tool the
534
- // session already allowed. Nothing secret is stored here, so no encryption.
537
+ // --- Confirm mode: SHARED preference (AEGISONE ON8, decision D8 rule 1) --
538
+ // "Confirm before running tools" — the switch for the renderer's tool-call
539
+ // approval gate (desktop/lib/local/engine.js gatedExecuteTool). ON (default,
540
+ // and the behaviour shipped so far) means every mutating tool call — exec /
541
+ // writeFile / editFile — is previewed and approved by the user first. OFF
542
+ // means the engine runs them straight through, like a tool the session
543
+ // already allowed. Nothing secret is stored here, so no encryption.
544
+ //
545
+ // This preference MOVED to the shared store (`confirmMode`) because D8 rule 1
546
+ // is "one posture, two faces": a rule set in the window has to be the rule the
547
+ // shell honors. The pre-ON8 `__confirmMode` namespace in `<userData>` is
548
+ // adopted once (client/prefs.js `extractNonSecretHalf`) and still read as a
549
+ // fallback, never written — a second writer is the split-brain this replaced.
550
+ // The gate's polarity is unchanged: ON when unset, in both places, so an
551
+ // install that never touched the switch keeps the gate exactly as it was.
552
+
553
+ function confirmModeState() {
554
+ const shared = loadShared();
555
+ if (typeof shared.confirmMode === 'boolean') {
556
+ return { enabled: shared.confirmMode, source: 'config' };
557
+ }
558
+ const cfg = load()[CONFIRM_MODE_NAMESPACE] || {};
559
+ if (cfg.enabled === undefined) return { enabled: true, source: 'default' };
560
+ return { enabled: Boolean(cfg.enabled), source: 'config' };
561
+ }
535
562
 
536
563
  function getConfirmMode() {
537
- const cfg = load()[CONFIRM_MODE_NAMESPACE] || {};
538
- // Unset === true: an existing install that never touched the toggle keeps
539
- // the gate exactly as it was.
540
- return cfg.enabled === undefined ? true : Boolean(cfg.enabled);
564
+ return confirmModeState().enabled;
541
565
  }
542
566
 
543
567
  function setConfirmMode(enabled) {
568
+ prefs.set({ confirmMode: Boolean(enabled) }, { dir: sharedDir });
569
+ // One-way: retire the legacy copy in the same breath, so the posture exists
570
+ // in exactly one place from here on (the same retirement the memory-persist
571
+ // preference below does).
544
572
  const data = load();
545
- data[CONFIRM_MODE_NAMESPACE] = { enabled: Boolean(enabled) };
546
- save(data);
547
- return getConfirmMode();
573
+ if (data[CONFIRM_MODE_NAMESPACE] !== undefined) {
574
+ delete data[CONFIRM_MODE_NAMESPACE];
575
+ save(data);
576
+ }
577
+ // The boolean, not the state record: main.js's IPC reply is `{ enabled }`
578
+ // and it wraps the return value itself, so widening this would have put an
579
+ // object inside an object on the wire.
580
+ return confirmModeState().enabled;
548
581
  }
549
582
 
550
583
  // --- Telemetry: reserved namespace, plain preference, DEFAULT OFF --------
@@ -771,6 +804,7 @@ function createSettingsStore({ dir, safeStorage, prefsDir, env } = {}) {
771
804
  setQuickLauncherConfig,
772
805
  getConfirmMode,
773
806
  setConfirmMode,
807
+ confirmModeState,
774
808
  getTelemetryOptIn,
775
809
  setTelemetryOptIn,
776
810
  getMemoryPersist,
package/main.js CHANGED
@@ -1972,8 +1972,10 @@ function createQueueDispatch({
1972
1972
  draining,
1973
1973
  stopping: stopRequested,
1974
1974
  defaultCwd: process.cwd(),
1975
- // What a task with no model of its own actually runs on. The queue is
1976
- // Aegis Cloud only, so this is a pooled tier id — and the card prints it,
1975
+ // What a task with no model of its own, on the Aegis Cloud class, actually
1976
+ // runs on — a pooled tier id. byok and local tasks always carry an
1977
+ // explicit model (see autonomous.js modelRefusal), so there is no
1978
+ // equivalent default to report for those classes. The card prints this
1977
1979
  // because "which model is this drain spending on" was previously
1978
1980
  // unanswerable from the UI.
1979
1981
  defaultModel: autonomous.resolveModel({ env }),
@@ -2008,19 +2010,20 @@ function createQueueDispatch({
2008
2010
  if (!path.isAbsolute(cwd)) return { error: 'the working directory must be an absolute path' };
2009
2011
  if (!isExistingDir(cwd)) return { error: `not a directory: ${cwd}` };
2010
2012
 
2013
+ const cls = autonomous.normalizeClass(p.class);
2011
2014
  const model = String(p.model == null ? '' : p.model).trim().slice(0, 120) || null;
2012
- // Aegis Cloud only (autonomous.js modelRefusal): the worker's requests go
2013
- // out as `class: 'aegis'`, so a model the pool does not serve is a task that
2014
- // fails inside a drain. Refused HERE so the card gets the reason at the
2015
- // moment of queueing instead of a red row later. queue.addTask enforces the
2016
- // same rule for the CLI and for hand-written queue files.
2017
- const modelRefusal = model ? autonomous.modelRefusal(model) : '';
2015
+ // Refused HERE so the card gets the reason at the moment of queueing
2016
+ // instead of a red row later. queue.addTask enforces the same (model,
2017
+ // class) rule for the CLI and for hand-written queue files — see
2018
+ // autonomous.js modelRefusal for what each class requires.
2019
+ const modelRefusal = autonomous.modelRefusal(model, cls);
2018
2020
  if (modelRefusal) return { error: modelRefusal };
2019
2021
  const effortRaw = String(p.effort == null ? '' : p.effort).trim().toLowerCase();
2020
2022
  return {
2021
2023
  opts: {
2022
2024
  task,
2023
2025
  cwd,
2026
+ class: cls,
2024
2027
  model,
2025
2028
  effort: QUEUE_EFFORTS.has(effortRaw) ? effortRaw : null,
2026
2029
  workers: clampInt(p.workers, 1, QUEUE_MAX_WORKERS),
@@ -2029,7 +2032,12 @@ function createQueueDispatch({
2029
2032
  // it costs about workers+1 full reasoning calls on one task, so the
2030
2033
  // card has to ask for it per task. queue.addTask stores the inverse
2031
2034
  // (`singlePass`), which is also the field a hand-written queue file
2032
- // or the CLI uses.
2035
+ // or the CLI uses. Fan-out is an Aegis Cloud mechanism only — a
2036
+ // byok/local task that somehow carried `fanout: true` is NOT honoured
2037
+ // as a fan-out here (autonomous.js's runTask forces it off for any
2038
+ // non-aegis class regardless of this flag), but it is still recorded
2039
+ // as the task's own ask rather than silently rewritten, matching
2040
+ // resolveFanout's "a wrong pick is reported, not rewritten" rule.
2033
2041
  singlePass: !(p.fanout === true || p.autonomous === true),
2034
2042
  commit: Boolean(p.commit),
2035
2043
  source: 'desktop',
@@ -3538,14 +3546,15 @@ function bootstrap() {
3538
3546
  // Nothing configured anywhere (env, this app's encrypted copy, or the
3539
3547
  // shared store): a fresh install. Auto-provision a free device key
3540
3548
  // from aegis1 — same shared helper mcp/server.js and the CLI use, so
3541
- // the desktop app works with no manual sign-in step. It starts at
3542
- // zero token-bank balance server-side (register-device), and pooled
3543
- // aegiscloud models additionally require an upgraded plan (aegis1's
3544
- // is_pro() gate) — this only unblocks BYOK and account status/
3545
- // settings until the account is funded and/or upgraded. Failure
3546
- // (offline, aegis1 unreachable) falls through exactly as before: the
3547
- // renderer's normal "not signed in" state handles it, and the next
3548
- // launch tries again.
3549
+ // the desktop app works with no manual sign-in step. A brand-new
3550
+ // fingerprint is granted aegis1's 30-day welcome-trial `unlimited`
3551
+ // entitlement (token-bank credit + a subscriptions row) in the same
3552
+ // register-device call that mints the key — see client/
3553
+ // auto-provision.js's header for the aegis1 commit that made this a
3554
+ // real grant rather than a badge. Failure (offline, aegis1
3555
+ // unreachable) falls through exactly as before: the renderer's
3556
+ // normal "not signed in" state handles it, and the next launch tries
3557
+ // again.
3549
3558
  const result = await autoProvision.autoProvisionApiKey(aegis, credentials);
3550
3559
  if (result.key) settings.setAegisKey(result.key);
3551
3560
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "aegis-desktop",
3
3
  "productName": "AEGIS Desktop",
4
- "version": "0.8.29",
4
+ "version": "0.8.32",
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",
package/preload.js CHANGED
@@ -423,8 +423,8 @@ const quickLauncher = {
423
423
  // a real checkout, so that switch is persisted and visible rather than
424
424
  // implied — but it is also not hidden from this file's users any more.
425
425
  const queue = {
426
- // Queue one task. `payload` is { task, cwd, model, effort, workers, maxRounds,
427
- // commit }; main.js range-checks every field and answers
426
+ // Queue one task. `payload` is { task, cwd, class, model, effort, workers,
427
+ // maxRounds, commit }; main.js range-checks every field and answers
428
428
  // { ok, item, items, pending, running, runs, … } or { ok: false, reason }.
429
429
  enqueue: (payload) => invokeQueue('enqueue', queueTaskFields(payload)),
430
430
  // Full state: { items, pending, running, draining, runs, defaultCwd, … }.
@@ -531,6 +531,11 @@ function queueTaskFields(payload) {
531
531
  return {
532
532
  task: String(p.task == null ? '' : p.task),
533
533
  cwd: String(p.cwd == null ? '' : p.cwd),
534
+ // 'aegis' | 'byok' | 'local' — same three classes the main composer uses
535
+ // (window.models.listClasses()). main.js defaults a blank/missing value to
536
+ // 'aegis' (autonomous.normalizeClass), so an older renderer build that
537
+ // never sends this field keeps behaving exactly as it did before.
538
+ class: p.class == null ? null : String(p.class),
534
539
  model: p.model == null ? null : String(p.model),
535
540
  effort: p.effort == null ? null : String(p.effort),
536
541
  workers: p.workers == null ? null : Number(p.workers),
package/renderer/app.js CHANGED
@@ -145,6 +145,10 @@ const ELEMENT_IDS = {
145
145
  send: 'send',
146
146
  exploreToggle: 'explore-toggle',
147
147
  queueTask: 'queue-task',
148
+ queueClassSelect: 'queue-class-select',
149
+ queueModelSelect: 'queue-model-select',
150
+ queueModelInput: 'queue-model-input',
151
+ queueModelHint: 'queue-model-hint',
148
152
  queueCwd: 'queue-cwd',
149
153
  queueCommit: 'queue-commit',
150
154
  queueEnqueue: 'queue-enqueue',
@@ -186,6 +190,11 @@ for (const [prop, id] of Object.entries(ELEMENT_IDS)) {
186
190
  }
187
191
 
188
192
  const CLASS_KEY = 'aegis.class';
193
+ // The queue card's own class pick, kept separate from the chat composer's
194
+ // (CLASS_KEY) — a queued task's backend and the interactive chat's are
195
+ // independent choices, so picking "local" for one must not silently flip
196
+ // the other the next time either picker loads.
197
+ const QUEUE_CLASS_KEY = 'aegis.queueClass';
189
198
  const AUTONOMOUS_KEY = 'aegis.autonomous';
190
199
  // The budget rung. Applies to every class now that the Max tokens dropdown is
191
200
  // gone, so it is no longer stored under the autonomous-mode namespace; the old
@@ -1463,6 +1472,8 @@ async function enqueueQueueTask() {
1463
1472
  const res = await queueApi.enqueue({
1464
1473
  task,
1465
1474
  cwd: els.queueCwd ? els.queueCwd.value.trim() : '',
1475
+ class: els.queueClassSelect ? els.queueClassSelect.value : undefined,
1476
+ model: queueSelectedModel(),
1466
1477
  commit: els.queueCommit ? els.queueCommit.checked : false,
1467
1478
  });
1468
1479
  if (res && res.ok) {
@@ -1525,9 +1536,14 @@ function renderQueueEntitlement(state) {
1525
1536
  if (!resolved) els.queueBoundary.textContent = '';
1526
1537
  else if (entitled) {
1527
1538
  // Say which of the two ways this account is paid for, so a funded bank
1528
- // does not read as a Pro plan somebody did not buy.
1539
+ // does not read as a Pro plan somebody did not buy — and name WHICH tier
1540
+ // the plan is: `ent.tierName` is resolved in main from the plan string the
1541
+ // server stated (shared module's TIER_NAMES), so a €9 Unlimited subscriber
1542
+ // is not told they own the retired €2 plan. That mislabelling is the same
1543
+ // class CLI commit 6443a60 fixed in `/billing`; the literal is only a
1544
+ // fallback for a snapshot from a host that predates the field.
1529
1545
  els.queueBoundary.textContent = ent.pro
1530
- ? 'Pro — the cloud drain and the fan-out are unlocked.'
1546
+ ? `${ent.tierName || 'Pro'} — the cloud drain and the fan-out are unlocked.`
1531
1547
  : 'Balance on your account — the cloud drain and the fan-out are unlocked.';
1532
1548
  } else {
1533
1549
  // The refusal names the Pro value AND keeps the free tier's real reach
@@ -1702,11 +1718,17 @@ function formatAutoInterval(ms) {
1702
1718
  return Number.isInteger(min) ? `every ${min} min` : `every ${min.toFixed(1)} min`;
1703
1719
  }
1704
1720
 
1705
- /** Paint both automation states from main's answer (`auto.status()`). */
1721
+ /**
1722
+ * Paint the auto-drain half of main's answer (`auto.status()`). The mint
1723
+ * (auto-seal) half is NOT painted here: that control lives on the φ(α) panel,
1724
+ * not the queue card, and desktop/renderer/fingerprint.js's own mount() reads
1725
+ * and repaints it independently (loadAutoMint() -> renderAutoMint(), a
1726
+ * closure local to that module, not a global this file could reach even if
1727
+ * it wanted to).
1728
+ */
1706
1729
  function renderAutoStatus(payload) {
1707
1730
  if (!payload || typeof payload !== 'object') return;
1708
1731
  if (payload.drain) renderAutoDrain(payload.drain);
1709
- if (typeof renderAutoMint === 'function' && payload.mint) renderAutoMint(payload.mint);
1710
1732
  }
1711
1733
 
1712
1734
  /**
@@ -3585,6 +3607,125 @@ async function loadModels(cls) {
3585
3607
  }
3586
3608
  }
3587
3609
 
3610
+ // --------------------------------------------------------- queue model class
3611
+ //
3612
+ // The queue card's own class/model picker, independent of the chat
3613
+ // composer's (QUEUE_CLASS_KEY vs CLASS_KEY — see the constant above): a
3614
+ // queued task can run on a different backend than whatever the composer is
3615
+ // currently pointed at. Deliberately a trimmed copy of loadClasses/loadModels
3616
+ // rather than a shared helper — the composer's version carries budget-ceiling
3617
+ // display and the autonomous-toggle visibility it owns, neither of which
3618
+ // belongs to a task that has not run yet.
3619
+
3620
+ /** Mirrors currentModelId() for the queue's own picker. */
3621
+ function queueSelectedModel() {
3622
+ if (els.queueModelInput && !els.queueModelInput.hidden) {
3623
+ const typed = els.queueModelInput.value.trim();
3624
+ if (typed) return typed;
3625
+ }
3626
+ return els.queueModelSelect ? els.queueModelSelect.value : '';
3627
+ }
3628
+
3629
+ /** Fan-out is an Aegis Cloud mechanism only (autonomous.js runTask forces it
3630
+ * off for any other class) — hide the control instead of leaving a checkbox
3631
+ * on screen that would silently do nothing, same reasoning as the composer's
3632
+ * autonomousToggleWrap (AUTONOMOUS_CLASS). */
3633
+ function updateQueueFanoutVisibility(cls) {
3634
+ const wrap = els.queueFanout && els.queueFanout.closest('label');
3635
+ if (wrap) wrap.hidden = cls !== 'aegis';
3636
+ }
3637
+
3638
+ async function loadQueueModels(cls) {
3639
+ if (!els.queueModelSelect) return;
3640
+ updateQueueFanoutVisibility(cls);
3641
+
3642
+ els.queueModelSelect.hidden = false;
3643
+ els.queueModelSelect.disabled = false;
3644
+ if (els.queueModelInput) {
3645
+ els.queueModelInput.hidden = true;
3646
+ els.queueModelInput.value = '';
3647
+ }
3648
+ if (els.queueModelHint) els.queueModelHint.textContent = '';
3649
+
3650
+ els.queueModelSelect.innerHTML = '';
3651
+ if (cls === 'aegis') {
3652
+ // Only this class has a real default to fall back to (resolveModel fills
3653
+ // in the pooled brain) — byok/local tasks must name a model, so neither
3654
+ // gets this option.
3655
+ const auto = document.createElement('option');
3656
+ auto.value = '';
3657
+ auto.textContent = 'server default (auto)';
3658
+ els.queueModelSelect.appendChild(auto);
3659
+ }
3660
+
3661
+ try {
3662
+ const data = await models.listModels(cls);
3663
+ const list = Array.isArray(data && data.models) ? data.models : [];
3664
+ const needsKey = Boolean(data && data.needsKey);
3665
+ const needsDaemon = Boolean(data && data.needsDaemon);
3666
+ const listed = !(data && data.listed === false);
3667
+ if (els.queueModelInput) {
3668
+ els.queueModelInput.hidden = !(cls === 'local' && (needsDaemon || !listed));
3669
+ }
3670
+ for (const m of list) {
3671
+ const opt = document.createElement('option');
3672
+ opt.value = m.id;
3673
+ opt.textContent = m.label || m.id;
3674
+ els.queueModelSelect.appendChild(opt);
3675
+ }
3676
+ if (!els.queueModelHint) return;
3677
+ if (needsKey) {
3678
+ els.queueModelHint.textContent =
3679
+ 'Connect this class in the Model card above (API key / provider key), then Refresh.';
3680
+ } else if (needsDaemon) {
3681
+ els.queueModelHint.textContent =
3682
+ `No local model server at ${(data && data.baseURL) || 'the configured address'} — ` +
3683
+ 'start it, then reopen this picker.';
3684
+ } else if (cls === 'local' && !listed) {
3685
+ els.queueModelHint.textContent =
3686
+ 'That address answered but listed no models — type the model tag above.';
3687
+ } else if (!list.length) {
3688
+ els.queueModelHint.textContent =
3689
+ cls === 'local'
3690
+ ? 'No models on this server yet — pull one first (ollama pull llama3.2).'
3691
+ : cls === 'aegis'
3692
+ ? "No models to pin — this runs the pooled route on the server's own default."
3693
+ : 'No models listed.';
3694
+ } else if (cls === 'byok' && data && data.needsProviderKey) {
3695
+ els.queueModelHint.textContent =
3696
+ 'A provider key is required before a queued task on this model can run — add one in the Model card above.';
3697
+ }
3698
+ } catch (err) {
3699
+ if (els.queueModelHint) {
3700
+ els.queueModelHint.textContent = `listModels failed: ${err && err.message ? err.message : err}`;
3701
+ }
3702
+ }
3703
+ }
3704
+
3705
+ async function loadQueueClasses() {
3706
+ if (!els.queueClassSelect) return;
3707
+ let classOptions = [];
3708
+ try {
3709
+ classOptions = (await models.listClasses()) || [];
3710
+ } catch (err) {
3711
+ classOptions = [];
3712
+ if (els.queueModelHint) {
3713
+ els.queueModelHint.textContent = `listClasses failed: ${err && err.message ? err.message : err}`;
3714
+ }
3715
+ }
3716
+ els.queueClassSelect.innerHTML = '';
3717
+ for (const c of classOptions) {
3718
+ const opt = document.createElement('option');
3719
+ opt.value = c.class;
3720
+ opt.textContent = c.configured ? c.label : `${c.label} (not configured)`;
3721
+ els.queueClassSelect.appendChild(opt);
3722
+ }
3723
+ const saved = localStorage.getItem(QUEUE_CLASS_KEY);
3724
+ if (saved && classOptions.some((c) => c.class === saved)) {
3725
+ els.queueClassSelect.value = saved;
3726
+ }
3727
+ await loadQueueModels(els.queueClassSelect.value);
3728
+ }
3588
3729
 
3589
3730
  // -------------------------------------------------------------- settings pane
3590
3731
 
@@ -4756,10 +4897,17 @@ async function init() {
4756
4897
  // A worker's frames are a status line, never transcript entries (that
4757
4898
  // separation is main.js's own progress channel, not aegis:chatDelta).
4758
4899
  if (typeof queueApi.onProgress === 'function') queueApi.onProgress(renderQueueProgress);
4900
+ if (els.queueClassSelect) {
4901
+ els.queueClassSelect.addEventListener('change', () => {
4902
+ localStorage.setItem(QUEUE_CLASS_KEY, els.queueClassSelect.value);
4903
+ loadQueueModels(els.queueClassSelect.value);
4904
+ });
4905
+ }
4759
4906
  // Paint the card once, from state that already exists on disk. A list, not a
4760
4907
  // drain: queueing a task in another window (or from the CLI) and opening
4761
4908
  // this one must show that task, and must not run it.
4762
4909
  await loadQueueState();
4910
+ await loadQueueClasses();
4763
4911
  }
4764
4912
 
4765
4913
  // ── the two automation switches ──────────────────────────────────────────
@@ -277,6 +277,24 @@
277
277
  rows="2"
278
278
  placeholder="Queue a task to run unattended…"
279
279
  ></textarea>
280
+ <!-- Which backend this task runs on — the same three classes
281
+ (window.models.listClasses()) and the same per-class model list
282
+ (listModels(cls)) the chat composer's #class-select/#model-select
283
+ use. Aegis Cloud can leave the model on "server default"; byok and
284
+ local tasks must name one (autonomous.js modelRefusal refuses a
285
+ blank at the door for those two). -->
286
+ <div class="api-key-row">
287
+ <select id="queue-class-select" title="Which backend this task runs on."></select>
288
+ <select id="queue-model-select" title="The model for the picked class."></select>
289
+ </div>
290
+ <input
291
+ type="text"
292
+ id="queue-model-input"
293
+ placeholder="model tag — e.g. llama3.2:3b"
294
+ autocomplete="off"
295
+ hidden
296
+ />
297
+ <p class="hint" id="queue-model-hint"></p>
280
298
  <div class="api-key-row">
281
299
  <input
282
300
  type="text"
package/vendor/aegis.js CHANGED
@@ -841,10 +841,16 @@ function createClient(opts = {}) {
841
841
  * Ask aegis1 for a free device key when the caller has none configured yet
842
842
  * (`POST /api/v1/auth/register-device`). Idempotent server-side on
843
843
  * `fingerprint` — the same fingerprint always returns the same account's
844
- * key rather than minting a new one. The account starts at zero token-bank
845
- * balance: this hands out an account, not free inference, so the existing
846
- * balance=0 gate on `/api/v1/chat/completions` still applies unchanged.
847
- * Sends no API key of its own — there isn't one yet.
844
+ * key rather than minting a new one. A BRAND-NEW fingerprint (first call
845
+ * ever for this machine) is additionally granted aegis1's 30-day
846
+ * welcome-trial `unlimited` entitlement in the SAME request that mints the
847
+ * key — a token-bank credit of UNLIMITED_CLOUD_ALLOWANCE_MICROS plus a
848
+ * time-boxed `subscriptions` row (aegis1 commit 4372830), so a fresh
849
+ * install can use pooled cloud models immediately instead of starting at a
850
+ * zero balance. Re-registering an already-known fingerprint does NOT
851
+ * re-grant it; the pool balance gate still applies once the allowance is
852
+ * spent or the 30 days lapse. Sends no API key of its own — there isn't
853
+ * one yet.
848
854
  */
849
855
  async function registerDevice(fingerprint, opts) {
850
856
  return apiPost('/api/v1/auth/register-device', { fingerprint }, undefined, opts);
@@ -12,11 +12,18 @@
12
12
  * device-fingerprint.js) so a wiped credential store still resolves back to
13
13
  * the same account rather than minting a new, unfunded one.
14
14
  *
15
- * The provisioned account starts at ZERO token-bank balance server-side, and
16
- * aegis1 additionally requires an upgraded (paid, active) plan to use pooled
17
- * aegiscloud models at all (nexus_chat_completions' is_pro() gate) — this
18
- * only unblocks BYOK and account status/settings until the account is funded
19
- * and/or upgraded. It is account creation, not a grant of free inference.
15
+ * As of aegis1 commit 4372830, a brand-new fingerprint (first call ever for
16
+ * this machine) is granted the 30-day welcome-trial `unlimited` entitlement
17
+ * server-side — a token-bank credit (UNLIMITED_CLOUD_ALLOWANCE_MICROS) and a
18
+ * time-boxed `unlimited` row in `subscriptions` — in the SAME request that
19
+ * mints the key, not a separate step this client has to ask for. is_pro()'s
20
+ * pool gate and the BYOK fee waiver both see that grant immediately. This
21
+ * comment previously said the account "starts at ZERO balance" and "only
22
+ * unblocks BYOK" — that described pre-4372830 behavior and was stale; see
23
+ * services/billing_service.py's is_pro()/is_unlimited() docstrings on the
24
+ * aegis1 side for the current contract. A re-registration of an already-known
25
+ * fingerprint (the idempotent path) does NOT re-grant the trial — it only
26
+ * happens once per machine, ever.
20
27
  */
21
28
  const { machineFingerprint } = require('./device-fingerprint.js');
22
29
 
package/vendor/prefs.js CHANGED
@@ -69,6 +69,11 @@ const SHARED_PREF_KEYS = Object.freeze([
69
69
  'effort',
70
70
  'vim',
71
71
  'memoryPersist',
72
+ // ON8 / D8 rule 1: the confirmation gate is a POSTURE, not a surface. The
73
+ // window's "confirm before running tools" switch and the shell's rule set
74
+ // answer the same question ("may this call run unattended?"), so the switch
75
+ // lives beside the rules that gate answers, in the one store both hosts read.
76
+ 'confirmMode',
72
77
  ]);
73
78
 
74
79
  /**
@@ -79,6 +84,15 @@ const SHARED_PREF_KEYS = Object.freeze([
79
84
  */
80
85
  const LEGACY_MEMORY_PERSIST_NAMESPACE = '__memoryPersist';
81
86
 
87
+ /**
88
+ * The second legacy namespace this store retires (ON8): the window's
89
+ * `__confirmMode: { enabled }` becomes the shared `confirmMode` boolean. Same
90
+ * rule, same reason as memory-persist above — and it is the one that closes
91
+ * D8's rule 1 in the direction that used to be impossible: a posture set in
92
+ * the window is now the posture the shell reads.
93
+ */
94
+ const LEGACY_CONFIRM_MODE_NAMESPACE = '__confirmMode';
95
+
82
96
  /**
83
97
  * Key names that mean "secret" wherever they appear in the tree. Deliberately
84
98
  * conservative: `keyMask`-style *previews* never reach this file (they are not
@@ -233,7 +247,7 @@ function remove(key, opts) {
233
247
  /**
234
248
  * The non-secret half of a legacy `<userData>/settings.json`, in the shared
235
249
  * store's spellings. Only namespaces THIS store owns are extracted: the other
236
- * app-level namespaces (`__confirmMode`, `__avatar`, `__telemetry`,
250
+ * app-level namespaces (`__avatar`, `__telemetry`,
237
251
  * `__autoDrain`, `__autoMint`, `__quickLauncher`) stay host-local until their
238
252
  * own phase moves them, and the provider rows are never here at all because a
239
253
  * provider row is where a key lives.
@@ -244,6 +258,13 @@ function extractNonSecretHalf(legacy) {
244
258
  if (src && typeof src === 'object' && typeof src.enabled === 'boolean') {
245
259
  out.memoryPersist = src.enabled;
246
260
  }
261
+ // ON8: the confirmation gate adopts the same way. `enabled: true` means the
262
+ // gate is UP, which is the shared `confirmMode: true`; the shared key keeps
263
+ // the window's own polarity so an adopting install cannot flip its gate.
264
+ const gate = (legacy || {})[LEGACY_CONFIRM_MODE_NAMESPACE];
265
+ if (gate && typeof gate === 'object' && typeof gate.enabled === 'boolean') {
266
+ out.confirmMode = gate.enabled;
267
+ }
247
268
  return out;
248
269
  }
249
270
 
@@ -290,10 +311,19 @@ function adopt(opts) {
290
311
  }
291
312
  const patch = {};
292
313
  for (const k of keys) patch[k] = half[k];
314
+ // `names`, NOT `keys` (AEGISONE ON8): this ledger is written through `write()`
315
+ // below, so it is scanned like everything else — and `SECRET_KEY_RE` matches
316
+ // `key`/`keys` by design, because a preference named `keys` IS how key material
317
+ // would smuggle itself into a file the desktop and the shell both read. Spelled
318
+ // `keys`, this field made `assertNoSecrets` refuse the store's own write, so
319
+ // EVERY adoption with something to adopt threw — the whole pre-D5 migration was
320
+ // dead, silently (settings.js's best-effort catch turned the throw into "no
321
+ // adoption" and no one was told). The scanner is the control here, not the bug;
322
+ // a field name is free to change.
293
323
  patch.adopted = {
294
324
  from: legacyFile,
295
325
  at: new Date().toISOString(),
296
- keys: keys.slice().sort(),
326
+ names: keys.slice().sort(),
297
327
  };
298
328
  write({ ...current, ...patch }, o);
299
329
  return { adopted: true, keys: keys.slice().sort(), from: legacyFile };