Skip to content
7 changes: 7 additions & 0 deletions libs/code/deepagents_code/_env_vars.py
Original file line number Diff line number Diff line change
Expand Up @@ -355,6 +355,13 @@
user-supplied key is always preserved.
"""

PLUGIN_AUTO_UPDATE = "DEEPAGENTS_CODE_PLUGIN_AUTO_UPDATE"
"""Toggle background updates for installed marketplace plugins.

Enabled by default; set to a falsy value (`0`, `false`, `no`, `off`, or empty)
to disable every plugin update regardless of its manifest setting.
"""

PLUGIN_CACHE_DIR = "DEEPAGENTS_CODE_PLUGIN_CACHE_DIR"
"""Override the plugin install/marketplace cache root.

Expand Down
40 changes: 40 additions & 0 deletions libs/code/deepagents_code/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -3254,6 +3254,9 @@ def __init__(
self._session_plugin_ids: frozenset[str] = frozenset()
"""Plugin ids loaded into the current session (startup or last `/reload`)."""

self._plugin_auto_update_started = False
"""Whether this session has started its first-prompt plugin update."""

self._discovered_plugin_ids: frozenset[str] = frozenset()
"""Plugin ids found by the latest background skill discovery."""

Expand Down Expand Up @@ -15376,6 +15379,35 @@ async def _remove_unanswered_offload_seed(
return False
return True

def _start_plugin_auto_update(self) -> None:
"""Start the plugin auto-update worker."""
self.run_worker(
self._auto_update_plugins(),
exclusive=True,
group="plugin-auto-update",
)

async def _auto_update_plugins(self) -> None:
"""Update plugins on disk and notify when `/reload` can apply them."""
from deepagents_code.plugins.discovery import auto_update_plugins

try:
updated = await asyncio.to_thread(auto_update_plugins)
except Exception:
logger.exception("Plugin auto-update failed")
return
if not updated:
return

names = [plugin_id.rsplit("@", 1)[0] for plugin_id in updated]
noun = "Plugin" if len(names) == 1 else "Plugins"
display = f"{len(names)} plugins" if names[2:] else " and ".join(names)
self.notify(
f"{noun} updated: {display}. Run /reload to apply.",
timeout=10,
markup=False,
)

async def _handle_user_message(self, message: str) -> None:
"""Handle a user message to send to the agent.

Expand Down Expand Up @@ -15436,6 +15468,9 @@ async def _send_to_agent(

# Check if agent is available
if self._agent and self._ui_adapter and self._session_state:
if not self._plugin_auto_update_started:
self._plugin_auto_update_started = True
self._start_plugin_auto_update()
self._set_agent_running(True)
# Fresh turn: no model text or tool call is visible yet, so an Esc
# interrupt may still return this prompt to the input.
Expand Down Expand Up @@ -22431,6 +22466,11 @@ def on_close(_result: None) -> None:
PluginManagerScreen(
mcp_server_info=self._mcp_server_info or [],
loaded_plugin_ids=self._session_plugin_ids,
on_auto_update_enabled=(
self._start_plugin_auto_update
if self._plugin_auto_update_started
else None
),
),
on_close,
)
Expand Down
11 changes: 11 additions & 0 deletions libs/code/deepagents_code/config_manifest.py
Original file line number Diff line number Diff line change
Expand Up @@ -1610,6 +1610,17 @@ def _credential_options() -> tuple[ConfigOption, ...]:
kind=OptionKind.STRUCTURED,
toml_keys=("mcp", "disabled_servers"),
),
# --- Plugins --------------------------------------------------------
ConfigOption(
key="plugins.auto_update",
group="Plugins",
summary="Update opted-in plugins after the first prompt; disable globally.",
kind=OptionKind.BOOL,
default=True,
env_var=_env_vars.PLUGIN_AUTO_UPDATE,
Comment thread
johannes117 marked this conversation as resolved.
toml_keys=("plugins", "auto_update"),
empty_env_is_false=True,
),
# --- Updates --------------------------------------------------------
ConfigOption(
key="update.auto_update",
Expand Down
148 changes: 148 additions & 0 deletions libs/code/deepagents_code/plugins/discovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
PluginInstance,
PluginMarketplace,
RepositoryMarketplaceSource,
UrlMarketplaceSource,
split_plugin_id,
)
from deepagents_code.plugins.store import (
Expand All @@ -39,6 +40,7 @@
load_installed_plugins,
load_marketplace_records,
plugin_data_dir,
plugin_mutation_lock,
remove_marketplace_record,
save_marketplace_record,
set_plugin_enabled,
Expand All @@ -48,6 +50,7 @@
logger = logging.getLogger(__name__)


@plugin_mutation_lock()
def add_local_marketplace(path: str | Path) -> PluginMarketplace:
"""Add a local marketplace to dcode state.

Expand All @@ -69,6 +72,7 @@ def add_local_marketplace(path: str | Path) -> PluginMarketplace:
return marketplace


@plugin_mutation_lock()
def add_marketplace_source(raw: str) -> PluginMarketplace:
"""Add a marketplace from a pasted source string.

Expand All @@ -92,6 +96,7 @@ def add_marketplace_source(raw: str) -> PluginMarketplace:
return marketplace


@plugin_mutation_lock()
def remove_marketplace(name: str) -> bool:
"""Remove a marketplace and every plugin installed from it.

Expand Down Expand Up @@ -147,6 +152,7 @@ def _require_installed_plugin(plugin_id: str) -> None:
raise MarketplaceError(msg)


@plugin_mutation_lock()
def set_installed_plugin_enabled(plugin_id: str, *, enabled: bool) -> None:
"""Set the enabled state of an installed plugin.

Expand All @@ -160,6 +166,7 @@ def set_installed_plugin_enabled(plugin_id: str, *, enabled: bool) -> None:
ensure_plugin_data_dir(plugin_id)


@plugin_mutation_lock()
def uninstall_plugin(plugin_id: str) -> None:
"""Uninstall a plugin (disable, clear records, delete orphaned cache).

Expand Down Expand Up @@ -192,6 +199,7 @@ def _resolve_marketplace_and_entry(
return marketplace, entry


@plugin_mutation_lock()
def install_plugin(plugin_id: str) -> PluginInstance:
"""Install a marketplace plugin into the versioned cache and enable it.

Expand Down Expand Up @@ -309,6 +317,146 @@ def _plugin_from_install_path(
return instance, inventory.warnings


def plugin_auto_update_setting() -> tuple[bool, str]:
"""Resolve whether plugin auto-updates are enabled and from which source.

Returns:
The enabled state and its configuration source.
"""
from deepagents_code.config_manifest import (
get_option,
load_config_toml,
resolve_scalar,
)

option = get_option("plugins.auto_update")
if option is None:
return True, "default"
enabled, source = resolve_scalar(option, toml_data=load_config_toml())
return bool(enabled), source


def auto_update_plugins() -> tuple[str, ...]:
"""Stage updated versions of enabled remote marketplace plugins.

Unversioned plugins are skipped so the running session's shared cache is not
replaced.

Returns:
Plugin ids whose installed cache path changed.
""" # noqa: DOC501 # Marketplace errors are isolated per source/plugin.
from filelock import Timeout

from deepagents_code._env_vars import OFFLINE, is_env_truthy

if is_env_truthy(OFFLINE) or not plugin_auto_update_setting()[0]:
return ()

try:
with plugin_mutation_lock(timeout=0):
records = load_marketplace_records(strict=True)
installed = load_installed_plugins(strict=True)
enabled = load_enabled_plugin_ids(strict=True)
updated: list[str] = []

for marketplace_name, record in sorted(records.items()):
match record.source_type:
case "github" | "git":
source = RepositoryMarketplaceSource(
source_type=record.source_type,
value=record.source,
ref=record.ref,
)
case "url":
source = UrlMarketplaceSource(
source_type="url", value=record.source
)
case _:
continue

try:
marketplace, _ = materialize_marketplace_source(source)
if marketplace.name != record.name:
msg = (
f"Marketplace {record.name!r} now declares the name "
f"{marketplace.name!r}"
)
raise MarketplaceError(msg)
except (OSError, RuntimeError, ValueError) as exc:
logger.warning(
"Could not refresh plugin marketplace %s: %s",
marketplace_name,
redact_urls_in_text(str(exc)),
)
continue

for plugin_id, installed_entry in sorted(installed.items()):
if plugin_id not in enabled or installed_entry.version is None:
continue
try:
plugin_name, plugin_marketplace = split_plugin_id(plugin_id)
except ValueError:
continue
if plugin_marketplace != marketplace_name:
continue

try:
entry = next(
(
plugin
for plugin in marketplace.plugins
if plugin.name == plugin_name
),
None,
)
if entry is None:
msg = (
f"Plugin {plugin_id!r} not found in marketplace "
f"{marketplace_name}"
)
raise MarketplaceError(msg)
source_root = materialize_plugin_source(marketplace, entry)
if source_root is None:
msg = f"Plugin {plugin_id} has an unsupported source"
raise MarketplaceError(msg)
manifest, _manifest_path, _warnings = load_manifest(
source_root, fallback_name=entry.name
)
if (
manifest is None
or manifest.name != plugin_name
or not manifest.auto_update
or not manifest.version
or manifest.version == installed_entry.version
):
continue

cache_and_register_plugin(
plugin_id,
source_root,
version=manifest.version,
validate=partial(
_validate_plugin_copy,
plugin_id=plugin_id,
fallback_name=entry.name,
),
)
updated.append(plugin_id)
except (OSError, RuntimeError, ValueError) as exc:
logger.warning(
"Could not update plugin %s: %s",
plugin_id,
redact_urls_in_text(str(exc)),
)

return tuple(updated)
except Timeout:
logger.debug(
"Skipping plugin auto-update because another mutation holds the lock"
)
return ()


def discover_plugins() -> PluginDiscoveryResult:
"""Discover enabled marketplace plugins from their install cache paths.

Expand Down
10 changes: 9 additions & 1 deletion libs/code/deepagents_code/plugins/manifest.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
logger = logging.getLogger(__name__)

_MANIFEST_RELATIVE_PATHS = (
Path("plugin.json"),
Path(".claude-plugin") / "plugin.json",
Path(".codex-plugin") / "plugin.json",
)
Expand Down Expand Up @@ -188,7 +189,7 @@ def _inline_hooks(value: object) -> JsonObject:
def load_manifest(
root: Path, *, fallback_name: str | None = None
) -> tuple[PluginManifest | None, Path | None, tuple[str, ...]]:
"""Load a Claude/Codex plugin manifest.
"""Load an Agent Plugins, Claude, or Codex plugin manifest.

Args:
root: Plugin root directory.
Expand Down Expand Up @@ -232,6 +233,9 @@ def load_manifest(
version_value = raw.get("version")
version = version_value if isinstance(version_value, str) else None
display_name_value = raw.get("displayName")
auto_update_settings = raw.get("extensions")
if isinstance(auto_update_settings, dict):
auto_update_settings = auto_update_settings.get("com.langchain.deepagents.code")
manifest = PluginManifest(
name=name,
version=version,
Expand All @@ -241,6 +245,10 @@ def load_manifest(
display_name=(
display_name_value if isinstance(display_name_value, str) else None
),
auto_update=(
isinstance(auto_update_settings, dict)
and auto_update_settings.get("autoUpdate") is True
),
)
return manifest, manifest_path, tuple(warnings)

Expand Down
2 changes: 2 additions & 0 deletions libs/code/deepagents_code/plugins/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ class PluginManifest:
inline_mcp: Inline MCP servers declared in the manifest.
inline_hooks: Inline hook configuration declared in the manifest, in
`hooks.json` document form.
auto_update: Whether this plugin permits automatic updates.
"""

name: str | None
Expand All @@ -71,6 +72,7 @@ class PluginManifest:
inline_mcp: JsonObject
inline_hooks: JsonObject = field(default_factory=dict)
display_name: str | None = None
auto_update: bool = False


@dataclass(frozen=True, slots=True, kw_only=True)
Expand Down
Loading