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.
@@ -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
- const maxRounds = statedRounds || baseRounds + (resumed ? sessionRounds.bonus(baseRounds) : 0);
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), or raise ${roundEnvName} for a longer single turn.]`;
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.34",
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 only on an unconnected, un-dismissed install. */
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
- // Both connect routes retire the block: the AEGIS Cloud key (keyConfigured)
2653
- // and a provider route of the user's own (providerConfigured — a BYOK key or
2654
- // a local base URL, which never touch aegis.apiKey). A panel that keeps
2655
- // advertising "Bring your own key" to someone who already did is a nag.
2656
- //
2657
- // keyConfigured is null until status is read, so an unknown state keeps the
2658
- // block visible: on a cold boot the user has no key far more often than not,
2659
- // and renderStatus() calls this again the moment the truth arrives.
2660
- box.hidden = dismissed || keyConfigured === true || providerConfigured === true;
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 the moment either one is taken. This sheet is the
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
- { class: cls, prompt, model, maxTokens, sessionId, autonomous, effort, workers, messages: historyForModel },
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
 
@@ -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
@@ -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;