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.
- package/lib/local/entitlement.js +81 -0
- package/lib/local/local.js +54 -2
- package/lib/local/tools.js +113 -0
- package/main.js +22 -5
- package/package.json +1 -1
- package/renderer/app.js +94 -8
- package/renderer/transcript-view.js +70 -5
- package/vendor/aegis.js +118 -6
- package/vendor/agent-gate.mjs +1063 -0
package/lib/local/entitlement.js
CHANGED
|
@@ -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,
|
package/lib/local/local.js
CHANGED
|
@@ -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
|
-
|
|
382
|
-
if (
|
|
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
|
package/lib/local/tools.js
CHANGED
|
@@ -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
|
|
686
|
-
// created
|
|
687
|
-
//
|
|
688
|
-
//
|
|
689
|
-
|
|
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.
|
|
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(
|
|
687
|
-
|
|
688
|
-
|
|
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
|
-
|
|
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
|
-
|
|
4503
|
-
|
|
4504
|
-
|
|
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'
|
|
229
|
-
|
|
230
|
-
|
|
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
|
-
|
|
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);
|