Plugin Marketplace (part 3)
Omniscio keeps your installed plugins up to date by itself: an update that asks for nothing new is installed quietly in the background, and one that wants access the running version did not have waits for your yes. A plugin that needs a newer app, or a newer plugin SDK than this build provides, is refused rather than loaded. Package signing is built but not switched on yet.
What it is
This is part 3 of the Plugin Marketplace page. Part 1 covers what the Marketplace is, browsing it, installing and uninstalling, the security gate on third-party backends and an agent's install request; part 2 covers what a plugin may read and watch, ratings and reviews, the error surfaces and the developer side. This half covers the third thing a marketplace has to get right: keeping what you already installed current, and refusing what this build cannot run.
Where to find it
- The automatic check runs on its own — on launch, every six hours, and every time the window regains focus. There is no screen you have to open for it.
- A waiting update reaches you as a count badge on the Browse tab, a dismissible banner at the top of the Browse view, and an inbox card with a Review updates button that opens the Updates Review modal.
- Checking on demand — a Check for updates button in the Marketplace sidebar footer, and a Check now button beside the auto-check toggle in Settings → Plugins.
- Turning it off — Settings → Plugins → Check for plugin updates
(
pluginAutoCheckUpdates, on by default). With it off, nothing is checked and nothing is updated in the background; it is also gated on the marketplace being enabled.
How it behaves
Auto-update (applies the safe ones, asks about the rest)
Omniscio keeps your installed plugins current by itself, and stops only where it would otherwise hand a plugin access you never agreed to.
- An update that requests nothing new is installed automatically, in the background, with no click. This is the common case, and it is why you rarely have to think about plugin versions at all.
- An update that wants access the running version did not have — a new permission, or a background service where there was none — is never applied on its own. It waits, with the permission list shown, until you say yes.
Both decisions are made in the app's main process, against the plugin's actual downloaded package rather than the marketplace listing, so a listing that under-reports what a version asks for cannot slip past. A refusal leaves your installed version exactly as it was. Four more cases wait for you rather than update: a disabled plugin (updating one would switch it back on), one whose stored approval is already behind its installed version, one you are running as a developer build, and anything the app cannot read.
When an update does wait for you, you see three non-intrusive cues:
- A count badge on the Browse tab and a dismissible "N plugin updates available · Review all" banner at the top of the Browse view.
- An inbox card ("N plugin updates available") with a Review updates button, which opens the Updates Review modal straight from the inbox. It lists what is waiting by name and version, so the card answers "what changed?" on its own without opening anything. It dedupes on the exact set, so the launch check, the 6-hour interval check and the window-focus re-check all collapse onto one row — and a newly published update still raises a fresh card after you dismissed the last one.
- The Updates Review modal (opened by the badge/banner/card). It lists each waiting plugin with its version bump (
v1.0.0 → v2.0.0), a short changelog snippet, and — critically — the permissions the new version newly requests (a "Requests new:" row). You apply per-row with Update, or all at once with Update all.
A background update is silent on purpose; a broken one is not. The app updates itself the same way — quietly, with no popup — and this subsystem already had a plugin-update toast removed three times, so nothing here raises one. The single exception is a half-landed update: if a plugin's files are replaced but its background service fails to restart, you get the same warning the manual path gives, because an enabled plugin with a dead service is otherwise silent.
Why a card and not a toast for the ones that wait. The check runs three ways you never asked for (launch, every 6 hours, every window focus), so a toast popped over whatever you were doing — and then auto-dismissed, routinely taking its own Review button with it before it could be clicked. The card is raised over
MARKETPLACE_NOTIFY_UPDATESand keyed by plugin-updates-alert.ts. The toast was removed and came back three times before this stuck, so it is now banned repo-wide rather than merely removed — re-adding it fails plugin-updates-never-a-toast.test.ts, which also fails if the card is dropped without a replacement.
The check runs on launch (deferred, after first paint), on a 6-hour interval, and on window focus. It's on by default and reuses the existing registry read (MARKETPLACE_FETCH_REGISTRY) — no new network surface. Turn it off at Settings → Plugins → Check for plugin updates (pluginAutoCheckUpdates, default true); with it off, nothing is checked and nothing is updated in the background. It's also gated on marketplaceEnabled, so hiding the marketplace disables it too.
Check on demand. Two buttons run the check immediately, for anyone who'd rather not wait for the automatic pass:
Marketplace sidebar, in its own footer below the category list (
MarketplaceSidebarFooter.tsx). The footer renders one of four states:- Loading… — before the first registry read has completed.
- Couldn't reach the marketplace — the first read failed outright.
- No plugins installed yet, with a Browse plugins link that clears the category filter back to All (and, on mobile, also drills straight into the results grid) — there's nothing to check yet, so the button below is omitted entirely.
- Otherwise, a three-tier hierarchy, top to bottom: your installed count (e.g.
5 installed) headlines the block; a status line underneath readsUp to date,N updates, orcheck failed, with achecked {when}freshness stamp that ages every 60 seconds (the stamp is omitted on failure — the line turning red already says so); the Check for updates button sits below that. It's shown whenever at least one plugin is installed, including when everything is already current — the update badge/banner/card only appear once updates are already known, so with nothing to report the Marketplace previously offered no way to ask at all.
The installed count replaced an earlier category-derived plugin total that silently tracked whichever category the user had last clicked in the list above it (never actually a total) and, at public scale, told the user nothing they could act on — "2,847 plugins" says nothing about you.
Settings → Plugins, a Check now button beside the auto-check toggle.
They differ in how they answer, and deliberately so. The sidebar button answers in place, on its own status line and freshness stamp — two separate lines, not one — because that footer sits inside one bordered block with the button and persists, so question and answer share one region. The Settings button has no such line next to it (reportUpToDate), so it is never a silent click: "All plugins are up to date" and a failed check still toast, and a check that finds updates opens the Updates Review modal right there rather than pointing you at an inbox card you did not know you were getting. Either way the pending set also lands in the inbox, so closing the modal loses nothing.
A press must always be visible, even when nothing changed. The sidebar button used to feel broken: pressing it while everything was already current changed nothing on screen — the status line already showed the verdict, the forced re-fetch commonly answers in ~200ms (too fast for a spinner to read as anything but a flash), and the sidebar button deliberately never toasts (see above). A user genuinely could not tell a working button from a dead one. The fix, entirely in
MarketplaceSidebarFooter.tsx:
- The button's label swaps to Checking… and HOLDS that state for a minimum window (
MIN_PENDING_MS, 700ms) even when the reply is instant, so the press is never a same-tick flash.- The freshness stamp resets to "checked just now" unconditionally, whether or not the verdict changed. This is the load-bearing part: "Up to date" is already on screen before the press and stays there after it, so the stamp is the only thing a successful press can ever visibly change. Skip the reset and a click that finds nothing new goes back to being silent — by design this time, instead of by accident.
- On success only, the status line takes a brief accent pulse (dropped under
prefers-reduced-motion) — never on failure, since the line turning red already says the press did something.- A screen-reader-only live region announces the outcome in the same words the visible line shows, and only a manual press writes to it. The automatic checks (launch, the 6-hour interval, window focus) still keep the stamp truthful, but must never claim the acknowledgment a manual press earns — an automatic refresh writing to that region would interrupt a screen-reader user roughly once a minute forever, and again on every alt-tab back into the app.
Both buttons spin while working and ignore a second click until the first settles (AsyncButton). Neither installs anything itself: a check asks main for the pass that applies the updates requesting nothing new (see above), and anything wanting new access still waits for your click.
A manual check is a REAL check. fetchRegistry() is TTL-cached for 5 minutes (CACHE_TTL_MS) and opening the Marketplace warms that cache on mount, so an unforced click would have answered straight from cache — reporting "up to date" without a single network call. Both manual buttons therefore pass force, which makes the handler drop the cache (invalidateRegistryCache()) before fetching. The automatic check deliberately does not force — the TTL still shields the remote registry, and a test locks that asymmetry. force is an optional field on the existing MARKETPLACE_FETCH_REGISTRY read: no new IPC channel, and the shared marketplace rate limit still applies.
force means "the user asked for this", and carries three things at once — they're one concept, so they ride one flag (marketplace-store.ts):
- Drop the main-side TTL cache — see above.
- Bypass the renderer's in-flight dedup — a click moments after mount would otherwise piggyback the very cached load it meant to skip.
- Never touch
isLoading— that flag swaps the whole plugin grid into skeleton boxes (BrowseTab.tsx), so a manual check used to blank the catalog the user was reading, for no visible reason. A forced load skips the flag entirely; the button's own spinner is the acknowledgment.
A forced load also does not eagerly clear registryError. Clearing it at load start made the status line read "up to date" — no error plus a stale count — while the check was still running, a confident wrong answer moments before it snapped to "check failed". The line now holds its last honest value until a real result lands.
Concurrent loads are ordered, not last-write-wins. Because a forced load bypasses the dedup, it runs alongside the mount's cached load and both write plugins on settle. Without ordering, the older cached response could land second and silently overwrite the fresh registry the user explicitly asked for — and the sidebar would then report against data it had just discarded. A monotonic request id gates the write, so only a response at least as new as the newest one already applied may commit.
Permission escalation is never silent. When you apply an update whose new version requests a permission the installed one didn't have, the consent dialog (PluginConsentDialog) highlights each new permission with a New badge and leads with "This update requests new permissions." The diffPermissions helper (src/shared/plugin-permissions.ts) computes installed-vs-incoming and drives both the modal row and the consent dialog.
Escalation the permission list cannot show is caught the same way. A new version that starts shipping a background process — full Node access to your files, the network and other processes — has an unchanged permission list, so no diff of declared permissions can see it. So does one that starts declaring sessionTools, which hands the plugin full-tool agent sessions inside your own projects with nothing else changed. In both cases the install gate reads the update's own downloaded manifest before the files are swapped, and asks you first: the approved powers are recorded on the consent record, and a flag the record never answered for counts as an unanswered ask rather than a grant. An update that genuinely requests nothing new still installs in one click.
Version compatibility (a plugin can be refused)
A plugin declares two version requirements, and Omniscio checks both when it loads one. If either fails, the plugin is marked incompatible: it stays visible in Settings but is withheld from the sidebar and the toolbar, and Omniscio records a plain-language reason for why.
minAppVersion— the plugin needs a newer Omniscio than you are running ("Needs Omniscio 0.2.0 or newer (you have 0.1.86)."). Fix by updating the app.sdkVersion— the plugin was built against a version of the plugin SDK this Omniscio build does not implement ("Built for plugin SDK ^3.0.0, but this version of Omniscio provides 2.0.0."). Fix by updating the app, or by the author publishing a build for the SDK you have.
This build implements SDK 2.0.0, matching the published @agent-mc/plugin-sdk on npm. Keeping those two numbers equal is the whole job: the check refuses anything above what the app implements, so if the app lags the published SDK, an author who installs the current SDK and declares the version they installed gets a plugin this app will not load — and sdkVersion is a required manifest field, so they cannot opt out of the mismatch. That is exactly what happened between 2026-08-11 and 2026-08-28, when the SDK went to 2.0.0 and the app still said 1.2.0 (a version npm never had).
The sdkVersion half is new as of 2026-07-27. Before that the field was read only to tell an old-style plugin from a worker-isolated one, and was never checked for compatibility — so a plugin built against a future, incompatible SDK loaded anyway and failed somewhere deep in a bridge call with nothing pointing at the real cause. The check is what makes a future SDK major version safe to ship.
The check is deliberately forgiving, because wrongly refusing a working plugin is worse than a clear runtime error. A plugin with no sdkVersion is always accepted. A bare version like 1.0.7 (which every plugin published so far declares) is read as a minimum, not an exact match, so those keep working as the SDK moves forward — that is why the move to a 2.0.0 host refused nothing already in the marketplace. Only a real range like ^3.0.0 can actually refuse, and an unparseable value is ignored rather than treated as a failure.
For agents
Package signing (built, not yet switched on)
Every published package is meant to carry an Ed25519 signature so the app can prove the file it downloaded is the one that was reviewed. The machinery exists on both sides — the server signs at upload, the catalogue serves the signature, and the app knows how to verify it, including key rotation. It is not switched on yet: no signing key exists, so nothing is signed today.
- A deploy without the key now FAILS. The signing secrets are declared and bound to the upload function, so signing can no longer be "switched on" by accident and silently do nothing — which is exactly what would have happened before, because nothing ever fed the key to the function.
- An upload that cannot be signed is refused, with a retryable error, before anything is stored — no half-uploaded file, no reserved version left behind.
- Approving a plugin can no longer un-sign it. The publish step rewrites the version record wholesale, so it now carries an existing signature forward.
- Already-published packages are signed by a script, not by re-uploading. It signs only bytes it can verify against the checksum recorded at publish time, and refuses anything it cannot prove.
Turning it on has a strict order, and getting it wrong breaks installs: bind (done) → create the key → deploy → back-sign the catalogue → pin the public key in a client release → wait for the fleet to upgrade → only then enforce. Enforcing before the fleet has upgraded breaks every install on every un-upgraded client.
Known gap: 5 published versions have no recorded checksum in any record, so their bytes cannot be proven and they will not be signed. (Two more that looked unrecoverable turned out to still have their checksum on the original submission — the same hash written at upload, simply never copied across — and are now recovered legitimately.)
Of those 5, only two need action: repoguard and virtual-pets are the only version of their plugin, so they would become uninstallable once strict checking is on. The rest are superseded by later versions, or retired.
Re-publishing those two is blocked by code, not by their authors — both are recorded as owned by a placeholder account nobody can sign in as, so any real developer's upload is refused, and the version has to be bumped because the endpoint will not overwrite one that already exists. Existing installs are unaffected either way: strict checking only applies when installing or updating.
Full invariants and current counts: marketplace-package-signing-contract.md.
Related
Plugin Marketplace is the first half of this page — what the Marketplace is, how to browse it, and how installing and the security gate work — and part 2 holds the privacy, rating and developer surfaces. The contracts named above hold the invariants behind what this half describes.
Last verified 2026-10-16