Repository navigation
Conversation
There was a problem hiding this comment.
Looks sound overall. On the server side, the ./gui path works: I ran serve from source with a local plugin directory, and plugin.list reports features.gui. GET /api/plugin/gui returns the bundle and its revision, and an unknown id returns a 400. Tests in core, plugin, desktop and app pass, and typecheck is clean in every changed package. I couldn't exercise the desktop or window side (Electron isn't available here).
Public API
- Added:
GET /api/plugin/gui(plugin.gui, queryid, returnsPlugin.GuiSource { source, revision }), the optionalPlugin.Features.guiflag, the./guipackage export / localgui.jsresolved byHost.resolve(Entrypoints.gui), and thePlugin.IDschema identifier in OpenAPI. Generated clients getplugin.gui. All of these are additive. - Removed: the desktop-only
.ocdxarchive install, remove and source RPCs, and theoc://extensionsprotocol. These never shipped. - Changed:
@opencode/gui-extensionsbecomes publishable with its SDK and contract entries, and a bundle's main setup moves from thedefaultexport tomain. - Packaged builds: the extension manager (enable/disable/reload) no longer refuses requests in packaged builds.
Concerns
revisionis the rawJSON.stringify([operation, version]), so it carries the plugin's configoptionsverbatim. I confirmed anapiKeycomes back in plain text from/api/plugin/gui. Window code then sends that string to main over an IPC schema capped at 16,384 characters (inline).- Main replaces its plugin map wholesale with whichever window reported last (inline).
Related: #43132
| Effect.orElseSucceed(() => undefined), | ||
| ) | ||
| if (source === undefined) return undefined | ||
| return { source, revision: slot.plugin.revision } satisfies Plugin.GuiSource |
There was a problem hiding this comment.
slot.plugin.revision is JSON.stringify([operation, loaded.version]) (plugin/module.ts), so it includes the plugin's config options verbatim. With { "package": "…", "options": { "apiKey": "sk-secret-123" } }, this endpoint returned the key inside revision. That string is then sent from window code to main, where ExtensionPlugin.revision is capped at text(16_384). A plugin with large options would fail that decode, and since ExtensionPlugins is one message, every plugin's main setup would be dropped with it. A short hash of the operation and version would avoid both problems.
There was a problem hiding this comment.
Fixed. revision is now a sha256 of the plugin operation and the bundle digest (packages/core/src/plugin/module.ts), so no options reach it, and the IPC field is text(256).
The endpoint now returns options on purpose: they become ctx.options for the plugin's GUI, the same object its server half gets. This is not a new exposure, because GET /api/config already returns the same plugins[].options to the same callers. An unknown id now returns 404 PluginNotFoundError.
There was a problem hiding this comment.
Confirmed on pr/head (c1f1fd3). My original concern is resolved:
- Revision: the GUI revision is now
Hash.sha256(JSON.stringify([revision, bundle]))(packages/core/src/plugin/module.ts:161)./api/plugin/guireturns that hash, not the rawJSON.stringify([operation, version]), which stays server-side asplugin.revision. The bundle digest only exists for local plugins (L146). For packages, the hash changes when the operation or installed version changes, which matches the comment at L142–143. - IPC cap: your
text(256)is moot. I can't find anExtensionPluginschema or anyrevisionfield inpackages/desktop/src/shared/ipc-rpc/extensions.tsanymore. Main gets the revision from its ownGET /api/plugin/gui(main/extension/host.ts:388), so one large plugin can no longer break the message for every plugin. - Options: returning them on purpose as
ctx.optionsis fine. They come fromopencode.jsonand default to{}(config/plugin/source.ts:115-117). TheSchema.Jsoncheck at L148–151 therefore only fails for SDK-injected non-JSON options, and when it does, it fails the whole plugin load, including the server half. That's acceptable, but it's a load failure, not just a missing GUI. I agree it's no new exposure, since configplugins[].optionsis already public (schema/src/config/plugin.ts:8). - 404:
server/src/handlers/plugin.ts:17-25returnsPluginNotFoundErrorwhen there's no active plugin or it has no./gui. A./guifile that can't be read now becomes a defect (500) throughEffect.promise, which matches the comment.
I only read the code; I didn't run builds or tests. I made no changes. Someone else has the shared checkout detached at an older commit (059f88c), and I left that alone.
| * Takes the local-server plugins a window found. Each one's `main` setup starts, reloads when its revision | ||
| * changes, and stops when the plugin is gone. A plugin cannot take a built-in's id. | ||
| */ | ||
| plugins(list: readonly ExtensionPlugin[]) { |
There was a problem hiding this comment.
Main keeps one plugin map and replaces it with whatever list the latest window sent. Every window runs ExtensionPlugins on its own. In plugins.tsx, find turns a failed plugin.gui fetch into undefined, and pluginDefinition reports main: false when its evaluation fails in that window. So one window's transient error makes main deactivate that plugin's main setup (or forget it entirely), and the next window's report brings it back. Should only one window own this report, or should main merge the reports per window, so main setups don't flap?
There was a problem hiding this comment.
Fixed in c1f1fd3: main no longer takes a list from windows.
- Windows send
ExtensionPluginsChangedwith no data when they seeplugin.updatedor reconnect. - Main lists the plugins and reads each bundle from its own server, one listing at a time; nudges during a listing trigger one more.
- A failed listing keeps what main runs, and a failed read keeps that plugin's last bundle, so one window's transient error cannot stop or flap a
mainsetup.
There was a problem hiding this comment.
I checked c1f1fd3 against pr/head, and it resolves the flapping I raised. Main now lists plugins from its own server, and the windows only nudge it. Neither kind of failure can remove a plugin anymore:
- Failed listing: in
listPlugins(host.ts~352–379), a rejectedplugin.list()returns early, so main keeps running what it already runs. The promise client throws on any non-200 response (request→responseError), so error responses don't come back asdata: undefinedand get mistaken for an empty list. - Failed bundle read: this falls back to
plugins.get(id). An existing plugin keeps its revision, soapplyPluginssees no change and doesn't reload it. A new plugin stays out until the next nudge. That matches the comment. - Queueing:
relistPluginscoalesces nudges correctly. Windows nudge after their own listing settles (plugins.tsx:110), and that listing can only happen once the sidecar is up, so the!clientearly return shouldn't drop a startup nudge.
One small leftover: the comment in reload (host.ts:798) says "A plugin's reload reads its bundle from the server again". It doesn't. loader re-evaluates the cached plugins.get(id).code. So a manual reload in Settings won't pick up a rebuilt gui.cjs in main unless plugin.updated fired first and caused a relist. You can either fix the comment, or make the plugin branch of reload await a fresh listPlugins() before lifecycle.reload.
I only read the code; I didn't run any tests. Your workspace was clean but detached at 059f88cc9, an older commit than pr/head, so I read the files with git show pr/head:…. I didn't change anything.
de43696 to
c1f1fd3
Compare
|
Nice work, but it is too big to review manually. Personally I would keep this one for reference and carve it up. Speaking about reference, I could not access the doc. As I was poking around, I thought of some plugins I would actually like to make, as I find application-oriented thinking grounds design. Suppose we want to put some CI affordances like CI status and a PR button inside the Composer, at the top or bottom. In order to support this I propose to add named slots inside components, like the composer. Composer has no extension point, while every neighboring chrome does. Titlebar ( Proposed — relative to blocks that already exist, no new strip keys (all names proposed):
flowchart LR
subgraph Form["composer form"]
direction TB
Att["attachments"] --> Edit["editor"] --> Footer["footer"]
end
subgraph FooterBoxes["footer boxes"]
direction LR
Add["+ AddMenu"] --> Controls["controls"] --> Actions["actions"]
end
Form --> FooterBoxes
style Att fill:#000,color:#fff
style Edit fill:#000,color:#fff
style Footer fill:#000,color:#fff
style Controls fill:#000,color:#fff
style Actions fill:#000,color:#fff
You can, of course, generalize this to other components. Feel free to contact me on Discord; I'm happy to be a 'design partner' or a devil's advocate. |
|
Use case from a server plugin that would like to ship a small We maintain a security plugin (server entry only today) that replaces secrets and passwords with placeholders before a request leaves for a cloud provider, optionally double-checks texts with a local LLM, and enforces routing rules for restricted data sources. It keeps per-session statistics: values replaced (by category, never the values), requests sent and blocked, local-LLM scans (calls, time, tokens, findings). Right now the only way to show them is a slash command whose output lands in the transcript. The natural home for this in the desktop app is the existing Context panel ( Two questions, in case they help shape the first cut:
As I read the PR, the data path would be the plugin's own RPC: the server entry registers methods and an event, and the GUI part calls |
|
Hi all We're not ready to launch this all the way, yet. but the generalised idea seems pretty stable. Seems you both want more slots? |
|
Yes. Slots inside existing components, not just around them. Looking forward to using it. |
|
yeah. the first cut i kept pretty conservative slots. but once i merge all this foundational work, i will probably take a pass at way more slots - using community feedback like urs! |
Plugins configured in opencode.json can export one CommonJS ./gui bundle that the app windows and the desktop main process both load from the server that runs the app. Settings > Plugins lists built-in and installed plugins. Removes the unshipped .ocdx archive installer and its storage. @opencode/plugin publishes the GUI plugin SDK as @opencode/plugin/gui beside /tui, so one dependency serves server, TUI and GUI entries.
The GUI SDK now lives in packages/plugin/src/gui and publishes with the same tsc step as ./tui, so the publish-time copy script (script/gui.ts) is gone. packages/gui-extensions becomes packages/gui-plugins and holds only the built-in plugins, which import @opencode/plugin/gui like any plugin. - Built-ins declare with Plugin.define, as TUI built-ins do; "built-in" is the host's flag for what the lists load. compose and the bridge are host-only in @opencode/plugin/gui/host; earlier ids live in gui-plugins/src/legacy.ts. - Public names say plugin: usePlugin, bindPlugin, PluginContext, and the SDK's TSDoc. Persisted names (extension.<id> keys, the extension table) stay. - IconName is generated from the app's catalog (bun run generate in packages/plugin); gui-plugins fails its typecheck when they drift. - Desktop main lists plugin GUIs from its own server; windows only nudge it, so one window's failed read can no longer stop a plugin's main setup. - Settings > Plugins reports a plugin GUI whose id a built-in claims. - ctx.app gains name, as in server plugins. - lint:changed lints in chunks and skips files moved without edits.
No release shipped the usage or summary ids, or a plugin toggle, so built-ins need no earlier-id enable state, and the summary/usage keybind, panel and stored-key migrations import nothing. Migrations of keys from before plugins (v2.0.20) stay. Also fixes the panel key format in the window docs.
A plugin's ./rpc contract imports @opencode/plugin/rpc, and the docs have its GUI import that contract, but neither host shared the module, so gui() failed the build. Both hosts now share it, and the preset reads the module lists from one file instead of a copy.
…once Main follows the app's own server for plugin.updated, as the TUI does, lists and evaluates each plugin GUI once per revision, and sends windows the set; windows no longer list plugins or nudge main, so Settings and every process see one list. Plugin.Declarations now declares every field once: Plugin.Definition adds the setups and the host-only Entry (in @opencode/plugin/gui/host, with compose and its error types) adds windowEntry and mainEntry. Both hosts check the OS with one runsOn rule, so main no longer lists another system's plugin as active.
The window and main hosts, their IPC, tests and stories now use the plugin name the SDK and docs use: runtime/plugin and main/plugin, usePluginHost, PluginHost, Plugin* IPC messages and events, pluginEnabled, ServerConnection.Plugin, and the built-ins' plugin context locals. Stored keys (extension.<id>.<key>, extension.servers, main's extension.<id> namespace) and the SQLite extension tables keep their names, as does the separate Settings > Extensions page for MCPs, skills and server plugins. Also clears older lint warnings in files the rename touched.
Corrects where plugin GUIs load, ctx.app (name, packaged on the web), runtime store scope, the awake example, useDrawer and a top-level await, and replaces other plugins' ids in examples with ctx.id. Documents ctx.list, the layout reads and open options, sessions and the session screen, composer update and detach, servers and server events, dialogs, keybinds, runtime store scopes, the createLatest result, onIdle, every registry's fields, the embed handle, Ipc state, and the four built-in contracts (file, review, browser, updater). Removes the duplicate field table, code-only headings, bold emphasis and em dashes, and links the GUI docs to the server and TUI halves.
Window stores live at plugin.<id>.<key> and main stores in the plugin.<id> namespace. Each store imports its value once from the extension.<id> key or namespace it had before the rename, in the same storage and scope, ahead of its own older homes; the old copy stays for older builds until the store is removed. The enable-state table is renamed to plugin, and the migration leaves a read-only extension view, so 2.0.24 to 2.0.28 still read their settings after a downgrade.
The sidebar lists Server plugins, TUI plugins and GUI plugins as parallel groups, each overview is titled after its half and links to the other two, and the TUI half is called TUI plugins everywhere (cli.json's CLI-only plugins keep their name). The TUI page changes only those names, its example file name and a link; it also adds page descriptions and a TUI card to the Build intro.
…ckage It is the app's and desktop's internal protocol (bridge messages, module tables, bundle evaluator, compose). The build no longer emits it, the publish script exports it as null so the ./* export cannot reach it, and publishing starts from a clean dist so no file an earlier build left behind ships. Workspace imports are unchanged.
…of field tables The GUI plugin pages replace their 34 tables with the server docs' pattern: an example, bullets for the rules a name does not say, and a #### Reference block of interfaces printed from the SDK source. Host APIs the table listed in one line each get their own System and environment section; the Desktop plugins page lists statuses and built-ins as bullets. Stored keys read plugin.<id>.<key>, matching the storage rename.
… > Plugins A row for a plugin from the config now lists its parts: the desktop UI, which the row's switch turns off; the server part, which -<id> in the global opencode.json turns off together with the desktop UI; and, when the plugin exports ./tui, the TUI part, which -<id> in cli.json turns off. Main adds whether a plugin exports ./tui to the installed list it sends windows. No switch changes what it does. The Desktop plugins page documents the parts and no longer describes where GUI plugins do not load.
Drops the web-app and remote-server notes that pointed at unshipped behaviour; the pages state where GUI entries load.
pluginEnabled now takes whether a plugin is built-in and resolves a built-in as enabled whatever row is saved under its id, so main, the window and the preload snapshot all ignore an old setting. Main rejects enable and disable for a built-in with a new builtin error code. Settings > Plugins shows no switch for built-ins, and rows drop the Active and Disabled labels the switch already shows, keeping Loading, Blocked and Failed. The docs say built-ins always run.
… them off Settings > Plugins rows from the config carry Server and TUI tags beside the id, like the Built-in label, and one note under the list says how to turn those parts off.
…ab lists plugins Rows use the Extensions tab's catalog list and row (cube icon, name, controls), the Providers tab's badges for Built-in, Server and TUI, and the MCP dialog's inline status with an error line below. Built-in rows keep the switch's width so every Reload lines up.
…y surface The comments in command.tsx, store.ts and registry.test.ts say plugin. Touching the files lints them, so: the settings store holds JSON values and decodes its file with a Schema instead of a typeof check (a JSON file that is not an object is set aside like an unreadable one); command.tsx builds its key tables as Maps, types its store without a cast, and narrows register's overloads with Predicate; the registry test seeds JSON.
dev added the waveform icon to the app's catalog.
A plugin package exports its GUI as ./gui-experimental (gui-experimental.cjs or .js in a local plugin directory), and the Vite preset writes dist/gui-experimental.cjs, so the name says the GUI plugin API may still change. The SDK stays @opencode/plugin/gui; internal names (features.gui, the plugin.gui route) are unchanged. The docs say the API is experimental.
A plugin in
opencode.jsoncan now ship a GUI part: one./gui-experimentalexport beside its server and./tuientries. The export saysexperimentalbecause the GUI plugin API may still change. There is no separate extension concept. The desktop app's built-in features are built-in plugins, and Settings > Plugins lists them with plugins from your config. The unshipped.ocdxarchive installer is gone.Docs preview: https://docs.vcybm3xt4hhu.opentunnel.xyz/v2/docs/build/plugins/gui/package/
Authoring
One npm package, one
@opencode/plugindependency, any combination of entries:{ "exports": { ".": "./dist/server.js", // import { Plugin } from "@opencode/plugin" "./tui": "./src/tui.tsx", // import { Plugin } from "@opencode/plugin/tui" "./gui-experimental": "./dist/gui-experimental.cjs" // import { Plugin } from "@opencode/plugin/gui" }, "dependencies": { "@opencode/plugin": "latest" } }The GUI half has the same shape as the server and TUI halves: one default export.
{ id, server }/{ id, tui }Plugin.define({ id, setup, main })ctx.options(window and main)context.clientctx.client: the client of the server that runs the pluginctx.appctx.app(wasctx.build)., and theopencodenamespace are reserved.gui()compiles Solid JSX and writes one CommonJS file with every dynamicimport()inlined. It keeps exactly the modules the app provides external, plus Electron and Node built-ins. Any othereffect/*,solid-js/*or@opencode/*import fails the build. A test builds a real fixture and reads the hosts' module lists, so the preset and the hosts cannot drift apart.requirecalls, and Solid hoists templates to module scope, so each process gets inert placeholders for the other process's modules. Main skipsuses/requires, so a window-only token never breaks main.Plugin.define, as TUI built-ins do. "Built-in" is only the host's flag for whatpackages/gui-pluginslists; the lists attach lazy entries and earlier ids through the host-onlycompose.Load path
sequenceDiagram participant S as Desktop's own server participant W as App window participant M as Desktop main W->>S: plugin.list() (features.gui) W->>S: GET /api/plugin/gui?id=acme S-->>W: { code, revision, options } W->>W: evaluatePluginBundle(code, { id, process: "window" }) S-->>M: plugin.updated (main follows the server itself) M->>S: plugin.list() + GET /api/plugin/gui?id=acme (sidecar credential) M->>W: PluginGuisChanged { plugins, reserved } M->>M: evaluatePluginBundle(code, { id, process: "main" })plugin.updated(as the TUI does), lists the plugins, evaluates each bundle once per revision, and sends windows the set. Windows never list plugins, so every window, main and Settings see one list; a listing or read that fails keeps what main runs.vite build --watchrewrites the localgui-experimental.cjs. Only the GUI revision changes, so the GUI reloads in every window and in main without restarting the server entry. Editing the server entry, its options, or runningopencode plugin updatereloads both.legacyids are dropped.Changes
Host.resolvefinds./gui-experimental(gui-experimental.cjs/gui-experimental.jslocally;.ts/ESM are never served).Plugin.Features.guiandPlugin.GuiBundle { code, revision, options }. The revision is an opaque hash of the plugin operation and the bundle.GET /api/plugin/guireturnsPluginNotFoundError(404).Plugin.IDgains its schema identifier.runtime/plugin/plugins.tsxloads bundles and keeps the last good list on transient failures. The host receives each plugin's options and its server's client.The host accepts definitions that join, are replaced (hot reload), and leave. Plugins never hold the startup gate. A returned window cleanup runs when the plugin goes away.
Settings > Plugins reports a plugin GUI that cannot load because a built-in claims its id.
Settings > Plugins (was the dev-only Extensions page, which collided with each server's Extensions tab) ships, with a Built-in label.
A disabled plugin stays disabled at startup.
Built-in plugins always run:
pluginEnabledignores a setting saved under a built-in's id in both hosts and the preload snapshot, main rejects enabling or disabling one (builtinerror), and Settings > Plugins shows no switch for it. Rows show a status only for Loading, Blocked and Failed; the switch shows on and off.Settings > Plugins tags each plugin from the config with its other parts (Server, and TUI when it exports
./tui), and one note under the list says how to turn them off:-<id>in the globalopencode.jsonor incli.json. The row's switch turns off only the desktop UI. No switch changes what it does.mainsetups run in the main-process host withctx.optionsand a sidecarctx.client. A returned cleanup becomes a scope finalizer. They get the same main context as built-ins,ctx.serverEndpointscredentials included:mainalready runs with full Node access, so this is not a new boundary.runsOnOS rule as built-ins, and pushes them to windows.oc://extensionsasset protocol and the manifest.20261013231314_drop-extension-filesdropsextension_file.extension.manifest/revisionstay so 2.0.24ΓÇô2.0.27 can still open the database after a downgrade.packages/plugin/src/guiand publishes with the sametscstep as./tui:@opencode/plugin/gui,/gui/main,/gui/{file,review,browser,updater}and/gui/vite. The hosts' internal/gui/hostis workspace-only: the build leaves it out and the published package exports it asnull. No copy script.packages/gui-extensionsis nowpackages/gui-pluginsand holds only the 13 built-ins, which import@opencode/plugin/guilike any plugin.usePlugin,bindPlugin,PluginContext) and the hosts (runtime/plugin,main/plugin,usePluginHost,Plugin*IPC). Persisted names move too; see Stored data.Plugin.Declarations;Plugin.Definitionadds setups, and the host-onlyEntry(withcomposeand its error types, in/gui/host) addswindowEntry/mainEntry.IconNameis generated from the app's icon catalog (bun run generateinpackages/plugin);gui-pluginsfails its typecheck when they drift.solid-js,electron,viteandvite-plugin-solidare optional peers.gui/, so the CLI bundle does not grow.Build/ctx.buildis renamedApp/ctx.app, which gainsnameas in server plugins.plugin.<id>.<key>(window) andplugin.<id>(main), and the enable table isplugin. Each store imports its pre-renameextension.*value once, copying rather than moving, and the migration leaves a read-onlyextensionview so 2.0.24 to 2.0.28 still read their settings after a downgrade.#### Referenceblock) instead of field tables, and describe only what works today.lint:changedlints in chunks (a renamed package lists hundreds of files) and skips files moved without edits.Verification
@opencode/plugintarball installed into a sample npm plugin:vite buildwith the preset emits one 1 kBgui-experimental.cjs(lazy page inlined);tscpasses against the published types. Both hosts load the bundle: the window renders the lazy settings page withctx.options, and main provides its Ipc.