aegis-desktop 1.0.13 → 1.0.15

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.
Files changed (96) hide show
  1. package/SOURCE-OFFER.txt +9 -0
  2. package/bin/aegis.js +1 -32
  3. package/lib/account-key.js +1 -56
  4. package/lib/auto.js +1 -430
  5. package/lib/avatar/companion.js +1 -698
  6. package/lib/avatar/cosmetics.js +1 -532
  7. package/lib/avatar/events.js +1 -344
  8. package/lib/avatar/holder-turn.js +1 -350
  9. package/lib/avatar/holders.js +1 -612
  10. package/lib/avatar/identity.js +1 -641
  11. package/lib/avatar/level.js +1 -368
  12. package/lib/avatar/mirror.js +1 -524
  13. package/lib/avatar/persona.js +1 -388
  14. package/lib/avatar/profile.js +1 -977
  15. package/lib/avatar/register.js +1 -307
  16. package/lib/avatar/store.js +1 -472
  17. package/lib/avatar/tiers.js +1 -38
  18. package/lib/avatar/transfer.js +1 -415
  19. package/lib/avatar/turn.js +1 -216
  20. package/lib/avatar/voice.js +1 -328
  21. package/lib/avatar/xp.js +1 -404
  22. package/lib/deep-link.js +1 -120
  23. package/lib/fingerprint.js +2 -347
  24. package/lib/google-connect.js +1 -169
  25. package/lib/local/agents.js +1 -105
  26. package/lib/local/autonomous.js +1 -1045
  27. package/lib/local/claude-pro.js +1 -993
  28. package/lib/local/context.js +1 -95
  29. package/lib/local/engine.js +1 -3228
  30. package/lib/local/entitlement.js +1 -654
  31. package/lib/local/git-scope.js +1 -228
  32. package/lib/local/local.js +1 -2135
  33. package/lib/local/prompt.js +1 -275
  34. package/lib/local/queue.js +1 -560
  35. package/lib/local/session-rounds.js +1 -208
  36. package/lib/local/shell.js +1 -680
  37. package/lib/local/telemetry.js +1 -470
  38. package/lib/local/tools.js +1 -1238
  39. package/lib/local/turn-guard.js +1 -300
  40. package/lib/local/worktree-lock.js +1 -235
  41. package/lib/provider-key-mirror.js +1 -252
  42. package/lib/quick-launcher.js +1 -84
  43. package/lib/settings.js +1 -890
  44. package/lib/sync/memory-queue.js +1 -60
  45. package/lib/sync/persist-gate.js +1 -148
  46. package/lib/sync/sessions.js +1 -106
  47. package/lib/window-state.js +1 -66
  48. package/main.js +3 -4390
  49. package/package.json +3 -5
  50. package/preload.js +3 -628
  51. package/renderer/app.js +1 -6560
  52. package/renderer/avatar/assemble.js +1 -335
  53. package/renderer/avatar/avatar.js +1 -380
  54. package/renderer/avatar/cards.js +1 -141
  55. package/renderer/avatar/hud.js +1 -206
  56. package/renderer/avatar/machine.js +1 -213
  57. package/renderer/avatar/pane.js +1 -439
  58. package/renderer/avatar/parts.js +1 -437
  59. package/renderer/budget.js +1 -168
  60. package/renderer/companion.js +1 -223
  61. package/renderer/diff.js +1 -359
  62. package/renderer/diffview.js +1 -336
  63. package/renderer/fingerprint.js +3 -720
  64. package/renderer/markdown.js +1 -168
  65. package/renderer/provider-toolbar.js +1 -134
  66. package/renderer/providers.js +1 -259
  67. package/renderer/quick.js +1 -191
  68. package/renderer/stream-policy.js +1 -306
  69. package/renderer/transcript-view.js +1 -323
  70. package/renderer/usage.js +1 -1239
  71. package/vendor/aegis.js +1 -2054
  72. package/vendor/attest.js +1 -442
  73. package/vendor/authorship-key.js +1 -410
  74. package/vendor/auto-provision.js +1 -134
  75. package/vendor/brain-catalog.js +1 -268
  76. package/vendor/byok-catalog.js +1 -230
  77. package/vendor/credentials.js +1 -494
  78. package/vendor/device-fingerprint.js +1 -141
  79. package/vendor/drive-connect.js +1 -85
  80. package/vendor/drive-upload.js +1 -174
  81. package/vendor/env-file.js +1 -1273
  82. package/vendor/fingerprint.js +1 -1064
  83. package/vendor/foreign-memory.js +1 -669
  84. package/vendor/google-signin.js +1 -125
  85. package/vendor/hooks.js +1 -430
  86. package/vendor/model-discovery.js +1 -330
  87. package/vendor/platform.js +1 -937
  88. package/vendor/prefs.js +1 -351
  89. package/vendor/presence-constants.js +1 -107
  90. package/vendor/presence.js +1 -438
  91. package/vendor/record-vault.js +3 -1055
  92. package/vendor/session-store.js +1 -548
  93. package/vendor/topup.js +1 -297
  94. package/vendor/touch.js +1 -458
  95. package/vendor/update.js +1 -704
  96. package/vendor/user-models.js +1 -381
@@ -1,656 +1,3 @@
1
1
  // SPDX-License-Identifier: AGPL-3.0-or-later
2
2
  // Copyright (C) 2026 Niklas Borneklint — AEGIS Code (aegiscode) / Aegiscloud.
3
- // AEGIS Code (aegiscode) — source: https://github.com/aegisinfo/aegiscode-plugin
4
- // Conveying a modified copy, or running one as a network service, owes the
5
- // corresponding source under this licence (§5/§13) — see LICENSING.md.
6
- 'use strict';
7
-
8
- /**
9
- * entitlement.js — the Pro boundary for the autonomous queue.
10
- *
11
- * Phase 4 of docs/upgrade-conversion-plan.md. The queue is the feature people
12
- * actually pay for, and until this file existed it was 100% free and ungated:
13
- * `registerQueueIpc` in main.js checked no key, no plan and no quota, so the
14
- * single most expensive thing this app can do (an unattended task, optionally
15
- * fanned out across pooled workers) ran forever on the free tier.
16
- *
17
- * WHAT IS GATED, AND WHY IT IS THE FAN-OUT. Free keeps everything local: the
18
- * drain runs on the user's own key, in this process, while the window is open.
19
- * The *fan-out* is the multiplier — aegis1's pool_brain spawns up to `workers`
20
- * reasoning workers plus a synthesis pass and re-sends the task context to each
21
- * (see autonomous.js resolveFanout) — so that is the worker ceiling this module
22
- * enforces: free = 1 worker (a plain single-pass turn), Pro = the fan-out plus
23
- * the cloud drain it belongs to. Nothing here changes price, the memory cap or
24
- * the free-tier definition; it only stops the free tier from spending the Pro
25
- * tier's multiplier.
26
- *
27
- * WHERE THE GATE LIVES. This module is the DECISION; main.js is the DOOR.
28
- * `registerQueueIpc` consults it, so a renderer that never renders the fan-out
29
- * control cannot get a fan-out anyway — renderer-side gating is cosmetic and
30
- * trivially bypassed (any devtools console can call `window.queue.enqueue`).
31
- * Nothing in the renderer decides entitlement; it displays what main tells it.
32
- *
33
- * THE SIGNAL (see the report / the module's `probe`): main injects a probe that
34
- * calls `aegis.verifyApiKey()` — aegis1 `/api/verify-api-key`, which its own
35
- * docstring describes as "Verify a shared api_key and return plan/email/
36
- * memory_token". The plan field is the account's plan, and aegis1 owns the
37
- * vocabulary for it (`services/billing_service.py`: `TIERS = ("free", "pro",
38
- * "unlimited")`, `PAID_PLANS = ("pro", "unlimited")`, plus the legacy aliases
39
- * that normalize onto one of them). This module MIRRORS that vocabulary rather
40
- * than inventing one; there is no new billing anything here, no new endpoint,
41
- * and no write to the server. The probe prefers the server's `plan_effective`
42
- * (its own `is_pro()` verdict, trial included) over the stored `plan` string
43
- * when it is present, because a welcome-trial account is a real Unlimited
44
- * entitlement that `users.plan` does not record — see `classifyProbe`.
45
- *
46
- * THE BUG THIS VOCABULARY COST (fixed here). `PAID_PLANS` read `("pro",)`,
47
- * mirroring the retired €2 line and MISSING `unlimited` — the €9/mo tier that
48
- * is the only one sold. So `normalizePlan('unlimited')` fell through to `free`,
49
- * and a paying Unlimited subscriber with a spent-out bank was handed the
50
- * free-tier boundary: the fan-out refused, the cloud drain clamped to one local
51
- * worker, and a "top up your balance" prompt — for an account whose plan is
52
- * precisely what pays for the drain. aegis1's own `LEGACY_PLAN_ALIASES` carries
53
- * the matching warning from the other side ("Deliberately NOT an alias:
54
- * `unlimited` is a live tier you can subscribe to, so it must resolve through
55
- * TIERS"), and its `test_tier_vocabulary` pins `PAID_PLANS` at two entries.
56
- * Two implementations of "is this account paid" had already drifted once; this
57
- * was the drift.
58
- *
59
- * PHASE 6 — THE SECOND SIGNAL, THE BALANCE. The plan is only half of how this
60
- * product charges. aegis1's actual paywall is the Token Bank running dry: the
61
- * balance is prepaid EUR the OPERATOR credits by hand, and `app.py` states the
62
- * consequence plainly — "Pool-key users with token bank balance skip legacy
63
- * credit checks." So entitlement here is `paid_plan || balance > 0` — and the
64
- * `paid_plan` half is what carries an Unlimited subscriber whose bank is empty;
65
- * the bank is an OVERAGE lever, never the thing that decides whether a paying
66
- * account may use the feature it pays for. When the
67
- * bank reads zero the wall names the fix that exists (a top-up, campaign
68
- * `topup_bank`) instead of a plan checkout the user may already own. Nothing in
69
- * this module grants a balance, expires one, or auto-charges: the operator
70
- * decides when an account pays, and this file only reads the number they set.
71
- * An unreadable balance is UNKNOWN, not zero — a network blip must never
72
- * manufacture a paywall, and only a balance we actually read can be "empty".
73
- *
74
- * FAIL CLOSED, AND SAY SO. Every way of not knowing — no key on the machine, an
75
- * offline/500 probe, a plan string outside the vocabulary — resolves to the
76
- * free boundary, and the snapshot carries `degraded: true` plus the reason so
77
- * the UI and the report can be honest about it. The fan-out is real money; an
78
- * unknown entitlement must not hand it out. The cost is the opposite error (a
79
- * paying customer offline at that instant is held to one worker for the length
80
- * of one TTL), which is bounded, visible in the note, and recoverable by
81
- * re-probing — the next `refresh()` after the TTL flips it back.
82
- *
83
- * Pure Node on purpose: no Electron, no network of its own (the probe is
84
- * injected), and `now` is a seam, so the whole thing is unit-testable —
85
- * including the bypass path (a renderer asking for workers > 1 while free must
86
- * be refused HERE, not merely hidden in the UI).
87
- */
88
-
89
- /** The free-tier fan-out ceiling: one worker per task, i.e. a plain turn. */
90
- const FREE_MAX_WORKERS = 1;
91
-
92
- /**
93
- * Aegis1's paid tiers. MIRROR of services/billing_service.py
94
- * `PAID_PLANS = ("pro", "unlimited")` — pinned literal on purpose, because this
95
- * is the line that has to move whenever the server sells another tier.
96
- *
97
- * `unlimited` (€9/mo) is the tier ON SALE and the one a paying customer almost
98
- * certainly owns; `pro` is the grandfathered €2 line, kept because its
99
- * subscribers keep their entitlements. Reading only `pro` was not a narrower
100
- * version of this gate, it was a gate pointing at the wrong tier: every
101
- * Unlimited account fell to the free boundary. `starter` stays out — aegis1
102
- * itself calls that one free.
103
- */
104
- const PAID_PLANS = Object.freeze(['pro', 'unlimited']);
105
-
106
- /**
107
- * What each paid tier is CALLED, so a surface that names the account's plan
108
- * names the one the server granted. The CLI has read the tier off the server's
109
- * plan string since 6443a60 (`cli/src/entitlement.js::paymentView`) after it
110
- * told a €9 subscriber they owned the retired €2 plan; this is the same value,
111
- * resolved once in the shared module both hosts read.
112
- */
113
- const TIER_NAMES = Object.freeze({ pro: 'Pro', unlimited: 'Unlimited' });
114
-
115
- /** The display name for a plan string, or null when it is not a paid tier. */
116
- function tierName(plan) {
117
- return TIER_NAMES[normalizePlan(plan)] || null;
118
- }
119
-
120
- /**
121
- * MIRROR of services/billing_service.py `LEGACY_PLAN_ALIASES`: every stored
122
- * plan string that normalizes onto a tier. `starter` is free-equivalent by
123
- * aegis1's own definition — reading it as paid here would hand the fan-out to
124
- * the one account the server itself treats as free.
125
- */
126
- const LEGACY_PLAN_ALIASES = Object.freeze({
127
- cloud: 'pro',
128
- memory: 'pro',
129
- s: 'pro',
130
- heavy: 'pro',
131
- team: 'pro',
132
- starter: 'free',
133
- });
134
-
135
- /**
136
- * The Pro value, in the words every surface repeats (Phase 3's PAID_PROMISE is
137
- * the same claim). A refusal that only stated the limit ("max 1 worker") reads
138
- * as a bug; naming what Pro buys is the whole point of the wall.
139
- */
140
- const PRO_VALUE =
141
- 'Pro puts the drain in the cloud, so the job keeps running after you close the laptop';
142
-
143
- /**
144
- * The honesty line — Phase 3's rule, and the second half of the same claim:
145
- * everything that runs on the user's own machine stays free forever, and the
146
- * cloud drain is the thing being sold. Both hosts that print a paid prompt
147
- * print this beside it (the desktop keeps its own literal in
148
- * `desktop/renderer/app.js`; the terminal reads it from here), because the
149
- * promise without the honesty line is the four-bullet feature list again.
150
- *
151
- * Kept byte-identical to the renderer's `FREE_HONESTY`, and asserted verbatim
152
- * by both hosts' tests, so the terminal and the GUI cannot come to disagree
153
- * about what is free — a disagreement that would either overcharge a promise
154
- * or undercut the offer, depending on which copy a user believed.
155
- */
156
- const FREE_HONESTY =
157
- 'Everything on your machine is free, permanently. The cloud drain is what you pay for.';
158
-
159
- /**
160
- * The BYOK lane's price, in words — built from the SERVER's published rate.
161
- *
162
- * The desktop README says the byok lane is "billed a flat handling fee" and
163
- * aegis1 charges exactly that (services/pricing.price_byok_call), but no client
164
- * surface said so: a user who pasted their own Anthropic key into AEGIS read
165
- * "your provider's cost" and never learned AEGIS adds a fee. That is the one
166
- * place in this product where the bill and the pitch disagreed, and it is the
167
- * copy the desktop session flagged as an unresolved honesty conflict.
168
- *
169
- * `fee` is aegis1's own `/api/v1/byok/providers` payload — a live payload, never
170
- * a hardcoded number, for the reason the engine already states: a client that
171
- * hardcodes a fee is a client that can disagree with the ledger. No published
172
- * fee (old server, offline, unauthenticated read that returned nothing) returns
173
- * '' and every host then says NOTHING about the price, which is the only honest
174
- * alternative to inventing one.
175
- *
176
- * `require_account` / `anonymous_daily_calls` come from the same payload and
177
- * describe enforcement, not price: with the relay requiring an AEGIS account the
178
- * sentence has to say so, or the user meets a 402 the client never warned about
179
- * — the exact failure tests/test_byok_fee.py::test_the_published_gate_warning_is
180
- * _true_whenever_the_gate_can_402 exists to prevent.
181
- */
182
- const BYOK_FEE_NOTE =
183
- 'Bring your own key is relayed through AEGIS, not dialled from here — the routing, prompt assembly and caching are billed as a flat handling fee, on top of your provider\'s own bill.';
184
-
185
- /** Per-million-token rendering of a published per-1k rate. */
186
- function ratePerMillion(usdPer1k) {
187
- return `$${(Number(usdPer1k) * 1000).toFixed(2)}/M`;
188
- }
189
-
190
- function byokFeeLine(fee) {
191
- const f = fee && typeof fee === 'object' ? fee : null;
192
- if (!f) return '';
193
- const inRate = Number(f.in_usd_per_1k);
194
- const outRate = Number(f.out_usd_per_1k);
195
- if (!Number.isFinite(inRate) || !Number.isFinite(outRate)) return '';
196
- if (f.disabled === true) {
197
- // The deployment switched the fee off; saying "free" is then the truth
198
- // rather than a promise this client cannot keep.
199
- return 'Bring your own key is relayed free on this deployment right now.';
200
- }
201
- // The price clause stands alone when the server published no enforcement
202
- // detail — which is every server deployed before this change, and the
203
- // default-off gate after it. Joined with ' — ' only when there IS a second
204
- // clause: a sentence-ending dash with nothing after it ("…provider's bill —.")
205
- // is what the first live run of this line actually printed.
206
- const price = `Bring your own key: relayed through AEGIS, handled at `
207
- + `${ratePerMillion(inRate)} in / ${ratePerMillion(outRate)} out, `
208
- + `on top of your provider's bill`;
209
- let clause = '';
210
- if (f.require_account === true) {
211
- clause = 'and needs an AEGIS account key (X-AEGIS-Key) for the fee to be billed';
212
- } else {
213
- const cap = Number(f.anonymous_daily_calls);
214
- if (Number.isFinite(cap) && cap > 0) {
215
- clause = `with ${cap} relayed turns/24h before an AEGIS account is required`;
216
- }
217
- }
218
- return clause ? `${price} — ${clause}.` : `${price}.`;
219
- }
220
-
221
- /** Campaign id for this prompt (Phase 1's fixed ids; aegis1 keeps the slug). */
222
- const CAMPAIGN = 'queue_cap';
223
-
224
- /**
225
- * Where a refusal sends someone. A single-quoted literal so
226
- * desktop/test/renderer-attribution.test.mjs can see it, and on aegis1's
227
- * `/go/` route rather than the raw /subscribe path: `/go/` owns utm_source and
228
- * utm_medium and maps `s`/`c` onto them, and `c=queue_cap` is what makes this
229
- * click attributable to the refusal that produced it. Appending utm_* here
230
- * would be a silent zero (aegis1 go_short_link() documents it) — the route
231
- * finds no `s`, files the click as 'direct', and the link still works.
232
- */
233
- const UPGRADE_URL = 'https://aegiscloud.org/go/upgrade?s=desktop&c=queue_cap';
234
-
235
- /**
236
- * The BALANCE wall (Phase 6). Aegis1's paywall is not a feature tier — it is
237
- * the Token Bank running dry. `balance_micros` is a prepaid EUR balance
238
- * (db.py), the operator credits it by hand, and aegis1 says the consequence
239
- * out loud in app.py: "Pool-key users with token bank balance skip legacy
240
- * credit checks." So the truthful trigger is balance == 0, and the money path
241
- * that fixes it is a TOP-UP, not a plan checkout.
242
- *
243
- * Campaign is `topup_bank` — the id Phase 1 already reserved for
244
- * "top-up / bank / balance surfaces" (desktop/renderer/app.js). Reusing it
245
- * rather than minting a sixth id keeps the documented list in the plan and the
246
- * code-level list in the renderer from drifting; a new id in one place and not
247
- * the other is exactly the silent-zero failure this project keeps hitting.
248
- */
249
- const BALANCE_CAMPAIGN = 'topup_bank';
250
-
251
- /** Where the balance wall sends someone: `/go/topup` (aegis1's `/go/<page>`
252
- * route handles both pages), same `s=desktop` requirement as UPGRADE_URL. */
253
- const BALANCE_URL = 'https://aegiscloud.org/go/topup?s=desktop&c=topup_bank';
254
-
255
- /**
256
- * HOST SEAM — the one thing that had to change for the terminal to reuse this
257
- * module (Phase 7).
258
- *
259
- * This file is no longer desktop-only: `cli/src/autonomous.js` gates the SAME
260
- * fan-out through the SAME decision, because the paid multiplier must not cost
261
- * money in one host and be free in another. But the two hosts are not the same
262
- * channel: a click from the terminal is a `cli` conversion and a click from the
263
- * GUI is a `desktop` one, and aegis1 files whatever `s` it is given.
264
- *
265
- * The URLs above are literals (`s=desktop`), so a CLI refusal printing one
266
- * would file every terminal click as a desktop one — silently, in the worst
267
- * available way, since the link still works and the only thing it destroys is
268
- * the number. That is the same failure `/go/` exists to prevent, so it gets the
269
- * same treatment: the channel is a parameter, and `host` (NOT `source`, which
270
- * already means "where this verdict came from" in the snapshot) is what
271
- * selects it.
272
- *
273
- * `SIGNUP_SOURCES` mirrors aegis1's accepted set. An unknown host is not sent:
274
- * it falls back to the desktop default rather than minting a source the server
275
- * will file as 'direct' — a wrong datapoint is worse than a missing one.
276
- */
277
- const SIGNUP_SOURCES = Object.freeze(['desktop', 'cli', 'mcp']);
278
- const DEFAULT_HOST = 'desktop';
279
-
280
- /** aegis1's `/go/<page>` short form, for a DIFFERENT channel than the literals
281
- * above. `page` is `upgrade` or `topup`; the wire names (utm_*) are never sent
282
- * here — `/go/` reads `s`/`c` and re-emits them itself (signup.js documents the
283
- * measured difference). */
284
- function goUrl(page, campaign, host) {
285
- const slug = String(host == null ? '' : host).trim().toLowerCase();
286
- const s = SIGNUP_SOURCES.includes(slug) ? slug : DEFAULT_HOST;
287
- return `https://aegiscloud.org/go/${String(page).trim().toLowerCase()}?s=${s}&c=${campaign}`;
288
- }
289
-
290
- /** The campaign-tagged prompt URL for one host: the same page and campaign as
291
- * the literals, filed under `host`'s channel. */
292
- function promptUrlFor(host) {
293
- return {
294
- upgrade: goUrl('upgrade', CAMPAIGN, host),
295
- balance: goUrl('topup', BALANCE_CAMPAIGN, host),
296
- };
297
- }
298
-
299
- /** How long a resolved snapshot is trusted before the next probe. The probe is
300
- * one small POST; a minute is short enough that a fresh purchase unlocks the
301
- * card on the next refresh and long enough that a drain never probes per task. */
302
- const DEFAULT_TTL_MS = 60 * 1000;
303
-
304
- /** Collapse any stored/alias plan string onto a tier, exactly as aegis1 does.
305
- *
306
- * Aegis1 checks its live `TIERS` BEFORE the alias table (`normalize_plan`), so
307
- * an alias can never shadow a tier it also names — and `unlimited` is a live
308
- * tier. It is therefore returned as itself and never collapsed onto `pro`: the
309
- * two grant different entitlements server-side (the monthly Unlimited
310
- * allowance and the BYOK fee waiver are `is_unlimited()`-gated), and telling a
311
- * €9 customer they own the retired €2 plan is the mistake commit 6443a60
312
- * exists to record.
313
- */
314
- function normalizePlan(plan) {
315
- const raw = String(plan == null ? '' : plan).trim().toLowerCase();
316
- if (!raw) return 'free';
317
- if (PAID_PLANS.includes(raw)) return raw;
318
- const aliased = LEGACY_PLAN_ALIASES[raw];
319
- return aliased || 'free';
320
- }
321
-
322
- /** The sentence a user types to route a task to the drain — quoted verbatim in
323
- * every refusal and in the CLI's promotion block, so the two hosts cannot
324
- * drift into two different instructions. */
325
- const ASK_EXAMPLE = 'Do X autonomously';
326
-
327
- /** True only for a tier aegis1 itself would call paid (`pro` or `unlimited`). */
328
- function isPaidPlan(plan) {
329
- return normalizePlan(plan) !== 'free';
330
- }
331
-
332
- /**
333
- * The refusal, as the sentence a user reads. Names the Pro value (the cloud
334
- * drain surviving app close), not just the ceiling, and carries the campaign
335
- * id in the link it is paired with (see `gateFanout`'s `upgradeUrl`).
336
- */
337
- function fanoutRefusal() {
338
- return (
339
- `Fan-out needs Pro: ${PRO_VALUE}. ` +
340
- // The ask, in the message, as the sentence to write — the CLI prints the
341
- // same two lines (`cli/src/autonomous.js`), so a user who meets this wall
342
- // in either host reads one instruction, not two vocabularies. `autonomously`
343
- // is the word the queue actually routes on (queue.js stores the inverse as
344
- // `singlePass`), which is why the example quotes it verbatim instead of
345
- // naming the feature.
346
- `Write it in the message: "${ASK_EXAMPLE}". ` +
347
- 'Unlock: Pro, or any Token Bank balance. ' +
348
- 'Free keeps the local drain on your own key — one worker per task, while this window is open.'
349
- );
350
- }
351
-
352
- /**
353
- * The refusal when the account's BANK is empty rather than its plan being
354
- * free. Still exactly one reason to pay (PRO_VALUE — Phase 3's rule: a refusal
355
- * that pitches something else is a second, competing offer at the moment the
356
- * first is declined), and it names the fix that actually exists: top the bank
357
- * up. "Your AEGIS balance is empty." is already aegis1's own string for this
358
- * state (translations.py), so the two surfaces say the same sentence.
359
- */
360
- function balanceRefusal() {
361
- return (
362
- 'Your AEGIS balance is empty — ' +
363
- `${PRO_VALUE}. ` +
364
- // Same ask as the plan wall, but the unlock line follows THIS wall: the
365
- // bank is empty, so topping it up is the action that clears it. Naming only
366
- // "upgrade to Pro" would send a user who already owns Pro (and simply spent
367
- // the bank) to a checkout for a plan they have — the exact confusion this
368
- // module exists to remove. Kept symmetric with `fanoutRefusal` otherwise.
369
- `Write it in the message: "${ASK_EXAMPLE}". ` +
370
- 'Unlock: top up the Token Bank, or upgrade to Pro. ' +
371
- 'Local drains stay free: one worker per task, on your own key, while this window is open.'
372
- );
373
- }
374
-
375
- /**
376
- * The account's bank, from whatever shape the probe answered.
377
- *
378
- * aegis1's `/api/token-bank/balance` returns `balance_eur` (micros stay
379
- * internal — its own docstring says so), and that value is ROUNDED to cents
380
- * before it is sent. 0.004 EUR therefore arrives as 0 and reads as empty;
381
- * that is the server's rounding, not this module's, and it is the safe
382
- * direction for a wall (it charges nothing, it only withholds the multiplier).
383
- *
384
- * `balanceKnown: false` is the important state: a probe that did not report a
385
- * balance has NOT proven the bank is funded, so it must not unlock anything.
386
- * Fail closed, same as the plan signal.
387
- *
388
- * The returned keys are `balanceKnown` / `balanceMicros` — deliberately the
389
- * SAME names `fromProbe` destructures, because these are spread straight into
390
- * `classifyProbe`'s result (`...bal`). They were once `known` / `micros`, and
391
- * the mismatch made every funded bank read as `undefined` → not banked, so the
392
- * operator's lever silently did nothing. Keep these names in lockstep with the
393
- * `...bal` spreads below; the test asserts the exact shape.
394
- */
395
- function balanceOf(body) {
396
- if (!body || typeof body !== 'object') return { balanceKnown: false, balanceMicros: null };
397
- let raw = null;
398
- if (body.balance_micros != null) raw = Number(body.balance_micros);
399
- else if (body.balance_eur != null) raw = Number(body.balance_eur) * 1_000_000;
400
- if (raw == null || !Number.isFinite(raw)) return { balanceKnown: false, balanceMicros: null };
401
- return { balanceKnown: true, balanceMicros: Math.trunc(raw) };
402
- }
403
-
404
- /**
405
- * Coerce a probe's answer into the three states this module cares about.
406
- *
407
- * The probe resolves aegis1's `{ valid, plan }` for a good key. A missing key
408
- * comes back as HTTP 400 `api_key required` (or resolves `valid: false`), which
409
- * is not a failure — it is the definitive "this install is free". Everything
410
- * else that did not produce a plan is `unreachable`, which also lands on the
411
- * free boundary but is a different FACT and must stay distinguishable in the
412
- * snapshot (a report that cannot tell "free" from "could not ask" is guessing).
413
- */
414
- function classifyProbe(result, error) {
415
- if (error) {
416
- const bal = balanceOf(error && error.data);
417
- const status = error.status;
418
- const data = (error && error.data) || {};
419
- const code = String(data.error || data.code || '');
420
- const message = String((error && error.message) || '');
421
- if (status === 400 || status === 401 || /api_key required|invalid api key|unauthor/i.test(`${code} ${message}`)) {
422
- return { source: 'no-key', plan: 'free', degraded: false, ...bal };
423
- }
424
- return { source: 'unreachable', plan: 'free', degraded: true, ...bal };
425
- }
426
- const body = result && typeof result === 'object' ? result : {};
427
- const bal = balanceOf(body);
428
- if (body.valid === false) return { source: 'no-key', plan: 'free', degraded: false, ...bal };
429
- // WHICH plan field. `plan_effective` is aegis1's own entitlement verdict —
430
- // the same predicate its `is_pro()` gates pooled calls on, so it counts the
431
- // 30-day welcome trial that never writes `users.plan` (see app.py's
432
- // register_device: the trial lives in `subscriptions`). The stored `plan`
433
- // column cannot express that account, so an installing user read as `free`
434
- // here and only the €12 signup credit kept the gate open — spend it, or fail
435
- // to receive it, and a real Unlimited account was refused the feature. The
436
- // stored string is still the fallback: an older server that predates the
437
- // field must keep working, and it is the same value when nothing is on trial.
438
- const rawPlan = body.plan_effective != null && String(body.plan_effective).trim()
439
- ? body.plan_effective
440
- : body.plan;
441
- const stated = String(rawPlan == null ? '' : rawPlan).trim();
442
- if (!stated) {
443
- // A key that verified but carried no plan: aegis1's own default for such an
444
- // account is `free` (the column default), and a missing field is not proof
445
- // of payment — so this is the free boundary, marked as not proven.
446
- return { source: 'no-plan-field', plan: 'free', degraded: true, ...bal };
447
- }
448
- // The server STATED a plan, so the verdict is proven — free or paid.
449
- // `degraded` means "no real answer" (timeout, no plan field, no key); a stated
450
- // `free` IS an answer. This was once `degraded: !known`, where `known` meant
451
- // "is this a PAID plan" — so every honestly-free account was marked degraded,
452
- // and every promotion path that requires `degraded === false` (the CLI's
453
- // per-drain upsell, the desktop's refusal link) went silent. The gate then
454
- // withheld the fan-out and never said how to pay for it: it charged nothing
455
- // and converted nobody. Unknown plan strings still normalize to `free`, so the
456
- // money direction stays fail-closed.
457
- return { source: 'server-plan', plan: normalizePlan(stated), degraded: false, ...bal };
458
- }
459
-
460
- /**
461
- * The gate. `probe` is injected (main.js passes the shared client's
462
- * verifyApiKey; tests pass a stub), so this module never touches the network
463
- * itself and every branch below is reachable in a unit test.
464
- */
465
- function createEntitlement({
466
- probe,
467
- now = Date.now,
468
- ttlMs = DEFAULT_TTL_MS,
469
- // The CHANNEL the prompts in this instance link to. 'desktop' by default so
470
- // every existing caller is unchanged; `cli/src/entitlement.js` passes 'cli'.
471
- host = DEFAULT_HOST,
472
- } = {}) {
473
- const urls = promptUrlFor(host);
474
- // Start at the free boundary, not at Pro. Until a probe has answered, nothing
475
- // has been paid for — and `current()` (the sync read the drain clamp uses) is
476
- // never allowed to be optimistic.
477
- let snapshot = Object.freeze({
478
- pro: false,
479
- entitled: false,
480
- balanceKnown: false,
481
- balanceMicros: null,
482
- balanceZero: false,
483
- wall: 'plan',
484
- plan: 'free',
485
- tier: null,
486
- tierName: null,
487
- planRaw: null,
488
- maxWorkers: FREE_MAX_WORKERS,
489
- source: 'unresolved',
490
- degraded: true,
491
- checkedAt: null,
492
- campaign: CAMPAIGN,
493
- host: SIGNUP_SOURCES.includes(String(host).toLowerCase()) ? String(host).toLowerCase() : DEFAULT_HOST,
494
- upgradeUrl: urls.upgrade,
495
- refusal: null,
496
- });
497
- let inflight = null;
498
-
499
- function fromProbe({ source, plan, degraded, planRaw, balanceKnown = false, balanceMicros = null }) {
500
- // The tier itself, not a paid/not-paid bit: `unlimited` and `pro` are both
501
- // paid and must stay distinguishable, because the surfaces that name the
502
- // account's plan (the desktop's queue boundary, the CLI's `/billing`) name
503
- // the one the server granted. `pro` below keeps its original meaning — "the
504
- // PLAN is paid for", as opposed to a free plan carried by a funded bank.
505
- const tier = normalizePlan(plan);
506
- const pro = tier !== 'free';
507
- const banked = balanceKnown && Number.isFinite(balanceMicros) && balanceMicros > 0;
508
- // TWO independent ways to be paid for: a paid plan, or a funded bank. The
509
- // operator controls the second by hand (aegis1 admin `add_funds`), which is
510
- // the lever this wall is built around — nothing here grants, times out or
511
- // auto-charges; it only reads the number the operator set. The FIRST way is
512
- // the one a paying subscriber holds, and it does not weaken when their bank
513
- // runs dry: the balance is overage, the subscription is the entitlement.
514
- const entitled = pro || banked;
515
- // The bank was READABLE and EMPTY — the operator's own signal that this
516
- // account is now paying, not the absence of a plan.
517
- const balanceZero = balanceKnown && !banked;
518
- // Which wall to print. Entitled accounts get none. An account whose bank we
519
- // read and found empty gets the TOP-UP wall, because topping up is the
520
- // action that actually clears it. Everything else — unknown balance, no
521
- // key, unreachable probe — gets the PLAN wall: we have not proven the bank
522
- // is the problem, and a top-up prompt we cannot justify is a dark pattern.
523
- const wall = entitled ? null : (balanceZero ? 'balance' : 'plan');
524
- return Object.freeze({
525
- pro,
526
- entitled,
527
- balanceKnown: Boolean(balanceKnown),
528
- balanceMicros: balanceKnown ? balanceMicros : null,
529
- balanceZero,
530
- wall,
531
- // The tier a paid PLAN grants, else `free`. A funded bank on a free plan
532
- // is still `free` here — the bank is not a tier, and naming one would put
533
- // a plan on the account the server never granted.
534
- plan: pro ? tier : 'free',
535
- tier: pro ? tier : null,
536
- tierName: pro ? (TIER_NAMES[tier] || null) : null,
537
- planRaw: planRaw == null ? null : String(planRaw),
538
- // A paid session has no ceiling of its own here: main.js still clamps the
539
- // renderer's number to QUEUE_MAX_WORKERS, which stays the only upper bound.
540
- maxWorkers: entitled ? null : FREE_MAX_WORKERS,
541
- source,
542
- // `degraded` means "this verdict was not proven by the server" — for a
543
- // paid verdict that is impossible (a plan string or a funded bank is what
544
- // proves it), so it only ever marks a free boundary we could not confirm.
545
- degraded: Boolean(degraded),
546
- checkedAt: now(),
547
- campaign: wall === 'balance' ? BALANCE_CAMPAIGN : CAMPAIGN,
548
- upgradeUrl: wall === 'balance' ? urls.balance : urls.upgrade,
549
- refusal: entitled ? null : (wall === 'balance' ? balanceRefusal() : fanoutRefusal()),
550
- });
551
- }
552
-
553
- /**
554
- * Re-probe (or return the cached verdict while it is fresh). Never throws:
555
- * a probe that fails IS an answer here (`unreachable` → free boundary).
556
- */
557
- async function refresh({ force = false } = {}) {
558
- const fresh = snapshot.checkedAt != null && now() - snapshot.checkedAt < ttlMs;
559
- if (fresh && !force) return snapshot;
560
- if (inflight) return inflight;
561
- inflight = (async () => {
562
- if (typeof probe !== 'function') {
563
- snapshot = fromProbe({ source: 'no-probe', plan: 'free', degraded: true });
564
- return snapshot;
565
- }
566
- try {
567
- const answer = await probe();
568
- const c = classifyProbe(answer, null);
569
- snapshot = fromProbe({ ...c, planRaw: answer && answer.plan });
570
- } catch (err) {
571
- const c = classifyProbe(null, err);
572
- snapshot = fromProbe(c);
573
- }
574
- return snapshot;
575
- })();
576
- try {
577
- return await inflight;
578
- } finally {
579
- inflight = null;
580
- }
581
- }
582
-
583
- /** The cached verdict, synchronously. Never probes — this is the read the
584
- * drain clamp makes on the hot path, and an optimistic answer would be a
585
- * free fan-out. */
586
- function current() {
587
- return snapshot;
588
- }
589
-
590
- /**
591
- * THE GATE. Decide what a renderer's enqueue payload may do.
592
- *
593
- * `workers` is the number the renderer asked for (`null` = "no pick"), and
594
- * `fanout` is its explicit fan-me-out flag. Either one naming more than one
595
- * worker is a fan-out request, and a fan-out request from a non-Pro session
596
- * is REFUSED — not silently clamped, because the request came from a control
597
- * that is supposed to mean something, and a silent downgrade reads as a bug
598
- * while teaching the user nothing. (A fan-out already sitting in the queue
599
- * file — the CLI, a hand-edited queue.jsonl — is the other case: that one is
600
- * clamped to a single pass at drain time, see main.js's `canFanOut`.)
601
- */
602
- function gateFanout({ workers, fanout } = {}) {
603
- const requested = Number.isInteger(workers) ? workers : null;
604
- const wantsFanout = fanout === true || (requested != null && requested > FREE_MAX_WORKERS);
605
- if (!wantsFanout) return { ok: true, workers: requested, fanout: Boolean(fanout) };
606
- if (snapshot.entitled) return { ok: true, workers: requested, fanout: true };
607
- return {
608
- ok: false,
609
- // The code names WHICH wall, because the two have different fixes: an
610
- // empty bank is topped up, a free plan is upgraded. A caller that showed
611
- // the same message for both would be sending half its users to the wrong
612
- // checkout, which is the exact confusion this phase exists to remove.
613
- code: snapshot.wall === 'balance' ? 'fanout_requires_balance' : 'fanout_requires_pro',
614
- reason: snapshot.refusal || fanoutRefusal(),
615
- campaign: snapshot.campaign,
616
- upgradeUrl: snapshot.upgradeUrl,
617
- wall: snapshot.wall,
618
- pro: false,
619
- entitled: false,
620
- maxWorkers: FREE_MAX_WORKERS,
621
- entitlement: snapshot,
622
- };
623
- }
624
-
625
- return { refresh, current, gateFanout };
626
- }
627
-
628
- module.exports = {
629
- createEntitlement,
630
- normalizePlan,
631
- isPaidPlan,
632
- tierName,
633
- TIER_NAMES,
634
- classifyProbe,
635
- fanoutRefusal,
636
- balanceRefusal,
637
- ASK_EXAMPLE,
638
- balanceOf,
639
- FREE_MAX_WORKERS,
640
- PAID_PLANS,
641
- LEGACY_PLAN_ALIASES,
642
- CAMPAIGN,
643
- UPGRADE_URL,
644
- BALANCE_CAMPAIGN,
645
- BALANCE_URL,
646
- PRO_VALUE,
647
- FREE_HONESTY,
648
- BYOK_FEE_NOTE,
649
- byokFeeLine,
650
- DEFAULT_TTL_MS,
651
- // Phase 7: the host seam + the builder the CLI binds itself to.
652
- SIGNUP_SOURCES,
653
- DEFAULT_HOST,
654
- goUrl,
655
- promptUrlFor,
656
- };
3
+ "use strict";const FREE_MAX_WORKERS=1,PAID_PLANS=Object.freeze(["pro","unlimited"]),TIER_NAMES=Object.freeze({pro:"Pro",unlimited:"Unlimited"});function tierName(e){return TIER_NAMES[normalizePlan(e)]||null}const LEGACY_PLAN_ALIASES=Object.freeze({cloud:"pro",memory:"pro",s:"pro",heavy:"pro",team:"pro",starter:"free"}),PRO_VALUE="Pro puts the drain in the cloud, so the job keeps running after you close the laptop",FREE_HONESTY="Everything on your machine is free, permanently. The cloud drain is what you pay for.",BYOK_FEE_NOTE="Bring your own key is relayed through AEGIS, not dialled from here — the routing, prompt assembly and caching are billed as a flat handling fee, on top of your provider's own bill.";function ratePerMillion(e){return`$${(1e3*Number(e)).toFixed(2)}/M`}function byokFeeLine(e){const n=e&&"object"==typeof e?e:null;if(!n)return"";const r=Number(n.in_usd_per_1k),a=Number(n.out_usd_per_1k);if(!Number.isFinite(r)||!Number.isFinite(a))return"";if(!0===n.disabled)return"Bring your own key is relayed free on this deployment right now.";const o=`Bring your own key: relayed through AEGIS, handled at ${ratePerMillion(r)} in / ${ratePerMillion(a)} out, on top of your provider's bill — no fee at all on Unlimited, and no fee at all during the free 1-month Unlimited trial`;let l="";if(!0===n.require_account)l="and needs an AEGIS account key (X-AEGIS-Key) for the fee to be billed";else{const e=Number(n.anonymous_daily_calls);Number.isFinite(e)&&e>0&&(l=`with ${e} relayed turns/24h before an AEGIS account is required`)}return l?`${o} — ${l}.`:`${o}.`}const CAMPAIGN="queue_cap",UPGRADE_URL="https://aegiscloud.org/go/upgrade?s=desktop&c=queue_cap",BALANCE_CAMPAIGN="topup_bank",BALANCE_URL="https://aegiscloud.org/go/topup?s=desktop&c=topup_bank",SIGNUP_SOURCES=Object.freeze(["desktop","cli","mcp"]),DEFAULT_HOST="desktop";function goUrl(e,n,r){const a=String(null==r?"":r).trim().toLowerCase(),o=SIGNUP_SOURCES.includes(a)?a:DEFAULT_HOST;return`https://aegiscloud.org/go/${String(e).trim().toLowerCase()}?s=${o}&c=${n}`}function promptUrlFor(e){return{upgrade:goUrl("upgrade",CAMPAIGN,e),balance:goUrl("topup",BALANCE_CAMPAIGN,e)}}const DEFAULT_TTL_MS=6e4;function normalizePlan(e){const n=String(null==e?"":e).trim().toLowerCase();return n?PAID_PLANS.includes(n)?n:LEGACY_PLAN_ALIASES[n]||"free":"free"}const ASK_EXAMPLE="Do X autonomously";function isPaidPlan(e){return"free"!==normalizePlan(e)}function fanoutRefusal(){return`Fan-out needs Pro: ${PRO_VALUE}. Write it in the message: "${ASK_EXAMPLE}". Unlock: Pro, or any Token Bank balance. Free keeps the local drain on your own key — one worker per task, while this window is open.`}function balanceRefusal(){return`Your AEGIS balance is empty — ${PRO_VALUE}. Write it in the message: "${ASK_EXAMPLE}". Unlock: top up the Token Bank, or upgrade to Pro. Local drains stay free: one worker per task, on your own key, while this window is open.`}function balanceOf(e){if(!e||"object"!=typeof e)return{balanceKnown:!1,balanceMicros:null};let n=null;return null!=e.balance_micros?n=Number(e.balance_micros):null!=e.balance_eur&&(n=1e6*Number(e.balance_eur)),null!=n&&Number.isFinite(n)?{balanceKnown:!0,balanceMicros:Math.trunc(n)}:{balanceKnown:!1,balanceMicros:null}}function classifyProbe(e,n){if(n){const e=balanceOf(n&&n.data),r=n.status,a=n&&n.data||{},o=String(a.error||a.code||""),l=String(n&&n.message||"");return 400===r||401===r||/api_key required|invalid api key|unauthor/i.test(`${o} ${l}`)?{source:"no-key",plan:"free",degraded:!1,...e}:{source:"unreachable",plan:"free",degraded:!0,...e}}const r=e&&"object"==typeof e?e:{},a=balanceOf(r);if(!1===r.valid)return{source:"no-key",plan:"free",degraded:!1,...a};const o=null!=r.plan_effective&&String(r.plan_effective).trim()?r.plan_effective:r.plan,l=String(null==o?"":o).trim();return l?{source:"server-plan",plan:normalizePlan(l),degraded:!1,...a}:{source:"no-plan-field",plan:"free",degraded:!0,...a}}function createEntitlement({probe:e,now:n=Date.now,ttlMs:r=6e4,host:a=DEFAULT_HOST}={}){const o=promptUrlFor(a);let l=Object.freeze({pro:!1,entitled:!1,balanceKnown:!1,balanceMicros:null,balanceZero:!1,wall:"plan",plan:"free",tier:null,tierName:null,planRaw:null,maxWorkers:1,source:"unresolved",degraded:!0,checkedAt:null,campaign:CAMPAIGN,host:SIGNUP_SOURCES.includes(String(a).toLowerCase())?String(a).toLowerCase():DEFAULT_HOST,upgradeUrl:o.upgrade,refusal:null}),t=null;function i({source:e,plan:r,degraded:a,planRaw:l,balanceKnown:t=!1,balanceMicros:i=null}){const u=normalizePlan(r),c="free"!==u,s=t&&Number.isFinite(i)&&i>0,d=c||s,f=t&&!s,p=d?null:f?"balance":"plan";return Object.freeze({pro:c,entitled:d,balanceKnown:Boolean(t),balanceMicros:t?i:null,balanceZero:f,wall:p,plan:c?u:"free",tier:c?u:null,tierName:c&&TIER_NAMES[u]||null,planRaw:null==l?null:String(l),maxWorkers:d?null:1,source:e,degraded:Boolean(a),checkedAt:n(),campaign:"balance"===p?BALANCE_CAMPAIGN:CAMPAIGN,upgradeUrl:"balance"===p?o.balance:o.upgrade,refusal:d?null:"balance"===p?balanceRefusal():fanoutRefusal()})}return{refresh:async function({force:a=!1}={}){if(null!=l.checkedAt&&n()-l.checkedAt<r&&!a)return l;if(t)return t;t=(async()=>{if("function"!=typeof e)return l=i({source:"no-probe",plan:"free",degraded:!0}),l;try{const n=await e(),r=classifyProbe(n,null);l=i({...r,planRaw:n&&n.plan})}catch(e){const n=classifyProbe(null,e);l=i(n)}return l})();try{return await t}finally{t=null}},current:function(){return l},gateFanout:function({workers:e,fanout:n}={}){const r=Number.isInteger(e)?e:null;return!0===n||null!=r&&r>1?l.entitled?{ok:!0,workers:r,fanout:!0}:{ok:!1,code:"balance"===l.wall?"fanout_requires_balance":"fanout_requires_pro",reason:l.refusal||fanoutRefusal(),campaign:l.campaign,upgradeUrl:l.upgradeUrl,wall:l.wall,pro:!1,entitled:!1,maxWorkers:1,entitlement:l}:{ok:!0,workers:r,fanout:Boolean(n)}}}}module.exports={createEntitlement:createEntitlement,normalizePlan:normalizePlan,isPaidPlan:isPaidPlan,tierName:tierName,TIER_NAMES:TIER_NAMES,classifyProbe:classifyProbe,fanoutRefusal:fanoutRefusal,balanceRefusal:balanceRefusal,ASK_EXAMPLE:ASK_EXAMPLE,balanceOf:balanceOf,FREE_MAX_WORKERS:1,PAID_PLANS:PAID_PLANS,LEGACY_PLAN_ALIASES:LEGACY_PLAN_ALIASES,CAMPAIGN:CAMPAIGN,UPGRADE_URL:UPGRADE_URL,BALANCE_CAMPAIGN:BALANCE_CAMPAIGN,BALANCE_URL:BALANCE_URL,PRO_VALUE:PRO_VALUE,FREE_HONESTY:FREE_HONESTY,BYOK_FEE_NOTE:BYOK_FEE_NOTE,byokFeeLine:byokFeeLine,DEFAULT_TTL_MS:6e4,SIGNUP_SOURCES:SIGNUP_SOURCES,DEFAULT_HOST:DEFAULT_HOST,goUrl:goUrl,promptUrlFor:promptUrlFor};