Skip to content

Commit 44a38fc

Browse files
author
Your Name
committed
Merge branch 'main' of github.com-personal:cecli-dev/cecli into cli-37-mcp-connection-keepalive
# Please enter a commit message to explain why this merge is necessary, # especially if it merges an updated upstream into a topic branch. # # Lines starting with '#' will be ignored, and an empty message aborts # the commit.
2 parents 9a35546 + 01ae3e4 commit 44a38fc

26 files changed

Lines changed: 743 additions & 83 deletions

cecli/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
from packaging import version
22

3-
__version__ = "0.100.6.dev"
3+
__version__ = "0.100.8.dev"
44
safe_version = __version__
55

66
try:

cecli/coders/agent_coder.py

Lines changed: 30 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ def __init__(self, *args, **kwargs):
5151
if kwargs.get("uuid", None):
5252
self.uuid = kwargs.get("uuid")
5353

54+
self.start_up_errors = []
5455
self.recently_removed = {}
5556
self.tool_usage_history = []
5657
self.loaded_custom_tools = []
@@ -109,19 +110,26 @@ def __init__(self, *args, **kwargs):
109110

110111
def post_init(self):
111112
super().post_init()
112-
# Populate per-instance tool and server filters from config
113-
self.registered_tools["included"] = set(
114-
map(str.lower, self.agent_config.get("tools_includelist", []))
115-
)
116-
self.registered_tools["excluded"] = set(
117-
map(str.lower, self.agent_config.get("tools_excludelist", []))
118-
)
119-
self.registered_servers["included"] = set(
120-
map(str.lower, self.agent_config.get("servers_includelist", []))
121-
)
122-
self.registered_servers["excluded"] = set(
123-
map(str.lower, self.agent_config.get("servers_excludelist", []))
124-
)
113+
114+
if not self._inherited_tools:
115+
# Populate per-instance tool and server filters from config
116+
self.registered_tools["included"] = set(
117+
map(str.lower, self.agent_config.get("tools_includelist", []))
118+
)
119+
self.registered_tools["excluded"] = set(
120+
map(str.lower, self.agent_config.get("tools_excludelist", []))
121+
)
122+
self.registered_servers["included"] = set(
123+
map(str.lower, self.agent_config.get("servers_includelist", []))
124+
)
125+
self.registered_servers["excluded"] = set(
126+
map(str.lower, self.agent_config.get("servers_excludelist", []))
127+
)
128+
129+
for err in self.start_up_errors:
130+
self.io.tool_warning(err)
131+
132+
self.start_up_errors = []
125133

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

149157
config["large_file_token_threshold"] = nested.getter(
@@ -1531,15 +1539,20 @@ def get_child_agent_states(self):
15311539
try:
15321540
service = AgentService.get_instance(self)
15331541
children = service.get_children(self)
1534-
15351542
if not children:
15361543
return None
15371544

1545+
# Filter to non-independent children only
1546+
dependent_children = [info for info in children if not info.independent]
1547+
1548+
if not dependent_children:
1549+
return None
1550+
15381551
result = '<context name="sub_agent_states" from="agent">\n'
15391552
result += "## Active Sub-Agent States\n\n"
1540-
result += f"Found {len(children)} active child sub-agent(s):\n\n"
1553+
result += f"Found {len(dependent_children)} active child sub-agent(s):\n\n"
15411554

1542-
for info in children:
1555+
for info in dependent_children:
15431556
result += f"**{info.name}**:\n"
15441557
result += f" - UUID: `{info.coder.uuid}`\n"
15451558
result += f" - Status: {info.status.value}\n"

cecli/coders/base_coder.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import asyncio
44
import base64
5+
import copy
56
import hashlib
67
import json
78
import locale
@@ -314,6 +315,8 @@ async def create(
314315
ignore_mentions=from_coder.ignore_mentions,
315316
file_watcher=from_coder.file_watcher,
316317
mcp_manager=from_coder.mcp_manager,
318+
registered_tools=copy.deepcopy(from_coder.registered_tools),
319+
registered_servers=copy.deepcopy(from_coder.registered_servers),
317320
uuid=from_coder.uuid,
318321
parent_uuid=from_coder.parent_uuid,
319322
repo=from_coder.repo,
@@ -416,6 +419,8 @@ def __init__(
416419
repomap_in_memory=False,
417420
linear_output=False,
418421
security_config=None,
422+
registered_tools=None,
423+
registered_servers=None,
419424
uuid: str = "",
420425
parent_uuid: str = "",
421426
):
@@ -427,6 +432,13 @@ def __init__(
427432
# Each contains "included" and "excluded" sets that filter from the global singletons
428433
self.registered_tools = {"included": set(), "excluded": set()}
429434
self.registered_servers = {"included": set(), "excluded": set()}
435+
self._inherited_tools = False
436+
437+
if registered_tools is not None or registered_servers is not None:
438+
self.registered_tools = registered_tools
439+
self.registered_servers = registered_servers
440+
self._inherited_tools = True
441+
430442
self.interrupt_event = ThreadSafeEvent()
431443
self.uuid = str(generate_unique_id())
432444
self.reflected_message = None

cecli/commands/__init__.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
from .context_management import ContextManagementCommand
2121
from .copy import CopyCommand
2222
from .copy_context import CopyContextCommand
23-
from .core import Commands, SwitchCoderSignal
23+
from .core import Commands, ReloadProgramSignal, SwitchCoderSignal
2424
from .diff import DiffCommand
2525
from .drop import DropCommand
2626
from .editor import EditCommand, EditorCommand
@@ -32,6 +32,7 @@
3232
from .help import HelpCommand
3333
from .history_search import HistorySearchCommand
3434
from .hooks import HooksCommand
35+
from .hot_reload import HotReloadCommand
3536
from .include_skill import IncludeSkillCommand
3637
from .lint import LintCommand
3738
from .list_sessions import ListSessionsCommand
@@ -116,6 +117,7 @@
116117
CommandRegistry.register(HelpCommand)
117118
CommandRegistry.register(HistorySearchCommand)
118119
CommandRegistry.register(HooksCommand)
120+
CommandRegistry.register(HotReloadCommand)
119121
CommandRegistry.register(ReapAgentCommand)
120122
CommandRegistry.register(SpawnAgentCommand)
121123
CommandRegistry.register(SwitchAgentCommand)
@@ -197,6 +199,7 @@
197199
"HelpCommand",
198200
"HistorySearchCommand",
199201
"HooksCommand",
202+
"HotReloadCommand",
200203
"IncludeSkillCommand",
201204
"ReapAgentCommand",
202205
"SpawnAgentCommand",
@@ -223,6 +226,7 @@
223226
"ReadOnlyCommand",
224227
"ReadOnlyStubCommand",
225228
"ReasoningEffortCommand",
229+
"ReloadProgramSignal",
226230
"RemoveHookCommand",
227231
"RemoveMcpCommand",
228232
"RemoveSkillCommand",

cecli/commands/core.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,25 @@ def __init__(self, placeholder=None, **kwargs):
3333
super().__init__()
3434

3535

36+
class ReloadProgramSignal(BaseException):
37+
"""
38+
Signal to reload the entire program configuration.
39+
40+
This is NOT an error - it's a control flow signal used to trigger
41+
a full program reload, re-parsing config files and re-initializing
42+
all components. Useful for hot-reloading when configuration files
43+
change.
44+
45+
Note: Inherits from BaseException (like KeyboardInterrupt and SystemExit)
46+
to avoid being caught by generic `except Exception` handlers.
47+
"""
48+
49+
def __init__(self, message="Reloading program configuration...", **kwargs):
50+
self.kwargs = kwargs
51+
self.message = message
52+
super().__init__(self.message)
53+
54+
3655
class Commands:
3756
scraper = None
3857

cecli/commands/hot_reload.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
from typing import List
2+
3+
from cecli.commands.core import ReloadProgramSignal
4+
from cecli.commands.utils.base_command import BaseCommand
5+
6+
7+
class HotReloadCommand(BaseCommand):
8+
NORM_NAME = "hot-reload"
9+
DESCRIPTION = "Hot-reload all configuration and restart the program"
10+
show_completion_notification = False
11+
12+
@classmethod
13+
async def execute(cls, io, coder, args, **kwargs):
14+
"""Raise ReloadProgramSignal to trigger a full program hot-reload.
15+
16+
Passes the current coder as from_coder so the new coder
17+
preserves its UUID, edit_format, and other state across
18+
the reload cycle.
19+
"""
20+
io.tool_output("Hot-reloading program configuration...")
21+
raise ReloadProgramSignal(
22+
"User requested configuration reload",
23+
from_coder=coder,
24+
)
25+
26+
@classmethod
27+
def get_completions(cls, io, coder, args) -> List[str]:
28+
"""Get completion options for hot-reload command."""
29+
return []
30+
31+
@classmethod
32+
def get_help(cls) -> str:
33+
"""Get help text for the hot-reload command."""
34+
help_text = super().get_help()
35+
help_text += "\nUsage:\n"
36+
help_text += " /hot-reload # Hot-reload all configuration files and restart\n"
37+
help_text += "\nThis will re-read config files, reinitialize the connection,"
38+
help_text += " and restart the chat session with the updated configuration."
39+
return help_text

cecli/commands/load_mcp.py

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
from typing import List
22

33
from cecli.commands.utils.base_command import BaseCommand
4-
from cecli.commands.utils.helpers import format_command_result
4+
from cecli.commands.utils.helpers import (
5+
format_command_result,
6+
iter_all_coders,
7+
update_server_registration,
8+
)
59

610

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

58+
# Before connecting any new server, convert coders with empty included sets
59+
# to explicit include lists of all currently connected MCP servers.
60+
# This moves them from "implicitly include all" to explicit state-machine
61+
# management, preventing the new server from being implicitly available
62+
# to all coders.
63+
connected_names = {s.name for s in coder.mcp_manager.connected_servers}
64+
if connected_names:
65+
for c in iter_all_coders(coder):
66+
if not c.registered_servers["included"]:
67+
included = set(connected_names) - c.registered_servers["excluded"]
68+
if c.edit_format in ("agent", "subagent"):
69+
included.add("Local") # "local" is always available
70+
c.registered_servers["included"] = included
71+
5472
# Process connections with interrupt support
5573
for server in servers_to_load:
5674
server_name = server.name
@@ -67,6 +85,14 @@ async def execute(cls, io, coder, args, **kwargs):
6785
continue
6886

6987
if did_connect:
88+
# Force-include on the primary (active) coder
89+
update_server_registration(coder, server_name, "include", force=True)
90+
91+
# Safe-exclude on all other coders (respects existing inclusions)
92+
for other_coder in iter_all_coders(coder):
93+
if other_coder is coder:
94+
continue
95+
update_server_registration(other_coder, server_name, "exclude", force=False)
7096
results.append(f"Loaded server: {server_name}")
7197
else:
7298
results.append(f"Unable to load server: {server_name}")

cecli/commands/remove_mcp.py

Lines changed: 33 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
from typing import List
22

33
from cecli.commands.utils.base_command import BaseCommand
4-
from cecli.commands.utils.helpers import format_command_result
4+
from cecli.commands.utils.helpers import (
5+
format_command_result,
6+
is_server_globally_excluded,
7+
update_server_registration,
8+
)
59

610

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

47-
coder.interrupt_event.clear()
48-
49-
was_disconnected, interrupted = await coder.coroutines.interruptible(
50-
coder.mcp_manager.disconnect_server(server_name),
51-
coder.interrupt_event,
52-
)
53-
54-
if interrupted:
55-
io.tool_warning(f"MCP disconnection interrupted: {server_name}")
56-
results.append(f"Interrupted: {server_name}")
51+
# Never remove the "local" server
52+
if server_name == "Local":
53+
results.append("Cannot remove 'Local' server")
5754
continue
5855

59-
if was_disconnected:
60-
results.append(f"Removed server: {server_name}")
56+
# Force-exclude on the primary (active) coder
57+
update_server_registration(coder, server_name, "exclude", force=True)
58+
# Check if all coders in the hierarchy have this server excluded
59+
all_excluded = is_server_globally_excluded(coder, server_name)
60+
61+
if all_excluded:
62+
was_disconnected, interrupted = await coder.coroutines.interruptible(
63+
coder.mcp_manager.disconnect_server(server_name),
64+
coder.interrupt_event,
65+
)
66+
67+
if interrupted:
68+
io.tool_warning(f"MCP disconnection interrupted: {server_name}")
69+
results.append(f"Interrupted: {server_name}")
70+
continue
71+
72+
if was_disconnected:
73+
results.append(f"Removed server: {server_name}")
74+
else:
75+
results.append(f"Unable to remove server: {server_name}")
6176
else:
62-
results.append(f"Unable to remove server: {server_name}")
77+
io.tool_output(
78+
f"Server '{server_name}' still in use by other coders, "
79+
f"keeping connection active."
80+
)
81+
results.append(f"Removed from active coder, still active for others: {server_name}")
6382

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

cecli/commands/spawn_agent.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,9 @@ async def execute(cls, io, coder, args, **kwargs):
2727

2828
try:
2929
agent_service = AgentService.get_instance(coder)
30-
new_coder, info = await agent_service.spawn(name, prompt, parent=coder, auto_reap=False)
30+
new_coder, info = await agent_service.spawn(
31+
name, prompt, parent=coder, auto_reap=False, independent=True
32+
)
3133

3234
# Set the newly spawned agent as the foreground agent
3335
agent_service.foreground_uuid = info.coder.uuid

0 commit comments

Comments
 (0)