aegis-desktop 0.8.32 → 0.8.34

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/main.js CHANGED
@@ -1748,6 +1748,18 @@ function registerModelIpc(ipcMain, engine, sessionsDir, aegis, onReplyFinished,
1748
1748
  if (name === 'chat') {
1749
1749
  ipcMain.handle(`${MODEL_PREFIX}${name}`, (event, payload) => {
1750
1750
  const opts = { ...(payload || {}) };
1751
+ // The rung every renderer sends is low|medium|high; this is the one
1752
+ // place a stale window can still hand us the retired `auto` (a page
1753
+ // loaded before the select lost that row, or a renderer that forgot to
1754
+ // resolve), and a falsy effort is NOT "the engine's default" — the
1755
+ // shared engine states the field conditionally, so an absent value
1756
+ // means "state no opinion" and the server sizes the turn itself. So it
1757
+ // is normalised here rather than passed through: an unknown value
1758
+ // becomes `high`, the same floor the engine and the CLI apply, and no
1759
+ // turn can reach the wire with an effort nobody chose.
1760
+ const effortRung =
1761
+ opts.effort === 'low' || opts.effort === 'medium' ? opts.effort : 'high';
1762
+ opts.effort = effortRung;
1751
1763
  const sender = event && event.sender;
1752
1764
  const forward = (chunk) => {
1753
1765
  if (
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "aegis-desktop",
3
3
  "productName": "AEGIS Desktop",
4
- "version": "0.8.32",
4
+ "version": "0.8.34",
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/renderer/app.js CHANGED
@@ -490,6 +490,22 @@ function rollFor(sessionId) {
490
490
  * per-turn figure beside the session total (the CLI shows both: the meta row's
491
491
  * `1.5k tok` and the status bar's rolling total).
492
492
  */
493
+ /**
494
+ * The account's entitlement snapshot, as the main process last reported it.
495
+ *
496
+ * One reader for the money rule, so the rows that print a charge cannot disagree
497
+ * with each other about which tier they are on — `feeWaived` in usage.js holds
498
+ * the rule itself. `state` is read defensively because this runs on paths that
499
+ * can fire before the first entitlement probe has answered: with no snapshot yet
500
+ * there is no proven tier, and `feeWaived(null)` says "not waived", which is the
501
+ * only safe reading of an unproven account (it is the same reason
502
+ * `entitlement.js` starts at the free boundary rather than at Pro).
503
+ */
504
+ function entitlement() {
505
+ const s = typeof state !== 'undefined' ? state : null;
506
+ return s && s.entitlement ? s.entitlement : null;
507
+ }
508
+
493
509
  function foldRoll(sessionId, usage, opts = {}) {
494
510
  const id = sessionId || '';
495
511
  const next = rollTurn(rollFor(id), usage, opts);
@@ -500,7 +516,7 @@ function foldRoll(sessionId, usage, opts = {}) {
500
516
 
501
517
  /**
502
518
  * Paint the topbar meter. Hidden while a session has accounted for nothing —
503
- * an unused thread must not display a `0 tok` it never measured — and shown
519
+ * an unused thread must not display a `0 tokens` it never measured — and shown
504
520
  * the moment a turn is folded in.
505
521
  *
506
522
  * `live`, when given, previews the in-flight turn on top of the session's
@@ -517,6 +533,23 @@ function foldRoll(sessionId, usage, opts = {}) {
517
533
  */
518
534
  function renderRollMeter(sessionId, live) {
519
535
  const el = els.sessionMeter;
536
+
537
+ // The bubble's live figure is painted FIRST, before any of the topbar's own
538
+ // gates can return early. It belongs to the turn in flight — it is the thing
539
+ // the reader is watching while the AI works — and it used to sit at the
540
+ // BOTTOM of this function, behind `if (currentSessionId !== sessionId)
541
+ // return` and behind `if (!el) return`. So a turn whose sessionId did not
542
+ // match the current view, or any window where the topbar element was
543
+ // missing, streamed to completion with a dead counter in the bubble even
544
+ // though the estimate was being recomputed here every frame. That is the
545
+ // "tokens don't work while the AI is working" report.
546
+ const liveCount = live ? liveTokenFig(live) : '';
547
+ const tokensEl = live ? liveTokensEl() : null;
548
+ if (tokensEl) {
549
+ tokensEl.textContent = liveCount;
550
+ tokensEl.hidden = !liveCount;
551
+ }
552
+
520
553
  if (!el) return;
521
554
  if (currentSessionId !== sessionId) { el.hidden = true; return; }
522
555
  let roll = rollsBySession.get(sessionId || '');
@@ -534,7 +567,115 @@ function renderRollMeter(sessionId, live) {
534
567
  const line = roll ? fmtRoll(roll) : '';
535
568
  el.textContent = line;
536
569
  el.hidden = !line;
570
+ // A live figure is an estimate of a turn still in flight; the class is what
571
+ // says so, so a reader is never left comparing a moving number with a settled
572
+ // one and concluding the meter lies.
573
+ el.classList.toggle('live', !!live && !!line);
537
574
  if (line) el.title = 'This session, counted the way the CLI counts it — every turn rolled into one running total';
575
+ return roll;
576
+ }
577
+
578
+ /**
579
+ * The bubble's live figure — `↓N tokens`, the CLI's live spinner tail — for a
580
+ * turn still in flight, or '' when there is nothing measured yet.
581
+ *
582
+ * OUTPUT only (the answer plus the thinking trace it was billed with), never
583
+ * input + output: this figure has to be the same quantity the CLI's spinner
584
+ * reports (`cli/src/chatflow.js`: `↓${fmtTokens(state.streamed)} tokens`, fed
585
+ * `streamedCells() / 4`) and the same one aegiscodex-dev climbs with while it
586
+ * works. Adding the prompt in made this surface count a second, much larger
587
+ * quantity that opened high and barely moved.
588
+ *
589
+ * Two ways to fail closed on the same number, because a dead counter is worse
590
+ * than a roughly right one — it tells the reader the turn has stalled:
591
+ *
592
+ * 1. `estimatedBuckets` is the authority. If it is unavailable or its shape
593
+ * is not what this call site expects, the throw used to escape into the
594
+ * painter every frame, so the figure never appeared at all while the
595
+ * answer streamed normally beside it. Caught.
596
+ * 2. Below that, the estimate is taken from the stream itself — the reply's
597
+ * own text at the 4:1 chars:tokens ratio of the same heuristic, i.e. the
598
+ * quantity the CLI's tail is built on. So there is a number to show from
599
+ * the first chunk regardless of what the model reports or bills.
600
+ */
601
+ function liveTokenFig(live) {
602
+ const reply = typeof live.reply === 'string' ? live.reply : '';
603
+ const reasoning = typeof live.reasoning === 'string' ? live.reasoning : '';
604
+ // A turn in flight that has produced nothing YET. Its figure is zero, and
605
+ // zero is what the other measured surface shows at this moment: the CLI's
606
+ // spinner tail counts the answer's deltas and the thinking trace's, so it too
607
+ // reads `↓0 tokens` only until the turn's first billed token.
608
+ //
609
+ // This is the ONE place the figure deliberately differs from the reference
610
+ // engine's, and the difference is the source of the number rather than its
611
+ // format: `aegiscodex-dev/src/main.js` climbs on ELAPSED TIME
612
+ // (`Math.round(secs * 21 + 7)` with `secs = Math.max(1, …)`, so its floor is
613
+ // 28 and it never prints 0) while this host reports what the turn actually
614
+ // produced. Matching the reference frame-for-frame would mean printing a
615
+ // count no provider ever sent, which this repo refuses everywhere else
616
+ // (`fmtRoll` will not put `0 tokens` on a meter that never took a reading;
617
+ // `fmtUsageSplit` omits an unmeasured half rather than inventing one). The
618
+ // measured zero is honest and self-correcting within the first token.
619
+ //
620
+ // Returning '' here was the start-of-turn lag — the bubble opened with no
621
+ // figure at all and popped one in seconds later, once the reply had grown past
622
+ // a threshold. Safe because the caller only passes `live` for a turn still
623
+ // running: this is a statement about that turn, not a fabricated measurement
624
+ // of a finished one.
625
+ if (!reply && !reasoning) return '↓0 tokens';
626
+ let output = 0;
627
+ try {
628
+ const est = estimatedBuckets(live.prompt, reply, reasoning);
629
+ if (est && typeof est.output === 'number' && est.output > 0) output = est.output;
630
+ } catch (err) {
631
+ /* the stream-text estimate below is the whole point of the fallback */
632
+ }
633
+ if (!output) {
634
+ // Counted, never spread. `[...part].length` is the readable spelling of
635
+ // "how many codepoints", and it is the one this fallback started with — but
636
+ // this function runs inside the painter, so on every frame of every turn
637
+ // that takes this path it built a full codepoint array of the whole reply
638
+ // and threw it away: 100k entries per frame, ~60 frames a second, on the
639
+ // largest replies. The codepoint walk below yields the identical count
640
+ // without the array (a surrogate pair is one codepoint, two UTF-16 units),
641
+ // so the figure is unchanged and the per-frame allocation is gone.
642
+ let cells = 0;
643
+ for (const part of [reply, reasoning]) {
644
+ let i = 0;
645
+ while (i < part.length) {
646
+ const cp = part.codePointAt(i);
647
+ i += cp > 0xffff ? 2 : 1;
648
+ cells += 1;
649
+ }
650
+ }
651
+ output = Math.ceil(cells / 4);
652
+ }
653
+ if (!output) return '';
654
+ return `↓${Math.round(output)} tokens`;
655
+ }
656
+
657
+ /**
658
+ * The live figure's element, CREATED ON DEMAND when the row does not have one.
659
+ *
660
+ * `setBusy` appends it, but not every working row goes through `setBusy`: a
661
+ * turn opened by `ensurePendingRow` (tool activity arriving before any text)
662
+ * had no `.typing-tokens` to find, so the meter had nowhere to paint. Built
663
+ * here, a sibling of `.body` for the same reason as there — `paintStream`
664
+ * assigns the body's `textContent` wholesale and would delete a child.
665
+ *
666
+ * Painted by `renderRollMeter`, the same authority that writes the topbar
667
+ * meter, so the two can never disagree.
668
+ */
669
+ function liveTokensEl() {
670
+ if (!pendingEl) return null;
671
+ let el = pendingEl.querySelector('.typing-tokens');
672
+ if (!el) {
673
+ el = document.createElement('span');
674
+ el.className = 'typing-tokens';
675
+ el.hidden = true;
676
+ pendingEl.appendChild(el);
677
+ }
678
+ return el;
538
679
  }
539
680
 
540
681
  /**
@@ -833,8 +974,8 @@ function currentBudgetModel() {
833
974
  */
834
975
  function budgetNote(cls, model) {
835
976
  const chosen = effortFor();
836
- const rung = chosen || 'high';
837
- const rungText = chosen || 'auto → high';
977
+ const rung = chosen;
978
+ const rungText = chosen;
838
979
  if (cls === AUTONOMOUS_CLASS) {
839
980
  // aegis1 services/pool_brain.py pass_budgets — the total across the worker
840
981
  // fan-out + synthesis pass, which is why it is not the local table.
@@ -864,16 +1005,20 @@ function updateBudgetControls(cls, model) {
864
1005
  }
865
1006
 
866
1007
  /**
867
- * The effort to send, or undefined for "no rung pinned".
1008
+ * The effort rung to send: always 'low' | 'medium' | 'high'.
868
1009
  *
869
- * Sent for EVERY class and model, not just the pooled one: with no max-tokens
870
- * control there is nothing else that sizes a call, and `auto` means "no rung
871
- * pinned" — the engine's high default — rather than the old silent
872
- * fall-through to the top rung.
1010
+ * Sent for EVERY class and model. The three-rung select has no "auto" row any
1011
+ * more, because "no rung pinned" is not a fourth level — it was the DEFAULT,
1012
+ * so a fresh window sent no effort at all and every model ran on whatever the
1013
+ * far end happened to do, which is what "the levels do nothing" was.
1014
+ *
1015
+ * A missing select (or one holding a stale `auto` persisted before this
1016
+ * change) resolves to `high`, the same default the engine and the CLI apply,
1017
+ * so an unreadable control can never silently mean "smallest".
873
1018
  */
874
1019
  function effortFor() {
875
1020
  const value = els.effortSelect && els.effortSelect.value;
876
- return value && value !== 'auto' ? value : undefined;
1021
+ return value === 'low' || value === 'medium' ? value : 'high';
877
1022
  }
878
1023
 
879
1024
  // ---------------------------------------------------------------- UI helpers
@@ -2121,7 +2266,7 @@ function memoryCard(e) {
2121
2266
  if (rel) bits.push(rel);
2122
2267
  else if (e.timestamp) bits.push(e.timestamp);
2123
2268
  if (e.session) bits.push(`session ${e.session}`);
2124
- if (e.tokenCount != null) bits.push(`${e.tokenCount} tok`);
2269
+ if (e.tokenCount != null) bits.push(`${e.tokenCount} tokens`);
2125
2270
  meta.textContent = bits.join(' · ');
2126
2271
  head.appendChild(meta);
2127
2272
  li.appendChild(head);
@@ -3042,10 +3187,15 @@ async function spawnPath(card, spec) {
3042
3187
  // A lane card is a dispatch the same way a turn is, and it was the
3043
3188
  // un-costed half of the 3x story: it reported tokens with no charge beside
3044
3189
  // them, so the extra calls were the least visible thing on the screen.
3190
+ // The card is also the place the wire's TOTAL was most misleading: a lane
3191
+ // re-sends the request plus a path hint, so its input half is nearly the
3192
+ // whole count and the card read as though the path had produced it all.
3193
+ // The figure is the output, like every other per-dispatch surface here.
3045
3194
  const flow = turnAccounting(data && data.usage, spec.model, {
3046
3195
  costUsd: data && typeof data.costUsd === 'number' ? data.costUsd : undefined,
3047
3196
  });
3048
- if (flow.tokens != null) bits.push(`${flow.tokens} tokens`);
3197
+ const flowProduced = displayTokens(flow);
3198
+ if (flowProduced != null) bits.push(`${flowProduced} tokens`);
3049
3199
  if (flow.cost != null) bits.push(fmtCost(flow.cost, flow.real));
3050
3200
  // …and roll it into the session, which is the half that was missing. The
3051
3201
  // card shows this ONE dispatch; a session total that skipped it would be
@@ -3164,6 +3314,19 @@ function setBusy(busy, { cancellable } = {}) {
3164
3314
  dots.className = 'typing-dots';
3165
3315
  for (let i = 0; i < 3; i++) dots.appendChild(document.createElement('span'));
3166
3316
  pendingEl.querySelector('.body').appendChild(dots);
3317
+ // The live token figure, rendered by `renderRollMeter` — the same authority
3318
+ // that writes the topbar meter, from the same roll, so the two can never
3319
+ // disagree. A SIBLING of `.body`, not a child: `paintStream` assigns the
3320
+ // body's `textContent` on the first chunk, which deletes everything inside
3321
+ // it — the dots go with it deliberately, and this must not.
3322
+ //
3323
+ // Starts hidden and empty. A fresh turn has measured nothing yet, and the
3324
+ // `0 tokens` that would fill this line is the exact fabrication `fmtRoll`
3325
+ // refuses to print.
3326
+ const tokens = document.createElement('span');
3327
+ tokens.className = 'typing-tokens';
3328
+ tokens.hidden = true;
3329
+ pendingEl.appendChild(tokens);
3167
3330
  if (cancellable) {
3168
3331
  const cancelBtn = document.createElement('button');
3169
3332
  cancelBtn.type = 'button';
@@ -4283,8 +4446,10 @@ async function send() {
4283
4446
  // Sent whether or not the fan-out is ticked: the fan-out is enabled by the
4284
4447
  // model id the pooled class sends, so a turn that never entered autonomous
4285
4448
  // mode still ran pooled and had no way to say how big it should be — and a
4286
- // DeepSeek reasoning model needs it to size its CoT. `undefined` = "auto" =
4287
- // the server (or the engine's rung default) infers it.
4449
+ // DeepSeek reasoning model needs it to size its CoT. It is always a rung now
4450
+ // (effortFor floors to `high`), never "state no opinion", because a pooled
4451
+ // call that states nothing is sized by the server's own default instead of
4452
+ // the level shown in the select.
4288
4453
  const effort = effortFor();
4289
4454
  const maxTokens = budgetFor(model, undefined, effort);
4290
4455
  const workers = autonomous ? parseInt(els.autonomousWorkers.value, 10) || undefined : undefined;
@@ -4310,6 +4475,13 @@ async function send() {
4310
4475
  threadMessages.push({ role: 'user', content: prompt });
4311
4476
 
4312
4477
  setBusy(true, { cancellable: true });
4478
+ // The counter exists from the frame the turn does, not from its first chunk.
4479
+ // Both live surfaces this one mirrors have a figure on screen from the start
4480
+ // (`cli/src/chatflow.js`'s spinner tail reads `↓0 tokens` on its first render;
4481
+ // the reference engine's count climbs from t=0), and this bubble used to open
4482
+ // with an empty line and pop a number in a second later. Painting here also
4483
+ // means the reader never sees the row without the figure it will carry.
4484
+ if (pendingEl) renderRollMeter(sessionId, { prompt, reply: '', reasoning: '' });
4313
4485
  // Sending is an explicit act, so it always returns the view to the tail.
4314
4486
  // This is the single moment the auto-scroll overrides the reader's scroll.
4315
4487
  stickToBottom({ force: true });
@@ -4328,7 +4500,16 @@ async function send() {
4328
4500
  // construction (declared here, not at module scope), so a new turn starts its
4329
4501
  // preview fresh — see `liveMeterDue` for why the meter is not recomputed on
4330
4502
  // every frame.
4331
- let meterChars = 0;
4503
+ //
4504
+ // Null, NOT 0: unset means "nothing painted for this turn yet", which
4505
+ // `liveMeterDue` reads as due on the first chunk. Seeded at 0 it read as
4506
+ // "measured an empty stream", so the turn's first paint waited out a full
4507
+ // step of the rule — a quarter kilobyte of text, ~64 tokens, seconds into a
4508
+ // slow turn — and the bubble sat with no figure at all and then jumped to it.
4509
+ // The CLI's spinner tail has no such dead zone (it prints `↓0 tokens` from
4510
+ // the first frame), so the two surfaces disagreed about when a count begins
4511
+ // existing.
4512
+ let meterChars = null;
4332
4513
 
4333
4514
  // Text arrives in dozens of small chunks per second; painting each one is
4334
4515
  // what made the window feel locked up. One paint per frame, off the latest
@@ -4420,7 +4601,7 @@ async function send() {
4420
4601
  if (data && data.model) bits.push(`model: ${data.model}`);
4421
4602
  else if (model) bits.push(`model: ${model}`);
4422
4603
  bits.push(classLabel(cls));
4423
- if (autonomous) bits.push(`autonomous (${effort || 'auto'}, ${workers || 'auto'}w)`);
4604
+ if (autonomous) bits.push(`autonomous (${effort}, ${workers || 'auto'}w)`);
4424
4605
  // The budget rung is worth showing even without the fan-out: on the pooled
4425
4606
  // class it is what sized the call, and a user cannot tell a 16k turn from a
4426
4607
  // 64k one by looking at the answer.
@@ -4431,26 +4612,42 @@ async function send() {
4431
4612
  // alone left this surface with no comparable meter at all — the desktop
4432
4613
  // and the CLI run the same engine, so a gap between them had to be
4433
4614
  // measured on the same prompt, and one side was not measuring.
4615
+ //
4616
+ // The figure is the OUTPUT (`displayTokens`), not the wire's total: the
4617
+ // total is input + output, and on a cached turn the input half is the
4618
+ // larger one, so `tokens: 3,145` on a turn that wrote 145 tokens read as a
4619
+ // model emitting twenty times what it did. Same rule as the CLI's
4620
+ // `fmtsUsageSplit`, and the same one `fmtRoll` now uses below — the two
4621
+ // figures on this row are the same quantity for the same reason.
4434
4622
  const turn = turnAccounting(data && data.usage, model, {
4435
4623
  costUsd: data && typeof data.costUsd === 'number' ? data.costUsd : undefined,
4436
4624
  });
4437
- if (turn.tokens != null) bits.push(`tokens: ${turn.tokens}`);
4625
+ const produced = displayTokens(turn);
4626
+ if (produced != null) bits.push(`tokens: ${produced}`);
4438
4627
  // A turn the wire did not report on still gets a figure — the same text
4439
4628
  // estimate that goes into the session total, marked `~` so an inferred
4440
4629
  // count is never read as a reported one. Printing nothing here while the
4441
4630
  // session total moved was the other half of "the counter looks dead".
4631
+ // Output only, like the reported figure beside it: `est.input` is the
4632
+ // prompt, and summing the two is the very reading this change removes.
4442
4633
  else {
4443
4634
  const est = estimatedBuckets(prompt, text, text === reasoningText ? '' : reasoningText);
4444
- if (est) bits.push(`~${est.input + est.output} tokens`);
4635
+ if (est && est.output > 0) bits.push(`~${est.output} tokens`);
4445
4636
  }
4446
- if (turn.cost != null) bits.push(fmtCost(turn.cost, turn.real));
4637
+ // No money on a tier AEGIS bills no fee to, and none from a snapshot that
4638
+ // has not proven a fee is owed — see `feeBillable` in usage.js, which is the
4639
+ // CLI's exact rule. Not a `$0.0000` in its place: a zero states a price, and
4640
+ // this account has no proven price on this turn. The token figure above is
4641
+ // unaffected, so the row keeps its meter either way.
4642
+ const billable = feeBillable(entitlement());
4643
+ if (turn.cost != null && billable) bits.push(fmtCost(turn.cost, turn.real));
4447
4644
  // …and on the byok class, whose money that figure is. The relay never
4448
4645
  // returns a settled charge (nothing on that route emits one), so this is
4449
4646
  // always the rate table's estimate of the caller's OWN provider bill — and
4450
4647
  // AEGIS additionally collects a handling fee on the same turn, which this
4451
4648
  // response cannot price. One bit, and the number stops reading as the whole
4452
4649
  // cost of a BYOK turn.
4453
- if (cls === 'byok' && turn.cost != null) {
4650
+ if (cls === 'byok' && turn.cost != null && billable) {
4454
4651
  bits.push('your provider bill (est.) + AEGIS handling fee');
4455
4652
  }
4456
4653
  // …AND the running session total beside it, which is the number the CLI
@@ -4473,7 +4670,7 @@ async function send() {
4473
4670
  // estimated twice if a turn ever collapses the two into one.
4474
4671
  reasoning: text === reasoningText ? '' : reasoningText,
4475
4672
  });
4476
- const rollLine = fmtRoll(roll);
4673
+ const rollLine = fmtRoll(roll, { omitMoney: !billable });
4477
4674
  if (rollLine) bits.push(`session: ${rollLine}`);
4478
4675
  addMessage('assistant', text, bits.join(' · ') || undefined, sessionId, toolLog);
4479
4676
 
@@ -4548,10 +4745,14 @@ async function send() {
4548
4745
  });
4549
4746
  const stopBits = ['stopped by you'];
4550
4747
  if (salvage.kind === 'reasoning-only') stopBits.push('reasoning only');
4551
- if (turn.tokens != null) stopBits.push(`tokens: ${turn.tokens}`);
4748
+ // The same figure and the same rule as the completed row above, so a
4749
+ // stopped turn and a finished one report one quantity. `displayTokens`
4750
+ // rather than `turn.tokens` — see the note there.
4751
+ const stopProduced = displayTokens(turn);
4752
+ if (stopProduced != null) stopBits.push(`tokens: ${stopProduced}`);
4552
4753
  else {
4553
4754
  const est = estimatedBuckets(prompt, text, salvageReasoning);
4554
- if (est) stopBits.push(`~${est.input + est.output} tokens`);
4755
+ if (est && est.output > 0) stopBits.push(`~${est.output} tokens`);
4555
4756
  }
4556
4757
  const stopRollLine = fmtRoll(roll);
4557
4758
  if (stopRollLine) stopBits.push(`session: ${stopRollLine}`);
@@ -4650,17 +4851,21 @@ async function init() {
4650
4851
  updateAutonomousControlsVisibility();
4651
4852
  });
4652
4853
 
4653
- // The one budget control: which rung sizes the call, or "auto" to let the
4654
- // server infer it from the ask (aegis1 services/pool_brain.py
4655
- // parse_brain_request). Applies to EVERY class now — with the max-tokens
4656
- // dropdown gone there is nothing else that sizes a call — so it is no longer
4657
- // stored under the autonomous-mode namespace; an install predating the rename
4658
- // is read from the old key once, and a stored rung this build's list no
4659
- // longer offers falls through to the markup's default rather than assigning
4660
- // an option that does not exist.
4854
+ // The one budget control: which rung sizes the call (low | medium | high).
4855
+ // Applies to EVERY class now — with the max-tokens dropdown gone there is
4856
+ // nothing else that sizes a call — so it is no longer stored under the
4857
+ // autonomous-mode namespace; an install predating the rename is read from
4858
+ // the old key once, and a stored rung this build's list no longer offers
4859
+ // falls through to the markup's default rather than assigning an option that
4860
+ // does not exist. That fall-through is load-bearing for the retired `auto`
4861
+ // value: a window that had `auto` persisted restores `high`, and the stale
4862
+ // string is rewritten so the next launch reads a rung that is really offered.
4661
4863
  const savedEffort = localStorage.getItem(EFFORT_KEY) || localStorage.getItem(LEGACY_EFFORT_KEY);
4662
- if (savedEffort && Array.from(els.effortSelect.options).some((o) => o.value === savedEffort)) {
4864
+ const offered = Array.from(els.effortSelect.options).some((o) => o.value === savedEffort);
4865
+ if (savedEffort && offered) {
4663
4866
  els.effortSelect.value = savedEffort;
4867
+ } else if (savedEffort) {
4868
+ localStorage.setItem(EFFORT_KEY, els.effortSelect.value);
4664
4869
  }
4665
4870
  els.effortSelect.addEventListener('change', () => {
4666
4871
  localStorage.setItem(EFFORT_KEY, els.effortSelect.value);
@@ -25,7 +25,7 @@
25
25
  <!-- The rolling session token total, counted the way the CLI counts it:
26
26
  every finished turn folded into one running number rather than a
27
27
  per-turn count that resets. Hidden until a turn has been accounted
28
- for, so an untouched thread never shows a `0 tok` it never measured. -->
28
+ for, so an untouched thread never shows a `0 tokens` it never measured. -->
29
29
  <div class="session-meter" id="session-meter" hidden></div>
30
30
  <div class="spacer"></div>
31
31
  <button type="button" id="new-chat" class="ghost-btn">New chat</button>
@@ -138,8 +138,7 @@
138
138
  <select id="effort-select">
139
139
  <option value="low">low</option>
140
140
  <option value="medium">medium</option>
141
- <option value="high">high</option>
142
- <option value="auto" selected>auto</option>
141
+ <option value="high" selected>high</option>
143
142
  </select>
144
143
  </div>
145
144
 
@@ -207,8 +207,23 @@ function isPlaceholderContent(content) {
207
207
  );
208
208
  }
209
209
 
210
- /** The smallest stream growth, in characters, that earns a live-meter recompute. */
211
- const LIVE_METER_MIN_GROWTH = 256;
210
+ /** The smallest stream growth, in characters, that earns a live-meter recompute.
211
+ *
212
+ * It was 256, and that number was the start-of-turn lag this rule exists to
213
+ * avoid: the counter's first paint required a quarter kilobyte of text — ~64
214
+ * tokens, several seconds into a slow turn — so the bubble opened with no
215
+ * figure at all and then jumped to it, while the CLI's own tail
216
+ * (`cli/src/chatflow.js`'s spinner, `↓${fmtTokens(state.streamed)}`) and the
217
+ * reference engine both have a count climbing from the first frame. 32 chars
218
+ * (~8 tokens) is a step the eye reads as continuous at the start of a reply,
219
+ * which is the whole point of a live count.
220
+ *
221
+ * The cost bound is NOT this number — it is the proportional term below. At
222
+ * 100k chars the step is 1/32 of the stream (3125), so lowering this floor
223
+ * leaves the long-reply recompute count unchanged; it only buys paints in the
224
+ * first ~1k characters, where each estimate is over a string too short to
225
+ * matter. */
226
+ const LIVE_METER_MIN_GROWTH = 32;
212
227
 
213
228
  /** …and the fraction of the whole stream that must have arrived since the last
214
229
  * one: a recompute comes due once the stream has grown by 1/this. */
@@ -241,6 +256,13 @@ const LIVE_METER_GROWTH_FRACTION = 32;
241
256
  */
242
257
  function liveMeterDue(measured, streamChars) {
243
258
  const now = Number(streamChars) || 0;
259
+ // Nothing has been painted yet this turn. The cursor is per-turn and starts
260
+ // UNSET (see app.js `let meterChars = null`), and an unset cursor must not be
261
+ // read as "measured an empty stream" — that made the first paint wait out the
262
+ // whole step below, which is the start-of-turn dead zone. The first text to
263
+ // arrive paints, so the figure is on screen from the first chunk rather than
264
+ // from the first 32nd of the reply.
265
+ if (measured === null || measured === undefined) return now > 0;
244
266
  const grown = now - (Number(measured) || 0);
245
267
  if (grown <= 0) return false;
246
268
  const step = Math.max(LIVE_METER_MIN_GROWTH, Math.floor(now / LIVE_METER_GROWTH_FRACTION));
@@ -148,6 +148,15 @@ body {
148
148
  display: none;
149
149
  }
150
150
 
151
+ /* A total that is still climbing has to look different from one that has
152
+ stopped, or "is it still working?" is answered by watching for the absence
153
+ of change. Set by `renderRollMeter` from the same `live` flag that makes the
154
+ figure an estimate in the first place. */
155
+ .session-meter.live {
156
+ color: var(--accent);
157
+ border-color: var(--accent);
158
+ }
159
+
151
160
  .spacer {
152
161
  flex: 1;
153
162
  }
@@ -876,21 +885,31 @@ body.memory-open {
876
885
  text-align: center;
877
886
  }
878
887
 
888
+ /* Dimmed by COLOR, not by `opacity`. The working indicator lives inside this
889
+ bubble, and `opacity` is inherited by everything painted under it — so the
890
+ 0.6 that was meant to fade placeholder prose also faded the dots, which
891
+ already sat on `--text3` (#3a4a60). Two dims multiplied on the one element
892
+ whose entire job is to be seen: the composite was about #232c3a on a dark
893
+ surface, i.e. the spinner was effectively invisible (reported as "too
894
+ transparent"). A color dim cannot compound like that. */
879
895
  .msg.pending {
880
- opacity: 0.6;
896
+ color: var(--text2);
881
897
  }
882
898
 
883
899
  .typing-dots {
884
900
  display: inline-flex;
885
901
  align-items: center;
886
- gap: 5px;
902
+ gap: 6px;
887
903
  }
888
904
 
905
+ /* 8px and `--text2` at rest, not 6px and `--text3`: the pulse should read as
906
+ motion on top of a visible indicator, not as the indicator briefly
907
+ appearing out of nothing twice a second. */
889
908
  .typing-dots span {
890
- width: 6px;
891
- height: 6px;
909
+ width: 8px;
910
+ height: 8px;
892
911
  border-radius: 50%;
893
- background: var(--text3);
912
+ background: var(--text2);
894
913
  animation: typing-glow 1.2s ease-in-out infinite;
895
914
  }
896
915
 
@@ -910,15 +929,38 @@ body.memory-open {
910
929
  0%,
911
930
  60%,
912
931
  100% {
913
- background: var(--text3);
932
+ background: var(--text2);
914
933
  box-shadow: none;
934
+ transform: scale(1);
915
935
  }
916
936
  30% {
917
937
  background: var(--accent);
918
- box-shadow: 0 0 6px 1px var(--accent);
938
+ box-shadow: 0 0 9px 2px var(--accent);
939
+ transform: scale(1.25);
919
940
  }
920
941
  }
921
942
 
943
+ /* The live token figure, beside the spinner rather than only in the topbar.
944
+ Reported as "I don't see the tokens when AI is working": the rolling total
945
+ was already live, but it was 40ch away in the topbar and muted to `--text2`,
946
+ so it read as a settled readout. Here it is where the reader is already
947
+ looking, in the accent the pulse uses. */
948
+ .typing-tokens {
949
+ display: block;
950
+ margin-top: 8px;
951
+ font-family: var(--font-mono);
952
+ font-size: 11px;
953
+ color: var(--accent);
954
+ white-space: nowrap;
955
+ }
956
+
957
+ /* Same reason `.session-meter[hidden]` exists: the rule above sets a display,
958
+ which would otherwise win over the `hidden` attribute and leave an empty
959
+ accent line under every fresh turn. */
960
+ .typing-tokens[hidden] {
961
+ display: none;
962
+ }
963
+
922
964
  .who {
923
965
  font-size: 11px;
924
966
  font-weight: 600;
package/renderer/usage.js CHANGED
@@ -609,14 +609,23 @@ function usageCost(usage, model, opts = {}) {
609
609
  * @param {object|null|undefined} usage the response's `usage` object
610
610
  * @param {string} model the model id that answered
611
611
  * @param {{costUsd?: number}} [opts] the server-settled charge, if any
612
- * @returns {{tokens: number|null, cost: number|null, real: boolean, estimated: boolean}}
612
+ * @returns {{tokens: number|null, output: number|null, cost: number|null,
613
+ * real: boolean, estimated: boolean}}
613
614
  * `real` is true only when `cost` is the settled charge. `cost` is
614
615
  * `null` when nothing was reported and no model was named — an
615
616
  * unpriced turn must render as nothing, never as $0.0000.
617
+ * `tokens` is the wire's TOTAL (input + output) and is what the ledger
618
+ * and the rolling fold need; `output` is the half a person reads, and
619
+ * it is what every per-turn surface shows (see `displayTokens`).
616
620
  */
617
621
  function turnAccounting(usage, model, opts = {}) {
618
622
  const tokens = usageTokens(usage);
619
623
  const u = usage && typeof usage === 'object' ? usage : {};
624
+ // The half the turn PRODUCED. Kept beside the total rather than replacing it:
625
+ // `tokens` still has to fold the ledger, and a rebuild from stored buckets
626
+ // must agree with the live fold to the unit.
627
+ const output =
628
+ usage && typeof usage === 'object' ? usageBuckets(usage).output : null;
620
629
  // The settled charge can arrive either on the response or folded into the
621
630
  // usage object — the upstream engine does the latter
622
631
  // (`{ ...result.usage, costUsd: result.costUsd }`), so both are accepted.
@@ -628,15 +637,23 @@ function turnAccounting(usage, model, opts = {}) {
628
637
  // because a settled charge is a fact and a table match is a guess — and the
629
638
  // pool's bill carries a margin and a prompt-cache discount this table
630
639
  // cannot see, so the two must never be averaged or confused.
631
- return { tokens, cost: settled, real: true, estimated: false, basis: 'settled', priced: true };
640
+ return {
641
+ tokens, output, cost: settled, real: true, estimated: false, basis: 'settled', priced: true,
642
+ };
643
+ }
644
+ if (tokens == null) {
645
+ return {
646
+ tokens, output, cost: null, real: false, estimated: false, basis: null, priced: false,
647
+ };
632
648
  }
633
- if (tokens == null) return { tokens, cost: null, real: false, estimated: false, basis: null, priced: false };
634
649
  const b = costBreakdown(u, model, opts);
635
650
  // A model the table cannot place still yields a number (ratesFor never
636
651
  // returns nothing), so this is always an estimate — but `priced: false` says
637
652
  // the figure is a placeholder rather than this model's rate, so the caller
638
653
  // can label it instead of printing it as though it had been measured.
639
- return { tokens, cost: b.cost, real: false, estimated: true, basis: b.basis, priced: b.priced };
654
+ return {
655
+ tokens, output, cost: b.cost, real: false, estimated: true, basis: b.basis, priced: b.priced,
656
+ };
640
657
  }
641
658
 
642
659
  /**
@@ -648,7 +665,7 @@ function turnAccounting(usage, model, opts = {}) {
648
665
  * — `tokens`, `inputTokens`, `outputTokens`, `calls` — and what the user reads
649
666
  * back is that RUNNING TOTAL. The status bar prints `state.tokens`
650
667
  * (renderStatus), and `ctrl+t` prints the tallies in one line
651
- * (`tokenSummary`: `12,400 tok (10,100 in / 2,300 out) · 4 calls · €0.03`).
668
+ * (`tokenSummary`: `12,400 tokens (10,100 in / 2,300 out) · 4 calls · €0.03`).
652
669
  * The tallies are reproduced; that one abbreviation is not — `fmtRoll` explains
653
670
  * why it drops the parenthetical rather than carrying the CLI's line verbatim.
654
671
  *
@@ -869,36 +886,104 @@ function fmtTokens(n) {
869
886
  .replace(/\B(?=(\d{3})+(?!\d))/g, ',');
870
887
  }
871
888
 
889
+ /**
890
+ * The token figure a person is SHOWN for a finished turn or a session roll.
891
+ *
892
+ * One authority, because there are two shapes that reach it and they had
893
+ * drifted apart: `turnAccounting` (a single dispatch, `{tokens, output}`) and
894
+ * `emptyRoll`/`rollTurn` (a session tally, same two fields). Every per-turn row
895
+ * and the topbar both route through here, so no surface can print a different
896
+ * quantity than another.
897
+ *
898
+ * What it returns is the OUTPUT — what the turn produced. The wire's total is
899
+ * input + output, and on any cached or long-context turn the input half
900
+ * dominates it: a 3,145-token call whose prompt was 3,000 tokens is a turn that
901
+ * wrote 145. Showing 3,145 beside a `session:` line that also reads 3,145 told
902
+ * the reader the model had emitted ten times what it did, and there is no way
903
+ * to tell from the row which of the two numbers is the prompt restated. The
904
+ * CLI settled this the same way (`fmtUsageSplit`, cli/src/format.js): the figure
905
+ * is the output, and the input half is not shown unlabelled beside it.
906
+ *
907
+ * It does NOT fall back to the total. Fall back to `tokens` and the input half
908
+ * is back on the screen with no label: a wire that priced a turn but did not
909
+ * itemise it would print `3,145` for a turn that wrote 145. Returns `null`
910
+ * rather than `0` in that case — `0 tokens` is a figure no provider sent,
911
+ * and this file refuses that fabrication everywhere else (`fmtRoll`).
912
+ *
913
+ * @param {{tokens?: number|null, output?: number|null}} [source]
914
+ * @returns {number|null}
915
+ */
916
+ function displayTokens(source) {
917
+ if (!source || typeof source !== 'object') return null;
918
+ // The half the turn PRODUCED, from either shape this file is handed: an
919
+ // accounting or roll record (`output`), or a stored token bucket
920
+ // (`tokens: {output}` — what `rollMessages` reads back out of the shared
921
+ // store, and what a rebuilt session presents).
922
+ const nested = source.tokens;
923
+ const candidates = [source.output];
924
+ if (nested && typeof nested === 'object') {
925
+ candidates.push(nested.output, nested.output_tokens, nested.completion_tokens);
926
+ } else {
927
+ candidates.push(source.output_tokens, source.completion_tokens);
928
+ }
929
+ for (const c of candidates) {
930
+ if (typeof c === 'number' && isFinite(c) && c > 0) return c;
931
+ }
932
+ // The wire's TOTAL is deliberately NOT a fallback here. `tokens` is
933
+ // input + output, and on a cached turn the input half is the larger one, so
934
+ // falling back to it put back the exact reading this rule exists to remove:
935
+ // `tokens: 3,145` on a turn that wrote 145. This was the last surviving
936
+ // place the input half reached the screen — the split had been dropped, and
937
+ // the total it fell back to still carried it.
938
+ //
939
+ // Stated once more, because it is the whole point: a turn the wire priced but
940
+ // did not itemise has NO output figure, and inventing one from the total
941
+ // would be a number no provider sent. That turn's row states its own text
942
+ // estimate instead (`~N tokens`), which is at least a figure about what was
943
+ // written.
944
+ return null;
945
+ }
946
+
872
947
  /**
873
948
  * The rolling session total as one line — the desktop's counterpart of the
874
949
  * CLI's `tokenSummary`. Empty string when nothing has been accounted for, so
875
950
  * an untouched session adds no noise to a turn's meta line.
876
951
  *
877
952
  * @param {object} [roll]
878
- * @returns {string} e.g. `12,400 tok · 4 calls · $0.0310` — the CLI's fields in
879
- * the CLI's order, minus the input/output parenthetical (see below).
953
+ * @returns {string} e.g. `2,300 tokens · 4 calls · $0.0310` — the CLI's fields in
954
+ * the CLI's order, minus the input/output parenthetical (see below). The
955
+ * figure is the OUTPUT (see `displayTokens`).
880
956
  */
881
- function fmtRoll(roll) {
957
+ function fmtRoll(roll, opts = {}) {
882
958
  const r = roll || emptyRoll();
883
959
  if (!r.turns && !r.calls) return '';
884
960
  // The fields are `tokenSummary`'s (cli/src/app.js:1413) in its order — tokens,
885
961
  // calls, money — with one dropped: the `(10,100 in / 2,300 out)` split. `in`
886
962
  // and `out` are a terminal status-line shorthand, legible to someone already
887
963
  // reading that status line and to nobody else, which is what an unlabelled
888
- // abbreviation in a GUI topbar turns into. The total is the figure a reader
889
- // wants; `r.input`/`r.output` are still folded onto the roll (see `rollTurn`)
890
- // for any surface that wants to show the split with real labels. The call
891
- // count stays unconditional, because it is the number that reveals a
892
- // fan-out, and it is not hidden at 1.
964
+ // abbreviation in a GUI topbar turns into.
965
+ //
966
+ // …and the figure that remains is the OUTPUT, not the wire's total. This line
967
+ // used to print `r.tokens`, which is input + output: on a cached turn the
968
+ // prompt restated is the larger half, so a session that wrote 2,300 tokens on
969
+ // a 10,100-token prompt read `12,400 tokens` and looked like a model emitting
970
+ // five times what it did. The parenthetical was not the only place the input
971
+ // half showed up — it was in the total too, and dropping the split alone left
972
+ // the reader comparing two numbers that were both mostly their own prompt.
973
+ // `displayTokens` holds the rule, the same one the CLI's `fmtUsageSplit` uses.
974
+ // `r.input` is still folded onto the roll (see `rollTurn`) for any surface
975
+ // that wants to show the split with real labels.
893
976
  const bits = [];
894
977
  // The token half only when something was actually counted. `rollTurn` counts
895
978
  // turns and calls BEFORE it looks at the token count, so a dispatch that
896
- // reported nothing still reaches here — and printing its empty tally would
897
- // put `0 tok` on the topbar, a figure the meter never took, which reads as a
898
- // counter that does not move. The turn count is still stated, because that
899
- // much is true.
900
- if (r.tokens > 0) {
901
- bits.push(`${fmtTokens(r.tokens)} tok`);
979
+ // reported nothing still reaches here — and printing an empty tally would put
980
+ // `0 tokens` on the topbar, a figure the meter never took, which reads as a
981
+ // counter that does not move. `displayTokens` returns null rather than 0 for
982
+ // exactly that case, and falls back to the total only when no split arrived.
983
+ // The turn count is still stated, because that much is true.
984
+ const produced = displayTokens(r);
985
+ if (produced != null) {
986
+ bits.push(`${fmtTokens(produced)} tokens`);
902
987
  }
903
988
  bits.push(`${r.calls} call${r.calls === 1 ? '' : 's'}`);
904
989
  // Money is where this line departs from `tokenSummary`, deliberately: that
@@ -906,7 +991,10 @@ function fmtRoll(roll) {
906
991
  // a settled charge and a local estimate apart so a `~`-estimate can never be
907
992
  // read as part of the bill. Desktop's pre-existing fmtCost renders both, and
908
993
  // its `$` convention is left exactly as it was.
909
- if (r.cost > 0 || r.estimate > 0) {
994
+ // Money is omitted entirely on a tier AEGIS does not bill a fee to
995
+ // (`opts.omitMoney` — see `feeWaived`). Not zeroed, not marked `~`: a `$0.0000`
996
+ // states a price, and this account has no price on this turn.
997
+ if (!opts.omitMoney && (r.cost > 0 || r.estimate > 0)) {
910
998
  const money = [];
911
999
  if (r.cost > 0) money.push(fmtCost(r.cost, true));
912
1000
  if (r.estimate > 0) money.push(fmtCost(r.estimate, false));
@@ -935,11 +1023,108 @@ function fmtRoll(roll) {
935
1023
  */
936
1024
  function fmtCost(cost, real) {
937
1025
  if (typeof cost !== 'number' || !isFinite(cost)) return '';
1026
+ // The `$` is not decoration: its loss was a regression, not a decision.
1027
+ // `4d5d1e9` introduced this function with it (`~$18.0000`); `6d48470` — a
1028
+ // commit about the token TOTAL, unrelated to money — dropped it as a stray
1029
+ // hunk. Every desktop turn row then painted a bare `18.0000`, a number with no
1030
+ // currency on it, where the CLI paints `€0.03` for the same figure.
938
1031
  return `${real ? '' : '~'}$${cost.toFixed(4)}`;
939
1032
  }
940
1033
 
1034
+ /**
1035
+ * True when this account's tier means AEGIS bills no per-turn fee, so no money
1036
+ * figure belongs on a turn row or a session roll.
1037
+ *
1038
+ * The fee in question is the BYOK handling fee — the only charge AEGIS adds on
1039
+ * top of a caller's own provider bill — and aegis1 waives it on Unlimited:
1040
+ * `lib/local/entitlement.js` states the rule in as many words ("the throughput
1041
+ * allowance and the BYOK fee waiver are `is_unlimited()`-gated"), and the tier
1042
+ * a welcome trial grants IS Unlimited (a 30-day `unlimited` row in
1043
+ * `subscriptions`, which `register_device` writes in the same request that mints
1044
+ * the key). So a trial account resolves here exactly like a paying one, which is
1045
+ * what "trial unlimited" means — the trial is not a separate tier with its own
1046
+ * money rule, it is Unlimited for 30 days.
1047
+ *
1048
+ * Matching is on the resolved tier rather than `pro`: `pro` is "the PLAN is paid
1049
+ * for" and is also true of the €9 tier's aliases, while the waiver is gated on
1050
+ * `unlimited` specifically. A funded bank on a free plan is not a tier at all
1051
+ * (`entitlement.js` refuses to name one) and is therefore NOT waived — that
1052
+ * account pays the fee from the bank, and hiding the number would leave it with
1053
+ * no meter for the only money it spends.
1054
+ *
1055
+ * @param {{tier?: string|null, plan?: string|null, planRaw?: string|null}} [entitlement]
1056
+ * @returns {boolean}
1057
+ */
1058
+ function feeWaived(entitlement) {
1059
+ if (!entitlement || typeof entitlement !== 'object') return false;
1060
+ const named = [
1061
+ entitlement.tier,
1062
+ entitlement.plan,
1063
+ entitlement.planRaw,
1064
+ entitlement.subscription,
1065
+ entitlement.subscriptionTier,
1066
+ entitlement.product,
1067
+ // The server's own two fields, as `register_device` writes them: a welcome
1068
+ // trial is a 30-day `unlimited` row in `subscriptions`, and `item`/`sku` is
1069
+ // that row's product name. Naming them here is what keeps a trial account
1070
+ // from resolving to "no tier" and being billed a fee it does not owe.
1071
+ entitlement.item,
1072
+ entitlement.sku,
1073
+ ]
1074
+ .filter((v) => typeof v === 'string')
1075
+ .map((v) => v.trim().toLowerCase());
1076
+ // An explicit boolean outranks every spelling below: aegis1's `is_unlimited()`
1077
+ // verdict is the fact, and the strings are only what it is written as on the
1078
+ // wire. A probe that answers with the bit and not the tier is still an answer.
1079
+ if (entitlement.unlimited === true) return true;
1080
+ if (named.some((v) => v === 'unlimited' || v === 'trial')) return true;
1081
+ // A tier string that merely CONTAINS the word — `unlimited_trial`,
1082
+ // `plan.unlimited`, `Unlimited (trial)` — so a server that decorates the name
1083
+ // cannot put the fee back on an account whose tier is Unlimited.
1084
+ return named.some((v) => /(^|[^a-z])unlimited([^a-z]|$)/.test(v));
1085
+ }
1086
+
1087
+ /**
1088
+ * Whether a money figure belongs on this account's turn row or session roll.
1089
+ *
1090
+ * The CLI's exact rule (`cli/src/format.js` → `feeBillable`), and it is the same
1091
+ * rule on purpose — the two hosts render the same turn from the same engine, so
1092
+ * a row that shows a fee on one and waives it on the other is a disagreement
1093
+ * about what the user owes.
1094
+ *
1095
+ * A price is printed UNLESS AEGIS either waives it or has not proven it owed:
1096
+ *
1097
+ * - a tier AEGIS waives (`feeWaived`) → no price. Unlimited, paid or on the
1098
+ * 30-day welcome trial, has no per-turn charge and no bank draw;
1099
+ * - a snapshot asserting a plan it cannot prove (`degraded`) → no price. The
1100
+ * entitlement module STARTS at exactly that: `makeFreeSnapshot()` reads
1101
+ * `plan: 'free'`, `tier: null`, `source: 'unresolved'`, `degraded: true`,
1102
+ * which says "no verdict yet", not "this account is free". Reading the
1103
+ * placeholder as a verdict is what put `~$0.0005` and an "AEGIS handling fee"
1104
+ * line on the turns of trial accounts whose probe had not answered — a fee
1105
+ * charged in writing to the one account that does not pay it;
1106
+ * - everything else → the price stands, including when no snapshot exists at
1107
+ * all. A cost figure is a measurement of the turn, and dropping a real number
1108
+ * because no tier was published would leave a self-hosted or BYO-key run with
1109
+ * no meter for money it actually spent.
1110
+ *
1111
+ * The token figure is never gated by this, on any branch: what a turn produced
1112
+ * is not a price, and hiding it would leave an Unlimited account with no meter.
1113
+ *
1114
+ * @param {{tier?: string|null, plan?: string|null, planRaw?: string|null,
1115
+ * degraded?: boolean, source?: string|null, unlimited?: boolean}} [entitlement]
1116
+ * @returns {boolean}
1117
+ */
1118
+ function feeBillable(entitlement) {
1119
+ if (!entitlement || typeof entitlement !== 'object') return true;
1120
+ if (feeWaived(entitlement)) return false;
1121
+ return entitlement.degraded !== true;
1122
+ }
1123
+
941
1124
  if (typeof module !== 'undefined' && module.exports) {
942
1125
  module.exports = {
1126
+ feeWaived,
1127
+ feeBillable,
943
1128
  RATES,
944
1129
  RATE_BASIS,
945
1130
  UNPRICED_BY_DESIGN,
@@ -951,6 +1136,7 @@ if (typeof module !== 'undefined' && module.exports) {
951
1136
  usageCost,
952
1137
  costBreakdown,
953
1138
  turnAccounting,
1139
+ displayTokens,
954
1140
  estimateTokens,
955
1141
  estimatedBuckets,
956
1142
  emptyRoll,