Skip to content

feat(plugins): web plugin loader, settings panels and right panels - #18

Draft
ashx-j wants to merge 8 commits into
mainfrom
feat/plugins-phase-4a
Draft

ashx-j wants to merge 8 commits into
mainfrom
feat/plugins-phase-4a

Conversation

@ashx-j

@ashx-j ashx-j commented Oct 30, 2026 •

Copy link
Copy Markdown
Owner

Phase 4a of the plugin system roadmap (#3, phase issue #8). It builds on #17, so until #14 to #17 merge this diff also shows their commits.

Until now plugins could only run on the server. This lets a plugin ship React code into the web app, and so into the desktop app, starting with two places it can show up: its own right panel in a thread, and its own settings panel on the Plugins page.

  • Serving. The server serves each active plugin's web bundle at /api/plugins/<id>/web.js, with the same sign-in as the rest of /api. It reads the bundle from the plugin's real folder and refuses a path that resolves outside it.
  • Loading. A small starter in the root route waits until the app is idle. On a server that advertises plugins, it follows plugins.subscribeStates and downloads the loader, its own chunk, only once an active plugin has a web part.
    • The loader fetches each bundle with the connection's bearer token or cookie and imports it from a blob: URL. A bare import(url) can't send credentials. The desktop CSP gains blob: in script-src for this.
    • A failed fetch, import or activate is shown on that plugin's row in Settings → Plugins, and other plugins keep loading.
    • Disabling, reloading or upgrading a plugin unloads its web part and everything it registered.
  • Shared modules. Bundles take React, react-dom, the JSX runtime, a fixed set of atom hooks, a small set of UI primitives and the SDK from globalThis.__t3PluginHost, so they don't ship their own React. The loader chunk installs that object. The atom hooks are only ones the app already bundles, because exposing all of @effect/atom-react grew the shared startup chunk by 1.5 KB.
  • Right panels. There's a new plugin right panel kind. Panels a plugin registers appear in the panel's add menu and in the empty panel's "Open a surface" list. While the plugin is unloaded, a plugin panel shows "Plugin not active" with a Close button, and a panel that throws shows an error notice instead of breaking the app. Reopening a closed plugin panel works like other panels.
  • Settings panel. A plugin's own settings panel replaces the generated form on the Plugins page.
  • Registries. Each registry is a module-level Map behind one version atom. Components read it through atoms derived from that version, because React Compiler would otherwise cache a plain lookup and never re-render.

Changes from the roadmap (issue #8 is updated):

  • T3 Connect (DPoP) connections can't load web bundles yet. The fetch needs the relay signer, which only the client runtime holds. The Plugins page says so.
  • Only the primary environment's plugins are loaded for now. Contributions carry their environment id, so adding other environments won't change the registries.
  • Mobile doesn't load web plugins. Release builds can't load code, as the roadmap already says.
  • Moved to 4b, where commands need them:
    • The SDK's Vite preset. hello-web is hand-written ESM against the shared modules.
    • Serving assets/ and api.assetUrl.
    • The settings, state, commands.run and navigate parts of the web API.
  • Plugin panels have no keyboard shortcut letter in the "Open a surface" list. Built-in letters are fixed, and plugin titles could collide with them.

Measurements:

Bundle, from production web builds of #17's head and this branch, compared chunk by chunk:

  • New chunks that only download on demand:

    • pluginLoader 3.8 KB, once an active plugin has a web part.
    • PluginPanelSurface 2.2 KB, when a plugin panel opens.
    • pluginWatch 0.4 KB and the plugin state atoms 0.8 KB, after idle on a plugin-capable server.
    • pluginRegistry 0.9 KB, with the right panel code.
  • main grew 1.7 KB: the starter, plus an error boundary that moved in from another shared chunk (threadOverlayRetention shrank 0.4 KB).

  • _chat grew 1.4 KB for the plugin panel kind in panel content and reopening.

  • PanelLayoutControls grew 2.1 KB for the plugin entries in both panel menus.

  • toast shrank 2.1 KB and a new localApi chunk of the same size appeared, so the loader can share that code. No net change.

  • The shared utils chunk is unchanged.

  • Renders: not measured. This PR changes no component on the timeline or sidebar paths, and none of them reads a plugin atom. The only new always-mounted component is the starter in the root route, which renders nothing.

  • Loader start: in the dev server, the loader installed its shared modules 0.75 to 0.8 s after the thread view first rendered, over 3 runs. It waits for requestIdleCallback, so it never runs before first paint.

  • With no plugins installed, or none with a web part, the loader chunk never downloads. The starter keeps one plugins.subscribeStates stream open, which sends one empty list and nothing after. Servers without the capability get no stream.

Screenshots:

Before this change, plugins had no web part. GitHub's CLI can't upload images, so the 5 screenshots are in the T3 thread for this work instead: the add menu and the empty panel listing "Hello panel", the open panel showing the server's greeting and the thread id, hello-web's own settings panel, and "Plugin not active" after disabling hello-web. They still need to be dragged into this description during review.

Test plugin: hello-web. Its web half registers a right panel that calls the plugin's server half (greeting), plus a settings panel.

Verification:

  • pluginLoader.browser.test.ts loads the real hello-web bundle through a blob: import in Chromium, records a failing plugin's error, and unloads everything on stop.
  • PluginPanelSurface.browser.test.tsx checks that a registered panel renders, then shows "Plugin not active" with a working Close once the plugin unloads.
  • The PluginHost.test.ts web bundle route case checks the bundle is served while the plugin is active and gone after disabling.
  • ElectronProtocol.test.ts covers the CSP change.
  • In a browser against a dev server on seeded data, I opened the panel from both menus, saw the server's greeting, opened the custom settings panel, and saw "Plugin not active" after disabling. With hello-web off, the loader never ran. Enabling it started the loader without a reload.
  • reactCompilerCoverage.test.ts passes, and vp run check:changed passed.
  • Not verified: the desktop app. The CSP change is covered by its unit test only.

Review: an adversarial review of #18 to #22 found issues in this PR; the fixes are in #23.


PR filed by Claude Opus 5.5 in Claude Code through T3 Code.

ashx-j and others added 8 commits October 31, 2026 21:22
…bility flag

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
PluginHost discovers installed and dev plugins, loads enabled CommonJS
entries after the server is listening, and hands each a Promise-based API
for logging, settings, storage, RPC methods and channels. Failures mark the
plugin failed and never stop the server. Settings live beside T3's state
with secrets in the server secret store; storage is a lazily opened SQLite
file. Eight plugins.* WebSocket methods manage plugins, and the environment
advertises plugins: true.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An adversarial review found that reloading one plugin left plugins whose
folders vanished running, that a caller disconnecting mid-disable left a
plugin half torn down but still routed, that a misspelled access level
made a write method callable by read-only sessions, and that reload kept
old code for symlinked plugin folders. Lifecycle changes now run in the
host's scope, vanished plugins are stopped, access must be read or write,
and discovery resolves real paths. Activation and cleanups get deadlines,
calls stop routing before teardown, and plugins.json keeps unknown keys.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Settings gains a Plugins page for the selected environment: each plugin
with its status, failure, enable switch and reload, plus a settings form
generated from the manifest's JSON Schema with write-only secret fields
and a JSON fallback. The page follows plugins.subscribeStates only while
open; the form model lives in client-runtime for the later mobile screen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ng and send hooks

Plugins can now follow stored orchestration events by type, read and act
on threads and projects, run scheduled work, see and extend outgoing
messages before they are sent, offer MCP tools to agents and serve HTTP
routes under /api/plugins/<id>, and read the server's version, descriptor
and redacted settings. The send hook runs in ThreadManagementService.dispatch
with a 2 s deadline and fails open; with no hooks it is one Ref read.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… writes

An adversarial review found that a send hook appending to /goal clear
turned it into setting a goal, that the SDK promised a veto nothing
honoured, that several event types merged out of sequence order, that a
schedule could outlive its plugin, and that plugin write tools skipped the
caller's mode limits. Hooks now skip slash commands, veto is gone from the
SDK, multi-type event streams stay ordered, schedules and hooks stop on
the deactivation signal, API calls end after cleanups, plugin write tools
need full access like environment writes, and tool schemas must be objects.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Plugins could only run on the server. The server now serves each active
plugin's web bundle, and the web app loads it once an active plugin has a
web part: a starter follows plugin state after idle, and the loader fetches
bundles with the connection's credentials and imports them from blob URLs.
Bundles share the app's React, atom hooks and UI kit through a host object.

Plugins can add right panels, listed in the add menu and the empty panel,
and replace their generated settings form. An unloaded plugin's panel shows
"Plugin not active" with a close button. The desktop CSP allows blob scripts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:XXL vouch:trusted PR author is trusted by repo permissions or the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant