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 +1 -1
- package/lib/local/autonomous.js +48 -8
- package/lib/local/claude-pro.js +119 -2
- package/lib/local/engine.js +116 -5
- package/lib/provider-key-mirror.js +114 -1
- package/main.js +58 -1
- package/package.json +7 -4
- package/renderer/app.js +64 -5
- package/renderer/index.html +1 -7
- package/renderer/usage.js +25 -6
- package/vendor/aegis.js +6 -0
- package/vendor/user-models.js +1 -1
package/README.md
CHANGED
package/lib/local/autonomous.js
CHANGED
|
@@ -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
|
-
//
|
|
598
|
-
// item whose model
|
|
599
|
-
// `--model` the CLI accepted before this rule existed,
|
|
600
|
-
// older build) would otherwise go out
|
|
601
|
-
//
|
|
602
|
-
// spelling, an opaque server error otherwise
|
|
603
|
-
//
|
|
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) {
|
package/lib/local/claude-pro.js
CHANGED
|
@@ -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:
|
|
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: [
|
|
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:
|
package/lib/local/engine.js
CHANGED
|
@@ -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
|
-
`${
|
|
993
|
-
(
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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": "^
|
|
66
|
-
"electron": "^
|
|
68
|
+
"electron-builder": "^26.15.3",
|
|
69
|
+
"electron": "^44.0.0"
|
|
67
70
|
},
|
|
68
71
|
"optionalDependencies": {
|
|
69
|
-
"electron": "^
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
5023
|
-
|
|
5024
|
-
|
|
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.
|
package/renderer/index.html
CHANGED
|
@@ -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
|
-
//
|
|
833
|
-
//
|
|
834
|
-
//
|
|
835
|
-
|
|
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 = {
|
package/vendor/user-models.js
CHANGED
|
@@ -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
|
*
|