Skip to content

feat(plugins): server plugin host - #15

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

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

Conversation

@ashx-j

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

Copy link
Copy Markdown
Owner

Phase 1 of the plugin system roadmap (#3, phase issue #5). It builds on #14, so until that merges this diff also shows Phase 0's two commits.

After Phase 0 a plugin could be described, but nothing ran it. Now the server finds plugins, runs the ones the user enabled, and lets clients manage them.

  • PluginHost finds plugins in <stateDir>/plugins/<id>/ and in dev folders listed in <stateDir>/plugins.json. It loads each enabled plugin's CommonJS entry and calls its activate with a Promise-based API: logging, settings, storage, RPC methods, publish channels and cleanup hooks. Each plugin activates in its own fiber after the server is listening. A plugin that throws while loading or activating is marked failed with the stage and message. A plugin method that throws fails only that call. In both cases the server and other plugins keep running.
  • Settings are saved in <stateDir>/plugin-data/<id>/settings.json and validated against the manifest's JSON Schema using Effect's own importer, so there is no new dependency. Fields marked format: "t3-secret" go to the server secret store and read back redacted. Storage is a key-value table in <stateDir>/plugins.sqlite, opened on first use.
  • There are 8 plugins.* WebSocket methods: list, subscribeStates, setEnabled, reload, settings.get, settings.update, invoke and subscribe. Enabling, reloading and changing settings need settings:write, since an enabled plugin runs code in the server. Calling a method a plugin registered as a write needs orchestration:operate.
  • The environment advertises plugins: true.

Changes from the roadmap (the issues are updated to match):

  • Settings live in plugin-data/<id>/, not inside the plugin's own folder. Otherwise replacing a plugin's files could lose its settings, and a dev plugin's settings folder would show up as a broken installed plugin.
  • There is one loader for both builds. A require made from process.execPath loads files from disk in the packaged single executable and in plain Node, so there is no separate import() path. I verified it in both.
  • Plugin state reaches clients through plugins.subscribeStates, not an opt-in on the server config stream. Clients open the config stream once per connection, so a settings page would have needed a second subscription that resends the whole config.
  • The t3_plugins_list MCP tool moved to Phase 3. Every agent turn pays for the advertised tool list, so it will appear only while a plugin is installed.
  • Newly found plugins start disabled.

Fixes from an adversarial review (a second Claude Opus 5.5 read the first version of this PR looking for bugs). Each confirmed bug has a test that fails without the fix:

  • Reloading one plugin stopped plugins whose folders had disappeared from tracking but left them running. They are now stopped.
  • A client disconnecting during a disable could leave a plugin half torn down while still receiving calls. Enable, disable and reload now run in the host's scope, so the caller only stops waiting.
  • A misspelled access value such as "Write" made a write method callable by read-only sessions. Only "read" and "write" are accepted now.
  • Reload kept running old code for a plugin folder that was a symlink, because Node caches modules by real path. Discovery now resolves real paths.
  • activate has a 30-second deadline, all of a plugin's cleanups share one 5-second deadline, calls stop reaching a plugin before its cleanups run, and in-flight calls end when it is disabled.
  • plugins.json keeps keys it doesn't know, and a malformed file now makes reload fail with a reason instead of quietly disabling everything.
  • The hello fixture no longer republishes its secret on a channel that any reader can subscribe to. The SDK docs now say not to do that.

Measurements:

  • Cold start: time from spawn to Listening on, on a copy of real data, 5 runs of each build alternating. Phase 0 had a median of 5020 ms (range 4930 to 5297) and this PR has a median of 5031 ms (range 4891 to 5054). That difference is within run-to-run noise.
  • subscribeServerConfig snapshot: the capabilities part grew from 1473 to 1488 bytes, which is the "plugins":true flag. The stream sends no new events.
  • With no plugins installed, no plugin code runs, plugins.sqlite is never created, and agents see no new MCP tools.

Test plugins:

  • hello-plugin. Its ping reads a setting. Its count writes to storage, needs write access and publishes on a channel. It republishes its settings when they change, and its cleanup writes a storage flag.
  • broken-plugin. Its activate throws.
  • The host tests also write small throwaway plugins to cover reload with changed code, startup not waiting for a slow plugin, and a disabled plugin's code never loading.

Verification: PluginHost.test.ts (10 cases), pluginDiscovery.test.ts, PluginSettingsStore.test.ts, PluginStorage.test.ts and RpcAuthorization.test.ts pass, and vp run check:changed passed. I also ran a real server against this worktree's seeded data, and the packaged single executable against a scratch home. In both, the hello plugin activated, plugins.invoke("ping") returned {"pong":"from verify","greeting":"hello"}, a settings update returned the secret as ••••••, and after disabling, ping failed with Plugin hello-plugin is disabled.


PR filed by Claude Opus 5.5 in Claude Code through T3 Code. A Claude Opus 5.5 subagent wrote the settings and storage services.

@ashx-j ashx-j mentioned this pull request Oct 30, 2026
9 tasks done
@github-actions github-actions Bot added vouch:trusted PR author is trusted by repo permissions or the VOUCHED list. size:XXL labels Oct 30, 2026
@ashx-j ashx-j mentioned this pull request Oct 30, 2026
@ashx-j
ashx-j force-pushed the feat/plugins-phase-1 branch from 3383ffe to 40fbace Compare October 30, 2026 16:16
ashx-j and others added 4 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>
@ashx-j
ashx-j force-pushed the feat/plugins-phase-1 branch from 40fbace to 2918b9f Compare October 31, 2026 21:32
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