efm-bridge
v0.4.4
Published
OpenCode V2 plugin that bridges efm-langserver diagnostics into agent context after edits and on demand
Readme
efm-bridge
OpenCode V2 plugin that turns the diagnostics from your own
efm-langserver configuration into
agent feedback: an appended digest after every edit, plus an on-demand
efm_diagnostics tool.
EFM remains the only policy point for which languages lint, which commands run, and which projects they run for. The bridge never parses EFM's configuration and never invents coverage of its own.
Install
opencode plugin add efm-bridgePublished packages ship the TUI entry as precompiled JavaScript (dist/tui.js):
OpenCode does not apply the Solid TSX transform to files inside node_modules.
A local directory install must run npm run build once before it is loaded.
Prerequisites:
go install github.com/mattn/efm-langserver@latestThe bridge resolves the binary on PATH first, then in ~/go/bin,
~/.npm-global/bin, ~/.local/bin, and the platform npm layout. EFM's own
configuration stays where EFM looks for it:
$XDG_CONFIG_HOME/efm-langserver/config.yaml, or
~/.config/efm-langserver/config.yaml when XDG_CONFIG_HOME is unset
(%APPDATA%\efm-langserver\config.yaml on Windows).
What it does
Post-edit lane
After a completed edit, write, or patch tool call, every target whose
extension maps to a language gets one didOpen plus one didSave on a
per-directory efm-langserver child. The bridge waits a bounded window (6 s) for
publishDiagnostics:
- findings: one digest is appended to the edit result with severity counts and the first messages;
- zero findings: the digest says the language ran clean;
- no publish: nothing is appended, because an unconfigured language is silent by design.
Injected text is appended only. It never replaces or consumes text another plugin added to the same result.
Agent lane
The bridge registers an MCP server named efm exposing exactly one tool,
diagnostics — agents call it as efm_diagnostics: every tool is namespaced by
the server id on the host.
efm_diagnostics(path) -> digest + { file, language, diagnostics, total }The tool lints the file through the same device and the same warm child as the edit lane. It returns a labeled off note while the trust gate is unavailable and a skip note naming the extension or language when nothing can be linted.
Options
| Option | Default | Meaning |
| --- | --- | --- |
| configPath | EFM's default path | Path override passed to EFM as -c. The bridge only stats the file. |
| languages | {} | Extension-to-languageId overrides merged over the default table. Keys may be written with or without the dot. |
| inheritEnv | false | Forward the full host environment to efm-langserver. Ignored unless EFM_BRIDGE_TRUSTED_OPTIONS=1 is set in the host environment. |
| mcpRegistration | true | Add the efm MCP entry when the user has none. An entry recorded by an earlier bridge version under efm-bridge is retired; a user-written entry is never modified. |
| turnEnd | false | Opt in to turn-end settlement: after each finished turn, re-check the turn's edited files through the running child and deliver at most one bounded message disclosing only what the agent has not seen. Fully dormant while false. |
Turn-end settlement is opt-in. With turnEnd: true, each finished agent turn
re-lints the files that turn edited and sends one bounded synthetic message when
something was not already disclosed in-band: a late or missing publish, an
unreadable file, an edited extension with no language mapping (named with the
mapping remedy), or content that changed after the last delivered digest.
Settled turns stay silent. Subagent, foreign-location, no-edit, and
gate-unavailable turns record a skip in the run history without messaging; the
bridge never modifies a tool result at settlement time.
npm installs ship the compiled dist/ JavaScript for the MCP launcher and
server, and store installs resolve those entries automatically; repository
installs keep using the TypeScript entries. An MCP entry the bridge recorded
earlier is rewritten to the current command at plugin activation. If a
consumer still runs a bridge version before 0.4.2, re-run
opencode plugin add efm-bridge after upgrading.
Example:
{
"plugins": [
{
"package": "efm-bridge",
"options": {
"languages": { "mjs": "javascript", "mdx": "markdown" }
}
}
]
}Extension table
md/markdown → markdown, sh/bash/zsh/ksh → shellscript,
yaml/yml → yaml, json/jsonc → json/jsonc, toml → toml,
ini/cfg/conf → ini/conf, py/pyi → python, js/mjs/cjs → javascript,
jsx → javascriptreact, ts/mts/cts → typescript, tsx → typescriptreact,
rs → rust, go → go, rb → ruby, lua → lua, php → php, java → java,
kt/kts → kotlin, scala → scala, cs → csharp, swift → swift,
c/h → c, cpp/cc/cxx/hpp/hh → cpp, m → objective-c,
mm → objective-cpp, css/scss/less → css/scss/less, html/htm → html,
xml/svg → xml, vue → vue, svelte → svelte, sql → sql,
graphql/gql → graphql, proto → proto, tf/tfvars → terraform,
nix → nix, zig → zig, ex/exs → elixir, erl → erlang,
hs → haskell, clj/cljs → clojure, pl → perl, r → r, tex → latex,
mk → makefile, vim → vim, dockerfile → dockerfile.
An unmatched extension produces no LSP traffic and no injected note.
Coexistence
The bridge is designed to run beside pi-lens or any other diagnostics plugin. Each bridge appends its own labeled block, owns its own child processes and MCP identity, and never inspects another plugin's state or configuration. Overlap is decided only by your efm and pi-lens configurations: if EFM lints markdown and pi-lens covers TypeScript, that split is exactly what you get.
Run metrics
The package's ./tui export adds an EFM block to the sidebar and an
/efm-runs command. The sidebar shows one row per file per lane, newest first
within each lane:
EFM
• markdown edit notes.md 2
✦ markdown diagnostics notes.md 0| Symbol | Lane |
| --- | --- |
| • | post-edit lint run |
| ✦ | agent-initiated efm_diagnostics call |
Rows are <symbol> <language> <tool> <file> <amount>. Tool names lose the
bridge namespace (efm-bridge_ or efm_) and one leading efm_ word, so
efm-bridge_efm_diagnostics and efm_diagnostics both become diagnostics.
File names are basenames only — directories are never stored or shown — and a
record without one renders --.
Amounts are compact: the findings count, 0 when the run completed without
findings, × when the run failed, and the short skip reason for a skipped run
(unmapped language, trust gate off, or a silent window). The panel lists every
retained run newest-first with tool, file, amount or skip wording, the bounded
reason, and the capture time.
Run history lives at ~/.efm-bridge/bridge/runs.json: one writer (the plugin
process), atomic writes, the newest 20 records per directory, and the 32 most
recently active directories. The MCP child never writes it.
Trust gate and states
The bridge does not spawn EFM unless the binary resolves and the config file exists. Each failure shape produces one self-identifying line:
efm bridge off: efm-langserver not found ...efm bridge off: efm config not found at <path> ...efm bridge recovering: efm-langserver exited at startup ...efm bridge recovering: no <language> diagnostics within Ns ...
Off and recovering labels are appended once per session state change, so a broken setup does not restate itself on every edit. Children restart with bounded exponential backoff and are terminated on plugin shutdown.
Known limitation: silent-clean
If a lint command inside EFM's own configuration names a binary that is missing, EFM exits zero with no output and publishes an empty diagnostic set. The LSP surface cannot distinguish that from a clean file, so the bridge reports the language as clean. Verify the commands in your EFM configuration independently (for example by running them by hand) when a file you expect to fail stays clean. The bridge cannot see EFM's configured tools.
Security
efm-langserver children receive an allowlisted environment (PATH, HOME,
SHELL, locale, temporary directories, XDG_*, and EFM_BRIDGE_*). Full
inheritance requires both inheritEnv: true and EFM_BRIDGE_TRUSTED_OPTIONS=1.
Diagnostic messages are treated as untrusted text: control characters are
stripped, common credential shapes are redacted, and every digest and payload is
size-bounded.
License
MIT
