aegis-desktop 1.0.8 → 1.0.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -13,7 +13,7 @@ Prebuilt binaries (AppImage / MSI+NSIS / dmg) ship with each release, and the
13
13
  package is on npm:
14
14
 
15
15
  ```bash
16
- npm install -g aegis-desktop # requires Node 18+
16
+ npm install -g aegis-desktop # requires Node 22+
17
17
  aegis # launch
18
18
  ```
19
19
 
@@ -209,8 +209,15 @@ const DEFAULT_MODEL = 'nexus-brain';
209
209
  * depends on what the user's own daemon happens to have. Both REQUIRE an
210
210
  * explicit model, refused by name instead of silently resolving to
211
211
  * something nobody chose.
212
+ * - 'pro' DOES have a default, but not one this app chooses: a blank model
213
+ * means "the `claude` CLI picks for the subscription", which is what
214
+ * `--model` omitted says (engine.js sends no flag for `default`). Once a
215
+ * model IS stated it must be one of claude-pro.js's PRO_MODELS aliases —
216
+ * the same list the model picker offers — because the child accepts only
217
+ * those four spellings, and a `provider:model` id copied from a byok task
218
+ * would otherwise be handed to `claude --model` and fail there.
212
219
  */
213
- const MODEL_CLASSES = Object.freeze(['aegis', 'byok', 'local']);
220
+ const MODEL_CLASSES = Object.freeze(['aegis', 'byok', 'local', 'pro']);
214
221
 
215
222
  const AEGIS_MODEL_RE = /^(?:nexus|aegis)-brain(?:-(?:smart|neo))?$/;
216
223
  const AEGIS_MODEL_IDS = Object.freeze(['nexus-brain', 'aegis-brain']);
@@ -270,6 +277,34 @@ function modelRefusal(id, cls) {
270
277
  }
271
278
  return '';
272
279
  }
280
+ if (normalized === 'pro') {
281
+ // The subscription route. A blank model is legal and meaningful: engine.js
282
+ // sends no `--model` at all for 'default', so the child resolves the pick
283
+ // itself. Anything STATED must be a PRO_MODELS alias, because those four
284
+ // spellings are exactly what `claude --model` accepts — a byok-style
285
+ // "provider:model" copied between tasks would be handed to the child and
286
+ // fail there, three stages into a drain, with the queue's own file naming
287
+ // something the queue never validated.
288
+ if (!stated) return '';
289
+ // Required lazily: claude-pro.js is a heavy sibling (child spawn, session
290
+ // files) and this module is loaded by queue.js at every CLI start. The
291
+ // require is a plain sibling path with no back-require, so there is no
292
+ // cycle to guard — this is about load cost, not correctness.
293
+ let aliases;
294
+ try {
295
+ aliases = require('./claude-pro.js').PRO_MODELS;
296
+ } catch {
297
+ // If the alias list cannot be read we cannot validate a stated model, and
298
+ // accepting it unvalidated is the exact failure this arm exists to stop.
299
+ return `a pro task's model cannot be checked right now — queue it with no --model to let the claude CLI pick`;
300
+ }
301
+ const ids = (Array.isArray(aliases) ? aliases : []).map((m) => String(m && m.id).trim());
302
+ if (ids.includes(stated.toLowerCase())) return '';
303
+ return (
304
+ `the autonomous queue's Pro class runs the subscription through the claude CLI, ` +
305
+ `which accepts only its own model aliases (${ids.join('/')}); "${stated}" is not one`
306
+ );
307
+ }
273
308
  // normalized === 'local'
274
309
  if (!stated) return 'a local task needs a model — the tag of whatever is pulled into the local daemon';
275
310
  return '';
@@ -594,13 +629,18 @@ function createQueueWorker({
594
629
  */
595
630
  async function runTask(item, { carry = '', commit } = {}) {
596
631
  const cwd = item.cwd || process.cwd();
597
- // Aegis Cloud or nothing — checked BEFORE the turn, not at the server. An
598
- // item whose model is not a pooled tier (a hand-edited queue file, a
599
- // `--model` the CLI accepted before this rule existed, another host's
600
- // older build) would otherwise go out as `class: 'aegis'` with an id the
601
- // pool does not serve: billed work if it happens to be a per-provider
602
- // spelling, an opaque server error otherwise. Failing here names the model
603
- // and the allowed ones, and costs nothing.
632
+ // The task's own (model, class) pair checked BEFORE the turn, not at the
633
+ // server. An item whose model does not belong to its class (a hand-edited
634
+ // queue file, a `--model` the CLI accepted before this rule existed,
635
+ // another host's older build) would otherwise go out under a transport
636
+ // that does not serve it: billed work if it happens to be a per-provider
637
+ // spelling, an opaque server error otherwise, and for 'pro' a `claude
638
+ // --model` that the child rejects. Failing here names the model and the
639
+ // spellings that class accepts, and costs nothing.
640
+ //
641
+ // "Aegis Cloud or nothing" was this comment's old claim, and it stopped
642
+ // being true when byok/local were added (9eeef09) — modelRefusal is what
643
+ // actually enforces the rule now, per class, and it runs for all four.
604
644
  const cls = normalizeClass(item.class);
605
645
  const refusal = modelRefusal(item.model, cls);
606
646
  if (refusal) {
@@ -317,11 +317,25 @@ function turnArgs(payload) {
317
317
  * "the first collecting block" pairs a fragment with the wrong call.
318
318
  * - one `user` line can carry a tool_result for EVERY call in a parallel
319
319
  * round, so all of them are resolved, not just the first.
320
+ *
321
+ * `thinking_delta` is the third delta type and is NOT folded into `text`: an
322
+ * extended-thinking block is deliberation, not the answer, so it goes out on
323
+ * its own callback and its own accumulator. Dropping it entirely (which is what
324
+ * this parser did) left BOTH channels empty for the whole thinking phase, which
325
+ * is the longest stretch of a Pro turn — so the renderers' live meters, whose
326
+ * only literal `0` is the "nothing received yet" branch, read `↓0 tokens` for
327
+ * the entire time the turn was visibly working. Claude Code Pro runs extended
328
+ * thinking by default, so this was the normal case, not an edge.
320
329
  */
321
330
  function createStreamParser(handlers) {
322
331
  const h = handlers || {};
323
332
  const tools = new Map(); // id -> {id, index, name, args, startedAt, phase}
324
333
  let text = '';
334
+ let thinking = '';
335
+ // Thinking progress the provider MEASURED, in tokens. On a real Pro turn
336
+ // `thinking_delta.thinking` is the empty string and this is the only figure
337
+ // the frame carries — see the `thinking_delta` branch below.
338
+ let thinkingTokens = 0;
325
339
  let resultLine = null;
326
340
 
327
341
  const emitRun = (block) => {
@@ -366,6 +380,30 @@ function createStreamParser(handlers) {
366
380
  const t = (e.delta.text || '').replace(/\x00/g, '');
367
381
  text += t;
368
382
  if (h.onText && t) h.onText(t);
383
+ } else if (e.delta.type === 'thinking_delta') {
384
+ // Same `\x00` scrub as the visible text, for the same reason: a
385
+ // NUL in the frame would otherwise be handed to a terminal.
386
+ const t = (e.delta.thinking || '').replace(/\x00/g, '');
387
+ thinking += t;
388
+ if (h.onThinking && t) h.onThinking(t);
389
+ // The figure that ACTUALLY moves on a subscription turn. Measured
390
+ // against Claude Code 2.1.228: every real `thinking_delta` carries
391
+ // `thinking: ""` with the deliberation withheld (the finished
392
+ // block ships a 5.4 KB `signature` and no text), while
393
+ // `estimated_tokens` advances in increments that sum to just under
394
+ // the billed `usage.output_tokens_details.thinking_tokens`
395
+ // (measured: 1900 vs 1943, 1800 vs 1832, 250 vs 339). So the text
396
+ // channel is empty for the WHOLE thinking phase on a Pro turn and
397
+ // the earlier `onThinking`-only fix never fired — the live meter
398
+ // still read `↓0 tokens` while 4114 thinking tokens were billed
399
+ // across three captured turns. Incremental, not cumulative: the
400
+ // increments sum to the billed figure, so they are added as they
401
+ // arrive rather than treated as a running total.
402
+ const est = e.delta.estimated_tokens;
403
+ if (typeof est === 'number' && est > 0 && Number.isFinite(est)) {
404
+ thinkingTokens += est;
405
+ if (h.onThinkingProgress) h.onThinkingProgress(est);
406
+ }
369
407
  } else if (e.delta.type === 'input_json_delta') {
370
408
  const t = collecting(e.index == null ? 0 : e.index);
371
409
  if (t) t.args += e.delta.partial_json || '';
@@ -396,6 +434,13 @@ function createStreamParser(handlers) {
396
434
  costUsd: typeof j.total_cost_usd === 'number' ? j.total_cost_usd : undefined,
397
435
  durationMs: j.duration_ms,
398
436
  sessionId: j.session_id,
437
+ // The deliberation this round DID produce, handed up so the engine
438
+ // can tell a thinking-only round apart from a genuinely empty one.
439
+ // On a real Pro turn the text is normally withheld and only the
440
+ // billed token count is available, so `thinking` is often empty
441
+ // even when the child thought — hence both fields, and the count.
442
+ thinking: thinking || '',
443
+ thinkingTokens,
399
444
  });
400
445
  }
401
446
  }
@@ -404,6 +449,14 @@ function createStreamParser(handlers) {
404
449
  get text() {
405
450
  return text;
406
451
  },
452
+ get thinking() {
453
+ return thinking;
454
+ },
455
+ // Thinking tokens the PROVIDER measured, not inferred from text — the only
456
+ // honest way to meter a Pro turn, whose thinking text is withheld.
457
+ get thinkingTokens() {
458
+ return thinkingTokens;
459
+ },
407
460
  get resultLine() {
408
461
  return resultLine;
409
462
  },
@@ -454,6 +507,41 @@ function seamAvailable(dir) {
454
507
  }
455
508
  }
456
509
 
510
+ /**
511
+ * The `choices[0].message` a Pro turn reports.
512
+ *
513
+ * `content` is the child's own final answer, as before. The addition is the
514
+ * deliberation, carried on the shape the engine already reads: `reasoning_content`
515
+ * is what `engine.js` `reasoningTextOf()` looks for first, so a Pro round that
516
+ * spent its output thinking is now visible as exactly that.
517
+ *
518
+ * This matters because of how a Pro turn fails. When extended thinking is on —
519
+ * which is Claude Code's default — the child can finish a round having produced
520
+ * deliberation and no answer, and the engine's recovery ladder decides what to
521
+ * do next by asking `reasonedButSilent(res)`, i.e. "was this round empty, or did
522
+ * it think and stay silent?". Before this field existed, a Pro result never
523
+ * carried reasoning at all, so that question always answered "genuinely empty"
524
+ * and the ladder burned its rounds on the generic nudge — the ask that invites
525
+ * the same silent thinking pass a second time. Worse, `emptyTurnError` reported
526
+ * it as "No visible text came back from any of those calls", which is the wrong
527
+ * diagnosis: the model was not silent, it was thinking.
528
+ *
529
+ * `thinking_tokens` rides alongside as the provider's own billed count. On a
530
+ * real Pro turn the deliberation TEXT is withheld — measured across three turns,
531
+ * every `thinking_delta` carried `thinking: ""` while thousands of tokens were
532
+ * billed — so a text-only signal would still miss the case this exists for. The
533
+ * count is the honest figure, and it is the only one that arrives.
534
+ */
535
+ function proMessage(text, thinking, thinkingTokens) {
536
+ const msg = { content: text || '' };
537
+ const deliberation = typeof thinking === 'string' ? thinking : '';
538
+ if (deliberation) msg.reasoning_content = deliberation;
539
+ if (typeof thinkingTokens === 'number' && thinkingTokens > 0 && Number.isFinite(thinkingTokens)) {
540
+ msg.thinking_tokens = thinkingTokens;
541
+ }
542
+ return msg;
543
+ }
544
+
457
545
  /**
458
546
  * Run one turn on the user's Claude Code subscription.
459
547
  *
@@ -635,6 +723,30 @@ function runClaudePro(opts, deps) {
635
723
  armIdle();
636
724
  if (o.onDelta) o.onDelta({ delta: t });
637
725
  },
726
+ // Extended thinking, forwarded on the SAME channel the engine's pooled
727
+ // brain path already uses for a worker's deliberation (`onReasoning`,
728
+ // engine.js — `onDelta({ reasoning })`). Both renderers already consume
729
+ // that field, so the live token meter counts the thinking phase on a Pro
730
+ // turn exactly as it does everywhere else, with no consumer-side edit.
731
+ // `engine.js` hands `runClaudePro` the RAW `opts.onDelta` (not the wrapped
732
+ // `onReasoning`), so this transport is the one that must emit the field.
733
+ onThinking: (t) => {
734
+ armIdle();
735
+ if (o.onDelta) o.onDelta({ reasoning: t });
736
+ },
737
+ // The channel that actually fires on a Pro turn. `onThinking` above is
738
+ // kept for any provider that does stream deliberation text, but Claude
739
+ // Code withholds it: measured across three real turns, every
740
+ // `thinking_delta` carried `thinking: ""`, so `onThinking` never fired
741
+ // once while 4114 thinking tokens were billed. This carries the
742
+ // provider's own MEASURED count, on a numeric channel of its own rather
743
+ // than the text one — the consumers turn `reasoning` text into tokens at
744
+ // 4 chars:1 token, so a token count sent down that path would be divided
745
+ // by four and read as a quarter of the truth.
746
+ onThinkingProgress: (est) => {
747
+ armIdle();
748
+ if (o.onDelta) o.onDelta({ thinkingTokens: est });
749
+ },
638
750
  onTool: (info) => {
639
751
  armIdle();
640
752
  if (!o.onDelta) return;
@@ -662,7 +774,7 @@ function runClaudePro(opts, deps) {
662
774
  return;
663
775
  }
664
776
  settle({
665
- choices: [{ message: { content: r.text || '' }, finish_reason: 'stop' }],
777
+ choices: [{ message: proMessage(r.text || '', r.thinking, r.thinkingTokens), finish_reason: 'stop' }],
666
778
  usage: r.usage || undefined,
667
779
  costUsd: r.costUsd,
668
780
  durationMs: r.durationMs,
@@ -704,7 +816,12 @@ function runClaudePro(opts, deps) {
704
816
  // one. Anything else is a real failure and is reported as one rather than
705
817
  // as an empty successful answer.
706
818
  settle({
707
- choices: [{ message: { content: parser.text }, finish_reason: 'stop' }],
819
+ choices: [
820
+ {
821
+ message: proMessage(parser.text, parser.thinking, parser.thinkingTokens),
822
+ finish_reason: 'stop',
823
+ },
824
+ ],
708
825
  usage: (parser.resultLine && parser.resultLine.usage) || undefined,
709
826
  childRecord: built.record,
710
827
  error:
@@ -743,6 +743,51 @@ function extractToolCalls(res) {
743
743
  }
744
744
 
745
745
  /** The textual content of one assistant turn (empty when it only called tools). */
746
+ /**
747
+ * The prompt a Pro turn actually hands the child.
748
+ *
749
+ * `pro` is the one class that does not replay `opts.messages` into its
750
+ * transport: the `claude` child runs its own agent loop, so sending this
751
+ * engine's transcript would make it re-run every tool call a second time. That
752
+ * decision is right for an ordinary turn and silently wrong for the engine's
753
+ * RE-ASK rounds — the synthesis pass and the recovery ladder both re-dispatch
754
+ * with `prompt: ''` plus a grown `messages`, which is the shape every other
755
+ * class reads as "continue this conversation; the new instruction is the last
756
+ * user message in the history". The Pro branch read only `opts.prompt`, so each
757
+ * of those rounds spawned `claude -p ''`: an empty prompt, on a child whose
758
+ * stdin is `'ignore'`, with `--resume` as its only other context.
759
+ *
760
+ * The consequence was the failure this function exists to prevent. A Pro turn
761
+ * whose first round came back empty could never be recovered, because every
762
+ * recovery round was guaranteed empty too — the ladder then reported "2 extra
763
+ * recovery calls with a larger budget and a changed ask came back empty too"
764
+ * over a transport that had been asked nothing at all. Measured on
765
+ * claude-code-pro, 2026-10-11.
766
+ *
767
+ * So when a round carries no prompt of its own, the LAST user message in
768
+ * `opts.messages` becomes the child's prompt. The nudges the ladder appends are
769
+ * exactly that, and `--resume` reattaches the child to its own session, so the
770
+ * instruction lands in a conversation that already holds the findings — which
771
+ * is what makes the recovery round a re-ask rather than a fresh, empty one.
772
+ *
773
+ * An ordinary turn is untouched: it has its own prompt, so the first branch
774
+ * returns before `messages` is ever consulted.
775
+ */
776
+ function proTurnPrompt(opts) {
777
+ const own = typeof opts.prompt === 'string' ? opts.prompt.trim() : '';
778
+ if (own) return opts.prompt;
779
+ const messages = Array.isArray(opts.messages) ? opts.messages : [];
780
+ for (let i = messages.length - 1; i >= 0; i -= 1) {
781
+ const m = messages[i];
782
+ if (!m || m.role !== 'user') continue;
783
+ if (typeof m.content === 'string' && m.content.trim()) return m.content;
784
+ }
785
+ // Nothing to borrow: keep the caller's own value rather than inventing text,
786
+ // so a round with genuinely no ask still reaches the ladder and is reported
787
+ // as the empty round it is.
788
+ return opts.prompt;
789
+ }
790
+
746
791
  function assistantText(res) {
747
792
  const msg = res && res.choices && res.choices[0] && res.choices[0].message;
748
793
  return (msg && typeof msg.content === 'string' && msg.content) || '';
@@ -808,6 +853,42 @@ function reasoningTextOf(res) {
808
853
  return '';
809
854
  }
810
855
 
856
+ /**
857
+ * Deliberation the provider MEASURED but withheld, in tokens.
858
+ *
859
+ * The companion to `reasoningTextOf()` above, for the one case where there is no
860
+ * text to read: Claude Code Pro runs extended thinking by default and withholds
861
+ * the deliberation itself, so `thinking_delta.thinking` arrives as the empty
862
+ * string while the billed count climbs. A round like that carries real reasoning
863
+ * and no reasoning TEXT, so a text-only predicate reads it as "the model said
864
+ * nothing" — the wrong diagnosis, and the one that made a Pro turn report
865
+ * "No visible text came back" while the child had in fact been thinking.
866
+ *
867
+ * Read from the message shape first (what `claude-pro.js` `proMessage()` sets)
868
+ * and then from the standard usage detail `completion_tokens_details.
869
+ * reasoning_tokens`, so a provider that meters deliberation on the usage
870
+ * envelope is seen too.
871
+ */
872
+ function reasoningTokensOf(res) {
873
+ if (!res) return 0;
874
+ const choice = res.choices && res.choices[0];
875
+ const msg = (choice && choice.message) || {};
876
+ const usage = res.usage || {};
877
+ const details = usage.completion_tokens_details || usage.output_tokens_details || {};
878
+ const candidates = [
879
+ msg.thinking_tokens,
880
+ msg.reasoning_tokens,
881
+ choice && choice.thinking_tokens,
882
+ res.thinking_tokens,
883
+ details.reasoning_tokens,
884
+ details.thinking_tokens,
885
+ ];
886
+ for (const c of candidates) {
887
+ if (typeof c === 'number' && Number.isFinite(c) && c > 0) return c;
888
+ }
889
+ return 0;
890
+ }
891
+
811
892
  /**
812
893
  * The empty turn `isTruncated()` cannot see, and the reason this error survived
813
894
  * a truncation retry that was already in place: a reasoning model that spends
@@ -818,9 +899,14 @@ function reasoningTextOf(res) {
818
899
  * `reasoning_content`, which fell straight through both retry gates to
819
900
  * emptyTurnError. Reasoning tokens and answer tokens share one budget, so the
820
901
  * recovery is identical to a 'length' cut: buy room and ask once more.
902
+ *
903
+ * A round with no reasoning TEXT but a non-zero reasoning-token count counts as
904
+ * reasoning here: that is a Pro turn whose deliberation was withheld, and
905
+ * treating it as genuinely empty is what sent it to the generic nudge instead
906
+ * of the ask that names the problem.
821
907
  */
822
908
  function reasonedButSilent(res) {
823
- return !assistantText(res) && reasoningTextOf(res) !== '';
909
+ return !assistantText(res) && (reasoningTextOf(res) !== '' || reasoningTokensOf(res) > 0);
824
910
  }
825
911
 
826
912
  /**
@@ -979,8 +1065,20 @@ const RECOVERY_ROUNDS = 2;
979
1065
  * real tool log on screen, keeps the transcript honest, and names the cause
980
1066
  * (renderer prints `Error: <message>`).
981
1067
  */
982
- function emptyTurnError({ cls, model, finishReason, reasoningChars, effort, recoveryRounds }) {
1068
+ function emptyTurnError({ cls, model, finishReason, reasoningChars, reasoningTokens, effort, recoveryRounds }) {
983
1069
  const calls = Number(recoveryRounds) || 0;
1070
+ // A withheld deliberation has no chars to count, so the two signals are
1071
+ // reported as the one fact they both are: the round spent its output on
1072
+ // thinking. Without this the Pro case printed "No visible text came back from
1073
+ // any of those calls", which describes a transport that produced nothing —
1074
+ // the opposite of a child that reasoned at length and wrote no answer.
1075
+ const tokens = Number(reasoningTokens) || 0;
1076
+ const reasoned = !!reasoningChars || tokens > 0;
1077
+ const reasonClause = reasoningChars
1078
+ ? `, hidden reasoning: ${reasoningChars} chars and no visible text`
1079
+ : tokens > 0
1080
+ ? `, ~${tokens} thinking tokens billed and no visible text`
1081
+ : '';
984
1082
  const err = new Error(
985
1083
  `The model returned no answer after ${cls}/${model} was asked to summarise its results` +
986
1084
  (calls
@@ -989,8 +1087,8 @@ function emptyTurnError({ cls, model, finishReason, reasoningChars, effort, reco
989
1087
  : '') +
990
1088
  ` (stop reason: ${finishReason || 'none'}` +
991
1089
  `${effort ? `, effort: ${effort}` : ''}` +
992
- `${reasoningChars ? `, hidden reasoning: ${reasoningChars} chars and no visible text` : ''}). ` +
993
- (reasoningChars
1090
+ `${reasonClause}). ` +
1091
+ (reasoned
994
1092
  ? // Deliberately NOT "raise the Effort rung". The budget was already taken
995
1093
  // out of the equation before this error could be reached: the recovery
996
1094
  // calls above ran at the top of the ladder, so pointing at the ceiling
@@ -1878,6 +1976,12 @@ function createLocalEngine({
1878
1976
  provider,
1879
1977
  model: bareModel,
1880
1978
  providerKey: opts.apiKey,
1979
+ // Only meaningful for provider "custom" (aegis1 reads it ONLY on
1980
+ // that id) — forwarded unconditionally anyway, same as the other
1981
+ // fields above, rather than special-cased per provider here.
1982
+ // `cfg` is this provider's settings row (byokNamespace(provider)),
1983
+ // the same row /byok-key and /model-add write.
1984
+ baseURL: opts.cfg && opts.cfg.baseURL,
1881
1985
  prompt: opts.prompt,
1882
1986
  system: opts.system,
1883
1987
  messages: opts.messages,
@@ -2017,7 +2121,10 @@ function createLocalEngine({
2017
2121
  // (one/src/child.js), so this host and AEGISONE's faces cannot disagree
2018
2122
  // about what the child was allowed to do.
2019
2123
  const proRes = await claudePro.runClaudePro({
2020
- prompt: opts.prompt,
2124
+ // The re-ask round's instruction, when this round has none of its own.
2125
+ // See proTurnPrompt(): without it every recovery round spawned the
2126
+ // child with an empty prompt and the turn could never be recovered.
2127
+ prompt: proTurnPrompt(opts),
2021
2128
  model: opts.model && opts.model !== 'default' ? opts.model : undefined,
2022
2129
  system: opts.system,
2023
2130
  cwd: opts.cwd,
@@ -2771,6 +2878,10 @@ function createLocalEngine({
2771
2878
  recoveryRounds,
2772
2879
  finishReason: finishReasonOf(res),
2773
2880
  reasoningChars: reasoningTextOf(res).length,
2881
+ // The withheld-deliberation case (a Pro turn with extended
2882
+ // thinking on): no chars to count, but the provider billed the
2883
+ // thinking. Without this the report claims nothing came back.
2884
+ reasoningTokens: reasoningTokensOf(res),
2774
2885
  });
2775
2886
  }
2776
2887
  return withTurnUsage(res);
@@ -97,6 +97,119 @@ function mirrorProviderKeyToEnvFile(envFile, provider, key, opts = {}) {
97
97
  }
98
98
  }
99
99
 
100
+ /**
101
+ * Copy every provider key the store already holds into the shared env file.
102
+ *
103
+ * `wrapProviderKeyMirror` above is a WRITE-TIME hook: it mirrors a key at the
104
+ * moment it is saved. That leaves a real gap the hook cannot close, because a
105
+ * key saved before the hook existed never fires it — an install whose provider
106
+ * row predates this module (or any row written while the wrapper was not
107
+ * installed, e.g. a direct settings write from a test or a repair script) keeps
108
+ * its key in the encrypted store alone, and the CLI never sees it. A stored key
109
+ * is not a mirrored key; only a fresh Save used to make them the same thing.
110
+ *
111
+ * The same gap swallows a "blank = keep" save: `renderer/app.js` saves a
112
+ * provider row with `key` undefined when the key field is left empty (that
113
+ * means "keep the stored one" — see the wrapper's comment), so the wrapper
114
+ * correctly mirrors nothing and the row's real key stays host-local for ever.
115
+ *
116
+ * So this runs ONCE at start-up and reconciles the two stores by reading, not
117
+ * by writing: for every non-reserved row that HAS a stored key whose variable
118
+ * the shared file does not already carry, write it. It is:
119
+ *
120
+ * - idempotent — a second call on an already-synced machine mirrors nothing
121
+ * (the `keyStatus(...).inFile` check is the guard), and it never rewrites a
122
+ * variable that is already present, so a hand-edited `.env` keeps its own
123
+ * spelling, comments and line order;
124
+ * - one-directional — store -> file only. The file is never read back into
125
+ * the encrypted store, so this cannot become a merge loop or overwrite a
126
+ * newer key the CLI wrote (which the desktop would then be second-guessing);
127
+ * - non-fatal — a row that cannot be decrypted or written is REPORTED and
128
+ * skipped. A start-up reconcile must never keep the window from opening.
129
+ *
130
+ * Must run after app 'ready': `safeStorage` (which decrypts the stored key) is
131
+ * not usable before then.
132
+ *
133
+ * @param {object} store a settings store (needs `list`, and `rawKey` for the
134
+ * decrypted value — the exact pair settings.js exposes)
135
+ * @param {object} envFile client/env-file.js
136
+ * @param {object} [opts] `onWarn`, `envOpts` (see above)
137
+ * @returns {{mirrored:string[], skipped:string[], failed:string[]}} what
138
+ * happened per provider, so a caller/test can assert rather than guess
139
+ */
140
+ function backfillProviderKeys(store, envFile, opts = {}) {
141
+ const warn = typeof opts.onWarn === 'function' ? opts.onWarn : () => {};
142
+ const envOpts = opts.envOpts || {};
143
+ const out = { mirrored: [], skipped: [], failed: [] };
144
+ if (!store || typeof store.list !== 'function') return out;
145
+ if (!envFile || typeof envFile.envVarFor !== 'function') return out;
146
+
147
+ let rows = [];
148
+ try {
149
+ rows = store.list() || [];
150
+ } catch (e) {
151
+ warn(`could not read the provider store to backfill the shared env file: ${e && e.message}`);
152
+ return out;
153
+ }
154
+
155
+ for (const row of rows) {
156
+ const provider = row && row.provider;
157
+ // Same filters as the write-time mirror: a reserved row is the store's own
158
+ // bookkeeping, and an unmappable name is a name we do not invent.
159
+ if (typeof provider !== 'string' || !provider) continue;
160
+ if (provider.startsWith(RESERVED_PREFIX)) continue;
161
+
162
+ const name = envFile.envVarFor(provider);
163
+ if (!name || !KEY_VAR_SUFFIX.test(name)) {
164
+ out.skipped.push(provider);
165
+ continue;
166
+ }
167
+ // A name the env file itself refuses (a production secret, not a host key)
168
+ // is not a failure to report — `setEnvValue` would reject it by design.
169
+ if (typeof envFile.isForeignSecret === 'function' && envFile.isForeignSecret(name)) {
170
+ out.skipped.push(provider);
171
+ continue;
172
+ }
173
+
174
+ // Already shared? Then there is nothing to do, and — importantly — nothing
175
+ // to overwrite. This is the idempotence guard.
176
+ let status = null;
177
+ try {
178
+ status = typeof envFile.keyStatus === 'function' ? envFile.keyStatus(name, envOpts) : null;
179
+ } catch { /* unreadable file is "not in the file"; the write below reports it */ }
180
+ if (status && status.inFile) {
181
+ out.skipped.push(provider);
182
+ continue;
183
+ }
184
+
185
+ // `row.configured` is the store's own "a key is stored" flag (settings.js
186
+ // get() derives it from the decrypted value), so a base-URL-only row is
187
+ // skipped here rather than being asked for a key it never had.
188
+ if (row.configured === false) {
189
+ out.skipped.push(provider);
190
+ continue;
191
+ }
192
+
193
+ let key = '';
194
+ try {
195
+ key = typeof store.rawKey === 'function' ? store.rawKey(provider) : '';
196
+ } catch (e) {
197
+ warn(`could not decrypt ${provider}'s stored key for the shared env file: ${e && e.message}`);
198
+ out.failed.push(provider);
199
+ continue;
200
+ }
201
+ if (!key) {
202
+ out.skipped.push(provider);
203
+ continue;
204
+ }
205
+
206
+ const res = mirrorProviderKeyToEnvFile(envFile, provider, key, opts);
207
+ if (res && res.ok) out.mirrored.push(provider);
208
+ else out.failed.push(provider);
209
+ }
210
+ return out;
211
+ }
212
+
100
213
  /**
101
214
  * Wrap a provider-settings store so every key saved or removed through it is
102
215
  * also written to the shared env file.
@@ -138,4 +251,4 @@ function wrapProviderKeyMirror(store, envFile, opts = {}) {
138
251
  return store;
139
252
  }
140
253
 
141
- module.exports = { mirrorProviderKeyToEnvFile, wrapProviderKeyMirror };
254
+ module.exports = { mirrorProviderKeyToEnvFile, wrapProviderKeyMirror, backfillProviderKeys };
package/main.js CHANGED
@@ -220,6 +220,29 @@ const QUICK_PREFIX = 'quick:';
220
220
  const QUEUE_PREFIX = 'queue:';
221
221
  const AUTO_PREFIX = 'auto:';
222
222
 
223
+ /**
224
+ * The model classes THIS face offers in its pickers, in registry order.
225
+ *
226
+ * Narrower than the engine's `CLASSES` registry on purpose. The engine is
227
+ * shared with the CLI (`cli/src/deps.js` requires this same file), so a class
228
+ * is added or removed once, in `engine.js`, for both hosts — and the CLI does
229
+ * ship `pro` (the user's own Claude Code subscription, spawned through the
230
+ * `claude` binary). This face does not: the Pro transport hands the turn to a
231
+ * headless child that runs its own agent loop and its own permission prompts,
232
+ * and this window has no channel to answer those prompts, so selecting it here
233
+ * is a class a user can pick and then watch do nothing. Omitted rather than
234
+ * listed-and-broken.
235
+ *
236
+ * Filtering HERE rather than in the registry is the same move the CLI makes in
237
+ * `cli/src/engine.js` (`listClasses().filter((c) => HOST_CLASSES.includes(…))`):
238
+ * the registry stays the single source of truth for every class that EXISTS,
239
+ * and each host names the subset it offers. Removing this filter, or adding a
240
+ * class here that `engine.listClasses()` does not return, is a one-line change
241
+ * on this line — and the picker is built from this function's output, so a class
242
+ * absent here is one no user can select, which is the whole point.
243
+ */
244
+ const DESKTOP_CLASSES = Object.freeze(['aegis', 'byok', 'local']);
245
+
223
246
  /**
224
247
  * Main -> renderer push channel for chat SSE deltas (D2.1 streaming render).
225
248
  * The invoke of aegis:chatCompletion still resolves once with the normalised
@@ -1579,7 +1602,10 @@ function createModelDispatch(engine, { env = process.env, settings } = {}) {
1579
1602
  };
1580
1603
 
1581
1604
  const dispatch = {
1582
- listClasses: () => engine.listClasses(),
1605
+ // The picker's source. Filtered through DESKTOP_CLASSES so this face never
1606
+ // offers a class it cannot serve; see that constant for why `pro` is
1607
+ // omitted here while the engine still ships it for the CLI.
1608
+ listClasses: async () => (await engine.listClasses()).filter((c) => DESKTOP_CLASSES.includes(c.class)),
1583
1609
  listModels: (payload) => engine.listModels(payload && payload.class),
1584
1610
  chat: chatWithAutoRounds,
1585
1611
  // The AEGIS key lives in a reserved namespace the store refuses to expose
@@ -3984,6 +4010,36 @@ function bootstrap() {
3984
4010
  } catch (err) {
3985
4011
  console.warn('presence: beacon not started', err && err.message);
3986
4012
  }
4013
+ // One-way backfill of the cross-host key mirror. wrapProviderKeyMirror
4014
+ // (above) is a WRITE-TIME hook: it only fires when a key is saved through
4015
+ // this store, so every row that predates the hook — or was saved with the
4016
+ // key field left blank ("keep the stored one", which mirrors nothing by
4017
+ // design) — still lives only in this app's encrypted store and is invisible
4018
+ // to the CLI and the MCP plugin. Running the reconcile here closes that gap
4019
+ // once per launch, in the store -> file direction only, and skips any
4020
+ // variable the shared file already carries. After app 'ready', because
4021
+ // safeStorage (which decrypts the stored key) is unusable before then, and
4022
+ // deliberately before createWindow() so the CLI's next run — not the next
4023
+ // Save — is when the key becomes visible.
4024
+ //
4025
+ // Never fatal: the keys are already saved, and a failed copy to the shared
4026
+ // file is a smaller problem than a window that does not open.
4027
+ try {
4028
+ const { backfillProviderKeys } = require('./lib/provider-key-mirror.js');
4029
+ const onWarn = (m) => console.warn(`aegis: ${m}`);
4030
+ const result = backfillProviderKeys(settings, envFile, { onWarn });
4031
+ if (result.mirrored.length) {
4032
+ console.warn(
4033
+ `aegis: shared ${result.mirrored.join(', ')} key(s) with the other hosts `
4034
+ + `(${envFile.envFileFor()})`
4035
+ );
4036
+ }
4037
+ if (engine && engine.settings && engine.settings !== settings) {
4038
+ backfillProviderKeys(engine.settings, envFile, { onWarn });
4039
+ }
4040
+ } catch (e) {
4041
+ console.warn(`aegis: could not backfill the shared env-file key mirror: ${e && e.message}`);
4042
+ }
3987
4043
  // Apply whatever was last saved (or the default): ship the shortcut only
3988
4044
  // when app.isPackaged || the settings flag is on — see
3989
4045
  // shouldEnableGlobalShortcut — so a plain `electron .` dev run never
@@ -4054,6 +4110,7 @@ module.exports = {
4054
4110
  taggedChunk,
4055
4111
  maskKey,
4056
4112
  createModelDispatch,
4113
+ DESKTOP_CLASSES,
4057
4114
  readTextFileForPreview,
4058
4115
  PREVIEW_READ_MAX_BYTES,
4059
4116
  createSyncDispatch,
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "aegis-desktop",
3
3
  "productName": "AEGIS Desktop",
4
- "version": "1.0.8",
4
+ "version": "1.0.10",
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",
@@ -58,14 +58,17 @@
58
58
  "test:renderer-fonts": "node test/renderer-fonts.test.mjs",
59
59
  "test:account-key": "node test/account-key.test.mjs"
60
60
  },
61
+ "engines": {
62
+ "node": ">=22.0.0"
63
+ },
61
64
  "dependencies": {
62
65
  "electron-updater": "^6.8.9"
63
66
  },
64
67
  "devDependencies": {
65
- "electron-builder": "^25.1.8",
66
- "electron": "^33.0.0"
68
+ "electron-builder": "^26.15.3",
69
+ "electron": "^44.0.0"
67
70
  },
68
71
  "optionalDependencies": {
69
- "electron": "^33.0.0"
72
+ "electron": "^44.0.0"
70
73
  }
71
74
  }
package/renderer/app.js CHANGED
@@ -572,6 +572,11 @@ function renderRollMeter(sessionId, live) {
572
572
  // model the meter sat on one number for the whole (longest, priciest)
573
573
  // phase of the turn and looked dead while the AI was demonstrably working.
574
574
  reasoning: live.reasoning,
575
+ // …and the provider-measured thinking tokens, which on a subscription
576
+ // turn are the whole of the deliberation (its text is withheld). The
577
+ // preview must carry the same quantity the bubble's own figure shows, or
578
+ // the two live meters disagree — which this file forbids.
579
+ measuredReasoningTokens: live.measuredReasoningTokens,
575
580
  });
576
581
  }
577
582
  const line = roll ? fmtRoll(roll) : '';
@@ -611,6 +616,15 @@ function renderRollMeter(sessionId, live) {
611
616
  function liveTokenFig(live) {
612
617
  const reply = typeof live.reply === 'string' ? live.reply : '';
613
618
  const reasoning = typeof live.reasoning === 'string' ? live.reasoning : '';
619
+ // Thinking tokens the PROVIDER measured — the only live signal on a
620
+ // subscription turn, which withholds the deliberation text entirely (every
621
+ // real `thinking_delta` carries `thinking: ""` while `estimated_tokens`
622
+ // advances). Part of the billed output, so it belongs in this figure exactly
623
+ // as the `reasoning` text does, and it is already a token count.
624
+ const measured =
625
+ typeof live.measuredReasoningTokens === 'number' && live.measuredReasoningTokens > 0
626
+ ? live.measuredReasoningTokens
627
+ : 0;
614
628
  // A turn in flight that has produced nothing YET. Its figure is zero, and
615
629
  // zero is what the other measured surface shows at this moment: the CLI's
616
630
  // spinner tail counts the answer's deltas and the thinking trace's, so it too
@@ -632,7 +646,12 @@ function liveTokenFig(live) {
632
646
  // a threshold. Safe because the caller only passes `live` for a turn still
633
647
  // running: this is a statement about that turn, not a fabricated measurement
634
648
  // of a finished one.
635
- if (!reply && !reasoning) return '↓0 tokens';
649
+ // The measured thinking count alone is enough to have a figure. Without this
650
+ // term a subscription turn — empty `reply`, empty `reasoning` text, but
651
+ // thousands of billed thinking tokens — took the `↓0 tokens` branch below for
652
+ // its whole thinking phase, which is the bug this function's `measured`
653
+ // addend exists to close.
654
+ if (!reply && !reasoning && !measured) return '↓0 tokens';
636
655
  let output = 0;
637
656
  try {
638
657
  const est = estimatedBuckets(live.prompt, reply, reasoning);
@@ -660,8 +679,19 @@ function liveTokenFig(live) {
660
679
  }
661
680
  output = Math.ceil(cells / 4);
662
681
  }
682
+ // Summed, not substituted: `measured` is the provider's count of deliberation
683
+ // that never entered `reply`/`reasoning` as text, so the two are disjoint and
684
+ // both are billed output. Adding it here is also what keeps the figure moving
685
+ // through the thinking phase and then continuing to climb as the answer
686
+ // streams, rather than dropping back to the answer-only estimate.
687
+ output += measured;
663
688
  if (!output) return '';
664
- return `↓${Math.round(output)} tokens`;
689
+ // `measured` is Anthropic's own `estimated_tokens` running count, which the
690
+ // stream parser's own measurements show landing under the real billed
691
+ // thinking-token figure (1900 vs 1943, 1800 vs 1832, 250 vs 339) — so once
692
+ // it has contributed, the figure is a floor, not the true count, until the
693
+ // turn's real `usage` lands.
694
+ return `↓${measured > 0 ? '≥' : ''}${Math.round(output)} tokens`;
665
695
  }
666
696
 
667
697
  /**
@@ -4984,6 +5014,10 @@ async function send() {
4984
5014
 
4985
5015
  let streamedText = '';
4986
5016
  let reasoningText = '';
5017
+ // Thinking tokens the provider MEASURED (subscription turns report progress
5018
+ // in these while withholding the text). Added verbatim to the live figure,
5019
+ // never divided by 4 the way the text channels are.
5020
+ let measuredReasoningTokens = 0;
4987
5021
  const toolLog = [];
4988
5022
  // How much of the stream the live meter last measured. Per-turn by
4989
5023
  // construction (declared here, not at module scope), so a new turn starts its
@@ -5019,14 +5053,39 @@ async function send() {
5019
5053
  // stream and this runs inside the painter: ungated it is linear per frame,
5020
5054
  // i.e. quadratic over the turn, on the thread the reader is scrolling.
5021
5055
  const streamChars = streamedText.length + reasoningText.length;
5022
- if (liveMeterDue(meterChars, streamChars)) {
5023
- meterChars = streamChars;
5024
- renderRollMeter(sessionId, { prompt, reply: streamedText, reasoning: reasoningText });
5056
+ // The provider's thinking tokens are progress too, and on a subscription
5057
+ // turn they are the ONLY progress: no text arrives at all during the
5058
+ // thinking phase, so a char-only gate saw `grown <= 0` and refused to
5059
+ // repaint, leaving the bubble frozen on `↓0 tokens` however many tokens were
5060
+ // billed. Counted at the same 4 chars:1 token ratio the other live surfaces
5061
+ // use, purely so this gate sees growth; the figure itself is rendered from
5062
+ // the token count, never this char-equivalent.
5063
+ const progress = streamChars + measuredReasoningTokens * 4;
5064
+ if (liveMeterDue(meterChars, progress)) {
5065
+ meterChars = progress;
5066
+ renderRollMeter(sessionId, {
5067
+ prompt,
5068
+ reply: streamedText,
5069
+ reasoning: reasoningText,
5070
+ measuredReasoningTokens,
5071
+ });
5025
5072
  }
5026
5073
  stickToBottom();
5027
5074
  });
5028
5075
 
5029
5076
  const onDelta = (chunk) => {
5077
+ // Provider-MEASURED thinking progress, in tokens. On a subscription turn
5078
+ // the deliberation text is withheld (`thinking_delta.thinking` is the empty
5079
+ // string), so the `reasoning` text branch below never fires and the live
5080
+ // meter sat on `↓0 tokens` through the whole thinking phase — the longest,
5081
+ // priciest part of the turn — while thousands of thinking tokens were
5082
+ // billed. Already a token count, so it is added rather than divided by 4
5083
+ // like the text channels.
5084
+ if (chunk && typeof chunk.thinkingTokens === 'number' && chunk.thinkingTokens > 0) {
5085
+ measuredReasoningTokens += chunk.thinkingTokens;
5086
+ paintStream();
5087
+ return;
5088
+ }
5030
5089
  // Extended-reasoning trace from a pooled brain turn: the fan-out's worker
5031
5090
  // findings, streamed before the synthesis pass writes the answer. Shown so
5032
5091
  // "work autonomously" doesn't look idle for the whole worker phase.
@@ -565,13 +565,7 @@
565
565
  <button type="button" class="wc-link" id="welcome-byok">
566
566
  Bring your own key →
567
567
  </button>
568
- <span class="wc-note" id="welcome-byok-note">
569
- The free path, and it stays free: exec, write and edit run on
570
- your machine, your repos, sessions and keys live on this disk,
571
- and a local model server needs no account at all. A provider
572
- key relayed through AEGIS is still a cloud turn, so it bills a
573
- small handling fee — nothing that runs on this machine does.
574
- </span>
568
+ <span class="wc-note" id="welcome-byok-note"></span>
575
569
  <button type="button" class="wc-skip" id="welcome-dismiss">
576
570
  Don't show this again
577
571
  </button>
package/renderer/usage.js CHANGED
@@ -828,16 +828,35 @@ function rollTurn(roll, usage, opts = {}) {
828
828
  // number, and what stops the total standing still on exactly the turns the
829
829
  // pool declined to report on — the symptom this fallback exists to remove.
830
830
  const est = estimatedBuckets(opts.prompt, opts.reply, opts.reasoning);
831
+ // Thinking tokens the provider MEASURED, not estimated. On a subscription
832
+ // turn the deliberation text is withheld entirely (every real
833
+ // `thinking_delta` carries `thinking: ""`), so `estimatedBuckets` above
834
+ // cannot see the longest, priciest part of the turn and the live preview
835
+ // read 0 for the whole thinking phase. Added verbatim — it is already a
836
+ // token count, and it is billed output exactly as the reply is.
837
+ const measured =
838
+ typeof opts.measuredReasoningTokens === 'number' && opts.measuredReasoningTokens > 0
839
+ ? opts.measuredReasoningTokens
840
+ : 0;
831
841
  if (!est) {
832
- // Nothing reported AND no text to estimate from. The single case that
833
- // stays uncounted: `unknown` names the gap, where a fabricated zero would
834
- // read as a measurement.
835
- next.unknown += 1;
842
+ // A subscription turn with nothing written yet but thinking under way is
843
+ // NOT the `unknown` case: the provider measured it, so a zero would be a
844
+ // fabricated reading rather than an absent one.
845
+ if (!measured) {
846
+ // Nothing reported AND no text to estimate from. The single case that
847
+ // stays uncounted: `unknown` names the gap, where a fabricated zero
848
+ // would read as a measurement.
849
+ next.unknown += 1;
850
+ return next;
851
+ }
852
+ next.tokens += measured;
853
+ next.output += measured;
854
+ next.estimated += 1;
836
855
  return next;
837
856
  }
838
- next.tokens += est.input + est.output;
857
+ next.tokens += est.input + est.output + measured;
839
858
  next.input += est.input;
840
- next.output += est.output;
859
+ next.output += est.output + measured;
841
860
  next.estimated += 1;
842
861
  return next;
843
862
  }
package/vendor/aegis.js CHANGED
@@ -1133,6 +1133,7 @@ function createClient(opts = {}) {
1133
1133
  onStream,
1134
1134
  signal,
1135
1135
  providerKey,
1136
+ baseURL,
1136
1137
  } = {}) {
1137
1138
  const key = providerKey !== undefined ? providerKey : opts.providerKey;
1138
1139
  // Same rule as the pooled path above: omit `max_tokens` entirely when the
@@ -1169,6 +1170,11 @@ function createClient(opts = {}) {
1169
1170
  // `tools: []` differently from the field's plain absence.
1170
1171
  ...(Array.isArray(tools) && tools.length ? { tools } : {}),
1171
1172
  ...(toolChoice ? { tool_choice: toolChoice } : {}),
1173
+ // Read server-side ONLY for provider "custom" (aegis1 app.py
1174
+ // byok_chat_completions) — every other provider's upstream host is a
1175
+ // constant the server already knows, so sending this for them is a
1176
+ // no-op rather than a behaviour change.
1177
+ ...(baseURL ? { base_url: baseURL } : {}),
1172
1178
  };
1173
1179
  if (model) body.model = model;
1174
1180
  const headers = {
@@ -27,7 +27,7 @@
27
27
  *
28
28
  * ── what a user does now ───────────────────────────────────────────────────
29
29
  *
30
- * CLI `/model-add <provider> <model-id>` (and `/model-rm …`)
30
+ * CLI `/model-add <provider> [api-key] <model-id>` (and `/model-rm …`)
31
31
  * Desktop Settings → "Add a model" → provider + id
32
32
  * Either hand-edit `$AEGISCODE_HOME/models.json` (default `~/.aegiscode`)
33
33
  *