aegis-desktop 0.8.34 → 0.8.35
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/engine.js +113 -2
- package/main.js +35 -0
- package/package.json +1 -1
- package/preload.js +6 -0
- package/renderer/app.js +162 -12
- package/renderer/index.html +2 -2
- package/renderer/style.css +18 -0
package/lib/local/engine.js
CHANGED
|
@@ -797,6 +797,7 @@ function createLocalEngine({
|
|
|
797
797
|
// remembering "no" makes the first answer stick.
|
|
798
798
|
const sessionDenials = new Map(); // rootSessionId -> Set<toolName>
|
|
799
799
|
const pendingApprovals = new Map(); // approvalId -> { resolve }
|
|
800
|
+
const pendingRoundAsks = new Map(); // askId -> { resolve }
|
|
800
801
|
|
|
801
802
|
function sessionAllows(rootId, name) {
|
|
802
803
|
const set = sessionAllowlists.get(rootId);
|
|
@@ -911,6 +912,62 @@ function createLocalEngine({
|
|
|
911
912
|
});
|
|
912
913
|
}
|
|
913
914
|
|
|
915
|
+
/** The renderer's rounds-dialog answer resolves the pending
|
|
916
|
+
* requestRoundsAsk() promise below. An unknown/already-answered id is a
|
|
917
|
+
* no-op, same contract as respondApproval. */
|
|
918
|
+
function respondRoundsAsk(askId, decision) {
|
|
919
|
+
const pending = pendingRoundAsks.get(askId);
|
|
920
|
+
if (!pending) return { ok: false };
|
|
921
|
+
pendingRoundAsks.delete(askId);
|
|
922
|
+
pending.resolve(decision === true || decision === 'continue');
|
|
923
|
+
return { ok: true };
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
/**
|
|
927
|
+
* Ask the renderer whether to fund another block of tool rounds, mirroring
|
|
928
|
+
* requestApproval's shape exactly: pushed over `rootOnDelta` as a `{ rounds }`
|
|
929
|
+
* chunk (the one channel a detached renderer has into a running turn) and
|
|
930
|
+
* resolved externally via `respondRoundsAsk`. Resolves `true` (fund one more
|
|
931
|
+
* block) or `false` (stop here) — never throws. Fails safe to `false`: no
|
|
932
|
+
* listener, an aborted turn, or an unanswered ask must not keep the loop
|
|
933
|
+
* waiting forever (or, worse, spend rounds nobody agreed to).
|
|
934
|
+
*/
|
|
935
|
+
function requestRoundsAsk(rootSessionId, rootOnDelta, signal, info) {
|
|
936
|
+
return new Promise((resolve) => {
|
|
937
|
+
if (signal && signal.aborted) {
|
|
938
|
+
resolve(false);
|
|
939
|
+
return;
|
|
940
|
+
}
|
|
941
|
+
const id = randomUUID();
|
|
942
|
+
let settled = false;
|
|
943
|
+
const onAbort = () => finish(false);
|
|
944
|
+
const finish = (decision) => {
|
|
945
|
+
if (settled) return;
|
|
946
|
+
settled = true;
|
|
947
|
+
pendingRoundAsks.delete(id);
|
|
948
|
+
if (signal) signal.removeEventListener('abort', onAbort);
|
|
949
|
+
resolve(decision);
|
|
950
|
+
};
|
|
951
|
+
if (signal) signal.addEventListener('abort', onAbort, { once: true });
|
|
952
|
+
pendingRoundAsks.set(id, { resolve: finish });
|
|
953
|
+
if (typeof rootOnDelta !== 'function') {
|
|
954
|
+
finish(false);
|
|
955
|
+
return;
|
|
956
|
+
}
|
|
957
|
+
rootOnDelta({
|
|
958
|
+
delta: '',
|
|
959
|
+
rounds: {
|
|
960
|
+
id,
|
|
961
|
+
sessionId: rootSessionId,
|
|
962
|
+
spent: info.spent,
|
|
963
|
+
extensions: info.extensions,
|
|
964
|
+
max: info.max,
|
|
965
|
+
size: info.size,
|
|
966
|
+
},
|
|
967
|
+
});
|
|
968
|
+
});
|
|
969
|
+
}
|
|
970
|
+
|
|
914
971
|
/**
|
|
915
972
|
* The gate itself: read-only tools and already-session-allowed mutating
|
|
916
973
|
* tools run exactly like executeTool always did. A first-time mutating
|
|
@@ -1522,6 +1579,12 @@ function createLocalEngine({
|
|
|
1522
1579
|
// synthesis pass) instead of a single provider call. UI-gated to the
|
|
1523
1580
|
// 'aegis' class only (see AUTONOMOUS_CLASS in app.js).
|
|
1524
1581
|
const autonomous = cls === 'aegis' && Boolean(payload && payload.autonomous);
|
|
1582
|
+
// Opt-in only: the round-horizon dialog (requestRoundsAsk below) needs a
|
|
1583
|
+
// live renderer on the other end of `rootOnDelta` able to answer it. The
|
|
1584
|
+
// interactive CLI session sets this; the queue worker and `-p` one-shot
|
|
1585
|
+
// runs never do (nobody is there to click), so they keep the old
|
|
1586
|
+
// stop-and-record-session behaviour (sessionRounds below) untouched.
|
|
1587
|
+
const roundsAsk = Boolean(payload && payload.roundsAsk);
|
|
1525
1588
|
const sessionId = (payload && payload.sessionId) || randomUUID();
|
|
1526
1589
|
// Recursion depth for the task tool: 0 for a real user turn, N+1 for a
|
|
1527
1590
|
// subagent spawned by depth N. Never set by an IPC caller — only by
|
|
@@ -1797,8 +1860,17 @@ function createLocalEngine({
|
|
|
1797
1860
|
}
|
|
1798
1861
|
// A resumed turn gets a small bonus on top of the base horizon so
|
|
1799
1862
|
// re-orientation does not consume the new budget before any work lands.
|
|
1800
|
-
|
|
1863
|
+
let maxRounds = statedRounds || baseRounds + (resumed ? sessionRounds.bonus(baseRounds) : 0);
|
|
1801
1864
|
let round = 0;
|
|
1865
|
+
// Rounds bought by one confirmed extension, and a hard cap on how many
|
|
1866
|
+
// extensions a single turn may spend — matching the upstream engine's
|
|
1867
|
+
// numbers (src/autonomous.js ROUND_EXTENSION_SIZE/MAX_ROUND_EXTENSIONS)
|
|
1868
|
+
// so a turn behaves the same whether it runs there or here. The cap
|
|
1869
|
+
// exists even though the renderer is the one asking: a dialog that
|
|
1870
|
+
// always says yes must still terminate (8 x baseRounds extra rounds).
|
|
1871
|
+
const ROUND_EXTENSION_SIZE = baseRounds;
|
|
1872
|
+
const MAX_ROUND_EXTENSIONS = 8;
|
|
1873
|
+
let extensions = 0;
|
|
1802
1874
|
|
|
1803
1875
|
// Token accounting for the whole TURN, not just its last round. An
|
|
1804
1876
|
// agentic turn makes one provider call per tool round, and returning only
|
|
@@ -1858,11 +1930,49 @@ function createLocalEngine({
|
|
|
1858
1930
|
// which is the same "(empty response)" failure the guards below exist
|
|
1859
1931
|
// to prevent.
|
|
1860
1932
|
if (round >= maxRounds) {
|
|
1933
|
+
// Before conceding, give an interactive caller the chance to fund
|
|
1934
|
+
// another block of rounds instead of ending the turn outright — the
|
|
1935
|
+
// upstream engine's "asks" contract (src/backend.js requestExtension).
|
|
1936
|
+
// Only reachable with `roundsAsk` AND a live signal: a caller with no
|
|
1937
|
+
// renderer on the other end (queue, `-p`) falls straight through to
|
|
1938
|
+
// the session-hold stop below, unchanged.
|
|
1939
|
+
if (roundsAsk && extensions < MAX_ROUND_EXTENSIONS && !signal.aborted) {
|
|
1940
|
+
const granted = await requestRoundsAsk(rootSessionId, rootOnDelta, signal, {
|
|
1941
|
+
spent: maxRounds,
|
|
1942
|
+
extensions: extensions + 1,
|
|
1943
|
+
max: MAX_ROUND_EXTENSIONS,
|
|
1944
|
+
size: ROUND_EXTENSION_SIZE,
|
|
1945
|
+
});
|
|
1946
|
+
if (granted) {
|
|
1947
|
+
extensions += 1;
|
|
1948
|
+
const spent = maxRounds;
|
|
1949
|
+
maxRounds += ROUND_EXTENSION_SIZE;
|
|
1950
|
+
history.push({
|
|
1951
|
+
role: 'user',
|
|
1952
|
+
content: `You have used all ${spent} tool rounds allowed for this turn; ` +
|
|
1953
|
+
`${ROUND_EXTENSION_SIZE} more have been granted (do not restart). Continue the task ` +
|
|
1954
|
+
'from exactly where you stopped — do not re-plan, do not repeat work already done, ' +
|
|
1955
|
+
'and finish with an answer.',
|
|
1956
|
+
});
|
|
1957
|
+
continue;
|
|
1958
|
+
}
|
|
1959
|
+
// Declined (or the extension cap is spent): fall through to the
|
|
1960
|
+
// same session-hold stop a non-interactive caller gets, so a "no"
|
|
1961
|
+
// here is bit-for-bit the old hard stop rather than a dead end.
|
|
1962
|
+
}
|
|
1963
|
+
// The knob is only advice where nobody could have been asked about
|
|
1964
|
+
// it. An interactive caller was already offered another block of
|
|
1965
|
+
// rounds (requestRoundsAsk above) and declined or spent the cap, so
|
|
1966
|
+
// pointing it at an env var it cannot set from inside the app — and
|
|
1967
|
+
// would have to restart to apply — is obsolete text: drop it. The
|
|
1968
|
+
// queue worker and `-p` one-shots have no dialog, so for them the
|
|
1969
|
+
// variable remains the only lever and is still named.
|
|
1970
|
+
const knob = roundsAsk ? '' : `, or raise ${roundEnvName} for a longer single turn`;
|
|
1861
1971
|
const note =
|
|
1862
1972
|
`[stopped at ${maxRounds} tool rounds` +
|
|
1863
1973
|
`${turnUsage.total_tokens ? `, ${turnUsage.total_tokens.toLocaleString()} tokens` : ''}` +
|
|
1864
1974
|
`. This session holds the work: your next message here resumes it automatically` +
|
|
1865
|
-
` (with a continuation preamble)
|
|
1975
|
+
` (with a continuation preamble)${knob}.]`;
|
|
1866
1976
|
// File the interruption before returning, so the horizon is a pause
|
|
1867
1977
|
// in the session rather than the end of the job. `chain` carries the
|
|
1868
1978
|
// count forward across resumes.
|
|
@@ -2146,6 +2256,7 @@ function createLocalEngine({
|
|
|
2146
2256
|
chat,
|
|
2147
2257
|
cancel,
|
|
2148
2258
|
respondApproval,
|
|
2259
|
+
respondRoundsAsk,
|
|
2149
2260
|
clearSessionApprovals,
|
|
2150
2261
|
settings,
|
|
2151
2262
|
unloadLocalModels,
|
package/main.js
CHANGED
|
@@ -1430,6 +1430,22 @@ function createModelDispatch(engine) {
|
|
|
1430
1430
|
// session" grants so a fresh thread never inherits a prior one's.
|
|
1431
1431
|
respondApproval: (payload) =>
|
|
1432
1432
|
engine.respondApproval(payload && payload.approvalId, payload && payload.decision),
|
|
1433
|
+
// Round-horizon gate (desktop/lib/local/engine.js requestRoundsAsk): the
|
|
1434
|
+
// renderer's rounds card answers a pending "fund another block of tool
|
|
1435
|
+
// rounds?" ask here. The ask itself arrives as a `{ rounds }` chunk on the
|
|
1436
|
+
// same chat() delta stream the approval card rides — see
|
|
1437
|
+
// desktop/renderer/app.js renderRoundsCard. An unanswered id is a no-op,
|
|
1438
|
+
// same contract as respondApproval.
|
|
1439
|
+
//
|
|
1440
|
+
// Guarded on the method's existence rather than assuming it: an engine
|
|
1441
|
+
// without the ask can never emit a `{ rounds }` chunk, so this branch is
|
|
1442
|
+
// unreachable there — but a renderer/window pair that is newer than the
|
|
1443
|
+
// engine it drives must get an honest `{ ok: false }` instead of a
|
|
1444
|
+
// TypeError inside the IPC handler.
|
|
1445
|
+
respondRoundsAsk: (payload) =>
|
|
1446
|
+
typeof engine.respondRoundsAsk === 'function'
|
|
1447
|
+
? engine.respondRoundsAsk(payload && payload.roundsAskId, payload && payload.decision)
|
|
1448
|
+
: { ok: false },
|
|
1433
1449
|
clearApprovals: (payload) =>
|
|
1434
1450
|
engine.clearSessionApprovals(payload && payload.sessionId),
|
|
1435
1451
|
};
|
|
@@ -1879,11 +1895,30 @@ function queueEngineWithDeniedApprovals(engine) {
|
|
|
1879
1895
|
/* the turn's own error path reports a gate that cannot be answered */
|
|
1880
1896
|
}
|
|
1881
1897
|
};
|
|
1898
|
+
// The third net under the same rule, for the round-horizon ask. A queued task
|
|
1899
|
+
// is unattended, so nobody can fund another block and the answer is always
|
|
1900
|
+
// "no" — which is precisely the engine's own non-interactive behaviour (it
|
|
1901
|
+
// stops, files the session hold, and names the env knob). Answering `false`
|
|
1902
|
+
// rather than leaving the promise pending is what keeps a worker from parking
|
|
1903
|
+
// on a card nobody will ever click: an unanswered ask does resolve false
|
|
1904
|
+
// (requestRoundsAsk fails safe), but waiting for the abort instead would look
|
|
1905
|
+
// like a hung task in the queue for however long the turn ran.
|
|
1906
|
+
const stopRounds = (chunk) => {
|
|
1907
|
+
const ask = chunk && chunk.rounds;
|
|
1908
|
+
if (!ask || !ask.id) return;
|
|
1909
|
+
if (typeof engine.respondRoundsAsk !== 'function') return;
|
|
1910
|
+
try {
|
|
1911
|
+
engine.respondRoundsAsk(ask.id, false);
|
|
1912
|
+
} catch {
|
|
1913
|
+
/* as above: the turn reports a gate that cannot be answered */
|
|
1914
|
+
}
|
|
1915
|
+
};
|
|
1882
1916
|
return {
|
|
1883
1917
|
...engine,
|
|
1884
1918
|
chat(payload, onDelta) {
|
|
1885
1919
|
return engine.chat(payload, (chunk) => {
|
|
1886
1920
|
deny(chunk);
|
|
1921
|
+
stopRounds(chunk);
|
|
1887
1922
|
if (typeof onDelta === 'function') onDelta(chunk);
|
|
1888
1923
|
});
|
|
1889
1924
|
},
|
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.35",
|
|
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/preload.js
CHANGED
|
@@ -357,6 +357,12 @@ const models = {
|
|
|
357
357
|
// this session" grants; newChat() calls it so a fresh thread starts clean.
|
|
358
358
|
respondApproval: (approvalId, decision) =>
|
|
359
359
|
invokeModel('respondApproval', { approvalId, decision }),
|
|
360
|
+
// Round-horizon gate: answers a pending `{ rounds }` ask raised by the engine
|
|
361
|
+
// when a turn has used every tool round it was allowed (see app.js
|
|
362
|
+
// renderRoundsCard, and requestRoundsAsk in lib/local/engine.js). Same shape
|
|
363
|
+
// and same channel as respondApproval above.
|
|
364
|
+
respondRoundsAsk: (roundsAskId, decision) =>
|
|
365
|
+
invokeModel('respondRoundsAsk', { roundsAskId, decision }),
|
|
360
366
|
clearApprovals: (sessionId) => invokeModel('clearApprovals', { sessionId }),
|
|
361
367
|
};
|
|
362
368
|
|
package/renderer/app.js
CHANGED
|
@@ -2639,7 +2639,27 @@ function hideWelcome() {
|
|
|
2639
2639
|
// window.open (CSP `script-src 'self'` drops nothing here, but the
|
|
2640
2640
|
// main process is what validates the URL).
|
|
2641
2641
|
|
|
2642
|
-
/** Show the connect block
|
|
2642
|
+
/** Show the connect block while EITHER connect route is still untaken, and
|
|
2643
|
+
* retire the individual routes that have been taken.
|
|
2644
|
+
*
|
|
2645
|
+
* This used to hide the whole block — "bring your own key" included — the
|
|
2646
|
+
* moment `keyConfigured` went true, on the reasoning that a panel still
|
|
2647
|
+
* advertising a route the user already took is a nag. It is a nag; but the
|
|
2648
|
+
* two routes are not alternatives, and the user who just redeemed an AEGIS
|
|
2649
|
+
* key is the one most likely to want a provider key of their own (a model the
|
|
2650
|
+
* pool does not carry, a turn that is not relayed). Hiding the BYOK path at
|
|
2651
|
+
* exactly that moment meant the app answered "you have a key" to a question
|
|
2652
|
+
* nobody had asked, and the two-key pop-up below was left to carry the whole
|
|
2653
|
+
* explanation. So the gates are now split, one per route, matching the CLI
|
|
2654
|
+
* welcome screen's `connectRows()`:
|
|
2655
|
+
*
|
|
2656
|
+
* cloud — retired by an AEGIS account key (that is the thing it pitches);
|
|
2657
|
+
* byok — retired by a provider route of the user's own (`providerConfigured`
|
|
2658
|
+
* — a BYOK key or a local base URL, which never touch aegis.apiKey).
|
|
2659
|
+
*
|
|
2660
|
+
* Both states are null/false until status is read, so an unknown state keeps
|
|
2661
|
+
* the block visible: on a cold boot the user has no key far more often than
|
|
2662
|
+
* not, and renderStatus() calls this again the moment the truth arrives. */
|
|
2643
2663
|
function applyWelcomeConnect() {
|
|
2644
2664
|
const box = document.getElementById('welcome-connect');
|
|
2645
2665
|
if (!box) return;
|
|
@@ -2649,15 +2669,22 @@ function applyWelcomeConnect() {
|
|
|
2649
2669
|
} catch {
|
|
2650
2670
|
/* storage disabled — treat as not dismissed */
|
|
2651
2671
|
}
|
|
2652
|
-
//
|
|
2653
|
-
|
|
2654
|
-
|
|
2655
|
-
|
|
2656
|
-
//
|
|
2657
|
-
//
|
|
2658
|
-
|
|
2659
|
-
|
|
2660
|
-
|
|
2672
|
+
// `!== true`, not `=== false`: null is "not read yet" and must read as owed.
|
|
2673
|
+
const cloudOwed = keyConfigured !== true;
|
|
2674
|
+
const byokOwed = providerConfigured !== true;
|
|
2675
|
+
box.hidden = dismissed || (!cloudOwed && !byokOwed);
|
|
2676
|
+
// Each route retires on its own, button AND its note — a note left under a
|
|
2677
|
+
// gone button reads as a dangling fragment.
|
|
2678
|
+
setHidden('welcome-cloud', !cloudOwed);
|
|
2679
|
+
setHidden('welcome-cloud-note', !cloudOwed);
|
|
2680
|
+
setHidden('welcome-byok', !byokOwed);
|
|
2681
|
+
setHidden('welcome-byok-note', !byokOwed);
|
|
2682
|
+
}
|
|
2683
|
+
|
|
2684
|
+
/** Hide/show one cloned panel node by id when it exists. */
|
|
2685
|
+
function setHidden(id, hidden) {
|
|
2686
|
+
const el = document.getElementById(id);
|
|
2687
|
+
if (el) el.hidden = hidden;
|
|
2661
2688
|
}
|
|
2662
2689
|
|
|
2663
2690
|
/**
|
|
@@ -2786,7 +2813,8 @@ function dismissWelcomeConnect() {
|
|
|
2786
2813
|
// ------------------------------------------- boot: the two-key pop-up
|
|
2787
2814
|
//
|
|
2788
2815
|
// The welcome block above pitches the ROUTES ("connect AEGIS Cloud", "bring
|
|
2789
|
-
// your own key") and retires
|
|
2816
|
+
// your own key") and retires each one as it is taken — keyConfigured for the
|
|
2817
|
+
// Cloud row, providerConfigured for the BYOK row. This sheet is the
|
|
2790
2818
|
// other question, and the one nothing on screen used to answer: this app and
|
|
2791
2819
|
// the `aegiscode` CLI on the command line are ONE engine over ONE store, so a
|
|
2792
2820
|
// user who added only one of the two keys gets a surface that refuses turns
|
|
@@ -3580,6 +3608,95 @@ function renderApprovalCard(row, approval, beforeSelector) {
|
|
|
3580
3608
|
return card;
|
|
3581
3609
|
}
|
|
3582
3610
|
|
|
3611
|
+
/**
|
|
3612
|
+
* Render the round-horizon card (`onDelta`'s `{ rounds: { id, spent,
|
|
3613
|
+
* extensions, max, size } }` chunk — see `requestRoundsAsk` in
|
|
3614
|
+
* desktop/lib/local/engine.js).
|
|
3615
|
+
*
|
|
3616
|
+
* This is the desktop half of what the CLI has handled since the ask landed
|
|
3617
|
+
* (`cli/src/app.js` `chunk.rounds` → `confirmRoundsExtension`). Without it the
|
|
3618
|
+
* desktop never opted in (`roundsAsk` was false, because nothing sent it), so a
|
|
3619
|
+
* turn that used its rounds ended with the engine's non-interactive note —
|
|
3620
|
+
* "…or raise AEGIS_CHAT_MAX_ROUNDS for a longer single turn" — naming an
|
|
3621
|
+
* environment variable a packaged Electron app gives its user no way to set.
|
|
3622
|
+
* The engine only says that where nobody could have been asked, so wiring the
|
|
3623
|
+
* ask is what deletes the obsolete line here: with the card on screen the note
|
|
3624
|
+
* drops the knob and keeps the part that is true (the work is held, the next
|
|
3625
|
+
* message resumes it).
|
|
3626
|
+
*
|
|
3627
|
+
* Answers through `models.respondRoundsAsk`; like the approval card it disables
|
|
3628
|
+
* itself on the first click so a double-click cannot send two decisions for one
|
|
3629
|
+
* id. Declining is NOT destructive: the engine falls through to the same
|
|
3630
|
+
* session-hold stop a non-interactive caller gets, so "Stop here" costs the user
|
|
3631
|
+
* nothing they cannot resume.
|
|
3632
|
+
*/
|
|
3633
|
+
function renderRoundsCard(row, ask, beforeSelector) {
|
|
3634
|
+
if (!row || !ask || !ask.id) return null;
|
|
3635
|
+
|
|
3636
|
+
const spent = Number(ask.spent) || 0;
|
|
3637
|
+
const size = Number(ask.size) || spent;
|
|
3638
|
+
const extensions = Number(ask.extensions) || 1;
|
|
3639
|
+
const max = Number(ask.max) || 8;
|
|
3640
|
+
|
|
3641
|
+
const card = document.createElement('div');
|
|
3642
|
+
card.className = 'approval-card rounds-card';
|
|
3643
|
+
card.dataset.roundsAskId = ask.id;
|
|
3644
|
+
|
|
3645
|
+
const title = document.createElement('div');
|
|
3646
|
+
title.className = 'approval-title';
|
|
3647
|
+
title.textContent = `Round limit reached — keep going?`;
|
|
3648
|
+
card.appendChild(title);
|
|
3649
|
+
|
|
3650
|
+
const summary = document.createElement('div');
|
|
3651
|
+
summary.className = 'approval-summary';
|
|
3652
|
+
// Says the three things the user needs to decide: what the limit is, what
|
|
3653
|
+
// "keep going" buys, and that stopping is a pause rather than a loss. No
|
|
3654
|
+
// environment variable is named, because the card IS the lever now.
|
|
3655
|
+
summary.textContent =
|
|
3656
|
+
`This turn has used all ${spent} tool rounds allowed for it. ` +
|
|
3657
|
+
`${size} more continue the SAME turn — no restart, no repeated work. ` +
|
|
3658
|
+
`Stopping holds everything done so far: your next message resumes it automatically. ` +
|
|
3659
|
+
`(${extensions} of ${max} extensions.)`;
|
|
3660
|
+
card.appendChild(summary);
|
|
3661
|
+
|
|
3662
|
+
const actions = document.createElement('div');
|
|
3663
|
+
actions.className = 'approval-actions';
|
|
3664
|
+
|
|
3665
|
+
const decide = (decision) => {
|
|
3666
|
+
for (const btn of actions.querySelectorAll('button')) btn.disabled = true;
|
|
3667
|
+
card.classList.add('resolved');
|
|
3668
|
+
const tag = document.createElement('div');
|
|
3669
|
+
tag.className = 'approval-decision';
|
|
3670
|
+
tag.textContent = decision ? `Continuing — ${size} more rounds` : 'Stopped — the work is held';
|
|
3671
|
+
card.appendChild(tag);
|
|
3672
|
+
models.respondRoundsAsk(ask.id, decision);
|
|
3673
|
+
};
|
|
3674
|
+
|
|
3675
|
+
const mkButton = (label, cls, decision) => {
|
|
3676
|
+
const btn = document.createElement('button');
|
|
3677
|
+
btn.type = 'button';
|
|
3678
|
+
btn.className = `approval-btn ${cls}`;
|
|
3679
|
+
btn.textContent = label;
|
|
3680
|
+
btn.addEventListener('click', () => decide(decision));
|
|
3681
|
+
return btn;
|
|
3682
|
+
};
|
|
3683
|
+
|
|
3684
|
+
actions.appendChild(mkButton(`Keep going (${size} more rounds)`, 'allow', true));
|
|
3685
|
+
actions.appendChild(mkButton('Stop here', 'deny', false));
|
|
3686
|
+
card.appendChild(actions);
|
|
3687
|
+
|
|
3688
|
+
let slot = row.querySelector('.approval-slot');
|
|
3689
|
+
if (!slot) {
|
|
3690
|
+
slot = document.createElement('div');
|
|
3691
|
+
slot.className = 'approval-slot';
|
|
3692
|
+
const before = beforeSelector ? row.querySelector(beforeSelector) : null;
|
|
3693
|
+
if (before) row.insertBefore(slot, before);
|
|
3694
|
+
else row.appendChild(slot);
|
|
3695
|
+
}
|
|
3696
|
+
slot.appendChild(card);
|
|
3697
|
+
return card;
|
|
3698
|
+
}
|
|
3699
|
+
|
|
3583
3700
|
function classLabel(cls) {
|
|
3584
3701
|
const found = classOptions.find((c) => c.class === cls);
|
|
3585
3702
|
return found ? found.label : cls;
|
|
@@ -4555,6 +4672,17 @@ async function send() {
|
|
|
4555
4672
|
stickToBottom({ force: true });
|
|
4556
4673
|
return;
|
|
4557
4674
|
}
|
|
4675
|
+
if (chunk && chunk.rounds) {
|
|
4676
|
+
const row = ensurePendingRow();
|
|
4677
|
+
row.classList.remove('pending');
|
|
4678
|
+
renderRoundsCard(row, chunk.rounds, '.body');
|
|
4679
|
+
// Same blocked-until-answered reason as the approval card above. The
|
|
4680
|
+
// engine holds the turn open on this promise, and an unanswered ask
|
|
4681
|
+
// resolves `false` — so a card the user never scrolled to would read as
|
|
4682
|
+
// the turn stopping for no reason.
|
|
4683
|
+
stickToBottom({ force: true });
|
|
4684
|
+
return;
|
|
4685
|
+
}
|
|
4558
4686
|
if (chunk && chunk.tool) {
|
|
4559
4687
|
// See the flow-stream handler above: the run frame is the CLI's live
|
|
4560
4688
|
// row. Ignored here, and ignored *before* `toolLog.push` — a run frame
|
|
@@ -4579,7 +4707,29 @@ async function send() {
|
|
|
4579
4707
|
// All four classes route through the model: surface (the main process
|
|
4580
4708
|
// decides transport — cloud client, ollama, or a direct provider).
|
|
4581
4709
|
const data = await models.chat(
|
|
4582
|
-
{
|
|
4710
|
+
{
|
|
4711
|
+
class: cls,
|
|
4712
|
+
prompt,
|
|
4713
|
+
model,
|
|
4714
|
+
maxTokens,
|
|
4715
|
+
sessionId,
|
|
4716
|
+
autonomous,
|
|
4717
|
+
effort,
|
|
4718
|
+
workers,
|
|
4719
|
+
messages: historyForModel,
|
|
4720
|
+
// Opt in to the round-horizon dialog. A live renderer on the other end
|
|
4721
|
+
// of the delta stream is exactly what this flag asserts, and this call
|
|
4722
|
+
// has one (the `{ rounds }` branch of onDelta above answers it via
|
|
4723
|
+
// renderRoundsCard → models.respondRoundsAsk). It is also what retires
|
|
4724
|
+
// the engine's "raise AEGIS_CHAT_MAX_ROUNDS" advice: that line is
|
|
4725
|
+
// written only for callers nobody could ask (see the `knob` note in
|
|
4726
|
+
// lib/local/engine.js), and a packaged app user has no env to set.
|
|
4727
|
+
// The discovery lane (spawnPath) deliberately does NOT pass this: it is
|
|
4728
|
+
// single-shot with no tools, so it can never reach a round horizon, and
|
|
4729
|
+
// opting in would buy a dialog nobody could answer in a fire-and-forget
|
|
4730
|
+
// card.
|
|
4731
|
+
roundsAsk: true,
|
|
4732
|
+
},
|
|
4583
4733
|
onDelta
|
|
4584
4734
|
);
|
|
4585
4735
|
|
package/renderer/index.html
CHANGED
|
@@ -544,14 +544,14 @@
|
|
|
544
544
|
<button type="button" class="wc-link" id="welcome-cloud">
|
|
545
545
|
Connect AEGIS Cloud — I already have a key →
|
|
546
546
|
</button>
|
|
547
|
-
<span class="wc-note">
|
|
547
|
+
<span class="wc-note" id="welcome-cloud-note">
|
|
548
548
|
Connect it here and the same queue runs on pooled models right
|
|
549
549
|
away, free, while this window is open.
|
|
550
550
|
</span>
|
|
551
551
|
<button type="button" class="wc-link" id="welcome-byok">
|
|
552
552
|
Bring your own key →
|
|
553
553
|
</button>
|
|
554
|
-
<span class="wc-note">
|
|
554
|
+
<span class="wc-note" id="welcome-byok-note">
|
|
555
555
|
The free path, and it stays free: exec, write and edit run on
|
|
556
556
|
your machine, your repos, sessions and keys live on this disk,
|
|
557
557
|
and a local model server needs no account at all. A provider
|
package/renderer/style.css
CHANGED
|
@@ -1525,6 +1525,24 @@ label {
|
|
|
1525
1525
|
background: rgba(255, 255, 255, 0.03);
|
|
1526
1526
|
}
|
|
1527
1527
|
|
|
1528
|
+
/* The round-horizon card borrows the approval card wholesale — same slot, same
|
|
1529
|
+
buttons, same one-decision-then-disable behaviour — and only re-tints to the
|
|
1530
|
+
accent: it is a budget question about the turn, not a permission question
|
|
1531
|
+
about a tool, and the two can be on screen in the same conversation. Without
|
|
1532
|
+
the tint a user reading quickly would answer the wrong one. */
|
|
1533
|
+
.rounds-card {
|
|
1534
|
+
border-color: var(--accent);
|
|
1535
|
+
background: rgba(90, 160, 255, 0.08);
|
|
1536
|
+
}
|
|
1537
|
+
|
|
1538
|
+
.rounds-card .approval-title {
|
|
1539
|
+
color: var(--accent);
|
|
1540
|
+
}
|
|
1541
|
+
|
|
1542
|
+
.rounds-card.resolved .approval-title {
|
|
1543
|
+
color: var(--text2);
|
|
1544
|
+
}
|
|
1545
|
+
|
|
1528
1546
|
.approval-title {
|
|
1529
1547
|
font-family: var(--font-mono);
|
|
1530
1548
|
font-size: 11px;
|