Admin → Add-ons: grant the ai-agent licence per account; the licence is the chat's only switch - #1220
Conversation
The API's generic add-on admin surface (graphql-api docs/AI-AGENT-LICENSE.md — addonProducts,
addonCustomers, addAddonCustomer, removeAddonCustomer) has been live on dev and prod with nothing
in the app driving it. This is the desktop half: a system-admin page at /admin/add-ons/:productId
that lists an add-on's holders and grants or revokes it by email, with an optional expiration.
Generic over add-on products on purpose — ai-agent is the first, the next is a product row on the
API and appears in the page's selector. The product rides the URL so a reload, a deep link and the
sidebar's remembered route all land on the same list; a disabled add-on still lists and revokes
but hides Grant (the API refuses new grants for it); a blank expiration is sent as null, not
omitted, because the API leaves an omitted one alone and re-granting a time-boxed holder from a
blank form should give the open-ended grant the form shows.
With it, the licence card an account already got now says what it grants: LimitSetting learns
'ai-agent' ("AI agent is available" — and nothing at all when false, the alpha's decision 1), and
the card wears the remote-ai mark instead of the r3 brand mark.
Model tests cover the product switch, the same-product no-op, search trimming, paging and the
refused-request path. Plan note: docs/superpowers/plans/2026-09-14-admin-addon-licenses-page.md.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The list is filtered by the selector above it, but a row that reads "account · devices · members ·
granted" says nothing about WHAT was granted — the operator had to look up. An Add-on column
resolves the row's own productId to the product's description ("AI Agent"), and the ai-agent rows
wear the remote-ai mark instead of the generic puzzle piece.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…r AI-portal default useChatEnabled already read limits['ai-agent'], but PENDING_FEATURES defaulted that flag ON for every dev build (MODE === 'development') and for app.ai.remote.it (VITE_CHAT_ALWAYS_ON), so on those the chat showed with or without a licence. The licence exists now — the ai-agent add-on, granted per account from Admin → Add-ons — so the forward declaration is retired as the 2026-08-31 note planned: PENDING_FEATURES, CHAT_ALWAYS_ON and VITE_CHAT_ALWAYS_ON are gone, the limits lookup is built only from what the API returns, and an account without the add-on has no ai-agent entry at all — no header button, no docked column, no popout, and no AI Agent section on the Test page (which also stops asking the agent service for the background status). The Test page's Features list drops the "pending" row and its string: a feature no licence carries is granted, not toggled on. Decision 3 of that note resolves as "the portal paywalls": an unlicensed account on app.ai gets the ordinary app with no chat; the Amplify env's VITE_CHAT_ALWAYS_ON is now inert. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… primary, with a plus The text button read as a label beside the selector. Same shape as the scripting header's Add. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
@codex review |
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: a8472eff91
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
|
|
||
| if (result !== 'ERROR' && result?.data?.data?.admin?.addonCustomers) { | ||
| const data = result.data.data.admin.addonCustomers | ||
| dispatch.adminAddonLicenses.setCustomers({ |
There was a problem hiding this comment.
Discard stale add-on customer responses
When an admin switches products while the previous product's request is still in flight, this unconditional update can let the older response overwrite the newly selected product's customers if it resolves last. The page then shows product A's rows under product B, and the revoke handler submits the current product B with a user ID selected from A; if that account also holds B, the admin can revoke the wrong grant. Associate each request with its product/search parameters and ignore the response when they no longer match the current state.
Useful? React with 👍 / 👎.
…pped (Codex round 1, P2)
Codex: switch products while the old product's page is in flight and, resolving last, it
overwrites the new product's rows — and a revoke from that list submits product B with a user id
picked from A's rows. The flagged line was fetch's write; the class is every await-then-write in
the model, so all of them are guarded by one mechanism rather than the one site:
* latest-wins tickets. fetch and fetchMore share ONE counter — a refresh landing under a Load
More would otherwise be appended to by rows paged off the list it replaced, which comparing
the response's product/search to the store at resolve time (still matching) lets through. A
superseded response writes nothing, the spinner included: the request that owns it clears it.
* every retiring event takes the next ticket: a product switch and a new search term through
the request they issue (setSearch is an effect now — commit + refetch — instead of a reducer
the page watched with a skip-first-mount effect), sign-out explicitly (reset is an effect that
retires the list AND the product list, then clears).
* fetchProducts has its own ticket, retired only by sign-out: not selection-scoped, and two of
its responses say the same thing.
Six new tests: the product switch, the refresh-under-Load-More, the spinner, sign-out; all fail
on the previous model.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 8547d9b5ae
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| if (!removeTarget || !productId) return | ||
|
|
||
| setRemoving(true) | ||
| const result = await graphQLRemoveAddonCustomer(productId, removeTarget.userId) |
There was a problem hiding this comment.
Revoke the selected row's product
When the confirmation remains open while the URL selection changes—for example through browser Back/Forward, or when a product refresh redirects away from a removed product—removeTarget still belongs to product A, but this call uses the current route's product B. If that account also holds B, confirming revokes B instead of the add-on whose row was selected. The latest-wins ticket fixes the earlier stale-list-response path, but it does not reset the dialog target; pass removeTarget.productId here or clear the target whenever the product changes.
Useful? React with 👍 / 👎.
…ve selection (Codex round 2, P2) Codex: with the revoke confirm open, a URL move (Back/Forward, or the product refresh redirecting off a product the API dropped) leaves removeTarget on product A while the mutation is built from the route's product B — an account holding both loses the wrong add-on. Round 1's tickets guard the LIST; this is the same class one level up, and the grant dialog had it too: opened under the title "Grant A", it would have granted B. Closed for both: the revoke uses the row's own productId (the licence revoked is the one the row showed, by construction), the grant dialog captures the product it was opened for and titles, mutates and reports with that, and a change of selection closes whichever dialog is up — what it was about is no longer on screen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 9c4291937e
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| // Blank = open-ended. Sent as null, not omitted: the API leaves an OMITTED expiration alone, | ||
| // and re-granting a time-boxed holder from a blank form should give the open-ended grant the | ||
| // form shows, not silently keep the old date. | ||
| const expiration = grantExpiration ? new Date(grantExpiration).toISOString() : null |
There was a problem hiding this comment.
Describe time-boxed add-ons as expiring
When an admin supplies this optional expiration, the resulting add-on appears on the account's license card, but LicensingSetting.tsx:32-34 labels every non-null license expiration as “Renews.” These manually granted add-ons instead stop providing the feature at that timestamp, so affected users are falsely told that access will renew; render an expiration-specific label for add-on licenses.
Useful? React with 👍 / 👎.
| if (ticket !== productsRequest || result === 'ERROR') return | ||
| const products: AdminAddonProduct[] = result?.data?.data?.admin?.addonProducts || [] | ||
| dispatch.adminAddonLicenses.setProducts(products) |
There was a problem hiding this comment.
Preserve products when no GraphQL response is returned
When the page fetches products while offline or before an auth header is available, post() returns undefined, but this path converts that missing response into a successfully loaded empty product list. The page then incorrectly says that no add-ons are defined and discards any previously loaded products; reconnecting does not repair it because CloudSync.all() does not fetch this admin model. Only mark products loaded when addonProducts is actually present, while retaining the prior state on a missing response.
Useful? React with 👍 / 👎.
…issing product response writes nothing (Codex round 3, two P2s) 1. LicensingSetting labelled EVERY licence expiration "Renews" — billing's word, right for a subscribed remote.it licence and wrong for a time-boxed add-on grant, which ends at that date (and for a custom or reseller term, which never renewed either). "Renews" now needs a subscription on the licence — the API attaches one only to a remote.it licence billing owns; any other expiration reads "Expires". New key licensingSetting.expires. 2. fetchProducts read a MISSING response (offline, or before an auth header exists — post() returns undefined, not 'ERROR') as an empty product list: productsLoaded flipped, the products already held were discarded, and the page announced no add-ons are defined. Only an answer carrying addonProducts writes now; fetch and fetchMore already kept their rows on a missing response, so this closes the last unguarded writer. Test: undefined and an answer without the field both leave the products alone. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: bcc2eb3800
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| </Box> | ||
| ) : loading && customers.length === 0 ? ( | ||
| <LoadingMessage message={`Loading ${label} licenses...`} /> | ||
| ) : customers.length === 0 ? ( |
There was a problem hiding this comment.
Wait for products before showing an empty license list
When first entering this page, productsLoaded and loading are both false while customers is empty, so this branch reports “No add-on licenses granted” before the product request resolves. If that request returns no response, the new guard in fetchProducts deliberately leaves the same unresolved state, and the reconnect path in CloudSync.all() does not refetch this admin model, so the false empty result persists until a manual refresh. Fresh evidence after the earlier cache-preservation fix is that this fallback still treats an unresolved product list as an empty customer result; show a loading/retry state until products and a selection are resolved.
Useful? React with 👍 / 👎.
| } else if (adminAddonLicensesPage) { | ||
| title = 'Refresh add-on licenses' | ||
| methods.push(async () => await dispatch.adminAddonLicenses.fetchProducts()) | ||
| methods.push(async () => await dispatch.adminAddonLicenses.fetch()) |
There was a problem hiding this comment.
Validate the selected product before refreshing customers
When Refresh is pressed after the API has stopped listing the selected product, CloudSync.call() runs these methods sequentially, but fetchProducts() only replaces the product array; the model's productId remains the removed ID until the page's redirect effect runs after rendering. This second method therefore starts an addonCustomers request for the removed product, surfacing a GraphQL error before the redirect selects and fetches the fallback product. Clear or validate the selection after refreshing products, or let the resulting selection change own the customer fetch.
Useful? React with 👍 / 👎.
| and its request's ticket retires whatever the old product still had in flight. Re-selecting | ||
| the current one is a no-op (the URL effect fires on every render of the route). */ | ||
| async select(productId: string, rootState) { | ||
| if (rootState.adminAddonLicenses.productId === productId) return |
There was a problem hiding this comment.
Refetch the selected product after changing API targets
When a system admin views this list, changes the API target in Test Settings, and returns to the same product URL, the add-on model still contains the previous stage's productId and customers because cloudSync.all() does not reset or fetch this model. The page refreshes only the product catalog on mount, and shared product IDs such as ai-agent make this equality return early, so customers from the old stage remain displayed while subsequent grant or revoke mutations are sent to the new stage. The fresh stale-data path not covered by the latest-wins tickets is this API-target change; key the cache by target, reset it during target switches, or force a customer fetch when the page remounts.
Useful? React with 👍 / 👎.
… (Codex round 4, three P2s)
Round 4's three findings share a root: the model had no notion of its own lifecycle. "Products not
loaded yet", "selection unresolved", "list never asked", "list from another API target" all
collapsed into customers.length === 0, and refresh was two independent calls. Restructured rather
than patched at the three sites:
* load STATUSES. products and the list each carry idle | loading | loaded | failed, kept apart
from what they last delivered; a failed ask keeps what was held. The page decides every screen
from a status AND the rows — loading until the catalogue answers, "Couldn't load … / Retry"
when it did not, "No add-on products" only once it said so, loading again until a product is
chosen and its list has answered, and "No … granted" only from a loaded, empty answer. A
failed Load More keeps the rows and the button.
* refresh(urlProduct) is the ONE way in — on mount, on every move of the URL's product, and the
refresh button's single call (with the route's product): catalogue first; the selection
checked against the fresh catalogue (the URL's product if listed, else the one held if still
listed, else cleared — so a product the API dropped never gets a list request, and the page
redirects to one that exists); then the list, fetched AFRESH every time. Afresh is the point
for the third finding: Test Settings switches the API target without a reload and
cloudSync.all() knows nothing of this model, so a remount used to sit over the old stage's
rows behind a product id that is the same on every stage. `select` is gone — the URL effect
was its only caller and refresh subsumes it.
Tests (20): the status reducers; fetchProducts resolving to the catalogue, and marking failed on
ERROR / no response / no field; refresh taking the URL's product, keeping the held one, clearing a
vanished one, and doing nothing when the catalogue did not answer; the ticket races as before.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
@codex review |
…heck) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 54b526897a
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| productId === state.productId | ||
| ? state |
There was a problem hiding this comment.
Clear cached customers before a cross-target refresh
When a system admin changes the API target in Test Settings and returns to the same product ID, this equality keeps the previous target's customers visible and interactive while refresh() fetches the new target; they remain indefinitely if the customer request fails because a nonempty failed list still renders the grid. A revoke during that interval is sent to the new API target using an old-target user ID, potentially revoking the corresponding account's grant in the wrong environment. Fresh evidence after the earlier forced-refetch fix is that the refetch does not clear or hide the cache while it is pending; invalidate the cached list when the target changes or keep it noninteractive until the new list succeeds.
Useful? React with 👍 / 👎.
| </Quote> | ||
| </ListItem> | ||
| </List> | ||
| {chatEnabled && ( |
There was a problem hiding this comment.
Revoke background access when the license turns off
When a user has enabled AI background work and then disables ai-agent in the Features list, or the add-on is revoked or expires, chatEnabled becomes false and this removes the only UI that calls backgroundDisable(). The stored grant is not tied to this component's lifecycle—chat.signOut() explicitly invokes backgroundDisable() because background access otherwise survives—so losing the entitlement can leave background AI access active with no way to revoke it except re-enabling the feature or signing out. Revoke the background grant when the entitlement transitions off, or keep its disable control reachable.
Useful? React with 👍 / 👎.
… includes the API target (Codex round 5, P1 + P2) P1 — a regression of the licence gate. Hiding the Test page's AI Agent section for an unlicensed account also hid the only control that revokes the agent's BACKGROUND grant, which is the agent's own standing at the AS and survives the entitlement (chat.signOut revokes it explicitly for that reason). An account whose add-on was revoked, expired, or switched off in Features kept background access with no way to end it short of signing out. Revoking when the entitlement flips off would be wrong — it also flips when merely viewing an organization without the add-on — so instead the status is always asked (one GET, on this staff page only) and the section shows while the feature is licensed OR a grant is standing, with the row saying the account no longer has Remote.It AI and to switch it off. Unlicensed with no grant: nothing, as before. P2 — rows fetched under one API target sat on screen, interactive, behind the same product id while refresh fetched from another (Test Settings switches the target without a reload), and stayed if that fetch failed. The target is now part of the list's identity alongside the product: refresh stamps the current target FIRST, before anything is awaited, and a changed target empties the list exactly as a changed product does — rows from elsewhere leave at once, and a failed fetch shows "Couldn't load … / Retry", never them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: ac4b7e2a7c
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| staying interactive until (or beyond, if it fails) the new answer. A switch's request retires | ||
| whatever the old product still had in flight (the tickets above). */ | ||
| async refresh(preferredProductId: string | undefined, rootState) { | ||
| dispatch.adminAddonLicenses.setTarget(getApiURL()) |
There was a problem hiding this comment.
Retire old-target list requests before clearing rows
When a customer-list request from API target A is still in flight as the admin switches to target B and returns to this page, setTarget() clears the cache but does not advance listRequest. While this refresh awaits B's product catalogue, A's response can still pass the ticket check and repopulate the grid; because product IDs are shared across stages, revoking one of those rows sends the mutation to B with an A-derived user ID. Fresh evidence after the earlier cross-target cache fix is that an in-flight response can now repopulate the cleared cache, so invalidate the list ticket as soon as the target changes.
Useful? React with 👍 / 👎.
| useEffect(() => { | ||
| backgroundStatus().then(setBackgroundEnrolled) | ||
| }, []) | ||
| const showAgentSettings = chatEnabled || !!backgroundEnrolled |
There was a problem hiding this comment.
Keep the revoke control visible when status lookup fails
When the enrollment GET fails because the agent is offline or returns a 401/5xx, backgroundStatus() converts that failure to false, and this expression hides the entire section for an unlicensed user. If that user still has the standing background grant described above, the only control that can revoke it disappears and is not retried while the page remains mounted. Fresh evidence after the earlier entitlement-loss fix is that request failure is still indistinguishable from a confirmed unenrolled response; preserve an unknown/error state and keep the disable control reachable until absence is confirmed.
Useful? React with 👍 / 👎.
…ge retires the page in flight (Codex round 6) P1 (hardening, resolved at the root instead): round 5 made the AI Agent section show for an UNLICENSED account while a background grant stands, on the premise that its toggle was the only control that could end the grant; round 6 then asked for that status probe to be hardened against failure. The premise was wrong. The background grant is an OAuth grant held at the AS for the agent's own client, and Account → Connected Apps — the same test-UI audience, not gated on the licence — lists and revokes it, killing every token minted from it, whether or not the agent service answers. (The agent gates on nothing licence-shaped today, and no background scheduler exists yet; whether it should refuse background work for a lapsed licence is its question.) So the section is back behind the plain licence gate, the orphaned-grant row and string are gone, and the comment points at the door that was always there. P2 (a real gap in the class): a target change emptied the list but never took a ticket, so a page still in flight from the OTHER target could pass the check and refill the emptied list while the catalogue was awaited — product ids being the same on every stage. refresh now retires the list's ticket whenever the target differs, before anything is awaited. Pinned. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 3f4c518789
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| // Blank = open-ended. Sent as null, not omitted: the API leaves an OMITTED expiration alone, | ||
| // and re-granting a time-boxed holder from a blank form should give the open-ended grant the | ||
| // form shows, not silently keep the old date. | ||
| const expiration = grantExpiration ? new Date(grantExpiration).toISOString() : null |
There was a problem hiding this comment.
Re-evaluate the entitlement when a timed grant expires
When a time-boxed grant expires while the grantee keeps the app continuously online and focused, the cached ai-agent limit remains true: useChatEnabled() reads only that cached boolean, and no timer refreshes or invalidates it when this timestamp passes. Consequently the chat stays mounted and usable until an unrelated manual sync or reconnect, so the newly supported expiration does not actually end client access at its declared time. Schedule an entitlement refresh/unmount for the expiration or include expiration validity in the gate.
Useful? React with 👍 / 👎.
| > | ||
| {/* Each screen is decided by a STATUS and the rows, never by an empty array alone — "nothing | ||
| has answered yet", "the answer was no", and "nobody holds it" are different screens. */} | ||
| {!products.length && productsStatus === 'failed' ? ( |
There was a problem hiding this comment.
Surface product failures when a cached catalog exists
When an admin changes API targets and the new target's product request fails, setTarget() clears the customer rows but fetchProducts() deliberately retains the old target's nonempty product catalog and marks it failed. This condition ignores that failure because products.length is still nonzero, then the page reaches the idle-list branch and displays “Loading … licenses” indefinitely instead of the error and Retry action. Handle productsStatus === 'failed' when no usable list exists even if a stale catalog is cached.
Useful? React with 👍 / 👎.
…ngth (Codex round 7, P2) After a target switch the rows are emptied first and the catalogue refetched; when that fetch failed, fetchProducts kept the stale catalogue (by design) — and the screen chooser, keyed on products.length, took a non-empty catalogue for a healthy one, fell through to "Loading … licenses" and stayed there with no Retry. The chooser now asks one question first — is anything on screen USABLE (the list answered, or rows are held) — and, when nothing is, a failed catalogue is the screen regardless of what stale catalogue is held; with something usable the rows stay and the failure is the snackbar. One Retry element serves both failure screens. Round 7's P1 — a time-boxed grant lapsing while the grantee's app stays open keeps its cached limit until the next sync — is how every licensed feature has always behaved, and the enforcement point is the server (the agent gates on nothing licence-shaped); recorded as the follow-up it is in the plan note rather than papered over with a client timer. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
@codex review |
|
Codex Review: Didn't find any major issues. Another round soon, please! Reviewed commit: ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
If Codex has suggestions, it will comment; otherwise it will react with 👍. Codex can also answer questions or update the PR. Try commenting "@codex address that feedback". |
What
The desktop half of the ai-agent licence (graphql-api
docs/AI-AGENT-LICENSE.md; the API side has been live on dev and prod with nothing driving it):/admin/add-ons/:productId?): a system-admin page that lists an add-on's holders and grants / revokes it by email, with an optional expiration. Generic over add-on products — ai-agent is the first; the next is a product row on the API and shows up in the selector. The product rides the URL (reload, deep link and the sidebar's remembered route all land on the same list); a disabled add-on still lists and revokes but hides Grant; a blank expiration is sent asnull, not omitted (the API leaves an omitted one alone, and a re-grant from a blank form should give the open-ended grant the form shows).LimitSettinglearnsai-agent("AI agent is available", and nothing at all when false — the alpha's decision 1), and the card wears theremote-aimark.PENDING_FEATURES/CHAT_ALWAYS_ON/VITE_CHAT_ALWAYS_ONare retired (the 2026-08-31 note's client cleanup): a dev build and app.ai.remote.it no longer default the flag on, so an account without the add-on sees no header button, no docked column, no popout, and no AI Agent section on the Test page. Decision 3 of that note resolves as "the portal paywalls" — an unlicensed account on app.ai gets the ordinary app; the Amplify env'sVITE_CHAT_ALWAYS_ONis inert.Plan note:
docs/superpowers/plans/2026-09-14-admin-addon-licenses-page.md.Verified
npm run typecheck,npm test(electron 44/44, frontend 58/58 —models/adminAddonLicenses.test.tscovers the model),npm run i18n:check.test:devagainst a local dev build of this branch (PORTAL_URL=http://localhost:3003): 80 passed, 1 skipped (the admin grant round trip — no admin key on this machine), 0 failed. Fresh e2e identities hold no grant, so the chat never docks for them now.Consequences
🤖 Generated with Claude Code