Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion cecli/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from packaging import version

__version__ = "0.100.6.dev"
__version__ = "0.100.8.dev"
safe_version = __version__

try:
Expand Down
47 changes: 30 additions & 17 deletions cecli/coders/agent_coder.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ def __init__(self, *args, **kwargs):
if kwargs.get("uuid", None):
self.uuid = kwargs.get("uuid")

self.start_up_errors = []
self.recently_removed = {}
self.tool_usage_history = []
self.loaded_custom_tools = []
Expand Down Expand Up @@ -109,19 +110,26 @@ def __init__(self, *args, **kwargs):

def post_init(self):
super().post_init()
# Populate per-instance tool and server filters from config
self.registered_tools["included"] = set(
map(str.lower, self.agent_config.get("tools_includelist", []))
)
self.registered_tools["excluded"] = set(
map(str.lower, self.agent_config.get("tools_excludelist", []))
)
self.registered_servers["included"] = set(
map(str.lower, self.agent_config.get("servers_includelist", []))
)
self.registered_servers["excluded"] = set(
map(str.lower, self.agent_config.get("servers_excludelist", []))
)

if not self._inherited_tools:
# Populate per-instance tool and server filters from config
self.registered_tools["included"] = set(
map(str.lower, self.agent_config.get("tools_includelist", []))
)
self.registered_tools["excluded"] = set(
map(str.lower, self.agent_config.get("tools_excludelist", []))
)
self.registered_servers["included"] = set(
map(str.lower, self.agent_config.get("servers_includelist", []))
)
self.registered_servers["excluded"] = set(
map(str.lower, self.agent_config.get("servers_excludelist", []))
)

for err in self.start_up_errors:
self.io.tool_warning(err)

self.start_up_errors = []

def _setup_agent(self):
os.makedirs(".cecli/temp", exist_ok=True)
Expand All @@ -143,7 +151,7 @@ def _get_agent_config(self):
try:
config = json.loads(self.args.agent_config)
except (json.JSONDecodeError, TypeError) as e:
self.io.tool_warning(f"Failed to parse agent-config JSON: {e}")
self.start_up_errors.append(f"Failed to parse agent-config JSON: {e}")
return {}

config["large_file_token_threshold"] = nested.getter(
Expand Down Expand Up @@ -1531,15 +1539,20 @@ def get_child_agent_states(self):
try:
service = AgentService.get_instance(self)
children = service.get_children(self)

if not children:
return None

# Filter to non-independent children only
dependent_children = [info for info in children if not info.independent]

if not dependent_children:
return None

result = '<context name="sub_agent_states" from="agent">\n'
result += "## Active Sub-Agent States\n\n"
result += f"Found {len(children)} active child sub-agent(s):\n\n"
result += f"Found {len(dependent_children)} active child sub-agent(s):\n\n"

for info in children:
for info in dependent_children:
result += f"**{info.name}**:\n"
result += f" - UUID: `{info.coder.uuid}`\n"
result += f" - Status: {info.status.value}\n"
Expand Down
12 changes: 12 additions & 0 deletions cecli/coders/base_coder.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import asyncio
import base64
import copy
import hashlib
import json
import locale
Expand Down Expand Up @@ -314,6 +315,8 @@ async def create(
ignore_mentions=from_coder.ignore_mentions,
file_watcher=from_coder.file_watcher,
mcp_manager=from_coder.mcp_manager,
registered_tools=copy.deepcopy(from_coder.registered_tools),
registered_servers=copy.deepcopy(from_coder.registered_servers),
uuid=from_coder.uuid,
parent_uuid=from_coder.parent_uuid,
repo=from_coder.repo,
Expand Down Expand Up @@ -416,6 +419,8 @@ def __init__(
repomap_in_memory=False,
linear_output=False,
security_config=None,
registered_tools=None,
registered_servers=None,
uuid: str = "",
parent_uuid: str = "",
):
Expand All @@ -427,6 +432,13 @@ def __init__(
# Each contains "included" and "excluded" sets that filter from the global singletons
self.registered_tools = {"included": set(), "excluded": set()}
self.registered_servers = {"included": set(), "excluded": set()}
self._inherited_tools = False

if registered_tools is not None or registered_servers is not None:
self.registered_tools = registered_tools
self.registered_servers = registered_servers
self._inherited_tools = True

self.interrupt_event = ThreadSafeEvent()
self.uuid = str(generate_unique_id())
self.reflected_message = None
Expand Down
6 changes: 5 additions & 1 deletion cecli/commands/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
from .context_management import ContextManagementCommand
from .copy import CopyCommand
from .copy_context import CopyContextCommand
from .core import Commands, SwitchCoderSignal
from .core import Commands, ReloadProgramSignal, SwitchCoderSignal
from .diff import DiffCommand
from .drop import DropCommand
from .editor import EditCommand, EditorCommand
Expand All @@ -32,6 +32,7 @@
from .help import HelpCommand
from .history_search import HistorySearchCommand
from .hooks import HooksCommand
from .hot_reload import HotReloadCommand
from .include_skill import IncludeSkillCommand
from .lint import LintCommand
from .list_sessions import ListSessionsCommand
Expand Down Expand Up @@ -116,6 +117,7 @@
CommandRegistry.register(HelpCommand)
CommandRegistry.register(HistorySearchCommand)
CommandRegistry.register(HooksCommand)
CommandRegistry.register(HotReloadCommand)
CommandRegistry.register(ReapAgentCommand)
CommandRegistry.register(SpawnAgentCommand)
CommandRegistry.register(SwitchAgentCommand)
Expand Down Expand Up @@ -197,6 +199,7 @@
"HelpCommand",
"HistorySearchCommand",
"HooksCommand",
"HotReloadCommand",
"IncludeSkillCommand",
"ReapAgentCommand",
"SpawnAgentCommand",
Expand All @@ -223,6 +226,7 @@
"ReadOnlyCommand",
"ReadOnlyStubCommand",
"ReasoningEffortCommand",
"ReloadProgramSignal",
"RemoveHookCommand",
"RemoveMcpCommand",
"RemoveSkillCommand",
Expand Down
19 changes: 19 additions & 0 deletions cecli/commands/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,25 @@ def __init__(self, placeholder=None, **kwargs):
super().__init__()


class ReloadProgramSignal(BaseException):
"""
Signal to reload the entire program configuration.

This is NOT an error - it's a control flow signal used to trigger
a full program reload, re-parsing config files and re-initializing
all components. Useful for hot-reloading when configuration files
change.

Note: Inherits from BaseException (like KeyboardInterrupt and SystemExit)
to avoid being caught by generic `except Exception` handlers.
"""

def __init__(self, message="Reloading program configuration...", **kwargs):
self.kwargs = kwargs
self.message = message
super().__init__(self.message)


class Commands:
scraper = None

Expand Down
39 changes: 39 additions & 0 deletions cecli/commands/hot_reload.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
from typing import List

from cecli.commands.core import ReloadProgramSignal
from cecli.commands.utils.base_command import BaseCommand


class HotReloadCommand(BaseCommand):
NORM_NAME = "hot-reload"
DESCRIPTION = "Hot-reload all configuration and restart the program"
show_completion_notification = False

@classmethod
async def execute(cls, io, coder, args, **kwargs):
"""Raise ReloadProgramSignal to trigger a full program hot-reload.

Passes the current coder as from_coder so the new coder
preserves its UUID, edit_format, and other state across
the reload cycle.
"""
io.tool_output("Hot-reloading program configuration...")
raise ReloadProgramSignal(
"User requested configuration reload",
from_coder=coder,
)

@classmethod
def get_completions(cls, io, coder, args) -> List[str]:
"""Get completion options for hot-reload command."""
return []

@classmethod
def get_help(cls) -> str:
"""Get help text for the hot-reload command."""
help_text = super().get_help()
help_text += "\nUsage:\n"
help_text += " /hot-reload # Hot-reload all configuration files and restart\n"
help_text += "\nThis will re-read config files, reinitialize the connection,"
help_text += " and restart the chat session with the updated configuration."
return help_text
28 changes: 27 additions & 1 deletion cecli/commands/load_mcp.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
from typing import List

from cecli.commands.utils.base_command import BaseCommand
from cecli.commands.utils.helpers import format_command_result
from cecli.commands.utils.helpers import (
format_command_result,
iter_all_coders,
update_server_registration,
)


class LoadMcpCommand(BaseCommand):
Expand Down Expand Up @@ -51,6 +55,20 @@ async def execute(cls, io, coder, args, **kwargs):
if not servers_to_load and results:
return format_command_result(io, cls.NORM_NAME, "", "\n".join(results))

# Before connecting any new server, convert coders with empty included sets
# to explicit include lists of all currently connected MCP servers.
# This moves them from "implicitly include all" to explicit state-machine
# management, preventing the new server from being implicitly available
# to all coders.
connected_names = {s.name for s in coder.mcp_manager.connected_servers}
if connected_names:
for c in iter_all_coders(coder):
if not c.registered_servers["included"]:
included = set(connected_names) - c.registered_servers["excluded"]
if c.edit_format in ("agent", "subagent"):
included.add("Local") # "local" is always available
c.registered_servers["included"] = included

# Process connections with interrupt support
for server in servers_to_load:
server_name = server.name
Expand All @@ -67,6 +85,14 @@ async def execute(cls, io, coder, args, **kwargs):
continue

if did_connect:
# Force-include on the primary (active) coder
update_server_registration(coder, server_name, "include", force=True)

# Safe-exclude on all other coders (respects existing inclusions)
for other_coder in iter_all_coders(coder):
if other_coder is coder:
continue
update_server_registration(other_coder, server_name, "exclude", force=False)
results.append(f"Loaded server: {server_name}")
else:
results.append(f"Unable to load server: {server_name}")
Expand Down
47 changes: 33 additions & 14 deletions cecli/commands/remove_mcp.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
from typing import List

from cecli.commands.utils.base_command import BaseCommand
from cecli.commands.utils.helpers import format_command_result
from cecli.commands.utils.helpers import (
format_command_result,
is_server_globally_excluded,
update_server_registration,
)


class RemoveMcpCommand(BaseCommand):
Expand Down Expand Up @@ -44,22 +48,37 @@ async def execute(cls, io, coder, args, **kwargs):
for item in servers_to_disconnect:
server_name = item.name if hasattr(item, "name") else item

coder.interrupt_event.clear()

was_disconnected, interrupted = await coder.coroutines.interruptible(
coder.mcp_manager.disconnect_server(server_name),
coder.interrupt_event,
)

if interrupted:
io.tool_warning(f"MCP disconnection interrupted: {server_name}")
results.append(f"Interrupted: {server_name}")
# Never remove the "local" server
if server_name == "Local":
results.append("Cannot remove 'Local' server")
continue

if was_disconnected:
results.append(f"Removed server: {server_name}")
# Force-exclude on the primary (active) coder
update_server_registration(coder, server_name, "exclude", force=True)
# Check if all coders in the hierarchy have this server excluded
all_excluded = is_server_globally_excluded(coder, server_name)

if all_excluded:
was_disconnected, interrupted = await coder.coroutines.interruptible(
coder.mcp_manager.disconnect_server(server_name),
coder.interrupt_event,
)

if interrupted:
io.tool_warning(f"MCP disconnection interrupted: {server_name}")
results.append(f"Interrupted: {server_name}")
continue

if was_disconnected:
results.append(f"Removed server: {server_name}")
else:
results.append(f"Unable to remove server: {server_name}")
else:
results.append(f"Unable to remove server: {server_name}")
io.tool_output(
f"Server '{server_name}' still in use by other coders, "
f"keeping connection active."
)
results.append(f"Removed from active coder, still active for others: {server_name}")

io.tool_output("\n".join(results))

Expand Down
4 changes: 3 additions & 1 deletion cecli/commands/spawn_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,9 @@ async def execute(cls, io, coder, args, **kwargs):

try:
agent_service = AgentService.get_instance(coder)
new_coder, info = await agent_service.spawn(name, prompt, parent=coder, auto_reap=False)
new_coder, info = await agent_service.spawn(
name, prompt, parent=coder, auto_reap=False, independent=True
)

# Set the newly spawned agent as the foreground agent
agent_service.foreground_uuid = info.coder.uuid
Expand Down
Loading
Loading