diff --git a/cecli/__init__.py b/cecli/__init__.py
index 19645dbc949..6fe485a8330 100644
--- a/cecli/__init__.py
+++ b/cecli/__init__.py
@@ -1,6 +1,6 @@
from packaging import version
-__version__ = "1.3.1.dev"
+__version__ = "1.3.2.dev"
safe_version = __version__
try:
diff --git a/cecli/args.py b/cecli/args.py
index 1dcebfb2584..43d7e8b1e92 100644
--- a/cecli/args.py
+++ b/cecli/args.py
@@ -331,6 +331,12 @@ def get_parser(default_config_files, git_root):
default=3,
help="Maximum number of retries a model gets on malformed outputs (default: 3)",
)
+ group.add_argument(
+ "--max-tool-calls",
+ type=int,
+ default=25,
+ help="Maximum number of tool calls allowed per message (default: 25)",
+ )
group.add_argument(
"--cost-limit",
type=float,
diff --git a/cecli/coders/agent_coder.py b/cecli/coders/agent_coder.py
index 08370f38e46..a3cf00ccb1d 100644
--- a/cecli/coders/agent_coder.py
+++ b/cecli/coders/agent_coder.py
@@ -105,8 +105,27 @@ def __init__(self, *args, **kwargs):
self.sub_agent_paths = self.agent_config.get("subagent_paths", [])
self._setup_agent()
- AgentService.build_registry(self.sub_agent_paths)
- ToolRegistry.build_registry(agent_config=self.agent_config)
+ # primary_root is needed early so the skills and tool registries can
+ # resolve relative paths against the primary workspace root rather than
+ # this coder's (possibly overridden) local root. base_coder re-affirms
+ # and keeps this value in super().__init__().
+ self.primary_root = kwargs.get("primary_root") or getattr(self, "primary_root", None)
+
+ # Allow a sub-agent to target a different working root (multi-project workspace).
+ if not kwargs.get("root"):
+ configured_root = self.agent_config.get("root")
+ if configured_root:
+ kwargs["root"] = configured_root
+
+ self._initialize_skills_manager(
+ self.agent_config, root=kwargs.get("root", self.primary_root)
+ )
+ AgentService.build_registry(
+ self.sub_agent_paths, root=kwargs.get("root", self.primary_root)
+ )
+ ToolRegistry.build_registry(
+ agent_config=self.agent_config, root=kwargs.get("root", self.primary_root)
+ )
self.loaded_custom_tools = ToolRegistry.loaded_custom_tools
super().__init__(*args, **kwargs)
@@ -133,6 +152,8 @@ def post_init(self):
self.io.tool_warning(err)
self.start_up_errors = []
+ # Preserve the original 10000 setting now that max tool calls is configurable
+ self.max_tool_calls = self.max_tool_calls * 400
def _setup_agent(self):
os.makedirs(".cecli/temp", exist_ok=True)
@@ -290,21 +311,19 @@ def _get_agent_config(self, config=None):
config["tools_excludelist"].append("loadskill")
config["tools_excludelist"].append("removeskill")
- self._initialize_skills_manager(config)
return config
- def _initialize_skills_manager(self, config):
+ def _initialize_skills_manager(self, config, root=None):
"""
Initialize the skills manager with the configured directory paths and filters.
"""
try:
- git_root = str(self.repo.root) if self.repo else None
self.skills_manager = SkillsManager(
directory_paths=config.get("skills_paths", []),
include_list=config.get("skills_includelist", []),
exclude_list=config.get("skills_excludelist", []),
initialize_list=config.get("skills_init", []),
- git_root=git_root,
+ root=root or self.root or self.primary_root,
coder=self,
)
except Exception as e:
@@ -731,7 +750,8 @@ def get_context_summary(self):
def get_environment_info(self):
"""
Generate an environment information context block with key system details.
- Returns formatted string with working directory, platform, date, and other relevant environment details.
+ Returns formatted string with working directory, platform, date, and other relevant environment details,
+ including the agent's own identity and the primary agent's UUID.
"""
if not self.use_enhanced_context:
return None
@@ -745,6 +765,17 @@ def get_environment_info(self):
result += f"- Current date: {current_date}\n"
result += f"- Platform: {platform_info}\n"
result += f"- Language preference: {language}\n"
+
+ # Agent identity so the model can recognise itself and the primary
+ # agent. Kept here (a static context block) rather than in the
+ # sub-agent states block to avoid re-injecting an agent's own
+ # identity whenever sub-agent state changes mid-conversation.
+ service = AgentService.get_instance(self)
+ self_uuid = str(self.uuid)
+ primary_uuid = str(service.coder.uuid)
+ self_name = service.get_agent_name(self) or "primary"
+ result += f"- Agent: {self_name} ({self_uuid}) [self]\n"
+ result += f"- Primary agent: primary ({primary_uuid})\n"
if self.repo:
try:
rel_repo_dir = self.repo.get_rel_repo_dir()
@@ -1221,15 +1252,6 @@ def _generate_tool_context(self, repetitive_tools):
for i, tool in enumerate(recent_history, 1):
context_parts.append(f"{i}. {tool}")
- if not self.edit_allowed:
- context_parts.append("\n\n")
- context_parts.append("## File Editing Tools Disabled")
- context_parts.append(
- "File editing tools are currently disabled. Use `ReadFile` to determine the"
- " current content ID prefixes needed to perform an edit and activate them when"
- " you are ready to edit a file."
- )
-
context_parts.append("\n\n")
repetition_warning = None
@@ -1706,7 +1728,7 @@ def get_sub_agents_context(self):
result = '\n'
result += "## Available Sub-Agents\n\n"
- result += f"Found {len(registry)} registered sub-agent(s):\n\n"
+ result += f"Found {len(registry)} registered sub-agent(s) types:\n\n"
for name, config in sorted(registry.items()):
result += f"**{name}**:\n"
@@ -1715,57 +1737,107 @@ def get_sub_agents_context(self):
result += f"{desc}\n"
result += "\n"
- result += "Use the `Delegate` tool with the sub-agent name to delegate tasks.\n"
- result += "Use the `Yield` tool to wait for responses for all active sub agents.\n"
+ result += "Use the `Delegate` tool with the sub-agent type to delegate tasks.\n"
+ result += "Use the `Yield` tool to wait for responses for all child sub agents.\n"
result += ""
return result
except Exception as e:
self.io.tool_error(f"Error generating sub-agents context: {str(e)}")
return None
- def get_child_agent_states(self):
- """Get the state of all active child sub-agents.
+ def get_sub_agent_states(self):
+ """Get the state of all active sub-agents.
+
+ Returns a formatted context block listing each active sub-agent as
+ ``{name} ({uuid}, {status})`` bullets, for both dependent children and
+ independent sub-agents. The independent ones are those reachable via
+ the ``Broadcast`` tool.
+
+ Finished/errored sub-agents are excluded. Returns None if there are no
+ active sub-agents, if enhanced context is disabled, or if the caller is
+ a sub-agent without nested delegation enabled.
+
+ The primary agent and calling agent's own identity are deliberately not
+ included here — they live in ``get_environment_info()`` (a static block)
+ so they don't re-inject and churn the conversation when sub-agent state
+ changes.
- Returns a formatted context block with each child sub-agent's name,
- UUID, and current status, or None if no children exist.
This is used by ConversationChunks.add_sub_agent_states() to provide
the model with visibility into active sub-agent states.
"""
if not self.use_enhanced_context:
return None
- # Sub-agents should only see child states when nested delegation is enabled
+ # Sub-agents should only see sub-agent states when nested delegation is enabled
if hasattr(self, "parent_uuid") and self.parent_uuid:
if not self.agent_config.get("allow_nested_delegation", False):
return None
try:
service = AgentService.get_instance(self)
+
+ from cecli.helpers.agents.service import SubAgentStatus
+
+ originator_uuid = str(self.uuid)
+
+ # Dependent children are the coder's direct children.
children = service.get_children(self)
- if not children:
- return None
+ dependent_children = [
+ info
+ for info in children
+ if not info.independent
+ and info.status not in (SubAgentStatus.FINISHED, SubAgentStatus.ERROR)
+ ]
- # Filter to non-independent children only
- dependent_children = [info for info in children if not info.independent]
+ # Independent agents aren't all direct children, so iterate over
+ # every active sub-agent reachable via the Broadcast tool and dedupe
+ # against the dependent children already captured above.
+ dependent_uuids = {info.coder.uuid for info in dependent_children}
+ independent_agents = []
+ seen = set()
+ for info in service.sub_agents.values():
+ if (
+ info.independent
+ and str(info.coder.uuid) != originator_uuid
+ and info.status not in (SubAgentStatus.ERROR,)
+ and info.coder.uuid not in dependent_uuids
+ and info.coder.uuid not in seen
+ ):
+ seen.add(info.coder.uuid)
+ independent_agents.append(info)
- if not dependent_children:
+ if not dependent_children and not independent_agents:
return None
result = '\n'
- result += "## Active Sub-Agent States\n\n"
- result += f"Found {len(dependent_children)} active child sub-agent(s):\n\n"
-
- for info in dependent_children:
- result += f"**{info.name}**:\n"
- result += f" - UUID: `{info.coder.uuid}`\n"
- result += f" - Status: {info.status.value}\n"
- if info.error:
- result += f" - Error: {info.error}\n"
- result += "\n"
+ result += "## Active Sub-Agent Instances\n\n"
+ result += f"Found {len(dependent_children) + len(independent_agents)} active sub-agent(s):\n\n"
+
+ if dependent_children:
+ for info in dependent_children:
+ line = f"- {info.name} ({info.coder.uuid}, {info.status.value})"
+ if info.error:
+ line += f" - Error: {info.error}"
+ result += line + "\n"
+
+ if independent_agents:
+ result += "## Independent Sub-Agent Instances\n"
+ result += (
+ "Communicate bi-directionally with these sub-agents using the "
+ "`Broadcast` tool:\n\n"
+ )
+
+ for info in independent_agents:
+ line = f"- {info.name} ({info.coder.uuid}, {info.status.value})"
+ if info.error:
+ line += f" - Error: {info.error}"
+ result += line + "\n"
+
result += ""
return result
+
except Exception as e:
- self.io.tool_error(f"Error generating child agent states: {str(e)}")
+ self.io.tool_error(f"Error generating sub-agent states: {str(e)}")
return None
def get_background_command_output(self):
diff --git a/cecli/coders/base_coder.py b/cecli/coders/base_coder.py
index 052886e1f47..0e196b76ec0 100755
--- a/cecli/coders/base_coder.py
+++ b/cecli/coders/base_coder.py
@@ -60,7 +60,7 @@
remove_reasoning_content,
replace_reasoning_tags,
)
-from cecli.repo import ANY_GIT_ERROR, GitRepo, GitRepoProxy
+from cecli.repo import ANY_GIT_ERROR, GitRepoProxy
from cecli.repomap import RepoMap
from cecli.report import update_error_prefix
from cecli.run_cmd import run_cmd_async
@@ -193,6 +193,8 @@ def total_cached_tokens(self, value):
abs_read_only_stubs_fnames = None
abs_rules_fnames = None
repo = None
+ root = "."
+ primary_root = None
last_coder_commit_hash = None
coder_edited_files = None
last_asked_for_commit_time = 0
@@ -327,6 +329,7 @@ async def create(
uuid=from_coder.uuid,
parent_uuid=from_coder.parent_uuid,
repo=from_coder.repo,
+ primary_root=from_coder.primary_root,
summarizer=from_coder.summarizer,
)
use_kwargs.update(update) # override to complete the switch
@@ -451,6 +454,8 @@ def __init__(
registered_servers=None,
uuid: str = "",
parent_uuid: str = "",
+ root=None,
+ primary_root=None,
init_metadata={},
):
from cecli.helpers.agents.service import AgentService
@@ -520,6 +525,7 @@ def __init__(
self.max_compaction_retries = max_compaction_retries
self.max_reflections = nested.getter(self.args, "max_reflections", 3)
+ self.max_tool_calls = nested.getter(self.args, "max_tool_calls", 25)
if not fnames:
fnames = []
@@ -621,13 +627,12 @@ def __init__(
self.repo = repo
if use_git and self.repo is None:
try:
- self.repo = GitRepoProxy(
- GitRepo(
- self.io,
- fnames,
- None,
- models=main_model.commit_message_models(),
- )
+ self.repo = GitRepoProxy.for_root(
+ None,
+ self.io,
+ fnames=fnames,
+ git_dname=None,
+ models=main_model.commit_message_models(),
)
except FileNotFoundError:
pass
@@ -664,12 +669,47 @@ def __init__(
if not self.repo:
self.root = utils.find_common_root(self.abs_fnames)
- # Initialize the FileSystemService singleton for all agents
- FileSystemService.get_instance(
+ # Allow sub-agent classes to override the working root (multi-project workspaces).
+ if root is not None:
+ self.root = os.path.normpath(os.path.abspath(root))
+
+ # A sub-agent may operate on a different base path than its parent. In
+ # that case its repo must be scoped to *this* root (per-base-path),
+ # rather than inheriting the parent's repo, so the coder's repo/fs match
+ # its own root.
+ if use_git and self.repo is not None and os.path.normpath(self.repo.root) != self.root:
+ try:
+ self.repo = GitRepoProxy.for_root(
+ self.root,
+ self.io,
+ fnames=[self.root],
+ git_dname=None,
+ models=main_model.commit_message_models(),
+ )
+ except FileNotFoundError:
+ self.repo = None
+
+ # Store the root of the primary coder so skills files, custom tools, and
+ # sub-agent paths can be resolved relative to the primary workspace even
+ # when this coder operates on a different base path.
+ self.primary_root = (
+ primary_root
+ if primary_root is not None
+ else getattr(self, "primary_root", None) or self.root
+ )
+
+ # Initialize the per-base-path FileSystemService for this coder
+ self.fs = FileSystemService.for_root(
root=self.root if hasattr(self, "root") else ".",
repo=self.repo if hasattr(self, "repo") else None,
)
+ # Auto-return the per-root service (and its git repo) when the last
+ # coder sharing this base path is destroyed.
+ _fs_key = FileSystemService._normalize_root(self.root if hasattr(self, "root") else ".")
+ FileSystemService._inc_ref(_fs_key)
+ weakref.finalize(self, FileSystemService._release, _fs_key)
+
if read_only_fnames:
self.abs_read_only_fnames = set()
for fname in read_only_fnames:
@@ -710,11 +750,7 @@ def __init__(
has_map_prompt = nested.getter(self, "gpt_prompts.repo_content_prefix")
if use_repo_map and self.repo and has_map_prompt:
- repo_root = (
- self.repo.workspace_path
- if (self.repo and getattr(self.repo, "workspace_path", None))
- else self.root
- )
+ repo_root = self.root
self.repo_map = RepoMap(
map_tokens,
self.map_cache_dir,
@@ -1048,6 +1084,25 @@ def abs_root_path(self, path):
fences = all_fences
fence = fences[0]
+ def resolve_relative_to_primary_root(self, path: str) -> str:
+ """Resolve a path relative to the primary coder's root (falling back to self.root).
+
+ Used for skills files, custom tools, and sub-agent paths that are defined
+ relative to the primary workspace even when this coder operates on a
+ different base path.
+ """
+ if not path:
+ return path
+ if path.startswith("/"):
+ # POSIX-style absolute path (e.g. "/tmp/foo"). os.path.isabs()
+ # returns False for "/"-rooted paths on Windows, so guard here
+ # to avoid re-anchoring them onto the primary root.
+ return path
+ if os.path.isabs(path):
+ return os.path.normpath(path)
+ base = self.primary_root or self.root
+ return os.path.normpath(os.path.join(base, path))
+
def show_pretty(self):
if not self.pretty:
return False
@@ -4567,8 +4622,8 @@ def is_file_safe(self, fname):
return
def get_all_relative_files(self):
- """Get all files known to the file service singleton."""
- fs = FileSystemService.get_instance()
+ """Get all files known to the file service for this coder's base path."""
+ fs = getattr(self, "fs", None) or FileSystemService.get_instance()
if fs.trie:
# Auto-rebuild if the repository state has changed
# (e.g., new commits, staged files, or HEAD change)
diff --git a/cecli/commands/__init__.py b/cecli/commands/__init__.py
index 0905610dc01..9f240a549ba 100644
--- a/cecli/commands/__init__.py
+++ b/cecli/commands/__init__.py
@@ -55,6 +55,7 @@
from .model import ModelCommand
from .models import ModelsCommand
from .multiline_mode import MultilineModeCommand
+from .open import OpenCommand
from .paste import PasteCommand
from .queue import QueueCommand
from .quit import QuitCommand
@@ -151,6 +152,7 @@
CommandRegistry.register(ModelCommand)
CommandRegistry.register(ModelsCommand)
CommandRegistry.register(MultilineModeCommand)
+CommandRegistry.register(OpenCommand)
CommandRegistry.register(PasteCommand)
CommandRegistry.register(QueueCommand)
CommandRegistry.register(QuitCommand)
@@ -240,6 +242,7 @@
"ModelCommand",
"ModelsCommand",
"MultilineModeCommand",
+ "OpenCommand",
"parse_quoted_filenames",
"PasteCommand",
"quote_filename",
diff --git a/cecli/commands/open.py b/cecli/commands/open.py
new file mode 100644
index 00000000000..ee0db6c2523
--- /dev/null
+++ b/cecli/commands/open.py
@@ -0,0 +1,76 @@
+"""Open command - register and open a workspace sub-agent rooted at a path."""
+
+from pathlib import Path
+
+from cecli.helpers.agents.service import AgentService
+from cecli.helpers.workspaces.subagents import register_workspace_subagents
+
+from .utils.base_command import BaseCommand
+
+
+class OpenCommand(BaseCommand):
+ NORM_NAME = "open"
+ DESCRIPTION = "Open a workspace sub-agent rooted at a given path"
+
+ @classmethod
+ async def execute(cls, io, coder, args, **kwargs):
+ """Open a workspace sub-agent rooted at the given path.
+
+ Syntax:
+ /open — register and open a ``ws:{name}`` sub-agent rooted at ````
+ """
+ parts = args.strip().split(maxsplit=1)
+ if len(parts) < 2:
+ io.tool_error("Usage: /open ")
+ return
+
+ name = parts[0]
+ path_arg = parts[1].strip()
+
+ project_name = name[3:] if name.startswith("ws:") else name
+ agent_name = f"ws:{project_name}"
+ path = Path(path_arg).expanduser()
+
+ config = {
+ "name": project_name,
+ "projects": [{"name": project_name, "path": str(path)}],
+ }
+ registered = register_workspace_subagents(config)
+ if agent_name not in registered:
+ io.tool_error(f"Error: '{path}' is not a valid git repository or does not exist.")
+ return
+
+ root = AgentService.get_registry()[agent_name].metadata.get("root")
+
+ try:
+ agent_service = AgentService.get_instance(coder)
+ new_coder, info = await agent_service.spawn(
+ agent_name, prompt=None, parent=coder, auto_reap=False, independent=True
+ )
+
+ agent_service.foreground_uuid = info.coder.uuid
+
+ if coder.tui and coder.tui():
+ tui = coder.tui()
+ switch_key = tui.get_keys_for("next_agent")
+ io.tool_output(
+ f"Opened workspace sub-agent '{agent_name}' rooted at {root}. Switch with {switch_key}"
+ )
+
+ try:
+ tui.call_from_thread(tui._switch_to_container, info.coder.uuid)
+ except Exception:
+ pass
+ else:
+ io.tool_output(f"Opened workspace sub-agent '{agent_name}' rooted at {root}.")
+ except Exception as e:
+ io.tool_error(f"Error opening workspace sub-agent '{agent_name}': {e}")
+
+ @classmethod
+ def get_help(cls) -> str:
+ return "Open a workspace sub-agent rooted at a path (/open )"
+
+ @classmethod
+ def get_completions(cls, io, coder, args) -> list[str]:
+ """Return registered workspace sub-agent names for tab-completion."""
+ return [name for name in AgentService.get_registry().keys() if name.startswith("ws:")]
diff --git a/cecli/commands/utils/helpers.py b/cecli/commands/utils/helpers.py
index b70fa0c40c1..2b4670c0ba6 100644
--- a/cecli/commands/utils/helpers.py
+++ b/cecli/commands/utils/helpers.py
@@ -159,7 +159,7 @@ def get_file_completions(
root = Path(coder.root) if hasattr(coder, "root") else Path.cwd()
# Try using FileSystemService for efficient lookups
- fs = FileSystemService.get_instance()
+ fs = getattr(coder, "fs", None) or FileSystemService.get_instance()
fs_available = fs.trie is not None or fs.ngram is not None
if completion_type == "glob":
diff --git a/cecli/commands/workspace.py b/cecli/commands/workspace.py
index ba53f260fe5..4fe1aa4fb22 100644
--- a/cecli/commands/workspace.py
+++ b/cecli/commands/workspace.py
@@ -1,68 +1,29 @@
-import subprocess
-
from cecli.commands.utils.base_command import BaseCommand
-from cecli.decoding import safe_open
class WorkspaceCommand(BaseCommand):
NORM_NAME = "workspace"
- DESCRIPTION = "Print information about the current workspace"
+ DESCRIPTION = "Print information about the active workspace sub-agents"
show_completion_notification = True
@classmethod
async def execute(cls, io, coder, args, **kwargs):
- """Execute the workspace command."""
- if not coder or not coder.repo:
- io.tool_output("No repository or workspace active.")
- return
-
- workspace_path = getattr(coder.repo, "workspace_path", None)
- if not workspace_path:
- io.tool_output("Not currently working within a cecli workspace.")
- return
-
- import json
+ """Show the registered ws:{name} workspace sub-agents and their roots."""
+ from cecli.helpers.agents.service import AgentService
- metadata_path = workspace_path / ".cecli-workspace.json"
- config = {}
- if metadata_path.exists():
- try:
- with safe_open(metadata_path, "r") as f:
- config = json.load(f)
- except Exception:
- pass
+ registry = AgentService.get_registry()
+ ws_agents = sorted((name, cfg) for name, cfg in registry.items() if name.startswith("ws:"))
- ws_name = config.get("name", workspace_path.name)
- is_active = config.get("active", False)
-
- io.print(f"Current Workspace: {ws_name}{' (Active)' if is_active else ''}")
- io.print(f"Root Directory: {workspace_path}")
- io.print("-" * 40)
- io.print("Projects:")
-
- projects = config.get("projects", [])
- for proj in projects:
- proj_name = proj.get("name")
- if not proj_name:
- continue
-
- proj_root = workspace_path / proj_name / "main"
- branch_info = "Unknown"
- if proj_root.exists():
- try:
- branch_info = subprocess.check_output(
- ["git", "-C", str(proj_root), "rev-parse", "--abbrev-ref", "HEAD"],
- stderr=subprocess.DEVNULL,
- encoding="utf-8",
- ).strip()
- except Exception:
- branch_info = "Error retrieving branch"
+ if not ws_agents:
+ io.tool_output("No workspace sub-agents are active.")
+ return
- repo_url = proj.get("repo", "N/A")
- io.print(f" - {proj_name}:")
- io.print(f" Branch: {branch_info}")
- io.print(f" Remote: {repo_url}")
- io.print(f" Path: {proj_root}")
+ io.print("Workspace Sub-Agents:")
+ for name, cfg in ws_agents:
+ metadata = getattr(cfg, "metadata", {}) or {}
+ io.print(f" - {name}")
+ io.print(f" Root: {metadata.get('root')}")
+ io.print(f" Layout: {metadata.get('layout')}")
io.print("")
@classmethod
@@ -70,5 +31,5 @@ def get_help(cls) -> str:
"""Get help text for the workspace command."""
help_text = super().get_help()
help_text += "\nUsage:\n"
- help_text += " /workspace # Show details of the active monorepo workspace\n"
+ help_text += " /workspace # List active workspace sub-agents\n"
return help_text
diff --git a/cecli/helpers/agents/defaults/memorizer.md b/cecli/helpers/agents/defaults/memorizer.md
index 9c9618c5ee9..3b454bf7d30 100644
--- a/cecli/helpers/agents/defaults/memorizer.md
+++ b/cecli/helpers/agents/defaults/memorizer.md
@@ -16,6 +16,8 @@ agent-config:
- gitremote
- gitshow
- gitstatus
+ servers_includelist:
+ - local
exclude_context_blocks:
- context_summary
- directory_structure
diff --git a/cecli/helpers/agents/service.py b/cecli/helpers/agents/service.py
index 2f39d013e8e..b0c9115260b 100644
--- a/cecli/helpers/agents/service.py
+++ b/cecli/helpers/agents/service.py
@@ -14,6 +14,7 @@
from uuid import uuid4
import cecli.models as models
+from cecli.helpers import nested
from cecli.helpers.coroutines import fire_and_forget
logger = logging.getLogger(__name__)
@@ -186,7 +187,7 @@ def mark_sub_agent_finished(
return
@classmethod
- def build_registry(cls, paths: List[str]) -> None:
+ def build_registry(cls, paths: List[str], root: Optional[str] = None) -> None:
"""Scan directories for .md sub-agent definition files and load them.
Each .md file should contain YAML front matter with:
@@ -204,16 +205,45 @@ def build_registry(cls, paths: List[str]) -> None:
```` - The parent coder's agent model
```` - The parent coder's main model
```` - The currently active (foreground) coder's main model
+
+ Args:
+ paths: Directories to scan for sub-agent ``.md`` definition files.
+ root: Optional base directory used to resolve relative ``paths``
+ (e.g. the workspace or project root in multi-project workspaces).
"""
from pathlib import Path
from .config import parse_subagent_file
+ # Resolve relative sub-agent directories against ``root`` so
+ # multi-project workspaces can reference local sub-agent definitions
+ # relative to their project/workspace root. Absolute and ``~`` paths
+ # are left untouched.
+ base = Path(root).expanduser().resolve() if root else None
+
+ def _resolve(directory: str) -> str:
+ raw = Path(directory).expanduser()
+ if raw.is_absolute():
+ return str(raw)
+ if base is not None:
+ return str((base / raw).resolve())
+ return str(raw)
+
+ paths = [_resolve(directory) for directory in paths]
+
# Always check the default sub-agents directory in the user's home
default_dir = str(Path.home() / ".cecli" / "subagents")
if default_dir not in paths:
paths = [default_dir] + list(paths)
+ # Also check the local default sub-agents directory under the given root
+ # (e.g. {root}/.cecli/subagents) before the user-configured paths so
+ # project-scoped sub-agents take precedence over the global default.
+ if base is not None:
+ local_default_dir = str(base / ".cecli" / "subagents")
+ if local_default_dir not in paths:
+ paths.insert(1, local_default_dir)
+
# Also scan the built-in defaults directory for .yml definitions
import cecli.helpers.agents.defaults as _defaults_pkg
@@ -553,6 +583,22 @@ async def _create_sub_agent_coder(
metadata = getattr(config, "metadata", {}).copy()
agent_config = metadata.get("agent-config", {})
+ # Optional per-sub-agent root override for multi-project workspaces.
+ configured_root = metadata.get("root")
+ if not configured_root and isinstance(agent_config, dict):
+ configured_root = agent_config.get("root")
+
+ # Workspace sub-agents (ws:*) inherit the parent/primary coder's
+ # agent_config as their default and additionally enable nested
+ # delegation so they can themselves serve as delegation bases. The
+ # ws-agent's own metadata agent-config is merged on top.
+ if name.startswith("ws:"):
+ inherited = dict(nested.getter(parent_coder, "agent_config", {}) or {})
+ incoming = dict(agent_config)
+ inherited.update(incoming)
+ inherited["allow_nested_delegation"] = True
+ agent_config = inherited
+
kwargs = dict(
io=parent_coder.io,
from_coder=parent_coder,
@@ -563,6 +609,8 @@ async def _create_sub_agent_coder(
map_tokens=0,
init_metadata={"agent_config": agent_config},
)
+ if configured_root:
+ kwargs["root"] = configured_root
if agent_config:
# Reset the per-instance tool/server filters so AgentCoder.post_init()
@@ -759,6 +807,53 @@ def _raise_if_signal(exc):
)
return task
+ def wake_primary(self, primary_coder: Any, message: str) -> str:
+ """Wake the primary coder so it processes *message*.
+
+ Unlike sub-agents, the primary coder does not use the
+ ``start_generate_task`` architecture; when it is idle its input loop
+ blocks in ``wait_for_input()``. Preserving the behaviour of
+ ``on_input_area_submit()``, we wake it by pushing the message onto its
+ per-coder input queue. If the primary is already generating, the
+ message is queued into its conversation instead so the running
+ ``generate`` loop picks it up.
+
+ Args:
+ primary_coder: The primary coder instance (``service.coder``).
+ message: The message text to deliver to the primary.
+
+ Returns:
+ A short mode string describing delivery: ``queued`` when the
+ primary is actively generating (message queued into its
+ conversation), ``started`` when the primary was idle and woken via
+ its input queue.
+ """
+ from cecli.helpers import queues
+ from cecli.helpers.conversation.service import ConversationService
+ from cecli.helpers.conversation.tags import MessageTag
+ from cecli.helpers.coroutines import is_active
+
+ def _queue_to_conversation() -> None:
+ ConversationService.get_manager(primary_coder).queue_message(
+ message_dict={"role": "user", "content": message},
+ tag=MessageTag.CUR,
+ hash_key=("broadcast", str(primary_coder.uuid), str(time.monotonic_ns())),
+ )
+
+ if is_active(getattr(primary_coder.io, "output_task", None)):
+ _queue_to_conversation()
+ return "queued"
+
+ delivered = queues.push_coder_input(
+ str(primary_coder.uuid),
+ {"text": message, "coder_uuid": str(primary_coder.uuid)},
+ )
+ if not delivered:
+ _queue_to_conversation()
+ return "queued"
+
+ return "started"
+
async def _inject_sub_agent_result(self, info: SubAgentInfo) -> None:
"""Inject the sub-agent's result (summary/error) into the parent's conversation.
diff --git a/cecli/helpers/conversation/integration.py b/cecli/helpers/conversation/integration.py
index 3ffdc22883e..6bb59fa5261 100644
--- a/cecli/helpers/conversation/integration.py
+++ b/cecli/helpers/conversation/integration.py
@@ -1,4 +1,5 @@
import json
+import os
import random
import time
import weakref
@@ -501,6 +502,8 @@ def add_rules_messages(self) -> List[Dict[str, Any]]:
"""
Get rules file messages for reference.
These are always reloaded from disk and use the RULES tag.
+ If no explicit rules files are configured, fall back to AGENTS.md
+ and CLAUDE.md files found in the coder root directory.
"""
coder = self.get_coder()
if not coder:
@@ -514,10 +517,21 @@ def add_rules_messages(self) -> List[Dict[str, Any]]:
return []
messages = []
- if not hasattr(coder, "abs_rules_fnames") or not coder.abs_rules_fnames:
+ rules_files = []
+ if hasattr(coder, "abs_rules_fnames") and coder.abs_rules_fnames:
+ rules_files = sorted(coder.abs_rules_fnames)
+ else:
+ # No explicit rules configured; fall back to convention files
+ # (AGENTS.md / CLAUDE.md) found in the coder root directory.
+ for fallback_name in ("AGENTS.md", "CLAUDE.md"):
+ fallback_path = coder.abs_root_path(fallback_name)
+ if os.path.isfile(fallback_path):
+ rules_files.append(fallback_path)
+
+ if not rules_files:
return messages
- for fname in sorted(coder.abs_rules_fnames):
+ for fname in rules_files:
# Read file content directly from disk
try:
content = coder.io.read_text(fname)
@@ -953,10 +967,10 @@ def add_sub_agent_states(self) -> None:
if not hasattr(coder, "use_enhanced_context") or not coder.use_enhanced_context:
return
- if not hasattr(coder, "get_child_agent_states"):
+ if not hasattr(coder, "get_sub_agent_states"):
return
- block = coder.get_child_agent_states()
+ block = coder.get_sub_agent_states()
if not block:
return
diff --git a/cecli/helpers/file_system/builders.py b/cecli/helpers/file_system/builders.py
index 5389ea50ef8..6dbb7cdca67 100644
--- a/cecli/helpers/file_system/builders.py
+++ b/cecli/helpers/file_system/builders.py
@@ -8,6 +8,7 @@
import hashlib
import os
import subprocess
+import time
from pathlib import Path
from .ignore import FileIgnoreFilter
@@ -118,6 +119,8 @@ def collect(
Walk filesystem collecting files relative to root using Breadth-First Search.
Highly optimized: lazy iteration, single-pass evaluation, and raw string paths.
"""
+ start_time = time.monotonic()
+
paths = []
# Only use pathlib for initial resolution, then stick to raw strings for speed
@@ -132,6 +135,9 @@ def collect(
path_count = 0
while dirs_to_scan:
+ if time.monotonic() - start_time > 120:
+ break
+
next_dirs = []
for current_path, rel_dir, depth in dirs_to_scan:
@@ -159,7 +165,7 @@ def collect(
# --- Handle Files ---
elif entry.is_file(follow_symlinks=False):
- # Short-circuit if we hit the 64 file cap (saves ignore_filter overhead)
+ # Short-circuit if we hit the 256 file cap (saves ignore_filter overhead)
if not is_subfolder_of_user and file_count >= 256:
continue
@@ -177,7 +183,7 @@ def collect(
# Global safety hard-stop
if not is_subfolder_of_user and path_count >= max_files:
return sorted(paths)
- except PermissionError:
+ except (PermissionError, OSError, TimeoutError):
continue # Skip folders we don't have read access to
dirs_to_scan = next_dirs
diff --git a/cecli/helpers/file_system/service.py b/cecli/helpers/file_system/service.py
index 2965490859e..94edd58d23c 100644
--- a/cecli/helpers/file_system/service.py
+++ b/cecli/helpers/file_system/service.py
@@ -1,9 +1,15 @@
"""
-FileSystemService: Global singleton for file path resolution and discovery.
+FileSystemService: per-base-path singleton for file path resolution and discovery.
-All agents (Coder, sub-agents, etc.) share one instance via get_instance().
-Initialized once on first call with root/repo params; subsequent calls
-ignore them and return the existing singleton.
+Each distinct base path owns exactly one instance, carrying its own repo,
+marisa-trie, and trigram index. Use FileSystemService.for_root(root, repo)
+to obtain the instance for a base path. Coders that share a base path share
+the same instance; coders on different base paths get independent instances,
+so multiple repositories can be served concurrently.
+
+get_instance() remains as a backward-compatible accessor that returns the
+most-recently-active base path's instance when no root is given, and delegates
+to for_root() when a root is provided.
Uses marisa-trie for memory-efficient prefix/string matching and
ngram for trigram-based fuzzy search. Supports construction from
@@ -24,12 +30,15 @@ class FileSystemService:
"""
Provides file path resolution, prefix queries, and fuzzy search.
- Singleton — all agents and sub-agents share one instance.
- Use FileSystemService.get_instance() to obtain it.
+ Singleton per base path — all coders that share a base path use one
+ instance; coders on different base paths get independent instances. Use
+ FileSystemService.for_root() to obtain the instance for a base path.
"""
- # --- Singleton state ---
- _instance = None
+ # --- Per-base-path singleton registry ---
+ _instances: dict[str, "FileSystemService"] = {}
+ _active_root: str | None = None
+ _refcounts: dict[str, int] = {}
# --- Instance attributes ---
root: str = "."
@@ -44,7 +53,7 @@ def __init__(
):
"""
Initialize the service. Not intended for direct use —
- call get_instance() instead.
+ call for_root() instead.
"""
self.root = os.path.normpath(root)
self.repo = repo
@@ -67,35 +76,104 @@ def ngram(self):
# ---------------------------------------------------------------
# Singleton access
# ---------------------------------------------------------------
+ @staticmethod
+ def _normalize_root(root: str) -> str:
+ """Normalize a base path into a canonical registry key."""
+ return os.path.normpath(os.path.abspath(root))
+
@classmethod
def reset_instance(cls) -> None:
- """Reset the singleton — used primarily in test teardown."""
- cls._instance = None
+ """Reset all per-base-path singletons — used primarily in test teardown."""
+ cls._instances.clear()
+ cls._refcounts.clear()
+ cls._active_root = None
+
+ @classmethod
+ def for_root(cls, root: str | None = None, repo=None) -> "FileSystemService":
+ """
+ Return the singleton for a base path.
+
+ Each distinct base path owns exactly one instance carrying its own
+ repo and indices. The first call for a base path builds it; later
+ calls return the cached instance. If a different repo is supplied for
+ an already-cached base path, the instance's repo is re-pointed and the
+ index is invalidated so the next rebuild uses the new repo.
+ """
+ root = root or "."
+ key = cls._normalize_root(root)
+ service = cls._instances.get(key)
+
+ if service is None:
+ service = cls._create(root=root, repo=repo)
+ cls._instances[key] = service
+ elif repo is not None and service.repo is not repo:
+ service.repo = repo
+ service._build_hash = "" # Invalidate for lazy rebuild with new repo
+ cls._active_root = key
+ return service
@classmethod
def get_instance(cls, root: str | None = None, repo=None) -> "FileSystemService":
"""
- Return the global singleton.
+ Backward-compatible accessor (see the module docstring).
- On first call, creates and builds the instance using root/repo.
- Subsequent calls return the existing instance. If a non-None root
- is explicitly provided and differs from the current root, the
- singleton is rebuilt (e.g. when a new coder is created in a
- different working directory).
+ With a root, delegates to for_root(). Without a root, returns the
+ most-recently-active base path's instance, building one for the
+ current directory if nothing is active yet.
"""
- if cls._instance is None:
- cls._instance = cls._create(root=root or ".", repo=repo)
- elif root is not None and root != cls._instance.root:
- cls._instance = cls._create(root=root, repo=repo)
- return cls._instance
+ if root is not None:
+ return cls.for_root(root=root, repo=repo)
+ if cls._active_root is None:
+ return cls.for_root(root=".", repo=repo)
+ return cls._instances[cls._active_root]
+
+ @classmethod
+ def evict(cls, root: str | None = None) -> None:
+ """Drop the instance for a base path (e.g. on coder teardown)."""
+ if root is None:
+ return
+ key = cls._normalize_root(root)
+ cls._instances.pop(key, None)
+ cls._refcounts.pop(key, None)
+ if cls._active_root == key:
+ cls._active_root = None
@classmethod
def _create(cls, root: str, repo=None) -> "FileSystemService":
- """Internal factory — builds and returns the singleton."""
+ """Internal factory — builds and returns a per-base-path instance."""
service = cls(root=root, repo=repo)
service.build()
return service
+ @classmethod
+ def _inc_ref(cls, key: str) -> None:
+ """Bump the reference count for a base path held by a live coder."""
+ cls._refcounts[key] = cls._refcounts.get(key, 0) + 1
+
+ @classmethod
+ def _release(cls, key: str) -> None:
+ """Drop one reference; evict the base-path service (and its git repo)
+ when the last coder using it is destroyed."""
+ count = cls._refcounts.get(key, 0)
+ if count <= 1:
+ cls._evict_pair(key)
+ else:
+ cls._refcounts[key] = count - 1
+
+ @classmethod
+ def _evict_pair(cls, key: str) -> None:
+ """Evict the FileSystemService and the matching GitRepoProxy for a base path."""
+ cls._instances.pop(key, None)
+ cls._refcounts.pop(key, None)
+ if cls._active_root == key:
+ cls._active_root = None
+ try:
+ from cecli.repo import GitRepoProxy
+
+ GitRepoProxy.evict(key)
+ except Exception:
+ pass
+
def has_git(self) -> bool:
"""Check if a git repo is available at root."""
return self.repo is not None or (os.path.isdir(os.path.join(self.root, ".git")))
diff --git a/cecli/helpers/hashpos/hashpos.py b/cecli/helpers/hashpos/hashpos.py
index bd5a150fa98..538fc483200 100644
--- a/cecli/helpers/hashpos/hashpos.py
+++ b/cecli/helpers/hashpos/hashpos.py
@@ -74,8 +74,8 @@ class HashPos:
rf"^({UNIQUE_HASH_DELIMITER}|{HASH_DELIMITER}[{_B1024_REGEX_SET}]{{4}}{HASH_DELIMITER})$"
)
- # Loose prefix for robust stripping: Matches a emdash-wrapped 4-char string containing non-ASCII
- _LOOSE_PREFIX_RE = re.compile(rf"^[{HASH_DELIMITER}]?\S{{0,4}}{HASH_DELIMITER}")
+ # Loose prefix for robust stripping: Matches a emdash-wrapped 8-char string containing non-ASCII
+ _LOOSE_PREFIX_RE = re.compile(rf"^[{HASH_DELIMITER}]?\S{{0,8}}{HASH_DELIMITER}")
def __init__(self, source_text: str = ""):
self.lines = source_text.splitlines()
diff --git a/cecli/helpers/hashpos/transformations.py b/cecli/helpers/hashpos/transformations.py
index 6026f57cdaa..d481918cb90 100644
--- a/cecli/helpers/hashpos/transformations.py
+++ b/cecli/helpers/hashpos/transformations.py
@@ -10,13 +10,8 @@
from __future__ import annotations
import re
-from typing import TYPE_CHECKING
-
-from cecli.helpers.hashpos.hashpos import UNIQUE_HASH_DELIMITER
-
-if TYPE_CHECKING:
- from cecli.helpers.hashpos.hashpos import HashPos
+from cecli.helpers.hashpos.hashpos import UNIQUE_HASH_DELIMITER, HashPos
# ────────────────────────────────────────────────────────────
# Pattern Matching
@@ -432,9 +427,24 @@ def reposition_indices(
def strip_hashline_prefix(value: str) -> str:
"""Strip the virtual prefix from a ReadFile output line reference."""
-
- if isinstance(value, str) and value.startswith(UNIQUE_HASH_DELIMITER):
- return value[len(UNIQUE_HASH_DELIMITER) :].lstrip()
+ if not isinstance(value, str):
+ return value
+
+ # Unique-line delimiter (——): not spatially resolvable, so strip it and match
+ # the remaining content as text.
+ if value.startswith(UNIQUE_HASH_DELIMITER):
+ return value[len(UNIQUE_HASH_DELIMITER) :]
+
+ # Valid canonical duplicate content ID (—XXXX—): keep it intact so that
+ # resolve_content_to_hashline_ids() can target a specific occurrence.
+ if HashPos.HASH_PREFIX_RE.match(value):
+ return value
+
+ # Malformed marker: a short string immediately followed by an em-dash (e.g.
+ # 'о‘星—'). This is not a valid content ID, so strip it.
+ stripped = HashPos._LOOSE_PREFIX_RE.sub("", value, count=1)
+ if stripped != value:
+ return stripped
return value
diff --git a/cecli/helpers/llms/domains/gemini.py b/cecli/helpers/llms/domains/gemini.py
index b267ced2dcc..2e2aed1c07d 100644
--- a/cecli/helpers/llms/domains/gemini.py
+++ b/cecli/helpers/llms/domains/gemini.py
@@ -194,7 +194,7 @@ async def gemini_complete(
url = f"{resolved['api_base']}/v1beta/models/{resolved['route']}:generateContent"
payload = gemini_payload(resolved, messages, tools, kwargs)
hdrs = {"Content-Type": "application/json", **headers}
- params = {"key": key} if key else {}
+ params: Dict[str, str] = {}
async with make_client(timeout=DEFAULT_TIMEOUT, verify=VERIFY_SSL) as client:
resp = await client.post(url, json=payload, headers=hdrs, params=params)
@@ -215,7 +215,7 @@ async def gemini_stream(
url = f"{resolved['api_base']}/v1beta/models/{resolved['route']}:streamGenerateContent"
payload = gemini_payload(resolved, messages, tools, kwargs)
hdrs = {"Content-Type": "application/json", **headers}
- params = {"key": key, "alt": "sse"} if key else {"alt": "sse"}
+ params = {"alt": "sse"}
has_seen_tool_calls = False
_stream_state["tool_indices"] = {}
diff --git a/cecli/helpers/llms/providers/gemini.py b/cecli/helpers/llms/providers/gemini.py
index a1515153b0c..cbe49c2a4b5 100644
--- a/cecli/helpers/llms/providers/gemini.py
+++ b/cecli/helpers/llms/providers/gemini.py
@@ -1,11 +1,10 @@
"""Gemini provider adapter for the llms package.
-Gemini authenticates via the ``key`` query parameter (or ``X-Goog-Api-Key``
-header), NOT via an ``Authorization: Bearer`` header. The base
-:class:`ProviderAdapter` adds a Bearer header whenever a key is present, which
-Google rejects with 401 for API keys, so this adapter overrides
-:meth:`build_headers` to skip it (the domain adapter passes ``key`` as a query
-param itself).
+Gemini authenticates via the ``x-goog-api-key`` header, NOT via an
+``Authorization: Bearer`` header. The base :class:`ProviderAdapter` adds a
+Bearer header whenever a key is present, which Google rejects with 401 for API
+keys, so this adapter overrides :meth:`build_headers` to set ``x-goog-api-key``
+instead.
"""
from __future__ import annotations
@@ -16,7 +15,7 @@
class GeminiProvider(ProviderAdapter):
- """Gemini: key via query param; no Authorization header."""
+ """Gemini: key via x-goog-api-key header; no Authorization header."""
provider: str = "gemini"
@@ -27,10 +26,12 @@ def build_headers(
family: str,
headers: Dict[str, str],
) -> Dict[str, str]:
- """Return headers without an Authorization header (key is a query param)."""
+ """Return headers with x-goog-api-key set instead of Authorization."""
merged = dict(headers)
merged.setdefault("Content-Type", "application/json")
+ if key:
+ merged["x-goog-api-key"] = key
return merged
diff --git a/cecli/helpers/monorepo/__init__.py b/cecli/helpers/monorepo/__init__.py
deleted file mode 100644
index e69de29bb2d..00000000000
diff --git a/cecli/helpers/monorepo/config.py b/cecli/helpers/monorepo/config.py
deleted file mode 100644
index 6fb4fdb29c2..00000000000
--- a/cecli/helpers/monorepo/config.py
+++ /dev/null
@@ -1,174 +0,0 @@
-import json
-from pathlib import Path
-from typing import Any, Dict, Optional
-
-import yaml
-
-from cecli.decoding import safe_open
-
-
-def resolve_workspace_config(config_arg: Optional[str] = None) -> Optional[Any]:
- """
- Common logic to resolve workspace configuration from hierarchy:
- 1. config_arg (JSON string)
- 2. Local .cecli.workspaces.yml/yaml
- 3. Global ~/.cecli/workspaces.yml/yaml
- 4. Fallback to .cecli.conf.yml
- """
- workspace_conf = None
-
- # 1. Try config_arg (JSON string from main.py)
- if config_arg:
- try:
- loaded = json.loads(config_arg)
- if isinstance(loaded, dict):
- workspace_conf = loaded.get("workspaces") or loaded.get("workspace") or loaded
- elif isinstance(loaded, list):
- workspace_conf = loaded
- except json.JSONDecodeError:
- try:
- loaded = yaml.safe_load(config_arg)
- if isinstance(loaded, dict):
- workspace_conf = loaded.get("workspaces") or loaded.get("workspace") or loaded
- elif isinstance(loaded, list):
- workspace_conf = loaded
- except yaml.YAMLError:
- pass
-
- # 2. Look for local .cecli.workspaces.yml/yaml
- if not workspace_conf:
- for local_name in [".cecli.workspaces.yml", ".cecli.workspaces.yaml"]:
- local_path = Path(local_name)
- if local_path.exists():
- try:
- with safe_open(local_path, "r") as f:
- loaded = yaml.safe_load(f)
- if loaded:
- workspace_conf = (
- loaded.get("workspaces") or loaded.get("workspace") or loaded
- )
- if workspace_conf:
- break
- except Exception:
- pass
-
- # 3. Look for global ~/.cecli/workspaces.yml/yaml
- if not workspace_conf:
- for global_name in ["workspaces.yml", "workspaces.yaml"]:
- global_path = Path.home() / ".cecli" / global_name
- if global_path.exists():
- try:
- with safe_open(global_path, "r") as f:
- loaded = yaml.safe_load(f)
- if loaded:
- workspace_conf = (
- loaded.get("workspaces") or loaded.get("workspace") or loaded
- )
- if workspace_conf:
- break
- except Exception:
- pass
-
- return workspace_conf
-
-
-def load_workspace_config_file(path: Path) -> Dict[str, Any]:
- """Load and validate a repo-local ``.cecli.workspaces.yml`` file."""
- from cecli.helpers.monorepo.local_workspace import load_workspace_file
-
- config = load_workspace_file(path)
- validate_config(config)
- return config
-
-
-def load_workspace_config(
- config_arg: Optional[str] = None, name: Optional[str] = None
-) -> Dict[str, Any]:
- """
- Load workspace configuration from hierarchy.
- If name is provided, select that specific workspace from a list.
- """
- workspace_conf = resolve_workspace_config(config_arg)
-
- config = {}
- # Handle list of workspaces or single dict
- if isinstance(workspace_conf, list):
- if name:
- selected_ws = next((ws for ws in workspace_conf if ws.get("name") == name), None)
- if not selected_ws:
- raise ValueError(f"Workspace '{name}' not found in configuration")
- config = selected_ws
- else:
- active_workspaces = [ws for ws in workspace_conf if ws.get("active")]
- if len(active_workspaces) > 1:
- active_names = [ws.get("name", "unknown") for ws in active_workspaces]
- raise ValueError(f"Multiple workspaces marked as active: {', '.join(active_names)}")
-
- active_ws = active_workspaces[0] if active_workspaces else None
-
- # If no workspace is explicitly marked active, but there is only one, use it
- if not active_ws and len(workspace_conf) == 1:
- active_ws = workspace_conf[0]
- config = active_ws if active_ws else {}
- elif isinstance(workspace_conf, dict):
- config = workspace_conf
-
- validate_config(config)
- return config
-
-
-def validate_config(config: Dict[str, Any]) -> None:
- """
- Validate workspace config shape.
-
- Each project must have a ``name`` and exactly one of ``path`` (local git
- root) or ``repo`` (clone URL). At most one project may set ``primary: true``.
- """
- if not config:
- return
-
- if "name" not in config:
- raise ValueError("Workspace configuration must include a 'name'")
-
- if "projects" not in config:
- config["projects"] = []
-
- project_names = set()
- primary_count = 0
- for project in config["projects"]:
- if "name" not in project:
- raise ValueError("Each project must have a 'name'")
- has_path = bool(project.get("path"))
- has_repo = bool(project.get("repo"))
- if has_path == has_repo:
- raise ValueError(
- f"Project '{project['name']}' must have exactly one of 'path' or 'repo'"
- )
- if project.get("primary"):
- primary_count += 1
- if project["name"] in project_names:
- raise ValueError(f"Duplicate project name: {project['name']}")
- project_names.add(project["name"])
- if primary_count > 1:
- raise ValueError("Only one project may be marked primary: true")
-
-
-def find_active_workspace_name(config_arg: Optional[str] = None) -> Optional[str]:
- """
- Find the name of the active workspace from the config without resolving it fully.
- Used in main.py to automatically activate a workspace.
- """
- workspace_conf = resolve_workspace_config(config_arg)
-
- if isinstance(workspace_conf, list):
- active_ws = next((ws for ws in workspace_conf if ws.get("active")), None)
- if active_ws:
- return active_ws.get("name")
- # If there's only one workspace, it's considered active
- if len(workspace_conf) == 1:
- return workspace_conf[0].get("name")
- elif isinstance(workspace_conf, dict):
- # If it's a single dict, it's considered active by default
- return workspace_conf.get("name")
-
- return None
diff --git a/cecli/helpers/monorepo/project.py b/cecli/helpers/monorepo/project.py
deleted file mode 100644
index 516208a2296..00000000000
--- a/cecli/helpers/monorepo/project.py
+++ /dev/null
@@ -1,90 +0,0 @@
-import subprocess
-from pathlib import Path
-from typing import Any, Dict
-
-from cecli.helpers.monorepo.worktree import WorktreeManager
-
-
-class Project:
- def __init__(self, workspace_path: Path, config: Dict[str, Any]):
- self.workspace_path = workspace_path
- self.config = config
- self.name = config["name"]
- self.repo_url = config["repo"]
- self.ignore_file = config.get("ignore")
- self.base_path = workspace_path / self.name
- self.main_path = self.base_path / "main"
-
- def initialize(self) -> None:
- """Clone the repository and setup worktrees."""
- if not self.main_path.exists():
- self.main_path.mkdir(parents=True, exist_ok=True)
-
- target_branch = self.config.get("branch")
- use_current = self.config.get("use_current_branch", True)
-
- clone_cmd = ["git", "clone", "--depth", "1"]
- if target_branch and not use_current:
- clone_cmd += ["--branch", target_branch]
-
- clone_cmd += [self.repo_url, str(self.main_path)]
-
- subprocess.run(clone_cmd, check=True)
-
- # Ensure correct branch is checked out
- target_branch = self.config.get("branch")
- use_current = self.config.get("use_current_branch", True)
-
- if target_branch and not use_current:
- try:
- # Check current branch
- current_branch = subprocess.check_output(
- ["git", "-C", str(self.main_path), "rev-parse", "--abbrev-ref", "HEAD"],
- encoding="utf-8",
- ).strip()
-
- if current_branch != target_branch:
- # Try to checkout directly
- res = subprocess.run(
- ["git", "-C", str(self.main_path), "checkout", target_branch],
- check=False,
- capture_output=True,
- )
-
- if res.returncode != 0:
- # If checkout fails, check if it exists on origin
- subprocess.run(
- ["git", "-C", str(self.main_path), "fetch", "origin", target_branch],
- check=False,
- )
-
- # Try checking out the remote branch
- res = subprocess.run(
- [
- "git",
- "-C",
- str(self.main_path),
- "checkout",
- "-b",
- target_branch,
- f"origin/{target_branch}",
- ],
- check=False,
- capture_output=True,
- )
-
- if res.returncode != 0:
- # If it still fails, it doesn't exist on origin, so create it locally
- subprocess.run(
- ["git", "-C", str(self.main_path), "checkout", "-b", target_branch],
- check=True,
- )
- except Exception:
- # Fallback for unexpected errors
- pass
- # Handle worktrees
- worktrees_config = self.config.get("worktrees", [])
- if worktrees_config:
- wt_manager = WorktreeManager(self.main_path)
- for wt_cfg in worktrees_config:
- wt_manager.create(wt_cfg["name"], wt_cfg["branch"])
diff --git a/cecli/helpers/monorepo/workspace.py b/cecli/helpers/monorepo/workspace.py
deleted file mode 100644
index 8011057c74b..00000000000
--- a/cecli/helpers/monorepo/workspace.py
+++ /dev/null
@@ -1,48 +0,0 @@
-import os
-from pathlib import Path
-from typing import Any, Dict
-
-from cecli.decoding import safe_open
-from cecli.helpers.monorepo.project import Project
-
-
-class WorkspaceManager:
- def __init__(self, workspace_name: str, config: Dict[str, Any]):
- self.name = workspace_name
- self.config = config
- self.path = Path(os.path.expanduser(f"~/.cecli/workspaces/{workspace_name}"))
-
- def exists(self) -> bool:
- """Check if workspace directory exists"""
- return self.path.exists()
-
- def initialize(self) -> None:
- """Create workspace structure and clone repositories"""
- self.path.mkdir(parents=True, exist_ok=True)
-
- projects_config = self.config.get("projects", [])
- for proj_cfg in projects_config:
- project = Project(self.path, proj_cfg)
- project.initialize()
-
- # Copy ignore files to workspace root
- for proj_cfg in projects_config:
- ignore_file = proj_cfg.get("ignore")
- if ignore_file:
- ignore_path = Path(ignore_file).expanduser()
- if ignore_path.exists():
- import shutil
-
- dest_path = self.path / f"{proj_cfg['name']}.ignore"
- shutil.copy2(ignore_path, dest_path)
-
- # Write metadata
- import json
-
- metadata_path = self.path / ".cecli-workspace.json"
- with safe_open(metadata_path, "w") as f:
- json.dump(self.config, f, indent=2)
-
- def get_working_directory(self) -> Path:
- """Return workspace root path for cd"""
- return self.path
diff --git a/cecli/helpers/monorepo/worktree.py b/cecli/helpers/monorepo/worktree.py
deleted file mode 100644
index 22be86b35f5..00000000000
--- a/cecli/helpers/monorepo/worktree.py
+++ /dev/null
@@ -1,21 +0,0 @@
-import subprocess
-from pathlib import Path
-
-
-class WorktreeManager:
- def __init__(self, main_repo_path: Path):
- self.main_repo_path = main_repo_path
- self.worktrees_dir = main_repo_path.parent / "worktrees"
-
- def create(self, name: str, branch: str) -> None:
- """Create a git worktree."""
- wt_path = self.worktrees_dir / name
- if wt_path.exists():
- return
-
- self.worktrees_dir.mkdir(parents=True, exist_ok=True)
-
- subprocess.run(
- ["git", "-C", str(self.main_repo_path), "worktree", "add", str(wt_path), branch],
- check=True,
- )
diff --git a/cecli/helpers/orchestration/environment.py b/cecli/helpers/orchestration/environment.py
index 0d301da54d5..0926c11665f 100644
--- a/cecli/helpers/orchestration/environment.py
+++ b/cecli/helpers/orchestration/environment.py
@@ -545,9 +545,10 @@ def build_orchestration_context_block(agent_config: dict[str, Any]) -> str | Non
orchestration_config = agent_config.get("orchestration", {})
context = """
-The `Orchestrate` tool runs Python in a sandbox where you can call other tools programmatically.
-Use it for batch or loop-heavy workflows.
+The `Orchestrate` tool runs Python in a sandbox where you can script other tools programmatically.
+Use it for batch, loop-heavy, and repeat-able workflows.
Variables and helpers persist across calls; `state` persists across all Orchestrate calls in the session.
+You may need to explore the below primitives to understand how to use the sandbox effectively.
### Primitives
@@ -556,7 +557,7 @@ def build_orchestration_context_block(agent_config: dict[str, Any]) -> str | Non
| `Agent.allowed_methods()` / `Agent.allowed_tools()` | List helper methods and available tools |
| `Agent.get_tool(name)` | Get a tool proxy (case-insensitive; `Local--` / `{{Server Name}}--` prefixes ok) |
| `await tool.call(**params)` | Run a tool; returns `{"result": [...], "errors": [...], "details": [...]}`, items with shape `{"content", "_"}` |
-| `Agent.peek(result)` / `Agent.get_value(result, "path", default?)` | Inspect / extract values from tool results |
+| `Agent.peek(result)` / `Agent.get_value(result, path, default?)` | Inspect / extract values from tool results. path is dot-separated string |
| `Agent.resolve_regions(path, specs)` / `Agent.edit_region(path, edits)` | Resolve text boundaries once, then apply edits |
| `gather(**tasks)` | Run tasks concurrently; results expose `.key` and `["key"]` |
| `state` / `shared_state` | Persistent dicts; `state.get(k)` falls back to `shared_state` |
diff --git a/cecli/helpers/skills.py b/cecli/helpers/skills.py
index 7d6fbea31f4..2c8d11e9897 100644
--- a/cecli/helpers/skills.py
+++ b/cecli/helpers/skills.py
@@ -52,7 +52,7 @@ def __init__(
include_list: Optional[List[str]] = None,
exclude_list: Optional[List[str]] = None,
initialize_list: Optional[List[str]] = None,
- git_root: Optional[str] = None,
+ root: Optional[str] = None,
coder=None,
):
"""
@@ -63,7 +63,7 @@ def __init__(
include_list: Optional list of skill names to include (whitelist)
exclude_list: Optional list of skill names to exclude (blacklist)
initialize_list: Optional list of skill names to load and include on startup
- git_root: Optional git root directory for relative path resolution
+ root: Optional base root directory for relative path resolution
coder: Optional reference to the coder instance (weak reference)
"""
# Always include the default skills directory in the user's home
@@ -71,12 +71,29 @@ def __init__(
if default_skill_dir not in directory_paths:
directory_paths = [default_skill_dir] + list(directory_paths)
- # Resolve every path and drop exact directory duplicates.
+ # Local path resolution base: use root so sub-agents with an overridden
+ # local root still resolve project skills relative to the primary
+ # workspace (instead of the working directory).
+ local_anchor = Path(root).expanduser().resolve() if root else Path.cwd()
+
+ # Also include the local default skills directory under the coder root
+ # so project-scoped skills can live in {root}/.cecli/skills alongside the
+ # global ~/.cecli/skills default.
+ if root:
+ local_default_skill_dir = str(local_anchor / ".cecli" / "skills")
+ if local_default_skill_dir not in directory_paths:
+ directory_paths.append(local_default_skill_dir)
+
+ # Resolve every path relative to local_anchor and drop exact duplicates.
resolved_paths = []
seen_paths = set()
for p in directory_paths:
try:
- path = Path(p).expanduser().resolve()
+ candidate = Path(p).expanduser()
+ if candidate.is_absolute():
+ path = candidate.resolve()
+ else:
+ path = (local_anchor / candidate).resolve()
except Exception:
continue
if path in seen_paths:
@@ -85,18 +102,17 @@ def __init__(
resolved_paths.append(path)
# Order paths: local project dirs first, then configured, home dirs last.
- git_root_path = Path(git_root).expanduser().resolve() if git_root else None
ordered = sorted(
enumerate(resolved_paths),
key=lambda item: (
- self._directory_priority(item[1], git_root_path),
+ self._directory_priority(item[1], local_anchor),
item[0],
),
)
self.directory_paths = [path for _, path in ordered]
self.include_list = set(include_list) if include_list else None
self.exclude_list = set(exclude_list) if exclude_list else set()
- self.git_root = Path(git_root).expanduser().resolve() if git_root else None
+ self.root = Path(root).expanduser().resolve() if root else None
self._coder_ref = weakref.ref(coder) if coder else None # Weak reference to coder instance
# Cache for loaded skills
@@ -134,12 +150,12 @@ def __init__(
# Save initial state from config
@staticmethod
- def _directory_priority(path: Path, git_root: Optional[Path] = None) -> int:
+ def _directory_priority(path: Path, root: Optional[Path] = None) -> int:
"""Return the ordering priority of a skill directory.
Lower values are scanned first and therefore win by-name conflicts:
- 0 - local project directory (under the git root or working directory)
+ 0 - local project directory (under the root or working directory)
1 - any other configured directory
2 - a home directory (including the implicit ``~/.cecli/skills`` default)
"""
@@ -149,7 +165,7 @@ def _directory_priority(path: Path, git_root: Optional[Path] = None) -> int:
if path == (home / ".cecli" / "skills"):
return 2
- local_anchor = git_root if git_root is not None else Path.cwd()
+ local_anchor = root if root is not None else Path.cwd()
try:
path.relative_to(local_anchor)
return 0
@@ -834,7 +850,7 @@ def skill_summary_loader(
directory_paths: List[str],
include_list: Optional[List[str]] = None,
exclude_list: Optional[List[str]] = None,
- git_root: Optional[str] = None,
+ root: Optional[str] = None,
) -> str:
"""
High-level function to load and summarize all available skills.
@@ -843,12 +859,12 @@ def skill_summary_loader(
directory_paths: List of directory paths to search for skills
include_list: Optional list of skill names to include (whitelist)
exclude_list: Optional list of skill names to exclude (blacklist)
- git_root: Optional git root directory for relative path resolution
+ root: Optional base root directory for relative path resolution
Returns:
Formatted summary of all available skills
"""
- manager = cls(directory_paths, include_list, exclude_list, git_root)
+ manager = cls(directory_paths, include_list, exclude_list, root)
summaries = manager.get_all_skill_summaries()
if not summaries:
@@ -862,15 +878,13 @@ def skill_summary_loader(
return result
@staticmethod
- def resolve_skill_directories(
- base_paths: List[str], git_root: Optional[str] = None
- ) -> List[Path]:
+ def resolve_skill_directories(base_paths: List[str], root: Optional[str] = None) -> List[Path]:
"""
Resolve skill directory paths relative to various locations.
Args:
base_paths: List of base directory paths
- git_root: Optional git root directory
+ root: Optional base root directory
Returns:
List of resolved Path objects
@@ -878,9 +892,9 @@ def resolve_skill_directories(
resolved_paths = []
for base_path in base_paths:
- # Try to resolve relative to git root first
- if git_root and not Path(base_path).is_absolute():
- git_path = Path(git_root) / base_path
+ # Try to resolve relative to root first
+ if root and not Path(base_path).is_absolute():
+ git_path = Path(root) / base_path
if git_path.exists():
resolved_paths.append(git_path.resolve())
continue
diff --git a/cecli/helpers/workspaces/__init__.py b/cecli/helpers/workspaces/__init__.py
new file mode 100644
index 00000000000..c15daf112fd
--- /dev/null
+++ b/cecli/helpers/workspaces/__init__.py
@@ -0,0 +1,13 @@
+"""Workspaces: local multi-project workspaces with implicit ``ws:{name}`` sub-agents.
+
+Replaces the former ``cecli.helpers.monorepo`` module. Workspaces are driven
+entirely by a local ``.cecli.workspaces.yml`` configuration file that lists
+existing git roots via ``path:`` entries. Each project automatically gets an
+implicit ``ws:{project}`` sub-agent (mirroring the ``worker`` default) whose
+``root`` points at that project, so workspaces can be spun up as agents.
+"""
+
+from .subagents import register_workspace_subagents
+from .workspace import WorkspaceManager
+
+__all__ = ["register_workspace_subagents", "WorkspaceManager"]
diff --git a/cecli/helpers/workspaces/config.py b/cecli/helpers/workspaces/config.py
new file mode 100644
index 00000000000..80121bc9ef2
--- /dev/null
+++ b/cecli/helpers/workspaces/config.py
@@ -0,0 +1,189 @@
+"""Workspace configuration loading and validation (local, path-based).
+
+Workspaces are defined by a local ``.cecli.workspaces.yml`` file listing
+existing git roots via ``path:`` entries. There is no ``repo:``/clone mode:
+every project must point at an on-disk git root.
+"""
+
+from __future__ import annotations
+
+import json
+from pathlib import Path
+from typing import Any, Dict, Optional
+
+import yaml
+
+from cecli.decoding import safe_open
+
+WORKSPACE_FILENAMES = (".cecli.workspaces.yml", ".cecli.workspaces.yaml")
+
+
+def resolve_workspace_config(config_arg: Optional[str] = None) -> Optional[Any]:
+ """Resolve the raw workspace config from the same hierarchy as before:
+
+ 1. ``config_arg`` (JSON/YAML string or path to a file)
+ 2. Local ``.cecli.workspaces.yml`` / ``.cecli.workspaces.yaml``
+ 3. Global ``~/.cecli/workspaces.yml`` / ``.cecli/workspaces.yaml``
+ """
+ workspace_conf = None
+
+ if config_arg:
+ candidate = Path(config_arg).expanduser()
+ if candidate.is_file():
+ workspace_conf = _load_yaml_file(candidate)
+ else:
+ workspace_conf = _parse_workspace_string(config_arg)
+
+ if not workspace_conf:
+ for name in WORKSPACE_FILENAMES:
+ local_path = Path(name)
+ if local_path.is_file():
+ workspace_conf = _load_yaml_file(local_path)
+ if workspace_conf:
+ break
+
+ if not workspace_conf:
+ for name in ("workspaces.yml", "workspaces.yaml"):
+ global_path = Path.home() / ".cecli" / name
+ if global_path.is_file():
+ workspace_conf = _load_yaml_file(global_path)
+ if workspace_conf:
+ break
+
+ return workspace_conf
+
+
+def _load_yaml_file(path: Path) -> Optional[Any]:
+ try:
+ with safe_open(path, "r") as f:
+ loaded = yaml.safe_load(f)
+ except Exception:
+ return None
+ if not loaded:
+ return None
+ if isinstance(loaded, dict):
+ return loaded.get("workspaces") or loaded.get("workspace") or loaded
+ return loaded
+
+
+def _parse_workspace_string(config_arg: str) -> Optional[Any]:
+ try:
+ loaded = json.loads(config_arg)
+ except (json.JSONDecodeError, TypeError):
+ try:
+ loaded = yaml.safe_load(config_arg)
+ except yaml.YAMLError:
+ return None
+ if isinstance(loaded, dict):
+ return loaded.get("workspaces") or loaded.get("workspace") or loaded
+ return loaded
+
+
+def load_workspace_config_file(path: Path) -> Dict[str, Any]:
+ """Load and validate a repo-local ``.cecli.workspaces.yml`` file."""
+ from .paths import load_workspace_file
+
+ config = load_workspace_file(path)
+ validate_config(config)
+ return config
+
+
+def load_workspace_config(
+ config_arg: Optional[str] = None,
+ name: Optional[str] = None,
+) -> Dict[str, Any]:
+ """Load workspace config from the hierarchy, optionally selecting by name."""
+ workspace_conf = resolve_workspace_config(config_arg)
+
+ config: Dict[str, Any] = {}
+ if isinstance(workspace_conf, list):
+ if name:
+ selected = next((ws for ws in workspace_conf if ws.get("name") == name), None)
+ if not selected:
+ raise ValueError(f"Workspace '{name}' not found in configuration")
+ config = selected
+ else:
+ active = [ws for ws in workspace_conf if ws.get("active")]
+ if len(active) > 1:
+ names = [ws.get("name", "unknown") for ws in active]
+ raise ValueError(f"Multiple workspaces marked as active: {', '.join(names)}")
+ active_ws = active[0] if active else None
+ if not active_ws and len(workspace_conf) == 1:
+ active_ws = workspace_conf[0]
+ config = active_ws if active_ws else {}
+ elif isinstance(workspace_conf, dict):
+ config = workspace_conf
+
+ validate_config(config)
+ return config
+
+
+def validate_config(config: Dict[str, Any]) -> None:
+ """Validate a workspace config.
+
+ Each project must have a ``name`` and **exactly one** of ``path`` (local
+ git root) or ``repo`` (clone URL). At most one project may set
+ ``primary: true``.
+ """
+ if not config:
+ return
+
+ if "name" not in config:
+ raise ValueError("Workspace configuration must include a 'name'")
+
+ if "projects" not in config:
+ config["projects"] = []
+
+ project_names = set()
+ primary_count = 0
+ for project in config["projects"]:
+ if "name" not in project:
+ raise ValueError("Each project must have a 'name'")
+ has_path = bool(project.get("path"))
+ has_repo = bool(project.get("repo"))
+ if not (has_path or has_repo):
+ raise ValueError(
+ f"Project '{project['name']}' must have exactly one of 'path' or 'repo'"
+ )
+ if has_path and has_repo:
+ raise ValueError(
+ f"Project '{project['name']}' must have exactly one of 'path' or 'repo'"
+ )
+ if project.get("primary"):
+ primary_count += 1
+ if project["name"] in project_names:
+ raise ValueError(f"Duplicate project name: {project['name']}")
+ project_names.add(project["name"])
+
+ if primary_count > 1:
+ raise ValueError("Only one project may be marked primary: true")
+
+
+def workspace_layout(config: Dict[str, Any]) -> str:
+ """Return the workspace layout: ``clone`` if any project uses ``repo``, else ``local``.
+
+ An explicit ``layout`` field on the workspace overrides the inference.
+ """
+ explicit = config.get("layout")
+ if explicit in ("clone", "local"):
+ return explicit
+ for proj in config.get("projects") or []:
+ if proj.get("repo"):
+ return "clone"
+ return "local"
+
+
+def find_active_workspace_name(config_arg: Optional[str] = None) -> Optional[str]:
+ """Return the active workspace name without fully resolving it."""
+ workspace_conf = resolve_workspace_config(config_arg)
+
+ if isinstance(workspace_conf, list):
+ active = next((ws for ws in workspace_conf if ws.get("active")), None)
+ if active:
+ return active.get("name")
+ if len(workspace_conf) == 1:
+ return workspace_conf[0].get("name")
+ elif isinstance(workspace_conf, dict):
+ return workspace_conf.get("name")
+
+ return None
diff --git a/cecli/helpers/monorepo/local_workspace.py b/cecli/helpers/workspaces/paths.py
similarity index 69%
rename from cecli/helpers/monorepo/local_workspace.py
rename to cecli/helpers/workspaces/paths.py
index 51df9b486c9..17f6000d6db 100644
--- a/cecli/helpers/monorepo/local_workspace.py
+++ b/cecli/helpers/workspaces/paths.py
@@ -1,18 +1,11 @@
-"""
-Repo-local multi-project workspaces (``path:`` git roots).
-
-Cecli already supports **clone** workspaces under ``~/.cecli/workspaces/`` with
-``repo:`` URLs and paths like ``{project}/main/{file}``. This module adds
-**local** layout: projects point at existing directories on disk, and tracked
-paths are prefixed as ``{project}/{file}`` (no ``/main/`` segment).
-
-Config file names (at the workspace root — usually the primary project directory):
+"""Path helpers for local and clone workspaces.
-- ``.cecli.workspaces.yml``
-- ``.cecli.workspaces.yaml``
+A workspace lists projects that are either:
-Each project must have exactly one of ``path`` (absolute local git root) or
-``repo`` (clone URL; handled by existing clone workspace code).
+- **local** — an existing git root referenced by an absolute ``path:``; tracked
+ paths are prefixed ``{project}/{file}``.
+- **clone** — a remote ``repo:`` URL cloned under ``~/.cecli/workspaces/``;
+ tracked paths are prefixed ``{project}/main/{file}``.
"""
from __future__ import annotations
@@ -20,7 +13,7 @@
import json
import subprocess
from pathlib import Path
-from typing import Any
+from typing import Any, Callable
import yaml
@@ -61,32 +54,42 @@ def primary_project(config: dict[str, Any]) -> dict[str, Any] | None:
for proj in projects:
if proj.get("primary"):
return proj
- if len(projects) == 1:
- return projects[0]
return projects[0] if projects else None
-def project_git_root(workspace_root: Path, project: dict[str, Any], *, layout: str) -> Path | None:
+def project_path(workspace_root: Path, project: dict[str, Any], *, layout: str) -> Path | None:
+ """Resolve a project's on-disk git root for the given layout, or None."""
name = project.get("name")
if not name:
return None
- path_val = project.get("path")
- if path_val:
- root = Path(str(path_val)).expanduser().resolve()
- if not root.is_dir():
+
+ if layout == "clone":
+ clone_root = workspace_root / name / "main"
+ if not clone_root.is_dir():
return None
try:
subprocess.check_output(
- ["git", "-C", str(root), "rev-parse", "--show-toplevel"],
+ ["git", "-C", str(clone_root), "rev-parse", "--show-toplevel"],
stderr=subprocess.DEVNULL,
)
- return root
+ return clone_root.resolve()
except Exception:
return None
- if layout != "clone":
+
+ path_val = project.get("path")
+ if not path_val:
+ return None
+ root = Path(str(path_val)).expanduser().resolve()
+ if not root.is_dir():
+ return None
+ try:
+ subprocess.check_output(
+ ["git", "-C", str(root), "rev-parse", "--show-toplevel"],
+ stderr=subprocess.DEVNULL,
+ )
+ return root
+ except Exception:
return None
- clone_root = workspace_root / name / "main"
- return clone_root if clone_root.is_dir() else None
def project_path_prefix(project: dict[str, Any], *, layout: str) -> str:
@@ -103,15 +106,19 @@ def resolve_workspace_file_path(
*,
layout: str,
) -> tuple[Path, Path, str] | None:
- """
- Map a workspace-relative path to ``(project_git_root, absolute_file, path_in_project_repo)``.
+ """Map a workspace-relative path to ``(project_git_root, abs_file, path_in_repo)``.
+
+ ``workspace_rel`` is ``{project}/{file}`` (local) or ``{project}/main/{file}``
+ (clone). If the leading segment is not a project name, it resolves against
+ the primary project.
"""
rel = workspace_rel.replace("\\", "/").lstrip("/")
if not rel:
return None
- parts = Path(rel).parts
+ parts = rel.split("/")
if not parts:
return None
+
projects = config.get("projects") or []
by_name = {str(p.get("name")): p for p in projects if p.get("name")}
@@ -120,17 +127,17 @@ def resolve_workspace_file_path(
proj = by_name.get(parts[0])
if not proj:
return None
- git_root = project_git_root(workspace_root, proj, layout=layout)
+ git_root = project_path(workspace_root, proj, layout=layout)
if not git_root:
return None
in_repo = "/".join(parts[2:]) if len(parts) > 2 else ""
abs_path = git_root / in_repo if in_repo else git_root
return git_root, abs_path, in_repo
- # Local layout: name/rest or bare path under primary-only tree
+ # name/rest for local, or name/main/rest handled above
if parts[0] in by_name:
proj = by_name[parts[0]]
- git_root = project_git_root(workspace_root, proj, layout=layout)
+ git_root = project_path(workspace_root, proj, layout=layout)
if not git_root:
return None
in_repo = "/".join(parts[1:]) if len(parts) > 1 else ""
@@ -139,10 +146,9 @@ def resolve_workspace_file_path(
primary = primary_project(config)
if primary:
- git_root = project_git_root(workspace_root, primary, layout=layout)
+ git_root = project_path(workspace_root, primary, layout=layout)
if git_root:
- in_repo = rel
- return git_root, git_root / in_repo, in_repo
+ return git_root, git_root / rel, rel
return None
@@ -151,15 +157,15 @@ def union_tracked_files(
config: dict[str, Any],
*,
layout: str,
- ignored_file=None,
+ ignored_file: Callable[[str], bool] | None = None,
) -> list[str]:
- """All tracked files as workspace-relative paths."""
+ """All tracked files as workspace-relative paths for the given layout."""
out: list[str] = []
for proj in config.get("projects") or []:
name = proj.get("name")
if not name:
continue
- git_root = project_git_root(workspace_root, proj, layout=layout)
+ git_root = project_path(workspace_root, proj, layout=layout)
if not git_root:
continue
prefix = project_path_prefix(proj, layout=layout)
@@ -174,7 +180,7 @@ def union_tracked_files(
for line in lines:
if not line.strip():
continue
- rel = f"{prefix}/{line}" if prefix else line
+ rel = f"{prefix}/{line}"
rel = rel.replace("\\", "/")
if ignored_file and ignored_file(rel):
continue
@@ -193,7 +199,7 @@ def project_head_shas(
name = proj.get("name")
if not name:
continue
- git_root = project_git_root(workspace_root, proj, layout=layout)
+ git_root = project_path(workspace_root, proj, layout=layout)
if not git_root:
shas.append(f"{name}:unknown")
continue
@@ -209,25 +215,22 @@ def project_head_shas(
return shas
-def write_workspace_metadata(workspace_root: Path, config: dict[str, Any], *, layout: str) -> None:
+def write_workspace_metadata(workspace_root: Path, config: dict[str, Any]) -> None:
meta_dir = workspace_root / ".cecli"
meta_dir.mkdir(parents=True, exist_ok=True)
- payload = {**config, "_layout": layout}
(meta_dir / ".workspace-meta.json").write_text(
- json.dumps(payload, indent=2),
+ json.dumps(config, indent=2),
encoding="utf-8",
)
-def read_workspace_metadata(workspace_root: Path) -> tuple[dict[str, Any], str] | None:
- legacy = workspace_root / ".cecli-workspace.json"
+def read_workspace_metadata(workspace_root: Path) -> dict[str, Any] | None:
modern = workspace_root / METADATA_NAME
+ legacy = workspace_root / ".cecli-workspace.json"
path = modern if modern.is_file() else legacy if legacy.is_file() else None
if not path:
return None
try:
- data = json.loads(path.read_text(encoding="utf-8"))
- layout = data.pop("_layout", "clone")
- return data, layout
+ return json.loads(path.read_text(encoding="utf-8"))
except Exception:
return None
diff --git a/cecli/helpers/workspaces/subagents.py b/cecli/helpers/workspaces/subagents.py
new file mode 100644
index 00000000000..7dd364da936
--- /dev/null
+++ b/cecli/helpers/workspaces/subagents.py
@@ -0,0 +1,121 @@
+"""Implicit ``ws:{name}`` workspace sub-agents.
+
+When a workspace is active, each project becomes a sub-agent named
+``ws:{project}``. The agent mirrors the ``worker`` default sub-agent but has
+its ``root`` overridden to the project's git root and ``allow_nested_delegation``
+enabled so it can itself serve as a base for further delegations.
+"""
+
+from __future__ import annotations
+
+import logging
+import os
+from pathlib import Path
+from typing import Any, Dict, List, Optional
+
+from .config import workspace_layout
+from .paths import project_path
+
+logger = logging.getLogger(__name__)
+
+
+def register_workspace_subagents(
+ workspace_config: Dict[str, Any] | None,
+ workspace_root: Optional[Path | str] = None,
+) -> List[str]:
+ """Create and register a ``ws:{name}`` sub-agent for each workspace project.
+
+ Each project may define a ``metadata`` block that supplies its sub-agent
+ setup the same way a sub-agent .md file does: ``model`` / ``hooks`` /
+ ``auto_reap`` become the config fields, and any other keys (e.g.
+ ``agent-config``) are merged into the sub-agent metadata.
+
+ ``root``, ``name`` and ``description`` are always derived from the
+ workspace/project definition and cannot be overridden by the metadata block.
+
+ Returns the list of registered agent names.
+ """
+ from cecli.helpers.agents.config import SubAgentConfig
+ from cecli.helpers.agents.service import AgentService
+
+ config = workspace_config or {}
+ projects = config.get("projects") or []
+
+ # Make sure the built-in defaults (including ``worker``) are loaded so the
+ # workspace agents can mirror them, regardless of call ordering.
+ if "worker" not in AgentService.get_registry():
+ AgentService.build_registry([])
+
+ worker = AgentService.get_registry().get("worker")
+
+ layout = workspace_layout(config)
+ if workspace_root is not None:
+ root_base = Path(workspace_root).resolve()
+ elif layout == "clone":
+ root_base = Path(os.path.expanduser(f"~/.cecli/workspaces/{config.get('name')}"))
+ else:
+ root_base = Path(".")
+
+ registered: List[str] = []
+ for proj in projects:
+ name = proj.get("name")
+ if not name:
+ continue
+ root = project_path(root_base, proj, layout=layout)
+ if not root:
+ continue
+
+ # A project may supply its own sub-agent setup under ``metadata``,
+ # matching how .md sub-agent definitions do: ``model`` / ``hooks`` /
+ # ``auto_reap`` map to the config fields; everything else is merged
+ # into the sub-agent metadata.
+ project_meta = dict(proj.get("metadata") or {})
+
+ # ``root``, ``name`` and ``description`` are always derived from the
+ # workspace/project definition and cannot be overridden by the metadata block.
+ project_meta.pop("root", None)
+ project_meta.pop("name", None)
+ project_meta.pop("description", None)
+ config_keys = {"model", "hooks", "auto_reap"}
+
+ model = project_meta.get("model", worker.model if worker else None)
+ hooks = (
+ dict(project_meta["hooks"])
+ if "hooks" in project_meta
+ else (dict(worker.hooks) if worker else {})
+ )
+ auto_reap = (
+ project_meta["auto_reap"]
+ if "auto_reap" in project_meta
+ else (worker.auto_reap if worker else None)
+ )
+
+ agent_name = f"ws:{name}"
+ metadata = dict(worker.metadata) if worker else {}
+ for key, value in project_meta.items():
+ if key in config_keys:
+ continue
+ metadata[key] = value
+ metadata["root"] = str(root)
+ metadata["layout"] = layout
+
+ agent_config = dict(metadata.get("agent-config") or {})
+ agent_config["allow_nested_delegation"] = True
+ metadata["agent-config"] = agent_config
+
+ metadata["description"] = f"Workspace sub-agent for project '{name}' at path {root}"
+
+ agent = SubAgentConfig(
+ name=agent_name,
+ prompt=(worker.prompt if worker else ""),
+ model=model,
+ hooks=hooks,
+ auto_reap=auto_reap,
+ metadata=metadata,
+ )
+
+ AgentService.register_subagent(agent_name, agent)
+ registered.append(agent_name)
+ logger.info("Registered workspace sub-agent '%s' -> %s", agent_name, root)
+
+ return registered
diff --git a/cecli/helpers/workspaces/workspace.py b/cecli/helpers/workspaces/workspace.py
new file mode 100644
index 00000000000..08e461449f7
--- /dev/null
+++ b/cecli/helpers/workspaces/workspace.py
@@ -0,0 +1,100 @@
+"""Workspace manager supporting clone and local layouts.
+
+- **clone** — projects with a ``repo:`` URL are cloned under
+ ``~/.cecli/workspaces/{name}/{project}/main``; the working directory is the
+ workspace root.
+- **local** — projects point at existing on-disk git roots via ``path:``; the
+ working directory is the primary project's git root.
+"""
+
+from __future__ import annotations
+
+import os
+import shutil
+import subprocess
+from pathlib import Path
+from typing import Any, Dict, Optional
+
+from .config import workspace_layout
+from .paths import primary_project, project_path
+
+
+class WorkspaceManager:
+ def __init__(
+ self,
+ workspace_name: str,
+ config: Dict[str, Any],
+ root: Optional[Path | str] = None,
+ ):
+ self.name = workspace_name
+ self.config = config
+ self.layout = workspace_layout(config)
+
+ if self.layout == "clone":
+ self.path = Path(os.path.expanduser(f"~/.cecli/workspaces/{workspace_name}"))
+ elif root is not None:
+ self.path = Path(root).resolve()
+ else:
+ primary = primary_project(config)
+ self.path = (
+ Path(str(primary["path"])).expanduser().resolve()
+ if primary and primary.get("path")
+ else Path.cwd().resolve()
+ )
+ self.root = self.path
+
+ def exists(self) -> bool:
+ """Check whether the workspace root directory exists."""
+ return self.path.exists()
+
+ def initialize(self) -> None:
+ """Create the workspace root, clone ``repo:`` projects, and write metadata."""
+ self.path.mkdir(parents=True, exist_ok=True)
+
+ if self.layout == "clone":
+ for proj in self.config.get("projects") or []:
+ if proj.get("repo"):
+ self._clone_project(self.path, proj)
+
+ from .paths import write_workspace_metadata
+
+ write_workspace_metadata(self.path, self.config)
+
+ def get_working_directory(self) -> Path:
+ """Return the workspace root (clone) or the primary project's git root (local)."""
+ if self.layout == "clone":
+ return self.path
+ primary = primary_project(self.config)
+ if primary:
+ root = project_path(self.path, primary, layout="local")
+ if root:
+ return root
+ return self.path
+
+ def _clone_project(self, workspace_root: Path, project: Dict[str, Any]) -> None:
+ """Clone a ``repo:`` project under ``{workspace_root}/{name}/main``."""
+ name = project.get("name")
+ repo_url = project.get("repo")
+ if not name or not repo_url:
+ return
+
+ main_path = workspace_root / name / "main"
+ if main_path.exists():
+ return
+ main_path.mkdir(parents=True, exist_ok=True)
+
+ target_branch = project.get("branch")
+ use_current = project.get("use_current_branch", True)
+
+ clone_cmd = ["git", "clone", "--depth", "1"]
+ if target_branch and not use_current:
+ clone_cmd += ["--branch", target_branch]
+ clone_cmd += [repo_url, str(main_path)]
+
+ subprocess.run(clone_cmd, check=True)
+
+ ignore_file = project.get("ignore")
+ if ignore_file:
+ ignore_path = Path(ignore_file).expanduser()
+ if ignore_path.exists():
+ shutil.copy2(ignore_path, workspace_root / f"{name}.ignore")
diff --git a/cecli/io.py b/cecli/io.py
index caf97bacfa6..a3bf286315f 100644
--- a/cecli/io.py
+++ b/cecli/io.py
@@ -1354,6 +1354,13 @@ async def _confirm_ask(
while True:
try:
if self.prompt_session:
+ if (
+ getattr(
+ getattr(self.prompt_session, "app", None), "_is_running", False
+ )
+ is True
+ ):
+ return True
# Call prompt_async directly instead of using input_task
# This allows KeyboardInterrupt to propagate properly
res = await self.prompt_session.prompt_async(question)
diff --git a/cecli/main.py b/cecli/main.py
index 262f7ab1646..dd40d83b99e 100644
--- a/cecli/main.py
+++ b/cecli/main.py
@@ -627,7 +627,7 @@ async def main_async(
from cecli.mcp import McpServerManager, load_mcp_servers
from cecli.models import ModelSettings
from cecli.onboarding import offer_openrouter_oauth, select_default_model
- from cecli.repo import GitRepo, GitRepoProxy
+ from cecli.repo import GitRepoProxy
from cecli.report import report_uncaught_exceptions, set_args_error_data
from cecli.versioncheck import check_version
from cecli.watch import FileWatcher
@@ -689,13 +689,14 @@ async def main_async(
args, unknown = parser.parse_known_args(argv)
os.unlink(_tmp_cfg)
- uses_workspace = False
if args.workspaces or args.workspace_name:
- from cecli.helpers.monorepo.config import (
+ from cecli.helpers.workspaces.config import (
find_active_workspace_name,
load_workspace_config,
+ workspace_layout,
)
- from cecli.helpers.monorepo.workspace import WorkspaceManager
+ from cecli.helpers.workspaces.subagents import register_workspace_subagents
+ from cecli.helpers.workspaces.workspace import WorkspaceManager
# Interpolate environment variables in the workspaces argument
if args.workspaces:
@@ -705,14 +706,17 @@ async def main_async(
ws_name = args.workspace_name or find_active_workspace_name(ws_config_arg)
if ws_name:
config = load_workspace_config(ws_config_arg, name=ws_name)
- workspace_manager = WorkspaceManager(ws_name, config)
- if not workspace_manager.exists():
- workspace_manager.initialize()
+ # Clone workspaces must be materialised so the implicit ws:{name}
+ # sub-agents can point their roots at the cloned checkouts. The
+ # primary agent's root is left unchanged: workspaces are just
+ # sub-agents with overridden roots.
+ if workspace_layout(config) == "clone":
+ wm = WorkspaceManager(ws_name, config)
+ if not wm.exists():
+ wm.initialize()
- os.chdir(workspace_manager.get_working_directory())
- git_root = get_git_root()
- uses_workspace = True
+ register_workspace_subagents(config)
if git_root:
git_conf = Path(git_root) / conf_fname
@@ -720,26 +724,6 @@ async def main_async(
all_config_paths.append(str(git_conf))
cecli_conf_yml_files.append(str(git_conf))
- # ── Re-merge if workspace changed git_root ─────────────────────────
- if uses_workspace:
- merged_config = config_utils.read_and_merge_all_configs(
- all_config_paths, conf_yml_files, cecli_conf_yml_files
- )
-
- _tmp_fd, _tmp_cfg = tempfile.mkstemp(suffix=".yml", prefix="cecli_merged_")
- os.close(_tmp_fd)
-
- with safe_open(_tmp_cfg, "w") as f:
- yaml.dump(merged_config, f)
-
- parser = get_parser([_tmp_cfg], git_root)
- args, unknown = parser.parse_known_args(argv)
-
- # Re-load dotenv files in case the new git_root has an env_file
- loaded_dotenvs = load_dotenv_files(git_root, args.env_file, args.encoding)
- os.unlink(_tmp_cfg)
- args, unknown = parser.parse_known_args(argv)
-
set_args_error_data(args)
# Override the module-level default encoding with the resolved config value
@@ -1004,7 +988,7 @@ def get_io(pretty):
return await main_async(argv, input, output, right_repo_root, return_coder=return_coder)
if (args.check_update or args.upgrade) and not args.just_check_update and not suppress_pre_init:
- await check_version(pre_init_io, verbose=args.verbose)
+ await check_version(pre_init_io, verbose=args.verbose, upgrade=args.upgrade)
elif args.just_check_update:
update_available = await check_version(pre_init_io, just_check=True, verbose=args.verbose)
return await graceful_exit(None, 0 if not update_available else 1)
@@ -1185,22 +1169,21 @@ def get_io(pretty):
repo = None
if args.git:
try:
- repo = GitRepoProxy(
- GitRepo(
- io,
- fnames,
- git_dname,
- args.cecli_ignore,
- models=main_model.commit_message_models(),
- attribute_author=args.attribute_author,
- attribute_committer=args.attribute_committer,
- attribute_commit_message_author=args.attribute_commit_message_author,
- attribute_commit_message_committer=args.attribute_commit_message_committer,
- commit_prompt=args.commit_prompt,
- subtree_only=args.subtree_only,
- git_commit_verify=args.git_commit_verify,
- attribute_co_authored_by=args.attribute_co_authored_by,
- )
+ repo = GitRepoProxy.for_root(
+ None,
+ io,
+ fnames=fnames,
+ git_dname=git_dname,
+ cecli_ignore_file=args.cecli_ignore,
+ models=main_model.commit_message_models(),
+ attribute_author=args.attribute_author,
+ attribute_committer=args.attribute_committer,
+ attribute_commit_message_author=args.attribute_commit_message_author,
+ attribute_commit_message_committer=args.attribute_commit_message_committer,
+ commit_prompt=args.commit_prompt,
+ subtree_only=args.subtree_only,
+ git_commit_verify=args.git_commit_verify,
+ attribute_co_authored_by=args.attribute_co_authored_by,
)
except FileNotFoundError:
pass
diff --git a/cecli/mcp/server.py b/cecli/mcp/server.py
index 108b8ea73c3..c1c29918f97 100644
--- a/cecli/mcp/server.py
+++ b/cecli/mcp/server.py
@@ -68,7 +68,14 @@ def __init__(self, server_config, io=None, verbose=False):
# concurrent disconnect on the same loop never blocks the loop thread.
self._cleanup_lock: threading.Lock = threading.Lock()
self._disconnecting = False
+ # The session (and its transport context) is owned by a single task so
+ # the AnyIO task group inside the transport is entered and exited on the
+ # same task, no matter which task calls disconnect().
self.exit_stack = AsyncExitStack()
+ self._cancel_keepalive_on_close = True
+ self._session_task: asyncio.Task | None = None
+ self._session_ready: asyncio.Future | None = None
+ self._shutdown_event: asyncio.Event | None = None
@property
def is_connected(self) -> bool:
@@ -78,16 +85,21 @@ def is_connected(self) -> bool:
async def connect(self):
"""Connect to the MCP server and return the session.
- If a session is already active, returns the existing session.
- Otherwise, establishes a new connection and initializes the session.
+ If a session is already active, returns the existing session. Otherwise,
+ establishes a new connection and initializes the session.
+
+ The session is owned by a dedicated task that both opens and closes the
+ transport, so the AnyIO task group inside the transport context is always
+ entered and exited on the same task, regardless of which task later calls
+ disconnect().
Returns:
ClientSession: The active session if mcp is not disabled
"""
current_loop = asyncio.get_running_loop()
if self.session is not None:
- # Event loop affinity check: streams from stdio_client() are bound
- # to the loop that created them. Reconnect if the loop changed.
+ # Event loop affinity check: streams from the transport are bound to
+ # the loop that created them. Reconnect if the loop changed.
if self._connection_loop is current_loop:
if self.verbose and self.io:
self.io.tool_output(f"Using existing session for MCP server: {self.name}")
@@ -99,51 +111,57 @@ async def connect(self):
if self.verbose and self.io:
self.io.tool_output(f"Establishing new connection to MCP server: {self.name}")
- command = self.config["command"]
-
- env = os.environ.copy()
- if self.config.get("env"):
- env.update(self.config["env"])
-
- server_params = StdioServerParameters(
- command=command,
- args=self.config.get("args"),
- env=env,
- )
+ self._connection_loop = current_loop
+ self._shutdown_event = asyncio.Event()
+ self._session_ready = current_loop.create_future()
+ self._session_task = asyncio.create_task(self._run_session())
try:
- os.makedirs(".cecli/logs/", exist_ok=True)
- with safe_open(".cecli/logs/mcp-errors.log", "w") as err_file:
- stdio_transport = await self.exit_stack.enter_async_context(
- stdio_client(server_params, errlog=err_file)
- )
- read, write = stdio_transport
- session = await self.exit_stack.enter_async_context(ClientSession(read, write))
- await session.initialize()
- self.session = session
- self._connection_loop = current_loop
- return session
- except Exception as e:
- logging.error(f"Error initializing server {self.name}: {e}")
+ session = await self._session_ready
+ except BaseException:
await self.disconnect()
raise
- async def disconnect(self):
- """
- Disconnect from the MCP server and clean up resources.
+ return session
+
+ async def disconnect(self, cancel_keepalive: bool = True):
+ """Disconnect from the MCP server and clean up resources.
Idempotent and safe to call from any task or thread: only one caller
performs the teardown; concurrent callers (possibly on another event
loop) return immediately once the session is marked gone.
+
+ The transport is closed on the session owner task (via a shutdown
+ signal), so the AnyIO task group inside the transport's async context is
+ exited on the same task that entered it.
"""
with self._cleanup_lock:
if self._disconnecting:
self.session = None
return
self._disconnecting = True
+ self._cancel_keepalive_on_close = cancel_keepalive
try:
- await self.exit_stack.aclose()
+ task = self._session_task
+ if task is not None and not task.done():
+ # Retire this task as owner before signalling, so an owner task on
+ # another loop won't clear state a newer session has taken over.
+ self._session_task = None
+ if self._shutdown_event is not None:
+ self._shutdown_event.set()
+
+ try:
+ await asyncio.wait_for(task, timeout=15)
+ except asyncio.TimeoutError:
+ task.cancel()
+ except RuntimeError:
+ # Session task lives on a different loop; it is torn down there.
+ pass
+
+ elif task is None:
+ await self._close_session(cancel_keepalive)
+ await self.exit_stack.aclose()
except (asyncio.CancelledError, RuntimeError, GeneratorExit):
# Expected during shutdown - anyio cancel scopes don't play
# well with asyncio teardown. Resources are still cleaned up.
@@ -152,6 +170,9 @@ async def disconnect(self):
logging.error(f"Error during cleanup of server {self.name}: {e}")
finally:
self.session = None
+ self._connection_loop = None
+ self._session_ready = None
+ self._shutdown_event = None
self._disconnecting = False
async def reconnect(self):
@@ -196,6 +217,109 @@ def is_session_expired_error(exc):
return False
+ async def _open_session(self):
+ """Open the MCP transport and initialize the session.
+
+ All context managers are entered into ``self.exit_stack`` and the active
+ session is stored on ``self.session``. This runs on the session owner
+ task (see ``_run_session``). Subclasses override this for
+ transport-specific setup.
+
+ Returns:
+ ClientSession: The initialized session
+ """
+ command = self.config["command"]
+
+ env = os.environ.copy()
+ if self.config.get("env"):
+ env.update(self.config["env"])
+
+ server_params = StdioServerParameters(
+ command=command,
+ args=self.config.get("args"),
+ env=env,
+ )
+
+ os.makedirs(".cecli/logs/", exist_ok=True)
+ with safe_open(".cecli/logs/mcp-errors.log", "w") as err_file:
+ stdio_transport = await self.exit_stack.enter_async_context(
+ stdio_client(server_params, errlog=err_file)
+ )
+ read, write = stdio_transport
+ session = await self.exit_stack.enter_async_context(ClientSession(read, write))
+ await session.initialize()
+ self.session = session
+
+ return session
+
+ async def _close_session(self, cancel_keepalive: bool = True):
+ """Tear down session-specific resources.
+
+ Always runs on the session owner task, immediately before the transport
+ context is closed. Subclasses override to clean up extra state.
+ """
+ return None
+
+ async def _run_session(self):
+ """Own the transport context for the lifetime of the session.
+
+ Opens the transport and session, then blocks until a shutdown is
+ requested, then closes the transport. Both the open and the close happen
+ in this single task so the AnyIO task group inside the transport context
+ (for example ``stdio_client``) is entered and exited on the same task.
+
+ Even when opening fails partway, any contexts already pushed onto
+ ``self.exit_stack`` are still closed here, so nothing leaks.
+ """
+ # This session owns its own transport stack so an older owner task that
+ # is still winding down can never close a newer session's contexts.
+ exit_stack = AsyncExitStack()
+ self.exit_stack = exit_stack
+
+ try:
+ try:
+ session = await self._open_session()
+ except BaseException as exc:
+ if not self._session_ready.done():
+ if isinstance(exc, asyncio.CancelledError):
+ # Deliver cancellation as a future cancellation, not as an
+ # exception value, so a connect() caller that never awaits
+ # the future won't trigger a "Future exception was never
+ # retrieved" warning.
+ self._session_ready.cancel()
+ else:
+ logging.error(f"Error initializing server {self.name}: {exc}")
+ self._session_ready.set_exception(exc)
+
+ return
+
+ if not self._session_ready.done():
+ self._session_ready.set_result(session)
+
+ await self._shutdown_event.wait()
+ except BaseException:
+ pass
+ finally:
+ try:
+ await self._close_session(self._cancel_keepalive_on_close)
+ except asyncio.CancelledError:
+ pass
+ except Exception as e:
+ logging.error(f"Error during cleanup of server {self.name}: {e}")
+
+ try:
+ await exit_stack.aclose()
+ except (asyncio.CancelledError, RuntimeError, GeneratorExit):
+ pass
+ except Exception as e:
+ logging.error(f"Error during cleanup of server {self.name}: {e}")
+
+ # Only the current owner may clear shared state; if a newer session
+ # has taken over, leave its session/loop fields alone.
+ if self._session_task is asyncio.current_task():
+ self.session = None
+ self._connection_loop = None
+
class HttpBasedMcpServer(McpServer):
"""Base class for HTTP-based MCP servers (HTTP streaming and SSE)."""
@@ -288,66 +412,48 @@ def _create_transport(self, url, http_client):
"""
raise NotImplementedError("Subclasses must implement _create_transport")
- async def connect(self):
- current_loop = asyncio.get_running_loop()
- if self.session is not None:
- if self._connection_loop is current_loop:
- if self.verbose and self.io:
- self.io.tool_output(f"Using existing session for {self.name}")
- return self.session
- if self.verbose and self.io:
- self.io.tool_output(f"Reconnecting {self.name} (event loop changed)")
- await self.disconnect()
-
- if self.verbose and self.io:
- self.io.tool_output(f"Establishing new connection to {self.name}")
-
- try:
- url = self.config.get("url")
- headers = self.config.get("headers", {})
-
- oauth_provider = None
- if not headers:
- oauth_provider = await self._create_oauth_provider()
-
- http_client_cls = _get_http_client_module().AsyncClient
- http_client = await self.exit_stack.enter_async_context(
- http_client_cls(
- auth=oauth_provider,
- follow_redirects=True,
- headers=headers,
- timeout=30,
- )
+ async def _open_session(self):
+ url = self.config.get("url")
+ headers = self.config.get("headers", {})
+
+ oauth_provider = None
+ if not headers:
+ oauth_provider = await self._create_oauth_provider()
+
+ http_client_cls = _get_http_client_module().AsyncClient
+ http_client = await self.exit_stack.enter_async_context(
+ http_client_cls(
+ auth=oauth_provider,
+ follow_redirects=True,
+ headers=headers,
+ timeout=30,
)
- self._http_client = http_client
+ )
+ self._http_client = http_client
- transport = await self.exit_stack.enter_async_context(
- self._create_transport(url, http_client=http_client)
- )
+ transport = await self.exit_stack.enter_async_context(
+ self._create_transport(url, http_client=http_client)
+ )
- read, write = _unpack_transport(transport)
+ read, write = _unpack_transport(transport)
- session = await self.exit_stack.enter_async_context(ClientSession(read, write))
- await session.initialize()
- self.session = session
- await self.start_keepalive()
- self._connection_loop = current_loop
+ session = await self.exit_stack.enter_async_context(ClientSession(read, write))
+ await session.initialize()
+ self.session = session
- if oauth_provider is not None and oauth_provider.context.oauth_metadata:
- token_endpoint = oauth_provider._get_token_endpoint()
- server_info = get_mcp_oauth_token(self.name)
- if "client_info" not in server_info:
- server_info["client_info"] = {}
+ await self.start_keepalive()
- server_info["client_info"]["token_endpoint"] = token_endpoint
+ if oauth_provider is not None and oauth_provider.context.oauth_metadata:
+ token_endpoint = oauth_provider._get_token_endpoint()
+ server_info = get_mcp_oauth_token(self.name)
+ if "client_info" not in server_info:
+ server_info["client_info"] = {}
- save_mcp_oauth_token(self.name, server_info)
+ server_info["client_info"]["token_endpoint"] = token_endpoint
- return session
- except Exception as e:
- logging.error(f"Error initializing {self.name}: {e}")
- await self.disconnect()
- raise
+ save_mcp_oauth_token(self.name, server_info)
+
+ return session
async def start_keepalive(self):
"""Start the background keepalive loop if configured."""
@@ -459,41 +565,21 @@ async def reconnect(self):
f"Reconnection attempt {attempt} failed for {self.name}: {e}"
)
- async def disconnect(self, cancel_keepalive: bool = True):
- """
- Disconnect from the MCP server and clean up resources.
+ async def _close_session(self, cancel_keepalive: bool = True):
+ if cancel_keepalive and self._keepalive_task:
+ self._keepalive_task.cancel()
- Idempotent and safe to call from any task or thread: only one caller
- performs the teardown; concurrent callers (possibly on another event
- loop) return immediately once the session is marked gone.
- """
- with self._cleanup_lock:
- if self._disconnecting:
- self.session = None
- return
- self._disconnecting = True
+ try:
+ await asyncio.wait_for(self._keepalive_task, timeout=15)
+ except asyncio.CancelledError:
+ pass
- try:
- if cancel_keepalive and self._keepalive_task:
- self._keepalive_task.cancel()
- try:
- await asyncio.wait_for(self._keepalive_task, timeout=15)
- except asyncio.CancelledError:
- pass
- logger.info(f"Keepalive task stopped for {self.name}")
- if hasattr(self, "_oauth_shutdown"):
- self._oauth_shutdown()
- await self.exit_stack.aclose()
- except (asyncio.CancelledError, RuntimeError, GeneratorExit):
- # Expected during shutdown - anyio cancel scopes don't play
- # well with asyncio teardown. Resources are still cleaned up.
- pass
- except Exception as e:
- logging.error(f"Error during cleanup of server {self.name}: {e}")
- finally:
- self.session = None
- self._http_client = None
- self._disconnecting = False
+ logger.info(f"Keepalive task stopped for {self.name}")
+
+ if hasattr(self, "_oauth_shutdown"):
+ self._oauth_shutdown()
+
+ self._http_client = None
class HttpStreamingServer(HttpBasedMcpServer):
@@ -507,32 +593,17 @@ def _create_transport(self, url, http_client):
class SseServer(McpServer):
"""SSE (Server-Sent Events) MCP server using mcp.client.sse_client."""
- async def connect(self):
- current_loop = asyncio.get_running_loop()
- if self.session is not None:
- if self._connection_loop is current_loop:
- logging.info(f"Using existing session for SSE MCP server: {self.name}")
- return self.session
- logging.info(f"Reconnecting SSE MCP server {self.name} (event loop changed)")
- await self.disconnect()
+ async def _open_session(self):
+ url = self.config.get("url")
+ headers = self.config.get("headers", {})
- logging.info(f"Establishing new connection to SSE MCP server: {self.name}")
- try:
- url = self.config.get("url")
- headers = self.config.get("headers", {})
- sse_transport = await self.exit_stack.enter_async_context(
- sse_client(url, headers=headers)
- )
- read, write = sse_transport
- session = await self.exit_stack.enter_async_context(ClientSession(read, write))
- await session.initialize()
- self.session = session
- self._connection_loop = current_loop
- return session
- except Exception as e:
- logging.error(f"Error initializing SSE server {self.name}: {e}")
- await self.disconnect()
- raise
+ sse_transport = await self.exit_stack.enter_async_context(sse_client(url, headers=headers))
+ read, write = sse_transport
+ session = await self.exit_stack.enter_async_context(ClientSession(read, write))
+ await session.initialize()
+ self.session = session
+
+ return session
class LocalServer(McpServer):
diff --git a/cecli/repo.py b/cecli/repo.py
index 7d052f58cc1..c0481051427 100644
--- a/cecli/repo.py
+++ b/cecli/repo.py
@@ -56,6 +56,20 @@ def set_git_env(var_name, value, original_value):
del os.environ[var_name]
+def _close_git_repo(repo: "GitRepo") -> None:
+ """Safely close a GitRepo that was just opened but will not be used.
+
+ Prevents leaking the underlying ``git.Repo`` file handle when
+ ``GitRepoProxy.for_root`` discovers an already-cached repository.
+ """
+ try:
+ with repo._git_lock:
+ if getattr(repo, "repo", None) is not None:
+ repo.repo.close()
+ except Exception:
+ pass
+
+
class CommitInfo:
"""
Data-only container for extracted commit information.
@@ -121,14 +135,7 @@ def __init__(
self.subtree_only = subtree_only
self.git_commit_verify = git_commit_verify
self.ignore_file_cache = {}
- self.is_workspace = False
- self.workspace_path = None
- self.workspace_config = {}
- self.workspace_layout = "clone"
- self.workspace_ignore_specs = {}
- self.workspace_ignore_ts = {}
self._git_lock = threading.RLock()
- # Workspace detection and config loading occurs later in __init__
if git_dname:
check_fnames = [git_dname]
@@ -159,36 +166,11 @@ def __init__(
if num_repos == 0:
raise FileNotFoundError
if num_repos > 1:
- from cecli.helpers.monorepo.config import load_workspace_config_file
- from cecli.helpers.monorepo.local_workspace import (
- find_workspace_config_file,
- )
-
- ws_file = find_workspace_config_file(Path(repo_paths[0]))
- if not ws_file:
- self.io.tool_error(
- "Files are in different git repos. Add a .cecli.workspaces.yml at a"
- " common ancestor with path: entries for each project."
- )
- raise FileNotFoundError
- self.workspace_config = load_workspace_config_file(ws_file)
- primary = next(
- (p for p in self.workspace_config.get("projects", []) if p.get("primary")),
- None,
+ self.io.tool_error(
+ "Files are in different git repos. Each coder operates on a single base path."
)
- if primary and primary.get("path"):
- self._init_repo_path = str(Path(str(primary["path"])).expanduser().resolve())
- else:
- self._init_repo_path = str(Path(repo_paths[0]).resolve())
- else:
- self._init_repo_path = repo_paths.pop()
-
- # Detect if we're in a workspace
- self.workspace_path = self._detect_workspace_path(self._init_repo_path)
- if self.workspace_path:
- self.is_workspace = True
- self._load_workspace_config()
- self.refresh_cecli_ignore()
+ raise FileNotFoundError
+ self._init_repo_path = repo_paths.pop()
self.init_repo()
if cecli_ignore_file:
@@ -200,73 +182,11 @@ def init_repo(self):
self.repo = git.Repo(self._init_repo_path, odbt=git.GitCmdObjectDB)
self.root = utils.safe_abs_path(self.repo.working_tree_dir)
- if self.is_workspace:
- self.root = self.workspace_path
-
try:
self.repo.head.commit # just access to check for errors, discard
except ANY_GIT_ERROR:
- if not self.is_workspace:
- self.repo = git.Repo(self._init_repo_path, odbt=git.GitCmdObjectDB)
- self.root = utils.safe_abs_path(self.repo.working_tree_dir)
-
- def _load_workspace_config(self) -> None:
- from cecli.helpers.monorepo.config import (
- load_workspace_config,
- load_workspace_config_file,
- )
- from cecli.helpers.monorepo.local_workspace import (
- find_workspace_config_file,
- read_workspace_metadata,
- write_workspace_metadata,
- )
-
- ws_file = find_workspace_config_file(Path(self.workspace_path))
- if ws_file:
- self.workspace_layout = "local"
- self.workspace_config = load_workspace_config_file(ws_file)
- write_workspace_metadata(
- Path(self.workspace_path), self.workspace_config, layout="local"
- )
- return
- meta = read_workspace_metadata(Path(self.workspace_path))
- if meta:
- self.workspace_config, self.workspace_layout = meta
- return
- self.workspace_layout = "clone"
- try:
- self.workspace_config = load_workspace_config(name=Path(self.workspace_path).name)
- except Exception:
- self.workspace_config = {}
-
- def _detect_workspace_path(self, start_path: str):
- """Check if current directory is within a workspace"""
- from cecli.helpers.monorepo.local_workspace import find_workspace_config_file
-
- current = Path(start_path).resolve()
- ws_file = find_workspace_config_file(current)
- if ws_file:
- return ws_file.parent.resolve()
-
- workspace_root = Path("~/.cecli/workspaces").expanduser()
-
- # Walk up directory tree looking for workspace root
- while current != current.parent:
- if workspace_root in current.parents or current == workspace_root:
- # If we are inside the workspace root, the workspace is the first child of workspace_root
- try:
- rel = current.relative_to(workspace_root)
- if rel.parts:
- return workspace_root / rel.parts[0]
- except (IndexError, ValueError):
- pass
-
- # Alternative check: look for .cecli-workspace.json
- if (current / ".cecli-workspace.json").exists():
- return current
-
- current = current.parent
- return None
+ self.repo = git.Repo(self._init_repo_path, odbt=git.GitCmdObjectDB)
+ self.root = utils.safe_abs_path(self.repo.working_tree_dir)
def __del__(self):
with self._git_lock:
@@ -343,9 +263,6 @@ async def commit(self, fnames=None, context=None, message=None, coder_edits=Fals
- User commit with explicit no-committer: coder_edits=False,
--no-attribute-committer -> Author=You, Committer=You
"""
- if self.is_workspace and getattr(self, "workspace_layout", "clone") == "local":
- return await self._commit_local_workspace(fnames, context, message, coder_edits, coder)
-
with self._git_lock:
if not fnames and not self.repo.is_dirty():
return
@@ -682,171 +599,25 @@ def get_tracked_files(self):
return res
- async def _commit_local_workspace(
- self, fnames=None, context=None, message=None, coder_edits=False, coder=None
- ):
- from collections import defaultdict
-
- from cecli.helpers.monorepo.local_workspace import resolve_workspace_file_path
-
- layout = getattr(self, "workspace_layout", "local")
- config = self.workspace_config or {}
- readonly = {
- str(p.get("name"))
- for p in config.get("projects", [])
- if p.get("readonly") and p.get("name")
- }
-
- by_root: dict[str, list[str]] = defaultdict(list)
- if fnames:
- for fname in fnames:
- resolved = resolve_workspace_file_path(
- Path(self.workspace_path), str(fname), config, layout=layout
- )
- if not resolved:
- continue
- git_root, _abs_path, in_repo = resolved
- parts = Path(str(fname)).parts
- if parts and parts[0] in readonly:
- continue
- if in_repo:
- by_root[str(git_root)].append(in_repo)
- else:
- for proj in config.get("projects", []):
- name = proj.get("name")
- if not name or name in readonly:
- continue
- from cecli.helpers.monorepo.local_workspace import project_git_root
-
- git_root = project_git_root(Path(self.workspace_path), proj, layout=layout)
- if not git_root:
- continue
- sub = GitRepo(self.io, [str(git_root)], None)
- for rel in sub.get_dirty_files() or []:
- by_root[str(git_root)].append(rel)
-
- last = None
- for root, rels in by_root.items():
- sub = GitRepo(self.io, [root], None)
- last = await sub.commit(
- rels, context=context, message=message, coder_edits=coder_edits, coder=coder
- )
- return last
-
- def get_workspace_files(self):
- """
- If in a workspace, return all tracked files from all projects.
- Paths are relative to the workspace root.
- """
- if not self.workspace_path:
- return self.get_tracked_files()
-
- import hashlib
-
- layout = getattr(self, "workspace_layout", "clone")
- config = self.workspace_config or {}
- if not config.get("projects"):
- return self.get_tracked_files()
-
- from cecli.helpers.monorepo.local_workspace import (
- project_head_shas,
- union_tracked_files,
- )
-
- project_shas = project_head_shas(Path(self.workspace_path), config, layout=layout)
- cache_key = hashlib.sha1(",".join(project_shas).encode()).hexdigest()
-
- if hasattr(self, "_workspace_files_cache"):
- cached_key, cached_files = self._workspace_files_cache
- if cached_key == cache_key:
- return cached_files
-
- if layout == "local":
- all_files = union_tracked_files(
- Path(self.workspace_path),
- config,
- layout=layout,
- ignored_file=self.ignored_file,
- )
- self._workspace_files_cache = (cache_key, all_files)
- return all_files
-
- import json
- import subprocess
-
- metadata_path = self.workspace_path / ".cecli-workspace.json"
- if not metadata_path.exists():
- return self.get_tracked_files()
-
- try:
- with safe_open(metadata_path, "r") as f:
- config = json.load(f)
- except Exception:
- return self.get_tracked_files()
-
- all_files = []
- for proj in config.get("projects", []):
- proj_name = proj.get("name")
- if not proj_name:
- continue
-
- proj_root = self.workspace_path / proj_name / "main"
- if not proj_root.exists():
- continue
-
- try:
- res = subprocess.check_output(
- ["git", "-C", str(proj_root), "ls-files"],
- stderr=subprocess.DEVNULL,
- encoding="utf-8",
- ).splitlines()
-
- for f in res:
- rel_path = f"{proj_name}/main/{f}"
- if not self.ignored_file(rel_path):
- all_files.append(rel_path)
- except Exception:
- continue
-
- self._workspace_files_cache = (cache_key, all_files)
- return all_files
-
def normalize_path(self, path):
orig_path = path
res = self.normalized_path.get(orig_path)
if res:
return res
- if self.is_workspace:
- try:
- # In workspace mode, try to make it relative to workspace_path first
- path = str(
- Path(
- PurePosixPath(
- (Path(self.workspace_path) / path).relative_to(self.workspace_path)
- )
- )
- )
- except ValueError:
- # Fallback to standard relative_to(self.root)
- path = str(Path(PurePosixPath((Path(self.root) / path).relative_to(self.root))))
- else:
- path = str(Path(PurePosixPath((Path(self.root) / path).relative_to(self.root))))
+ path = str(Path(PurePosixPath((Path(self.root) / path).relative_to(self.root))))
self.normalized_path[orig_path] = path
return path
def refresh_cecli_ignore(self):
- if not self.cecli_ignore_file and not self.is_workspace:
+ if not self.cecli_ignore_file:
return
current_time = time.time()
if current_time - self.cecli_ignore_last_check < 1:
return
- if self.is_workspace:
- self._refresh_workspace_ignores()
-
self.cecli_ignore_last_check = current_time
if not self.cecli_ignore_file or not self.cecli_ignore_file.is_file():
@@ -862,35 +633,6 @@ def refresh_cecli_ignore(self):
lines,
)
- def _refresh_workspace_ignores(self):
- if not hasattr(self, "workspace_config") or not self.workspace_config:
- return
-
- if not hasattr(self, "workspace_ignore_specs"):
- self.workspace_ignore_specs = {}
- self.workspace_ignore_ts = {}
-
- projects = self.workspace_config.get("projects", [])
- for proj in projects:
- proj_name = proj.get("name")
- ignore_file = proj.get("ignore")
- if not proj_name or not ignore_file:
- continue
-
- ignore_path = self.workspace_path / f"{proj_name}.ignore"
- if not ignore_path.is_file():
- continue
-
- mtime = ignore_path.stat().st_mtime
- if mtime != self.workspace_ignore_ts.get(proj_name):
- self.workspace_ignore_ts[proj_name] = mtime
- self.ignore_file_cache = {}
- lines = ignore_path.read_text().splitlines()
- self.workspace_ignore_specs[proj_name] = pathspec.PathSpec.from_lines(
- pathspec.patterns.GitWildMatchPattern,
- lines,
- )
-
def _get_gitignore_spec(self, dir_path):
"""Get or create a GitIgnoreSpec for a directory, caching for performance."""
dir_path = Path(dir_path).resolve()
@@ -1002,32 +744,6 @@ def ignored_file_raw(self, fname):
if cwd_path not in fname_path.parents and fname_path != cwd_path:
return True
- if self.is_workspace:
- # Check project-specific ignores
- try:
- fname_rel = self.normalize_path(fname)
- parts = Path(fname_rel).parts
- if parts:
- proj_name = parts[0]
- if (
- hasattr(self, "workspace_ignore_specs")
- and proj_name in self.workspace_ignore_specs
- ):
- # Check against project-specific spec
- # The spec expects paths relative to the project root (usually proj/main/)
- layout = getattr(self, "workspace_layout", "clone")
- if layout == "clone" and len(parts) > 2 and parts[1] == "main":
- proj_rel_path = str(Path(*parts[2:]))
- else:
- proj_rel_path = str(Path(*parts[1:]))
-
- if self.workspace_ignore_specs[proj_name].match_file(proj_rel_path):
- return True
- # If not matched by project-specific ignore, continue to global ignore
- # but don't return False yet as there might be a global .cecli.ignore
- except (ValueError, IndexError):
- pass
-
if not self.cecli_ignore_file or not self.cecli_ignore_file.is_file():
return False
@@ -1070,22 +786,8 @@ def get_non_ignored_files_from_root(self):
def get_repo_files(self) -> list[str]:
"""
- Get all relevant files from the repository, respecting workspace
- structure and custom ignore rules.
-
- This is a unified file collection method that encapsulates the
- logic for choosing the right file source depending on the repo's
- configuration:
-
- - Workspace repos (``workspace_path`` is set) → ``get_workspace_files()``
- - Non-workspace with ``cecli_ignore`` file → ``get_non_ignored_files_from_root()``
- - Non-workspace without ``cecli_ignore`` → ``get_tracked_files()``
-
- Returns:
- Sorted list of root-relative file paths
+ Get all relevant files from the repository for this single base path.
"""
- if hasattr(self, "workspace_path") and self.workspace_path:
- return self.get_workspace_files()
if self.cecli_ignore_file and self.cecli_ignore_file.is_file():
return self.get_non_ignored_files_from_root()
return self.get_tracked_files()
@@ -1127,19 +829,6 @@ def path_in_repo(self, path):
return self.normalize_path(path) in tracked_files
def abs_root_path(self, path):
- if self.is_workspace and getattr(self, "workspace_layout", "clone") == "local":
- from cecli.helpers.monorepo.local_workspace import (
- resolve_workspace_file_path,
- )
-
- resolved = resolve_workspace_file_path(
- Path(self.workspace_path),
- str(path),
- self.workspace_config or {},
- layout="local",
- )
- if resolved:
- return utils.safe_abs_path(resolved[1])
res = Path(self.root) / path
return utils.safe_abs_path(res)
@@ -1278,6 +967,64 @@ def __init__(self, target):
max_workers=1, thread_name_prefix="git-repo"
)
+ _instances: dict[str, "GitRepoProxy"] = {}
+
+ @classmethod
+ def for_root(
+ cls,
+ root: str | None,
+ io,
+ fnames=None,
+ git_dname=None,
+ cecli_ignore_file=None,
+ **kwargs,
+ ) -> "GitRepoProxy":
+ """
+ Return a per-base-path ``GitRepoProxy`` singleton.
+
+ Coders that share a base path share one proxy (and its underlying
+ ``git.Repo`` plus single-thread executor), so multi-repository work can
+ keep each repository on its own proxy while coders on the same base path
+ stay consistent. Coders on different base paths get independent proxies.
+
+ If *root* is provided it is used as the registry key and to discover the
+ repository; otherwise it is derived from *fnames* (the same discovery the
+ ``GitRepo`` constructor performs).
+ """
+ if root is None:
+ target = GitRepo(io, fnames, git_dname, cecli_ignore_file, **kwargs)
+ key = os.path.normpath(os.path.abspath(target.root))
+ proxy = cls._instances.get(key)
+ if proxy is None:
+ proxy = cls(target)
+ cls._instances[key] = proxy
+ else:
+ _close_git_repo(target) # Discard the just-opened duplicate
+ return proxy
+
+ key = os.path.normpath(os.path.abspath(root))
+ proxy = cls._instances.get(key)
+ if proxy is None:
+ target = GitRepo(io, fnames or [root], git_dname, cecli_ignore_file, **kwargs)
+ proxy = cls(target)
+ cls._instances[key] = proxy
+ return proxy
+
+ @classmethod
+ def evict(cls, root: str | None = None) -> None:
+ """Drop the proxy for a base path (e.g. on coder teardown)."""
+ if root is None:
+ return
+ key = os.path.normpath(os.path.abspath(root))
+ cls._instances.pop(key, None)
+
+ @classmethod
+ def reset_instances(cls) -> None:
+ """Reset all per-base-path proxies — used primarily in test teardown."""
+ cls._instances.clear()
+
+ # ------------------------------------------------------------------
+ # Sync methods that access self.repo (the git.Repo object)
# ------------------------------------------------------------------
# Sync methods that access self.repo (the git.Repo object)
# ------------------------------------------------------------------
@@ -1350,9 +1097,6 @@ def get_non_ignored_files_from_root(self):
def get_repo_files(self):
return self._executor.submit(self._target.get_repo_files).result()
- def get_workspace_files(self):
- return self._executor.submit(self._target.get_workspace_files).result()
-
# ------------------------------------------------------------------
# Async methods – called from the main asyncio task only, and
# already protected by ``_git_lock`` on the target; we do *not*
@@ -1360,7 +1104,7 @@ def get_workspace_files(self):
# with LLM calls (``get_commit_message``) that must stay async.
# ------------------------------------------------------------------
- # commit() and _commit_local_workspace() are forwarded via __getattr__
+ # commit() is forwarded via __getattr__
# ------------------------------------------------------------------
# Access to the raw ``git.Repo`` – return a sub-proxy that routes
diff --git a/cecli/tools/__init__.py b/cecli/tools/__init__.py
index bc0009b3833..2deb7bf501b 100644
--- a/cecli/tools/__init__.py
+++ b/cecli/tools/__init__.py
@@ -3,6 +3,7 @@
from . import (
_yield,
+ broadcast,
command,
delegate,
edit_file,
@@ -32,6 +33,7 @@
edit_file,
explore_code,
_yield,
+ broadcast,
git_branch,
git_diff,
git_log,
diff --git a/cecli/tools/_yield.py b/cecli/tools/_yield.py
index c3577e15acf..2efe5d5f4d6 100644
--- a/cecli/tools/_yield.py
+++ b/cecli/tools/_yield.py
@@ -33,6 +33,15 @@ class Tool(BaseTool):
"and returned to the parent agent."
),
},
+ "wait": {
+ "type": "integer",
+ "description": (
+ "Optional time in seconds (between 15 and 120) to wait "
+ "before returning. When provided, the tool simply sleeps "
+ "for the specified duration and returns, "
+ "allowing for other tasks to proceed."
+ ),
+ },
},
"required": [],
},
@@ -53,6 +62,45 @@ async def execute(cls, coder, **kwargs):
response = ToolResponse(cls.NORM_NAME)
if coder:
+ wait = kwargs.get("wait")
+ if wait is not None and wait != "":
+ if isinstance(wait, bool):
+ wait_seconds = 30
+ else:
+ try:
+ wait_seconds = int(wait)
+ except (ValueError, TypeError):
+ wait_seconds = 30
+
+ wait_seconds = max(15, min(120, wait_seconds))
+
+ # Plain wait — sleep for the requested duration without checking
+ # sub-agents or marking the task as finished.
+ interrupt_event = coder.interrupt_event
+ if interrupt_event is None:
+ interrupt_event = ThreadSafeEvent()
+
+ interrupt_task = asyncio.create_task(interrupt_event.wait())
+ sleep_task = asyncio.create_task(asyncio.sleep(wait_seconds))
+ done, pending = await asyncio.wait(
+ {sleep_task, interrupt_task},
+ return_when=asyncio.FIRST_COMPLETED,
+ )
+
+ for task in pending:
+ task.cancel()
+ try:
+ await task
+ except (asyncio.CancelledError, Exception):
+ pass
+
+ if interrupt_task in done:
+ response.append_result(f"Wait interrupted after {wait_seconds} seconds.")
+ else:
+ response.append_result(f"Waited for {wait_seconds} seconds.")
+
+ return response
+
waited_for_sub_agents = False
# Check for active child sub-agents and await their tasks before finishing
try:
@@ -61,7 +109,9 @@ async def execute(cls, coder, **kwargs):
active_tasks = [
info.generate_task
for info in children
- if info.generate_task is not None and not info.generate_task.done()
+ if not info.independent
+ and info.generate_task is not None
+ and not info.generate_task.done()
]
if active_tasks:
@@ -236,6 +286,13 @@ def format_output(cls, coder, mcp_server, tool_response):
coder.io.tool_error("Invalid Tool JSON")
return
+ wait = params.get("wait")
+ if wait:
+ coder.io.tool_output("")
+ coder.io.tool_output(f"{color_start}Wait:{color_end}")
+ coder.io.tool_output(f"{wait} seconds")
+ coder.io.tool_output("")
+
summary = params.get("summary")
if summary:
coder.io.tool_output("")
diff --git a/cecli/tools/broadcast.py b/cecli/tools/broadcast.py
new file mode 100644
index 00000000000..17aae0056cb
--- /dev/null
+++ b/cecli/tools/broadcast.py
@@ -0,0 +1,268 @@
+"""Broadcast tool - sends a message to one or more sub-agents."""
+
+import time
+
+from cecli.tools.utils.base_tool import BaseTool
+from cecli.tools.utils.helpers import ToolError
+from cecli.tools.utils.output import color_markers, tool_footer, tool_header
+from cecli.tools.utils.responses import ToolResponse
+from cecli.tools.validations import ToolValidations
+
+
+class Tool(BaseTool):
+ NORM_NAME = "broadcast"
+ RESULT_TYPE = "list"
+ VALIDATIONS = {
+ "targets": ["coerce_list"],
+ }
+ SCHEMA = {
+ "type": "function",
+ "function": {
+ "name": "Broadcast",
+ "description": (
+ "Broadcast a message to one or more sub-agent instances. Sends the message to the "
+ "specified target sub-agents, or to every active sub-agent when empty/omitted. "
+ "The message is queued into the target's context if it is actively generating, "
+ "and delivered immediately if the target is idle, waking them."
+ ),
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "message": {
+ "type": "string",
+ "description": "The message to broadcast to the target sub-agent(s).",
+ },
+ "targets": {
+ "type": "array",
+ "items": {"type": "string"},
+ "description": (
+ "Optional array of sub-agent IDs (UUIDs, UUID prefixes, or "
+ "agent names) to send the message to, or 'primary' for the primary agent. "
+ "Set as an empty string to broadcast to all active sub-agents."
+ ),
+ },
+ },
+ "required": ["message"],
+ },
+ },
+ }
+
+ @classmethod
+ async def execute(cls, coder, **kwargs):
+ """Broadcast a message to one or more sub-agents.
+
+ For each target sub-agent, the message is either queued into its
+ conversation (if it is actively generating) or used to start a fresh
+ generate task (if it is idle). When no targets are specified, the
+ message is broadcast to every active sub-agent except the originator.
+
+ Args:
+ coder: The coder instance invoking the tool (the originator).
+ message: The message to broadcast.
+ targets: Optional list of sub-agent IDs.
+
+ Returns:
+ ToolResponse with a per-target delivery summary.
+ """
+ from cecli.helpers.agents.service import AgentService
+
+ response = ToolResponse(cls.NORM_NAME, result_type=cls.RESULT_TYPE)
+
+ message = kwargs.get("message")
+ if message is None or not str(message).strip():
+ response.append_error("'message' parameter must be a non-empty string.")
+ return response
+ message = str(message).strip()
+
+ targets = kwargs.get("targets")
+ if targets is not None and not isinstance(targets, list):
+ response.append_error("'targets' parameter must be an array of sub-agent IDs.")
+ return response
+
+ agent_service = AgentService.get_instance(coder)
+ originator_uuid = str(coder.uuid)
+
+ target_infos, errors = cls._collect_targets(agent_service, targets, originator_uuid)
+
+ for error in errors:
+ response.append_error(error)
+
+ delivered = []
+ delivery_errors = []
+ for info in target_infos:
+ try:
+ mode = cls._deliver(info, message, agent_service, coder)
+ delivered.append((info, mode))
+ except Exception as exc:
+ delivery_errors.append(f"Broadcast to '{cls._target_label(info)}' failed: {exc}")
+
+ for info, mode in delivered:
+ response.append_result(f"{cls._target_label(info)}: {mode}")
+
+ for error in delivery_errors:
+ response.append_error(error)
+
+ if not delivered and not errors and not delivery_errors:
+ response.append_result("No targets to broadcast to.")
+
+ return response
+
+ @classmethod
+ def format_output(cls, coder, mcp_server, tool_response):
+ """Format output for the Broadcast tool - show the message and targets."""
+ color_start, color_end = color_markers(coder)
+
+ tool_header(coder=coder, mcp_server=mcp_server, tool_response=tool_response)
+
+ try:
+ params = ToolValidations.validate_params(
+ tool_response.function.arguments, cls.VALIDATIONS, cls.SCHEMA
+ )
+ except ToolError:
+ coder.io.tool_error("Invalid Tool JSON")
+ return
+
+ message = params.get("message", "")
+ targets = params.get("targets", [])
+
+ coder.io.tool_output("")
+ coder.io.tool_output(f"{color_start}message:{color_end}")
+ coder.io.tool_output(message)
+ coder.io.tool_output("")
+ if targets:
+ coder.io.tool_output(
+ f"{color_start}targets:{color_end} {', '.join(str(t) for t in targets)}"
+ )
+ else:
+ coder.io.tool_output(f"{color_start}targets:{color_end} All")
+
+ tool_footer(coder=coder, tool_response=tool_response, params=params)
+
+ @classmethod
+ def _collect_targets(cls, service, targets, originator_uuid):
+ """Return ``(target_infos, errors)`` for the broadcast.
+
+ When ``targets`` is empty, returns every active sub-agent except the
+ originator. When ``targets`` is provided, each entry is resolved by
+ UUID, UUID prefix, or agent name (including ``primary`` for the primary
+ agent); unknown IDs produce an error.
+ """
+ from cecli.helpers.agents.service import SubAgentInfo, SubAgentStatus
+
+ if not targets:
+ infos = [
+ info
+ for info in service.sub_agents.values()
+ if str(info.coder.uuid) != originator_uuid
+ and info.status not in (SubAgentStatus.ERROR,)
+ ]
+ return infos, []
+
+ infos = []
+ errors = []
+ for target in targets:
+ info = cls._resolve_target(service, target)
+ if info is None:
+ errors.append(f"Unknown sub-agent target '{target}'.")
+ continue
+ target_uuid = info.coder.uuid if isinstance(info, SubAgentInfo) else info.uuid
+ if str(target_uuid) != originator_uuid:
+ infos.append(info)
+
+ return infos, errors
+
+ @staticmethod
+ def _resolve_target(service, target):
+ """Resolve a target sub-agent by UUID, UUID prefix, or agent name.
+
+ The primary agent is also resolvable by its name (``primary``) or UUID
+ so sub-agents can broadcast a message back to their parent.
+ """
+ target = str(target).strip()
+ if not target:
+ return None
+
+ info = service.sub_agents.get(target)
+ if info is not None:
+ return info
+
+ for uuid, candidate in service.sub_agents.items():
+ if uuid.startswith(target):
+ return candidate
+
+ for candidate in service.sub_agents.values():
+ if candidate.name == target:
+ return candidate
+
+ primary_coder = service.coder
+ primary_uuid = str(getattr(primary_coder, "uuid", ""))
+ if primary_coder is not None and (target == "primary" or target == primary_uuid):
+ return primary_coder
+
+ return None
+
+ @staticmethod
+ def _deliver(info, message, agent_service, sender_coder):
+ """Queue, wake, or start a generate task for a single target.
+
+ When the target is the primary agent, ``AgentService.wake_primary()`` is
+ used: if the primary is actively generating the message is queued into
+ its conversation, otherwise it is woken via its per-coder input queue
+ (the primary does not use the sub-agent generate-task architecture).
+
+ When the target is a sub-agent that is still generating, the message is
+ queued into its conversation; an idle sub-agent gets a fresh generate
+ task via ``AgentService.start_generate_task()``.
+
+ The message is prefixed with the sender's identity so the target
+ sub-agent can see who broadcast it.
+
+ Returns a short mode string describing how the message was delivered.
+ """
+ from cecli.helpers.agents.service import SubAgentInfo
+ from cecli.helpers.conversation import ConversationService, MessageTag
+ from cecli.helpers.coroutines import is_active
+
+ sender_name = agent_service.get_agent_name(sender_coder) or "primary"
+ sender_uuid = str(sender_coder.uuid)
+ message = (
+ "\n"
+ f"[Message Sent from Agent {sender_name} ({sender_uuid})]\n"
+ "You may respond with the `Broadcast` tool if the message "
+ "is relevant to you\n\n"
+ f"{message}"
+ ""
+ )
+
+ if not isinstance(info, SubAgentInfo):
+ # Primary-agent target. The primary does not use the sub-agent
+ # generate-task architecture — if it is idle we must wake it via
+ # its per-coder input queue (same strategy as on_input_area_submit()).
+ return agent_service.wake_primary(info, message)
+
+ if is_active(info.generate_task):
+ ConversationService.get_manager(info.coder).queue_message(
+ message_dict={
+ "role": "user",
+ "content": message,
+ },
+ tag=MessageTag.CUR,
+ hash_key=("broadcast", str(info.coder.uuid), str(time.monotonic_ns())),
+ )
+ return "queued"
+
+ agent_service.start_generate_task(info, message)
+ return "started"
+
+ @staticmethod
+ def _target_label(target):
+ """Return a display label for a broadcast target.
+
+ Sub-agents are labelled ``name (uuid)``; the primary agent is labelled
+ ``primary (uuid)``.
+ """
+ from cecli.helpers.agents.service import SubAgentInfo
+
+ if isinstance(target, SubAgentInfo):
+ return f"{target.name} ({target.coder.uuid})"
+ return f"primary ({target.uuid})"
diff --git a/cecli/tools/command.py b/cecli/tools/command.py
index 4359003e57a..828eea3dfcc 100644
--- a/cecli/tools/command.py
+++ b/cecli/tools/command.py
@@ -226,7 +226,7 @@ async def _get_confirmation(cls, coder, command_string, background):
return True # Previously approved for session
# Previously declined - skip session question, continue to normal confirmation
- if coder.skip_cli_confirmations:
+ if coder.skip_cli_confirmations or getattr(coder.args, "yes_always_commands", False):
return True
# Check if command matches any allowed_commands patterns
diff --git a/cecli/tools/edit_file.py b/cecli/tools/edit_file.py
index edd7f24f560..a2f792f958b 100644
--- a/cecli/tools/edit_file.py
+++ b/cecli/tools/edit_file.py
@@ -61,8 +61,8 @@ class Tool(BaseTool):
f"duplicate lines by their hashed prefix (e.g., '{HASH_DELIMITER}WecX{HASH_DELIMITER}'); use "
"'@000' for empty files. Identifiers track content, so edits can re-prefix identical lines "
"elsewhere — re-read the file after editing for fresh identifiers. Multiple edits to one "
- "file are applied bottom-to-top; overlapping or contained ranges are merged or rejected "
- "automatically."
+ "file are applied bottom-to-top automatically; overlapping or contained ranges are merged "
+ "or rejected automatically."
),
"parameters": {
"type": "object",
@@ -94,7 +94,7 @@ class Tool(BaseTool):
"type": "string",
"description": (
"The replacement text for 'replace'. "
- "For 'delete' leave this empty (\"\"). "
+ "For 'delete' leave this as an empty string (\"\"). "
"Supplied as-is; do not include identifier prefixes."
),
},
diff --git a/cecli/tools/grep.py b/cecli/tools/grep.py
index 7c3e26059db..ff3a40bed13 100644
--- a/cecli/tools/grep.py
+++ b/cecli/tools/grep.py
@@ -1,3 +1,4 @@
+import base64
import os
import re
import shutil
@@ -185,6 +186,195 @@ def _flush_current():
return files
+def _build_powershell_exclude_regex(exclude_dirs=None):
+ """Build a PowerShell -notmatch regex that skips default build/artifact dirs."""
+ if exclude_dirs is None:
+ exclude_dirs = DEFAULT_EXCLUDE_DIRS
+ fragments = []
+ for entry in exclude_dirs:
+ # Escape the literal text, then turn * globs into path-segment wildcards.
+ frag = re.escape(entry).replace(r"\*", r"[^\\/]*")
+ fragments.append(frag)
+ if not fragments:
+ return None
+ return r"(?:\\|/)(?:" + "|".join(fragments) + r")(?:\\|/|$)"
+
+
+def _build_powershell_search_script(
+ search_dir_path,
+ pattern,
+ file_pattern,
+ use_regex,
+ case_insensitive,
+ context_before,
+ context_after,
+ count_only,
+):
+ """Build a PowerShell pipeline that mimics rg/ag/grep via Select-String."""
+
+ def _ps_quote(value):
+ # Embed a value in a PowerShell single-quoted string ('' escapes a quote).
+ return "'" + str(value).replace("'", "''") + "'"
+
+ parts = ["Get-ChildItem", "-LiteralPath", _ps_quote(search_dir_path), "-Recurse", "-File"]
+
+ if file_pattern != "*":
+ parts.extend(["-Filter", _ps_quote(file_pattern)])
+
+ exclude_regex = _build_powershell_exclude_regex()
+ if exclude_regex:
+ parts.extend(
+ [
+ "|",
+ "Where-Object",
+ "{",
+ "$_.FullName",
+ "-notmatch",
+ _ps_quote(exclude_regex),
+ "}",
+ ]
+ )
+
+ parts.extend(["|", "Select-String", "-Pattern", _ps_quote(pattern)])
+
+ if not use_regex:
+ parts.append("-SimpleMatch")
+ if not case_insensitive:
+ parts.append("-CaseSensitive")
+
+ if count_only:
+ parts.extend(
+ [
+ "|",
+ "Group-Object",
+ "Path",
+ "|",
+ "ForEach-Object",
+ "{",
+ '"$($_.Name):$($_.Count)"',
+ "}",
+ ]
+ )
+ else:
+ parts.extend(["-Context", f"{context_before},{context_after}"])
+
+ return " ".join(parts)
+
+
+def _encode_powershell_command(script, powershell_path):
+ """Encode a PowerShell script for -EncodedCommand and return the full command."""
+ encoded = base64.b64encode(script.encode("utf-16-le")).decode("ascii")
+ return (
+ f"{powershell_path} -NoProfile -NonInteractive -OutputFormat Text "
+ f"-EncodedCommand {encoded}"
+ )
+
+
+def _parse_select_string_output(output, repo_root):
+ """Parse Select-String (PowerShell) output into per-file groups.
+
+ With -OutputFormat Text, each match is rendered as:
+ :: (no context)
+ and with context as:
+ > :: (match line)
+ :: (context line, leading spaces)
+
+ PowerShell renders paths relative to the current directory, so each path is
+ re-resolved against *repo_root* to an absolute path. This keeps the parsed
+ groups consistent with the count pass (Group-Object Path -> absolute paths)
+ and with the downstream truncation/relpath logic.
+ """
+ if not output:
+ return []
+
+ file_groups = {}
+ file_order = []
+
+ def _flush(current_file, current_lines, match_count):
+ if current_file is None or not current_lines:
+ return
+ if current_file in file_groups:
+ existing = file_groups[current_file]
+ existing["match_count"] += match_count
+ existing["content_parts"].append("\n".join(current_lines))
+ else:
+ file_groups[current_file] = {
+ "path": current_file,
+ "match_count": match_count,
+ "content_parts": ["\n".join(current_lines)],
+ }
+ file_order.append(current_file)
+
+ entry_re = re.compile(r"^(.+?)[:-](\d+)[:-](.*)$")
+
+ current_file = None
+ current_lines = []
+ match_count = 0
+
+ for raw_line in output.splitlines():
+ line = raw_line.rstrip("\r")
+ if not line.strip():
+ continue
+
+ # Detect Select-String prefix markers: '> ' for matches, spaces for context.
+ prefix = ""
+ is_match = True
+ rest = line
+ if line.startswith("> "):
+ prefix = "> "
+ rest = line[2:]
+ elif line.startswith(" "):
+ prefix = " "
+ rest = line[2:]
+ is_match = False
+ elif line.startswith(" "):
+ prefix = " "
+ rest = line.lstrip(" ")
+ is_match = False
+
+ m = entry_re.match(rest)
+ if not m:
+ if current_file is not None:
+ current_lines.append(line)
+ continue
+
+ filepath = m.group(1)
+ abs_path = (
+ filepath
+ if os.path.isabs(filepath)
+ else os.path.normpath(os.path.join(repo_root, filepath))
+ )
+ rebuilt = prefix + abs_path + rest[len(filepath) :]
+
+ if current_file is None:
+ current_file = abs_path
+ match_count = 1 if is_match else 0
+ current_lines = [rebuilt]
+ elif abs_path == current_file:
+ current_lines.append(rebuilt)
+ if is_match:
+ match_count += 1
+ else:
+ _flush(current_file, current_lines, match_count)
+ current_file = abs_path
+ match_count = 1 if is_match else 0
+ current_lines = [rebuilt]
+
+ _flush(current_file, current_lines, match_count)
+
+ files = []
+ for fpath in file_order:
+ group = file_groups[fpath]
+ files.append(
+ {
+ "path": group["path"],
+ "match_count": group["match_count"],
+ "content": "\n".join(group["content_parts"]),
+ }
+ )
+ return files
+
+
class Tool(BaseTool):
NORM_NAME = "grep"
RESULT_TYPE = "list"
@@ -258,6 +448,26 @@ def _validate_backend(cls, tool_name, tool_path):
"""Test if a search backend actually works by running a quick check."""
import subprocess
+ if tool_name == "powershell":
+ # PowerShell backend: verify the Select-String cmdlet is available.
+ test_cmd = [
+ tool_path,
+ "-NoProfile",
+ "-NonInteractive",
+ "-Command",
+ "if (Get-Command Select-String -ErrorAction SilentlyContinue) { exit 0 } else { exit 1 }",
+ ]
+ try:
+ result = subprocess.run(
+ test_cmd,
+ capture_output=True,
+ timeout=10,
+ text=True,
+ )
+ return result.returncode == 0
+ except (subprocess.TimeoutExpired, OSError):
+ return False
+
try:
# Test with a simple pattern on a small known file
test_cmd = [tool_path, "--version"]
@@ -305,10 +515,14 @@ def _validate_backend(cls, tool_name, tool_path):
@classmethod
def _find_search_tool(self):
- """Find the best available command-line search tool (rg, ag, grep)."""
- candidates = ["rg", "ag", "grep"]
+ """Find the best available command-line search tool (rg, ag, grep, or PowerShell Select-String)."""
+ candidates = ["rg", "ag", "grep", "powershell"]
for name in candidates:
- path = shutil.which(name)
+ if name == "powershell":
+ # PowerShell (Select-String) is the Windows-native fallback.
+ path = shutil.which("powershell") or shutil.which("pwsh")
+ else:
+ path = shutil.which(name)
if not path:
continue
if self._validate_backend(name, path):
@@ -325,6 +539,7 @@ def execute(
"""
Search for lines matching patterns in files within the project repository.
Uses rg (ripgrep), ag (the silver searcher), or grep, whichever is available.
+ On Windows these may be absent, so PowerShell's Select-String is used as a fallback.
Returns a JSON string with structured results including per-file groupings,
match counts, and summary metadata.
"""
@@ -344,9 +559,9 @@ def execute(
tool_name, tool_path = cls._find_search_tool()
if not tool_path:
- coder.io.tool_error("No search tool (rg, ag, grep) found in PATH.")
+ coder.io.tool_error("No search tool (rg, ag, grep, powershell) found in PATH.")
response = ToolResponse(cls.NORM_NAME, result_type=cls.RESULT_TYPE)
- response.append_error("No search tool (rg, ag, grep) found.")
+ response.append_error("No search tool (rg, ag, grep, powershell) found.")
return response
all_operation_results = []
@@ -385,48 +600,61 @@ def execute(
try:
search_dir_path = Path(repo.root) / directory
- # Build base content command
- base_cmd = [tool_path, "-n"]
- if tool_name == "rg":
- base_cmd.append("--with-filename")
-
- # Pattern type
- pattern_flag = []
- if use_regex:
- if tool_name == "grep":
- pattern_flag = ["-E"]
+ if tool_name == "powershell":
+ # PowerShell (Select-String) backend: build a pipeline script and
+ # invoke it via -EncodedCommand to avoid shell quoting issues.
+ content_string = _encode_powershell_command(
+ _build_powershell_search_script(
+ search_dir_path=search_dir_path,
+ pattern=pattern,
+ file_pattern=file_pattern,
+ use_regex=use_regex,
+ case_insensitive=case_insensitive,
+ context_before=context_before,
+ context_after=context_after,
+ count_only=False,
+ ),
+ tool_path,
+ )
else:
+ # Build base content command
+ base_cmd = [tool_path, "-n"]
if tool_name == "rg":
- pattern_flag = ["-F"]
- elif tool_name == "ag":
- pattern_flag = ["-Q"]
- elif tool_name == "grep":
- pattern_flag = ["-F"]
-
- # Case sensitivity
- case_flag = ["-i"] if case_insensitive else []
+ base_cmd.append("--with-filename")
- # File filtering
- file_filter = []
- if file_pattern != "*":
- if tool_name == "rg":
- file_filter = ["-g", file_pattern]
- elif tool_name == "ag":
- file_filter = ["-G", file_pattern]
+ # Pattern type
+ pattern_flag = []
+ if use_regex:
+ if tool_name == "grep":
+ pattern_flag = ["-E"]
+ else:
+ if tool_name == "rg":
+ pattern_flag = ["-F"]
+ elif tool_name == "ag":
+ pattern_flag = ["-Q"]
+ elif tool_name == "grep":
+ pattern_flag = ["-F"]
+
+ # Case sensitivity
+ case_flag = ["-i"] if case_insensitive else []
+
+ # File filtering
+ file_filter = []
+ if file_pattern != "*":
+ if tool_name == "rg":
+ file_filter = ["-g", file_pattern]
+ elif tool_name == "ag":
+ file_filter = ["-G", file_pattern]
+ elif tool_name == "grep":
+ file_filter = ["-r", f"--include={file_pattern}"]
elif tool_name == "grep":
- file_filter = ["-r", f"--include={file_pattern}"]
- elif tool_name == "grep":
- file_filter = ["-r"]
+ file_filter = ["-r"]
- # Exclusions
- exclude_args = []
- _build_exclude_args(tool_name, exclude_args)
+ # Exclusions
+ exclude_args = []
+ _build_exclude_args(tool_name, exclude_args)
- # --- PASS 1: Get match counts (fast, no context) ---
- counts = {}
- if count_enabled:
- # Build count command (separate flags to avoid -r confusion)
- # NOTE: rg -r = --replace (takes arg), not recursive. rg is recursive by default.
+ # --- PASS1: Build count command (fast, no context) ---
count_cmd_parts = [tool_path]
if tool_name == "rg":
count_cmd_parts.append("-c")
@@ -443,6 +671,23 @@ def execute(
+ ["--", pattern, str(search_dir_path)]
)
count_string = oslex.join(count_cmd)
+
+ # --- PASS2: Build content with context ---
+ content_cmd = (
+ base_cmd
+ + (["-B", str(context_before)] if context_before >= 0 else [])
+ + (["-A", str(context_after)] if context_after >= 0 else [])
+ + case_flag
+ + pattern_flag
+ + exclude_args
+ + file_filter
+ + ["--", pattern, str(search_dir_path)]
+ )
+ content_string = oslex.join(content_cmd)
+
+ # --- PASS1: Get match counts (fast, no context) ---
+ counts = {}
+ if count_enabled and tool_name != "powershell":
coder.io.tool_output(
f"⛭ Counting matches with {tool_name}: '{pattern}' in {directory}",
type="tool-result",
@@ -456,18 +701,7 @@ def execute(
if count_status == 0:
counts = _parse_count_output(count_output)
- # --- PASS 2: Get content with context ---
- content_cmd = (
- base_cmd
- + (["-B", str(context_before)] if context_before >= 0 else [])
- + (["-A", str(context_after)] if context_after >= 0 else [])
- + case_flag
- + pattern_flag
- + exclude_args
- + file_filter
- + ["--", pattern, str(search_dir_path)]
- )
- content_string = oslex.join(content_cmd)
+ # --- PASS2: Get content with context ---
coder.io.tool_output(
f"⛭ Executing {tool_name}: '{pattern}' in {directory}",
type="tool-result",
@@ -482,7 +716,10 @@ def execute(
output_content = content_output or ""
if content_status == 0 and output_content:
- parsed_files = _parse_content_into_files(output_content)
+ if tool_name == "powershell":
+ parsed_files = _parse_select_string_output(output_content, repo.root)
+ else:
+ parsed_files = _parse_content_into_files(output_content)
# Merge in counts from pass 1 if available
if counts:
@@ -510,7 +747,7 @@ def execute(
file_lines = pf["content"].splitlines()
# Find actual match lines (lines with `:LINE:` pattern)
filepath_escaped = re.escape(pf["path"])
- match_line_re = re.compile(r"^" + filepath_escaped + r":(\d+):")
+ match_line_re = re.compile(r"^(?:> )?" + filepath_escaped + r":(\d+):")
match_lines_found = [ln for ln in file_lines if match_line_re.match(ln)]
# Normalize path to be relative to repo root
diff --git a/cecli/tools/utils/registry.py b/cecli/tools/utils/registry.py
index 7b5e516c749..bca50fcac87 100644
--- a/cecli/tools/utils/registry.py
+++ b/cecli/tools/utils/registry.py
@@ -63,13 +63,17 @@ def list_tools(cls) -> List[str]:
return list(cls._tools.keys())
@classmethod
- def build_registry(cls, agent_config: Optional[Dict] = None) -> Dict[str, Type]:
+ def build_registry(
+ cls, agent_config: Optional[Dict] = None, root: Optional[str] = None
+ ) -> Dict[str, Type]:
"""
Build a filtered registry of tools based on agent configuration.
Args:
agent_config: Agent configuration dictionary with optional
tools_includelist/tools_excludelist keys
+ root: Optional base directory used to resolve relative
+ ``tools_paths``. Defaults to the working directory.
Returns:
A dictionary mapping normalized tool names to tool classes.
@@ -78,12 +82,34 @@ def build_registry(cls, agent_config: Optional[Dict] = None) -> Dict[str, Type]:
if agent_config is None:
agent_config = {}
+ # Resolve tools_paths relative to root when provided (used by
+ # workspace sub-agents so custom tools resolve against the primary
+ # workspace root, not the sub-agent's overridden local root).
+ base = Path(root).expanduser().resolve() if root else None
+
+ def _resolve(tool_path: str) -> Path:
+ raw = Path(tool_path).expanduser()
+ if raw.is_absolute():
+ return raw.resolve()
+ if base is not None:
+ return (base / raw).resolve()
+ return raw.resolve()
+
# Load tools from tool_paths if specified
tools_paths = agent_config.get("tools_paths", agent_config.get("tool_paths", []))
loaded_custom_tools = []
+ # Always scan the default custom-tools directories: the global default
+ # in the user's home and the local default under the given root. The
+ # global default is scanned first, the local default next so it takes
+ # precedence, and any explicitly configured tools_paths last.
+ default_tools_dirs = [Path.home() / ".cecli" / "tools"]
+ if base is not None:
+ default_tools_dirs.append(base / ".cecli" / "tools")
+ tools_paths = [str(p) for p in default_tools_dirs] + list(tools_paths)
+
for tool_path in tools_paths:
- path = Path(tool_path)
+ path = _resolve(tool_path)
if path.is_dir():
# Find all Python files in the directory
for py_file in path.glob("*.py"):
diff --git a/cecli/tui/app.py b/cecli/tui/app.py
index 1b7cb8f9541..0910b788d47 100644
--- a/cecli/tui/app.py
+++ b/cecli/tui/app.py
@@ -502,18 +502,17 @@ def _update_key_hints_for_commands(self, text: str, is_completion: bool = False)
self.update_key_hints_left(KeyHints.DEFAULT_LEFT_TEXT)
def _load_git_info(self):
- """Load git branch and dirty count (deferred to avoid blocking startup)."""
+ """Load git branch (deferred to avoid blocking startup)."""
footer = self.query_one(MainFooter)
if self.worker.coder.repo:
try:
branch = self.worker.coder.repo.repo.active_branch.name or "main"
- dirty = self.worker.coder.repo.get_dirty_files()
- footer.update_git(branch, len(dirty) if dirty else 0)
+ footer.update_git(branch)
except Exception:
if self.worker.coder.repo:
- footer.update_git("main", 0)
+ footer.update_git("main")
else:
- footer.update_git("No Repo", 0)
+ footer.update_git("No Repo")
def check_output_queue(self):
"""Process messages from coder worker."""
@@ -1247,6 +1246,8 @@ def action_switch_to_primary(self) -> None:
# Update input autocomplete data for the primary agent
self.enable_input({}, coder=self.worker.coder)
+ self._refresh_footer()
+
def action_switch_prev_agent(self) -> None:
"""Switch to the previous agent (primary or sub-agent), wrapping around."""
if not self._sub_agent_containers:
@@ -1320,6 +1321,8 @@ def _switch_to_container(self, uuid: str, suppress_input_enable: bool = False) -
coder = agent_service.foreground_coder
self.enable_input({}, coder=coder)
+ self._refresh_footer()
+
def create_sub_agent_container(self, uuid: str, name: str) -> None:
"""Create an OutputContainer for a sub-agent."""
from cecli.helpers.agents.service import AgentService
@@ -1378,10 +1381,45 @@ def remove_sub_agent_container(self, uuid: str) -> None:
agent_service.foreground_uuid = None
primary = self.query_one("#output", OutputContainer)
primary.display = True
+ self._refresh_footer()
# Sync border title with mode and sub-agent info
self._sync_sub_agent_display()
+ def _project_name_for_coder(self, coder) -> str:
+ """Compute a display project name for a coder's root (home-shortened)."""
+ home = str(Path.home())
+ repo = getattr(coder, "repo", None)
+ root = str(getattr(coder, "root", "") or "")
+ if not root and repo:
+ root = str(repo.root)
+ if not root:
+ root = str(Path.cwd())
+ if root.startswith(home):
+ project_name = root.replace(home, "~", 1)
+ else:
+ project_name = root
+ if len(project_name) >= 64:
+ project_name = project_name.split("/")[-1]
+ return project_name
+
+ def _refresh_footer(self):
+ """Refresh the footer with the active coder's project/root and git branch."""
+ try:
+ footer = self.query_one(MainFooter)
+ coder = self._get_visible_coder()
+ project_name = self._project_name_for_coder(coder)
+ branch = ""
+ repo = getattr(coder, "repo", None)
+ if repo:
+ try:
+ branch = repo.repo.active_branch.name or "main"
+ except Exception:
+ branch = branch or "main"
+ footer.update_info(project_name, branch)
+ except Exception:
+ pass
+
def _sync_sub_agent_display(self) -> None:
"""Update the InputContainer border title with mode and sub-agent pills.
@@ -1603,7 +1641,9 @@ def _get_path_completions(self, prefix: str) -> tuple[list[str], set[str]]:
# Try FileSystemService first for efficient lookups
try:
- fs = FileSystemService.get_instance()
+ fs = getattr(coder, "fs", None) or FileSystemService.for_root(
+ str(root), repo=getattr(coder, "repo", None)
+ )
if prefix:
if fs.trie:
is_fuzzy = False
diff --git a/cecli/tui/widgets/footer.py b/cecli/tui/widgets/footer.py
index 5f77cdae230..6ea1983ddb4 100644
--- a/cecli/tui/widgets/footer.py
+++ b/cecli/tui/widgets/footer.py
@@ -16,7 +16,6 @@ class MainFooter(Static):
# Right side info
project_name = reactive("")
git_branch = reactive("")
- git_dirty = reactive(0)
cost = reactive(0.0)
# Spinner state
@@ -138,8 +137,6 @@ def render(self) -> Text:
if self.git_branch:
right.append(self.git_branch)
- # if self.git_dirty:
- # right.append(f" +{self.git_dirty}")
# right.append(" ")
# Always show cost
@@ -168,10 +165,15 @@ def update_cost(self, cost: float):
self.cost = cost
self.refresh()
- def update_git(self, branch: str, dirty_count: int = 0):
+ def update_git(self, branch: str):
"""Update git status display."""
self.git_branch = branch
- self.git_dirty = dirty_count
+ self.refresh()
+
+ def update_info(self, project_name: str, branch: str):
+ """Update the project/root and git status display together."""
+ self.project_name = project_name
+ self.git_branch = branch
self.refresh()
def update_mode(self, mode: str):
diff --git a/cecli/utils.py b/cecli/utils.py
index f4341e03e7f..438c4ec33d0 100644
--- a/cecli/utils.py
+++ b/cecli/utils.py
@@ -127,7 +127,7 @@ def __init__(self):
def __enter__(self):
res = super().__enter__()
os.chdir(Path(self.temp_dir.name).resolve())
- return res
+ return str(Path(res).resolve())
def __exit__(self, exc_type, exc_val, exc_tb):
if self.cwd:
@@ -299,16 +299,61 @@ def get_pip_install(args):
return cmd
+def get_self_upgrade_command():
+ """Return the command used to upgrade cecli for the current install method."""
+ # pipx sets these environment variables inside each tool's venv
+ if os.environ.get("PIPX_VENV_DIR") or os.environ.get("PIPX_PACKAGE_DIR"):
+ return ["pipx", "upgrade", "cecli-dev"]
+
+ if _is_uv_tool_env():
+ return ["uv", "tool", "upgrade", "cecli-dev"]
+
+ return get_pip_install(["cecli-dev"])
+
+
+def _is_pip_install_command(cmd):
+ """Return True if cmd is a `python -m pip install ...` invocation."""
+ try:
+ i = cmd.index("-m")
+ except ValueError:
+ return False
+ return i + 2 < len(cmd) and cmd[i + 1] == "pip" and cmd[i + 2] == "install"
+
+
+def _is_uv_tool_env():
+ """Return True if running inside a `uv tool install` environment."""
+ cfg = Path(sys.prefix) / "pyvenv.cfg"
+ try:
+ if not (cfg.exists() and "uv = " in cfg.read_text(errors="replace")):
+ return False
+ except OSError:
+ return False
+
+ prefix = Path(sys.prefix)
+ tool_dir = os.environ.get("UV_TOOL_DIR")
+ if tool_dir:
+ try:
+ if prefix.is_relative_to(tool_dir):
+ return True
+ except (ValueError, OSError):
+ pass
+
+ # Default uv tool installs live under ".../uv/tools/".
+ # A plain `uv venv` project venv does not have the uv/tools path parts.
+ return "uv" in prefix.parts and "tools" in prefix.parts
+
+
def run_install(cmd):
print()
print("Installing:", printable_shell_command(cmd))
- # First ensure pip is available
- ensurepip_cmd = [sys.executable, "-m", "ensurepip", "--upgrade"]
- try:
- subprocess.run(ensurepip_cmd, capture_output=True, check=False)
- except Exception:
- pass # Continue even if ensurepip fails
+ if _is_pip_install_command(cmd):
+ # First ensure pip is available
+ ensurepip_cmd = [sys.executable, "-m", "ensurepip", "--upgrade"]
+ try:
+ subprocess.run(ensurepip_cmd, capture_output=True, check=False)
+ except Exception:
+ pass # Continue even if ensurepip fails
try:
from cecli.waiting import Spinner
@@ -386,7 +431,9 @@ def touch_file(fname):
return False
-async def check_pip_install_extra(io, module, prompt, pip_install_cmd, self_update=False):
+async def check_pip_install_extra(
+ io, module, prompt, pip_install_cmd=None, self_update=False, cmd=None
+):
if module:
for _attempt in range(2):
try:
@@ -403,7 +450,8 @@ async def check_pip_install_extra(io, module, prompt, pip_install_cmd, self_upda
except (ImportError, ModuleNotFoundError, RuntimeError):
break
- cmd = get_pip_install(pip_install_cmd)
+ if cmd is None:
+ cmd = get_pip_install(pip_install_cmd or [])
if prompt:
io.tool_warning(prompt)
@@ -414,9 +462,13 @@ async def check_pip_install_extra(io, module, prompt, pip_install_cmd, self_upda
print(printable_shell_command(cmd)) # plain print so it doesn't line-wrap
return
- if not await io.confirm_ask(
- "Run pip install?", default="y", subject=printable_shell_command(cmd)
- ):
+ run_prompt = "Run pip install?"
+ if cmd and cmd[0] == "uv":
+ run_prompt = "Run uv tool upgrade?"
+ elif cmd and cmd[0] == "pipx":
+ run_prompt = "Run pipx upgrade?"
+
+ if not await io.confirm_ask(run_prompt, default="y", subject=printable_shell_command(cmd)):
return
success, output = run_install(cmd)
diff --git a/cecli/versioncheck.py b/cecli/versioncheck.py
index 95ff02b32b7..b0681501a9e 100644
--- a/cecli/versioncheck.py
+++ b/cecli/versioncheck.py
@@ -40,7 +40,7 @@ async def install_upgrade(io, latest_version=None):
io.tool_warning(text)
return True
success = await utils.check_pip_install_extra(
- io, None, new_ver_text, ["cecli-dev"], self_update=True
+ io, None, new_ver_text, cmd=utils.get_self_upgrade_command(), self_update=True
)
if success:
io.tool_output("Re-run cecli to use new version.")
@@ -48,8 +48,8 @@ async def install_upgrade(io, latest_version=None):
return
-async def check_version(io, just_check=False, verbose=False):
- if not just_check and VERSION_CHECK_FNAME.exists():
+async def check_version(io, just_check=False, verbose=False, upgrade=False):
+ if not just_check and not upgrade and VERSION_CHECK_FNAME.exists():
day = 60 * 60 * 24
since = time.time() - os.path.getmtime(VERSION_CHECK_FNAME)
if 0 < since < day:
@@ -66,10 +66,8 @@ async def check_version(io, just_check=False, verbose=False):
current_version = cecli.__version__
if just_check or verbose:
io.tool_output(f"Current version: {current_version}")
- io.tool_output(f"Latest version: {latest_version}")
- is_update_available = (
- packaging.version.parse(latest_version).release
- > packaging.version.parse(current_version).release
+ is_update_available = packaging.version.parse(latest_version) > packaging.version.parse(
+ current_version
)
except Exception as err:
io.tool_error(f"Error checking pypi for new version: {err}")
diff --git a/cecli/website/docs/config.md b/cecli/website/docs/config.md
index 113a324d946..19a106e8a79 100644
--- a/cecli/website/docs/config.md
+++ b/cecli/website/docs/config.md
@@ -36,7 +36,7 @@ CECLI_TUI=true
## Default File Locations
-Cecli also checks several default locations inside `~/.cecli/` for configuration, environment variables, and agent resources. These are always included with lower precedence than project-level equivalents, so a setting in a project `.cecli.conf.yml` or `.env` file will override the `~/.cecli/` default.
+Cecli also checks several default locations inside `~/.cecli/` for configuration, environment variables, and agent resources. These are always included with lower precedence than project-level equivalents, so a setting in a project `.cecli.conf.yml` or `.env` file will override the `~/.cecli/` default. The agent resource registries below each also look for a **local** default under an agent's working root, which takes precedence over the global default.
### `~/.cecli/conf.yml`
@@ -48,11 +48,21 @@ An environment file loaded before any other `.env` file, so project-level `.env`
### `~/.cecli/skills/`
-A directory containing skill packages (each a sub-directory with a `SKILL.md` file). Skills here are discoverable alongside those in any user-configured skills paths.
+`Local: .cecli/skills/`
+
+A directory containing skill packages (each a sub-directory with a `SKILL.md` file).
### `~/.cecli/subagents/`
-A directory containing sub-agent definition files (`.md` files with YAML front matter). Sub-agents here are registered alongside those in any user-configured sub-agent paths.
+`Local: .cecli/subagents/`
+
+A directory containing sub-agent definition files (`.md` files with YAML front matter).
+
+### `~/.cecli/tools/`
+
+`Local: .cecli/tools/`
+
+A directory containing custom tool packages (`.py` files exposing a `Tool` class).
> **Tip:**
> See the [API key configuration docs](config/api-keys.html) for information on how to configure and store your API keys.
diff --git a/cecli/website/docs/config/subagents.md b/cecli/website/docs/config/subagents.md
index de25d363907..7f65bc516ad 100644
--- a/cecli/website/docs/config/subagents.md
+++ b/cecli/website/docs/config/subagents.md
@@ -73,6 +73,7 @@ agent-config:
|---------|-------------|
| `/spawn-agent ` | Spawn a sub-agent without a prompt (non-blocking — waits for user input) |
| `/spawn-agent ` | Spawn a sub-agent with a prompt (non-blocking — starts processing immediately) |
+| `/open ` | Register and open a `ws:{name}` workspace sub-agent rooted at `` (ad-hoc, no config file required) |
| `/reap-agent` | Force destroy the currently active sub-agent |
> **Tip**: `/spawn-agent` supports tab completion of sub-agent names.
diff --git a/cecli/website/docs/config/workspaces.md b/cecli/website/docs/config/workspaces.md
index 822aead0b5a..747c93c96f7 100644
--- a/cecli/website/docs/config/workspaces.md
+++ b/cecli/website/docs/config/workspaces.md
@@ -1,130 +1,145 @@
---
parent: Configuration
nav_order: 41
-description: Workspaces allow you to work across multiple related repositories simultaneously
+description: Workspaces turn multiple git repositories into agent-ready sub-agents you can spin up
---
# Workspaces
-Workspaces allow you to manage multiple git repositories within a single monorepo-like folder structure, enabling development across multiple related projects. `cecli` supports two workspace modes:
+Workspaces let you manage several git repositories as one unit and, crucially, turn each repository into an agent you can delegate to. A workspace is defined by a `.cecli.workspaces.yml` file that lists its projects. Each project is either:
-**clone** workspaces (remote `repo:` URLs cloned into `~/.cecli/workspaces/`)
+- **local** — an existing on-disk git root referenced by an absolute `path:` (used in-place, no cloning).
+- **clone** — a remote `repo:` URL that is cloned into `~/.cecli/workspaces/{workspace}/{project}/main`.
-**local** workspaces (existing on-disk git roots referenced by absolute `path:`)
+## Workspace sub-agents
-## Configuration
+When a workspace is active, each project automatically becomes an **implicit `ws:{project}` sub-agent**. These agents are modelled on the built-in `worker` sub-agent but with two key differences:
+
+- Their `root` is **overridden** to point at the project's git root (the
+ in-place `path:` directory for local projects, or the cloned checkout for `repo:` projects), so the agent operates inside that repository.
+- Their agent config sets `allow_nested_delegation: true`, so a `ws:*` agent can itself serve as a base for further delegations.
+
+Because `ws:{name}` agents are registered with the sub-agent registry, they are available through the normal mechanisms:
+
+- The `Delegate` tool (from a primary agent)
+- `/spawn-agent ws:{name}` (interactively)
+- `/open ` (ad-hoc, no config file required)
-You can configure workspaces in multiple locations. `cecli` searches for configurations in the following order:
+You can find the agent name reported by `/workspace` (e.g. `ws:app`).
-1. **CLI Argument**: Via a JSON/YAML configuration or file path passed to the `--workspaces` argument.
-2. **Local Workspaces File**: `.cecli.workspaces.yml` or `.cecli.workspaces.yaml` in the current directory.
-3. **Global Workspaces File**: `~/.cecli/workspaces.yml` or `~/.cecli/workspaces.yaml`.
+`/open app /path/to/app` opens a `ws:app` sub-agent rooted at the given path without needing a `.cecli.workspaces.yml` file. The path must be an existing git root; the agent is registered immediately and becomes the foreground agent.
+
+Each project may carry a `metadata` block that configures its `ws:{name}` agent the same way a sub-agent `.md` front-matter does: `model`, `hooks` and `auto_reap` map to the config fields, and any other key (e.g. `agent-config`) is merged into the agent's metadata. `root`, `name` and `description` are always derived from the project definition and cannot be overridden by `metadata`.
+
+## Configuration
-4. **Repo-Local Config File**: `.cecli.workspaces.yml` or `.cecli.workspaces.yaml` placed at a common ancestor of your project directories. `cecli` discovers this file by walking up from any project path, enabling a **local** workspace layout without cloning into `~/.cecli/workspaces/`.
+`cecli` searches for workspace config in the following order:
+
+1. **CLI argument** — a JSON/YAML string or file path passed to `--workspaces`.
+2. **Local workspace file** — `.cecli.workspaces.yml` / `.cecli.workspaces.yaml`
+ in the current directory, or at a common ancestor of the project
+ directories (discovered by walking up from any project path).
+3. **Global workspace file** — `~/.cecli/workspaces.yml` / `.cecli/workspaces.yaml`.
### Example Configuration
```yaml
workspaces:
- name: "my-workspace"
+ name: my-workspace
projects:
- - name: "frontend"
- repo: "https://github.com/user/frontend.git"
- branch: "main"
- worktrees:
- - name: "feature-auth"
- branch: "feature/auth"
- - name: "backend"
- repo: "https://github.com/user/backend.git"
- branch: "develop"
- use_current_branch: true # Default: true. Set to false to force branch switching on init.
- ignore: "~/.cecli/backend.ignore" # Optional: Path to a custom ignore file for this project
+ - name: app
+ path: /abs/path/to/app
+ primary: true # At most one project can be primary
+ metadata: # Optional sub-agent front-matter
+ model:
+ agent-config:
+ skills_paths: ["~/my-skills", "./project-skills"]
+ skills_includelist: ["python-refactoring", "react-components"]
+ - name: lib
+ path: /abs/path/to/lib
+ - name: docs
+ repo: https://github.com/user/docs.git
+ branch: main
+ use_current_branch: false # Force checkout of `branch` on init
+ ignore: ~/.cecli/docs.ignore # Optional custom ignore file
```
-### Local Workspace Configuration
-
-For **local** workspaces, place a `.cecli.workspaces.yml` file at a common ancestor of your project directories. Each project references an existing git root via `path:` instead of a remote `repo:` URL.
+### Project Fields
-```yaml
-# .cecli.workspaces.yml
-name: my-workspace
-projects:
- - name: app
- path: /abs/path/to/app
- primary: true # At most one project can be primary
- - name: lib
- path: /abs/path/to/lib
- readonly: true # Prevents commits to this project
-```
+| Field | Required | Description |
+|---------------------|----------|-------------|
+| `name` | Yes | Unique project name; also names the `ws:{name}` sub-agent |
+| `path` | One of | Absolute path to an existing local git root |
+| `repo` | One of | Remote clone URL (cloned under `~/.cecli/workspaces/`) |
+| `primary` | No | At most one project may set `primary: true` |
+| `branch` | No | Branch to check out when cloning (`repo:` projects) |
+| `use_current_branch`| No | Default `true`; set `false` to force branch switching on init |
+| `ignore` | No | Path to a custom ignore file for this project |
+| `metadata` | No | Optional sub-agent front-matter for the `ws:{name}` agent |
**Validation rules:**
-- Each project must have a `name` and **exactly one** of `path` (local git root) or `repo` (clone URL).
-- At most one project can be marked `primary: true`.
-- Projects with `readonly: true` are excluded from commits.
+- Each project must have a `name` and **exactly one** of `path` or `repo`.
+- At most one project may be marked `primary: true`.
+- Project names must be unique (they become `ws:{name}` agent names).
### Path Layout
-The workspace layout determines how file paths are structured within the workspace:
-
| Layout | Prefix | Example |
|--------|--------|--------|
| **clone** (repo-based) | `{project}/main/{file}` | `app/main/src/main.py` |
| **local** (path-based) | `{project}/{file}` | `app/src/main.py` |
-
### Multiple Workspaces
-You can define a list of workspaces. Use the `active: true` flag to specify which one should be used by default when running `cecli` without the `--workspace-name` argument. **Note: At most one workspace can be marked as active.**
+You can define a list of workspaces and mark one `active: true` (at most one):
+
```yaml
workspaces:
- - name: "project-a"
+ - name: project-a
active: true
projects:
- - name: "app"
- repo: "https://github.com/user/app.git"
- - name: "project-b"
+ - name: app
+ path: /abs/path/to/app
+ - name: project-b
projects:
- - name: "api"
- repo: "https://github.com/user/api.git"
+ - name: api
+ repo: https://github.com/user/api.git
```
-
## Usage
-To use a workspace:
-
```bash
cecli --workspace-name my-workspace
# OR if using a specific config file
cecli --workspaces path/to/workspaces.yml --workspace-name my-workspace
```
-If the workspace does not exist, `cecli` will create the directory structure at `~/.cecli/workspaces/my-workspace/` and clone the configured repositories. For **local** workspaces, the configured `path:` directories are used in-place — no cloning occurs.
-
-### Clone Workspace Structure
+Activating a workspace registers a `ws:{name}` sub-agent for each resolvable project. The primary agent's root is **unchanged** — multi-project work happens by delegating to the `ws:{name}` sub-agents, each rooted at its own project.
-```
-~/.cecli/workspaces/
-└── workspace-name/
- ├── .cecli-workspace.json
- └── project-name/
- ├── main/ # Main repository clone
- └── worktrees/ # Additional worktrees
-```
+- For **local** workspaces, the configured `path:` directories are used
+ in-place — no cloning occurs.
+- For **clone** workspaces, `cecli` creates `~/.cecli/workspaces/{workspace}/` and clones each `repo:` project into `{workspace}/{project}/main`.
-### Local Workspace Structure
-
-Local workspaces do **not** create a `~/.cecli/workspaces/` directory. Instead, the config file directory itself serves as the workspace root, with metadata stored at:
+Metadata is stored at the workspace root:
```
.cecli/
└── .workspace-meta.json
```
-The project directories exist at their configured `path:` locations on disk.
+Clone workspaces materialise under `~/.cecli/workspaces/`:
-## Arguments
+```
+~/.cecli/workspaces/
+└── my-workspace/
+ ├── .cecli/
+ │ └── .workspace-meta.json
+ └── app/
+ └── main/ # git clone of `repo:`
+```
-`--workspaces `: Provide a JSON/YAML configuration or file path for workspace initialization.
+## Arguments
-`--workspace-name `: Specify the workspace name to activate from the configuration.
+- `--workspaces `: Provide a JSON/YAML configuration or file path for
+ workspace initialization.
+- `--workspace-name `: Specify the workspace name to activate.
diff --git a/requirements.txt b/requirements.txt
index 434705d7ce4..56d75692d3a 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,5 +1,17 @@
# This file was autogenerated by uv via the following command:
# uv pip compile --no-strip-extras --constraint=requirements/common-constraints.txt --output-file=tmp.requirements.txt requirements/requirements.in
+aiohappyeyeballs==2.6.1
+ # via
+ # -c requirements/common-constraints.txt
+ # aiohttp
+aiohttp==3.14.3
+ # via
+ # -c requirements/common-constraints.txt
+ # kubernetes
+aiosignal==1.4.0
+ # via
+ # -c requirements/common-constraints.txt
+ # aiohttp
annotated-types==0.7.0
# via
# -c requirements/common-constraints.txt
@@ -15,8 +27,13 @@ anyio==4.11.0
attrs==25.4.0
# via
# -c requirements/common-constraints.txt
+ # aiohttp
# jsonschema
# referencing
+bcrypt==5.0.0
+ # via
+ # -c requirements/common-constraints.txt
+ # chromadb
beautifulsoup4==4.14.2
# via
# -c requirements/common-constraints.txt
@@ -25,11 +42,16 @@ blinker==1.9.0
# via
# -c requirements/common-constraints.txt
# -r requirements/requirements.in
+build==1.3.0
+ # via
+ # -c requirements/common-constraints.txt
+ # chromadb
certifi==2025.11.12
# via
# -c requirements/common-constraints.txt
# httpcore
# httpx
+ # kubernetes
# requests
cffi==2.0.0
# via
@@ -42,9 +64,14 @@ charset-normalizer==3.4.9
# -c requirements/common-constraints.txt
# -r requirements/requirements.in
# requests
+chromadb==1.5.9
+ # via
+ # -c requirements/common-constraints.txt
+ # -r requirements/requirements.in
click==8.3.1
# via
# -c requirements/common-constraints.txt
+ # typer
# uvicorn
configargparse==1.7.1
# via
@@ -63,6 +90,27 @@ diskcache==5.6.3
# via
# -c requirements/common-constraints.txt
# -r requirements/requirements.in
+durationpy==0.10
+ # via
+ # -c requirements/common-constraints.txt
+ # kubernetes
+filelock==3.20.0
+ # via
+ # -c requirements/common-constraints.txt
+ # huggingface-hub
+flatbuffers==25.12.19
+ # via
+ # -c requirements/common-constraints.txt
+ # onnxruntime
+frozenlist==1.8.0
+ # via
+ # -c requirements/common-constraints.txt
+ # aiohttp
+ # aiosignal
+fsspec==2025.10.0
+ # via
+ # -c requirements/common-constraints.txt
+ # huggingface-hub
gitdb==4.0.12
# via
# -c requirements/common-constraints.txt
@@ -71,29 +119,52 @@ gitpython==3.1.45
# via
# -c requirements/common-constraints.txt
# -r requirements/requirements.in
+googleapis-common-protos==1.75.1
+ # via
+ # -c requirements/common-constraints.txt
+ # opentelemetry-exporter-otlp-proto-grpc
+grpcio==1.83.0
+ # via
+ # -c requirements/common-constraints.txt
+ # chromadb
+ # opentelemetry-exporter-otlp-proto-grpc
h11==0.16.0
# via
# -c requirements/common-constraints.txt
# httpcore
# uvicorn
+hf-xet==1.2.0
+ # via
+ # -c requirements/common-constraints.txt
+ # huggingface-hub
httpcore==1.0.9
# via
# -c requirements/common-constraints.txt
# httpx
+httptools==0.8.0
+ # via
+ # -c requirements/common-constraints.txt
+ # uvicorn
httpx==0.28.1
# via
# -c requirements/common-constraints.txt
+ # chromadb
# mcp
httpx-sse==0.4.3
# via
# -c requirements/common-constraints.txt
# mcp
+huggingface-hub==0.36.0
+ # via
+ # -c requirements/common-constraints.txt
+ # tokenizers
idna==3.11
# via
# -c requirements/common-constraints.txt
# anyio
# httpx
# requests
+ # yarl
importlib-metadata==8.7.0
# via
# -c requirements/common-constraints.txt
@@ -102,6 +173,7 @@ importlib-resources==6.5.2
# via
# -c requirements/common-constraints.txt
# -r requirements/requirements.in
+ # chromadb
json-repair==0.60.1
# via
# -c requirements/common-constraints.txt
@@ -110,11 +182,16 @@ jsonschema==4.25.1
# via
# -c requirements/common-constraints.txt
# -r requirements/requirements.in
+ # chromadb
# mcp
jsonschema-specifications==2025.9.1
# via
# -c requirements/common-constraints.txt
# jsonschema
+kubernetes==36.0.3
+ # via
+ # -c requirements/common-constraints.txt
+ # chromadb
linkify-it-py==2.0.3
# via
# -c requirements/common-constraints.txt
@@ -141,10 +218,19 @@ mdurl==0.1.2
# via
# -c requirements/common-constraints.txt
# markdown-it-py
+mmh3==5.2.1
+ # via
+ # -c requirements/common-constraints.txt
+ # chromadb
mslex==1.3.0
# via
# -c requirements/common-constraints.txt
# oslex
+multidict==6.7.0
+ # via
+ # -c requirements/common-constraints.txt
+ # aiohttp
+ # yarl
ngram==4.0.3
# via
# -c requirements/common-constraints.txt
@@ -152,20 +238,67 @@ ngram==4.0.3
numpy==2.3.5
# via
# -c requirements/common-constraints.txt
+ # chromadb
+ # onnxruntime
# rustworkx
# soundfile
+oauthlib==3.3.1
+ # via
+ # -c requirements/common-constraints.txt
+ # requests-oauthlib
+onnxruntime==1.29.0
+ # via
+ # -c requirements/common-constraints.txt
+ # chromadb
+opentelemetry-api==1.44.0
+ # via
+ # -c requirements/common-constraints.txt
+ # chromadb
+ # opentelemetry-exporter-otlp-proto-grpc
+ # opentelemetry-sdk
+ # opentelemetry-semantic-conventions
+opentelemetry-exporter-otlp-proto-common==1.44.0
+ # via
+ # -c requirements/common-constraints.txt
+ # opentelemetry-exporter-otlp-proto-grpc
+opentelemetry-exporter-otlp-proto-grpc==1.44.0
+ # via
+ # -c requirements/common-constraints.txt
+ # chromadb
+opentelemetry-proto==1.44.0
+ # via
+ # -c requirements/common-constraints.txt
+ # opentelemetry-exporter-otlp-proto-common
+ # opentelemetry-exporter-otlp-proto-grpc
+opentelemetry-sdk==1.44.0
+ # via
+ # -c requirements/common-constraints.txt
+ # chromadb
+ # opentelemetry-exporter-otlp-proto-grpc
+opentelemetry-semantic-conventions==0.65b0
+ # via
+ # -c requirements/common-constraints.txt
+ # opentelemetry-sdk
orjson==3.11.9
# via
# -c requirements/common-constraints.txt
# -r requirements/requirements.in
+ # chromadb
oslex==0.1.3
# via
# -c requirements/common-constraints.txt
# -r requirements/requirements.in
+overrides==7.7.0
+ # via
+ # -c requirements/common-constraints.txt
+ # chromadb
packaging==25.0
# via
# -c requirements/common-constraints.txt
# -r requirements/requirements.in
+ # build
+ # huggingface-hub
+ # onnxruntime
pathspec==0.12.1
# via
# -c requirements/common-constraints.txt
@@ -186,6 +319,17 @@ prompt-toolkit==3.0.52
# via
# -c requirements/common-constraints.txt
# -r requirements/requirements.in
+propcache==0.4.1
+ # via
+ # -c requirements/common-constraints.txt
+ # aiohttp
+ # yarl
+protobuf==7.36.0
+ # via
+ # -c requirements/common-constraints.txt
+ # googleapis-common-protos
+ # onnxruntime
+ # opentelemetry-proto
psutil==7.1.3
# via
# -c requirements/common-constraints.txt
@@ -198,6 +342,10 @@ py-cymbal==0.2.1
# via
# -c requirements/common-constraints.txt
# -r requirements/requirements.in
+pybase64==1.5.0
+ # via
+ # -c requirements/common-constraints.txt
+ # chromadb
pycparser==2.23
# via
# -c requirements/common-constraints.txt
@@ -205,6 +353,7 @@ pycparser==2.23
pydantic==2.12.4
# via
# -c requirements/common-constraints.txt
+ # chromadb
# mcp
# pydantic-settings
pydantic-core==2.41.5
@@ -214,6 +363,7 @@ pydantic-core==2.41.5
pydantic-settings==2.12.0
# via
# -c requirements/common-constraints.txt
+ # chromadb
# mcp
pydub==0.25.1
# via
@@ -236,11 +386,24 @@ pyperclip==1.11.0
# via
# -c requirements/common-constraints.txt
# -r requirements/requirements.in
+pypika==0.51.1
+ # via
+ # -c requirements/common-constraints.txt
+ # chromadb
+pyproject-hooks==1.2.0
+ # via
+ # -c requirements/common-constraints.txt
+ # build
+python-dateutil==2.9.0.post0
+ # via
+ # -c requirements/common-constraints.txt
+ # kubernetes
python-dotenv==1.2.2
# via
# -c requirements/common-constraints.txt
# -r requirements/requirements.in
# pydantic-settings
+ # uvicorn
python-multipart==0.0.20
# via
# -c requirements/common-constraints.txt
@@ -249,6 +412,10 @@ pyyaml==6.0.3
# via
# -c requirements/common-constraints.txt
# -r requirements/requirements.in
+ # chromadb
+ # huggingface-hub
+ # kubernetes
+ # uvicorn
rapidfuzz==3.14.5
# via
# -c requirements/common-constraints.txt
@@ -262,11 +429,20 @@ requests==2.32.5
# via
# -c requirements/common-constraints.txt
# -r requirements/requirements.in
+ # huggingface-hub
+ # kubernetes
+ # requests-oauthlib
+requests-oauthlib==2.0.0
+ # via
+ # -c requirements/common-constraints.txt
+ # kubernetes
rich==14.2.0
# via
# -c requirements/common-constraints.txt
# -r requirements/requirements.in
+ # chromadb
# textual
+ # typer
rpds-py==0.29.0
# via
# -c requirements/common-constraints.txt
@@ -276,10 +452,19 @@ rustworkx==0.17.1
# via
# -c requirements/common-constraints.txt
# -r requirements/requirements.in
+shellingham==1.5.4
+ # via
+ # -c requirements/common-constraints.txt
+ # typer
shtab==1.8.0
# via
# -c requirements/common-constraints.txt
# -r requirements/requirements.in
+six==1.17.0
+ # via
+ # -c requirements/common-constraints.txt
+ # kubernetes
+ # python-dateutil
smmap==5.0.2
# via
# -c requirements/common-constraints.txt
@@ -312,10 +497,18 @@ starlette==0.50.0
# via
# -c requirements/common-constraints.txt
# mcp
+tenacity==9.1.2
+ # via
+ # -c requirements/common-constraints.txt
+ # chromadb
textual==8.2.8
# via
# -c requirements/common-constraints.txt
# -r requirements/requirements.in
+tokenizers==0.22.1
+ # via
+ # -c requirements/common-constraints.txt
+ # chromadb
tomlkit==0.14.0
# via
# -c requirements/common-constraints.txt
@@ -324,6 +517,8 @@ tqdm==4.67.1
# via
# -c requirements/common-constraints.txt
# -r requirements/requirements.in
+ # chromadb
+ # huggingface-hub
# via
# -c requirements/common-constraints.txt
# -r requirements/requirements.in
@@ -353,17 +548,31 @@ truststore==0.10.4
# via
# -c requirements/common-constraints.txt
# -r requirements/requirements.in
+typer==0.20.0
+ # via
+ # -c requirements/common-constraints.txt
+ # chromadb
typing-extensions==4.15.0
# via
# -c requirements/common-constraints.txt
+ # aiohttp
+ # aiosignal
# anyio
# beautifulsoup4
+ # chromadb
+ # grpcio
+ # huggingface-hub
# mcp
+ # opentelemetry-api
+ # opentelemetry-exporter-otlp-proto-grpc
+ # opentelemetry-sdk
+ # opentelemetry-semantic-conventions
# pydantic
# pydantic-core
# referencing
# starlette
# textual
+ # typer
# typing-inspection
typing-inspection==0.4.2
# via
@@ -378,27 +587,43 @@ uc-micro-py==1.0.3
urllib3==2.5.0
# via
# -c requirements/common-constraints.txt
+ # kubernetes
# requests
-uvicorn==0.38.0
+uvicorn[standard]==0.38.0
# via
# -c requirements/common-constraints.txt
+ # chromadb
# mcp
+uvloop==0.22.1
+ # via
+ # -c requirements/common-constraints.txt
+ # uvicorn
watchfiles==1.1.1
# via
# -c requirements/common-constraints.txt
# -r requirements/requirements.in
+ # uvicorn
wcwidth==0.2.14
# via
# -c requirements/common-constraints.txt
# prompt-toolkit
+websocket-client==1.9.0
+ # via
+ # -c requirements/common-constraints.txt
+ # kubernetes
websockets==16.1.1
# via
# -c requirements/common-constraints.txt
# -r requirements/requirements.in
+ # uvicorn
xxhash==3.6.0
# via
# -c requirements/common-constraints.txt
# -r requirements/requirements.in
+yarl==1.22.0
+ # via
+ # -c requirements/common-constraints.txt
+ # aiohttp
zipp==3.23.0
# via
# -c requirements/common-constraints.txt
diff --git a/requirements/common-constraints.txt b/requirements/common-constraints.txt
index 4fd11b636ba..f1e10a0bf65 100644
--- a/requirements/common-constraints.txt
+++ b/requirements/common-constraints.txt
@@ -48,7 +48,9 @@ charset-normalizer==3.4.9
# -r requirements/requirements.in
# requests
chromadb==1.5.9
- # via -r requirements/requirements-help.in
+ # via
+ # -r requirements/requirements-help.in
+ # -r requirements/requirements.in
click==8.3.1
# via
# pip-tools
diff --git a/requirements/requirements-help.in b/requirements/requirements-help.in
index 1bbbacbc7d0..b0d20386377 100644
--- a/requirements/requirements-help.in
+++ b/requirements/requirements-help.in
@@ -1,4 +1,4 @@
-chromadb
+chromadb>=1.5.9
# numpy is pulled in by chromadb's onnxruntime embedding stack
numpy>=1.26.4
\ No newline at end of file
diff --git a/requirements/requirements.in b/requirements/requirements.in
index 0e3ba766d00..c83aca94357 100644
--- a/requirements/requirements.in
+++ b/requirements/requirements.in
@@ -33,6 +33,7 @@ beautifulsoup4>=4.13.4
pypandoc>=1.15
# storage/caching
+chromadb>=1.5.9
diskcache>=5.6.3
# general string parsing
diff --git a/tests/basic/test_coder.py b/tests/basic/test_coder.py
index 216ef6e1585..07b02a92df1 100644
--- a/tests/basic/test_coder.py
+++ b/tests/basic/test_coder.py
@@ -17,7 +17,7 @@
from cecli.io import InputOutput
from cecli.mcp import McpServerManager
from cecli.models import Model
-from cecli.repo import GitRepo
+from cecli.repo import GitRepo, GitRepoProxy
from cecli.sendchat import sanity_check_messages
from cecli.utils import GitTemporaryDirectory
@@ -38,10 +38,11 @@ def setup(self, gpt35_model):
self.mock_webbrowser = self.webbrowser_patcher.start()
# Reset conversation system before each test
ConversationService.get_chunks(self).reset()
- # Reset FileSystemService singleton for test isolation
+ # Reset per-root singletons for test isolation
from cecli.helpers.file_system import FileSystemService
FileSystemService.reset_instance()
+ GitRepoProxy.reset_instances()
yield
# Cleanup after each test
self.webbrowser_patcher.stop()
diff --git a/tests/basic/test_file_system_service.py b/tests/basic/test_file_system_service.py
new file mode 100644
index 00000000000..a154dab43e9
--- /dev/null
+++ b/tests/basic/test_file_system_service.py
@@ -0,0 +1,206 @@
+import gc
+import os
+import weakref
+
+import pytest
+
+from cecli.coders import Coder
+from cecli.helpers.file_system import FileSystemService
+from cecli.io import InputOutput
+from cecli.repo import GitRepoProxy
+from cecli.utils import GitTemporaryDirectory
+
+
+@pytest.fixture(autouse=True)
+def reset_registries():
+ FileSystemService.reset_instance()
+ GitRepoProxy.reset_instances()
+ yield
+ FileSystemService.reset_instance()
+ GitRepoProxy.reset_instances()
+
+
+class TestFileSystemServicePerRoot:
+ def test_for_root_caches_per_base_path(self):
+ with GitTemporaryDirectory() as root:
+ first = FileSystemService.for_root(root)
+ again = FileSystemService.for_root(root)
+
+ assert first is again
+ assert FileSystemService._normalize_root(root) in FileSystemService._instances
+
+ def test_for_root_distinct_for_distinct_base_paths(self):
+ with GitTemporaryDirectory() as root_a, GitTemporaryDirectory() as root_b:
+ service_a = FileSystemService.for_root(root_a)
+ service_b = FileSystemService.for_root(root_b)
+
+ assert service_a is not service_b
+ assert service_a.root.rstrip("/") == root_a.rstrip("/")
+ assert service_b.root.rstrip("/") == root_b.rstrip("/")
+
+ def test_get_instance_returns_active_root(self):
+ with GitTemporaryDirectory() as root_a, GitTemporaryDirectory() as root_b:
+ service_a = FileSystemService.for_root(root_a)
+ service_b = FileSystemService.for_root(root_b)
+
+ assert FileSystemService.get_instance() is service_b
+ assert FileSystemService.get_instance() is not service_a
+
+ def test_evict_removes_root(self):
+ with GitTemporaryDirectory() as root:
+ FileSystemService.for_root(root)
+ key = FileSystemService._normalize_root(root)
+ assert key in FileSystemService._instances
+
+ FileSystemService.evict(root)
+ assert key not in FileSystemService._instances
+
+
+class TestGitRepoProxyPerRoot:
+ def test_for_root_caches_per_base_path(self):
+ with GitTemporaryDirectory() as root:
+ io = InputOutput(pretty=False, yes=True)
+ proxy_a = GitRepoProxy.for_root(None, io, fnames=[root])
+ proxy_b = GitRepoProxy.for_root(None, io, fnames=[root])
+
+ assert proxy_a is proxy_b
+
+ def test_for_root_distinct_for_distinct_base_paths(self):
+ with GitTemporaryDirectory() as root_a, GitTemporaryDirectory() as root_b:
+ io = InputOutput(pretty=False, yes=True)
+ proxy_a = GitRepoProxy.for_root(None, io, fnames=[root_a])
+ proxy_b = GitRepoProxy.for_root(None, io, fnames=[root_b])
+
+ assert proxy_a is not proxy_b
+ assert proxy_a.root.rstrip("/") == root_a.rstrip("/")
+ assert proxy_b.root.rstrip("/") == root_b.rstrip("/")
+
+ def test_for_root_with_explicit_root_caches(self):
+ with GitTemporaryDirectory() as root:
+ io = InputOutput(pretty=False, yes=True)
+ proxy = GitRepoProxy.for_root(root, io)
+
+ assert proxy.root.rstrip("/") == root.rstrip("/")
+ assert GitRepoProxy.for_root(root, io) is proxy
+
+
+class TestAutomaticCleanup:
+ """The per-root service is released when the last coder sharing it dies."""
+
+ class _FakeCoder:
+ def __init__(self, root):
+ self.root = root
+ self.repo = GitRepoProxy.for_root(
+ None, InputOutput(pretty=False, yes=True), fnames=[root]
+ )
+ self.fs = FileSystemService.for_root(root, repo=self.repo)
+ self._fs_key = FileSystemService._normalize_root(root)
+ FileSystemService._inc_ref(self._fs_key)
+ weakref.finalize(self, FileSystemService._release, self._fs_key)
+
+ def test_last_coder_gc_evicts_service_and_repo(self):
+ with GitTemporaryDirectory() as root:
+ key = FileSystemService._normalize_root(root)
+ coder = self._FakeCoder(root)
+
+ assert FileSystemService._refcounts.get(key) == 1
+ assert key in FileSystemService._instances
+ assert key in GitRepoProxy._instances
+
+ del coder
+ gc.collect()
+
+ assert key not in FileSystemService._instances
+ assert key not in GitRepoProxy._instances
+ assert FileSystemService._refcounts.get(key, 0) == 0
+
+ def test_shared_root_kept_alive_until_last_coder_dies(self):
+ with GitTemporaryDirectory() as root:
+ key = FileSystemService._normalize_root(root)
+ coder_a = self._FakeCoder(root)
+ coder_b = self._FakeCoder(root)
+
+ assert coder_a.fs is coder_b.fs
+ assert coder_a.repo is coder_b.repo
+ assert FileSystemService._refcounts.get(key) == 2
+
+ del coder_b
+ gc.collect()
+ assert key in FileSystemService._instances
+ assert key in GitRepoProxy._instances
+ assert FileSystemService._refcounts.get(key) == 1
+
+ del coder_a
+ gc.collect()
+ assert key not in FileSystemService._instances
+ assert key not in GitRepoProxy._instances
+ assert FileSystemService._refcounts.get(key, 0) == 0
+
+
+class TestCoderIntegration:
+ async def test_coder_gets_per_root_fs_and_repo(self, gpt35_model):
+ with GitTemporaryDirectory() as root_a:
+ io = InputOutput(pretty=False, yes=True)
+ coder_a = await Coder.create(gpt35_model, None, io)
+ with GitTemporaryDirectory() as root_b:
+ coder_b = await Coder.create(gpt35_model, None, io)
+
+ assert coder_a.fs is not coder_b.fs
+ assert coder_a.repo is not coder_b.repo
+ assert coder_a.fs.repo is coder_a.repo
+ assert coder_b.fs.repo is coder_b.repo
+ assert coder_a.root.rstrip("/") == root_a.rstrip("/")
+ assert coder_b.root.rstrip("/") == root_b.rstrip("/")
+
+
+class TestRootOverride:
+ """Validates the sub-agent root override and primary_root retention."""
+
+ async def test_root_kwarg_overrides_working_dir(self, gpt35_model):
+ with GitTemporaryDirectory() as root_a:
+ os.chdir(root_a)
+ nested = os.path.join(root_a, "nested")
+ os.makedirs(nested, exist_ok=True)
+ io = InputOutput(pretty=False, yes=True)
+
+ coder = await Coder.create(gpt35_model, None, io, root=nested)
+
+ assert os.path.normpath(coder.root) == os.path.normpath(nested)
+ assert coder.primary_root == coder.root
+ assert coder.fs is FileSystemService.for_root(nested, repo=coder.repo)
+
+ async def test_primary_root_propagated_from_parent(self, gpt35_model):
+ with GitTemporaryDirectory():
+ io = InputOutput(pretty=False, yes=True)
+ parent = await Coder.create(gpt35_model, None, io)
+ with GitTemporaryDirectory() as root_b:
+ child = await Coder.create(from_coder=parent, root=root_b, io=io)
+
+ assert child.primary_root.rstrip("/") == parent.root.rstrip("/")
+ assert child.root.rstrip("/") == root_b.rstrip("/")
+ assert child.fs is not parent.fs
+
+ async def test_sub_agent_repo_scoped_to_its_root(self, gpt35_model):
+ with GitTemporaryDirectory():
+ io = InputOutput(pretty=False, yes=True)
+ parent = await Coder.create(gpt35_model, None, io)
+ with GitTemporaryDirectory() as root_b:
+ child = await Coder.create(from_coder=parent, root=root_b, io=io)
+
+ assert child.root.rstrip("/") == root_b.rstrip("/")
+ assert child.repo.root.rstrip("/") == root_b.rstrip("/")
+ assert child.fs.repo.root.rstrip("/") == root_b.rstrip("/")
+
+ async def test_resolve_relative_to_primary_root(self, gpt35_model):
+ with GitTemporaryDirectory() as root_a:
+ os.chdir(root_a)
+ io = InputOutput(pretty=False, yes=True)
+ coder = await Coder.create(gpt35_model, None, io)
+ base = coder.primary_root or coder.root
+
+ resolved = coder.resolve_relative_to_primary_root("skills/mine")
+ assert resolved == os.path.normpath(os.path.join(base, "skills/mine"))
+
+ abs_path = "/tmp/abs-skill"
+ assert coder.resolve_relative_to_primary_root(abs_path) == abs_path
+ assert coder.resolve_relative_to_primary_root("") == ""
diff --git a/tests/basic/test_skills.py b/tests/basic/test_skills.py
index b7e24f5d082..9271f8f426e 100644
--- a/tests/basic/test_skills.py
+++ b/tests/basic/test_skills.py
@@ -35,7 +35,7 @@ def test_skills_manager_initialization(self):
assert manager.directory_paths == [Path.home() / ".cecli" / "skills"]
assert manager.include_list is None
assert manager.exclude_list == set()
- assert manager.git_root is None
+ assert manager.root is None
# Test _loaded_skills is initialized as empty set
assert manager._loaded_skills == set()
@@ -51,15 +51,25 @@ def test_skills_manager_initialization(self):
["/tmp/test"],
include_list=["skill1", "skill2"],
exclude_list=["skill3"],
- git_root="/tmp",
+ root="/tmp",
)
- # "/tmp/test" + default home dir = 2 paths
- assert len(manager.directory_paths) == 2
+ # "/tmp/test" + local default + default home dir = 3 paths
+ assert len(manager.directory_paths) == 3
+ assert Path("/tmp/test").resolve() in manager.directory_paths
+ assert (Path("/tmp") / ".cecli" / "skills").resolve() in manager.directory_paths
assert manager.include_list == {"skill1", "skill2"}
assert manager.exclude_list == {"skill3"}
- assert manager.git_root == Path("/tmp").expanduser().resolve()
+ assert manager.root == Path("/tmp").expanduser().resolve()
assert manager._loaded_skills == set()
+ def test_local_default_skills_dir(self):
+ """Local default {root}/.cecli/skills is included alongside global default."""
+ manager = SkillsManager([], root="/tmp/local-root")
+
+ paths = [str(p) for p in manager.directory_paths]
+ assert (Path("/tmp/local-root") / ".cecli" / "skills").resolve() in manager.directory_paths
+ assert str(Path.home() / ".cecli" / "skills") in paths
+
def test_create_and_parse_skill(self):
"""Test creating a skill and parsing its metadata."""
# Create a skill directory structure
@@ -164,15 +174,15 @@ def test_resolve_skill_directories(self):
assert len(paths) == 1
assert paths[0] == Path(self.temp_dir).resolve()
- # Test with relative path and git root
- paths = SkillsManager.resolve_skill_directories(["./test-dir"], git_root=self.temp_dir)
+ # Test with relative path and root
+ paths = SkillsManager.resolve_skill_directories(["./test-dir"], root=self.temp_dir)
# Should not resolve because directory doesn't exist
assert len(paths) == 0
# Create the directory and test again
test_dir = Path(self.temp_dir) / "test-dir"
test_dir.mkdir()
- paths = SkillsManager.resolve_skill_directories(["./test-dir"], git_root=self.temp_dir)
+ paths = SkillsManager.resolve_skill_directories(["./test-dir"], root=self.temp_dir)
assert len(paths) == 1
assert paths[0] == test_dir.resolve()
diff --git a/tests/commands/test_open.py b/tests/commands/test_open.py
new file mode 100644
index 00000000000..5c5510a6e98
--- /dev/null
+++ b/tests/commands/test_open.py
@@ -0,0 +1,118 @@
+from pathlib import Path
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+from cecli.commands.open import OpenCommand
+
+
+@pytest.fixture
+def mock_coder():
+ coder = MagicMock()
+ coder.uuid = "primary-uuid"
+ coder.tui = None
+ return coder
+
+
+@pytest.fixture
+def mock_io():
+ io = MagicMock()
+ return io
+
+
+@pytest.fixture
+def mock_agent_service():
+ with patch("cecli.commands.open.AgentService") as MockAgentService:
+ agent_service = MockAgentService.get_instance.return_value
+ MockAgentService.get_registry.return_value = {
+ "ws:app": MagicMock(metadata={"root": "/abs/app"})
+ }
+ yield agent_service
+
+
+class TestOpenCommand:
+ @pytest.mark.asyncio
+ async def test_execute_missing_args(self, mock_coder, mock_io):
+ await OpenCommand.execute(mock_io, mock_coder, "")
+ mock_io.tool_error.assert_called_once_with("Usage: /open ")
+
+ @pytest.mark.asyncio
+ async def test_execute_invalid_path(self, mock_coder, mock_io):
+ with patch("cecli.commands.open.register_workspace_subagents", return_value=[]):
+ await OpenCommand.execute(mock_io, mock_coder, "app /no/such/path")
+
+ open_path = Path("/no/such/path").expanduser()
+ mock_io.tool_error.assert_called_once_with(
+ f"Error: '{open_path}' is not a valid git repository or does not exist."
+ )
+
+ @pytest.mark.asyncio
+ async def test_execute_success_non_tui(self, mock_coder, mock_io, mock_agent_service):
+ info = MagicMock()
+ info.coder.uuid = "sub-uuid-1"
+ mock_agent_service.spawn = AsyncMock(return_value=(MagicMock(), info))
+
+ with patch("cecli.commands.open.register_workspace_subagents", return_value=["ws:app"]):
+ await OpenCommand.execute(mock_io, mock_coder, "app /abs/app")
+
+ # spawn is non-blocking with no prompt; the sub-agent becomes the foreground agent.
+ mock_agent_service.spawn.assert_awaited_once_with(
+ "ws:app", prompt=None, parent=mock_coder, auto_reap=False, independent=True
+ )
+ assert mock_agent_service.foreground_uuid == "sub-uuid-1"
+ mock_io.tool_output.assert_called_once_with(
+ "Opened workspace sub-agent 'ws:app' rooted at /abs/app."
+ )
+
+ @pytest.mark.asyncio
+ async def test_execute_success_tui(self, mock_coder, mock_io, mock_agent_service):
+ tui = MagicMock()
+ tui.get_keys_for.return_value = ""
+ mock_coder.tui = MagicMock(return_value=tui)
+
+ info = MagicMock()
+ info.coder.uuid = "sub-uuid-1"
+ mock_agent_service.spawn = AsyncMock(return_value=(MagicMock(), info))
+
+ with patch("cecli.commands.open.register_workspace_subagents", return_value=["ws:app"]):
+ await OpenCommand.execute(mock_io, mock_coder, "app /abs/app")
+
+ tui.call_from_thread.assert_called_once_with(tui._switch_to_container, "sub-uuid-1")
+ mock_io.tool_output.assert_called_once_with(
+ "Opened workspace sub-agent 'ws:app' rooted at /abs/app. Switch with "
+ )
+
+ @pytest.mark.asyncio
+ async def test_execute_ws_prefix(self, mock_coder, mock_io, mock_agent_service):
+ info = MagicMock()
+ info.coder.uuid = "sub-uuid-1"
+ mock_agent_service.spawn = AsyncMock(return_value=(MagicMock(), info))
+
+ with patch("cecli.commands.open.register_workspace_subagents", return_value=["ws:app"]):
+ await OpenCommand.execute(mock_io, mock_coder, "ws:app /abs/app")
+
+ mock_agent_service.spawn.assert_awaited_once_with(
+ "ws:app", prompt=None, parent=mock_coder, auto_reap=False, independent=True
+ )
+
+ @pytest.mark.asyncio
+ async def test_execute_spawn_error(self, mock_coder, mock_io, mock_agent_service):
+ mock_agent_service.spawn = AsyncMock(side_effect=RuntimeError("boom"))
+
+ with patch("cecli.commands.open.register_workspace_subagents", return_value=["ws:app"]):
+ await OpenCommand.execute(mock_io, mock_coder, "app /abs/app")
+
+ mock_io.tool_error.assert_called_once_with(
+ "Error opening workspace sub-agent 'ws:app': boom"
+ )
+
+ def test_get_help(self):
+ assert "(/open )" in OpenCommand.get_help()
+
+ def test_get_completions(self):
+ with patch("cecli.commands.open.AgentService") as MockAgentService:
+ MockAgentService.get_registry.return_value = {
+ "ws:app": MagicMock(),
+ "worker": MagicMock(),
+ }
+ assert OpenCommand.get_completions(MagicMock(), MagicMock(), "") == ["ws:app"]
diff --git a/tests/helpers/monorepo/LOCAL_WORKSPACE.md b/tests/helpers/monorepo/LOCAL_WORKSPACE.md
deleted file mode 100644
index dd4311195cb..00000000000
--- a/tests/helpers/monorepo/LOCAL_WORKSPACE.md
+++ /dev/null
@@ -1,66 +0,0 @@
-# PR: Local `path:` projects for cecli workspaces
-
-## Summary
-
-Extends cecli’s existing **clone** workspace mode (`repo:` URLs under `~/.cecli/workspaces/`, paths like `project/main/file.py`) with **local** layout: multiple git roots on disk referenced by absolute `path:` in a repo-local config file.
-
-## Motivation
-
-IDE clients (e.g. BrightVision) open a **primary git repo** but need agent context across **sibling repos** without cloning into `~/.cecli/workspaces/`. Submodule-only setups are a different layout; this PR adds an explicit, reviewable config surface.
-
-## Config
-
-Place at the workspace root (walked up from any listed project path):
-
-```yaml
-# .cecli.workspaces.yml
-name: my-workspace
-projects:
- - name: app
- path: /abs/path/to/app
- primary: true
- - name: lib
- path: /abs/path/to/lib
- readonly: true
-```
-
-Rules (enforced in `validate_config`):
-
-- Each project: `name` + **exactly one** of `path` or `repo`
-- At most one `primary: true`
-
-## Path layout
-
-| Layout | Prefix | Example |
-|--------|--------|---------|
-| **local** (this PR) | `{project}/{file}` | `app/src/main.py` |
-| **clone** (existing) | `{project}/main/{file}` | `app/main/src/main.py` |
-
-## Behavior changes
-
-| Area | Change |
-|------|--------|
-| `GitRepo.__init__` | Multiple git roots allowed when `.cecli.workspaces.yml` is found on a common ancestor |
-| `get_workspace_files` | Local layout unions `git ls-files` from each `path:` root |
-| `commit` | Local layout commits per underlying repo (`_commit_local_workspace`) |
-| `abs_root_path` | Resolves prefixed paths to the correct project root |
-
-Clone workspaces and `.cecli-workspace.json` metadata are unchanged.
-
-## Tests
-
-- `tests/helpers/monorepo/test_config.py` — validation (`path` / `repo` XOR)
-- `tests/helpers/monorepo/test_local_workspace.py` — helpers + `GitRepo` integration
-- Existing `test_repomap_workspace.py`, `test_workspace.py`, etc. — still pass (clone layout)
-
-Run:
-
-```bash
-pytest tests/helpers/monorepo -q
-```
-
-## Non-goals (follow-up PRs)
-
-- Auto-registering git submodules into the workspace registry
-- Combining submodule `RepoSet` with local YAML in one facade
-- New global config file formats (reuse `.cecli.workspaces.yml` only)
diff --git a/tests/helpers/monorepo/test_config.py b/tests/helpers/monorepo/test_config.py
deleted file mode 100644
index 0d85669753c..00000000000
--- a/tests/helpers/monorepo/test_config.py
+++ /dev/null
@@ -1,75 +0,0 @@
-import pytest
-
-from cecli.helpers.monorepo.config import validate_config
-
-
-def test_validate_config_empty():
- # Should not raise
- validate_config({})
-
-
-def test_validate_config_no_name():
- with pytest.raises(ValueError, match="Workspace configuration must include a 'name'"):
- validate_config({"projects": []})
-
-
-def test_validate_config_invalid_project_missing_source():
- with pytest.raises(ValueError, match="exactly one of 'path' or 'repo'"):
- validate_config({"name": "test", "projects": [{"name": "p1"}]})
-
-
-def test_validate_config_invalid_project_both_sources():
- with pytest.raises(ValueError, match="exactly one of 'path' or 'repo'"):
- validate_config(
- {
- "name": "test",
- "projects": [
- {
- "name": "p1",
- "path": "/tmp/p1",
- "repo": "https://github.com/org/r.git",
- }
- ],
- }
- )
-
-
-def test_validate_config_path_project():
- validate_config(
- {
- "name": "local",
- "projects": [{"name": "app", "path": "/abs/app", "primary": True}],
- }
- )
-
-
-def test_validate_config_duplicate_project():
- with pytest.raises(ValueError, match="Duplicate project name: p1"):
- validate_config(
- {
- "name": "test",
- "projects": [{"name": "p1", "repo": "url1"}, {"name": "p1", "repo": "url2"}],
- }
- )
-
-
-def test_validate_config_valid():
- config = {"name": "test", "projects": [{"name": "p1", "repo": "url1"}]}
- validate_config(config)
- assert config["projects"][0]["name"] == "p1"
-
-
-def test_load_workspace_config_json_string():
- from cecli.helpers.monorepo.config import load_workspace_config
-
- config_str = '{"name": "json-ws", "projects": []}'
- config = load_workspace_config(config_str)
- assert config["name"] == "json-ws"
-
-
-def test_load_workspace_config_yaml_string():
- from cecli.helpers.monorepo.config import load_workspace_config
-
- config_str = "name: yaml-ws\nprojects: []"
- config = load_workspace_config(config_str)
- assert config["name"] == "yaml-ws"
diff --git a/tests/helpers/monorepo/test_config_active.py b/tests/helpers/monorepo/test_config_active.py
deleted file mode 100644
index 95c27944e93..00000000000
--- a/tests/helpers/monorepo/test_config_active.py
+++ /dev/null
@@ -1,79 +0,0 @@
-import pytest
-
-from cecli.helpers.monorepo.config import load_workspace_config
-
-
-def test_load_workspace_config_multiple_active_error():
- config_list = [
- {"name": "ws1", "active": True, "projects": []},
- {"name": "ws2", "active": True, "projects": []},
- ]
-
- # Mocking what would be in the config file/arg
- with pytest.raises(ValueError, match="Multiple workspaces marked as active: ws1, ws2"):
- # We simulate the loaded config being a list
- from unittest.mock import mock_open, patch
-
- import yaml
-
- with patch("pathlib.Path.exists", return_value=True):
- with patch("builtins.open", mock_open(read_data=yaml.dump({"workspace": config_list}))):
- load_workspace_config()
-
-
-def test_load_workspace_config_select_by_name():
- config_list = [
- {"name": "ws1", "active": True, "projects": []},
- {"name": "ws2", "active": False, "projects": []},
- ]
-
- from unittest.mock import mock_open, patch
-
- import yaml
-
- with patch("pathlib.Path.exists", return_value=True):
- with patch("builtins.open", mock_open(read_data=yaml.dump({"workspace": config_list}))):
- # Should select ws2 even if ws1 is active
- config = load_workspace_config(name="ws2")
- assert config["name"] == "ws2"
-
-
-def test_load_workspace_config_no_active_uses_first_if_only_one():
- config_list = [{"name": "ws1", "projects": []}]
-
- from unittest.mock import mock_open, patch
-
- import yaml
-
- with patch("pathlib.Path.exists", return_value=True):
- with patch("builtins.open", mock_open(read_data=yaml.dump({"workspace": config_list}))):
- config = load_workspace_config()
- assert config["name"] == "ws1"
-
-
-def test_load_workspace_config_single_dict_is_active_by_default():
- config_dict = {"name": "single-ws", "projects": []}
-
- from unittest.mock import mock_open, patch
-
- import yaml
-
- with patch("pathlib.Path.exists", return_value=True):
- with patch("builtins.open", mock_open(read_data=yaml.dump({"workspace": config_dict}))):
- # Should work even if no name is passed and active is not set
- config = load_workspace_config()
- assert config["name"] == "single-ws"
-
-
-def test_load_workspace_config_multiple_in_list_none_active_picks_none():
- config_list = [{"name": "ws1", "projects": []}, {"name": "ws2", "projects": []}]
-
- from unittest.mock import mock_open, patch
-
- import yaml
-
- with patch("pathlib.Path.exists", return_value=True):
- with patch("builtins.open", mock_open(read_data=yaml.dump({"workspace": config_list}))):
- # With multiple and none active, it should return empty if no name provided
- config = load_workspace_config()
- assert config == {}
diff --git a/tests/helpers/monorepo/test_ignore_logic.py b/tests/helpers/monorepo/test_ignore_logic.py
deleted file mode 100644
index d7d7393fc38..00000000000
--- a/tests/helpers/monorepo/test_ignore_logic.py
+++ /dev/null
@@ -1,103 +0,0 @@
-import os
-import shutil
-import tempfile
-import unittest
-from pathlib import Path
-from unittest.mock import MagicMock, patch
-
-import git
-
-from cecli.helpers.monorepo.workspace import WorkspaceManager
-from cecli.io import InputOutput
-from cecli.repo import GitRepo
-
-
-class TestIgnoreLogic(unittest.TestCase):
- def setUp(self):
- self.test_dir = Path(tempfile.mkdtemp()).resolve()
- self.old_cwd = os.getcwd()
- os.chdir(self.test_dir)
-
- # Setup a dummy source ignore file
- self.src_ignore = self.test_dir / "my_proj.ignore_src"
- self.src_ignore.write_text("ignored_file.txt\n*.log\n")
-
- self.workspace_name = "test_ws"
- # Use a local path for testing instead of ~/.cecli
- self.workspace_root = (self.test_dir / "workspaces").resolve()
- self.workspace_root.mkdir(parents=True, exist_ok=True)
- self.ws_path = self.workspace_root / self.workspace_name
-
- self.config = {
- "name": self.workspace_name,
- "projects": [
- {
- "name": "my_proj",
- "repo": "https://github.com/example/repo",
- "ignore": str(self.src_ignore),
- }
- ],
- }
-
- def tearDown(self):
- os.chdir(self.old_cwd)
- if hasattr(self, "test_dir") and self.test_dir.exists():
- shutil.rmtree(self.test_dir)
-
- def test_ignore_file_copying(self):
- # Test that WorkspaceManager.initialize copies the ignore file
- wm = WorkspaceManager(self.workspace_name, self.config)
- # Use our test ws_path
- wm.path = self.ws_path
-
- with patch("cecli.helpers.monorepo.project.Project.initialize"):
- wm.initialize()
-
- dest_ignore = self.ws_path / "my_proj.ignore"
- self.assertTrue(dest_ignore.exists(), "Ignore file should be copied to workspace root")
- self.assertEqual(dest_ignore.read_text(), self.src_ignore.read_text())
-
- def test_repo_ignore_loading(self):
- # Test that GitRepo loads the copied ignore file
- wm = WorkspaceManager(self.workspace_name, self.config)
- wm.path = self.ws_path
-
- with patch("cecli.helpers.monorepo.project.Project.initialize"):
- wm.initialize()
-
- io = InputOutput()
- # Create a dummy file in the workspace to trigger detection
- dummy_file = self.ws_path / "my_proj" / "main" / "some_file.txt"
- dummy_file.parent.mkdir(parents=True, exist_ok=True)
- dummy_file.touch()
-
- # Mock git.Repo to avoid FileNotFoundError in GitRepo.__init__
- mock_repo = MagicMock(spec=git.Repo)
- mock_repo.working_dir = str(self.ws_path)
- mock_repo.__enter__.return_value = mock_repo
-
- # Patch _detect_workspace_path to return our test workspace path
- with patch("git.Repo", return_value=mock_repo):
- with patch("cecli.repo.GitRepo._detect_workspace_path", return_value=self.ws_path):
- with patch("cecli.repo.GitRepo.init_repo"):
- with patch(
- "cecli.helpers.monorepo.config.load_workspace_config",
- return_value=self.config,
- ):
- repo = GitRepo(io, fnames=[str(dummy_file)], git_dname=None)
-
- self.assertTrue(repo.is_workspace)
- self.assertEqual(Path(repo.workspace_path), self.ws_path)
-
- # Verify ignore spec is loaded
- repo._refresh_workspace_ignores()
- self.assertIn("my_proj", repo.workspace_ignore_specs)
-
- spec = repo.workspace_ignore_specs["my_proj"]
- self.assertTrue(spec.match_file("ignored_file.txt"))
- self.assertTrue(spec.match_file("test.log"))
- self.assertFalse(spec.match_file("keep.txt"))
-
-
-if __name__ == "__main__":
- unittest.main()
diff --git a/tests/helpers/monorepo/test_local_workspace.py b/tests/helpers/monorepo/test_local_workspace.py
deleted file mode 100644
index db29df4b06d..00000000000
--- a/tests/helpers/monorepo/test_local_workspace.py
+++ /dev/null
@@ -1,235 +0,0 @@
-"""Tests for repo-local workspaces (``path:`` git roots, ``.cecli.workspaces.yml``)."""
-
-from __future__ import annotations
-
-import json
-import subprocess
-from pathlib import Path
-
-import pytest
-import yaml
-
-from cecli.helpers.monorepo.config import load_workspace_config_file, validate_config
-from cecli.helpers.monorepo.local_workspace import (
- find_workspace_config_file,
- load_workspace_file,
- primary_project,
- project_path_prefix,
- read_workspace_metadata,
- resolve_workspace_file_path,
- union_tracked_files,
- write_workspace_metadata,
-)
-from cecli.io import InputOutput
-from cecli.repo import GitRepo
-from cecli.utils import make_repo
-
-
-def _init_git_repo(path: Path, readme: str = "# repo\n") -> None:
- make_repo(path)
- readme_path = path / "README.md"
- readme_path.write_text(readme, encoding="utf-8")
- subprocess.run(["git", "add", "README.md"], cwd=path, check=True, capture_output=True)
- subprocess.run(
- ["git", "commit", "-m", "init", "--no-gpg-sign"],
- cwd=path,
- check=True,
- capture_output=True,
- )
-
-
-@pytest.fixture
-def two_path_projects(tmp_path: Path):
- """
- Workspace root with ``.cecli.workspaces.yml`` and two sibling git checkouts.
-
- Layout::
-
- ws/
- .cecli.workspaces.yml
- app/ (git)
- lib/ (git)
- """
- ws = tmp_path / "ws"
- app = ws / "app"
- lib = ws / "lib"
- app.mkdir(parents=True)
- lib.mkdir(parents=True)
- _init_git_repo(app, "# app\n")
- _init_git_repo(lib, "# lib\n")
-
- config = {
- "name": "pair",
- "projects": [
- {"name": "app", "path": str(app.resolve()), "primary": True},
- {"name": "lib", "path": str(lib.resolve())},
- ],
- }
- (ws / ".cecli.workspaces.yml").write_text(
- yaml.dump(config, sort_keys=False),
- encoding="utf-8",
- )
- return ws, config, app, lib
-
-
-class TestValidateConfigPathProjects:
- def test_path_only_project_valid(self):
- validate_config(
- {
- "name": "local",
- "projects": [{"name": "app", "path": "/tmp/app", "primary": True}],
- }
- )
-
- def test_repo_only_project_valid(self):
- validate_config(
- {
- "name": "clone",
- "projects": [{"name": "p1", "repo": "https://github.com/org/r.git"}],
- }
- )
-
- def test_missing_path_and_repo(self):
- with pytest.raises(ValueError, match="exactly one of 'path' or 'repo'"):
- validate_config({"name": "test", "projects": [{"name": "p1"}]})
-
- def test_both_path_and_repo(self):
- with pytest.raises(ValueError, match="exactly one of 'path' or 'repo'"):
- validate_config(
- {
- "name": "test",
- "projects": [
- {
- "name": "p1",
- "path": "/tmp/a",
- "repo": "https://github.com/org/r.git",
- }
- ],
- }
- )
-
- def test_multiple_primary(self):
- with pytest.raises(ValueError, match="Only one project may be marked primary"):
- validate_config(
- {
- "name": "test",
- "projects": [
- {"name": "a", "path": "/a", "primary": True},
- {"name": "b", "path": "/b", "primary": True},
- ],
- }
- )
-
-
-class TestLocalWorkspaceHelpers:
- def test_find_workspace_config_file_walks_up(self, two_path_projects):
- ws, _config, app, _lib = two_path_projects
- expected = (ws / ".cecli.workspaces.yml").resolve()
- assert find_workspace_config_file(ws).resolve() == expected
- # YAML lives at workspace root; project checkout is a subdirectory.
- assert find_workspace_config_file(app).resolve() == expected
- assert find_workspace_config_file(app / "README.md").resolve() == expected
-
- def test_load_workspace_config_file(self, two_path_projects):
- ws, _config, _app, _lib = two_path_projects
- loaded = load_workspace_config_file(ws / ".cecli.workspaces.yml")
- assert loaded["name"] == "pair"
- assert len(loaded["projects"]) == 2
-
- def test_union_tracked_files(self, two_path_projects):
- ws, config, _app, _lib = two_path_projects
- files = union_tracked_files(ws, config, layout="local")
- assert "app/README.md" in files
- assert "lib/README.md" in files
-
- def test_resolve_workspace_file_path_prefixed(self, two_path_projects):
- ws, config, _app, _lib = two_path_projects
- resolved = resolve_workspace_file_path(ws, "lib/README.md", config, layout="local")
- assert resolved is not None
- git_root, abs_path, in_repo = resolved
- assert in_repo == "README.md"
- assert abs_path.name == "README.md"
- assert git_root.name == "lib"
-
- def test_project_path_prefix_local_vs_clone(self):
- proj = {"name": "app"}
- assert project_path_prefix(proj, layout="local") == "app"
- assert project_path_prefix(proj, layout="clone") == "app/main"
-
- def test_primary_project_explicit_and_implicit(self):
- cfg = {
- "projects": [
- {"name": "a", "path": "/a"},
- {"name": "b", "path": "/b", "primary": True},
- ]
- }
- assert primary_project(cfg)["name"] == "b"
-
- single = {"projects": [{"name": "only", "path": "/only"}]}
- assert primary_project(single)["name"] == "only"
-
- def test_workspace_metadata_roundtrip(self, two_path_projects):
- ws, config, _app, _lib = two_path_projects
- write_workspace_metadata(ws, config, layout="local")
- meta = read_workspace_metadata(ws)
- assert meta is not None
- loaded, layout = meta
- assert layout == "local"
- assert loaded["name"] == config["name"]
- meta_path = ws / ".cecli" / ".workspace-meta.json"
- assert meta_path.is_file()
- on_disk = json.loads(meta_path.read_text(encoding="utf-8"))
- assert on_disk.get("_layout") == "local"
-
-
-class TestGitRepoLocalWorkspace:
- def test_detects_local_workspace_and_unions_files(self, two_path_projects):
- ws, _config, app, lib = two_path_projects
- io = InputOutput(yes=True)
- repo = GitRepo(io, [str(app / "README.md"), str(lib / "README.md")], None)
-
- assert repo.is_workspace
- assert repo.workspace_layout == "local"
- assert repo.workspace_path == ws.resolve()
-
- files = repo.get_workspace_files()
- assert "app/README.md" in files
- assert "lib/README.md" in files
-
- def test_abs_root_path_resolves_prefixed_path(self, two_path_projects):
- _ws, _config, app, _lib = two_path_projects
- io = InputOutput(yes=True)
- repo = GitRepo(io, [str(app)], None)
-
- abs_path = Path(repo.abs_root_path("app/README.md"))
- assert abs_path == (app / "README.md").resolve()
-
- def test_without_workspace_file_multi_repo_fails(self, tmp_path: Path):
- root = tmp_path / "orphan"
- a = root / "a"
- b = root / "b"
- a.mkdir(parents=True)
- b.mkdir(parents=True)
- _init_git_repo(a)
- _init_git_repo(b)
- io = InputOutput(yes=True)
- with pytest.raises(FileNotFoundError):
- GitRepo(io, [str(a / "README.md"), str(b / "README.md")], None)
-
- def test_load_workspace_file_defaults(self, tmp_path: Path):
- path = tmp_path / ".cecli.workspaces.yml"
- path.write_text("projects: []\n", encoding="utf-8")
- loaded = load_workspace_file(path)
- assert "name" in loaded
- assert loaded["projects"] == []
-
-
-class TestGitRepoLocalWorkspaceNoYaml:
- def test_single_repo_without_yaml_is_not_local_workspace(self, tmp_path: Path):
- repo_dir = tmp_path / "solo"
- repo_dir.mkdir()
- _init_git_repo(repo_dir)
- io = InputOutput(yes=True)
- repo = GitRepo(io, [str(repo_dir)], None)
- assert find_workspace_config_file(repo_dir) is None
- assert getattr(repo, "workspace_layout", "clone") != "local" or not repo.is_workspace
diff --git a/tests/helpers/monorepo/test_repomap_workspace.py b/tests/helpers/monorepo/test_repomap_workspace.py
deleted file mode 100644
index 5649889e535..00000000000
--- a/tests/helpers/monorepo/test_repomap_workspace.py
+++ /dev/null
@@ -1,231 +0,0 @@
-import json
-import subprocess
-from pathlib import Path
-from unittest.mock import MagicMock, patch
-
-import pytest
-
-from cecli.io import InputOutput
-from cecli.repo import GitRepo
-
-
-@pytest.fixture
-def mock_workspace(tmp_path):
- workspace_root = tmp_path / "workspace"
- workspace_root.mkdir()
-
- # Project 1
- p1_dir = workspace_root / "p1" / "main"
- p1_dir.mkdir(parents=True)
- subprocess.run(["git", "init", "-b", "main"], cwd=p1_dir, check=True)
- subprocess.run(["git", "config", "user.email", "test@test.com"], cwd=p1_dir, check=True)
- subprocess.run(["git", "config", "user.name", "Test"], cwd=p1_dir, check=True)
- (p1_dir / "file1.py").write_text("def func1(): pass")
- subprocess.run(["git", "add", "file1.py"], cwd=p1_dir, check=True)
- subprocess.run(["git", "commit", "-m", "p1 init"], cwd=p1_dir, check=True)
-
- # Project 2
- p2_dir = workspace_root / "p2" / "main"
- p2_dir.mkdir(parents=True)
- subprocess.run(["git", "init", "-b", "main"], cwd=p2_dir, check=True)
- subprocess.run(["git", "config", "user.email", "test@test.com"], cwd=p2_dir, check=True)
- subprocess.run(["git", "config", "user.name", "Test"], cwd=p2_dir, check=True)
- (p2_dir / "file2.py").write_text("def func2(): pass")
- subprocess.run(["git", "add", "file2.py"], cwd=p2_dir, check=True)
- subprocess.run(["git", "commit", "-m", "p2 init"], cwd=p2_dir, check=True)
-
- # Workspace metadata
- config = {
- "name": "test-ws",
- "projects": [{"name": "p1", "repo": "url1"}, {"name": "p2", "repo": "url2"}],
- }
- (workspace_root / ".cecli-workspace.json").write_text(json.dumps(config))
-
- return workspace_root
-
-
-def test_get_workspace_files(mock_workspace):
- io = MagicMock(spec=InputOutput)
- # Initialize GitRepo in p1 but it should detect the workspace
- repo = GitRepo(io, [], str(mock_workspace / "p1" / "main"))
-
- # Force workspace_path for the test since _detect_workspace_path looks in ~/.cecli/workspaces
- repo.workspace_path = mock_workspace
-
- files = repo.get_workspace_files()
- assert "p1/main/file1.py" in files
- assert "p2/main/file2.py" in files
- assert len(files) == 2
-
-
-@pytest.mark.asyncio
-async def test_coder_get_all_relative_files_workspace_integration(mock_workspace):
- io = MagicMock()
- repo = GitRepo(io, [], str(mock_workspace / "p1" / "main"))
- repo.workspace_path = mock_workspace
-
- # Create a Coder-like object without full __init__
- class SimpleCoder:
- def __init__(self, repo):
- self.repo = repo
- self.in_chat_files = []
-
- def get_inchat_relative_files(self):
- return self.in_chat_files
-
- def get_all_relative_files(self):
- # Verify the logic we implemented in base_coder.py
- if self.repo:
- if hasattr(self.repo, "workspace_path") and self.repo.workspace_path:
- files = self.repo.get_workspace_files()
- elif not self.repo.cecli_ignore_file or not self.repo.cecli_ignore_file.is_file():
- files = self.repo.get_tracked_files()
- else:
- files = self.repo.get_non_ignored_files_from_root()
- else:
- files = self.get_inchat_relative_files()
- return files
-
- coder = SimpleCoder(repo)
- files = coder.get_all_relative_files()
-
- assert "p1/main/file1.py" in files
- assert "p2/main/file2.py" in files
- assert len(files) == 2
-
-
-def test_repo_root_detection_for_repomap(mock_workspace):
- io = MagicMock()
- repo = GitRepo(io, [], str(mock_workspace / "p1" / "main"))
- repo.workspace_path = mock_workspace
-
- # Verify that the logic we added to base_coder.py picks the workspace root
- repo_root = (
- repo.workspace_path if (repo and getattr(repo, "workspace_path", None)) else Path(repo.root)
- )
-
- assert repo_root == mock_workspace
- assert (repo_root / ".cecli-workspace.json").exists()
-
-
-def test_project_branch_switching(mock_workspace):
- # Setup: Create a new branch in p1
- p1_dir = mock_workspace / "p1" / "main"
- subprocess.run(["git", "checkout", "-b", "feature/xyz"], cwd=p1_dir, check=True)
- (p1_dir / "feature.py").write_text("print('feature')")
- subprocess.run(["git", "add", "feature.py"], cwd=p1_dir, check=True)
- subprocess.run(["git", "commit", "-m", "feature commit"], cwd=p1_dir, check=True)
-
- # Go back to master
- subprocess.run(["git", "checkout", "main"], cwd=p1_dir, check=True)
-
- # Define config with the new branch
- config = {
- "name": "test-ws",
- "projects": [
- {"name": "p1", "repo": "url1", "branch": "feature/xyz", "use_current_branch": False}
- ],
- }
-
- from cecli.helpers.monorepo.workspace import WorkspaceManager
-
- wm = WorkspaceManager("test-ws", config)
- wm.path = mock_workspace # Override path for test
-
- # Re-initialize should trigger checkout
- wm.initialize()
-
- # Verify p1 is now on feature/xyz
- current_branch = subprocess.check_output(
- ["git", "-C", str(p1_dir), "rev-parse", "--abbrev-ref", "HEAD"], encoding="utf-8"
- ).strip()
-
- assert current_branch == "feature/xyz"
- assert (p1_dir / "feature.py").exists()
-
-
-def test_project_use_current_branch_flag(mock_workspace):
- # Setup: p1 is on master
- p1_dir = mock_workspace / "p1" / "main"
- subprocess.run(["git", "checkout", "main"], cwd=p1_dir, check=True)
-
- # Define config with a different branch but use_current_branch=True
- config = {
- "name": "test-ws",
- "projects": [
- {"name": "p1", "repo": "url1", "branch": "feature/xyz", "use_current_branch": True}
- ],
- }
-
- from cecli.helpers.monorepo.workspace import WorkspaceManager
-
- wm = WorkspaceManager("test-ws", config)
- wm.path = mock_workspace
-
- # Re-initialize should NOT trigger checkout
- wm.initialize()
-
- # Verify p1 is still on main
- current_branch = subprocess.check_output(
- ["git", "-C", str(p1_dir), "rev-parse", "--abbrev-ref", "HEAD"], encoding="utf-8"
- ).strip()
-
- assert current_branch == "main"
-
-
-def test_get_workspace_files_caching(mock_workspace):
- io = MagicMock()
- repo = GitRepo(io, [], str(mock_workspace / "p1" / "main"))
- repo.workspace_path = mock_workspace
-
- # First call - should populate cache
- with patch("subprocess.check_output", wraps=subprocess.check_output) as mock_run:
- files1 = repo.get_workspace_files()
- # Should have called rev-parse (2x) and ls-files (2x)
- assert mock_run.call_count >= 4
-
- # Second call - should use cache
- with patch("subprocess.check_output", wraps=subprocess.check_output) as mock_run:
- files2 = repo.get_workspace_files()
- # Should only call rev-parse to check SHAs (2x), NOT ls-files
- # Total calls = number of projects (2)
- assert mock_run.call_count == 2
- assert files1 == files2
-
- # Modify a project - should invalidate cache
- p1_dir = mock_workspace / "p1" / "main"
- (p1_dir / "new_file.py").write_text("test")
- subprocess.run(["git", "add", "new_file.py"], cwd=p1_dir, check=True)
- subprocess.run(["git", "commit", "-m", "new file"], cwd=p1_dir, check=True)
-
- with patch("subprocess.check_output", wraps=subprocess.check_output) as mock_run:
- files3 = repo.get_workspace_files()
- # Should call rev-parse (2x) and then ls-files (2x) because SHAs changed
- assert mock_run.call_count >= 4
- assert "p1/main/new_file.py" in files3
-
-
-@pytest.mark.asyncio
-async def test_workspace_command(mock_workspace):
- from cecli.commands.workspace import WorkspaceCommand
- from cecli.io import InputOutput
-
- io = MagicMock(spec=InputOutput)
- repo = MagicMock()
- repo.workspace_path = mock_workspace
- repo.root = str(mock_workspace / "p1" / "main")
-
- coder = MagicMock()
- coder.repo = repo
-
- await WorkspaceCommand.execute(io, coder, None)
-
- # Check if io.print was called with workspace info
- # WorkspaceCommand prints name, root, then projects
- io.print.assert_any_call("Current Workspace: test-ws")
- io.print.assert_any_call(f"Root Directory: {mock_workspace}")
-
- # Verify project details were printed
- # The output includes project name, branch, remote, path
- io.print.assert_any_call(" - p1:")
- io.print.assert_any_call(" - p2:")
diff --git a/tests/helpers/monorepo/test_workspace.py b/tests/helpers/monorepo/test_workspace.py
deleted file mode 100644
index b1db56247b9..00000000000
--- a/tests/helpers/monorepo/test_workspace.py
+++ /dev/null
@@ -1,52 +0,0 @@
-import json
-from unittest.mock import patch
-
-import pytest
-
-from cecli.helpers.monorepo.workspace import WorkspaceManager
-
-
-@pytest.fixture
-def temp_workspace_root(tmp_path):
- workspace_root = tmp_path / ".cecli" / "workspaces"
- workspace_root.mkdir(parents=True)
-
- def mock_expand(path):
- if path.startswith("~/.cecli/workspaces"):
- return str(workspace_root / path.replace("~/.cecli/workspaces", "").lstrip("/"))
- return path
-
- with patch("os.path.expanduser", side_effect=mock_expand):
- yield workspace_root
-
-
-def test_workspace_manager_exists(temp_workspace_root):
- config = {"name": "test-ws", "projects": []}
- wm = WorkspaceManager("test-ws", config)
- assert not wm.exists()
-
- wm.path.mkdir(parents=True)
- assert wm.exists()
-
-
-@patch("cecli.helpers.monorepo.project.Project.initialize")
-def test_workspace_manager_initialize(mock_proj_init, temp_workspace_root):
- config = {
- "name": "test-ws",
- "projects": [{"name": "p1", "repo": "url1"}, {"name": "p2", "repo": "url2"}],
- }
- wm = WorkspaceManager("test-ws", config)
- wm.initialize()
-
- assert wm.exists()
- assert (wm.path / ".cecli-workspace.json").exists()
- assert mock_proj_init.call_count == 2
-
- with open(wm.path / ".cecli-workspace.json", "r") as f:
- saved_config = json.load(f)
- assert saved_config["name"] == "test-ws"
-
-
-def test_workspace_manager_get_working_directory(temp_workspace_root):
- wm = WorkspaceManager("test-ws", {})
- assert wm.get_working_directory() == temp_workspace_root / "test-ws"
diff --git a/tests/helpers/test_llms_gemini_auth.py b/tests/helpers/test_llms_gemini_auth.py
new file mode 100644
index 00000000000..53d5436bfcf
--- /dev/null
+++ b/tests/helpers/test_llms_gemini_auth.py
@@ -0,0 +1,80 @@
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+from cecli.helpers.llms.domains.gemini import gemini_complete
+from cecli.helpers.llms.providers.gemini import GeminiProvider
+
+
+def test_gemini_provider_build_headers():
+ provider = GeminiProvider()
+ headers = provider.build_headers(
+ resolved={"provider": "gemini"},
+ key="my-secret-gemini-key",
+ family="gemini",
+ headers={"Custom-Header": "value"},
+ )
+
+ assert headers["x-goog-api-key"] == "my-secret-gemini-key"
+ assert headers["Content-Type"] == "application/json"
+ assert headers["Custom-Header"] == "value"
+ assert "Authorization" not in headers
+
+
+def test_gemini_provider_build_headers_no_key():
+ provider = GeminiProvider()
+ headers = provider.build_headers(
+ resolved={"provider": "gemini"},
+ key=None,
+ family="gemini",
+ headers={},
+ )
+
+ assert "x-goog-api-key" not in headers
+ assert "Authorization" not in headers
+
+
+@pytest.mark.asyncio
+async def test_gemini_complete_sends_x_goog_api_key_header():
+ mock_response = MagicMock()
+ mock_response.raise_for_status = MagicMock()
+ mock_response.json.return_value = {
+ "responseId": "resp-123",
+ "candidates": [
+ {
+ "content": {"parts": [{"text": "Hello world"}]},
+ "finishReason": "STOP",
+ }
+ ],
+ }
+
+ mock_client = AsyncMock()
+ mock_client.post.return_value = mock_response
+
+ mock_make_client = MagicMock()
+ mock_make_client.return_value.__aenter__.return_value = mock_client
+
+ with patch("cecli.helpers.llms.domains.gemini.make_client", mock_make_client):
+ resolved = {
+ "api_base": "https://generativelanguage.googleapis.com",
+ "route": "gemini-2.0-flash",
+ "model": "gemini/gemini-2.0-flash",
+ }
+ messages = [{"role": "user", "content": "Hi"}]
+ headers = {"x-goog-api-key": "my-secret-gemini-key"}
+
+ resp = await gemini_complete(
+ resolved=resolved,
+ messages=messages,
+ tools=None,
+ key="my-secret-gemini-key",
+ headers=headers,
+ kwargs={},
+ )
+
+ assert resp.choices[0].message.content == "Hello world"
+ mock_client.post.assert_called_once()
+ _, kwargs = mock_client.post.call_args
+ assert kwargs["headers"]["x-goog-api-key"] == "my-secret-gemini-key"
+ assert "Authorization" not in kwargs["headers"]
+ assert kwargs["params"] == {}
diff --git a/tests/helpers/workspaces/conftest.py b/tests/helpers/workspaces/conftest.py
new file mode 100644
index 00000000000..eeb10f72386
--- /dev/null
+++ b/tests/helpers/workspaces/conftest.py
@@ -0,0 +1,11 @@
+import pytest
+
+from cecli.helpers.agents.service import AgentService
+
+
+@pytest.fixture(autouse=True)
+def reset_agent_registry():
+ """Clear the global sub-agent registry before/after each test."""
+ AgentService._global_registry.clear()
+ yield
+ AgentService._global_registry.clear()
diff --git a/tests/helpers/workspaces/test_config.py b/tests/helpers/workspaces/test_config.py
new file mode 100644
index 00000000000..93aba38d095
--- /dev/null
+++ b/tests/helpers/workspaces/test_config.py
@@ -0,0 +1,115 @@
+import json
+
+import pytest
+import yaml
+
+from cecli.helpers.workspaces.config import (
+ find_active_workspace_name,
+ load_workspace_config,
+ validate_config,
+ workspace_layout,
+)
+
+
+def test_validate_config_empty():
+ validate_config({})
+
+
+def test_validate_config_no_name():
+ with pytest.raises(ValueError, match="must include a 'name'"):
+ validate_config({"projects": []})
+
+
+def test_validate_config_project_missing_source():
+ with pytest.raises(ValueError, match="exactly one of 'path' or 'repo'"):
+ validate_config({"name": "test", "projects": [{"name": "p1"}]})
+
+
+def test_validate_config_project_both_sources():
+ with pytest.raises(ValueError, match="exactly one of 'path' or 'repo'"):
+ validate_config(
+ {
+ "name": "test",
+ "projects": [{"name": "p1", "path": "/a", "repo": "https://github.com/o/r.git"}],
+ }
+ )
+
+
+def test_validate_config_path_project():
+ validate_config(
+ {"name": "local", "projects": [{"name": "app", "path": "/abs/app", "primary": True}]}
+ )
+
+
+def test_validate_config_repo_project():
+ validate_config(
+ {"name": "clone", "projects": [{"name": "app", "repo": "https://github.com/o/r.git"}]}
+ )
+
+
+def test_validate_config_duplicate_project():
+ with pytest.raises(ValueError, match="Duplicate project name: p1"):
+ validate_config(
+ {
+ "name": "test",
+ "projects": [{"name": "p1", "path": "/a"}, {"name": "p1", "path": "/b"}],
+ }
+ )
+
+
+def test_validate_config_multiple_primary():
+ with pytest.raises(ValueError, match="Only one project may be marked primary"):
+ validate_config(
+ {
+ "name": "test",
+ "projects": [
+ {"name": "a", "path": "/a", "primary": True},
+ {"name": "b", "path": "/b", "primary": True},
+ ],
+ }
+ )
+
+
+def test_workspace_layout_local():
+ config = {"name": "local", "projects": [{"name": "app", "path": "/a"}]}
+ assert workspace_layout(config) == "local"
+
+
+def test_workspace_layout_clone():
+ config = {"name": "clone", "projects": [{"name": "app", "repo": "https://github.com/o/r.git"}]}
+ assert workspace_layout(config) == "clone"
+
+
+def test_workspace_layout_explicit_field_overrides_inference():
+ config = {"name": "ws", "layout": "clone", "projects": [{"name": "app", "path": "/a"}]}
+ assert workspace_layout(config) == "clone"
+
+
+def test_load_workspace_config_json_string():
+ config = load_workspace_config(json.dumps({"name": "json-ws", "projects": []}))
+ assert config["name"] == "json-ws"
+
+
+def test_load_workspace_config_yaml_string():
+ config = load_workspace_config("name: yaml-ws\nprojects: []")
+ assert config["name"] == "yaml-ws"
+
+
+def test_load_workspace_config_select_by_name():
+ config_list = [{"name": "ws1", "active": True, "projects": []}, {"name": "ws2", "projects": []}]
+ config = load_workspace_config(yaml.dump({"workspaces": config_list}), name="ws2")
+ assert config["name"] == "ws2"
+
+
+def test_load_workspace_config_multiple_active_error():
+ config_list = [
+ {"name": "ws1", "active": True, "projects": []},
+ {"name": "ws2", "active": True, "projects": []},
+ ]
+ with pytest.raises(ValueError, match="Multiple workspaces marked as active"):
+ load_workspace_config(yaml.dump({"workspaces": config_list}))
+
+
+def test_find_active_workspace_name():
+ config_list = [{"name": "ws1", "active": True, "projects": []}, {"name": "ws2", "projects": []}]
+ assert find_active_workspace_name(yaml.dump({"workspaces": config_list})) == "ws1"
diff --git a/tests/helpers/workspaces/test_paths.py b/tests/helpers/workspaces/test_paths.py
new file mode 100644
index 00000000000..f0bf61634f3
--- /dev/null
+++ b/tests/helpers/workspaces/test_paths.py
@@ -0,0 +1,116 @@
+import os
+import subprocess
+import tempfile
+from pathlib import Path
+
+from cecli.helpers.workspaces.paths import (
+ project_path,
+ resolve_workspace_file_path,
+ union_tracked_files,
+)
+from cecli.utils import make_repo
+
+
+def _make_git_repo(base: Path, name: str, files=None) -> Path:
+ root = base / name
+ os.makedirs(root, exist_ok=True)
+ make_repo(root)
+ if files:
+ for rel in files:
+ f = root / rel
+ f.parent.mkdir(parents=True, exist_ok=True)
+ f.write_text(rel, encoding="utf-8")
+ subprocess.check_call(["git", "-C", str(root), "add", rel])
+ return root.resolve()
+
+
+def test_project_path_returns_git_root_local():
+ with tempfile.TemporaryDirectory() as td:
+ base = Path(td)
+ root = _make_git_repo(base, "app")
+ assert project_path(Path("."), {"name": "app", "path": str(root)}, layout="local") == root
+
+
+def test_project_path_clone_returns_main_checkout():
+ with tempfile.TemporaryDirectory() as td:
+ base = Path(td)
+ clone_root = _make_git_repo(base, "app/main")
+ assert (
+ project_path(base, {"name": "app", "repo": "https://x/r.git"}, layout="clone")
+ == clone_root
+ )
+
+
+def test_project_path_none_for_non_git():
+ with tempfile.TemporaryDirectory() as td:
+ plain = Path(td) / "plain"
+ os.makedirs(plain, exist_ok=True)
+ assert project_path(Path("."), {"name": "p", "path": str(plain)}, layout="local") is None
+
+
+def test_project_path_none_for_missing():
+ assert project_path(Path("."), {"name": "p", "path": "/does/not/exist"}, layout="local") is None
+
+
+def test_resolve_workspace_file_path_local():
+ with tempfile.TemporaryDirectory() as td:
+ base = Path(td)
+ app = _make_git_repo(base, "app")
+ config = {"name": "ws", "projects": [{"name": "app", "path": str(app)}]}
+ resolved = resolve_workspace_file_path(base, "app/src/main.py", config, layout="local")
+ assert resolved is not None
+ git_root, abs_path, in_repo = resolved
+ assert git_root == app
+ assert abs_path == app / "src" / "main.py"
+ assert in_repo == "src/main.py"
+
+
+def test_resolve_workspace_file_path_clone():
+ with tempfile.TemporaryDirectory() as td:
+ base = Path(td)
+ clone_root = _make_git_repo(base, "app/main")
+ config = {"name": "ws", "projects": [{"name": "app", "repo": "https://x/r.git"}]}
+ resolved = resolve_workspace_file_path(base, "app/main/src/main.py", config, layout="clone")
+ assert resolved is not None
+ git_root, abs_path, in_repo = resolved
+ assert git_root == clone_root
+ assert abs_path == clone_root / "src" / "main.py"
+ assert in_repo == "src/main.py"
+
+
+def test_resolve_workspace_file_path_primary_fallback():
+ with tempfile.TemporaryDirectory() as td:
+ base = Path(td)
+ app = _make_git_repo(base, "app")
+ config = {"name": "ws", "projects": [{"name": "app", "path": str(app), "primary": True}]}
+ resolved = resolve_workspace_file_path(base, "some/file.txt", config, layout="local")
+ assert resolved is not None
+ git_root, abs_path, in_repo = resolved
+ assert git_root == app
+ assert in_repo == "some/file.txt"
+
+
+def test_union_tracked_files_local():
+ with tempfile.TemporaryDirectory() as td:
+ base = Path(td)
+ app = _make_git_repo(base, "app", files=["src/main.py", "README.md"])
+ lib = _make_git_repo(base, "lib", files=["lib.py"])
+ config = {
+ "name": "ws",
+ "projects": [{"name": "app", "path": str(app)}, {"name": "lib", "path": str(lib)}],
+ }
+
+ files = union_tracked_files(base, config, layout="local")
+ assert "app/src/main.py" in files
+ assert "app/README.md" in files
+ assert "lib/lib.py" in files
+
+
+def test_union_tracked_files_clone():
+ with tempfile.TemporaryDirectory() as td:
+ base = Path(td)
+ _make_git_repo(base, "app/main", files=["src/main.py"])
+ config = {"name": "ws", "projects": [{"name": "app", "repo": "https://x/r.git"}]}
+
+ files = union_tracked_files(base, config, layout="clone")
+ assert "app/main/src/main.py" in files
diff --git a/tests/helpers/workspaces/test_subagents.py b/tests/helpers/workspaces/test_subagents.py
new file mode 100644
index 00000000000..11b1b632a63
--- /dev/null
+++ b/tests/helpers/workspaces/test_subagents.py
@@ -0,0 +1,106 @@
+import os
+import tempfile
+from pathlib import Path
+
+from cecli.helpers.agents.service import AgentService
+from cecli.helpers.workspaces.subagents import register_workspace_subagents
+from cecli.utils import make_repo
+
+
+def _make_git_repo(base: Path, name: str) -> Path:
+ root = base / name
+ os.makedirs(root, exist_ok=True)
+ make_repo(root)
+ return root.resolve()
+
+
+def test_register_workspace_subagents_creates_ws_agents_local():
+ with tempfile.TemporaryDirectory() as td:
+ base = Path(td)
+ app = _make_git_repo(base, "app")
+ lib = _make_git_repo(base, "lib")
+
+ config = {
+ "name": "ws",
+ "projects": [
+ {"name": "app", "path": str(app), "primary": True},
+ {"name": "lib", "path": str(lib)},
+ ],
+ }
+
+ registered = register_workspace_subagents(config)
+ assert "ws:app" in registered
+ assert "ws:lib" in registered
+
+ ws_app = AgentService.get_registry()["ws:app"]
+ assert ws_app.name == "ws:app"
+ assert ws_app.metadata["root"] == str(app)
+ assert ws_app.metadata["agent-config"]["allow_nested_delegation"] is True
+
+ ws_lib = AgentService.get_registry()["ws:lib"]
+ assert ws_lib.metadata["root"] == str(lib)
+ assert ws_lib.metadata["agent-config"]["allow_nested_delegation"] is True
+
+
+def test_register_workspace_subagents_clone_root():
+ with tempfile.TemporaryDirectory() as td:
+ base = Path(td)
+ clone_root = _make_git_repo(base, "app/main")
+
+ config = {"name": "ws", "projects": [{"name": "app", "repo": "https://x/r.git"}]}
+ registered = register_workspace_subagents(config, workspace_root=base)
+ assert "ws:app" in registered
+
+ ws_app = AgentService.get_registry()["ws:app"]
+ assert ws_app.metadata["root"] == str(clone_root)
+ assert ws_app.metadata["layout"] == "clone"
+ assert ws_app.metadata["agent-config"]["allow_nested_delegation"] is True
+
+
+def test_register_workspace_subagents_project_metadata_frontmatter():
+ with tempfile.TemporaryDirectory() as td:
+ base = Path(td)
+ app = _make_git_repo(base, "app")
+
+ config = {
+ "name": "ws",
+ "projects": [
+ {
+ "name": "app",
+ "path": str(app),
+ "metadata": {
+ "model": "",
+ "auto_reap": False,
+ "name": "hijack",
+ "root": "/somewhere/else",
+ "description": "Custom ws agent",
+ "tools_includelist": ["readfile", "grep", "yield"],
+ },
+ }
+ ],
+ }
+
+ registered = register_workspace_subagents(config)
+ assert "ws:app" in registered
+
+ ws_app = AgentService.get_registry()["ws:app"]
+ assert ws_app.name == "ws:app"
+ assert ws_app.model == ""
+ assert ws_app.auto_reap is False
+ # ``root``, ``name`` and ``description`` are always derived from the
+ # workspace/project definition and cannot be overridden by ``metadata``.
+ assert ws_app.metadata["root"] == str(app)
+ assert ws_app.metadata.get("name") != "hijack"
+ assert ws_app.metadata["description"].startswith("Workspace sub-agent for project 'app'")
+ assert ws_app.metadata["tools_includelist"] == ["readfile", "grep", "yield"]
+ assert ws_app.metadata["agent-config"]["allow_nested_delegation"] is True
+
+
+def test_register_workspace_subagents_skips_non_git_project():
+ with tempfile.TemporaryDirectory() as td:
+ base = Path(td)
+ plain = base / "plain"
+ os.makedirs(plain, exist_ok=True)
+ config = {"name": "ws", "projects": [{"name": "plain", "path": str(plain)}]}
+ registered = register_workspace_subagents(config)
+ assert registered == []
diff --git a/tests/helpers/workspaces/test_workspace.py b/tests/helpers/workspaces/test_workspace.py
new file mode 100644
index 00000000000..05334b5efa8
--- /dev/null
+++ b/tests/helpers/workspaces/test_workspace.py
@@ -0,0 +1,58 @@
+import os
+import tempfile
+from pathlib import Path
+
+from cecli.helpers.workspaces.workspace import WorkspaceManager
+from cecli.utils import make_repo
+
+
+def _make_git_repo(base: Path, name: str) -> Path:
+ root = base / name
+ os.makedirs(root, exist_ok=True)
+ make_repo(root)
+ return root.resolve()
+
+
+def test_get_working_directory_returns_primary_project():
+ with tempfile.TemporaryDirectory() as td:
+ base = Path(td)
+ app = _make_git_repo(base, "app")
+ lib = _make_git_repo(base, "lib")
+ config = {
+ "name": "ws",
+ "projects": [
+ {"name": "app", "path": str(app), "primary": True},
+ {"name": "lib", "path": str(lib)},
+ ],
+ }
+
+ manager = WorkspaceManager("ws", config, root=base)
+ assert manager.path == base.resolve()
+ assert manager.get_working_directory() == app
+
+
+def test_default_root_falls_back_to_primary_project():
+ with tempfile.TemporaryDirectory() as td:
+ base = Path(td)
+ app = _make_git_repo(base, "app")
+ config = {"name": "ws", "projects": [{"name": "app", "path": str(app), "primary": True}]}
+
+ manager = WorkspaceManager("ws", config)
+ assert manager.path == app
+
+
+def test_exists_and_initialize_local():
+ with tempfile.TemporaryDirectory() as td:
+ base = Path(td)
+ manager = WorkspaceManager("ws", {"name": "ws", "projects": []}, root=base)
+ manager.initialize()
+ assert manager.exists()
+ assert (base / ".cecli" / ".workspace-meta.json").is_file()
+
+
+def test_clone_workspace_uses_cecli_workspaces_dir():
+ config = {"name": "my-ws", "projects": [{"name": "app", "repo": "https://x/r.git"}]}
+ manager = WorkspaceManager("my-ws", config)
+ assert manager.layout == "clone"
+ assert manager.path == Path(os.path.expanduser("~/.cecli/workspaces/my-ws"))
+ assert manager.get_working_directory() == manager.path
diff --git a/tests/subagents/test_service.py b/tests/subagents/test_service.py
index 29026791e80..2084906b5a1 100644
--- a/tests/subagents/test_service.py
+++ b/tests/subagents/test_service.py
@@ -130,6 +130,20 @@ def test_build_registry(self, temp_dir):
assert "reviewer" in AgentService._global_registry
AgentService._global_registry = {}
+ def test_build_registry_resolves_relative_path_against_root(self, temp_dir):
+ """Relative sub-agent directories resolve against the provided ``root``."""
+ AgentService._global_registry = {}
+
+ subagents_dir = temp_dir / "subagents"
+ subagents_dir.mkdir()
+ md_file = subagents_dir / "reviewer.md"
+ md_file.write_text("---\n" "name: reviewer\n" "---\n" "Review code.")
+
+ # ``subagents`` is relative — it must be resolved against ``root``.
+ AgentService.build_registry(["subagents"], root=str(temp_dir))
+ assert "reviewer" in AgentService._global_registry
+ AgentService._global_registry = {}
+
def test_build_registry_skips_missing_dir(self):
"""Non-existent directories are skipped silently (default dirs still scanned)."""
AgentService._global_registry = {}
@@ -144,6 +158,19 @@ def test_build_registry_skips_missing_dir(self):
# Verify the nonexistent path didn't cause an error - default entries are fine
assert AgentService._global_registry is not None
+ def test_build_registry_includes_local_default_dir(self, temp_dir):
+ """build_registry scans {root}/.cecli/subagents as a local default."""
+ AgentService._global_registry = {}
+
+ local_dir = temp_dir / ".cecli" / "subagents"
+ local_dir.mkdir(parents=True)
+ md_file = local_dir / "localagent.md"
+ md_file.write_text("---\nname: localagent\n---\nLocal agent.")
+
+ AgentService.build_registry([], root=str(temp_dir))
+ assert "localagent" in AgentService._global_registry
+ AgentService._global_registry = {}
+
# ================================================================== #
# Instance initialization
diff --git a/tests/tools/test_grep.py b/tests/tools/test_grep.py
index 9030f883468..4a630b6eb04 100644
--- a/tests/tools/test_grep.py
+++ b/tests/tools/test_grep.py
@@ -60,3 +60,103 @@ def test_dash_prefixed_pattern_is_searched_literally(search_term, tmp_path, monk
assert "total_files" in op["_"]
assert isinstance(op["_"]["total_files"], int)
coder.io.tool_error.assert_not_called()
+
+
+@pytest.mark.skipif(shutil.which("powershell") is None, reason="powershell is required")
+@pytest.mark.parametrize(
+ "search_term",
+ [
+ "--pattern",
+ "-pattern",
+ ],
+)
+def test_powershell_dash_prefixed_pattern_is_searched_literally(search_term, tmp_path, monkeypatch):
+ sample = tmp_path / "example.txt"
+ sample.write_text(f"flag {search_term} should be found\n")
+
+ coder = SimpleNamespace(
+ repo=SimpleNamespace(root=str(tmp_path)),
+ io=SimpleNamespace(
+ tool_error=Mock(),
+ tool_output=Mock(),
+ tool_warning=Mock(),
+ ),
+ verbose=False,
+ root=str(tmp_path),
+ tui=lambda: None,
+ )
+
+ monkeypatch.setattr(
+ grep.Tool, "_find_search_tool", lambda: ("powershell", shutil.which("powershell"))
+ )
+
+ result = grep.Tool.execute(
+ coder,
+ searches=[
+ {
+ "pattern": search_term,
+ "file_glob": "*.txt",
+ "directory": ".",
+ "use_regex": False,
+ "case_insensitive": False,
+ "context_before": 0,
+ "context_after": 0,
+ }
+ ],
+ )
+
+ response_dict = result.to_dict()
+ operations = response_dict["result"]
+ assert len(operations) == 1
+ op = operations[0]
+ assert op["_"]["pattern"] == search_term
+ assert op["_"]["error"] is None
+ assert op["_"]["total_files"] >= 1
+ assert op["_"]["total_matches"] >= 1
+ assert any("example.txt" in f["file"] for f in op["_"]["files"])
+ coder.io.tool_error.assert_not_called()
+
+
+@pytest.mark.skipif(shutil.which("powershell") is None, reason="powershell is required")
+def test_powershell_counts_and_context(tmp_path, monkeypatch):
+ sample = tmp_path / "sample.txt"
+ sample.write_text("alpha\nbeta\nalpha\ngamma alpha\n")
+
+ coder = SimpleNamespace(
+ repo=SimpleNamespace(root=str(tmp_path)),
+ io=SimpleNamespace(
+ tool_error=Mock(),
+ tool_output=Mock(),
+ tool_warning=Mock(),
+ ),
+ verbose=False,
+ root=str(tmp_path),
+ tui=lambda: None,
+ )
+
+ monkeypatch.setattr(
+ grep.Tool, "_find_search_tool", lambda: ("powershell", shutil.which("powershell"))
+ )
+
+ result = grep.Tool.execute(
+ coder,
+ searches=[
+ {
+ "pattern": "alpha",
+ "file_glob": "*.txt",
+ "directory": ".",
+ "use_regex": False,
+ "case_insensitive": True,
+ "context_before": 1,
+ "context_after": 1,
+ }
+ ],
+ )
+
+ response_dict = result.to_dict()
+ op = response_dict["result"][0]
+ assert op["_"]["error"] is None
+ assert op["_"]["total_matches"] >= 3
+ file_entry = next(f for f in op["_"]["files"] if f["file"] == "sample.txt")
+ assert file_entry["match_count"] >= 3
+ coder.io.tool_error.assert_not_called()
diff --git a/tests/tools/test_registry.py b/tests/tools/test_registry.py
index f9ba9e49952..dd926e9df68 100644
--- a/tests/tools/test_registry.py
+++ b/tests/tools/test_registry.py
@@ -184,6 +184,19 @@ def test_skill_and_mcp_tools_in_context_manager(self):
assert "load_mcp" in params, "context_manager should have load_mcp param"
assert "remove_mcp" in params, "context_manager should have remove_mcp param"
+ def test_build_registry_scans_default_tools_dirs(self, tmp_path):
+ """build_registry scans the global and local default tools directories."""
+ local_dir = tmp_path / ".cecli" / "tools"
+ local_dir.mkdir(parents=True)
+ (local_dir / "custom_tool.py").write_text(
+ "class Tool:\n"
+ " NORM_NAME = 'custom_tool'\n"
+ " SCHEMA = {'function': {'name': 'custom_tool', 'description': 'desc'}}\n"
+ )
+
+ registry = ToolRegistry.build_registry({}, root=str(tmp_path))
+ assert "custom_tool" in registry
+
if __name__ == "__main__":
# Run tests if executed directly