@genesislcap/ai-assistant 15.6.2 → 15.7.1

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.
Files changed (76) hide show
  1. package/dist/ai-assistant.api.json +422 -6
  2. package/dist/ai-assistant.d.ts +654 -19
  3. package/dist/chat-driver.cjs +285 -26
  4. package/dist/chat-driver.cjs.map +3 -3
  5. package/dist/chat-driver.mjs +285 -26
  6. package/dist/chat-driver.mjs.map +3 -3
  7. package/dist/custom-elements.json +289 -32
  8. package/dist/dts/channel/ai-activity-channel.d.ts +51 -1
  9. package/dist/dts/channel/ai-activity-channel.d.ts.map +1 -1
  10. package/dist/dts/components/chat-driver/chat-driver.d.ts +99 -1
  11. package/dist/dts/components/chat-driver/chat-driver.d.ts.map +1 -1
  12. package/dist/dts/components/chat-driver/chat-driver.test.d.ts.map +1 -1
  13. package/dist/dts/components/orchestrating-driver/orchestrating-driver.budget.test.d.ts +2 -0
  14. package/dist/dts/components/orchestrating-driver/orchestrating-driver.budget.test.d.ts.map +1 -0
  15. package/dist/dts/components/orchestrating-driver/orchestrating-driver.d.ts +14 -0
  16. package/dist/dts/components/orchestrating-driver/orchestrating-driver.d.ts.map +1 -1
  17. package/dist/dts/components/settings-modal/settings-modal.styles.d.ts.map +1 -1
  18. package/dist/dts/components/settings-modal/settings-modal.template.d.ts.map +1 -1
  19. package/dist/dts/main/blocked-state.test.d.ts +2 -0
  20. package/dist/dts/main/blocked-state.test.d.ts.map +1 -0
  21. package/dist/dts/main/main.d.ts +435 -19
  22. package/dist/dts/main/main.d.ts.map +1 -1
  23. package/dist/dts/main/main.styles.d.ts.map +1 -1
  24. package/dist/dts/main/main.styles.test.d.ts +2 -0
  25. package/dist/dts/main/main.styles.test.d.ts.map +1 -0
  26. package/dist/dts/main/main.template.d.ts +53 -0
  27. package/dist/dts/main/main.template.d.ts.map +1 -1
  28. package/dist/dts/state/ai-assistant-slice.d.ts +162 -6
  29. package/dist/dts/state/ai-assistant-slice.d.ts.map +1 -1
  30. package/dist/dts/state/debug-event-log.d.ts +6 -1
  31. package/dist/dts/state/debug-event-log.d.ts.map +1 -1
  32. package/dist/dts/state/session-store.d.ts +11 -0
  33. package/dist/dts/state/session-store.d.ts.map +1 -1
  34. package/dist/dts/utils/banked-usage-baselines.d.ts +51 -0
  35. package/dist/dts/utils/banked-usage-baselines.d.ts.map +1 -0
  36. package/dist/esm/components/chat-driver/chat-driver.js +263 -21
  37. package/dist/esm/components/chat-driver/chat-driver.test.js +464 -1
  38. package/dist/esm/components/orchestrating-driver/orchestrating-driver.budget.test.js +312 -0
  39. package/dist/esm/components/orchestrating-driver/orchestrating-driver.js +89 -4
  40. package/dist/esm/components/settings-modal/settings-modal.styles.js +63 -18
  41. package/dist/esm/components/settings-modal/settings-modal.template.js +36 -7
  42. package/dist/esm/main/blocked-state.test.js +969 -0
  43. package/dist/esm/main/cost-session-banking.test.js +238 -8
  44. package/dist/esm/main/main.js +826 -48
  45. package/dist/esm/main/main.styles.js +72 -0
  46. package/dist/esm/main/main.styles.test.js +99 -0
  47. package/dist/esm/main/main.template.js +121 -4
  48. package/dist/esm/state/ai-assistant-slice.js +145 -7
  49. package/dist/esm/state/ai-assistant-slice.test.js +138 -1
  50. package/dist/esm/state/debug-event-log.js +7 -2
  51. package/dist/esm/state/debug-event-log.test.js +49 -1
  52. package/dist/esm/state/persistence/session-snapshot.test.js +18 -0
  53. package/dist/esm/utils/banked-usage-baselines.js +84 -0
  54. package/dist/tsconfig.tsbuildinfo +1 -1
  55. package/docs/migration-GENC-1464.md +562 -0
  56. package/docs/sub_agent.md +20 -3
  57. package/package.json +17 -17
  58. package/src/channel/ai-activity-channel.ts +56 -2
  59. package/src/components/chat-driver/chat-driver.test.ts +549 -0
  60. package/src/components/chat-driver/chat-driver.ts +324 -14
  61. package/src/components/orchestrating-driver/orchestrating-driver.budget.test.ts +438 -0
  62. package/src/components/orchestrating-driver/orchestrating-driver.ts +101 -6
  63. package/src/components/settings-modal/settings-modal.styles.ts +63 -18
  64. package/src/components/settings-modal/settings-modal.template.ts +38 -7
  65. package/src/main/blocked-state.test.ts +1316 -0
  66. package/src/main/cost-session-banking.test.ts +283 -11
  67. package/src/main/main.styles.test.ts +130 -0
  68. package/src/main/main.styles.ts +72 -0
  69. package/src/main/main.template.ts +131 -4
  70. package/src/main/main.ts +827 -42
  71. package/src/state/ai-assistant-slice.test.ts +215 -0
  72. package/src/state/ai-assistant-slice.ts +218 -8
  73. package/src/state/debug-event-log.test.ts +63 -0
  74. package/src/state/debug-event-log.ts +7 -2
  75. package/src/state/persistence/session-snapshot.test.ts +22 -0
  76. package/src/utils/banked-usage-baselines.ts +90 -0
@@ -22,7 +22,7 @@
22
22
  // =============================================================================
23
23
  var FoundationAiAssistant_1;
24
24
  import { __awaiter, __decorate, __rest } from "tslib";
25
- import { AIProviderRegistry, isObservableAIProviderRegistry } from '@genesislcap/foundation-ai';
25
+ import { AIProviderRegistry, BUDGETED_VENDORS, DEFAULT_BUDGET_EXHAUSTED_MESSAGE, isObservableAIProviderRegistry, VENDOR_LABELS, vendorTypeOfLabel, } from '@genesislcap/foundation-ai';
26
26
  import { showNotificationDialog } from '@genesislcap/foundation-notifications';
27
27
  import { avoidTreeShaking } from '@genesislcap/foundation-utils';
28
28
  import { customElement, DOM, html, GenesisElement, observable, volatile, attr, } from '@genesislcap/web-core';
@@ -53,6 +53,7 @@ import { AI_COLOUR_AMBER, AI_COLOUR_CYAN, AI_COLOUR_PINK, AI_COLOUR_VIOLET, } fr
53
53
  import { ChatSuggestions } from '../suggestions/chat-suggestions';
54
54
  import { AnimatedPanelToggle } from '../utils/animated-panel-toggle';
55
55
  import { resolveExclusiveLoadingStyle } from '../utils/animation-exclusivity';
56
+ import { deleteBankedBaseline, getBankedBaseline, setBankedBaseline, } from '../utils/banked-usage-baselines';
56
57
  import { collectSessionModels } from '../utils/collect-session-models';
57
58
  import { clearCostSessionHistory, isCostSessionRecord, loadCostSessionHistory, resolveBankedUsage, saveCostSessionHistory, sortRecordsByRecency, upsertRecord, } from '../utils/cost-session-history';
58
59
  import { deriveCostSessionTitleFromMessages, resolveCostSessionTitle, } from '../utils/derive-cost-session-title';
@@ -110,6 +111,84 @@ const COMPOSER_MIN_HEIGHT_PX = 48;
110
111
  const COMPOSER_MAX_HEIGHT_PX = 400;
111
112
  /** Keep at least this much of the message list visible while growing the composer. */
112
113
  const COMPOSER_MIN_MESSAGES_PX = 80;
114
+ /**
115
+ * Composer placeholder while the assistant is blocked (GENC-1464). Deliberately
116
+ * fixed and short, and NOT host-overridable — the banner directly above it
117
+ * carries the detail, and that one IS overridable via `setBlocked(true, reason)`.
118
+ * The banner's own default copy is `DEFAULT_BUDGET_EXHAUSTED_MESSAGE`, read
119
+ * straight from `foundation-ai` so the banner and the driver's transcript bubble
120
+ * cannot drift apart.
121
+ */
122
+ const BLOCKED_PLACEHOLDER = 'AI usage limit reached';
123
+ /**
124
+ * Closing sentence for a wall the user cannot route around. Singular/plural
125
+ * variants because the banner names one vendor or several.
126
+ */
127
+ const RAISE_LIMIT_ACTION = 'Contact your administrator to raise it.';
128
+ const RAISE_LIMITS_ACTION = 'Contact your administrator to raise them.';
129
+ /**
130
+ * Banner copy built from the figures a 402 actually reported, or `undefined`
131
+ * when the proxy sent none.
132
+ *
133
+ * `undefined` is the meaningful return, not a fallback: paired with the
134
+ * slice's "a re-latch without a reason keeps the existing explanation" rule, it
135
+ * makes a figureless driver latch leave a host-supplied reason intact rather
136
+ * than blanking it back to the default at the exact moment the wall is hit.
137
+ *
138
+ * Exported for the unit test that pins the formatting; not part of the element
139
+ * API.
140
+ *
141
+ * @internal
142
+ */
143
+ export function formatBlockedReason(budget, vendor) {
144
+ if (!budget || (budget.budgetUsd == null && budget.spentUsd == null))
145
+ return undefined;
146
+ const money = (v) => (v == null ? 'an unknown amount' : `$${v.toFixed(2)}`);
147
+ const figures = `(${money(budget.spentUsd)} of ${money(budget.budgetUsd)})`;
148
+ // A per-vendor statement carries NO action clause, because the right action is
149
+ // not knowable at latch time: whether "switch vendor" or "contact your
150
+ // administrator" is the honest advice depends on whether any OTHER vendor still
151
+ // has headroom, which only the composing getter can see. The vendor-agnostic
152
+ // form keeps its historical trailing sentence — nothing composes onto it.
153
+ return vendor
154
+ ? `${vendorDisplayName(vendor)}'s AI usage limit is reached ${figures}.`
155
+ : `AI usage limit reached ${figures}. Contact your administrator to raise it.`;
156
+ }
157
+ /**
158
+ * Human-readable name for a vendor, falling back to its raw type.
159
+ *
160
+ * `'none'` needs no special case: it is the "no provider configured" sentinel,
161
+ * it is filtered out of {@link FoundationAiAssistant.reachableVendors}, the latch
162
+ * refuses to attribute a wall to it, and
163
+ * {@link FoundationAiAssistant.setVendorBlocked} rejects it at the boundary — so
164
+ * nothing can put it in front of this function. The `?? vendor` fallback is for a
165
+ * vendor type added to `AIProviderType` without a label, which would print the
166
+ * type rather than nothing.
167
+ */
168
+ function vendorDisplayName(vendor) {
169
+ var _a;
170
+ return (_a = VENDOR_LABELS[vendor]) !== null && _a !== void 0 ? _a : vendor;
171
+ }
172
+ /**
173
+ * `a`, `a and b`, `a, b and c`.
174
+ *
175
+ * The general joiner is kept rather than hardcoded to a pair because the list
176
+ * is `BUDGETED_VENDORS`-shaped, and that list is a cross-repo contract owned by
177
+ * the proxy (today `anthropic` and `gemini`; `openai` is refused up front with
178
+ * 400 UNSUPPORTED_PROVIDER and can never be walled) — a vendor added there must
179
+ * not require an edit here. Its upper bound is not an assertion in a comment — it is
180
+ * the key count of `VENDOR_LABELS`, since every element of every list passed here
181
+ * comes from `blockedVendors` or `reachableVendors`, both of which hold
182
+ * `AIProviderType` values, and the type has five members of which `'none'` is
183
+ * excluded from both.
184
+ */
185
+ function formatVendorList(vendors) {
186
+ var _a;
187
+ const names = vendors.map(vendorDisplayName);
188
+ if (names.length <= 1)
189
+ return (_a = names[0]) !== null && _a !== void 0 ? _a : '';
190
+ return `${names.slice(0, -1).join(', ')} and ${names[names.length - 1]}`;
191
+ }
113
192
  // Register supporting components when the main component module is imported.
114
193
  avoidTreeShaking(AiChatMarkdown, AiChatInteractionWrapper, AiHaloOverlay, AiWavesIndicator, AiFlowingWavesIndicator, AiPlasmaOrbIndicator, AiChatBubble, AiActivityHalo, ChatSuggestions, AgentPicker);
115
194
  /**
@@ -191,8 +270,9 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
191
270
  this.showingSplash = false;
192
271
  /** Title tracked for the in-flight cost session (used when `header-title` changes). */
193
272
  this._activeCostSessionTitle = '';
194
- /** Memoised pre-transcript spend for `_bankedFor` see `bankedUsageFor`. */
195
- this._bankedUsage = emptyUsage();
273
+ // NOTE: the pre-transcript ("banked") spend is deliberately NOT held here. It is keyed by
274
+ // session in `banked-usage-baselines` so the many element instances a single session sees —
275
+ // layout re-renders, popout, docking — share one baseline. See `bankedUsageFor`.
196
276
  /**
197
277
  * Whether the row list reflects storage yet. Gates cost writes so a project's banked
198
278
  * spend is never derived from a ledger that simply hasn't arrived.
@@ -394,14 +474,25 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
394
474
  }
395
475
  /**
396
476
  * True when a new send must be refused: a turn is running (`busy`), a page-reload
397
- * restore is loading history that would clobber it (`restoring`), or a manual
398
- * compaction is rewriting history (`compacting`). Mirrors the composer's
399
- * `?disabled` gate so a **programmatic** `send`/`submitMessage` (e.g. a host's
400
- * custom input while the built-in composer is hidden) can't slip past it and have
401
- * its message wiped when the restored/compacted history lands (GENC-1351 §6).
477
+ * restore is loading history that would clobber it (`restoring`), a manual
478
+ * compaction is rewriting history (`compacting`), or a backend condition has
479
+ * locked the assistant outright (`blocked` e.g. an exhausted AI budget).
480
+ * Mirrors the composer's `?disabled` gate so a **programmatic**
481
+ * `send`/`submitMessage` (e.g. a host's custom input while the built-in composer
482
+ * is hidden) can't slip past it and have its message wiped when the
483
+ * restored/compacted history lands (GENC-1351 §6).
402
484
  */
403
485
  get sendBlocked() {
404
- return this.busy || this.restoring || this.compacting;
486
+ return this.busy || this.restoring || this.compacting || this.blocked;
487
+ }
488
+ /**
489
+ * Why a programmatic send was refused, for `submitMessage`'s `errors`. A
490
+ * backend block is called out distinctly because — unlike the transient
491
+ * "busy" cases — waiting and retrying will never clear it, and a caller
492
+ * looping on "Assistant is busy" would spin forever.
493
+ */
494
+ sendRefusalReason() {
495
+ return this.blocked ? this.effectiveBlockedReason : 'Assistant is busy';
405
496
  }
406
497
  /**
407
498
  * Re-runs `agentsChanged` if the live `agents` array no longer matches the
@@ -590,6 +681,538 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
590
681
  var _a;
591
682
  (_a = this._sessionRef) === null || _a === void 0 ? void 0 : _a.actions.aiAssistant.setRestoring(value);
592
683
  }
684
+ /**
685
+ * Whether the assistant is blocked by a backend condition and cannot send —
686
+ * today, an exhausted AI-spend budget (GENC-1464). While true the composer is
687
+ * disabled, `send`/`submitMessage` refuse, suggestions stop being fetched, and
688
+ * a persistent banner (`part="blocked-banner"`) sits above the composer
689
+ * explaining why. The transcript stays visible and scrollable throughout —
690
+ * unlike `compacting` and `restoring`, this does not replace the conversation.
691
+ *
692
+ * **Latched.** Nothing in the element clears it: not a new turn, not "Clear"
693
+ * / "New chat", not a pop-in/out. It stays set until the host writes
694
+ * `false` — because nothing the user can do inside the assistant refills a
695
+ * budget. Hosts typically set it from their own pre-flight budget check on
696
+ * mount, and clear it once a later check shows headroom again.
697
+ *
698
+ * The driver also latches it automatically when a turn ends with the
699
+ * `'budget-exhausted'` failure reason — off the `tool-loop-end` activity bus
700
+ * (which also covers a sub-agent wall, a turn this element did not start, and
701
+ * an element swapped in mid-turn) and off the driver's return value (which
702
+ * covers a wall hit during multi-agent classification, where no tool loop ran
703
+ * and so no bus event fires). So a host that does no pre-flight at all still
704
+ * gets a correct locked UI the moment the first 402 lands. The bus topic is
705
+ * tab-scoped, so a wall hit in a popped-out window latches the main window
706
+ * too — correct for a shared spend cap.
707
+ *
708
+ * **Lifetime — read this before relying on it as your source of truth.** The
709
+ * latch is in-memory and per-`stateKey`:
710
+ *
711
+ * - It does **not** survive `switchSession`. Switching away tears the outgoing
712
+ * session's store down entirely, so switching back yields a fresh, unblocked
713
+ * store.
714
+ * - It does **not** survive a page reload. It is deliberately absent from the
715
+ * persisted session snapshot: the snapshot is long-lived, so a persisted
716
+ * latch would outlive an out-of-band budget raise with no in-element way to
717
+ * clear it — a stale lock is worse than re-deriving the state.
718
+ *
719
+ * The durable source of truth is therefore the **host's pre-flight**, which
720
+ * should run on mount and on every session switch. See
721
+ * `docs/migration-GENC-1464.md` §"How to adopt", Option B.
722
+ *
723
+ * @beta
724
+ */
725
+ get blocked() {
726
+ var _a;
727
+ const session = (_a = this._sessionRef) === null || _a === void 0 ? void 0 : _a.store.aiAssistant;
728
+ if (!session)
729
+ return false;
730
+ if (session.blocked)
731
+ return true;
732
+ // Byte-identical to the old stored read for every host that never adopts the
733
+ // per-vendor API — which is what makes this derivation non-breaking.
734
+ if (session.blockedVendors.length === 0)
735
+ return false;
736
+ // Unknown reachability (statuses not loaded yet, or providers that report no
737
+ // status) fails SAFE — any wall blocks everything, exactly today's semantics —
738
+ // and needs no clause of its own: `every` on the empty set is vacuously true.
739
+ return this.reachableVendors.every((v) => session.blockedVendors.includes(v));
740
+ }
741
+ set blocked(value) {
742
+ var _a;
743
+ (_a = this._sessionRef) === null || _a === void 0 ? void 0 : _a.actions.aiAssistant.setBlocked({ blocked: value });
744
+ }
745
+ /**
746
+ * The distinct vendors the registry can currently reach, from the provider
747
+ * statuses this element already loads on connect and refreshes on every
748
+ * observable-registry change.
749
+ *
750
+ * This is the element's answer to "which vendor would the next turn use" — a
751
+ * question that has **no** correct answer and deliberately gets no API. The
752
+ * provider is resolved per turn AND per agent: `activeProviderInput` may be an
753
+ * async function of the turn's context, an orchestrated turn picks its agent
754
+ * with an LLM `classify()` call, and sub-agents resolve their own providers.
755
+ * Any pre-turn "peek" would therefore be a guess that is wrong precisely on the
756
+ * multi-agent hosts per-vendor budgets exist for.
757
+ *
758
+ * Asking instead which vendors are REACHABLE is answerable, synchronous, and
759
+ * free — and it is enough: the composer must stay live while any reachable
760
+ * vendor has headroom, and must lock when none does. It also makes vendor-switch
761
+ * recovery a derivation rather than a mutation: a host that swaps its registry
762
+ * to another vendor fires the observable, the statuses reload, the walled vendor
763
+ * drops out of this set, and `blocked` goes false with every latch left intact.
764
+ *
765
+ * @beta
766
+ */
767
+ get reachableVendors() {
768
+ var _a, _b;
769
+ const seen = new Set();
770
+ for (const entry of this.providerStatuses) {
771
+ const provider = (_a = entry.status) === null || _a === void 0 ? void 0 : _a.provider;
772
+ if (!provider || provider === 'none')
773
+ continue;
774
+ // Reachable means "can serve the next turn", and `chat()` is OPTIONAL on
775
+ // `AIProvider` — the shipped `ChromeProvider` and `OpenAIProvider`
776
+ // implement `getStatus()` only. Counting a chat-less provider here made
777
+ // every consumer wrong at once: it was headroom for the `blocked`
778
+ // derivation (so `otherVendorAvailable: false` failed to lock the
779
+ // composer and each send burned another doomed 402), and it was a switch
780
+ // target for the banner ("Switch to Chrome to keep going" toward a
781
+ // provider that cannot chat).
782
+ //
783
+ // The gate is CAPABILITY, not vendor identity, so a host's custom
784
+ // chat-capable OpenAI provider still counts — the same distinction
785
+ // `validate-providers.ts` already enforces by throwing. And it drops a
786
+ // vendor only on positive evidence: statuses normally come from
787
+ // `providerRegistry.listStatuses()`, whose `name` always resolves, but a
788
+ // host (or test) that assigns `providerStatuses` directly may have no
789
+ // matching registry entry, and an unresolvable name must not silently
790
+ // erase a vendor the status layer vouched for.
791
+ const registered = (_b = this.providerRegistry) === null || _b === void 0 ? void 0 : _b.get(entry.name);
792
+ if (registered && typeof registered.chat !== 'function')
793
+ continue;
794
+ seen.add(provider);
795
+ }
796
+ return [...seen];
797
+ }
798
+ /**
799
+ * Vendors currently walled by the AI-spend budget, in the order they were
800
+ * walled. Empty for a host that never adopts the per-vendor API.
801
+ *
802
+ * May include a vendor this registry cannot reach — either because the registry
803
+ * moved on after the wall was latched, or because a 402's
804
+ * `otherVendorAvailable: false` walls every vendor the proxy meters (which is
805
+ * what that verdict is a statement about; see
806
+ * {@link FoundationAiAssistant.latchBlockedFrom}). Neither costs anything
807
+ * internally — {@link FoundationAiAssistant.blocked} asks only about the
808
+ * reachable set, and the banner reads the reachability-filtered
809
+ * {@link FoundationAiAssistant.relevantBlockedVendors} — but a host rendering
810
+ * this list itself should filter it against its own registry.
811
+ *
812
+ * Read-only on purpose: a settable array would let a host write a partial list
813
+ * and silently orphan the per-vendor banner copy.
814
+ * {@link FoundationAiAssistant.setVendorBlocked} is the write path.
815
+ *
816
+ * @beta
817
+ */
818
+ get blockedVendors() {
819
+ var _a, _b;
820
+ return (_b = (_a = this._sessionRef) === null || _a === void 0 ? void 0 : _a.store.aiAssistant.blockedVendors) !== null && _b !== void 0 ? _b : [];
821
+ }
822
+ /**
823
+ * Whether this specific vendor's budget is walled — regardless of whether any
824
+ * other vendor still has headroom.
825
+ *
826
+ * @beta
827
+ */
828
+ isVendorBlocked(vendor) {
829
+ return this.blockedVendors.includes(vendor);
830
+ }
831
+ /**
832
+ * Wall (or release) one vendor's budget, optionally with banner copy for it.
833
+ * The per-vendor mirror of {@link FoundationAiAssistant.setBlocked}.
834
+ *
835
+ * Walling a vendor does **not** on its own disable the composer: while another
836
+ * reachable vendor has headroom the assistant stays usable and the banner tells
837
+ * the user to switch. Only when every reachable vendor is walled does `blocked`
838
+ * become true.
839
+ *
840
+ * `reason` is composed into the banner sentence for that vendor, **however many
841
+ * vendors are walled** — see {@link FoundationAiAssistant.effectiveBlockedReason}.
842
+ * It is per-vendor copy, not a whole-banner override; {@link FoundationAiAssistant.blockedReason}
843
+ * is the override. Omitting it keeps whatever explanation that vendor already
844
+ * carried, so a driver latch landing after a host one cannot blank it.
845
+ *
846
+ * `'none'` is rejected with a warning rather than accepted: it is the "no
847
+ * provider configured" sentinel, not a vendor. Latching it would put the raw
848
+ * sentinel in the banner ("none's AI usage limit is reached") and could never
849
+ * be undone by derivation, because `'none'` never appears in
850
+ * {@link FoundationAiAssistant.reachableVendors} — so `blocked` could never
851
+ * become derivable from it either. Use {@link FoundationAiAssistant.setBlocked}
852
+ * for a vendor-agnostic block.
853
+ *
854
+ * @beta
855
+ */
856
+ setVendorBlocked(vendor, blocked, reason) {
857
+ var _a;
858
+ if (vendor === 'none') {
859
+ logger.warn("FoundationAiAssistant.setVendorBlocked: 'none' is the no-provider sentinel, not a vendor — " +
860
+ 'ignoring. Use setBlocked() for a vendor-agnostic block.');
861
+ return;
862
+ }
863
+ (_a = this._sessionRef) === null || _a === void 0 ? void 0 : _a.actions.aiAssistant.setVendorBlocked({ vendor, blocked, reason });
864
+ }
865
+ /** Whether this vendor's wall came from the sweep alone — see the slice's `sweptVendors`. */
866
+ isVendorSwept(vendor) {
867
+ var _a, _b;
868
+ return ((_b = (_a = this._sessionRef) === null || _a === void 0 ? void 0 : _a.store.aiAssistant.sweptVendors) !== null && _b !== void 0 ? _b : []).includes(vendor);
869
+ }
870
+ /**
871
+ * The walled vendors the user can still be routed to — i.e.
872
+ * {@link FoundationAiAssistant.blockedVendors} narrowed to the reachable set,
873
+ * which is the only set the banner may name.
874
+ *
875
+ * A wall the registry can no longer reach is not news: it cannot be hit, and
876
+ * naming it puts a vendor in front of the user that is not theirs. The concrete
877
+ * failure this exists to stop: a host ships Anthropic-only, Anthropic walls, the
878
+ * host swaps its registry to Gemini, Gemini walls — and the banner reads "AI
879
+ * usage limits are reached for Anthropic and Gemini" to a user who has never
880
+ * had an Anthropic key. The spurious second name also flips the copy onto the
881
+ * plural branch, so even the closing sentence is wrong.
882
+ *
883
+ * When nothing is reachable YET (the statuses are still loading — the exact
884
+ * window the pre-flight and the cross-tab bus latch into), the fallback keeps
885
+ * every wall for LOCKING (`blocked` still derives true) but names only the
886
+ * vendors walled by a REFUSAL, dropping the ones the `otherVendorAvailable`
887
+ * sweep added. A refusal is a fact about a vendor this user just used; a sweep
888
+ * entry is the proxy's headroom verdict about a vendor the host may not even
889
+ * ship — naming it here reproduced the exact failure above from the other
890
+ * direction ("Gemini's AI usage limit is reached" to an Anthropic-only user),
891
+ * and flipped the copy onto the plural branch with it. Self-corrects when the
892
+ * statuses land: from then on reachability, not provenance, decides.
893
+ *
894
+ * @internal
895
+ */
896
+ get relevantBlockedVendors() {
897
+ var _a, _b;
898
+ const walled = this.blockedVendors;
899
+ const reachable = this.reachableVendors;
900
+ if (reachable.length === 0) {
901
+ const swept = (_b = (_a = this._sessionRef) === null || _a === void 0 ? void 0 : _a.store.aiAssistant.sweptVendors) !== null && _b !== void 0 ? _b : [];
902
+ return walled.filter((v) => !swept.includes(v));
903
+ }
904
+ return walled.filter((v) => reachable.includes(v));
905
+ }
906
+ /**
907
+ * Whether the blocked banner has anything to say — a vendor-agnostic block, OR
908
+ * at least one walled vendor the registry can still reach. Broader than
909
+ * {@link FoundationAiAssistant.blocked} on purpose: partial exhaustion leaves
910
+ * the composer live but still needs to be announced, because the next turn may
911
+ * route to the walled vendor and fail.
912
+ *
913
+ * Reads the reachability-filtered list for the same reason the copy does — a
914
+ * host that has swapped its registry away from the walled vendor has nothing
915
+ * left to announce, and would otherwise get a banner that falls through to the
916
+ * generic default copy over a perfectly usable composer.
917
+ *
918
+ * @internal
919
+ */
920
+ get bannerVisible() {
921
+ return this.blocked || this.relevantBlockedVendors.length > 0;
922
+ }
923
+ /**
924
+ * Whether a suggestions fetch would hit a wall.
925
+ *
926
+ * Unlike a chat turn, this one CAN be resolved exactly: both suggestion paths
927
+ * go to the registry **default** and never to a per-agent override
928
+ * (`ChatDriver.getSuggestions` calls `providerRegistry.default()`, and
929
+ * `OrchestratingDriver.getSuggestions` just delegates to it). So the vendor a
930
+ * suggestions call would use is knowable, and this is the one place in the
931
+ * feature where that is true.
932
+ *
933
+ * Same rationale as the guard it replaces: a doomed suggestions call burns a
934
+ * `suggestions.failed` meta event and parks a raw transport error in
935
+ * `suggestionsState` for hosts to find.
936
+ *
937
+ * @internal
938
+ */
939
+ get suggestionsBlocked() {
940
+ var _a, _b;
941
+ if (this.blocked)
942
+ return true;
943
+ const defaultVendor = (_b = (_a = this.providerStatuses.find((e) => e.isDefault)) === null || _a === void 0 ? void 0 : _a.status) === null || _b === void 0 ? void 0 : _b.provider;
944
+ return !!defaultVendor && defaultVendor !== 'none' && this.isVendorBlocked(defaultVendor);
945
+ }
946
+ /**
947
+ * Explanation shown in the blocked banner, or `null` for the element's default
948
+ * copy. Only meaningful while {@link FoundationAiAssistant.blocked} is true;
949
+ * setting `blocked = false` clears it.
950
+ *
951
+ * Writable, and symmetric with every other store-backed accessor on this
952
+ * class: writing it re-latches with the CURRENT `blocked` value, so it changes
953
+ * the copy without disturbing the flag. Assigning `null` clears the
954
+ * explanation while staying blocked (the banner falls back to the default
955
+ * copy). {@link FoundationAiAssistant.setBlocked} remains the way to write
956
+ * both in one atomic action.
957
+ *
958
+ * @beta
959
+ */
960
+ get blockedReason() {
961
+ var _a, _b;
962
+ return (_b = (_a = this._sessionRef) === null || _a === void 0 ? void 0 : _a.store.aiAssistant.blockedReason) !== null && _b !== void 0 ? _b : null;
963
+ }
964
+ set blockedReason(value) {
965
+ var _a;
966
+ // Deliberately NOT `setBlocked({ blocked: this.blocked, reason })`: `blocked`
967
+ // is derived now, so re-latching with it would pass `false` for a partially
968
+ // walled session and clear every per-vendor latch as a side effect of setting
969
+ // a string.
970
+ (_a = this._sessionRef) === null || _a === void 0 ? void 0 : _a.actions.aiAssistant.setBlockedReason(value);
971
+ }
972
+ /**
973
+ * Latch the backend block off a turn outcome. The single decision point for
974
+ * both latch sites (the `tool-loop-end` bus subscription and `send()`'s
975
+ * return-value check), so the rule cannot diverge between them.
976
+ *
977
+ * Idempotent and one-way **per vendor**: it never unblocks, and it never
978
+ * re-writes an existing wall. That guard is load-bearing, not just an
979
+ * optimisation — the bus fires from the driver's `finally`, i.e. BEFORE
980
+ * `sendMessage()` resolves, so a host subscribed to `tool-loop-end` (what the
981
+ * migration guide's Option C recommends) sets its own detailed reason and the
982
+ * return-value latch would otherwise land a moment later and blank it.
983
+ *
984
+ * The guard being per-vendor rather than global is the whole difference. A
985
+ * single global "already blocked, do nothing" would swallow a second vendor's
986
+ * wall — so a session walled on Anthropic could never record that Gemini went
987
+ * too, and under a mixed registry an Anthropic-only wall would lock a composer
988
+ * that Gemini could still serve.
989
+ *
990
+ * **Which field is authoritative**, in order:
991
+ *
992
+ * 1. `budget.vendorLabel` — stamped by the transport that was actually refused,
993
+ * so it can never be stale. But it is a static per-transport string, so a
994
+ * white-labelled or multiplexing gateway fronting several upstreams leaves
995
+ * it unclaimed by any vendor.
996
+ * 2. `budget.vendor` — which the driver resolved from the label where it could,
997
+ * and otherwise from the proxy's own `vendor` field on the 402. It is
998
+ * therefore NOT simply `vendorLabel` normalised, and the two can disagree;
999
+ * that fallback is the only attribution on offer for the gateway case above.
1000
+ * 3. `vendorHint` — the bus detail's top-level vendor, i.e. the driver's
1001
+ * last-resolved provider. Last resort because it CAN be stale: an
1002
+ * orchestrated turn classifies against the registry default, a provider the
1003
+ * chat driver may never have resolved, so its last-resolved provider there is
1004
+ * the previous turn's vendor or nothing at all.
1005
+ *
1006
+ * A wall that names no recognised vendor falls back to the vendor-agnostic
1007
+ * block — fail-safe, and identical to the pre-per-vendor behaviour.
1008
+ *
1009
+ * **`otherVendorAvailable: false` is server-known truth and outranks every
1010
+ * inference made here.** The proxy meters the pots, so only it can say whether
1011
+ * anything else has headroom; this element can only observe which vendors the
1012
+ * registry can REACH, which says nothing about their remaining spend. So when
1013
+ * the proxy says no, every other *budgeted* vendor is walled in the same pass.
1014
+ * That is what makes `blocked` derive true — locking the composer on the first
1015
+ * response instead of after a second doomed turn — and what empties the "free"
1016
+ * set the banner would otherwise have advised switching to.
1017
+ *
1018
+ * The sweep runs over `BUDGETED_VENDORS`, deliberately **not** over
1019
+ * {@link FoundationAiAssistant.reachableVendors}. Reachability is loaded
1020
+ * asynchronously (`activateSession` kicks off `loadProviderStatuses()` and does
1021
+ * not await it), so a wall landing before the statuses resolve would have swept
1022
+ * an empty set — silently discarding the one fact on the 402 the client cannot
1023
+ * re-derive, with nothing to re-run it when the statuses arrived. Worse, it let
1024
+ * the verdict effectively un-latch: the single wall derived `blocked` only
1025
+ * while the reachable set was empty, so the moment the statuses landed the
1026
+ * composer came back to life against a budget the proxy had already said was
1027
+ * gone. Sweeping the metered vendor list instead makes the verdict independent
1028
+ * of load order and of registry membership, which is exactly what it is: a
1029
+ * statement about the proxy's pots, not about this user's registry.
1030
+ *
1031
+ * Walling a vendor the registry cannot reach costs nothing: `blocked` only asks
1032
+ * whether every REACHABLE vendor is walled, and the banner reads
1033
+ * {@link FoundationAiAssistant.relevantBlockedVendors}, which filters the walls
1034
+ * back down to the reachable set before naming any of them.
1035
+ *
1036
+ * One boundary remains on the sweep: it walls only vendors the proxy meters,
1037
+ * enforced structurally by iterating `BUDGETED_VENDORS` itself rather than
1038
+ * filtering a wider set through a predicate. Chrome runs on-device with no pot
1039
+ * to exhaust, so "no other vendor has budget" is not a statement about it, and
1040
+ * "Switch to Chrome to keep going." stays honest advice.
1041
+ *
1042
+ * The sweep runs last but sits OUTSIDE the per-vendor idempotence guard, and
1043
+ * both halves of that matter. Last, so the vendor that actually refused this
1044
+ * turn heads the wall order the multi-vendor banner reads. Outside the guard,
1045
+ * because the verdict can arrive on a LATER 402 for a vendor that is already
1046
+ * walled — turn 1 walls Anthropic while Gemini still has headroom, turn 2 walls
1047
+ * it again and reports that Gemini has since gone too — and an early return
1048
+ * would drop exactly the news the second turn was there to deliver.
1049
+ *
1050
+ * @param reason - the turn's failure reason; anything but `'budget-exhausted'` is ignored.
1051
+ * @param ref - session store to write through; defaults to the live one. `send()`
1052
+ * passes the ref it captured before its awaits, since a lifecycle event during the
1053
+ * turn may already have cleared `_sessionRef`.
1054
+ * @param budget - what the 402 reported: figures when the proxy sent any, the
1055
+ * refusing vendor, and its `otherVendorAvailable` verdict.
1056
+ * @param vendorHint - the `tool-loop-end` detail's top-level vendor, used only
1057
+ * when the budget payload names none.
1058
+ *
1059
+ * @internal
1060
+ */
1061
+ latchBlockedFrom(reason, ref = this._sessionRef, budget, vendorHint) {
1062
+ var _a, _b;
1063
+ if (reason !== 'budget-exhausted')
1064
+ return;
1065
+ const vendor = (_b = (_a = vendorTypeOfLabel(budget === null || budget === void 0 ? void 0 : budget.vendorLabel)) !== null && _a !== void 0 ? _a : budget === null || budget === void 0 ? void 0 : budget.vendor) !== null && _b !== void 0 ? _b : vendorHint;
1066
+ if (!vendor || vendor === 'none') {
1067
+ if (!this.blocked) {
1068
+ ref === null || ref === void 0 ? void 0 : ref.actions.aiAssistant.setBlocked({ blocked: true, reason: formatBlockedReason(budget) });
1069
+ }
1070
+ }
1071
+ else if (!this.isVendorBlocked(vendor) || this.isVendorSwept(vendor)) {
1072
+ // The idempotence guard protects a host-supplied reason from being
1073
+ // overwritten by this latch's boilerplate — but a SWEPT wall cannot carry
1074
+ // one (the sweep writes no reason, and any host call clears the swept
1075
+ // mark), so upgrading it on a first-hand refusal clobbers nothing and
1076
+ // gains the vendor its own figures plus a place in the early-window
1077
+ // banner (see `relevantBlockedVendors`).
1078
+ if (this.reachableVendors.length === 0) {
1079
+ // Not fatal — `blocked` still derives to true, so the UI is correct — but
1080
+ // the host gets no per-vendor benefit and would otherwise have no signal.
1081
+ logger.warn(`FoundationAiAssistant: ${vendor} hit its AI budget, but no registered provider counts ` +
1082
+ 'as reachable, so per-vendor blocking degrades to blocking everything. Two causes: no ' +
1083
+ 'provider reports a status (implement AIProvider.getStatus()), or every provider that ' +
1084
+ 'does is chat-less and so cannot serve a turn (implement chat() on at least one).');
1085
+ }
1086
+ ref === null || ref === void 0 ? void 0 : ref.actions.aiAssistant.setVendorBlocked({
1087
+ vendor,
1088
+ blocked: true,
1089
+ reason: formatBlockedReason(budget, vendor),
1090
+ });
1091
+ }
1092
+ // Last, so the vendor that actually refused this turn heads the wall order the
1093
+ // multi-vendor banner reads — and unconditionally, NOT under the per-vendor
1094
+ // idempotence guard above, because the verdict can arrive on a later 402 for a
1095
+ // vendor that is already walled.
1096
+ if ((budget === null || budget === void 0 ? void 0 : budget.otherVendorAvailable) !== false)
1097
+ return;
1098
+ // Over the metered vendor list, NOT the reachable set: the statuses load
1099
+ // asynchronously, so a reachability-scoped sweep dropped the verdict entirely
1100
+ // when the wall beat them home. `relevantBlockedVendors` decides what the
1101
+ // banner may NAME — reachability-filtered when the statuses are in, and
1102
+ // refusals-only while they are not, which is what `swept: true` records.
1103
+ for (const other of BUDGETED_VENDORS) {
1104
+ if (other === vendor || this.isVendorBlocked(other))
1105
+ continue;
1106
+ // No reason: the slice's "an omitted reason keeps the existing explanation"
1107
+ // rule applies, and there are no figures for this vendor — only the proxy's
1108
+ // word that it has nothing left. `swept: true` records exactly that
1109
+ // provenance, so the unknown-reachability banner can decline to name it.
1110
+ ref === null || ref === void 0 ? void 0 : ref.actions.aiAssistant.setVendorBlocked({ vendor: other, blocked: true, swept: true });
1111
+ }
1112
+ }
1113
+ /**
1114
+ * Block or unblock the assistant, optionally with custom banner copy.
1115
+ * Equivalent to the {@link FoundationAiAssistant.blocked} setter, plus the
1116
+ * reason in one atomic write.
1117
+ *
1118
+ * Blocking writes the **vendor-agnostic** block — what a host with one budget
1119
+ * pot means — and does not populate
1120
+ * {@link FoundationAiAssistant.blockedVendors}. Unblocking clears the
1121
+ * per-vendor walls as well, so a host asserting "the wall is gone" after its
1122
+ * own pre-flight cannot be silently overruled by a driver latch it never saw.
1123
+ *
1124
+ * @beta
1125
+ */
1126
+ setBlocked(blocked, reason) {
1127
+ var _a;
1128
+ (_a = this._sessionRef) === null || _a === void 0 ? void 0 : _a.actions.aiAssistant.setBlocked({ blocked, reason });
1129
+ }
1130
+ /**
1131
+ * Copy shown in the blocked banner, in ascending specificity:
1132
+ *
1133
+ * 1. the host's `blockedReason` — a whole-banner override, unchanged;
1134
+ * 2. copy composed from the walled vendors (their per-vendor reasons when set,
1135
+ * otherwise their names) plus the action that is actually available;
1136
+ * 3. the default budget-exhaustion message.
1137
+ *
1138
+ * The action clause is computed here rather than stored, because whether
1139
+ * "switch vendor" or "contact your administrator" is honest depends on whether
1140
+ * another reachable vendor still has headroom — which changes when the registry
1141
+ * changes, long after the wall was latched. Telling a user to switch to a
1142
+ * vendor that is also exhausted is worse than saying nothing.
1143
+ *
1144
+ * The `free` set is a client-side inference — "reachable and not known to be
1145
+ * walled" — and it is only ever allowed to be one. Where the proxy has told us
1146
+ * otherwise (`otherVendorAvailable: false`) the latch has already walled those
1147
+ * vendors, so they are not in `free` here and no "switch to X" clause can be
1148
+ * composed from them. Both lists are filtered by reachability, so the copy
1149
+ * names only vendors this user's registry can actually route to.
1150
+ *
1151
+ * **Per-vendor copy is composed however many vendors are walled**, which is
1152
+ * what {@link FoundationAiAssistant.setVendorBlocked} and
1153
+ * `docs/migration-GENC-1464.md` both promise. It previously reached only the
1154
+ * single-vendor branch, so a host that had carefully set copy for each of its
1155
+ * vendors watched all of it vanish the moment a second one walled — replaced by
1156
+ * generic copy, at the exact moment the situation got worse.
1157
+ *
1158
+ * The compact "AI usage limits are reached for A and B." list is kept for the
1159
+ * case it was written for: NO walled vendor carries copy, so a statement per
1160
+ * vendor would just repeat one boilerplate sentence per name. As soon as any of
1161
+ * them does carry copy, the branch lists one statement per vendor instead —
1162
+ * falling back to that same boilerplate for the ones that have none, so the
1163
+ * sentence set stays complete rather than silently naming a subset.
1164
+ *
1165
+ * @internal
1166
+ */
1167
+ get effectiveBlockedReason() {
1168
+ var _a;
1169
+ if (this.blockedReason)
1170
+ return this.blockedReason;
1171
+ const walled = this.relevantBlockedVendors;
1172
+ if (walled.length === 0)
1173
+ return DEFAULT_BUDGET_EXHAUSTED_MESSAGE;
1174
+ const free = this.reachableVendors.filter((v) => !walled.includes(v));
1175
+ const action = free.length
1176
+ ? `Switch to ${formatVendorList(free)} to keep going.`
1177
+ : walled.length > 1
1178
+ ? RAISE_LIMITS_ACTION
1179
+ : RAISE_LIMIT_ACTION;
1180
+ const reasons = (_a = this._sessionRef) === null || _a === void 0 ? void 0 : _a.store.aiAssistant.blockedVendorReasons;
1181
+ const statementFor = (v) => { var _a; return (_a = reasons === null || reasons === void 0 ? void 0 : reasons[v]) !== null && _a !== void 0 ? _a : `${vendorDisplayName(v)}'s AI usage limit is reached.`; };
1182
+ if (walled.length === 1)
1183
+ return `${statementFor(walled[0])} ${action}`;
1184
+ // Several walled vendors carry several sets of figures, which do not fit one
1185
+ // sentence. With no per-vendor copy to preserve, name them in a list rather
1186
+ // than repeat the same boilerplate sentence per vendor.
1187
+ if (!walled.some((v) => reasons === null || reasons === void 0 ? void 0 : reasons[v])) {
1188
+ return `AI usage limits are reached for ${formatVendorList(walled)}. ${action}`;
1189
+ }
1190
+ return `${walled.map(statementFor).join(' ')} ${action}`;
1191
+ }
1192
+ /**
1193
+ * Copy the driver writes into the TRANSCRIPT when a turn hits the budget wall.
1194
+ *
1195
+ * A host-set `blockedReason` still wins, so a white-labelled host does not read
1196
+ * its own explanation in the banner and the shipped default directly below it —
1197
+ * that is the whole reason the driver takes this at all.
1198
+ *
1199
+ * What it deliberately is NOT is
1200
+ * {@link FoundationAiAssistant.effectiveBlockedReason}. That getter
1201
+ * composes advice — `"…Switch to Gemini to keep going."` — which is true only
1202
+ * while that vendor has headroom, and the transcript is a permanent record. The
1203
+ * driver reads this once at CONSTRUCTION, and `getOrCreateDriver` keys on the
1204
+ * agents list, so an agents swap *after* a wall re-runs `createDriver` and would
1205
+ * freeze that sentence into every later turn's bubble. Reading the latch's own
1206
+ * copy instead is structurally incapable of carrying the advice: the only
1207
+ * writer of `blockedReason` is the vendor-agnostic branch of
1208
+ * `latchBlockedFrom`, whose `formatBlockedReason(budget)` form has no switch
1209
+ * clause. Per-vendor copy lives in `blockedVendorReasons` and reaches the
1210
+ * banner alone.
1211
+ */
1212
+ get transcriptBudgetExhaustedMessage() {
1213
+ var _a;
1214
+ return (_a = this.blockedReason) !== null && _a !== void 0 ? _a : DEFAULT_BUDGET_EXHAUSTED_MESSAGE;
1215
+ }
593
1216
  /**
594
1217
  * Name of the agent the user has pinned via the agent picker. `null` means
595
1218
  * automatic routing (Auto). Persisted on the session store, so it survives
@@ -874,9 +1497,9 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
874
1497
  // The row is a projection of the session totals, not a transfer out of them.
875
1498
  }
876
1499
  /**
877
- * Spend for `projectKey` that predates the current transcript, resolved once per project
878
- * and memoised (`_bankedFor`) so it cannot drift mid-session as the row it came from is
879
- * rewritten by our own upserts.
1500
+ * Spend for `projectKey` that predates the current transcript, resolved once per SESSION and
1501
+ * shared by every element instance wired to it (see `banked-usage-baselines`), so it cannot
1502
+ * drift as the row it came from is rewritten by our own upserts.
880
1503
  *
881
1504
  * The two cases turn on whether the transcript is authoritative for this project:
882
1505
  *
@@ -886,28 +1509,76 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
886
1509
  * - **Transcript starts empty**: nothing on screen accounts for the previous total, so the
887
1510
  * whole of it becomes banked. Without this the next turn would rewrite the row downwards
888
1511
  * to just that turn, losing the project's earlier spend.
1512
+ *
1513
+ * The baseline is deliberately NOT element state. This element is remounted repeatedly inside
1514
+ * a single session (the host re-rendering its layout, popping out, docking) and a fresh
1515
+ * instance re-deriving would take the second branch against rows that already hold the live
1516
+ * transcript — banking the session as its own baseline. Keying on the session instead means a
1517
+ * remount inherits the baseline that transcript was paired with.
889
1518
  */
890
1519
  bankedUsageFor(projectKey) {
891
- if (this._bankedFor === projectKey)
892
- return this._bankedUsage;
1520
+ const resolved = getBankedBaseline(projectKey);
1521
+ if (resolved)
1522
+ return resolved;
893
1523
  const existing = this.costSessionHistory.find((r) => r.projectKey === projectKey);
894
1524
  // `persistenceActive` is what decides whether the transcript speaks for the project's
895
1525
  // history: it is on only when a provider is wired AND the user is remembering the chat.
896
- this._bankedUsage = resolveBankedUsage(existing, this.persistenceActive);
897
- this._bankedFor = projectKey;
898
- return this._bankedUsage;
1526
+ const banked = resolveBankedUsage(existing, this.persistenceActive);
1527
+ setBankedBaseline(projectKey, banked);
1528
+ return banked;
899
1529
  }
900
1530
  /**
901
- * Discard the banked figure for when the spend it stands for has been deliberately
902
- * thrown away (the user clearing history, or deleting this project's row).
1531
+ * Re-baseline this session against an emptied transcript: what came before, plus what the
1532
+ * transcript being discarded proved. `discarded` must be read BEFORE the live figures are
1533
+ * reset, since that is the only record of the latter.
1534
+ *
1535
+ * Not the same as dropping the baseline. Dropping it re-derives through `persistenceActive`,
1536
+ * and with persistence ON that carries the row's *old* banked figure forward — discarding
1537
+ * every turn between the last baseline and the clear. The transcript is gone either way, so
1538
+ * the branch that reads it as authoritative no longer applies.
903
1539
  *
904
- * NOT for reloads. A reload re-reads rows that already include our own upserts, so
905
- * re-deriving from them would fold the live session into its own baseline; only a genuine
906
- * project change should re-derive, which `bankedUsageFor`'s per-project memo handles.
1540
+ * Derived from the memoised baseline and the live figure rather than from the project's row,
1541
+ * because a row is only as current as the last `finalizeCostSession` which skips its write
1542
+ * while the ledger is unloaded, and while the session has no usage yet.
907
1543
  */
908
- resetBankedUsage() {
909
- this._bankedFor = undefined;
910
- this._bankedUsage = emptyUsage();
1544
+ rebaselineOnClearedTranscript(key, discarded) {
1545
+ // Already resolved for this session? Then the new baseline is fully determined without
1546
+ // consulting the ledger at all — `bankedUsageFor` would short-circuit on this same value — so
1547
+ // answer before the guard below, which would otherwise discard a figure that is both correct
1548
+ // and independent of the rows. Losing it costs real spend: with a row at `usage: 120,
1549
+ // banked: 90` and 30 on screen, a clear during a reload would drop the memoised 90, and the
1550
+ // next turn would re-derive `row.banked` and rewrite the row BACKWARDS from 120 to 95.
1551
+ const resolved = getBankedBaseline(key);
1552
+ if (resolved) {
1553
+ setBankedBaseline(key, addUsage(resolved, discarded));
1554
+ return;
1555
+ }
1556
+ // Nothing memoised, so a baseline can only come from the rows — and a stamped one is STICKY
1557
+ // (`bankedUsageFor` short-circuits on any stored entry, and a zero usage is a *resolved* value
1558
+ // there, not "unresolved"). So unless the rows are genuinely known, do not guess:
1559
+ //
1560
+ // - `enabled` false: `reloadCostSessionHistory` sets `_costHistoryLoaded` TRUE over a
1561
+ // deliberately empty list, meaning "nothing will be loaded" rather than "rows are known".
1562
+ // The flag alone therefore is not enough. Reachable because `enabled` is
1563
+ // `cost?.enabled === true` — false for an unbound `chatConfig`, and for any host resolving
1564
+ // it from an async setting, exactly as `chatConfigChanged` already expects.
1565
+ // - Provider read in flight: the list is emptied and the flag cleared synchronously, on
1566
+ // connect and again on every `chatConfig` rebind.
1567
+ // - Unreadable: the rows we hold are a fallback, not the ledger.
1568
+ //
1569
+ // In all three, pinning would bury this project's real total the moment it arrived — a later
1570
+ // load does NOT invalidate baselines (deliberately, see `reloadCostSessionHistory`), so the
1571
+ // next write would replace the row off the pinned figure and lose everything before it.
1572
+ //
1573
+ // Dropping instead leaves the next write to re-derive from rows that actually arrived. It
1574
+ // under-banks `discarded` in that window — a bounded loss, and what every clear did before
1575
+ // this method existed. Nothing is written while disabled, so there is no banked value to
1576
+ // preserve in that case either.
1577
+ if (!this.resolvedCostHistory.enabled || !this._costHistoryLoaded || this._ledgerUnreadable) {
1578
+ deleteBankedBaseline(key);
1579
+ return;
1580
+ }
1581
+ setBankedBaseline(key, addUsage(this.bankedUsageFor(key), discarded));
911
1582
  }
912
1583
  showConfirmationDialog(confirmationMessage, modalParent, onConfirm, onCancel) {
913
1584
  showNotificationDialog({
@@ -921,24 +1592,27 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
921
1592
  }
922
1593
  /** Remove one entry from persisted build history. */
923
1594
  removeCostSessionFromHistory(id) {
924
- // Deleting the active project's row discards its banked spend by intent, so drop the
925
- // memo too — otherwise the next turn would rewrite the row we just removed, restoring
926
- // the total the user asked to be rid of.
1595
+ // Deleting a row discards its banked spend by intent, so drop the baseline too —
1596
+ // otherwise the next turn would rewrite the row we just removed, restoring the total the
1597
+ // user asked to be rid of.
927
1598
  //
928
- // Resolve the row first: `id` is a row id while `_bankedFor` is a projectKey. They match
929
- // only for rows this element wrote (`finalizeCostSession` sets `id: projectKey`); a row from
930
- // a host provider can carry any id, and comparing across the two namespaces would silently
931
- // skip the reset.
1599
+ // Resolve the row first: `id` is a row id while baselines are keyed by projectKey. They
1600
+ // match only for rows this element wrote (`finalizeCostSession` sets `id: projectKey`); a
1601
+ // row from a host provider can carry any id, and deleting by the wrong namespace would
1602
+ // silently leave the baseline in place.
932
1603
  const removed = this.costSessionHistory.find((record) => record.id === id);
933
- if (removed && this._bankedFor === removed.projectKey)
934
- this.resetBankedUsage();
1604
+ if (removed)
1605
+ deleteBankedBaseline(removed.projectKey);
935
1606
  this.persistCostHistory(this.costSessionHistory.filter((record) => record.id !== id));
936
1607
  }
937
1608
  /** Clear all persisted build history for this assistant scope. */
938
1609
  clearCostSessionHistory() {
939
- // Every row is gone, so nothing is banked any more — without this the active project's
940
- // next turn would resurrect its pre-clear total.
941
- this.resetBankedUsage();
1610
+ // Every row is gone, so nothing is banked any more — without this the next turn would
1611
+ // resurrect the pre-clear total. Dropped per row rather than for the active session alone,
1612
+ // so a project whose row was cleared while another session was displaying it does not keep
1613
+ // a baseline pointing at spend that no longer exists anywhere.
1614
+ for (const record of this.costSessionHistory)
1615
+ deleteBankedBaseline(record.projectKey);
942
1616
  // Provider-backed: write an empty ledger through. The `localStorage` path instead
943
1617
  // removes its key outright rather than leaving an empty array behind.
944
1618
  if (this.costHistoryProvider()) {
@@ -1353,6 +2027,7 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
1353
2027
  maxFoldOperations: agent.maxFoldOperations,
1354
2028
  maxTurnSnapshots: agent.maxTurnSnapshots,
1355
2029
  activityBus: agenticActivityBus,
2030
+ budgetExhaustedMessage: this.transcriptBudgetExhaustedMessage,
1356
2031
  });
1357
2032
  }
1358
2033
  return new ChatDriver(this.providerRegistry, {
@@ -1361,6 +2036,7 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
1361
2036
  maxTurnSnapshots: agent.maxTurnSnapshots,
1362
2037
  sessionKey: (_c = this.getStateKey()) !== null && _c !== void 0 ? _c : '',
1363
2038
  activityBus: agenticActivityBus,
2039
+ budgetExhaustedMessage: this.transcriptBudgetExhaustedMessage,
1364
2040
  });
1365
2041
  }
1366
2042
  /**
@@ -1669,7 +2345,7 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
1669
2345
  });
1670
2346
  }
1671
2347
  connectedCallback() {
1672
- var _a;
2348
+ var _a, _b;
1673
2349
  // Initialise the store reference BEFORE super.connectedCallback() so that
1674
2350
  // the first FAST render has access to the store. The store Proxy calls
1675
2351
  // Observable.track(observableStore, sliceName) whenever a slice is read,
@@ -1707,6 +2383,18 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
1707
2383
  });
1708
2384
  }
1709
2385
  this.wireAppSettingsProvider();
2386
+ // Latch the backend block off the activity bus (GENC-1464). This is the primary
2387
+ // seam: it fires for a turn this element did not start, for a sub-agent's
2388
+ // wall (the child inherits the parent's bus and publishes its own
2389
+ // `tool-loop-end`), and it writes through whichever element is connected NOW
2390
+ // rather than the one that happened to call `send()`. Re-subscribed per
2391
+ // connect (docking/popout remounts); balanced in `disconnectedCallback`.
2392
+ (_b = this._unsubBudgetLatch) === null || _b === void 0 ? void 0 : _b.call(this);
2393
+ this._unsubBudgetLatch = agenticActivityBus.subscribe('tool-loop-end', (d) =>
2394
+ // Pass the figures through: this seam wins the latch for an in-loop wall (the
2395
+ // common case), so dropping them here would render the generic banner copy in
2396
+ // exactly the path `ChatDriverResult.budget` was widened to serve.
2397
+ this.latchBlockedFrom(d === null || d === void 0 ? void 0 : d.failureReason, undefined, d === null || d === void 0 ? void 0 : d.budget, d === null || d === void 0 ? void 0 : d.vendor));
1710
2398
  if (this.messagesEl) {
1711
2399
  this._scrollListener = () => {
1712
2400
  this._userScrolledAway =
@@ -1768,6 +2456,10 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
1768
2456
  // keeping the high-water `lastEventIndex` would then skip every event of the
1769
2457
  // resumed session (GENC-1351 §5.12 / #9). Clear (same-session) still uses reset.
1770
2458
  deleteDiagnosticsCursorsFor(oldKey);
2459
+ // The cost baseline is paired with the transcript in the store just disposed, so it goes
2460
+ // with it (GENC-1475). A later switch back re-restores from the provider — or starts empty
2461
+ // where it does not — and re-derives from the ledger rows accordingly.
2462
+ deleteBankedBaseline(oldKey);
1771
2463
  clearMetaSession(oldKey);
1772
2464
  // Activate the incoming session. Swapping the observable `_sessionRef` re-points
1773
2465
  // every store-backed getter binding at the new store; `activateSession` rebuilds
@@ -1796,7 +2488,7 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
1796
2488
  this.switchSession(currentKey !== null && currentKey !== void 0 ? currentKey : targetKey, targetKey);
1797
2489
  }
1798
2490
  disconnectedCallback() {
1799
- var _a, _b, _c, _d, _e, _f;
2491
+ var _a, _b, _c, _d, _e, _f, _g;
1800
2492
  window.removeEventListener('pagehide', this._handlePageHide);
1801
2493
  // Write out anything the debounce is still holding — the timer would otherwise fire against
1802
2494
  // a torn-down element, or not at all.
@@ -1814,10 +2506,12 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
1814
2506
  this.unwireDriver();
1815
2507
  (_a = this.unsubBus) === null || _a === void 0 ? void 0 : _a.call(this);
1816
2508
  this.unsubBus = undefined;
1817
- (_b = this.unsubProviderRegistry) === null || _b === void 0 ? void 0 : _b.call(this);
2509
+ (_b = this._unsubBudgetLatch) === null || _b === void 0 ? void 0 : _b.call(this);
2510
+ this._unsubBudgetLatch = undefined;
2511
+ (_c = this.unsubProviderRegistry) === null || _c === void 0 ? void 0 : _c.call(this);
1818
2512
  this.unsubProviderRegistry = undefined;
1819
2513
  this.unwireAppSettingsProvider();
1820
- (_c = this._executionCompletionUnsub) === null || _c === void 0 ? void 0 : _c.call(this);
2514
+ (_d = this._executionCompletionUnsub) === null || _d === void 0 ? void 0 : _d.call(this);
1821
2515
  this._executionCompletionUnsub = undefined;
1822
2516
  // Note: the restore-core autosave AND diagnostics-append timers live on the
1823
2517
  // shared per-session persister (GENC-1351 §5.10), not here — they must survive a
@@ -1835,12 +2529,12 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
1835
2529
  // the panel re-mounts open.
1836
2530
  this._agentPickerToggle.finalize();
1837
2531
  if (this.settingsOpen) {
1838
- (_d = this.settingsModal) === null || _d === void 0 ? void 0 : _d.close();
2532
+ (_e = this.settingsModal) === null || _e === void 0 ? void 0 : _e.close();
1839
2533
  this.settingsOpen = false;
1840
2534
  }
1841
2535
  this._sessionMenuToggle.finalize();
1842
2536
  // Capture before clearing — `wasBusy` reads the driver, which is dropped below.
1843
- this.logMeta('assistant.disconnected', { wasBusy: (_f = (_e = this.driver) === null || _e === void 0 ? void 0 : _e.isBusy()) !== null && _f !== void 0 ? _f : false });
2537
+ this.logMeta('assistant.disconnected', { wasBusy: (_g = (_f = this.driver) === null || _f === void 0 ? void 0 : _f.isBusy()) !== null && _g !== void 0 ? _g : false });
1844
2538
  // Clear local references only — driver and store stay in their registries.
1845
2539
  this.driver = undefined;
1846
2540
  this._sessionRef = undefined;
@@ -2347,11 +3041,17 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
2347
3041
  catch (e) {
2348
3042
  logger.error('Session clear: agent serialize() failed:', e);
2349
3043
  }
3044
+ // Read BEFORE `resetSession()` zeroes it: this is what the transcript about to be discarded
3045
+ // proved, and nothing else records it. Banking it is what stops the next turn rewriting the
3046
+ // project's row down to itself and losing the spend up to the clear (GENC-1475).
3047
+ const discarded = this.sessionUsage;
2350
3048
  if (this.driver)
2351
3049
  this.teardownDriver(key);
2352
3050
  (_g = this._sessionRef) === null || _g === void 0 ? void 0 : _g.actions.aiAssistant.resetSession();
2353
- if (key)
3051
+ if (key) {
3052
+ this.rebaselineOnClearedTranscript(key, discarded);
2354
3053
  this.rebuildDriver(key);
3054
+ }
2355
3055
  // Restore-core is owned by the shared per-session persister (GENC-1351 §5.10):
2356
3056
  // it drops the snapshot + resets the carry-forward cache. Diagnostics is
2357
3057
  // append-only and handled separately below.
@@ -2459,10 +3159,34 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
2459
3159
  get settingsCostCurrentSectionVisible() {
2460
3160
  return this.settingsCostCurrentSlotted.length > 0;
2461
3161
  }
3162
+ /**
3163
+ * Whether the build-history section has anything in it — the built-in row list, or a host's own
3164
+ * markup in the `settings-cost-history` slot.
3165
+ *
3166
+ * Slotted content is checked FIRST, ahead of `enabled`. That flag governs the *built-in* ledger:
3167
+ * it gates the row writes, the reload, and the lifetime sum — and it defaults to `false`
3168
+ * (`cost?.enabled === true`), while the Usage tab itself shows for `showCost`/`showTokens`, which
3169
+ * default true. So a host can legitimately render its own build history into the slot and never
3170
+ * opt into our `localStorage` ledger. Short-circuiting on `enabled` above the slot check hid that
3171
+ * host's markup outright, since the slot is the only thing in the container.
3172
+ */
2462
3173
  get settingsCostHistorySectionVisible() {
3174
+ if (this.settingsCostHistorySlotted.length > 0)
3175
+ return true;
2463
3176
  if (!this.resolvedCostHistory.enabled)
2464
3177
  return false;
2465
- return this.costSessionHistory.length > 0 || this.settingsCostHistorySlotted.length > 0;
3178
+ return this.costSessionHistory.length > 0;
3179
+ }
3180
+ /**
3181
+ * Whether the "Build history" heading has anything under it.
3182
+ *
3183
+ * Broader than {@link FoundationAiAssistant.settingsCostHistorySectionVisible} because the
3184
+ * heading also covers the lifetime total, which renders before the first row exists (rows are
3185
+ * written as spend accrues, the lifetime section only needs history switched on). Gating the
3186
+ * heading on the rows alone left that total sitting under nothing.
3187
+ */
3188
+ get settingsCostHistoryHeadingVisible() {
3189
+ return this.settingsCostHistorySectionVisible || this.settingsCostLifetimeVisible;
2466
3190
  }
2467
3191
  /** Whether the built-in Usage tab summary block has anything to show. */
2468
3192
  get settingsCostSummaryVisible() {
@@ -2696,6 +3420,10 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
2696
3420
  * message will go; otherwise falls back to the host-provided placeholder.
2697
3421
  */
2698
3422
  get effectivePlaceholder() {
3423
+ // The block outranks the agent pin: while blocked the composer is disabled
3424
+ // anyway, so naming the pinned agent would only imply a send is possible.
3425
+ if (this.blocked)
3426
+ return BLOCKED_PLACEHOLDER;
2699
3427
  if (this.pinnedAgentName)
2700
3428
  return `Message ${this.pinnedAgentName}...`;
2701
3429
  return this.placeholder;
@@ -3079,6 +3807,11 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
3079
3807
  (_b = (_a = this.driver) === null || _a === void 0 ? void 0 : _a.cancel) === null || _b === void 0 ? void 0 : _b.call(_a);
3080
3808
  }
3081
3809
  handleSuggestionClick(suggestion) {
3810
+ // Belt-and-braces with the template gate that hides the chips while blocked:
3811
+ // `send()` refuses, but `inputValue` would already have been written, leaving
3812
+ // the chip's text stranded in a disabled textarea the user cannot clear.
3813
+ if (this.sendBlocked)
3814
+ return;
3082
3815
  this.inputValue = suggestion;
3083
3816
  this.send();
3084
3817
  }
@@ -3107,7 +3840,7 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
3107
3840
  return __awaiter(this, void 0, void 0, function* () {
3108
3841
  var _a, _b, _c;
3109
3842
  if (this.sendBlocked) {
3110
- return { ok: false, errors: ['Assistant is busy'] };
3843
+ return { ok: false, errors: [this.sendRefusalReason()] };
3111
3844
  }
3112
3845
  let nextAttachments = [];
3113
3846
  if ((_a = input.files) === null || _a === void 0 ? void 0 : _a.length) {
@@ -3120,7 +3853,7 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
3120
3853
  // (Cast widens through TS's narrowing of `this.state` from the earlier
3121
3854
  // early-return; the getter can return a different value across an await.)
3122
3855
  if (this.sendBlocked) {
3123
- return { ok: false, errors: ['Assistant is busy'] };
3856
+ return { ok: false, errors: [this.sendRefusalReason()] };
3124
3857
  }
3125
3858
  if (errors.length) {
3126
3859
  this.attachmentErrors = errors;
@@ -3152,6 +3885,21 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
3152
3885
  if (!this.driver || !suggestionsConfig || suggestionsConfig.behavior === 'never') {
3153
3886
  return;
3154
3887
  }
3888
+ // A guaranteed 402. This runs from the post-turn `finally` (the turn that
3889
+ // just latched), from `connectedCallback` (a pop-in against a blocked store),
3890
+ // and from the `pinnedAgentName` setter — which resets `suggestionsState`
3891
+ // first, defeating the already-fetched guard below. So without this a blocked
3892
+ // session still fires one doomed request per turn, per pop-in and per pin
3893
+ // change, each one burning a `suggestions.failed` meta event and parking a
3894
+ // raw transport-error string in `suggestionsState.message`.
3895
+ //
3896
+ // Resolved against the registry DEFAULT, not the whole composer gate: both
3897
+ // suggestion paths call `providerRegistry.default()`, so under partial
3898
+ // exhaustion this is knowable exactly — and suggestions stay available when
3899
+ // the default vendor is the one with headroom.
3900
+ if (this.suggestionsBlocked) {
3901
+ return;
3902
+ }
3155
3903
  if (suggestionsConfig.behavior === 'initial' && this.messages.length > 0) {
3156
3904
  return;
3157
3905
  }
@@ -3233,7 +3981,16 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
3233
3981
  ? `${input}\n\n*Attached: ${pendingAttachments.map((a) => a.name).join(', ')}*`
3234
3982
  : input;
3235
3983
  try {
3236
- yield ((_a = this.driver) === null || _a === void 0 ? void 0 : _a.sendMessage(displayInput, pendingAttachments));
3984
+ const result = yield ((_a = this.driver) === null || _a === void 0 ? void 0 : _a.sendMessage(displayInput, pendingAttachments));
3985
+ // Latch the backend block the moment the driver reports it, so the composer
3986
+ // locks without waiting on the host's next pre-flight. The bus subscription
3987
+ // in `connectedCallback` covers most walls; this covers the one it cannot —
3988
+ // a wall hit during multi-agent classification, where no tool loop ran and
3989
+ // so no `tool-loop-end` was published. `latchBlockedFrom` is idempotent, so
3990
+ // the two seams overlapping is harmless.
3991
+ if ((result === null || result === void 0 ? void 0 : result.reason) === 'done') {
3992
+ this.latchBlockedFrom(result.failureReason, capturedSessionRef, result.budget);
3993
+ }
3237
3994
  }
3238
3995
  finally {
3239
3996
  this.stopLoadingTimer();
@@ -3413,6 +4170,24 @@ __decorate([
3413
4170
  __decorate([
3414
4171
  volatile
3415
4172
  ], FoundationAiAssistant.prototype, "busy", null);
4173
+ __decorate([
4174
+ volatile
4175
+ ], FoundationAiAssistant.prototype, "blocked", null);
4176
+ __decorate([
4177
+ volatile
4178
+ ], FoundationAiAssistant.prototype, "reachableVendors", null);
4179
+ __decorate([
4180
+ volatile
4181
+ ], FoundationAiAssistant.prototype, "relevantBlockedVendors", null);
4182
+ __decorate([
4183
+ volatile
4184
+ ], FoundationAiAssistant.prototype, "bannerVisible", null);
4185
+ __decorate([
4186
+ volatile
4187
+ ], FoundationAiAssistant.prototype, "suggestionsBlocked", null);
4188
+ __decorate([
4189
+ volatile
4190
+ ], FoundationAiAssistant.prototype, "effectiveBlockedReason", null);
3416
4191
  __decorate([
3417
4192
  volatile
3418
4193
  ], FoundationAiAssistant.prototype, "effectiveChatInputDuringExecution", null);
@@ -3530,6 +4305,9 @@ __decorate([
3530
4305
  __decorate([
3531
4306
  volatile
3532
4307
  ], FoundationAiAssistant.prototype, "settingsCostHistorySectionVisible", null);
4308
+ __decorate([
4309
+ volatile
4310
+ ], FoundationAiAssistant.prototype, "settingsCostHistoryHeadingVisible", null);
3533
4311
  __decorate([
3534
4312
  volatile
3535
4313
  ], FoundationAiAssistant.prototype, "settingsCostSummaryVisible", null);