Skip to content

feat(desktop): ship GUI plugins through ./gui exports - #55246

Open
Hona wants to merge 20 commits into
anomalyco:devfrom
Hona:curious-planet
Open

Hona wants to merge 20 commits into
anomalyco:devfrom
Hona:curious-planet

Conversation

@Hona

@Hona Hona commented Oct 13, 2026 •

Copy link
Copy Markdown
Member

A plugin in opencode.json can now ship a GUI part: one ./gui-experimental export beside its server and ./tui entries. The export says experimental because 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 .ocdx archive installer is gone.

Docs preview: https://docs.vcybm3xt4hhu.opentunnel.xyz/v2/docs/build/plugins/gui/package/

Authoring

One npm package, one @opencode/plugin dependency, 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.

export default Plugin.define({
  id: "acme", // must equal the server plugin's id
  provides: { acme: Acme },
  setup(ctx) {
    // every app window
    ctx.add(Command, { id: "open", title: ctx.t("open"), run: () => void ctx.client?.rpc(AcmeRpc).open({}) })
    return () => {} // optional cleanup, as server and TUI plugins return
  },
  main(ctx) {
    // optional: once in the desktop main process
    ctx.provide(Acme, { ping: () => `pong for ${ctx.options.team}` })
  },
})
export default defineConfig({ plugins: [gui()] }) // import { gui } from "@opencode/plugin/gui/vite"
Server / TUI plugin GUI plugin
default export { id, server } / { id, tui } default export Plugin.define({ id, setup, main })
plugin options ctx.options (window and main)
context.client ctx.client: the client of the server that runs the plugin
ctx.app ctx.app (was ctx.build)
returned cleanup returned cleanup (window setup stays synchronous)
  • Id check: a GUI whose id differs from the server plugin's id fails with a clear error. Built-in ids, ids nested under one with ., and the opencode namespace are reserved.
  • Vite preset: gui() compiles Solid JSX and writes one CommonJS file with every dynamic import() inlined. It keeps exactly the modules the app provides external, plus Electron and Node built-ins. Any other effect/*, 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.
  • Inert placeholders: bundlers hoist require calls, and Solid hoists templates to module scope, so each process gets inert placeholders for the other process's modules. Main skips uses/requires, so a window-only token never breaks main.
  • Built-ins declare with the same Plugin.define, as TUI built-ins do. "Built-in" is only the host's flag for what packages/gui-plugins lists; the lists attach lazy entries and earlier ids through the host-only compose.

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" })
Loading
  • Trust: GUI code loads only from the desktop's own server, which already runs the plugin's server entry. The web app (its CSP forbids evaluating code) and remote SSH/WSL/HTTP servers load no plugin GUIs yet. Remote TUI and GUI slots will follow together.
  • Main owns the plugin list: main follows its own server's 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.
  • Hot reload, as for the server and TUI: vite build --watch rewrites the local gui-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 running opencode plugin update reloads both.
  • Options: the server decodes the plugin's options as JSON and serves them with the bundle; non-JSON options fail the plugin load with a clear error.
  • Isolation: the definition is checked at the boundary (tokens, stores, inline catalog), so a malformed or version-skewed bundle fails only its own plugin. A plugin's legacy ids are dropped.

Changes

  • Server:
    • Host.resolve finds ./gui-experimental (gui-experimental.cjs/gui-experimental.js locally; .ts/ESM are never served).
    • New Plugin.Features.gui and Plugin.GuiBundle { code, revision, options }. The revision is an opaque hash of the plugin operation and the bundle.
    • GET /api/plugin/gui returns PluginNotFoundError (404). Plugin.ID gains its schema identifier.
  • App:
    • runtime/plugin/plugins.tsx loads 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: pluginEnabled ignores a setting saved under a built-in's id in both hosts and the preload snapshot, main rejects enabling or disabling one (builtin error), 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 global opencode.json or in cli.json. The row's switch turns off only the desktop UI. No switch changes what it does.

      Settings > Plugins listing each part of a plugin and its switch

  • Desktop:
    • Plugin main setups run in the main-process host with ctx.options and a sidecar ctx.client. A returned cleanup becomes a scope finalizer. They get the same main context as built-ins, ctx.serverEndpoints credentials included: main already runs with full Node access, so this is not a new boundary.
    • Main lists plugin GUIs from its own server, filters them with the same runsOn OS rule as built-ins, and pushes them to windows.
    • Removes the archive install/remove/source RPCs, the oc://extensions asset protocol and the manifest.
    • Migration 20261013231314_drop-extension-files drops extension_file. extension.manifest/revision stay so 2.0.24╬ô├ç├┤2.0.27 can still open the database after a downgrade.
  • SDK:
    • The SDK lives in packages/plugin/src/gui and publishes with the same tsc step as ./tui: @opencode/plugin/gui, /gui/main, /gui/{file,review,browser,updater} and /gui/vite. The hosts' internal /gui/host is workspace-only: the build leaves it out and the published package exports it as null. No copy script.
    • packages/gui-extensions is now packages/gui-plugins and holds only the 13 built-ins, which import @opencode/plugin/gui like any plugin.
    • Plugin names everywhere: the SDK (usePlugin, bindPlugin, PluginContext) and the hosts (runtime/plugin, main/plugin, usePluginHost, Plugin* IPC). Persisted names move too; see Stored data.
    • Every declaration field is declared once, in Plugin.Declarations; Plugin.Definition adds setups, and the host-only Entry (with compose and its error types, in /gui/host) adds windowEntry/mainEntry.
    • IconName is generated from the app's icon catalog (bun run generate in packages/plugin); gui-plugins fails its typecheck when they drift.
    • solid-js, electron, vite and vite-plugin-solid are optional peers.
    • The server and TUI runtime never load gui/, so the CLI bundle does not grow.
    • Build/ctx.build is renamed App/ctx.app, which gains name as in server plugins.
  • Stored data: plugin data lives at plugin.<id>.<key> (window) and plugin.<id> (main), and the enable table is plugin. Each store imports its pre-rename extension.* value once, copying rather than moving, and the migration leaves a read-only extension view so 2.0.24 to 2.0.28 still read their settings after a downgrade.
  • Docs naming: the sidebar lists Server, TUI and GUI plugins as parallel groups, and the TUI half is called TUI plugins throughout. The GUI pages use the server docs' layout (examples, then a TypeScript #### Reference block) instead of field tables, and describe only what works today.
  • Tooling: lint:changed lints in chunks (a renamed package lists hundreds of files) and skips files moved without edits.
  • Docs: adds a Desktop section (plugins, review, files, terminal, browser, session, SSH, WSL, pairing) and Build > GUI Plugins (overview, package and build, window SDK, main process and IPC), and a GUI example in RPC.

Verification

  • A packed @opencode/plugin tarball installed into a sample npm plugin: vite build with the preset emits one 1 kB gui-experimental.cjs (lazy page inlined); tsc passes against the published types. Both hosts load the bundle: the window renders the lazy settings page with ctx.options, and main provides its Ipc.
  • Component stories cover plugin GUIs joining, hot-reloading in place, running their returned cleanup and leaving.

@Hona
Hona requested a review from Brendonovich as a code owner October 13, 2026 22:10
Copilot AI balanced review requested due to automatic review settings October 13, 2026 22:10

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@opencode-agent opencode-agent Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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, query id, returns Plugin.GuiSource { source, revision }), the optional Plugin.Features.gui flag, the ./gui package export / local gui.js resolved by Host.resolve (Entrypoints.gui), and the Plugin.ID schema identifier in OpenAPI. Generated clients get plugin.gui. All of these are additive.
  • Removed: the desktop-only .ocdx archive install, remove and source RPCs, and the oc://extensions protocol. These never shipped.
  • Changed: @opencode/gui-extensions becomes publishable with its SDK and contract entries, and a bundle's main setup moves from the default export to main.
  • Packaged builds: the extension manager (enable/disable/reload) no longer refuses requests in packaged builds.

Concerns

  1. revision is the raw JSON.stringify([operation, version]), so it carries the plugin's config options verbatim. I confirmed an apiKey comes 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).
  2. Main replaces its plugin map wholesale with whichever window reported last (inline).

Related: #43132

Comment thread packages/core/src/plugin.ts Outdated
Effect.orElseSucceed(() => undefined),
)
if (source === undefined) return undefined
return { source, revision: slot.plugin.revision } satisfies Plugin.GuiSource

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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/gui returns that hash, not the raw JSON.stringify([operation, version]), which stays server-side as plugin.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 an ExtensionPlugin schema or any revision field in packages/desktop/src/shared/ipc-rpc/extensions.ts anymore. Main gets the revision from its own GET /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.options is fine. They come from opencode.json and default to {} (config/plugin/source.ts:115-117). The Schema.Json check 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 config plugins[].options is already public (schema/src/config/plugin.ts:8).
  • 404: server/src/handlers/plugin.ts:17-25 returns PluginNotFoundError when there's no active plugin or it has no ./gui. A ./gui file that can't be read now becomes a defect (500) through Effect.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[]) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in c1f1fd3: main no longer takes a list from windows.

  • Windows send ExtensionPluginsChanged with no data when they see plugin.updated or 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 main setup.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 rejected plugin.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 as data: undefined and get mistaken for an empty list.
  • Failed bundle read: this falls back to plugins.get(id). An existing plugin keeps its revision, so applyPlugins sees no change and doesn't reload it. A new plugin stays out until the next nudge. That matches the comment.
  • Queueing: relistPlugins coalesces 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 !client early 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.

@Hona Hona changed the title feat(desktop): ship GUI extensions through plugin ./gui exports feat(desktop): ship GUI plugins through ./gui exports Oct 13, 2026
@Hona
Hona force-pushed the curious-planet branch 3 times, most recently from de43696 to c1f1fd3 Compare October 14, 2026 03:32
@esafak

esafak commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

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 (TitlebarItem), panel strip (session.panel.end / session.panel.sidebar + Panel), menus (MenuItem), settings (SettingsPage), servers (Server) all have registries. The composer footer in ComposerEditor (AddMenu + agent/model/variant controls + alternate-delivery / exit-shell / submit) exposes only data-slot DOM hooks, no SDK token, and the session composer region mounts no ExtensionSlot. SlotMap stays at its shipped keys with no composer key. window.bottom works today but is global chrome below everything, not “above the input”.

Proposed — relative to blocks that already exist, no new strip keys (all names proposed):

  • rows: ComposerStrip{anchor: "region"|"attachments"|"editor"|"footer", order} — sign picks side (negative before, positive after); host merges the shared gaps (attachments+/editor- are one gap) and sorts each gap by order. E.g. CI line is {anchor:"region", order:-10} (outside, above the box).
  • pills: ComposerAction{box: "controls"|"actions", order, after?} — after-+, between pickers, or leading in the fixed action box with submit pinned trailing.
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
Loading

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.

@olobarius

Copy link
Copy Markdown

Use case from a server plugin that would like to ship a small ./gui part on top of this PR.

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 (context:main, opened from the context ring in the session header), next to tokens and cost. In 2.0.32 that panel renders a fixed list (session, messages, provider, model, context limit, tokens, cost, timestamps, system prompt, raw messages). The slots I can see are window.bottom, session.header, session.panel.end and session.panel.sidebar, but none of them is inside that panel.

Two questions, in case they help shape the first cut:

  1. Could the Context panel expose a slot, for example session.context rendered with { session } below the built-in stats? A plugin could then add a short section there instead of opening its own side-panel tab. Usage and quota plugins (see the opencode-quota comment in [FEATURE]: Plugin UI surface for the web/desktop app, mirroring the TUI plugin API #43132) would fit the same slot.
  2. For session.header: will plugin contributions be ordered next to the built-in context ring (order: 10), so that a small status badge (an icon plus a count that opens the plugin's panel) is possible without crowding the header?

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 ctx.client.rpc(...) and subscribes to the event. That covers everything we need. Thanks for the work on this.

@Hona

Hona commented Oct 19, 2026

Copy link
Copy Markdown
Member Author

Hi all

We're not ready to launch this all the way, yet.
But I will add more Slots/Registries later
And also edit the doco.

but the generalised idea seems pretty stable.

Seems you both want more slots?
Which we can always add more later!
otherwise it seems solid

@esafak

esafak commented Oct 19, 2026

Copy link
Copy Markdown
Contributor

Yes. Slots inside existing components, not just around them. Looking forward to using it.

@Hona

Hona commented Oct 19, 2026

Copy link
Copy Markdown
Member Author

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!

Hona added 9 commits October 20, 2026 14:23
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.
Hona added 10 commits October 20, 2026 14:28
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.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants