aegis-desktop 0.8.13 → 0.8.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -90,6 +90,84 @@ const LEGACY_PLAN_ALIASES = Object.freeze({
90
90
  const PRO_VALUE =
91
91
  'Pro puts the drain in the cloud, so the job keeps running after you close the laptop';
92
92
 
93
+ /**
94
+ * The honesty line — Phase 3's rule, and the second half of the same claim:
95
+ * everything that runs on the user's own machine stays free forever, and the
96
+ * cloud drain is the thing being sold. Both hosts that print a paid prompt
97
+ * print this beside it (the desktop keeps its own literal in
98
+ * `desktop/renderer/app.js`; the terminal reads it from here), because the
99
+ * promise without the honesty line is the four-bullet feature list again.
100
+ *
101
+ * Kept byte-identical to the renderer's `FREE_HONESTY`, and asserted verbatim
102
+ * by both hosts' tests, so the terminal and the GUI cannot come to disagree
103
+ * about what is free — a disagreement that would either overcharge a promise
104
+ * or undercut the offer, depending on which copy a user believed.
105
+ */
106
+ const FREE_HONESTY =
107
+ 'Everything on your machine is free, permanently. The cloud drain is what you pay for.';
108
+
109
+ /**
110
+ * The BYOK lane's price, in words — built from the SERVER's published rate.
111
+ *
112
+ * The desktop README says the byok lane is "billed a flat handling fee" and
113
+ * aegis1 charges exactly that (services/pricing.price_byok_call), but no client
114
+ * surface said so: a user who pasted their own Anthropic key into AEGIS read
115
+ * "your provider's cost" and never learned AEGIS adds a fee. That is the one
116
+ * place in this product where the bill and the pitch disagreed, and it is the
117
+ * copy the desktop session flagged as an unresolved honesty conflict.
118
+ *
119
+ * `fee` is aegis1's own `/api/v1/byok/providers` payload — a live payload, never
120
+ * a hardcoded number, for the reason the engine already states: a client that
121
+ * hardcodes a fee is a client that can disagree with the ledger. No published
122
+ * fee (old server, offline, unauthenticated read that returned nothing) returns
123
+ * '' and every host then says NOTHING about the price, which is the only honest
124
+ * alternative to inventing one.
125
+ *
126
+ * `require_account` / `anonymous_daily_calls` come from the same payload and
127
+ * describe enforcement, not price: with the relay requiring an AEGIS account the
128
+ * sentence has to say so, or the user meets a 402 the client never warned about
129
+ * — the exact failure tests/test_byok_fee.py::test_the_published_gate_warning_is
130
+ * _true_whenever_the_gate_can_402 exists to prevent.
131
+ */
132
+ const BYOK_FEE_NOTE =
133
+ 'Bring your own key is relayed through AEGIS, not dialled from here — the routing, prompt assembly and caching are billed as a flat handling fee, on top of your provider\'s own bill.';
134
+
135
+ /** Per-million-token rendering of a published per-1k rate. */
136
+ function ratePerMillion(usdPer1k) {
137
+ return `$${(Number(usdPer1k) * 1000).toFixed(2)}/M`;
138
+ }
139
+
140
+ function byokFeeLine(fee) {
141
+ const f = fee && typeof fee === 'object' ? fee : null;
142
+ if (!f) return '';
143
+ const inRate = Number(f.in_usd_per_1k);
144
+ const outRate = Number(f.out_usd_per_1k);
145
+ if (!Number.isFinite(inRate) || !Number.isFinite(outRate)) return '';
146
+ if (f.disabled === true) {
147
+ // The deployment switched the fee off; saying "free" is then the truth
148
+ // rather than a promise this client cannot keep.
149
+ return 'Bring your own key is relayed free on this deployment right now.';
150
+ }
151
+ // The price clause stands alone when the server published no enforcement
152
+ // detail — which is every server deployed before this change, and the
153
+ // default-off gate after it. Joined with ' — ' only when there IS a second
154
+ // clause: a sentence-ending dash with nothing after it ("…provider's bill —.")
155
+ // is what the first live run of this line actually printed.
156
+ const price = `Bring your own key: relayed through AEGIS, handled at `
157
+ + `${ratePerMillion(inRate)} in / ${ratePerMillion(outRate)} out, `
158
+ + `on top of your provider's bill`;
159
+ let clause = '';
160
+ if (f.require_account === true) {
161
+ clause = 'and needs an AEGIS account key (X-AEGIS-Key) for the fee to be billed';
162
+ } else {
163
+ const cap = Number(f.anonymous_daily_calls);
164
+ if (Number.isFinite(cap) && cap > 0) {
165
+ clause = `with ${cap} relayed turns/24h before an AEGIS account is required`;
166
+ }
167
+ }
168
+ return clause ? `${price} — ${clause}.` : `${price}.`;
169
+ }
170
+
93
171
  /** Campaign id for this prompt (Phase 1's fixed ids; aegis1 keeps the slug). */
94
172
  const CAMPAIGN = 'queue_cap';
95
173
 
@@ -478,6 +556,9 @@ module.exports = {
478
556
  BALANCE_CAMPAIGN,
479
557
  BALANCE_URL,
480
558
  PRO_VALUE,
559
+ FREE_HONESTY,
560
+ BYOK_FEE_NOTE,
561
+ byokFeeLine,
481
562
  DEFAULT_TTL_MS,
482
563
  // Phase 7: the host seam + the builder the CLI binds itself to.
483
564
  SIGNUP_SOURCES,
@@ -58,6 +58,45 @@ function baseOf(baseURL) {
58
58
  return String(baseURL || DEFAULT_BASE).replace(/\/+$/, '');
59
59
  }
60
60
 
61
+ /**
62
+ * DeepSeek's chat template (also the one a locally-pulled `deepseek-*` Ollama
63
+ * model uses) wraps tool calls and turn boundaries in special tokens —
64
+ * fullwidth pipe (U+FF5C) delimiting an identifier that uses ▁ (U+2581) as its
65
+ * word separator, e.g. |tool▁calls▁begin|, |tool▁call▁end|, |Assistant|,
66
+ * |begin▁of▁sentence|. These are chat-template internals, never user-facing
67
+ * text; when the local daemon's tool-call path leaks them into `delta.content`
68
+ * they used to reach the screen verbatim — reported as the pasted
69
+ * "||DSML||" envelope. Returns a per-turn stripper closure with a short
70
+ * lookback buffer so a token split across two SSE chunks isn't half-rendered
71
+ * before its closing pipe arrives.
72
+ */
73
+ const DEEPSEEK_ENVELOPE_RE = /|[A-Za-z][A-Za-z0-9]*(?:▁[A-Za-z0-9]+)*|/g;
74
+ function makeDeepSeekEnvelopeStripper() {
75
+ let buf = '';
76
+ const strip = (chunk) => {
77
+ buf += chunk;
78
+ buf = buf.replace(DEEPSEEK_ENVELOPE_RE, '');
79
+ const openIdx = buf.lastIndexOf('|');
80
+ if (openIdx !== -1) {
81
+ const tail = buf.slice(openIdx);
82
+ if (tail.length <= 60 && /^|[A-Za-z0-9▁]*$/.test(tail)) {
83
+ const out = buf.slice(0, openIdx);
84
+ buf = tail;
85
+ return out;
86
+ }
87
+ }
88
+ const out = buf;
89
+ buf = '';
90
+ return out;
91
+ };
92
+ strip.flush = () => {
93
+ const out = buf;
94
+ buf = '';
95
+ return out;
96
+ };
97
+ return strip;
98
+ }
99
+
61
100
  /**
62
101
  * Whether a base URL addresses hardware on this machine (or on this private
63
102
  * network) — the gate that decides whether the free lane exists at all.
@@ -369,6 +408,7 @@ async function chat({
369
408
  let usage = null;
370
409
  const toolCalls = [];
371
410
  const byIndex = new Map();
411
+ const stripEnvelope = makeDeepSeekEnvelopeStripper();
372
412
 
373
413
  await readSSE(
374
414
  res,
@@ -378,8 +418,11 @@ async function chat({
378
418
  if (!choice) return;
379
419
  const delta = choice.delta || {};
380
420
  if (typeof delta.content === 'string' && delta.content) {
381
- content += delta.content;
382
- if (typeof onDelta === 'function') onDelta({ text: delta.content });
421
+ const clean = stripEnvelope(delta.content);
422
+ if (clean) {
423
+ content += clean;
424
+ if (typeof onDelta === 'function') onDelta({ text: clean });
425
+ }
383
426
  }
384
427
  const frags = delta.tool_calls || [];
385
428
  for (const f of frags) {
@@ -399,6 +442,15 @@ async function chat({
399
442
  { idleTimeoutMs: SSE_IDLE_TIMEOUT_MS }
400
443
  );
401
444
 
445
+ // A trailing '|…' that never closed (stream ended right after it, or it
446
+ // was real prose all along) is real content, not an unfinished envelope
447
+ // token — release it rather than dropping it silently.
448
+ const strayTail = stripEnvelope.flush();
449
+ if (strayTail) {
450
+ content += strayTail;
451
+ if (typeof onDelta === 'function') onDelta({ text: strayTail });
452
+ }
453
+
402
454
  for (const [i, acc] of [...byIndex.entries()].sort((a, b) => a[0] - b[0])) {
403
455
  if (!acc.function.name && !acc.function.arguments) continue;
404
456
  // Ollama does not always mint an id; the engine's tool loop keys results
@@ -42,6 +42,7 @@ const { spawn } = require('node:child_process');
42
42
  const crypto = require('node:crypto');
43
43
  const fs = require('node:fs');
44
44
  const path = require('node:path');
45
+ const { pathToFileURL } = require('node:url');
45
46
  const { agentRoles } = require('./agents.js');
46
47
 
47
48
  const OUTPUT_CAP = 30_000; // chars fed back to the model per tool result
@@ -770,9 +771,121 @@ function isTool(name) {
770
771
  * all land on the error branch rather than rejecting. `ctx` carries per-turn
771
772
  * state (getShell, signal); omit it and exec falls back to a one-shot spawn.
772
773
  */
774
+ /**
775
+ * `tools/agent-gate.mjs` is ESM and this module is CJS, so it is imported
776
+ * dynamically and once. The candidates are tried in order; the first that
777
+ * resolves wins:
778
+ *
779
+ * 1. `../../../tools/agent-gate.mjs` — the repo checkout (dev / CI). This is
780
+ * the SAME file the git hooks execute, so "what is allowed" is computed
781
+ * once, by one implementation.
782
+ * 2. `<process.resourcesPath>/tools/agent-gate.mjs` — where electron-builder's
783
+ * `extraResources` puts it in a packaged build, as a real file OUTSIDE the
784
+ * asar. That placement is load-bearing, not cosmetic: the gate is loaded
785
+ * with dynamic `import()`, and Node's ESM loader does not go through
786
+ * Electron's asar-aware fs patch (electron/asar#249), so a copy living only
787
+ * inside the archive can fail to resolve — leaving the packaged app with no
788
+ * gate at all. This is the app's only dynamic ESM import, so nothing else
789
+ * here exercises that path. Conveniently this is also what candidate (1)
790
+ * resolves to from `resources/app.asar/lib/local/`, so dev and packaged
791
+ * builds agree on one file.
792
+ * 3. `../../vendor/agent-gate.mjs` — the staged copy inside the app dir, kept
793
+ * as the last resort for a build that is not archived (`asar: false`) or an
794
+ * npm-installed tree. It is the candidate that fails inside an asar, hence
795
+ * third and not first.
796
+ *
797
+ * Before (2) existed a packaged build had no gate at all: the manifest is found
798
+ * by climbing from the working directory, so the packaged app would open a
799
+ * guarded tree and every write through it succeeded while the same call through
800
+ * the repo copy was refused.
801
+ *
802
+ * If nothing resolves, that is a broken gate rather than a permissive one, and
803
+ * executeTool refuses mutating calls into a tree that declares a manifest.
804
+ */
805
+ let agentGatePromise;
806
+
807
+ function agentGateCandidates({ resourcesPath = process.resourcesPath, dirname = __dirname } = {}) {
808
+ const candidates = ['../../../tools/agent-gate.mjs'];
809
+ // process.resourcesPath is an Electron-provided value and is absent under
810
+ // plain node, so this is guarded rather than assumed. An absolute path must
811
+ // be a file URL to be imported.
812
+ if (typeof resourcesPath === 'string' && resourcesPath) {
813
+ candidates.push(pathToFileURL(path.join(resourcesPath, 'tools', 'agent-gate.mjs')).href);
814
+ }
815
+ candidates.push('../../vendor/agent-gate.mjs');
816
+ return candidates;
817
+ }
818
+
819
+ function loadAgentGate() {
820
+ if (!agentGatePromise) {
821
+ const candidates = agentGateCandidates();
822
+ const tryNext = (i) => (i >= candidates.length
823
+ ? null
824
+ : import(candidates[i]).catch(() => tryNext(i + 1)));
825
+ agentGatePromise = tryNext(0);
826
+ }
827
+ return agentGatePromise;
828
+ }
829
+
830
+ /**
831
+ * The nearest `.aegis/guard.json` at or above `start`, or null. The identical
832
+ * climb as findManifest() in tools/agent-gate.mjs and src/repoguard.js. It is
833
+ * used ONLY to decide whether a call that could not load the gate module is
834
+ * unsafe; when the module loads it is the sole authority.
835
+ */
836
+ function nearestManifest(start) {
837
+ let dir = path.resolve(start || process.cwd());
838
+ try { if (fs.statSync(dir).isFile()) dir = path.dirname(dir); } catch { /* keep as-is */ }
839
+ for (;;) {
840
+ const candidate = path.join(dir, '.aegis', 'guard.json');
841
+ if (fs.existsSync(candidate)) return candidate;
842
+ const parent = path.dirname(dir);
843
+ if (parent === dir) return null;
844
+ dir = parent;
845
+ }
846
+ }
847
+
773
848
  async function executeTool(name, args, ctx) {
774
849
  if (!isTool(name)) return fail(`unknown tool "${name}" (known: ${toolNames().join(', ')})`);
775
850
  const input = args && typeof args === 'object' ? args : {};
851
+ // ── agent-key gate (tools/agent-gate.mjs) ──────────────────────────────
852
+ // The in-process half of the lock whose other half is .githooks/. A tree
853
+ // that declares .aegis/guard.json must not be mutated through this app
854
+ // without the key, exactly as it cannot be mutated by the engine or
855
+ // committed by git. Reads are never gated. Refused — not warned — when the
856
+ // key is absent, the manifest is unreadable, or the key file is
857
+ // world-readable; AEGIS_GATE_SKIP=1 is the one bypass, and it is refused in
858
+ // an unattended run. The module is required lazily so a packaged app whose
859
+ // tree has no tools/ directory still starts and simply has no gate.
860
+ let gate = null;
861
+ try {
862
+ gate = await loadAgentGate();
863
+ } catch { /* not running from the repo: no manifest to honour */ }
864
+ if (gate) {
865
+ const verdict = gate.guardTool({
866
+ tool: name, args: input, cwd: (ctx && ctx.cwd) || process.cwd(),
867
+ env: process.env, unattended: Boolean(ctx && ctx.autonomous),
868
+ });
869
+ if (!verdict.allowed) return fail(gate.refusalText(verdict));
870
+ if (verdict.code === 'skipped') {
871
+ const res = await EXECUTORS[name](input, ctx || {});
872
+ return res && typeof res === 'object' ? { ...res, output: `⚠ ${verdict.reason}\n${res.output || ''}` } : res;
873
+ }
874
+ } else if (MUTATING_TOOLS.has(name)) {
875
+ // Neither the repo copy nor the staged one loaded. A broken gate must
876
+ // never silently allow (the hooks exit 4 for exactly this reason), so a
877
+ // mutating call into a tree that declares a manifest is refused with the
878
+ // path of the manifest that could not be honoured.
879
+ const man = nearestManifest((ctx && ctx.cwd) || process.cwd());
880
+ if (man) {
881
+ return fail(
882
+ `error: refused — ${man} declares an agent-gated tree, but the agent-key gate module could not be loaded `
883
+ + '(tried tools/agent-gate.mjs beside the app and vendor/agent-gate.mjs inside it), so this '
884
+ + `${name} cannot be authorised. A build that missed the staged copy is the usual cause: `
885
+ + 'run scripts/predist.mjs before packaging.',
886
+ );
887
+ }
888
+ }
776
889
  try {
777
890
  return await EXECUTORS[name](input, ctx || {});
778
891
  } catch (e) {
package/main.js CHANGED
@@ -682,11 +682,28 @@ function providerRouteConfigured(engine) {
682
682
  * import to stay unit-testable.
683
683
  */
684
684
  function createIpcDispatch(aegis, dir, persistApiKey, openExternal, providerConfigured, avatar) {
685
- // One service for the window's lifetime, bound to the app's data dir. It is
686
- // created lazily inside its handlers rather than here, so a broken shared
687
- // module surfaces as an error in the fingerprint panel instead of preventing
688
- // the window from opening at all.
689
- const fingerprint = createFingerprintService({ dir });
685
+ // One service for the window's lifetime, bound to the app's data dir — but
686
+ // created LAZILY, which is what the comment here always claimed and what the
687
+ // code did not do. `createFingerprintService({ dir })` throws on a missing
688
+ // dir, and a test or a call site that has no userData yet (every
689
+ // `createIpcDispatch(client)` in `test/desktop-shell.mjs`, and any bootstrap
690
+ // where `resolveUserDataDir` came back empty) died at construction — taking
691
+ // the whole dispatch surface with it, not just the fingerprint panel. The
692
+ // proxy defers construction to the first fingerprint call, so a broken shared
693
+ // module surfaces as an error in that panel instead of preventing the window
694
+ // from opening at all, exactly as documented below.
695
+ let fingerprintService = null;
696
+ const fingerprint = new Proxy({}, {
697
+ get(_target, prop) {
698
+ if (!fingerprintService) fingerprintService = createFingerprintService({ dir });
699
+ const value = fingerprintService[prop];
700
+ return typeof value === 'function' ? value.bind(fingerprintService) : value;
701
+ },
702
+ has(_target, prop) {
703
+ if (!fingerprintService) fingerprintService = createFingerprintService({ dir });
704
+ return prop in fingerprintService;
705
+ },
706
+ });
690
707
  // `providerConfigured` is an injected thunk (bootstrap() passes one that reads
691
708
  // the settings store) so this module keeps its no-Electron, unit-testable
692
709
  // shape; absent in tests and older call sites, where it reads as "no".
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "aegis-desktop",
3
3
  "productName": "AEGIS Desktop",
4
- "version": "0.8.13",
4
+ "version": "0.8.15",
5
5
  "description": "Thin Electron host for AEGIS — a local chat UI over the shared client/aegis.js transport. Ships transport + UI only; engine logic stays server-side.",
6
6
  "author": {
7
7
  "name": "AEGIS Code",
package/renderer/app.js CHANGED
@@ -664,6 +664,21 @@ function rafPainter(paint) {
664
664
  return transcript ? transcript.paint(paint) : paint;
665
665
  }
666
666
 
667
+ /**
668
+ * What the last stop attempt actually achieved.
669
+ *
670
+ * `models.cancel()` travels over IPC and can be REFUSED — the engine has no
671
+ * AbortController for that session id (`{ ok: false }`), the channel is not
672
+ * registered (the promise rejects), or the call throws. The old code wrapped
673
+ * this in a bare `catch` and ignored the returned promise, so every one of
674
+ * those became silence: the button said "stopping…", the turn kept streaming,
675
+ * and the only visible evidence was a salvage bubble that never appeared —
676
+ * which reads as a salvage bug several layers away from the real one. Recorded
677
+ * here so the smoke run can fail on it, and the rejection is handled so it
678
+ * cannot vanish into an unhandled-rejection warning nobody reads.
679
+ */
680
+ let lastStopOutcome = null;
681
+
667
682
  /**
668
683
  * Stop the turn running right now. The transport already honours the abort all
669
684
  * the way down (engine.cancel -> AbortController -> the cloud client's fetch),
@@ -682,10 +697,43 @@ function stopPendingTurn() {
682
697
  // could be left `true` by an earlier turn and make an unrelated transport
683
698
  // failure look like a stop the user asked for.
684
699
  stoppedTurn = runningTurn;
700
+ const stopSessionId = pendingSessionId;
701
+ lastStopOutcome = { sessionId: stopSessionId, at: Date.now(), state: 'requested' };
685
702
  try {
686
- models.cancel(pendingSessionId);
687
- } catch {
688
- /* a dead controller is not an error */
703
+ const pending = models.cancel(stopSessionId);
704
+ if (pending && typeof pending.then === 'function') {
705
+ pending.then(
706
+ (r) => {
707
+ lastStopOutcome = {
708
+ sessionId: stopSessionId,
709
+ at: Date.now(),
710
+ state: 'settled',
711
+ // The engine reports `{ ok: Boolean(controller) }` — a false here
712
+ // means it had nothing to abort, i.e. the stop was refused. Not an
713
+ // error, but never a success either.
714
+ accepted: !r || r.ok !== false,
715
+ result: r === undefined ? null : r,
716
+ };
717
+ },
718
+ (e) => {
719
+ lastStopOutcome = {
720
+ sessionId: stopSessionId,
721
+ at: Date.now(),
722
+ state: 'rejected',
723
+ accepted: false,
724
+ error: (e && e.message) || String(e),
725
+ };
726
+ }
727
+ );
728
+ }
729
+ } catch (e) {
730
+ lastStopOutcome = {
731
+ sessionId: stopSessionId,
732
+ at: Date.now(),
733
+ state: 'threw',
734
+ accepted: false,
735
+ error: (e && e.message) || String(e),
736
+ };
689
737
  }
690
738
  // The abort is not instantaneous. Marking the button is all the feedback that
691
739
  // survives the trip: `setBusy(false)` deletes the entire pending bubble on
@@ -1772,7 +1820,27 @@ const memoryView = {
1772
1820
  * instead. Confusing the two is how a save would read a hidden textarea. */
1773
1821
  function overlayOpen() {
1774
1822
  if (memoryOverlayOpen()) return true;
1775
- return !!(window.aegisFingerprint && window.aegisFingerprint.isOpen());
1823
+ // Ask the PANEL, not the module.
1824
+ //
1825
+ // `window.aegisFingerprint` is desktop/renderer/fingerprint.js, whose exports
1826
+ // are `mount` plus pure helpers — it has no `isOpen`. The thing that knows
1827
+ // whether the sheet is up is the handle `mount()` returned, kept in `fpPanel`.
1828
+ // Reaching for `window.aegisFingerprint.isOpen()` threw inside the Escape
1829
+ // listener, and a listener that throws is reported as an uncaught error
1830
+ // rather than propagated to the dispatcher — so `defaultPrevented` stayed
1831
+ // false, Escape stopped interrupting turns, and the visible symptom was a
1832
+ // salvage failure several layers away. A source-shape change must not be able
1833
+ // to do that again: an unusable handle reads as "not open" here, so a broken
1834
+ // φ(α) panel can never disarm the keyboard interrupt (the smoke run fails
1835
+ // loudly instead, on the recorded reason).
1836
+ if (fpPanel && typeof fpPanel.isOpen === 'function') {
1837
+ try {
1838
+ return !!fpPanel.isOpen();
1839
+ } catch (e) {
1840
+ return false;
1841
+ }
1842
+ }
1843
+ return false;
1776
1844
  }
1777
1845
 
1778
1846
  /** The memory inspector alone. Split out of overlayOpen() when the authorship
@@ -4483,11 +4551,20 @@ async function init() {
4483
4551
  // which made its veto assertion unfalsifiable. Exposes state only: no
4484
4552
  // setters, nothing that can drive the UI. Frozen so a stray write in a test
4485
4553
  // cannot fake a passing run.
4554
+ // Bound below, read by the diagnostic just above. `defaultPrevented === false`
4555
+ // on its own cannot say whether Escape was declined by policy or never
4556
+ // handled at all, and that ambiguity is what makes an unwired interrupt look
4557
+ // like a renderer regression.
4558
+ let escapeBind = null;
4486
4559
  window.__aegisSmoke = Object.freeze({
4487
4560
  isScrolledUp: () => transcript.isScrolledUp(),
4488
4561
  metrics: () => transcript.metrics(),
4562
+ escapeDecision: () => (escapeBind && typeof escapeBind.lastDecision === 'function'
4563
+ ? escapeBind.lastDecision()
4564
+ : null),
4565
+ stopOutcome: () => lastStopOutcome,
4489
4566
  });
4490
- bindEscapeInterrupt({
4567
+ escapeBind = bindEscapeInterrupt({
4491
4568
  doc: document,
4492
4569
  // The memory overlay wins: while it is open, Escape closes it rather than
4493
4570
  // reaching past it to cancel a turn the user may not be looking at.
@@ -4499,9 +4576,18 @@ async function init() {
4499
4576
  // both are somehow up.
4500
4577
  isOverlayOpen: overlayOpen,
4501
4578
  onOverlayEscape: () => {
4502
- if (window.aegisFingerprint && window.aegisFingerprint.isOpen()) {
4503
- window.aegisFingerprint.close();
4504
- return;
4579
+ // Same handle, same reason as overlayOpen(): the module has no `close`,
4580
+ // the mounted panel does. Guessed at the module here too, which threw for
4581
+ // the same reason — and an overlay can only be closed by its own sheet's
4582
+ // handle, so there is nothing to fall back to but the memory overlay.
4583
+ if (fpPanel && typeof fpPanel.close === 'function') {
4584
+ try {
4585
+ fpPanel.close();
4586
+ return;
4587
+ } catch (e) {
4588
+ // fall through to the memory overlay rather than rethrowing inside a
4589
+ // keydown listener
4590
+ }
4505
4591
  }
4506
4592
  closeMemoryOverlay();
4507
4593
  },
@@ -218,21 +218,85 @@ function attachScrollLift(deps) {
218
218
  function bindEscapeInterrupt(deps) {
219
219
  const d = deps || {};
220
220
 
221
+ // Why the last Escape did what it did.
222
+ //
223
+ // This exists because the only externally visible signal an ignored Escape
224
+ // leaves behind is `defaultPrevented === false`, and that single bit cannot
225
+ // tell four legitimate refusals (overlay open, no pending turn, no stopTurn,
226
+ // auto-repeat) apart from the one genuinely broken case: a dependency threw
227
+ // inside the listener, and `dispatchEvent` reported the listener error as
228
+ // uncaught instead of propagating it to the caller. Both look identical from
229
+ // outside — the turn keeps running and `defaultPrevented` is false — so a
230
+ // wiring fault is indistinguishable from a policy decision without this.
231
+ let lastDecision = null;
232
+
221
233
  function handle(e) {
234
+ const decision = {
235
+ at: Date.now(),
236
+ key: e && e.key,
237
+ repeat: Boolean(e && e.repeat),
238
+ overlayOpen: null,
239
+ hasPendingTurn: null,
240
+ consumed: false,
241
+ threw: null,
242
+ };
243
+ lastDecision = decision;
244
+
222
245
  if (!e || e.key !== 'Escape') return false;
223
246
  // A held key is one intent, not one per repeat. Without this the auto-repeat
224
247
  // events keep arriving after the interrupted turn has already ended, and
225
248
  // the ones that land once the user has sent the next message stop that turn
226
249
  // instead — the keyboard twin of a stuck cancel button.
227
250
  if (e.repeat) return false;
228
- if (typeof d.isOverlayOpen === 'function' && d.isOverlayOpen()) {
229
- if (typeof d.onOverlayEscape === 'function') d.onOverlayEscape();
230
- return false;
251
+ if (typeof d.isOverlayOpen === 'function') {
252
+ try {
253
+ decision.overlayOpen = Boolean(d.isOverlayOpen());
254
+ } catch (err) {
255
+ // A probe that breaks must not disarm the interrupt: the user's only
256
+ // keyboard route to stopping a runaway turn is this handler, and
257
+ // "unknown" is not "open". The reason is recorded rather than raised,
258
+ // so the smoke run fails on it while the user still gets their Escape.
259
+ decision.threw = 'isOverlayOpen: ' + ((err && err.message) || err);
260
+ decision.overlayOpen = false;
261
+ }
262
+ if (decision.overlayOpen) {
263
+ if (typeof d.onOverlayEscape === 'function') {
264
+ try {
265
+ d.onOverlayEscape();
266
+ } catch (err) {
267
+ decision.threw = 'onOverlayEscape: ' + ((err && err.message) || err);
268
+ }
269
+ }
270
+ return false;
271
+ }
272
+ }
273
+ if (typeof d.hasPendingTurn === 'function') {
274
+ try {
275
+ decision.hasPendingTurn = Boolean(d.hasPendingTurn());
276
+ } catch (err) {
277
+ decision.threw = 'hasPendingTurn: ' + ((err && err.message) || err);
278
+ return false;
279
+ }
280
+ if (!decision.hasPendingTurn) return false;
231
281
  }
232
- if (typeof d.hasPendingTurn === 'function' && !d.hasPendingTurn()) return false;
233
282
  if (typeof d.stopTurn !== 'function') return false;
234
283
  if (typeof e.preventDefault === 'function') e.preventDefault();
235
- d.stopTurn();
284
+ decision.consumed = true;
285
+ // The key is consumed either way — the interrupt has been asked for and the
286
+ // turn must not survive a throw from the stop itself. What the stop
287
+ // ANSWERED is recorded, because a consumed key with a refused stop is the
288
+ // one combination that looks like success from outside while nothing has
289
+ // happened: `defaultPrevented` is true, the bubble never appears, and the
290
+ // turn runs to completion.
291
+ let stopResult = null;
292
+ try {
293
+ const r = d.stopTurn();
294
+ stopResult = r === undefined ? null : Boolean(r);
295
+ } catch (err) {
296
+ stopResult = false;
297
+ decision.threw = 'stopTurn: ' + ((err && err.message) || err);
298
+ }
299
+ decision.stopAccepted = stopResult;
236
300
  return true;
237
301
  }
238
302
 
@@ -242,6 +306,7 @@ function bindEscapeInterrupt(deps) {
242
306
 
243
307
  return {
244
308
  handle: handle,
309
+ lastDecision: () => lastDecision,
245
310
  unbind: () => {
246
311
  if (d.doc && typeof d.doc.removeEventListener === 'function') {
247
312
  d.doc.removeEventListener('keydown', handle);