Skip to content

feat: support plugins as the unit of opt-in (#378) - #394

Merged
Jason Robert (jrob5756) merged 4 commits into
mainfrom
feature/378-plugin-support
Aug 10, 2026
Merged

feat: support plugins as the unit of opt-in (#378)#394
Jason Robert (jrob5756) merged 4 commits into
mainfrom
feature/378-plugin-support

Conversation

@jrob5756

@jrob5756Jason Robert (jrob5756) commented Aug 10, 2026

Copy link
Copy Markdown
Collaborator

Closes#378.

The problem

Conductor loaded a plugin's skills/ and dropped everything else it shipped. A
plugin's parts are written to work together — its SKILL.md routinely tells the
agent to hand work to prs:code-reviewer, or to call an ado MCP tool. So the
skill loaded, the agent read those instructions, reached for a subagent that was
never registered, and said nothing.

The reproduction from the issue, before:

{"available": ["explore", "task", "general-purpose", "code-review", "research"],
"prs_found": []}

After, on a live run:

{"available": ["explore", "task", "general-purpose", "code-review", "research",
"prs:code-reviewer", "prs:code-simplifier", "prs:comment-analyzer",
"prs:dead-code-finder", "prs:pr-test-analyzer",
"prs:silent-failure-hunter", "prs:type-design-analyzer"],
"prs_found": ["prs:code-reviewer", "prs:pr-test-analyzer", "prs:silent-failure-hunter"]}

What this adds

runtime.plugins and per-agent plugins: opt into the whole unit — skills,
agents/*.agent.md subagents, and declared MCP servers:

runtime:
plugins:
- prs # everything the plugin ships
- name: adomcp: false # skills and agents only

Entries take a string shorthand or an object with per-component switches, all
defaulting on — defaulting one off would recreate the partial load this
fixes. An entry is an installed plugin name or a path, classified by the same
syntactic rule skills: uses. Tri-state inheritance matches skills: exactly.

Also recognises .github/plugin/plugin.json alongside
.claude-plugin/plugin.json, in one shared definition now used by both
plugin resolution and resolve_skill_plugin. Both conventions have always
worked at runtime, so recognising only the latter was Conductor's own gap — on
an ordinary machine it stranded 12 of 13 installed plugins.

Why deconstruct rather than register the plugin root

Both SDKs have a whole-plugin surface (Copilot's plugin_directories,
ClaudeAgentOptions.plugins) and both are all-or-nothing. Registering a root is
roughly one line and forfeits the ability to decline any single component.

Empirically, Copilot's excluded_toolshides an MCP tool from the model but
does not stop the server subprocess launching
— proved with a startup marker
file: the model reported the tool absent while the marker was on disk. For
ado --authentication azcli the credential use happens at process start, not at
tool call, so mcp: false built that way would be a cosmetic filter sold as a
guarantee. Registering the root also inverts the providers against each other:
plugin MCP is unavoidable on Copilot and suppressed on claude-agent-sdk by its
unconditional strict_mcp_config=True.

Deconstructed, each component rides the surface Conductor already uses for it,
so plugin MCP inherits the existing tools: filter, runtime.tool_output
limits and dashboard tool events.

Two findings that were open questions on the issue, both now verified against
live SDKs:

  • custom_agents accepts a qualified <plugin>:<agent> name. A session
    given {"name": "myplug:quokka"} listed it among launchable agent types, so
    namespacing survives deconstruction and two plugins shipping a review agent
    do not collide. This was the one result that could have sunk the approach.
  • ClaudeAgentOptions.agents exists, so subagents register inline there.
    But registering a plugin root is still the only way to reach its skills on
    that SDK, and the docs describe that option as providing "custom commands,
    agents, skills, and hooks" — a filter for skills, none for the rest.

Provider support

ProviderPlugins
copilotYes — each component registered individually
claude-agent-sdkYes, with one declared carve-out
claude, hermes, acaNo — plugins: rejected at validate and run time

Unlike skills there is no eager-injection fallback: text in a prompt cannot
become a subagent or an MCP server, so CAPABILITIES.plugins=False is a real
refusal rather than a degraded mode.

The carve-out: on claude-agent-sdk, agents: false alongside skills: true
is refused, because reaching the skills requires registering the root, which
carries every subagent with it. Its hooks/ warning likewise says exposed to
the CLI
rather than not loaded, which would be false. Both branch on
AgentProvider.skills_require_plugin_root — a description of the mechanism
rather than a provider-name check. The identical config works on copilot.

Silent-failure guards

The whole point of the feature is that a component never goes missing quietly,
so every refusal is enforced twice — in config/validator.py and again in
AgentExecutor — because conductor run never invokes the static validator.
Name collisions (two plugins shipping one skill name; an MCP server name claimed
twice, or claimed by both a plugin and runtime.mcp_servers) are refused
rather than resolved by precedence, in the provider merge helpers as well as the
validator. hooks/ and commands/ are dropped loudly via a validate warning.

conductor validate now prints what each plugin actually contributes:

Plugins: 2 enabled
• prs — 3 skill(s), 7 agent(s), 0 MCP server(s) — .../installed-plugins/team/prs
agents: prs:code-reviewer, prs:code-simplifier, prs:comment-analyzer, ...
• ado — 0 skill(s), 1 agent(s), 1 MCP server(s) — .../installed-plugins/team/ado
mcp: ado
disabled by this workflow: mcp

Breaking change

Removes plugins from skill_discovery.sources. It scanned a plugin's
skills/ and left the rest behind, which is this bug rather than a feature with
a gap — and it was wrong more often than it looked: of 13 installed plugins, 3
loaded their instructions without the subagents those instructions dispatch to,
and the 3 most plugin-like (MCP + subagent toolkits with no skills/) were
never discovered at all. personal and project are unchanged. Replace with
runtime.plugins, which brings the whole unit and, unlike a scan, reproduces on
another machine.

Layout

New src/conductor/plugins/ (manifest, agent parsing, resolution, errors), plus
src/conductor/frontmatter.py — a shared --- splitter extracted so SKILL.md
and *.agent.md use one parser rather than two that can drift.

tests/test_plugins/ builds every plugin tree on disk and takes home as a
fixture, so no test reads the developer's real ~. Its executor- and
engine-integration suites are load-bearing: a plugin's subagents and MCP servers
have no fallback delivery path, so a negative assertion could not tell a working
path from a dropped one.

Verification

  • ruff check / ruff format / ty check clean
  • 5276 tests pass with the optional claude-agent-sdk installed (5107 without)
  • All 30 examples validate, including the new examples/plugins.yaml — which
    ships its own examples/demo-plugin/ so it validates anywhere rather than
    depending on what the runner happens to have installed
  • Live end-to-end run against the real Copilot runtime, reproducing the issue

Review

A code-review pass found 6 issues, all fixed and regression-tested — including
three latent silent-drop paths that would have ironically reintroduced the exact
bug class this feature removes.

Follow-up

Git-backed plugin sources (plugin_sources, lockfile, cache, conductor plugin
verbs) are filed separately as #380, deliberately sequenced second: name and
path resolution has to exist regardless, and git should feed it rather than
duplicate it.


Review round

A seven-agent code review ran over this branch. Its findings clustered in one
place — components that reached the provider incompletely, and refusals that
existed only in the half of the codebase conductor run skips. Fixed in
6c75670, polished in 369699d.

The most serious finding was that plugin MCP servers bypassed Conductor's
resolution pipeline entirely: a plugin's stdio server was handed a literal
${VAR} and its http server attached with no Authorization header. Verified
against a real installed plugin declaring an oauth block. The server loaded
and did not work — issue #378's own failure mode, one layer down. Both server
sources now share mcp_auth.resolve_mcp_servers so they cannot drift, and four
documents that claimed plugin MCP inherits a tools: filter via MCPServerDef
now say what actually happens.

Also fixed: _translate_mcp_servers silently dropped every key it did not
recognise (an oauth block vanished; a disabled: true server would have been
launched); a discovered skill could shadow a named plugin's skill, inverting
the resolution-versus-discovery distinction the feature rests on;
_reject_unsupported_plugins failed open for a provider with no capability
declaration; agents: false still parsed every *.agent.md, so the documented
opt-out failed over the files it opted out of; and Path satisfying .name let
duck-typing reclassify a path entry as an installed name and resolve a different
plugin on disk.

Two refusals ran in only one of their two required places, which matters because
conductor run never calls the static validator: the claude-agent-sdk
agents: false carve-out, and the MCP name-collision check.

Test gap worth calling out. The engine test exercised the single-provider
branch, but conductor run always uses the registry branch — its
workflow_plugins= argument could be deleted with all 5107 tests still green.
Mutation-verified before and after; it is now parametrized over both modes.
capabilities.plugins=True is also enforced at import rather than trusted, so a
provider cannot declare plugin support while dropping a delivery channel.

Every fix is mutation-verified where a test was the point.

Verification

  • 10/10 CI checks green, including Windows
  • 5308 tests with the optional claude-agent-sdk installed; 5134 without
  • ruff / ruff format / ty check clean
  • All 30 examples validate
  • Live end-to-end runs against the real Copilot runtime, confirming all three
    components (skills, subagents, MCP) reach the SDK

Jason Robertand others added 4 commits August 7, 2026 18:29
Conductor loaded a plugin's `skills/` and dropped everything else it
shipped. A plugin's parts are written to work together — its `SKILL.md`
routinely tells the agent to hand work to `prs:code-reviewer`, or to call
an `ado` MCP tool — so the skill loaded, the agent read those
instructions, reached for a subagent that was never registered, and said
nothing.
Add `runtime.plugins` and per-agent `plugins:`, which opt into the whole
unit: skills, `agents/*.agent.md` subagents, and declared MCP servers.
Entries take a string shorthand or an object with per-component switches
(`skills` / `agents` / `mcp`), all defaulting on — defaulting one off
would recreate the partial load the feature exists to fix. An entry is an
installed plugin name or a path, classified by the same syntactic rule
`skills:` uses; an uninstalled name errors naming where it looked, and an
ambiguous one errors rather than picking a winner.
Also recognise `.github/plugin/plugin.json` alongside
`.claude-plugin/plugin.json`, in one shared definition used by both
plugin resolution and `resolve_skill_plugin`. Both conventions have
always worked at runtime, so recognising only the latter was Conductor's
own gap — on an ordinary machine it stranded 12 of 13 installed plugins.
Conductor deconstructs a plugin rather than handing its root to the SDK.
Both SDKs' whole-plugin surfaces are all-or-nothing, and on Copilot
`excluded_tools` hides an MCP tool from the model but does not stop the
server subprocess launching with the user's credentials — verified with a
startup marker file — so `mcp: false` built that way would be a guarantee
that isn't one. Deconstructed, plugin MCP servers also inherit the
existing `tools:` filters, `runtime.tool_output` limits, and dashboard
tool events. `custom_agents` accepts the qualified `<plugin>:<agent>`
name (verified against a live session), so namespacing survives.
Supported on `copilot` and `claude-agent-sdk`; `claude`, `hermes` and
`aca` reject `plugins:`, since injecting text into a prompt cannot
produce a subagent or an MCP server. On `claude-agent-sdk`, reaching a
plugin's skills requires registering its root, which also contributes
every subagent it ships — so `agents: false` alongside `skills: true` is
refused there rather than silently granting more than the YAML declared.
Name collisions are refused rather than resolved by precedence, in the
provider merge helpers as well as the validator, because `conductor run`
never invokes the static validator and a dropped server or skill would be
exactly the silent omission this feature removes.
BREAKING: remove `plugins` from `skill_discovery.sources`. It scanned a
plugin's `skills/` and left the rest behind, which is this bug rather
than a feature with a gap; it was also wrong more often than it looked —
of 13 installed plugins, 3 loaded instructions without the subagents
those instructions dispatch to, and the 3 most plugin-like were never
discovered at all. Use `runtime.plugins`, which reproduces on another
machine.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Seven-agent review of the plugin implementation. The findings cluster in
one place: components that reached the provider incompletely, or refusals
that existed only in the half of the codebase `conductor run` skips.
Plugin MCP servers bypassed Conductor's resolution pipeline entirely.
A plugin's stdio server was handed a literal `${VAR}` and its http server
attached with no `Authorization` header — verified against a real
installed plugin declaring an `oauth` block. The server loaded and did
not work, which is issue #378's own failure mode one layer down. Both
sources now share `mcp_auth.resolve_mcp_servers`, so they cannot drift.
Four places claimed plugin MCP inherits a `tools:` filter via
`MCPServerDef`; it does not, and they now say what actually happens.
`_translate_mcp_servers` silently dropped every key it did not recognise.
It was written for `MCPServerDef`'s closed field set and is now fed
arbitrary third-party JSON, so an `oauth` block vanished and a
`disabled: true` server would have been launched. It now fails closed,
the same standard the narrowing `tools:` filter is already held to.
A discovered skill could shadow a *named* plugin's skill, inverting the
resolution-versus-discovery distinction the feature rests on — and the
warning called a scanned root "declared". Precedence now follows how much
the author said, and the message names the real source.
Refusals that ran in only one of two places, because `conductor run`
never calls the static validator: the claude-agent-sdk `agents: false`
carve-out, and the MCP name-collision check on both providers. A plugin
component that a name clash would have made unreachable was silently
dropped instead of refused.
Also: `_reject_unsupported_plugins` failed *open* for a provider with no
capability declaration; a provider could declare `plugins=True` while
lacking a skill surface, dropping plugin skills with the run reporting
success; `getattr(..., "skills_require_plugin_root", False)` would have
turned a rename into a silently disabled guard; `agents: false` still
parsed every `*.agent.md`, so the documented opt-out failed over the
files it opted out of; two entries resolving to one root discarded the
second's switches in the permissive direction; and `Path` satisfying
`.name` let duck-typing reclassify a path entry as an installed name and
resolve a different plugin. The three new dataclasses now enforce their
documented invariants, as their `skills/` counterparts already did.
A plugin's `tools:` frontmatter is written in its authoring CLI's
vocabulary, so forwarding it to claude-agent-sdk handed a subagent no
valid identifier; `tools: []` there left registered subagents with no
dispatch tool. Both refused rather than silently under-delivering.
Tests: the engine test exercised the single-provider branch, but
`conductor run` always uses the registry branch — its `workflow_plugins=`
could be deleted with all 5107 tests green. Now parametrized over both,
and mutation-verified. Adds coverage for claude-agent-sdk options
delivery, the Copilot resume path (which also guards the pre-existing
skills feature), the cache key, permission-denied trees, and the
`.github/plugin` convention end-to-end. `capabilities.plugins=True` is
now enforced at import rather than trusted, which gives the previously
unused `uses_native_plugins` a consumer.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
… pass
Final polish pass over the plugin implementation. No behaviour change
outside two verbose-output lines, both of which previously contradicted
what the surrounding code claimed.
`_merge_skills_and_plugin_skills` was called twice in `execute()` — once
to build `skill_directories` and again to count for the summary. It is
not idempotent: the second call re-fired the shadowing warning, so a
plugin skill losing to a declared one was reported twice. The result is
now computed once. The same summary counted by skill *name*, so a plugin
skill that had just been reported as "not enabled" was counted as
forwarded on the next line; counting by directory says what actually
went.
The MCP name-collision refusal was written out character-for-character in
both providers, differing only in line wrapping — the exact drift risk
`describe_dropped_components` was extracted to prevent, one layer over.
Now `providers/base.py::refuse_mcp_server_clashes`, with the "refuse
rather than resolve by precedence" argument stated once.
`plugins/registry.py` already imported `is_path_entry` from
`skills/registry.py` to decide *whether* an entry is a path, then
re-implemented deciding *which* path it is — including a duplicated
"normpath, not resolve()" comment. Both halves now share
`normalize_entry_path`, so a workflow naming one directory under both
`skills:` and `plugins:` cannot reach two different places.
Also collapses four dead empty-dict branches in `_merge_mcp_servers`
(`_stamp_cwd({})` is already `{}`, and no key can be overwritten once
clashes are refused), replaces a `setdefault`-as-clash-check with the
`get`-based form its two sibling functions already use, unpacks a
positional tuple access, and moves a comment onto the line it describes.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
`os.geteuid` does not exist on Windows, and the `skipif` expression is
evaluated at import — so it raised `AttributeError` during collection and
took the whole module with it, failing the Windows install-script job.
Uses the idiom the repo already had in `tests/test_skills/test_path_entries.py`
(`hasattr(os, "geteuid") and ...`), plus an explicit `win32` skip: `chmod`
does not restrict reads there, so the tests would not be meaningful even
once they collect.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@jrob5756
Jason Robert (jrob5756) marked this pull request as ready for review August 10, 2026 15:11
@jrob5756
Jason Robert (jrob5756) merged commit 5b620d4 into mainAug 10, 2026
10 checks passed
@jrob5756
Jason Robert (jrob5756) deleted the feature/378-plugin-support branch August 10, 2026 15:12
Sign up for freeto 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.

Support plugins as the unit of opt-in, so skills keep their subagents

1 participant

@jrob5756