aegis-desktop 0.4.0 → 0.4.2

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
@@ -1,8 +1,49 @@
1
1
  # AEGIS Desktop
2
2
 
3
- Electron host see the repo root [README.md](../README.md) for the full
4
- build/architecture reference. This file covers day-to-day usage details that
5
- don't belong in the top-level doc.
3
+ A standalone Electron chat app over the [AEGIS](https://aegiscloud.org) API,
4
+ with an **agentic tool loop**: the model can read, write, and edit files,
5
+ list directories, glob, grep, run shell commands in a persistent session, and
6
+ delegate whole sub-tasks to subagents. It does **not** require Claude Code.
7
+
8
+ ```bash
9
+ npm install -g aegis-desktop
10
+ aegis
11
+ ```
12
+
13
+ ## Model classes
14
+
15
+ Pick any of four transports from the model-class picker, switchable
16
+ mid-conversation with context intact:
17
+
18
+ | Class | Transport | Key held in |
19
+ |---|---|---|
20
+ | **Aegis Cloud** | `aegiscloud.org` — one entry, **Nexus**; the pool auto-routes across whichever providers are live | main process |
21
+ | **Ollama** | local `ollama` daemon | no key needed |
22
+ | **Custom OpenAI-compatible** (LM Studio, OpenRouter, vLLM, …) | direct from the desktop app | main process — never sent to the renderer |
23
+ | **Anthropic-compatible** (Claude, or any Messages-format gateway) | direct from the desktop app | main process |
24
+
25
+ Get a free AEGIS key at **https://aegiscloud.org**, or use your own
26
+ Ollama/OpenAI-compatible/Anthropic-compatible endpoint — no AEGIS account
27
+ needed for those.
28
+
29
+ ## Tools available to the model
30
+
31
+ | Tool | What it does |
32
+ |---|---|
33
+ | `readFile` · `writeFile` · `editFile` | File access scoped to the working directory |
34
+ | `listDir` · `glob` · `grep` | Navigate and search a tree |
35
+ | `exec` | Run commands in a persistent shell session |
36
+ | `task` | Delegate a self-contained sub-task to a subagent |
37
+
38
+ Before `exec`, `writeFile`, or `editFile` runs, a diff/approval card asks you
39
+ to confirm — approve once, approve for the rest of the conversation, or deny.
40
+ Flip **Settings → "Confirm before running tools"** off if you'd rather the
41
+ agent run mutating tool calls without asking; it's on by default.
42
+
43
+ Conversations persist locally and sync to AEGIS cloud memory via a pending
44
+ queue that flushes on each "Sync now" or heartbeat retry. The **remember**
45
+ button on any assistant reply pins that message to cross-machine memory —
46
+ queued locally if you're offline.
6
47
 
7
48
  ## Keyboard shortcuts
8
49
 
@@ -45,6 +86,15 @@ accelerator is already claimed by another application, registration fails
45
86
  gracefully: a warning is logged to the main process console and the
46
87
  Quick Launcher card shows the reason instead of the app crashing or hanging.
47
88
 
89
+ ## Deep links
90
+
91
+ The app registers an `aegis://` protocol handler:
92
+
93
+ | Link | What it does |
94
+ |---|---|
95
+ | `aegis://open?session=<id>` | Resumes a saved session |
96
+ | `aegis://new?prompt=<text>` | Starts a fresh chat with that prompt pre-filled |
97
+
48
98
  ## Run from source
49
99
 
50
100
  ```bash
@@ -53,9 +103,35 @@ npm install
53
103
  npm start
54
104
  ```
55
105
 
106
+ ## Build a distributable
107
+
108
+ ```bash
109
+ npm run dist # packaged app (AppImage / MSI+NSIS / dmg)
110
+ npm run dist:dir # unpacked dir, for quick testing
111
+ ```
112
+
56
113
  ## Checks
57
114
 
58
115
  ```bash
59
- npm run check # node --check every main-process + renderer file
116
+ npm run check # node --check every main-process + renderer file
60
117
  node ../test/desktop-shell.mjs # headless IPC smoke test (no Electron binary needed)
61
118
  ```
119
+
120
+ ## Structure
121
+
122
+ ```
123
+ main.js Electron main process — window + IPC shell only
124
+ preload.js Context-isolated IPC bridge exposed to the renderer
125
+ renderer/ UI (vanilla JS, no framework)
126
+ lib/local/ Model classes, providers, agentic tool loop, prompt
127
+ lib/sync/ Local session/memory persistence + sync queue
128
+ vendor/aegis.js The AEGIS transport client (thin — no engine logic)
129
+ bin/aegis.js `aegis` CLI entry point for the global npm install
130
+ ```
131
+
132
+ This directory is part of the [aegiscode-plugin](../README.md) monorepo,
133
+ which also ships a Claude Code plugin and the shared `client/aegis.js`
134
+ transport over the same AEGIS backend — see the repo root for that fuller
135
+ architecture picture. A read-only mirror of just this directory (for
136
+ browsing or `git clone`) lives at
137
+ [aegiscloud/aegiscode-desktop](https://github.com/aegiscloud/aegiscode-desktop).
@@ -123,23 +123,47 @@ function normalizeCatalog(models) {
123
123
  }
124
124
 
125
125
  /**
126
- * The Aegis Cloud catalog (`/api/v1/models`) also lists internal routing
127
- * aliases per-provider variants like `openai-gpt4o-mini`/`anthropic-haiku`,
128
- * and six pooled-brain tier ids (`{aegis,nexus}-brain[-smart|-neo]`) that all
129
- * run the same worker pool on the same backend model. None of those are a
130
- * human's model choice; picking between them put six near-duplicate "brain"
131
- * entries in the desktop dropdown. Surface only the four platform models the
132
- * user actually selects between, plus one collapsed "Nexus" entry standing
133
- * in for whichever brain tier the pool advertises.
126
+ * The Aegis Cloud catalog (`/api/v1/models`) lists every backend the pool can
127
+ * reach: per-provider ids (`openai`, `anthropic`, `groq`, `gemini`, ...) and
128
+ * six pooled-brain tier ids (`{aegis,nexus}-brain[-smart|-neo]`) that all run
129
+ * the same worker pool on the same backend model. None of that is a human's
130
+ * model choice which providers currently hold a valid key is an ops detail
131
+ * (today: deepseek/anthropic/groq; openai and gemini drift in and out), and
132
+ * surfacing it invites picking a provider that happens to be dead right now.
133
+ * The pool already auto-routes across whichever providers are live, so the
134
+ * desktop dropdown offers exactly one entry for the "aegis" class: the
135
+ * collapsed "Nexus" brain — never the raw provider list.
134
136
  */
135
- const AEGIS_PLATFORM_MODELS = Object.freeze(['openai', 'anthropic', 'groq', 'gemini']);
136
- const NEXUS_BRAIN_ID = 'aegis-brain';
137
- const NEXUS_LABEL = 'Nexus (Aegis brain)';
137
+ const NEXUS_BRAIN_IDS = Object.freeze(['nexus-brain', 'aegis-brain']);
138
+ const NEXUS_LABEL = 'Nexus';
139
+
140
+ /**
141
+ * Pick the one entry standing in for the pooled brain. The server serves
142
+ * `nexus-brain` as the canonical tier (`hidden: false`, no `alias_of`) and
143
+ * marks every other spelling — `aegis-brain` and the `-smart`/`-neo` tiers —
144
+ * as `hidden: true, alias_of: "nexus-brain"`. Prefer the canonical id so the
145
+ * request travels on the name the server owns; fall back to the alias, then to
146
+ * any tier whose `alias_of` names the brain, so a renamed or trimmed catalog
147
+ * still resolves to something selectable instead of silently emptying the
148
+ * dropdown (the previous fixed-id lookup returned [] if `aegis-brain` was ever
149
+ * retired, leaving the class with no model to choose).
150
+ */
151
+ function selectBrainEntry(models) {
152
+ return (
153
+ models.find((m) => NEXUS_BRAIN_IDS.includes(m.id) && !m.hidden && m.alias_of === undefined)
154
+ || models.find((m) => NEXUS_BRAIN_IDS.includes(m.id))
155
+ || models.find((m) => m.alias_of && NEXUS_BRAIN_IDS.includes(m.alias_of))
156
+ || null
157
+ );
158
+ }
138
159
 
139
160
  function filterAegisCatalog(models) {
140
- const platform = models.filter((m) => AEGIS_PLATFORM_MODELS.includes(m.id));
141
- const nexus = models.find((m) => m.id === NEXUS_BRAIN_ID);
142
- return nexus ? [...platform, { ...nexus, label: NEXUS_LABEL }] : platform;
161
+ const nexus = selectBrainEntry(models);
162
+ if (!nexus) return [];
163
+ // Drop the alias bookkeeping: this entry *is* the selection, so the renderer
164
+ // must never treat it as a hidden alias and filter it back out.
165
+ const { hidden, alias_of, ...rest } = nexus;
166
+ return [{ ...rest, label: NEXUS_LABEL }];
143
167
  }
144
168
 
145
169
  // ── Agent-loop helpers ──────────────────────────────────────────────────────
@@ -497,6 +521,12 @@ function createLocalEngine({ aegis, settings, ollama, providers, tools, promptBu
497
521
  mode: opts.mode,
498
522
  maxTokens: opts.maxTokens,
499
523
  stream: true,
524
+ // The pooled (Nexus) brain is streamed, and an OpenAI-compatible SSE
525
+ // stream reports no token usage unless asked. Without this the Aegis
526
+ // Cloud class — the desktop's default — was the one class that answered
527
+ // with text but never a token count, so a pooled turn's spend was
528
+ // invisible here while the same model through the MCP path reported it.
529
+ includeUsage: true,
500
530
  onStream: opts.onDelta,
501
531
  // Extended-reasoning trace (the fan-out's worker findings). Its own
502
532
  // channel so it never counts as answer text — see vendor/aegis.js.
@@ -658,6 +688,41 @@ function createLocalEngine({ aegis, settings, ollama, providers, tools, promptBu
658
688
  let truncationRetried = false;
659
689
  let synthesisDone = false;
660
690
 
691
+ // Token accounting for the whole TURN, not just its last round. An
692
+ // agentic turn makes one provider call per tool round, and returning only
693
+ // the final round's `usage` (what this did) reported a fraction of what
694
+ // was actually spent — the tool phase's tokens simply disappeared. Every
695
+ // dispatch is summed, including the truncation retry and the synthesis
696
+ // re-dispatch: both are real, separately billed provider calls.
697
+ const turnUsage = { calls: 0 };
698
+ const USAGE_FIELDS = ['input_tokens', 'output_tokens', 'prompt_tokens', 'completion_tokens', 'total_tokens'];
699
+ const addUsage = (res) => {
700
+ const u = res && res.usage;
701
+ if (!u || typeof u !== 'object') return;
702
+ turnUsage.calls += 1;
703
+ for (const key of USAGE_FIELDS) {
704
+ if (typeof u[key] === 'number' && Number.isFinite(u[key])) {
705
+ turnUsage[key] = (turnUsage[key] || 0) + u[key];
706
+ }
707
+ }
708
+ // A provider that reports only the split (Anthropic-compatible) has no
709
+ // total to sum, so derive one or the renderer has nothing to print.
710
+ if (typeof u.total_tokens !== 'number') {
711
+ const derived =
712
+ (u.prompt_tokens ?? u.input_tokens ?? 0) + (u.completion_tokens ?? u.output_tokens ?? 0);
713
+ if (derived) turnUsage.total_tokens = (turnUsage.total_tokens || 0) + derived;
714
+ }
715
+ };
716
+ // The turn's totals win over any single round's, so the attached usage is
717
+ // never the last round wearing the whole turn's label.
718
+ const withTurnUsage = (res) => {
719
+ if (!res || typeof res !== 'object' || !turnUsage.calls) return res;
720
+ // Built from the accumulator alone: every field already present in a
721
+ // round's usage was summed into it, so merging the last round back in
722
+ // could only reintroduce a partial number under a whole-turn label.
723
+ return { ...res, usage: { ...turnUsage } };
724
+ };
725
+
661
726
  // Round 1's shorthand prompt was sent as `prompt`, not as a message, so
662
727
  // any follow-up dispatch in this turn must fold it into the history
663
728
  // first or the model would be shown a nudge with no question above it.
@@ -683,6 +748,7 @@ function createLocalEngine({ aegis, settings, ollama, providers, tools, promptBu
683
748
  if (!retriable) throw e;
684
749
  res = await dispatch(cls, { ...opts, tools: [] });
685
750
  }
751
+ addUsage(res);
686
752
 
687
753
  // Budget exhausted before the answer was written. Doubling it costs
688
754
  // one request and converts a dead turn into a real one; a second
@@ -698,11 +764,12 @@ function createLocalEngine({ aegis, settings, ollama, providers, tools, promptBu
698
764
  if (!truncationRetried && !assistantText(res) && isTruncated(res)) {
699
765
  truncationRetried = true;
700
766
  res = await dispatch(cls, { ...opts, maxTokens: doubledBudget(opts.maxTokens) });
767
+ addUsage(res);
701
768
  }
702
769
 
703
770
  const calls = toolSchemas.length ? extractToolCalls(res) : [];
704
771
  if (!calls.length) {
705
- if (assistantText(res) || synthesisDone || !toolsEnabled) return res;
772
+ if (assistantText(res) || synthesisDone || !toolsEnabled) return withTurnUsage(res);
706
773
  // The model stopped without calling a tool and without saying
707
774
  // anything. Force the summary out of the context it already holds
708
775
  // instead of handing the renderer a blank completion. Skipped when
@@ -712,6 +779,7 @@ function createLocalEngine({ aegis, settings, ollama, providers, tools, promptBu
712
779
  foldPromptIntoHistory();
713
780
  history.push({ role: 'user', content: EMPTY_TURN_NUDGE });
714
781
  res = await dispatch(cls, { ...opts, messages: history, prompt: '', tools: [] });
782
+ addUsage(res);
715
783
  if (!assistantText(res)) {
716
784
  throw emptyTurnError({
717
785
  cls,
@@ -720,7 +788,7 @@ function createLocalEngine({ aegis, settings, ollama, providers, tools, promptBu
720
788
  finishReason: finishReasonOf(res),
721
789
  });
722
790
  }
723
- return res;
791
+ return withTurnUsage(res);
724
792
  }
725
793
 
726
794
  foldPromptIntoHistory();
@@ -743,6 +811,8 @@ function createLocalEngine({ aegis, settings, ollama, providers, tools, promptBu
743
811
  cls, model, maxTokens, mode: payload && payload.mode, parentSignal: signal, depth, rootSessionId, rootOnDelta,
744
812
  })
745
813
  : await gatedExecuteTool(call, { toolCtx, rootSessionId, rootOnDelta, signal });
814
+ // A subagent's spend rides back on its tool result (see runSubagent).
815
+ if (result && result.usage) addUsage({ usage: result.usage });
746
816
  if (onDelta) onDelta({ delta: '', tool: { name: call.name, args: call.args, ok: result.ok } });
747
817
  history.push({
748
818
  role: 'tool',
@@ -798,9 +868,15 @@ function createLocalEngine({ aegis, settings, ollama, providers, tools, promptBu
798
868
  () => {}
799
869
  );
800
870
  const text = assistantText(res);
871
+ // The subagent's tokens were billed to the same account as the parent
872
+ // turn, so its usage travels back on the tool result and is summed into
873
+ // the parent's total. Attached even on the no-output branch: a subagent
874
+ // that burned a full context and said nothing is precisely the spend the
875
+ // user most needs to see.
876
+ const spent = res && res.usage ? { usage: res.usage } : {};
801
877
  return text
802
- ? { ok: true, output: text }
803
- : { ok: false, error: `subagent (${label}) produced no output` };
878
+ ? { ok: true, output: text, ...spent }
879
+ : { ok: false, error: `subagent (${label}) produced no output`, ...spent };
804
880
  } catch (e) {
805
881
  return { ok: false, error: `subagent (${label}) failed: ${e && e.message ? e.message : e}` };
806
882
  } finally {
@@ -380,6 +380,48 @@ async function openaiCompatible({
380
380
  return result;
381
381
  }
382
382
 
383
+ /**
384
+ * Normalise an Anthropic usage object into the one shape every caller reads.
385
+ *
386
+ * Anthropic reports tokens split across TWO events and in TWO pieces:
387
+ * - `message_start` carries the prompt side (`input_tokens`, plus
388
+ * `cache_creation_input_tokens` / `cache_read_input_tokens`);
389
+ * - `message_delta` carries ONLY the cumulative `output_tokens`.
390
+ * Nothing in the wire format is called `total_tokens`, which is the single
391
+ * field the renderer prints — so every Anthropic-compatible model reported no
392
+ * token count at all while OpenAI-compatible ones showed one.
393
+ *
394
+ * Returns `null` for an empty/absent usage so the caller can omit the field
395
+ * rather than advertise a fake `0`.
396
+ */
397
+ function normalizeAnthropicUsage(raw) {
398
+ if (!raw || typeof raw !== 'object') return null;
399
+ const num = (v) => (typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : 0);
400
+ const input = num(raw.input_tokens);
401
+ const output = num(raw.output_tokens);
402
+ const cacheRead = num(raw.cache_read_input_tokens);
403
+ const cacheCreate = num(raw.cache_creation_input_tokens);
404
+ // Cached prompt tokens are still prompt tokens: OpenAI counts them inside
405
+ // `prompt_tokens` (exposing the subset as prompt_tokens_details.cached), so
406
+ // folding them in keeps the two wire formats' numbers comparable — and a
407
+ // turn that reads a big cached prefix must not look free.
408
+ const prompt = input + cacheRead + cacheCreate;
409
+ if (!prompt && !output) return null;
410
+ const usage = {
411
+ // Anthropic's own names first, so a caller reading native blocks keeps both
412
+ // halves of the split it used to get (it previously got only output_tokens).
413
+ input_tokens: input,
414
+ output_tokens: output,
415
+ // OpenAI spellings, read by the renderer and every other transport here.
416
+ prompt_tokens: prompt,
417
+ completion_tokens: output,
418
+ total_tokens: prompt + output,
419
+ };
420
+ if (cacheRead) usage.cache_read_input_tokens = cacheRead;
421
+ if (cacheCreate) usage.cache_creation_input_tokens = cacheCreate;
422
+ return usage;
423
+ }
424
+
383
425
  /** Anthropic Messages streaming chat (x-api-key + anthropic-version). */
384
426
  async function anthropicMessages({
385
427
  baseURL,
@@ -423,8 +465,11 @@ async function anthropicMessages({
423
465
 
424
466
  let fullText = '';
425
467
  let resultModel = modelId;
426
- let usage = null;
427
468
  let stopReason = null;
469
+ // Accumulated, never replaced: the prompt half arrives on `message_start` and
470
+ // the output half on `message_delta`, so assignment (which is what this used
471
+ // to do) left whichever event came last owning the whole object.
472
+ const usageAcc = {};
428
473
  const toolBlocks = new Map(); // content-block index → {id, name, args}
429
474
 
430
475
  await requestStream({
@@ -446,7 +491,7 @@ async function anthropicMessages({
446
491
  }
447
492
  if (json.type === 'message_start' && json.message) {
448
493
  if (json.message.model) resultModel = json.message.model;
449
- if (json.message.usage) usage = json.message.usage;
494
+ if (json.message.usage) Object.assign(usageAcc, json.message.usage);
450
495
  }
451
496
  // A tool_use block opens here; its arguments arrive as
452
497
  // input_json_delta fragments below (the text deltas we already parsed
@@ -474,13 +519,30 @@ async function anthropicMessages({
474
519
  }
475
520
  }
476
521
  if (json.type === 'message_delta') {
477
- if (json.usage) usage = json.usage;
522
+ // `message_delta.usage` is PARTIAL — `output_tokens` only — so it is
523
+ // merged onto what `message_start` already reported, not assigned over
524
+ // it. Assigning it wholesale is what made prompt tokens vanish from
525
+ // every Anthropic-compatible call's accounting.
526
+ if (json.usage) {
527
+ for (const [k, v] of Object.entries(json.usage)) {
528
+ if (k === 'output_tokens') continue;
529
+ // Only take a field this event carries a real value for. Some
530
+ // Anthropic-compatible gateways echo the whole usage object here
531
+ // with a zeroed prompt side, and letting a bogus 0 overwrite the
532
+ // count message_start already reported is the same data loss in a
533
+ // different costume.
534
+ if (usageAcc[k] == null || (typeof v === 'number' && v > 0)) usageAcc[k] = v;
535
+ }
536
+ if (typeof json.usage.output_tokens === 'number') {
537
+ usageAcc.output_tokens = json.usage.output_tokens;
538
+ }
539
+ }
478
540
  if (json.delta && json.delta.stop_reason) stopReason = json.delta.stop_reason;
479
541
  }
480
542
  // Non-streaming fallback: a full Anthropic Message with content blocks.
481
543
  if (Array.isArray(json.content)) {
482
544
  if (json.model) resultModel = json.model;
483
- if (json.usage) usage = json.usage;
545
+ if (json.usage) Object.assign(usageAcc, json.usage);
484
546
  if (json.stop_reason) stopReason = json.stop_reason;
485
547
  json.content.forEach((block, i) => {
486
548
  if (block && block.type === 'text' && block.text && !fullText) {
@@ -501,6 +563,7 @@ async function anthropicMessages({
501
563
 
502
564
  const toolCalls = finalizeToolCalls(toolBlocks);
503
565
  const result = { model: resultModel, choices: [{ message: { content: fullText } }] };
566
+ const usage = normalizeAnthropicUsage(usageAcc);
504
567
  if (usage) result.usage = usage;
505
568
  if (stopReason) result.stop_reason = stopReason;
506
569
  if (toolCalls.length) {
package/main.js CHANGED
@@ -1432,11 +1432,6 @@ function bootstrap() {
1432
1432
  pushToMain: pushQuickLauncherResult,
1433
1433
  });
1434
1434
  registerQuickLauncherIpc(ipcMain, quickLauncherDispatch);
1435
- // Apply whatever was last saved (or the default) right away: ship the
1436
- // shortcut only when app.isPackaged || the settings flag is on — see
1437
- // shouldEnableGlobalShortcut — so a plain `electron .` dev run never grabs
1438
- // a systemwide hotkey unless the developer opted in from Settings.
1439
- quickLauncherDispatch.setConfig(settings.quickLauncherConfig());
1440
1435
 
1441
1436
  // A held global shortcut outlives this app if not released — every quit
1442
1437
  // path (explicit quit, window-all-closed on non-mac, OS shutdown) must
@@ -1572,6 +1567,12 @@ function bootstrap() {
1572
1567
  // key was saved in-app (safeStorage is usable only after app ready).
1573
1568
  const persistedKey = settings.aegisRawKey();
1574
1569
  if (persistedKey) aegis.setApiKey(persistedKey);
1570
+ // Apply whatever was last saved (or the default): ship the shortcut only
1571
+ // when app.isPackaged || the settings flag is on — see
1572
+ // shouldEnableGlobalShortcut — so a plain `electron .` dev run never
1573
+ // grabs a systemwide hotkey unless the developer opted in from Settings.
1574
+ // Must run after 'ready' — globalShortcut throws before then.
1575
+ quickLauncherDispatch.setConfig(settings.quickLauncherConfig());
1575
1576
  const win = createWindow();
1576
1577
  updateManager.start();
1577
1578
  // Cold-launch deep link (Linux/Windows argv, or a pre-ready macOS
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "aegis-desktop",
3
3
  "productName": "AEGIS Desktop",
4
- "version": "0.4.0",
4
+ "version": "0.4.2",
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",
@@ -29,7 +29,7 @@
29
29
  "icon": "node scripts/generate-icon.mjs",
30
30
  "dist": "npm run predist && electron-builder",
31
31
  "dist:dir": "npm run predist && electron-builder --dir",
32
- "check": "node --check main.js && node --check preload.js && node --check renderer/app.js && node --check renderer/quick.js && node --check renderer/max-tokens.js && node --check renderer/markdown.js && node --check renderer/vendor/aegis-highlight.js && node --check scripts/predist.mjs && node --check scripts/generate-icon.mjs && node --check lib/local/context.js && node --check lib/local/providers.js && node --check lib/local/ollama.js && node --check lib/local/engine.js && node --check lib/local/tools.js && node --check lib/local/prompt.js && node --check lib/local/shell.js && node --check lib/local/agents.js && node --check lib/settings.js && node --check lib/sync/sessions.js && node --check lib/sync/memory-queue.js && node --check lib/window-state.js && node --check lib/deep-link.js && node --check lib/quick-launcher.js && node --check bin/aegis.js",
32
+ "check": "node --check main.js && node --check preload.js && node --check renderer/app.js && node --check renderer/quick.js && node --check renderer/max-tokens.js && node --check renderer/usage.js && node --check renderer/stream-policy.js && node --check renderer/transcript-view.js && node --check renderer/markdown.js && node --check renderer/vendor/aegis-highlight.js && node --check scripts/predist.mjs && node --check scripts/generate-icon.mjs && node --check lib/local/context.js && node --check lib/local/providers.js && node --check lib/local/ollama.js && node --check lib/local/engine.js && node --check lib/local/tools.js && node --check lib/local/prompt.js && node --check lib/local/shell.js && node --check lib/local/agents.js && node --check lib/settings.js && node --check lib/sync/sessions.js && node --check lib/sync/memory-queue.js && node --check lib/window-state.js && node --check lib/deep-link.js && node --check lib/quick-launcher.js && node --check bin/aegis.js",
33
33
  "test:shell": "node ../test/desktop-shell.mjs",
34
34
  "test:model": "node ../test/model-dispatch.mjs",
35
35
  "test:max-tokens": "node ../test/max-tokens.test.mjs",
@@ -38,7 +38,10 @@
38
38
  "test:engine": "node ../test/local-engine.test.mjs",
39
39
  "test:highlight": "node ../test/aegis-highlight.test.mjs",
40
40
  "test:markdown": "node ../test/markdown.test.mjs",
41
- "test:deep-link": "node test/deep-link.test.mjs"
41
+ "test:deep-link": "node test/deep-link.test.mjs",
42
+ "test:stream-policy": "node ../test/stream-policy.test.mjs",
43
+ "test:renderer-dom": "node ../test/renderer-dom.test.mjs",
44
+ "test:renderer-wiring": "node ../test/renderer-wiring.test.mjs"
42
45
  },
43
46
  "dependencies": {
44
47
  "electron": "^33.0.0",
package/renderer/app.js CHANGED
@@ -13,9 +13,10 @@
13
13
  * selection, routed in the main process. If this file grows engine logic it is
14
14
  * wrong.
15
15
  *
16
- * `maxTokensCeiling`/`FLAT_CEILING` come from max-tokens.js, a sibling
17
- * classic script loaded before this one (see index.html) so the per-model
18
- * ceiling math stays unit-testable without window.aegis/window.models.
16
+ * `maxTokensCeiling`/`FLAT_CEILING` come from max-tokens.js and `usageTokens`
17
+ * from usage.js, sibling classic scripts loaded before this one (see
18
+ * index.html) so the per-model ceiling math and the token-usage displayed
19
+ * number mapping stay unit-testable without window.aegis/window.models.
19
20
  */
20
21
 
21
22
  // Everything below runs inside an IIFE. preload.js's contextBridge.exposeInMainWorld
@@ -249,6 +250,79 @@ function abortBranches() {
249
250
  activeBranches.clear();
250
251
  }
251
252
 
253
+ // ----------------------------------------------------------------- streaming
254
+ // Two problems share a root: a running turn owns the transcript. It scrolls
255
+ // the view on every chunk and repaints on every chunk, so the user can neither
256
+ // read earlier turns nor stay responsive enough to hit "stop". Both are fixed
257
+ // by giving the reader a veto over the scroll and batching the paints.
258
+ //
259
+ // The decisions *and* their DOM listeners live in transcript-view.js (a
260
+ // sibling classic script loaded before this one), so the behaviours this path
261
+ // exists to guarantee — follow only at the tail, one paint per frame, Escape
262
+ // interrupts — are asserted against real code in test/renderer-dom.test.mjs
263
+ // instead of only as pure math in stream-policy.js.
264
+
265
+ /**
266
+ * Set when the user asks the running turn to stop. The abort comes back as a
267
+ * rejected IPC call, which does not preserve `err.name`, so this flag — not an
268
+ * AbortError check — is what distinguishes a stop the user asked for from a
269
+ * genuine failure.
270
+ */
271
+ let userStopped = false;
272
+
273
+ /**
274
+ * Transcript policy, created by init(): the reader's scroll veto, the
275
+ * frame-coalesced painter, and the Escape→stop listener all live in it. Built
276
+ * in init() rather than here because #messages does not exist until the body
277
+ * has parsed.
278
+ */
279
+ let transcript = null;
280
+
281
+ /**
282
+ * Auto-scroll only while the reader is still at the tail — the rule itself is
283
+ * `shouldFollow` in transcript-view.js. Null-safe: a boot that failed early
284
+ * must not turn into a second error on the first paint.
285
+ */
286
+ function stickToBottom(opts) {
287
+ if (!transcript) return false;
288
+ return transcript.follow(opts);
289
+ }
290
+
291
+ /** One paint per frame, off the latest cumulative text — see transcript-view.js. */
292
+ function rafPainter(paint) {
293
+ // Every caller runs after boot; painting directly is the safe degradation.
294
+ return transcript ? transcript.paint(paint) : paint;
295
+ }
296
+
297
+ /**
298
+ * Stop the turn running right now. The transport already honours the abort all
299
+ * the way down (engine.cancel -> AbortController -> the cloud client's fetch),
300
+ * so this only has to reach it — the button in the bubble and Escape are two
301
+ * doors onto the same call.
302
+ */
303
+ function stopPendingTurn() {
304
+ if (!pendingSessionId) return false;
305
+ // Recorded before the abort lands: `send()`'s catch reads it to tell a
306
+ // deliberate stop from a real error.
307
+ userStopped = true;
308
+ try {
309
+ models.cancel(pendingSessionId);
310
+ } catch {
311
+ /* a dead controller is not an error */
312
+ }
313
+ // The abort is not instantaneous. Marking the button is all the feedback that
314
+ // survives the trip: `setBusy(false)` deletes the entire pending bubble on
315
+ // the way out, so anything written into it would vanish a moment later.
316
+ if (pendingEl) {
317
+ const btn = pendingEl.querySelector('.cancel-btn');
318
+ if (btn) {
319
+ btn.disabled = true;
320
+ btn.textContent = 'stopping…';
321
+ }
322
+ }
323
+ return true;
324
+ }
325
+
252
326
  function exploreEnabled() {
253
327
  const box = els.exploreToggle;
254
328
  return Boolean(box && box.checked);
@@ -1196,7 +1270,10 @@ function addMessage(role, text, meta, sessionId, toolLog) {
1196
1270
  }
1197
1271
 
1198
1272
  els.messages.appendChild(row);
1199
- els.messages.scrollTop = els.messages.scrollHeight;
1273
+ // Follows only when the reader is still at the tail — see stickToBottom.
1274
+ // A discrete new message must not drag the view away from someone reading
1275
+ // history; the send path forces the follow explicitly instead.
1276
+ stickToBottom();
1200
1277
  return row;
1201
1278
  }
1202
1279
 
@@ -1355,9 +1432,8 @@ async function spawnPath(card, spec) {
1355
1432
  const bits = [spec.path.title];
1356
1433
  if (data && data.model) bits.push(data.model);
1357
1434
  else if (spec.model) bits.push(spec.model);
1358
- if (data && data.usage && data.usage.total_tokens != null) {
1359
- bits.push(`${data.usage.total_tokens} tokens`);
1360
- }
1435
+ const flowTokens = usageTokens(data && data.usage);
1436
+ if (flowTokens != null) bits.push(`${flowTokens} tokens`);
1361
1437
  meta.textContent = bits.join(' · ');
1362
1438
  } catch (err) {
1363
1439
  const message = err && err.message ? err.message : String(err);
@@ -1440,9 +1516,9 @@ function setBusy(busy, { cancellable } = {}) {
1440
1516
  cancelBtn.type = 'button';
1441
1517
  cancelBtn.className = 'cancel-btn';
1442
1518
  cancelBtn.textContent = 'cancel';
1443
- cancelBtn.addEventListener('click', () => {
1444
- if (pendingSessionId) models.cancel(pendingSessionId);
1445
- });
1519
+ // One path for both doors: the button and Escape must produce identical
1520
+ // feedback, including the salvage `send()` performs.
1521
+ cancelBtn.addEventListener('click', () => stopPendingTurn());
1446
1522
  pendingEl.appendChild(cancelBtn);
1447
1523
  }
1448
1524
  } else if (pendingEl) {
@@ -2166,6 +2242,9 @@ async function send() {
2166
2242
  }
2167
2243
 
2168
2244
  els.prompt.value = '';
2245
+ // Clear the stop flag a previous turn may have left set, so a stale `true`
2246
+ // can never make an unrelated failure look like a deliberate stop.
2247
+ userStopped = false;
2169
2248
  addMessage('user', prompt);
2170
2249
 
2171
2250
  const ceiling = applyMaxTokensClamp(model);
@@ -2190,6 +2269,9 @@ async function send() {
2190
2269
  threadMessages.push({ role: 'user', content: prompt });
2191
2270
 
2192
2271
  setBusy(true, { cancellable: true });
2272
+ // Sending is an explicit act, so it always returns the view to the tail.
2273
+ // This is the single moment the auto-scroll overrides the reader's scroll.
2274
+ stickToBottom({ force: true });
2193
2275
 
2194
2276
  // Persist the user turn locally (best-effort — never blocks chat).
2195
2277
  try {
@@ -2201,24 +2283,38 @@ async function send() {
2201
2283
  let streamedText = '';
2202
2284
  let reasoningText = '';
2203
2285
  const toolLog = [];
2286
+
2287
+ // Text arrives in dozens of small chunks per second; painting each one is
2288
+ // what made the window feel locked up. One paint per frame, off the latest
2289
+ // cumulative text.
2290
+ const paintStream = rafPainter(() => {
2291
+ if (!pendingEl) return;
2292
+ pendingEl.classList.remove('pending');
2293
+ if (reasoningText) {
2294
+ const rEl = ensureReasoningEl(pendingEl);
2295
+ if (rEl.textContent !== reasoningText) rEl.textContent = reasoningText;
2296
+ }
2297
+ const bodyEl = pendingEl.querySelector('.body');
2298
+ if (bodyEl && bodyEl.textContent !== streamedText) bodyEl.textContent = streamedText;
2299
+ stickToBottom();
2300
+ });
2301
+
2204
2302
  const onDelta = (chunk) => {
2205
2303
  // Extended-reasoning trace from a pooled brain turn: the fan-out's worker
2206
2304
  // findings, streamed before the synthesis pass writes the answer. Shown so
2207
2305
  // "work autonomously" doesn't look idle for the whole worker phase.
2208
2306
  if (chunk && typeof chunk.reasoning === 'string' && chunk.reasoning) {
2209
2307
  reasoningText += chunk.reasoning;
2210
- if (pendingEl) {
2211
- pendingEl.classList.remove('pending');
2212
- ensureReasoningEl(pendingEl).textContent = reasoningText;
2213
- els.messages.scrollTop = els.messages.scrollHeight;
2214
- }
2308
+ paintStream();
2215
2309
  return;
2216
2310
  }
2217
2311
  if (chunk && chunk.approval) {
2218
2312
  if (pendingEl) {
2219
2313
  pendingEl.classList.remove('pending');
2220
2314
  renderApprovalCard(pendingEl, chunk.approval, '.body');
2221
- els.messages.scrollTop = els.messages.scrollHeight;
2315
+ // Forced on purpose: the turn is blocked until this is answered, so
2316
+ // the card has to be brought into view even if the reader scrolled up.
2317
+ stickToBottom({ force: true });
2222
2318
  }
2223
2319
  return;
2224
2320
  }
@@ -2227,7 +2323,7 @@ async function send() {
2227
2323
  if (pendingEl) {
2228
2324
  pendingEl.classList.remove('pending');
2229
2325
  appendToolActivity(pendingEl, chunk.tool, 'tool-activity', '.body');
2230
- els.messages.scrollTop = els.messages.scrollHeight;
2326
+ stickToBottom();
2231
2327
  }
2232
2328
  return;
2233
2329
  }
@@ -2235,11 +2331,7 @@ async function send() {
2235
2331
  chunk && (typeof chunk.delta === 'string' ? chunk.delta : chunk.content);
2236
2332
  if (!delta) return;
2237
2333
  streamedText += delta;
2238
- if (!pendingEl) return;
2239
- pendingEl.classList.remove('pending');
2240
- const bodyEl = pendingEl.querySelector('.body');
2241
- if (bodyEl) bodyEl.textContent = streamedText;
2242
- els.messages.scrollTop = els.messages.scrollHeight;
2334
+ paintStream();
2243
2335
  };
2244
2336
 
2245
2337
  try {
@@ -2261,9 +2353,8 @@ async function send() {
2261
2353
  else if (model) bits.push(`model: ${model}`);
2262
2354
  bits.push(classLabel(cls));
2263
2355
  if (autonomous) bits.push(`autonomous (${effort}, ${workers || 3}w)`);
2264
- if (data && data.usage && data.usage.total_tokens != null) {
2265
- bits.push(`tokens: ${data.usage.total_tokens}`);
2266
- }
2356
+ const turnTokens = usageTokens(data && data.usage);
2357
+ if (turnTokens != null) bits.push(`tokens: ${turnTokens}`);
2267
2358
  addMessage('assistant', text, bits.join(' · ') || undefined, sessionId, toolLog);
2268
2359
 
2269
2360
  try {
@@ -2279,11 +2370,27 @@ async function send() {
2279
2370
  addFlowLane({ prompt, cls, model, maxTokens, parentSessionId: sessionId });
2280
2371
  }
2281
2372
  } catch (err) {
2282
- addMessage(
2283
- 'assistant',
2284
- `Error: ${err && err.message ? err.message : err}`,
2285
- 'request failed'
2286
- );
2373
+ // A stop is not a failure. The transport rethrows on abort — the SSE read
2374
+ // rejects and the loop re-raises — so the naive path here would discard
2375
+ // everything already streamed and answer with a red "aborted" error,
2376
+ // destroying the partial reply at the exact moment the user asked to keep
2377
+ // it. Salvage the partial turn and label it honestly instead.
2378
+ if (isCancellation(err, { userStopped })) {
2379
+ const text = streamedText || reasoningText || '(stopped before any output)';
2380
+ threadMessages.push({ role: 'assistant', content: text });
2381
+ addMessage('assistant', text, 'stopped by you', sessionId, toolLog);
2382
+ try {
2383
+ await sync.append(sessionId, { role: 'assistant', content: text });
2384
+ } catch {
2385
+ /* persistence is non-fatal */
2386
+ }
2387
+ } else {
2388
+ addMessage(
2389
+ 'assistant',
2390
+ `Error: ${err && err.message ? err.message : err}`,
2391
+ 'request failed'
2392
+ );
2393
+ }
2287
2394
  } finally {
2288
2395
  setBusy(false);
2289
2396
  pendingSessionId = null;
@@ -2452,8 +2559,37 @@ async function init() {
2452
2559
  renderMemoryOverlay();
2453
2560
  });
2454
2561
  }
2455
- document.addEventListener('keydown', (e) => {
2456
- if (e.key === 'Escape' && overlayOpen()) closeMemoryOverlay();
2562
+ // The transcript's scroll/paint/Escape policy. Both listeners are registered
2563
+ // inside transcript-view.js so the behaviours they enforce are the ones
2564
+ // test/renderer-dom.test.mjs drives: the passive `scroll` listener is the
2565
+ // reader's veto over the streaming auto-scroll (without it the transcript
2566
+ // stays pinned to the tail no matter how far up you read while a model is
2567
+ // working), and the `keydown` listener is Escape-as-interrupt (the keyboard
2568
+ // twin of the cancel button, for the window that is too busy to aim at it).
2569
+ transcript = createTranscriptView({
2570
+ messages: els.messages,
2571
+ requestFrame: (fn) => requestAnimationFrame(fn),
2572
+ });
2573
+ transcript.attachScrollVeto();
2574
+ // Read-only diagnostic surface for the headless smoke run
2575
+ // (test/electron-smoke.mjs). Everything in this file lives inside the IIFE,
2576
+ // so an injected script cannot otherwise see the scroll veto — the Phase 9
2577
+ // harness read `transcript.isScrolledUp()` directly and silently got `null`,
2578
+ // which made its veto assertion unfalsifiable. Exposes state only: no
2579
+ // setters, nothing that can drive the UI. Frozen so a stray write in a test
2580
+ // cannot fake a passing run.
2581
+ window.__aegisSmoke = Object.freeze({
2582
+ isScrolledUp: () => transcript.isScrolledUp(),
2583
+ metrics: () => transcript.metrics(),
2584
+ });
2585
+ bindEscapeInterrupt({
2586
+ doc: document,
2587
+ // The memory overlay wins: while it is open, Escape closes it rather than
2588
+ // reaching past it to cancel a turn the user may not be looking at.
2589
+ isOverlayOpen: overlayOpen,
2590
+ onOverlayEscape: closeMemoryOverlay,
2591
+ hasPendingTurn: () => !!pendingSessionId,
2592
+ stopTurn: stopPendingTurn,
2457
2593
  });
2458
2594
 
2459
2595
  // Auto-update banner: `?`-guarded like the memory inspector above, since
@@ -329,6 +329,9 @@
329
329
  </div>
330
330
 
331
331
  <script src="max-tokens.js"></script>
332
+ <script src="usage.js"></script>
333
+ <script src="stream-policy.js"></script>
334
+ <script src="transcript-view.js"></script>
332
335
  <script src="vendor/marked.umd.js"></script>
333
336
  <script src="vendor/purify.min.js"></script>
334
337
  <script src="vendor/aegis-highlight.js"></script>
@@ -0,0 +1,88 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Pure decisions behind a live streaming turn: whether the transcript is
5
+ * allowed to follow the stream, and whether a rejection means the user asked
6
+ * to stop. Kept out of app.js — like max-tokens.js — so both rules are
7
+ * unit-testable from plain Node without a DOM or window.aegis. app.js only
8
+ * calls into this.
9
+ *
10
+ * Both rules exist because a running turn used to own the transcript: it
11
+ * re-pinned the view on every chunk (so earlier turns could not be read) and
12
+ * it reported a deliberate stop as a failure (so interrupting lost the answer
13
+ * already on screen).
14
+ */
15
+
16
+ /** Distance from the bottom still counted as "following the stream". */
17
+ const STICK_SLOP_PX = 48;
18
+
19
+ /**
20
+ * Is the view at (or effectively at) the tail? A few pixels of slack absorbs
21
+ * sub-pixel layout and a scrollbar that appears mid-stream; without it the
22
+ * follow silently stops a fraction short.
23
+ */
24
+ function nearBottom(metrics, slop) {
25
+ const m = metrics || {};
26
+ const budget = Number.isFinite(slop) ? slop : STICK_SLOP_PX;
27
+ const scrollHeight = Number(m.scrollHeight) || 0;
28
+ const scrollTop = Number(m.scrollTop) || 0;
29
+ const clientHeight = Number(m.clientHeight) || 0;
30
+ return scrollHeight - scrollTop - clientHeight <= budget;
31
+ }
32
+
33
+ /**
34
+ * May the transcript be scrolled to the bottom right now? `force` is for the
35
+ * cases where the view genuinely must move (the user just sent, or a card is
36
+ * blocking the turn); otherwise the reader's explicit scroll-away wins.
37
+ *
38
+ * The flag is checked before the measurement on purpose: a mid-stream reflow
39
+ * can momentarily measure as "at the bottom" while the reader is nowhere near
40
+ * it, which is exactly the case where re-pinning feels like a hijack.
41
+ */
42
+ function shouldFollow(metrics, opts) {
43
+ const o = opts || {};
44
+ if (o.force) return true;
45
+ if (o.userScrolledUp) return false;
46
+ return nearBottom(metrics, o.slop);
47
+ }
48
+
49
+ /**
50
+ * Signatures of a real abort, as each layer spells it: Chromium's fetch
51
+ * ("The user aborted a request."), undici/Node ("This operation was aborted"),
52
+ * and an AbortSignal's own reason ("signal is aborted without reason").
53
+ *
54
+ * Deliberately NOT a bare /abort/i. A dropped socket surfaces as
55
+ * "ECONNABORTED: connection aborted by peer" or "socket hang up", which
56
+ * contains the same word but is a genuine transport failure — matching it
57
+ * would relabel a real error as a deliberate stop and strand the user with a
58
+ * silently truncated answer.
59
+ */
60
+ const ABORT_SIGNATURES = [
61
+ /AbortError/,
62
+ /\boperation was aborted\b/i,
63
+ /\buser aborted\b/i,
64
+ /\brequest\s+aborted\b/i,
65
+ /\bsignal is aborted\b/i,
66
+ /\baborted without reason\b/i,
67
+ ];
68
+
69
+ /**
70
+ * Did the user ask for this turn to stop? The abort travels back over IPC,
71
+ * which rebuilds the Error object and drops `name`/`code` — the renderer only
72
+ * sees a wrapped message. So the caller's own flag is the primary signal, and
73
+ * the signatures above are a backstop for an abort this renderer did not
74
+ * initiate.
75
+ */
76
+ function isCancellation(err, opts) {
77
+ const o = opts || {};
78
+ if (o.userStopped) return true;
79
+ if (!err) return false;
80
+ if (err.name === 'AbortError' || err.code === 'ABORT_ERR') return true;
81
+ const msg = typeof err === 'string' ? err : err.message;
82
+ if (typeof msg !== 'string') return false;
83
+ return ABORT_SIGNATURES.some((re) => re.test(msg));
84
+ }
85
+
86
+ if (typeof module !== 'undefined' && module.exports) {
87
+ module.exports = { nearBottom, shouldFollow, isCancellation, STICK_SLOP_PX };
88
+ }
@@ -0,0 +1,176 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * The DOM-touching half of the transcript policy (plan Phase 8), extracted
5
+ * from app.js so the two symptoms this work exists to prevent can be asserted
6
+ * *behaviourally* — not just as pure math in stream-policy.js:
7
+ *
8
+ * - the transcript snapping to the bottom while you read → `follow()` asks
9
+ * stream-policy's `shouldFollow()` and the reader's own scroll sets the
10
+ * veto through `attachScrollVeto()`;
11
+ * - Escape failing to interrupt → `bindEscapeInterrupt()` is the one place
12
+ * the keyboard listener is registered.
13
+ *
14
+ * Why extract instead of testing app.js itself: app.js boots only under the
15
+ * Electron host (`window.aegis`/`window.models`) and builds a 2000-line UI, so
16
+ * a test that drives it would assert on a mock of everything. Everything here
17
+ * touches exactly four DOM surfaces — three numbers on the transcript element
18
+ * (scrollHeight/scrollTop/clientHeight), one passive `scroll` listener, one
19
+ * `keydown` listener on the document, and `requestAnimationFrame` — so
20
+ * test/renderer-dom.test.mjs drives the real file with a 60-line fake DOM and
21
+ * no third-party dependency. app.js only calls into this.
22
+ *
23
+ * Loaded as a classic script (index.html) *before* app.js, and requireable
24
+ * from Node like max-tokens.js/stream-policy.js.
25
+ */
26
+
27
+ // The decisions stay in stream-policy.js — one definition of "at the tail" and
28
+ // of "the reader has the veto". Classic script: resolve the globals declared by
29
+ // the sibling script tag; CommonJS: resolve the module. Eager, so a missing
30
+ // stream-policy.js script tag fails at load instead of on the first paint.
31
+ const policy =
32
+ typeof module !== 'undefined' && module.exports
33
+ ? require('./stream-policy.js')
34
+ : { nearBottom: nearBottom, shouldFollow: shouldFollow };
35
+
36
+ /**
37
+ * Coalescing paint + follow-the-tail state for the one transcript element.
38
+ *
39
+ * `messages` is the transcript (`#messages`); `requestFrame` defaults to the
40
+ * window's `requestAnimationFrame` and is injectable so a test can flush
41
+ * frames deterministically.
42
+ */
43
+ function createTranscriptView(deps) {
44
+ const d = deps || {};
45
+ const messages = d.messages;
46
+ const requestFrame =
47
+ typeof d.requestFrame === 'function'
48
+ ? d.requestFrame
49
+ : typeof requestAnimationFrame === 'function'
50
+ ? requestAnimationFrame
51
+ : (fn) => fn();
52
+
53
+ /** Set while the reader has deliberately scrolled away from the tail. */
54
+ let scrolledUp = false;
55
+
56
+ /** Current transcript scroll metrics, or null when there is no transcript. */
57
+ function metrics() {
58
+ if (!messages) return null;
59
+ return {
60
+ scrollHeight: messages.scrollHeight,
61
+ scrollTop: messages.scrollTop,
62
+ clientHeight: messages.clientHeight,
63
+ };
64
+ }
65
+
66
+ /**
67
+ * The reader's veto over the streaming auto-scroll. This is the passive
68
+ * `scroll` listener: without it `scrolledUp` can never become true, leaving
69
+ * the transcript pinned to the tail no matter how far up you read while a
70
+ * model is working. Fires on every scroll frame, so it only records position.
71
+ */
72
+ function noteScroll() {
73
+ scrolledUp = !policy.nearBottom(metrics());
74
+ return scrolledUp;
75
+ }
76
+
77
+ function attachScrollVeto(el) {
78
+ const target = el || messages;
79
+ if (!target || typeof target.addEventListener !== 'function') return false;
80
+ target.addEventListener('scroll', noteScroll, { passive: true });
81
+ return true;
82
+ }
83
+
84
+ /**
85
+ * Follow the tail only while the reader is still there (was
86
+ * `stickToBottom`). A streaming turn must never yank the view back down once
87
+ * someone has scrolled up to read — that was why the transcript felt
88
+ * unscrollable while a model was working. `force` is for the cases where the
89
+ * view genuinely must follow: a message the user just sent, or a card they
90
+ * just opened. Returns whether the view moved.
91
+ */
92
+ function follow(opts) {
93
+ if (!messages) return false;
94
+ if (!policy.shouldFollow(metrics(), { force: opts && opts.force, userScrolledUp: scrolledUp })) {
95
+ return false;
96
+ }
97
+ if (opts && opts.force) scrolledUp = false;
98
+ messages.scrollTop = messages.scrollHeight;
99
+ return true;
100
+ }
101
+
102
+ /**
103
+ * Coalesce high-frequency stream updates to one paint per frame (was
104
+ * `rafPainter`). A cloud brain fan-out emits dozens of chunks a second, and
105
+ * each repaint read scrollHeight (forcing a synchronous layout) — that
106
+ * thrash is what made the window feel frozen mid-turn. Only the latest
107
+ * payload is painted; dropped frames are invisible because the text is
108
+ * cumulative. The returned function is idempotent within a frame.
109
+ */
110
+ function paint(fn) {
111
+ let queued = false;
112
+ return function schedule() {
113
+ if (queued) return;
114
+ queued = true;
115
+ requestFrame(() => {
116
+ queued = false;
117
+ fn();
118
+ });
119
+ };
120
+ }
121
+
122
+ return {
123
+ metrics: metrics,
124
+ noteScroll: noteScroll,
125
+ attachScrollVeto: attachScrollVeto,
126
+ follow: follow,
127
+ paint: paint,
128
+ isScrolledUp: () => scrolledUp,
129
+ };
130
+ }
131
+
132
+ /**
133
+ * Escape → the running turn's interrupt (the keyboard twin of the cancel
134
+ * button, for the window that is too busy to aim at it).
135
+ *
136
+ * Deliberately the only `keydown` registration: the memory overlay wins while
137
+ * it is open — Escape closes it rather than reaching past it to cancel a turn
138
+ * the user may not be looking at — and Escape does nothing at all when no turn
139
+ * is pending, so the key never becomes a surprise.
140
+ *
141
+ * Returns the handler plus an `unbind()` for symmetry with the listener it
142
+ * owns; the return value is also what lets a test call the decision directly.
143
+ */
144
+ function bindEscapeInterrupt(deps) {
145
+ const d = deps || {};
146
+
147
+ function handle(e) {
148
+ if (!e || e.key !== 'Escape') return false;
149
+ if (typeof d.isOverlayOpen === 'function' && d.isOverlayOpen()) {
150
+ if (typeof d.onOverlayEscape === 'function') d.onOverlayEscape();
151
+ return false;
152
+ }
153
+ if (typeof d.hasPendingTurn === 'function' && !d.hasPendingTurn()) return false;
154
+ if (typeof d.stopTurn !== 'function') return false;
155
+ if (typeof e.preventDefault === 'function') e.preventDefault();
156
+ d.stopTurn();
157
+ return true;
158
+ }
159
+
160
+ if (d.doc && typeof d.doc.addEventListener === 'function') {
161
+ d.doc.addEventListener('keydown', handle);
162
+ }
163
+
164
+ return {
165
+ handle: handle,
166
+ unbind: () => {
167
+ if (d.doc && typeof d.doc.removeEventListener === 'function') {
168
+ d.doc.removeEventListener('keydown', handle);
169
+ }
170
+ },
171
+ };
172
+ }
173
+
174
+ if (typeof module !== 'undefined' && module.exports) {
175
+ module.exports = { createTranscriptView, bindEscapeInterrupt };
176
+ }
@@ -0,0 +1,42 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Pure token-usage → display-number mapping.
5
+ *
6
+ * Standalone from app.js (same reason as max-tokens.js/stream-policy.js): it is
7
+ * requireable from a plain Node test without window.aegis. app.js only calls
8
+ * into it.
9
+ *
10
+ * Why this exists at all: the two wire formats spell the same quantity
11
+ * differently, and the renderer used to read exactly one of the spellings.
12
+ *
13
+ * OpenAI-compatible { prompt_tokens, completion_tokens, total_tokens }
14
+ * Anthropic-compatible { input_tokens, output_tokens } ← no total
15
+ *
16
+ * Reading only `total_tokens` — the field OpenAI happens to provide — meant
17
+ * every Anthropic-compatible model rendered with no token count at all, while
18
+ * the call was silently being billed. Accepting both spellings, and deriving
19
+ * the total when the provider doesn't state one, is what makes the spend
20
+ * visible regardless of which endpoint answered.
21
+ */
22
+
23
+ /**
24
+ * Number of tokens to show for a completed turn, or `null` when the provider
25
+ * reported none (an unknown count must render as nothing, never as `0`).
26
+ *
27
+ * @param {{total_tokens?: number, prompt_tokens?: number, completion_tokens?: number,
28
+ * input_tokens?: number, output_tokens?: number}|null|undefined} usage
29
+ * @returns {number|null}
30
+ */
31
+ function usageTokens(usage) {
32
+ if (!usage || typeof usage !== 'object') return null;
33
+ if (typeof usage.total_tokens === 'number') return usage.total_tokens;
34
+ const input = usage.input_tokens ?? usage.prompt_tokens;
35
+ const output = usage.output_tokens ?? usage.completion_tokens;
36
+ if (typeof input !== 'number' && typeof output !== 'number') return null;
37
+ return (input || 0) + (output || 0);
38
+ }
39
+
40
+ if (typeof module !== 'undefined' && module.exports) {
41
+ module.exports = { usageTokens };
42
+ }
package/vendor/aegis.js CHANGED
@@ -290,6 +290,7 @@ function createClient(opts = {}) {
290
290
  onStream,
291
291
  onReasoning,
292
292
  idleTimeoutMs,
293
+ includeUsage,
293
294
  signal,
294
295
  extra,
295
296
  } = {}) {
@@ -307,9 +308,22 @@ function createClient(opts = {}) {
307
308
  if (!stream || typeof onStream !== 'function') {
308
309
  return apiPost('/api/v1/chat/completions', { ...body, stream: false });
309
310
  }
311
+ // An OpenAI-compatible SSE response carries no `usage` unless the caller
312
+ // asks for it (`stream_options.include_usage`). Without this the streamed
313
+ // AEGIS pool call — the only path the desktop uses for the Nexus brain —
314
+ // resolved with no usage at all, so a pooled turn could report its text but
315
+ // never what it spent, while the same model through the non-streaming MCP
316
+ // path reported both. Only the streaming request sets it: `stream_options`
317
+ // is invalid alongside `stream:false`, so the non-stream branch above and
318
+ // every non-stream fallback must stay clean.
319
+ if (includeUsage) body.stream_options = { include_usage: true };
310
320
  return postStream('/api/v1/chat/completions', body, authHeaders(), onStream, signal, {
311
321
  onReasoning,
312
322
  idleTimeoutMs,
323
+ // A server that predates `stream_options` 400s the whole request; the
324
+ // stream is worth more than the token count, so retry on the wire without
325
+ // the hint before sacrificing streaming for the non-stream fallback.
326
+ retryWithoutStreamOptions: Boolean(includeUsage),
313
327
  });
314
328
  }
315
329
 
@@ -370,23 +384,49 @@ function createClient(opts = {}) {
370
384
  * This keeps streaming purely additive for hosts that opt in.
371
385
  */
372
386
  async function postStream(path, body, headers, onStream, signal, streamOpts) {
373
- const { onReasoning, idleTimeoutMs } = streamOpts || {};
374
- let res;
375
- try {
376
- res = await fetch(`${apiBase}${path}`, {
387
+ const { onReasoning, idleTimeoutMs, retryWithoutStreamOptions } = streamOpts || {};
388
+ const request = (payload) =>
389
+ fetch(`${apiBase}${path}`, {
377
390
  method: 'POST',
378
391
  headers,
379
- body: JSON.stringify({ ...body, stream: true }),
392
+ body: JSON.stringify({ ...payload, stream: true }),
380
393
  signal,
381
394
  });
395
+ let res;
396
+ try {
397
+ res = await request(body);
382
398
  } catch (err) {
383
399
  throw err; // network-level failure; nothing to fall back to
384
400
  }
385
401
 
402
+ // Ask for the token count, but never at the cost of the stream: a server
403
+ // that predates `stream_options` rejects the request outright, so drop that
404
+ // one hint and put it back on the wire before falling back to a non-stream
405
+ // response (which would answer in one lump and end the live paint).
406
+ if (!res.ok && retryWithoutStreamOptions && body.stream_options) {
407
+ try {
408
+ if (res.body) await res.body.cancel();
409
+ } catch {
410
+ /* the failed response is being discarded anyway */
411
+ }
412
+ const cleanBody = { ...body };
413
+ delete cleanBody.stream_options;
414
+ body = cleanBody;
415
+ try {
416
+ res = await request(body);
417
+ } catch (err) {
418
+ throw err;
419
+ }
420
+ }
421
+
386
422
  if (!res.ok) {
387
423
  // The endpoint may not accept `stream: true`. Retry once without it so
388
- // the caller gets the normal structured JSON (result or error).
389
- const data = await apiPost(path, { ...body, stream: false }, headers);
424
+ // the caller gets the normal structured JSON (result or error). Strip
425
+ // `stream_options` too it is only legal with `stream: true`, so leaving
426
+ // it on would turn this graceful fallback into a second rejection.
427
+ const cleanBody = { ...body };
428
+ delete cleanBody.stream_options;
429
+ const data = await apiPost(path, { ...cleanBody, stream: false }, headers);
390
430
  const fullText = textOf(data);
391
431
  if (fullText) onStream({ delta: fullText });
392
432
  return data;