diff --git a/cecli/coders/agent_coder.py b/cecli/coders/agent_coder.py
index b9622fd856a..184080e2046 100644
--- a/cecli/coders/agent_coder.py
+++ b/cecli/coders/agent_coder.py
@@ -833,10 +833,12 @@ def get_environment_info(self):
result += f"- Git repository: {rel_repo_dir} with {num_files:,} files\n"
except Exception:
result += "- Git repository: active but details unavailable\n"
- if self.mcp_manager and self.mcp_manager.connected_servers:
- num_mcp_servers = len(self.mcp_manager.connected_servers)
- result += f"- Connected MCP servers: {num_mcp_servers}\n"
+ else:
result += "- Git repository: none\n"
+
+ if self.mcp_manager and self.mcp_manager.connected_servers:
+ num_mcp_servers = len(self.mcp_manager.connected_servers)
+ result += f"- Connected MCP servers: {num_mcp_servers}\n"
result += ""
return result
except Exception as e:
diff --git a/cecli/coders/base_coder.py b/cecli/coders/base_coder.py
index 5b5840836de..a6714110d1c 100755
--- a/cecli/coders/base_coder.py
+++ b/cecli/coders/base_coder.py
@@ -744,6 +744,15 @@ def __init__(
if root is not None:
self.root = os.path.normpath(os.path.abspath(root))
+ # Outside a git repo with no files in context there is nothing to derive
+ # a root from. Fall back to the primary workspace (sub-agents) or the
+ # process working directory so tools that pass root as their subprocess
+ # cwd never receive an empty path.
+ if not self.root:
+ self.root = utils.safe_abs_path(
+ primary_root or getattr(self, "primary_root", None) or os.getcwd()
+ )
+
# 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
@@ -2331,6 +2340,7 @@ async def compact_context_if_needed(self, force=False, message=""):
if (
self.context_compaction_max_tokens
and non_compactable_tokens >= self.context_compaction_max_tokens
+ and not force
):
if getattr(self, "_compaction_noop_warning_time", 0) + 300 < time.time():
self._compaction_noop_warning_time = time.time()
diff --git a/cecli/helpers/agents/service.py b/cecli/helpers/agents/service.py
index 4025b951923..f1777b56971 100644
--- a/cecli/helpers/agents/service.py
+++ b/cecli/helpers/agents/service.py
@@ -87,6 +87,7 @@ class SubAgentInfo:
independent: bool = False
detached: bool = False
cancel_requested: bool = False # the current run was cancelled (see _was_cancelled)
+ worktree: Optional[Any] = None
class AgentService:
@@ -535,6 +536,28 @@ def _cleanup_sub_agent(self, agent_uuid: str) -> None:
if info is None:
return
+ # Release a finished agent's worktree before tearing down its coder. A
+ # still-running task is left to WorktreeManager.release_all() on exit.
+ worktree = getattr(info, "worktree", None)
+ if worktree is not None and (info.generate_task is None or info.generate_task.done()):
+ record = worktree
+ result = self._release_worktree(
+ record, f"Failed to release worktree for sub-agent {agent_uuid}"
+ )
+ if result is not None:
+ _subagent_event(self.coder, info.coder.uuid, "worktree", **result.to_dict())
+
+ path = getattr(record, "path", None)
+ if path is None and isinstance(record, dict):
+ path = record.get("path")
+ if path:
+ try:
+ from cecli.repo import GitRepoProxy
+
+ GitRepoProxy.evict(str(path))
+ except Exception:
+ logger.warning("Failed to evict worktree repo proxy", exc_info=True)
+
# Destroy conversation resources for the sub-agent
from cecli.helpers.conversation.service import ConversationService
@@ -590,6 +613,20 @@ def _cleanup_sub_agent(self, agent_uuid: str) -> None:
AgentTree.unregister(info.coder.uuid)
self.destroy_instance(info.coder.uuid)
+ @staticmethod
+ def _release_worktree(record: Any, failure_message: str) -> Any:
+ """Release *record*, logging *failure_message* if the release raises.
+
+ Returns the release result, or None when the release failed.
+ """
+ try:
+ from cecli.helpers.worktrees import WorktreeManager
+
+ return WorktreeManager.release(record)
+ except Exception:
+ logger.warning("%s", failure_message, exc_info=True)
+ return None
+
def _check_max_sub_agents(self) -> None:
"""If we've hit max_sub_agents, reap the oldest finished one.
@@ -618,12 +655,31 @@ def _check_max_sub_agents(self) -> None:
"Wait for one to finish or use /reap-agent to free resources."
)
+ @staticmethod
+ def _resolve_root(value: Any, base: Any = None) -> str:
+ """Resolve *value* to an absolute, existing directory.
+
+ Relative paths resolve against *base* (usually the parent coder's root).
+ """
+ from pathlib import Path
+
+ path = Path(value).expanduser()
+ if not path.is_absolute():
+ base_path = Path(base).expanduser() if isinstance(base, (str, Path)) else Path.cwd()
+ path = base_path / path
+ path = path.resolve()
+ if not path.is_dir():
+ raise ValueError(f"Sub-agent root is not an existing directory: {path}")
+ return str(path)
+
async def _create_sub_agent_coder(
self,
name: str,
parent: Any = None,
auto_reap: Optional[bool] = None,
independent: bool = False,
+ root: Any = None,
+ resume_worktree: Any = None,
) -> Tuple[Any, SubAgentInfo]:
"""Create a sub-agent coder, register it, and set up its container and prompt.
@@ -639,6 +695,10 @@ async def _create_sub_agent_coder(
auto_reap: If True, agent may be automatically reaped when FINISHED.
If not set, defers to the sub-agent config's ``auto_reap``
value, then defaults to ``True``.
+ independent: If True, the sub-agent is detached from its parent.
+ root: Optional working directory for the sub-agent. Relative paths
+ resolve against the parent coder's root.
+ resume_worktree: Optional saved worktree record to recreate and use.
Returns:
Tuple of ``(new_coder, info)``.
@@ -682,7 +742,8 @@ async def _create_sub_agent_coder(
from cecli.coders import Coder
- metadata = getattr(config, "metadata", {}).copy()
+ metadata = getattr(config, "metadata", None)
+ metadata = dict(metadata) if isinstance(metadata, dict) else {}
agent_config = metadata.get("agent-config", {})
# Optional per-sub-agent root override for multi-project workspaces.
@@ -690,6 +751,13 @@ async def _create_sub_agent_coder(
if not configured_root and isinstance(agent_config, dict):
configured_root = agent_config.get("root")
+ worktree_spec = (
+ metadata.get("worktree")
+ if name.startswith("wf:") and isinstance(metadata, dict)
+ else None
+ )
+ override_root = override.get("root") if override else None
+
# Workspace/workflow sub-agents (ws:*, wf:*) inherit the parent/primary
# coder's agent_config as their default; the agent's own metadata
# agent-config is merged on top. ws: additionally hard-enables nested
@@ -711,7 +779,6 @@ async def _create_sub_agent_coder(
# this merge so an allowlist override cannot hide the file/exec tools.
if override:
agent_config = apply_agent_override(agent_config, override.get("agent-config"))
- configured_root = override.get("root") or configured_root
if override.get("description") is not None:
metadata["description"] = override["description"]
if "detached" in override:
@@ -726,6 +793,47 @@ async def _create_sub_agent_coder(
servers.append("local")
agent_config["servers_includelist"] = servers
+ if not name.startswith("wf:"):
+ resume_worktree = None
+
+ parent_root = getattr(parent_coder, "root", None) or None
+ worktree_record = None
+ resolved_root = None
+
+ if root is not None:
+ resolved_root = self._resolve_root(root, parent_root)
+ elif override_root:
+ resolved_root = str(override_root)
+ elif resume_worktree is not None:
+ from cecli.helpers.worktrees import WorktreeManager
+
+ worktree_record = await asyncio.to_thread(
+ WorktreeManager.recreate,
+ resume_worktree,
+ agent_name=name,
+ uuid=new_uuid,
+ )
+ resolved_root = worktree_record.path
+ elif worktree_spec is not None:
+ from cecli.helpers.worktrees import WorktreeManager
+
+ base_ref = worktree_spec.get("branch") if isinstance(worktree_spec, dict) else None
+ try:
+ worktree_record = await asyncio.to_thread(
+ WorktreeManager.create,
+ agent_name=name,
+ uuid=new_uuid,
+ source=metadata.get("worktree_source") or parent_root,
+ base_ref=base_ref,
+ base_root=parent_root,
+ reuse_existing=True,
+ )
+ except Exception as e:
+ raise RuntimeError(f"Failed to create worktree for sub-agent '{name}': {e}") from e
+ resolved_root = worktree_record.path
+ elif configured_root:
+ resolved_root = str(configured_root)
+
kwargs = dict(
io=parent_coder.io,
from_coder=parent_coder,
@@ -751,8 +859,8 @@ async def _create_sub_agent_coder(
)
)
- if configured_root:
- kwargs["root"] = configured_root
+ if resolved_root:
+ kwargs["root"] = resolved_root
if agent_config:
# Reset the per-instance tool/server filters so AgentCoder.post_init()
@@ -784,35 +892,57 @@ async def _create_sub_agent_coder(
agent_model=model_override,
)
- new_coder = await Coder.create(**kwargs)
+ try:
+ new_coder = await Coder.create(**kwargs)
+ except BaseException:
+ if worktree_record is not None:
+ await asyncio.to_thread(
+ self._release_worktree,
+ worktree_record,
+ "Failed to release worktree after coder creation failed",
+ )
+ raise
+
new_coder.max_sub_agents = getattr(self.coder, "max_sub_agents", 30)
+ if worktree_record is not None:
+ new_coder.worktree = worktree_record
# IOProxy wrapping is handled by base_coder.py's Coder.__init__
# Re-acquire the lock to register — we must re-check max agents since
# the lock was released and other spawns may have registered in between.
- with self._get_lock(self._spawn_locks, parent_coder.uuid):
- self._check_max_sub_agents()
-
- # Register in global coder lookup and the visibility tree
- self._uuid_coder_map[new_uuid] = weakref.ref(new_coder)
- AgentTree.register(
- str(new_coder.uuid),
- parent_uuid=str(parent_coder.uuid),
- name=name,
- )
+ try:
+ with self._get_lock(self._spawn_locks, parent_coder.uuid):
+ self._check_max_sub_agents()
+
+ # Register in global coder lookup and the visibility tree
+ self._uuid_coder_map[new_uuid] = weakref.ref(new_coder)
+ AgentTree.register(
+ str(new_coder.uuid),
+ parent_uuid=str(parent_coder.uuid),
+ name=name,
+ )
- info = SubAgentInfo(
- name=name,
- coder=new_coder,
- parent_uuid=parent_coder.uuid,
- status=SubAgentStatus.CREATED,
- auto_reap=auto_reap,
- independent=independent,
- detached=isinstance(agent_config, dict)
- and bool(agent_config.get("detached", False)),
- )
- self.sub_agents[new_coder.uuid] = info
- self._sub_agent_order.append(new_coder.uuid)
+ info = SubAgentInfo(
+ name=name,
+ coder=new_coder,
+ parent_uuid=parent_coder.uuid,
+ status=SubAgentStatus.CREATED,
+ auto_reap=auto_reap,
+ independent=independent,
+ detached=isinstance(agent_config, dict)
+ and bool(agent_config.get("detached", False)),
+ worktree=worktree_record,
+ )
+ self.sub_agents[new_coder.uuid] = info
+ self._sub_agent_order.append(new_coder.uuid)
+ except BaseException:
+ if worktree_record is not None:
+ await asyncio.to_thread(
+ self._release_worktree,
+ worktree_record,
+ "Failed to release worktree after registration failed",
+ )
+ raise
# Notify TUI to create a container
try:
@@ -821,16 +951,15 @@ async def _create_sub_agent_coder(
_tui_notify(tui, tui.create_sub_agent_container, new_uuid, name)
except Exception:
logger.warning("Failed to notify TUI to create sub-agent container", exc_info=True)
-
- _subagent_event(
- parent_coder,
- new_coder.uuid,
- "created",
+ created_event = dict(
parent_uuid=str(parent_coder.uuid),
name=name,
description=metadata.get("description") if isinstance(metadata, dict) else None,
root=str(getattr(new_coder, "root", "") or ""),
)
+ if worktree_record is not None:
+ created_event["worktree"] = worktree_record.to_dict()
+ _subagent_event(parent_coder, new_coder.uuid, "created", **created_event)
# Initialize system prompt from config
system_prompt = override.get("prompt") or getattr(config, "prompt", "")
@@ -894,6 +1023,7 @@ async def switch_sub_agent_coder(self, info: SubAgentInfo, switch, preproc=True)
kwargs["summarize_from_coder"] = False
new_coder = await Coder.create(**kwargs)
+ new_coder.worktree = getattr(old_coder, "worktree", None)
AgentTree.replace(old_coder.uuid, new_coder.uuid)
new_coder.prompt_queue = list(old_coder.prompt_queue)
@@ -1073,9 +1203,11 @@ def _queue_to_conversation() -> None:
async def _inject_sub_agent_result(self, info: SubAgentInfo) -> None:
"""Inject the sub-agent's result (summary/error) into the parent's conversation.
- Adds a user message with the result content and an assistant acknowledgment
- so the parent coder (and therefore the LLM) sees what the sub-agent produced.
- Uses unique hash keys so multiple sub-agent completions don't overwrite each other.
+ Adds a single user message containing a typed ```` block so the parent coder (and therefore the
+ LLM) sees what the sub-agent produced. No synthetic assistant reply is
+ fabricated on the model's behalf. Uses unique hash keys so multiple
+ sub-agent completions don't overwrite each other.
"""
from cecli.helpers.conversation.service import ConversationService
from cecli.helpers.conversation.tags import MessageTag
@@ -1096,38 +1228,27 @@ async def _inject_sub_agent_result(self, info: SubAgentInfo) -> None:
return
if info.status == SubAgentStatus.ERROR:
- user_content = (
- f"The **{info.name}** agent (`{coder_uuid}`) encountered an error:\n"
- f"{info.error}"
- )
- assistant_content = (
- f"The {info.name} agent `{coder_uuid}` failed with the error above. "
- f"You may want to review or retry the delegation."
- )
+ header = f"The **{info.name}** agent (`{coder_uuid}`) encountered an error:"
+ body = str(info.error)
elif info.status == SubAgentStatus.FINISHED:
- is_interrupted = info.summary == DEFAULT_SUMMARY_INTERRUPTED
- summary_text = info.summary or DEFAULT_SUMMARY_COMPLETED
- if is_interrupted:
- user_content = (
- f"The **{info.name}** agent (`{coder_uuid}`) was interrupted:\n"
- f"{summary_text}"
- )
- assistant_content = (
- f"The {info.name} agent `{coder_uuid}` was interrupted before completing its task. "
- f"You may want to review or retry the delegation."
- )
+ if info.summary == DEFAULT_SUMMARY_INTERRUPTED:
+ header = f"The **{info.name}** agent (`{coder_uuid}`) was interrupted:"
else:
- user_content = (
- f"The **{info.name}** agent (`{coder_uuid}`) completed with the following summary:\n"
- f"{summary_text}"
- )
- assistant_content = (
- f"Thank you for sharing the summary for {info.name} agent `{coder_uuid}`. "
- f"The agent has finished its task."
+ header = (
+ f"The **{info.name}** agent (`{coder_uuid}`) completed with the following"
+ " summary:"
)
+ body = info.summary or DEFAULT_SUMMARY_COMPLETED
else:
return
+ user_content = (
+ '\n'
+ f"{header}\n"
+ f"{body}\n"
+ ""
+ )
+
with self._get_lock(self._conversation_locks, info.parent_uuid):
ConversationService.get_manager(parent_coder).add_message(
message_dict={"role": "user", "content": user_content},
@@ -1135,12 +1256,6 @@ async def _inject_sub_agent_result(self, info: SubAgentInfo) -> None:
hash_key=("sub_agent_result", "user", coder_uuid, str(time.monotonic_ns())),
force=True,
)
- ConversationService.get_manager(parent_coder).add_message(
- message_dict={"role": "assistant", "content": assistant_content},
- tag=MessageTag.CUR,
- hash_key=("sub_agent_result", "assistant", coder_uuid, str(time.monotonic_ns())),
- force=True,
- )
_subagent_event(
parent_coder, parent_coder.uuid, "message", sender_uuid=coder_uuid, text=user_content
@@ -1153,6 +1268,8 @@ async def invoke(
blocking: bool = True,
parent: Any = None,
auto_reap: Optional[bool] = None,
+ root: Any = None,
+ resume_worktree: Any = None,
) -> Optional[str]:
"""Invoke a sub-agent by name with the given prompt (blocking by default).
@@ -1162,9 +1279,15 @@ async def invoke(
blocking: If True, waits for completion and returns summary.
parent: Optional coder instance to use as the parent for nested
sub-agent hierarchies. Defaults to ``self.coder``.
+ root: Optional working directory for this invocation.
+ resume_worktree: Optional saved worktree record to recreate.
"""
new_coder, info = await self._create_sub_agent_coder(
- name, auto_reap=auto_reap, parent=parent
+ name,
+ auto_reap=auto_reap,
+ parent=parent,
+ root=root,
+ resume_worktree=resume_worktree,
)
if not blocking:
@@ -1197,6 +1320,8 @@ async def spawn(
parent: Any = None,
auto_reap: Optional[bool] = None,
independent: bool = False,
+ root: Any = None,
+ resume_worktree: Any = None,
) -> Tuple[Any, SubAgentInfo]:
"""Spawn a sub-agent (non-blocking) that waits for user input.
@@ -1206,13 +1331,20 @@ async def spawn(
immediately with this prompt (fire-and-forget).
parent: Optional coder instance to use as the parent for nested
sub-agent hierarchies. Defaults to ``self.coder``.
+ root: Optional working directory for the sub-agent.
+ resume_worktree: Optional saved worktree record to recreate.
Returns:
Tuple of ``(new_coder, info)`` so callers can further interact
with the sub-agent (e.g. call ``start_generate_task`` later).
"""
new_coder, info = await self._create_sub_agent_coder(
- name, auto_reap=auto_reap, parent=parent, independent=independent
+ name,
+ auto_reap=auto_reap,
+ parent=parent,
+ independent=independent,
+ root=root,
+ resume_worktree=resume_worktree,
)
if prompt:
sender = parent if parent is not None else self.coder
@@ -1279,6 +1411,7 @@ def get_active_agents(self) -> List[Dict[str, Any]]:
"uuid": info.coder.uuid,
"status": info.status.value,
"summary": info.summary,
+ "worktree": info.worktree.to_dict() if info.worktree is not None else None,
}
for info in self.sub_agents.values()
]
@@ -1421,11 +1554,24 @@ def _tui_notify(tui, callback, *args):
thread. Scheduler-dispatched workflow runs now execute on the coder's own
loop (the TUI worker-thread loop — see ``SchedulerService._on_coder_loop``),
so notifications originate from the worker thread and ``call_from_thread``
- applies; when we are already on the app thread (or the TUI is unavailable),
- fall back to calling directly.
+ applies; when we are already on the app thread, the callback runs directly.
+
+ Notifications that arrive after the app has stopped are dropped: its DOM is
+ already torn down by then, so running the callback would fail on its widget
+ lookups. This happens on shutdown, when ``graceful_exit()`` cleans up
+ sub-agents after the TUI has exited.
"""
+ if getattr(tui, "is_running", None) is False:
+ return
+
try:
tui.call_from_thread(callback, *args)
except RuntimeError:
+ # A stale app object stays reachable through the coder's weakref after
+ # the TUI exits; its DOM is gone, so drop the notification instead of
+ # calling the callback. Otherwise this is the same-thread error and the
+ # callback must run here.
+ if getattr(tui, "is_running", None) is False:
+ return
callback(*args)
diff --git a/cecli/helpers/background_commands.py b/cecli/helpers/background_commands.py
index 50d59ec5b86..579ce68eac0 100644
--- a/cecli/helpers/background_commands.py
+++ b/cecli/helpers/background_commands.py
@@ -283,6 +283,7 @@ def __init__(
self.start_time = time.time()
self.end_time = None
self.persist = persist
+ self.continued_in_background = False
self.input_buffer = input_buffer or InputBuffer()
self.writer_thread = None
self._stop_event = threading.Event()
@@ -671,6 +672,20 @@ def start_background_command(
except Exception as e:
raise RuntimeError(f"Failed to start background command: {str(e)}")
+ @classmethod
+ def mark_continued_in_background(cls, command_key: str) -> bool:
+ """Flag a timeout-managed command as continuing after its timeout."""
+
+ with cls._lock:
+ bg_process = cls._background_commands.get(command_key)
+
+ if not bg_process:
+ return False
+
+ bg_process.continued_in_background = True
+
+ return True
+
@classmethod
def is_command_running(cls, command_key: str) -> bool:
"""
@@ -826,6 +841,9 @@ def list_background_commands(cls) -> Dict[str, Dict[str, any]]:
"command": bg_process.command,
"running": bg_process.is_alive(),
"persist": getattr(bg_process, "persist", False),
+ "continued_in_background": getattr(
+ bg_process, "continued_in_background", False
+ ),
"buffer_size": bg_process.buffer.size(),
"pages": getattr(bg_process.buffer, "page_count", 0),
"total_chars": getattr(
diff --git a/cecli/helpers/conversation/files.py b/cecli/helpers/conversation/files.py
index cd14a60825e..e75749306f3 100644
--- a/cecli/helpers/conversation/files.py
+++ b/cecli/helpers/conversation/files.py
@@ -263,9 +263,10 @@ def generate_diff(self, fname: str) -> Optional[str]:
context_lines=3,
)
- # If there's a diff, update the last snapshot with current content
+ # If there's a diff, update the snapshot and invalidate cached read ranges.
if diff_text.strip():
self._file_contents_snapshot[abs_fname] = current_content
+ self.invalidate_file_context(abs_fname)
return diff_text if diff_text.strip() else None
@@ -512,6 +513,19 @@ def update_file_context(
return (start_line, end_line) # fallback
+ def invalidate_file_context(self, file_path: str) -> None:
+ """Discard cached ReadFile ranges after a file edit.
+
+ Cached ranges are numeric line intervals. An edit can change the lines
+ inside a range or shift every later range, so the whole per-file range
+ cache is discarded instead of trying to remap offsets.
+ """
+ abs_fname = os.path.abspath(file_path)
+ self._numbered_contexts.pop(abs_fname, None)
+ self._last_merged_ranges.pop(abs_fname, None)
+ self._emitted_contexts.pop(abs_fname, None)
+ self._file_context_versions.pop(abs_fname, None)
+
def push_range(self, file_path: str, ranges: List[Tuple[int, int]]) -> None:
"""
Push merged ranges into the _last_merged_ranges cache for a file.
diff --git a/cecli/helpers/conversation/integration.py b/cecli/helpers/conversation/integration.py
index 215c2dab182..fd42101cc02 100644
--- a/cecli/helpers/conversation/integration.py
+++ b/cecli/helpers/conversation/integration.py
@@ -772,6 +772,15 @@ def add_file_context_messages(self, promote_messages=True) -> None:
hash_key=("file_context_user", file_path, content_hash),
)
+ def notify_file_edited(self, file_path: str) -> None:
+ """Invalidate cached ReadFile ranges for a file changed by a tool."""
+
+ coder = self.get_coder()
+ if not coder:
+ return
+
+ ConversationService.get_files(coder).invalidate_file_context(file_path)
+
def reset(self) -> None:
"""
Reset the entire conversation system to initial state.
diff --git a/cecli/helpers/coroutines.py b/cecli/helpers/coroutines.py
index 6c8e590404e..ed5d26ddc28 100644
--- a/cecli/helpers/coroutines.py
+++ b/cecli/helpers/coroutines.py
@@ -158,10 +158,26 @@ async def cancel_and_abandon(tasks, grace=INTERRUPT_UNWIND_GRACE_SECONDS):
if not pending_tasks:
return
+ # Tasks can outlive their loop during shutdown. Cancelling one then calls
+ # loop.call_soon() on a closed loop and raises RuntimeError, so drop those
+ # instead of failing the whole shutdown sequence.
+ active_tasks = []
for task in pending_tasks:
+ task_loop = task.get_loop() if hasattr(task, "get_loop") else None
+
+ if task_loop is not None and task_loop.is_closed():
+ background_tasks.discard(task)
+ continue
+
+ active_tasks.append(task)
+
+ if not active_tasks:
+ return
+
+ for task in active_tasks:
task.cancel()
- done, still_running = await asyncio.wait(set(pending_tasks), timeout=grace)
+ done, still_running = await asyncio.wait(set(active_tasks), timeout=grace)
drain_task_results(done)
for task in still_running:
diff --git a/cecli/helpers/hashline.py b/cecli/helpers/hashline.py
index f232e5e85ff..4b7bebb5968 100644
--- a/cecli/helpers/hashline.py
+++ b/cecli/helpers/hashline.py
@@ -183,12 +183,23 @@ def find_hashline_range(
start_hash, _, _ = parse_hashline(start_line_hash)
end_hash, _, _ = parse_hashline(end_line_hash)
+ # Handle an empty range at EOF ("000@" ... "000@"): append content.
+ if start_hash == "000@" and end_hash == "000@":
+ return len(hashed_lines), len(hashed_lines) - 1
+
+ # "000@" is only valid as a start marker when paired with another "000@".
+ if start_hash == "000@":
+ raise ContentHashError(
+ "Start marker '000@' (EOF) is only valid with end marker '000@' "
+ "to append at the end of the file"
+ )
+
# Handle special marker "@000" (top of file)
if start_hash == "@000":
found_start = 0
# If end is also "@000", it's an empty range at the start
if end_hash == "@000":
- return 0, 0
+ return 0, -1
# If end is "000@", it's the entire file
if end_hash == "000@":
if not hashed_lines:
@@ -1208,6 +1219,24 @@ def _merge_replace_operations(resolved_ops):
and current["op"]["operation"] == "replace"
and prev["op"].get("text") is not None
and current["op"].get("text") is not None
+ and not (
+ prev["op"].get("start_line_hash") == "000@"
+ and prev["op"].get("end_line_hash") == "000@"
+ )
+ and not (
+ prev["op"].get("start_line_hash") == "@000"
+ and prev["op"].get("end_line_hash") == "@000"
+ )
+ and not (
+ current["op"].get("start_line_hash") == "000@"
+ and current["op"].get("end_line_hash") == "000@"
+ )
+ and not (
+ current["op"].get("start_line_hash") == "@000"
+ and current["op"].get("end_line_hash") == "@000"
+ )
+ and prev["op"].get("original_operation") != "insert"
+ and current["op"].get("original_operation") != "insert"
):
# Check if they are contiguous or overlapping
# Contiguous: prev.end_idx == current.start_idx OR prev.end_idx + 1 == current.start_idx
@@ -1355,7 +1384,10 @@ def _deduplicate_ranges(resolved_ops):
ops_by_key = {}
for op in resolved_ops:
- key = (op["start_idx"], op["op"]["operation"])
+ key = (
+ op["start_idx"],
+ op["op"].get("original_operation", op["op"]["operation"]),
+ )
ops_by_key.setdefault(key, []).append(op)
for ops in ops_by_key.values():
@@ -1508,7 +1540,8 @@ def _merged_contained_ranges(resolved_ops):
if (
op_a["start_idx"] == op_b["start_idx"]
and op_a["end_idx"] == op_b["end_idx"]
- and op_a["op"]["operation"] != op_b["op"]["operation"]
+ and op_a["op"].get("original_operation", op_a["op"]["operation"])
+ != op_b["op"].get("original_operation", op_b["op"]["operation"])
):
# Keep both operations if they have different types
continue
@@ -1538,7 +1571,7 @@ def sort_ranges(op):
start_idx = op["start_idx"]
# Operation type priority: insert (0), replace (1), delete (2)
# Lower priority number means applied first
- op_type = op["op"]["operation"]
+ op_type = op["op"].get("original_operation", op["op"]["operation"])
if op_type == "insert":
priority = 0
elif op_type == "replace":
@@ -1577,11 +1610,27 @@ def _would_create_duplicate_content(source_lines, candidate_start, candidate_end
def _fix_duplicate_content_boundaries(source_lines, resolved_ops):
"""
Expand edit boundaries when replacement content duplicates adjacent lines.
+ Expansions never cross another operation's resolved range.
"""
+
+ def _overlaps_other(start, end, current):
+ for other in resolved_ops:
+ if other is current:
+ continue
+ if start <= other["end_idx"] and end >= other["start_idx"]:
+ return True
+ return False
+
for resolved in resolved_ops:
op = resolved["op"]
if op["operation"] not in {"replace", "insert", "delete"}:
continue
+ if op.get("original_operation") == "insert":
+ continue
+ if (op.get("start_line_hash") == "000@" and op.get("end_line_hash") == "000@") or (
+ op.get("start_line_hash") == "@000" and op.get("end_line_hash") == "@000"
+ ):
+ continue
repl_text = op.get("text", "") or ""
repl_lines = repl_text.splitlines()
if not repl_lines:
@@ -1591,7 +1640,7 @@ def _fix_duplicate_content_boundaries(source_lines, resolved_ops):
end_idx = resolved["end_idx"]
# Expand start backward if first replacement line duplicates line before
- while start_idx > 0:
+ while start_idx > 0 and not _overlaps_other(start_idx - 1, end_idx, resolved):
line_before = source_lines[start_idx - 1].strip()
first_repl = repl_lines[0].strip()
if line_before and line_before == first_repl:
@@ -1600,7 +1649,9 @@ def _fix_duplicate_content_boundaries(source_lines, resolved_ops):
break
# Expand end forward if last replacement line duplicates line after
- while end_idx < len(source_lines) - 1:
+ while end_idx < len(source_lines) - 1 and not _overlaps_other(
+ start_idx, end_idx + 1, resolved
+ ):
line_after = source_lines[end_idx + 1].strip()
last_repl = repl_lines[-1].strip()
if line_after and line_after == last_repl:
@@ -1668,6 +1719,23 @@ def count_ast_errors(node) -> int:
errors += count_ast_errors(child)
return errors
+ def apply_resolved_operations(lines: list, ops: list) -> bytes:
+ """Apply resolved operations to a line list for whole-batch AST checks."""
+ result = list(lines)
+
+ for candidate in sorted(ops, key=lambda item: (-item["start_idx"], item["index"])):
+ candidate_op = candidate["op"]
+ candidate_start = candidate["start_idx"]
+ candidate_end = candidate["end_idx"]
+ replacement = (candidate_op.get("text") or "").splitlines()
+
+ if candidate_op["operation"] == "insert":
+ result[candidate_start + 1 : candidate_start + 1] = replacement
+ else:
+ result[candidate_start : candidate_end + 1] = replacement
+
+ return "\n".join(result).encode("utf-8")
+
def _detect_indent_unit(source_lines: list) -> int:
"""Detect the common indentation unit from non-empty source lines."""
indents = []
@@ -1709,6 +1777,12 @@ def _has_indent_jump(test_lines: list, unit: int) -> bool:
return False
+ def _replacement_duplicate_count(lines: list, replacement_lines: list) -> int:
+ """Count extra occurrences of replacement-text lines in the file."""
+ counts = Counter(line.strip() for line in lines if line.strip())
+ keys = {line.strip() for line in replacement_lines if line.strip()}
+ return sum(max(0, counts[key] - 1) for key in keys)
+
# Establish the baseline syntax health of the original file
base_tree = parser.parse(source_bytes)
baseline_error_count = count_ast_errors(base_tree.root_node)
@@ -1742,6 +1816,7 @@ def _has_indent_jump(test_lines: list, unit: int) -> bool:
repl_lines = replacement_text.splitlines()
llm_end = min(llm_end, len(source_lines) - 1)
+ baseline_replacement_duplicates = _replacement_duplicate_count(source_lines, repl_lines)
offsets = [0, 1, -1, 2, -2]
all_candidates = []
@@ -1767,11 +1842,6 @@ def _has_indent_jump(test_lines: list, unit: int) -> bool:
if overlaps:
continue
- if _would_create_duplicate_content(
- source_lines, candidate_start, candidate_end, repl_lines
- ):
- continue
-
test_source_bytes = apply_edit(
source_lines,
candidate_start,
@@ -1783,6 +1853,12 @@ def _has_indent_jump(test_lines: list, unit: int) -> bool:
edited_tree = parser.parse(test_source_bytes)
ast_error_count = count_ast_errors(edited_tree.root_node)
+ test_lines = test_source_bytes.decode("utf-8").splitlines()
+ duplicate_delta = (
+ _replacement_duplicate_count(test_lines, repl_lines)
+ - baseline_replacement_duplicates
+ )
+
is_partial = (start_shift == 0) ^ (end_shift == 0)
is_downward = start_shift <= 0
is_both = start_shift == end_shift
@@ -1792,7 +1868,6 @@ def _has_indent_jump(test_lines: list, unit: int) -> bool:
# Check for indentation jumps in the resulting edit
if is_normally_indented:
- test_lines = test_source_bytes.decode("utf-8").splitlines()
has_indent_jump = _has_indent_jump(test_lines, indent_unit)
else:
has_indent_jump = False
@@ -1804,6 +1879,7 @@ def _has_indent_jump(test_lines: list, unit: int) -> bool:
"ast_error_count": ast_error_count,
"start_line_match": start_line_match,
"end_line_match": end_line_match,
+ "duplicate_delta": duplicate_delta,
"cumulative_movement": cumulative_movement,
"has_indent_jump": has_indent_jump,
"is_partial": is_partial,
@@ -1813,15 +1889,19 @@ def _has_indent_jump(test_lines: list, unit: int) -> bool:
)
if all_candidates:
- # 1. AST Health is the absolute highest priority.
- # 2. Cumulative movement acts as the tie-breaker for equally valid ASTs.
+ # 1. AST health is the highest priority.
+ # 2. Preserve exact boundary-line matches.
+ # 3. Minimize total duplication of replacement-text lines.
+ # 4. Prefer the smallest cumulative movement.
+ # 5. Use indentation heuristics only to break remaining ties.
all_candidates.sort(
key=lambda r: (
r["ast_error_count"], # Ascending: fewer syntax errors wins
not r["start_line_match"], # Descending: True first
not r["end_line_match"], # Descending: True first
- r.get("has_indent_jump", False), # Ascending: False (no jump) first
+ r["duplicate_delta"], # Ascending: fewer replacement-line duplicates
r["cumulative_movement"], # Ascending: smaller is better
+ r.get("has_indent_jump", False), # Ascending: False (no jump) first
not r["is_partial"], # Booleans: False comes before True
not r["is_downward"], # Booleans: False comes before True
r["is_both"], # Booleans: False comes before True
@@ -1833,7 +1913,6 @@ def _has_indent_jump(test_lines: list, unit: int) -> bool:
# EVICTION GATE: If the absolute best boundary still introduces NEW
# syntax errors compared to the original file, discard the edit entirely.
if best["ast_error_count"] > baseline_error_count:
-
rejected_ops.append(
{
"index": resolved["index"],
@@ -1843,6 +1922,11 @@ def _has_indent_jump(test_lines: list, unit: int) -> bool:
f"{len(all_candidates)} candidate boundary adjustments could resolve it"
),
"operation": op,
+ "resolved": {
+ **resolved,
+ "start_idx": best["start_idx"],
+ "end_idx": best["end_idx"],
+ },
}
)
continue
@@ -1852,6 +1936,21 @@ def _has_indent_jump(test_lines: list, unit: int) -> bool:
safe_resolved_ops.append(resolved)
+ # An operation can be individually invalid but valid in combination with
+ # sibling operations (e.g., a structural refactor split across edits).
+ # Accept the batch when the combined result is no worse than the source.
+ if rejected_ops and len(resolved_ops) > 1:
+ batch_ops = safe_resolved_ops + [
+ rejected["resolved"] for rejected in rejected_ops if "resolved" in rejected
+ ]
+ combined_tree = parser.parse(apply_resolved_operations(source_lines, batch_ops))
+ if count_ast_errors(combined_tree.root_node) <= baseline_error_count:
+ safe_resolved_ops = batch_ops
+ rejected_ops = []
+
+ for rejected in rejected_ops:
+ rejected.pop("resolved", None)
+
return safe_resolved_ops, rejected_ops
@@ -1916,31 +2015,71 @@ def _raise_clear_hash_error(hp, op, key):
"Use a hashed ID prefix (e.g., `—XXXX—`) to target the line."
)
- # Count how many lines contain this value as a substring
- matching_count = 0
- for line in hp.lines:
- if value_stripped in line:
- matching_count += 1
+ if "\n" in value_stripped or "\r" in value_stripped:
+ raise ContentHashError(
+ "Line anchors must be single-line; multi-line text is only supported "
+ "in the replacement text."
+ )
- if matching_count > 1:
+ if re.search(r"(?:^|[ \t])@[CPN][0-9]*[ \t]*$", value_stripped):
raise ContentHashError(
- f"Line '{value_stripped}' appears {matching_count} times in the file. "
- "Use the hashed ID prefix (e.g., `—XXXX—`) to disambiguate "
- "which occurrence to target."
+ "ReadFile-only range markers ('@C', '@P', '@N') are not "
+ "supported by EditFile; use a content ID or '@L' line reference."
)
- elif matching_count == 1:
+
+ if re.search(r"(?:^|[ \t])@[AB][ \t]*$", value_stripped):
raise ContentHashError(
- f"Line '{value_stripped}' was found once in the file but could not be "
- "resolved as a unique content ID. Use the hashed ID prefix (e.g., `—XXXX—`) "
- "to target it precisely."
+ "Bare '@A'/'@B' hints are not supported; include anchor text before "
+ "the hint, e.g. 'my line @A{regex}'."
)
- else:
+
+ substring_indices = [i for i, line in enumerate(hp.lines) if value_stripped in line]
+ exact_indices = [i for i, line in enumerate(hp.lines) if line.strip() == value_stripped]
+
+ def _format_line_numbers(indices):
+ shown = ", ".join(str(i + 1) for i in indices[:10])
+ if len(indices) > 10:
+ return f"{shown} (+{len(indices) - 10} more)"
+ return shown
+
+ if not substring_indices:
raise ContentHashError(
f"Line '{value_stripped}' was not found in the file. "
"Use the exact line content or a hashed ID prefix (e.g., `—XXXX—`) "
"to target the desired line."
)
+ if len(exact_indices) > 1:
+ raise ContentHashError(
+ f"Line '{value_stripped}' appears {len(exact_indices)} times "
+ f"(lines {_format_line_numbers(exact_indices)}). "
+ "Add a line hint (e.g., 'text @L') or use the hashed ID prefix "
+ "(e.g., `—XXXX—`) to choose one occurrence."
+ )
+
+ if len(exact_indices) == 1:
+ raise ContentHashError(
+ f"Line '{value_stripped}' exactly matches line {exact_indices[0] + 1} "
+ "but could not be resolved to a unique content ID. Use its hashed ID "
+ "prefix (e.g., `—XXXX—`) to target it precisely."
+ )
+
+ if len(substring_indices) == 1:
+ matched_line = hp.lines[substring_indices[0]].strip()
+ raise ContentHashError(
+ f"Line '{value_stripped}' only partially matches line "
+ f"{substring_indices[0] + 1} ('{matched_line}'). "
+ "Anchors must match the complete line. "
+ "Use the full line text or the hashed ID prefix (e.g., `—XXXX—`)."
+ )
+
+ raise ContentHashError(
+ f"Line '{value_stripped}' partially matches {len(substring_indices)} lines "
+ f"(lines {_format_line_numbers(substring_indices)}). "
+ "Anchors must match one complete line; use the full line text or the "
+ "hashed ID prefix (e.g., `—XXXX—`) to disambiguate."
+ )
+
def _detect_overlapping_ranges(resolved_ops):
"""Check resolved operations for genuinely overlapping ranges.
@@ -1988,8 +2127,8 @@ def _detect_overlapping_ranges(resolved_ops):
op_b = b["op"]
error_msg = (
f"Operation {a['index'] + 1} ({op_a['operation']}, lines "
- f"{a_start}-{a_end}) overlaps with operation {b['index'] + 1} "
- f"({op_b['operation']}, lines {b_start}-{b_end}). "
+ f"{a_start + 1}-{a_end + 1}) overlaps with operation {b['index'] + 1} "
+ f"({op_b['operation']}, lines {b_start + 1}-{b_end + 1}). "
"Edits with overlapping ranges are not supported. "
"Combine them into a single edit or use non-overlapping ranges."
)
@@ -2067,12 +2206,72 @@ def apply_hashline_operations(
hashed_content = hp.format_content()
hashed_lines = hashed_content.splitlines(keepends=True)
+ def _rewrite_insert_as_replace(op):
+ """Rewrite insert to replace so it follows the same remediation chain."""
+ text = op.get("text") or ""
+ start_hash = op["start_line_hash"]
+ position = (op.get("position") or "after").lower()
+
+ if position not in ("before", "after"):
+ raise ContentHashError(f"Invalid insert position '{position}'; use 'before' or 'after'")
+
+ if start_hash in ("@000", "000@"):
+ return {
+ **op,
+ "end_line_hash": start_hash,
+ "operation": "replace",
+ "original_operation": "insert",
+ }
+
+ fragment, line_num_str, _ = parse_hashline(start_hash)
+ target_idx = find_hashline_by_exact_match(hashed_lines, fragment, line_num_str)
+ if target_idx is None:
+ raise ContentHashError(f"Start line hash fragment '{fragment}' not found in file")
+
+ target_line = strip_hashline(hashed_lines[target_idx])
+
+ if position == "before":
+ replacement = text if text.endswith("\n") else text + "\n"
+ replacement += target_line
+ else:
+ replacement = target_line
+ if not replacement.endswith("\n"):
+ replacement += "\n"
+ replacement += text
+
+ return {
+ **op,
+ "end_line_hash": start_hash,
+ "operation": "replace",
+ "text": replacement,
+ "original_operation": "insert",
+ }
+
# Resolve all operations to indices first
resolved_ops = []
# Loop to convert hashline references to line indices
for i, op in enumerate(normalized_operations):
try:
if op["operation"] == "insert":
+ if op.get("text"):
+ rewritten_op = _rewrite_insert_as_replace(op)
+ found_start, found_end = find_hashline_range(
+ hashed_lines,
+ rewritten_op["start_line_hash"],
+ rewritten_op["end_line_hash"],
+ allow_exact_match=True,
+ replacement_text=rewritten_op.get("text"),
+ )
+ resolved_ops.append(
+ {
+ "index": i,
+ "start_idx": found_start,
+ "end_idx": found_end,
+ "op": rewritten_op,
+ }
+ )
+ continue
+
start_hash_fragment, start_line_num_str, start_line_num = parse_hashline(
op["start_line_hash"]
)
@@ -2139,7 +2338,9 @@ def apply_hashline_operations(
)
except Exception as e:
raise ContentHashError(
- f"Could not resolve hash range {start_hash}...{end_hash}: {str(e)}"
+ f"Could not resolve hash range {start_hash}...{end_hash}: {e}. "
+ "The file may have changed since these IDs were read; re-read "
+ "it and use the fresh IDs or '@L' anchors."
)
except Exception as e:
@@ -2221,10 +2422,14 @@ def apply_hashline_operations(
else:
hashed_lines.append(text)
elif op["operation"] == "delete":
- del hashed_lines[start_idx : end_idx + 1]
+ if start_idx > end_idx:
+ changed = False
+ else:
+ del hashed_lines[start_idx : end_idx + 1]
elif op["operation"] == "replace":
- # If operation ends with "000@", ensure end_idx is at actual end of file
- if op.get("end_line_hash") == "000@":
+ # If operation ends with "000@", ensure end_idx is at actual end of file.
+ # A "000@" start is an empty append range, so leave it alone.
+ if op.get("end_line_hash") == "000@" and op.get("start_line_hash") != "000@":
end_idx = len(hashed_lines) - 1
text = op["text"]
@@ -2271,6 +2476,16 @@ def apply_hashline_operations(
next_idx < len(hashed_lines) and hashed_lines[next_idx].strip() == ""
)
+ # Appending after the last line needs a separator when the
+ # source text did not end with a newline.
+ if (
+ start_idx > end_idx
+ and start_idx >= len(hashed_lines)
+ and hashed_lines
+ and not hashed_lines[-1].endswith("\n")
+ ):
+ hashed_lines[-1] += "\n"
+
hashed_lines[start_idx : end_idx + 1] = replacement_lines
# Ensure the trailing empty line is preserved after replacement
@@ -2324,6 +2539,14 @@ def apply_hashline_operations(
# Original doesn't end with newline but result does - remove it
result = result[:-1]
+ # Restore CRLF when the source used it consistently.
+ if (
+ original_content
+ and "\r\n" in original_content
+ and original_content.count("\r\n") == original_content.count("\n")
+ ):
+ result = result.replace("\r\n", "\n").replace("\n", "\r\n")
+
return result, successful_ops, failed_ops
diff --git a/cecli/helpers/hashpos/hashpos.py b/cecli/helpers/hashpos/hashpos.py
index 538fc483200..1fa8e40f541 100644
--- a/cecli/helpers/hashpos/hashpos.py
+++ b/cecli/helpers/hashpos/hashpos.py
@@ -86,17 +86,50 @@ def __init__(self, source_text: str = ""):
if line.strip():
self.line_counts[line] = self.line_counts.get(line, 0) + 1
+ self._line_anchors = []
+ self._line_local_occurrences = []
+ last_anchor = 0
+ segment_counts = {}
+
+ for line in self.lines:
+ self._line_anchors.append(last_anchor)
+
+ if not line.strip():
+ self._line_local_occurrences.append(0)
+ continue
+
+ occurrence = segment_counts.get(line, 0) + 1
+ segment_counts[line] = occurrence
+ self._line_local_occurrences.append(occurrence)
+
+ if self.line_counts.get(line, 0) == 1:
+ last_anchor = self._get_anchor_hash(line)
+ segment_counts = {}
+
def _get_line_hash(self, text: str) -> int:
return xxhash.xxh3_64_intdigest(text.encode("utf-8")) & 0xFFFFF
+ def _get_anchor_hash(self, text: str) -> int:
+ return self._get_line_hash(text) & 0x3FFF
+
def generate_public_id(self, text: str, line_idx: int, occurrence: int) -> str:
+ """Encode content hash + local anchor hash + local occurrence.
+
+ The anchor is the hash of the closest preceding unique line (or 0 for
+ the file prefix); occurrence counts equal lines of the same content
+ since that anchor. This keeps IDs stable when unrelated content shifts
+ line numbers.
+ """
line_hash = self._get_line_hash(text)
- # Explicit modulo for bounds wrapping
- idx_bits = line_idx % 16384
- occ_bits = occurrence % 64
+ if 0 <= line_idx < len(self.lines) and self.lines[line_idx] == text:
+ anchor_bits = self._line_anchors[line_idx]
+ occ_bits = self._line_local_occurrences[line_idx]
+ else:
+ anchor_bits = 0
+ occ_bits = occurrence
- packed = (line_hash << 20) | (idx_bits << 6) | occ_bits
+ packed = (line_hash << 20) | ((anchor_bits & 0x3FFF) << 6) | (occ_bits & 0x3F)
res = ""
for i in range(3, -1, -1):
@@ -109,14 +142,13 @@ def unpack_public_id(self, public_id: str) -> tuple[int, int, int]:
packed |= self.B1024.index(char) << (10 * (3 - i))
occ_bits = packed & 0x3F
- idx_bits = (packed >> 6) & 0x3FFF
+ anchor_bits = (packed >> 6) & 0x3FFF
line_hash = (packed >> 20) & 0xFFFFF
- return line_hash, idx_bits, occ_bits
+ return line_hash, anchor_bits, occ_bits
def format_content(self, start_line: int = 1) -> str:
formatted_lines = []
- seen = {}
for i, line in enumerate(self.lines):
if not line.strip():
@@ -129,8 +161,7 @@ def format_content(self, start_line: int = 1) -> str:
# Flush directly against code using the unique token
formatted_lines.append(f"{UNIQUE_HASH_DELIMITER}{line}")
else:
- occ = seen.get(line, 0) + 1
- seen[line] = occ
+ occ = self._line_local_occurrences[i]
prefix = self.generate_public_id(line, i, occ)
# Wrap the generated Base1024 hash in tildes
formatted_lines.append(f"{self.get_wrapped_id(prefix)}{line}")
@@ -148,38 +179,36 @@ def resolve_to_lines(self, public_id: str, start_line: int = 1) -> list[int]:
if len(clean_id) != 4:
raise ValueError(f"Invalid public ID string for unpacking: {public_id}")
- target_line_hash, target_idx, target_occ = self.unpack_public_id(clean_id)
+ target_line_hash, target_anchor, target_occurrence = self.unpack_public_id(clean_id)
- matches = []
- for i, line in enumerate(self.lines):
- if self._get_line_hash(line) == target_line_hash:
- matches.append((i, line))
+ matches = [
+ i for i, line in enumerate(self.lines) if self._get_line_hash(line) == target_line_hash
+ ]
if not matches:
return []
if len(matches) == 1:
- return [matches[0][0]]
+ return matches
- current_seen = {}
- scored_matches = []
-
- for i, line in matches:
- current_seen[line] = current_seen.get(line, 0) + 1
- current_occ = current_seen[line]
+ exact_matches = [
+ i
+ for i in matches
+ if self._line_anchors[i] == target_anchor
+ and (self._line_local_occurrences[i] & 0x3F) == target_occurrence
+ ]
- # Apply identical modulo to current spatial data before comparing
- current_idx_mod = i % 16384
- current_occ_mod = current_occ % 64
+ if exact_matches:
+ return sorted(exact_matches)
- # Cartesian distance squared
- distance_sq = ((current_idx_mod - target_idx) ** 2) + (
- (current_occ_mod - target_occ) ** 2
- )
- scored_matches.append((distance_sq, i))
+ scored_matches = []
+ for i in matches:
+ anchor_penalty = 0 if self._line_anchors[i] == target_anchor else 1
+ occurrence_distance = abs((self._line_local_occurrences[i] & 0x3F) - target_occurrence)
+ scored_matches.append((anchor_penalty, occurrence_distance, i))
- scored_matches.sort(key=lambda x: x[0])
- return [m[1] for m in scored_matches]
+ scored_matches.sort()
+ return [m[2] for m in scored_matches]
def resolve_range(self, start_id: str, end_id: str) -> tuple[int, int]:
starts = self.resolve_to_lines(start_id)
diff --git a/cecli/helpers/hashpos/transformations.py b/cecli/helpers/hashpos/transformations.py
index 0f76be0c164..5ea1e10106a 100644
--- a/cecli/helpers/hashpos/transformations.py
+++ b/cecli/helpers/hashpos/transformations.py
@@ -3,8 +3,7 @@
Centralizes all operations related to searching, processing line hints,
contextual markers, and extracting content from HashPos-encoded files.
-Previously scattered across read_file.py, edit_file.py, hashline.py,
-and region_resolver.py.
+Previously scattered across read_file.py, edit_file.py, and hashline.py.
"""
from __future__ import annotations
@@ -155,6 +154,9 @@ def resolve_at_l(
):
return line_spec
+ if line_spec == "@L0":
+ return "@000"
+
line_num = int(line_spec[2:]) - 1
if line_num < 0 or line_num >= len(lines):
@@ -183,8 +185,8 @@ def extract_hint(
Supports three hint types:
* ``@L`` — direct line number (always resolvable).
- * ``@A{{{{regex}}}}`` — keep only matches **after** the first regex hit.
- * ``@B{{{{regex}}}}`` — keep only matches **before** the last regex hit.
+ * ``@A{regex}`` / ``@A{{regex}}`` — keep only matches **after** the first regex hit.
+ * ``@B{regex}`` / ``@B{{regex}}`` — keep only matches **before** the last regex hit.
Returns a ``(stripped_pattern, hint_value, hint_type)`` tuple:
@@ -199,8 +201,8 @@ def extract_hint(
stripped = pattern[: m.start()]
return (stripped if stripped else pattern), int(m.group(1)) - 1, "L"
- # @A{{regex}} hint — first regex match, filter to lines AFTER
- m = re.search(r"(?:^|[ \t]+)@A\{\{(.+?)\}\}[ \t]*$", pattern)
+ # @A{regex} or @A{{regex}} hint — first regex match, filter to lines AFTER
+ m = re.search(r"(?:^|[ \t]+)@A\{\{?(.+?)\}\}?[ \t]*$", pattern)
if m:
stripped = pattern[: m.start()]
@@ -217,8 +219,8 @@ def extract_hint(
return stripped, None, None
- # @B{{regex}} hint — last regex match, filter to lines BEFORE
- m = re.search(r"(?:^|[ \t]+)@B\{\{(.+?)\}\}[ \t]*$", pattern)
+ # @B{regex} or @B{{regex}} hint — last regex match, filter to lines BEFORE
+ m = re.search(r"(?:^|[ \t]+)@B\{\{?(.+?)\}\}?[ \t]*$", pattern)
if m:
stripped = pattern[: m.start()]
diff --git a/cecli/helpers/orchestration/agent_proxy.py b/cecli/helpers/orchestration/agent_proxy.py
index a016d8d4ff9..7401a7db9e7 100644
--- a/cecli/helpers/orchestration/agent_proxy.py
+++ b/cecli/helpers/orchestration/agent_proxy.py
@@ -389,77 +389,6 @@ def get_content_id(self, file_path: str, line_content: str) -> str:
raise ValueError(f"Could not resolve content ID for '{line_content}' in {file_path}")
return result
- def resolve_regions(
- self,
- file_path: str,
- regions: list[dict[str, str]],
- exact: bool = True,
- ):
- """
- Store named region patterns for lazy content-ID resolution.
-
- Content IDs are resolved *on access* via `.get_start(name)` /
- `.get_end(name)`, so they are always fresh — even after
- intervening edits shift hashline positions.
-
- Args:
- file_path: Path to the file containing the target regions.
- regions: List of region spec dicts with "name", "start", "end" keys.
- exact: When True (default), use exact line matching (no stripping).
- When False, use substring matching with stripped content.
-
- Returns an :class:`AgentRegion` instance.
- """
-
- from cecli.helpers.orchestration.region_resolver import AgentRegion
-
- return AgentRegion(file_path, self._coder, regions, exact=exact)
-
- async def edit_region(
- self,
- file_path: str,
- edits: list[dict[str, object]],
- change_id: str | None = None,
- ):
- """
- Thin wrapper around `EditFile` that accepts `{"start": content_id, "end": content_id}` region dicts.
-
- Use with `Agent.resolve_regions()` and `regions.get(name)`:
-
- regions = Agent.resolve_regions("foo.py", [
- {"name": "my_func", "start": "def my_func", "end": "return result"},
- ])
- await Agent.edit_region(
- file_path="foo.py",
- edits=[
- {"region": regions.get("my_func"), "text": "def my_func():\\n return 42"},
- ],
- )
- """
-
- edit_tool = self.get_tool("EditFile")
-
- edit_objects: list[dict[str, object]] = []
- for edit in edits:
- region = edit["region"]
-
- edit_objects.append(
- {
- "file_path": file_path,
- "operation": edit.get("operation", "replace"),
- "start_line": region["start"],
- "end_line": region["end"],
- "text": edit["text"],
- }
- )
-
- # self._validate_edit_regions(edits, edit_objects)
-
- return await edit_tool.call(
- edits=edit_objects,
- change_id=change_id,
- )
-
async def sleep(self, seconds: float) -> None:
"""Safe sleep - pauses execution (0-120 seconds max).
@@ -505,57 +434,6 @@ def allowed_methods(self) -> list[str]:
)
return builtins + globals_list
- @staticmethod
- def _validate_edit_regions(
- edits: list[dict[str, object]],
- edit_objects: list[dict[str, object]],
- ) -> None:
- """Reject batches where any two edit regions overlap or are adjacent.
-
- Adjacent edits (within 8 lines of each other) corrupt content IDs
- because the first edit regenerates IDs in the surrounding ~8-line
- window, making the second edit's target IDs stale.
- """
- _ADJACENCY_THRESHOLD = 8 # lines of ID-regeneration buffer
-
- if len(edits) <= 1:
- return
-
- # Collect (start_line, end_line, idx) for each edit
- indexed: list[tuple[int, int, int]] = []
- for i, (edit, eo) in enumerate(zip(edits, edit_objects)):
- region = edit["region"]
- sl = region.get("start_line")
- el = region.get("end_line")
- if sl is None or el is None:
- # No line numbers available — skip validation (best-effort)
- return
- indexed.append((int(sl), int(el), i))
-
- # Sort by start_line
- indexed.sort(key=lambda x: x[0])
-
- for j in range(len(indexed) - 1):
- sl_a, el_a, idx_a = indexed[j]
- sl_b, el_b, idx_b = indexed[j + 1]
-
- if sl_b <= el_a:
- raise ValueError(
- f"Overlapping edit regions detected: "
- f"edit {idx_a + 1} (lines {sl_a}-{el_a}) overlaps with "
- f"edit {idx_b + 1} (lines {sl_b}-{el_b}). "
- f"Split edits into separate calls."
- )
- if sl_b <= el_a + _ADJACENCY_THRESHOLD:
- raise ValueError(
- f"Adjacent edit regions detected: "
- f"edit {idx_a + 1} (lines {sl_a}-{el_a}) is within "
- f"{_ADJACENCY_THRESHOLD} lines of edit {idx_b + 1} (lines {sl_b}-{el_b}). "
- f"Content IDs within ~8 lines of an edit are regenerated, "
- f"so adjacent edits corrupt each other's targets. "
- f"Split edits into separate calls."
- )
-
# ---------------------------------------------------------------------------
# Main execution environment
diff --git a/cecli/helpers/orchestration/environment.py b/cecli/helpers/orchestration/environment.py
index 59cef7da459..188317bd0df 100644
--- a/cecli/helpers/orchestration/environment.py
+++ b/cecli/helpers/orchestration/environment.py
@@ -558,22 +558,13 @@ def build_orchestration_context_block(agent_config: dict[str, Any]) -> str | Non
| `Agent.get_tool(name)` | Get a tool proxy (case-insensitive; `Local--` / `{{Server Name}}--` prefixes ok) |
| `Agent.get_tool_schema(name)` | Return a copy of the full function-calling JSON schema |
| `Agent.get_tool_signature(name)` | Return a readable Python-style signature; optional params without defaults appear as `= ...` |
-| `await tool.call(**params)` | Run a tool; returns `{"result": [...], "errors": [...], "details": [...]}`, items with shape `{"content", "_"}` |
+| `await tool.call(**params)` | Run a tool; `params` match the tool's JSON schema. Returns `{"result", "errors", "details"}` |
| `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 that survive between Orchestrate calls; `state.get(k)` falls back to `shared_state`. Use these for anything you need to reuse in a later call |
| `print(...)` / `reset(local_vars=True, state=False)` | Emit output / clear namespaces |
| `typeof(x)`, `isinstance(x, t)`, `hasattr(x, n)`, `repr(x)`, `vars(obj)` | Type inspection and debugging |
-### Region editing
-
-- `resolve_regions(path, specs)`:
- `specs` = `[{"name", "start", "end", "start_line_hint"?, "end_line_hint"?}]`; `start`/`end` are line text, `@L{num}`, or a content ID.
- Returns `regions` with `.get(name)` -> `{"start", "end", "start_line", "end_line"}` plus `.get_start(name)` / `.get_end(name)`.
-- `edit_region(path, edits)`:
- `edits` = `[{"region": regions.get(name), "text", "operation" ("replace"|"delete", default "replace")}]`.
-
### Modules
Pre-imported, read-only: `re`, `math`, `itertools`, `collections`, `datetime`, `pathlib` (I/O blocked), `json`, `traceback`. Any other import fails.
@@ -583,7 +574,7 @@ def build_orchestration_context_block(agent_config: dict[str, Any]) -> str | Non
- Keyword args only; `gather()` takes named tasks
- Do not touch private (`_`) attributes or `__builtins__`
- Always `await` tool calls
-- Inspect tool results with `Agent.peek()` / `Agent.get_value()`; edit files via `Agent.resolve_regions()` + `Agent.edit_region()` or `EditFile` directly
+- Inspect tool results with `Agent.peek()` / `Agent.get_value()`
"""
# Task 6: Append sandbox configuration overrides when non-empty
diff --git a/cecli/helpers/orchestration/region_resolver.py b/cecli/helpers/orchestration/region_resolver.py
deleted file mode 100644
index 5d578b58ad2..00000000000
--- a/cecli/helpers/orchestration/region_resolver.py
+++ /dev/null
@@ -1,488 +0,0 @@
-"""
-Region resolution helpers for the orchestration sandbox.
-
-Provides AgentRegion for storing named region boundary *patterns* that are
-resolved to content IDs on-demand at access time. This ensures content IDs
-are always fresh — important after intervening edits shift hashline positions.
-"""
-
-from __future__ import annotations
-
-from typing import Any
-
-from cecli.helpers.hashpos.transformations import (
- extract_hint,
- is_content_id,
- narrow_by_proximity,
- resolve_at_l,
- search_in_lines,
-)
-
-
-class AgentRegion:
- """
- Stores named region boundary *patterns* resolved to content IDs on access.
-
- Content IDs can shift after edits, so ``get_start(name)`` and
- ``get_end(name)`` re-read the file and re-resolve patterns at each call.
- Use these directly in ``EditFile`` calls for always-fresh IDs.
-
- When a region spec uses a **content ID** (e.g. `"—abcd—"`) instead of
- text, the referenced line content is snapshotted on first resolution.
- If the ID goes stale after intervening edits, subsequent resolutions
- fall back to content matching against the snapshotted line text.
-
- Example usage in orchestration code:
-
- regions = Agent.resolve_regions("foo.py", [
- {"name": "helper", "start": "def helper", "end": "return x"},
- ])
- edit_tool = Agent.get_tool("EditFile")
- await edit_tool.call(edits=[{
- "file_path": "foo.py",
- "operation": "replace",
- "start_line": regions.get_start("helper"),
- "end_line": regions.get_end("helper"),
- "text": "def helper():\\n return 42",
- }])
- """
-
- def __init__(
- self,
- file_path: str,
- coder: Any,
- region_specs: list[dict[str, str]],
- exact: bool = True,
- ) -> None:
- self._exact = exact
- self._file_path = file_path
- self._coder = coder
- self._specs: dict[str, dict[str, str]] = {}
-
- for spec in region_specs:
- name = spec["name"]
- entry: dict[str, object] = {
- "start": spec["start"],
- "end": spec["end"],
- }
- if "start_line_hint" in spec:
- entry["start_line_hint"] = spec["start_line_hint"]
- if "end_line_hint" in spec:
- entry["end_line_hint"] = spec["end_line_hint"]
- self._specs[name] = entry
-
- # Eagerly validate uniqueness for all regions at creation time.
- # This catches ambiguous patterns immediately with clear error messages.
- self._eager_validate()
-
- # ------------------------------------------------------------------
- # Public API
- # ------------------------------------------------------------------
-
- def get_start(self, name: str) -> str:
- """Re-read file and return the current content ID for the start of *name*."""
-
- return self._resolve(name)[0]
-
- def get_end(self, name: str) -> str:
- """Re-read file and return the current content ID for the end of *name*."""
-
- return self._resolve(name)[1]
-
- def get_start_line(self, name: str) -> int:
- """Re-read file and return the current 1-based start line for *name*."""
-
- return self._resolve(name)[2]
-
- def get_end_line(self, name: str) -> int:
- """Re-read file and return the current 1-based end line for *name*."""
-
- return self._resolve(name)[3]
-
- def __contains__(self, name: str) -> bool:
- return name in self._specs
-
- def __len__(self) -> int:
- return len(self._specs)
-
- def names(self) -> list[str]:
- """Return the list of region names."""
-
- return sorted(self._specs.keys())
-
- def get(self, name: str) -> dict[str, object]:
- """Return ``{"start": ..., "end": ..., "start_line": N, "end_line": N}`` for *name*.
-
- The returned dict can be passed directly as the ``region`` value
- in ``Agent.edit_region()`` edits. ``start_line`` / ``end_line`` are
- 1-based for readability and enable adjacent-edit detection.
- """
-
- start_id, end_id, start_line, end_line = self._resolve(name)
- return {
- "start": start_id,
- "end": end_id,
- "start_line": start_line + 1,
- "end_line": end_line + 1,
- }
-
- def __repr__(self) -> str:
- names = ", ".join(sorted(self._specs.keys()))
- return f"AgentRegion({len(self._specs)} regions on " f"{self._file_path!r}: {names})"
-
- # ------------------------------------------------------------------
- # Resolution internals
- # ------------------------------------------------------------------
-
- def _eager_validate(self) -> None:
- """Eagerly resolve and validate all region patterns at init time.
-
- Raises ValueError immediately for ambiguous patterns so the LLM
- gets clear feedback without waiting for the first access.
- """
-
- for name in list(self._specs.keys()):
- self._resolve(name)
-
- def _resolve(self, name: str) -> tuple[str, str, int, int]:
- """
- Re-read file and resolve *name* to
- (start_id, end_id, start_line, end_line).
-
- When a pattern is a content ID the referenced line is snapshotted
- so future resolutions can fall back to content matching if the
- original ID goes stale.
- """
-
- import os
-
- from cecli.helpers.hashline import (
- ContentHashError,
- normalize_hashline,
- resolve_content_to_hashline_ids,
- )
- from cecli.helpers.hashpos.hashpos import HashPos
- from cecli.tools.utils.helpers import resolve_paths
-
- spec = self._specs[name]
-
- # Read explicit line hints from spec (preferred over @L in patterns).
- # 1-based in the spec, converted to 0-based internally.
- explicit_start = spec.get("start_line_hint")
- explicit_end = spec.get("end_line_hint")
-
- abs_path, rel_path = resolve_paths(self._coder, self._file_path)
-
- if not os.path.isfile(abs_path):
- raise ValueError(f"File not found: {self._file_path}")
-
- content = self._coder.io.read_text(abs_path)
-
- if content is None:
- raise ValueError(f"Could not read file: {self._file_path}")
-
- lines = content.splitlines()
- hp = HashPos(content)
-
- start_pattern = self._resolve_pattern(
- hp, lines, spec, "start", normalize_hashline, ContentHashError
- )
- end_pattern = self._resolve_pattern(
- hp, lines, spec, "end", normalize_hashline, ContentHashError
- )
-
- # Always strip @L hints from patterns — they are metadata, not literal text.
- # Explicit hints (start_line_hint / end_line_hint) override any @L in patterns.
- start_pattern, extracted_start, start_hint_type = self._extract_l_hint(start_pattern, lines)
- end_pattern, extracted_end, end_hint_type = self._extract_l_hint(end_pattern, lines)
-
- # Handle explicit hints (start_line_hint / end_line_hint)
- # Integers are treated as @L (1-based line numbers).
- # Strings support the full @L, @A, @B syntax (same as ReadFile).
- if explicit_start is not None:
- if isinstance(explicit_start, str):
- # String hint — parse through _extract_l_hint
- _, start_hint, start_hint_type = self._extract_l_hint(explicit_start, lines)
- if start_hint is None:
- # String hint like "@L394" may fail because the regex
- # requires the @L to be preceded by whitespace or be at
- # the start of the string. Guide the user to use an
- # integer hint instead.
- raise ValueError(
- f"start_line_hint '{explicit_start}' for region "
- f"'{name}' could not be resolved. "
- f"Use an integer line number instead: "
- f"start_line_hint={explicit_start.replace('@L', '')}"
- )
- else:
- # Integer hint — treat as @L (1-based, converted to 0-based)
- start_hint = explicit_start - 1
- start_hint_type = "L"
- else:
- start_hint = extracted_start
-
- if explicit_end is not None:
- if isinstance(explicit_end, str):
- _, end_hint, end_hint_type = self._extract_l_hint(explicit_end, lines)
- if end_hint is None:
- # String hint like "@L437" may fail because the regex
- # requires @L to be preceded by whitespace or be at
- # the start of the string. Guide the user to use an
- # integer hint instead.
- raise ValueError(
- f"end_line_hint '{explicit_end}' for region "
- f"'{name}' could not be resolved. "
- f"Use an integer line number instead: "
- f"end_line_hint={explicit_end.replace('@L', '')}"
- )
- else:
- end_hint = explicit_end - 1
- end_hint_type = "L"
- else:
- end_hint = extracted_end
-
- # Strip hashline prefixes from text patterns — the LLM may have copied
- # content-ID-prefixed lines from a ReadFile response (e.g. —XYZ12—text).
- # Content-ID patterns and special markers are left untouched.
- if not self._looks_like_content_id(start_pattern) and start_pattern not in ("@000", "000@"):
- start_pattern = HashPos.strip_prefix(start_pattern)
- if not self._looks_like_content_id(end_pattern) and end_pattern not in ("@000", "000@"):
- end_pattern = HashPos.strip_prefix(end_pattern)
-
- # Validate uniqueness for text-based patterns (not content IDs or special markers).
- self._validate_pattern_uniqueness(
- start_pattern, start_hint, "start", name, lines, start_hint_type
- )
- self._validate_pattern_uniqueness(end_pattern, end_hint, "end", name, lines, end_hint_type)
-
- start_id, end_id = resolve_content_to_hashline_ids(
- content,
- start_pattern,
- end_pattern,
- start_hint_line=start_hint if start_hint_type == "L" else None,
- exact=self._exact,
- )
-
- # Resolve line numbers from content IDs
- def _line_from_id(content_id: str, default_if_not_found: int) -> int:
- if content_id == "@000":
- return 1
-
- if content_id == "000@":
- return len(lines)
-
- try:
- normalized = normalize_hashline(content_id)
- candidates = hp.resolve_to_lines(normalized)
-
- if candidates:
- return candidates[0] + 1
- except (ContentHashError, ValueError):
- pass
-
- return default_if_not_found
-
- start_line = _line_from_id(start_id, -1)
- end_line = _line_from_id(end_id, -1)
-
- if start_line < 0 or end_line < 0:
- parts = []
- if start_line >= 0:
- parts.append(f" Start pattern resolved to: {start_pattern!r} (line {start_line})")
- else:
- parts.append(f" Start pattern NOT FOUND: {start_pattern!r}")
- if end_line >= 0:
- parts.append(f" End pattern resolved to: {end_pattern!r} (line {end_line})")
- else:
- parts.append(f" End pattern NOT FOUND: {end_pattern!r}")
- raise ValueError(
- f"Could not resolve line numbers for region "
- f"'{name}' in {self._file_path}\n" + "\n".join(parts)
- )
-
- return start_id, end_id, start_line, end_line
-
- # ------------------------------------------------------------------
- # Helpers
- # ------------------------------------------------------------------
-
- @staticmethod
- def _search_in_lines(lines: list[str], pattern: str) -> list[int]:
- """Return 0-based indices of all lines where *pattern* matches.
-
- Supports multiline patterns (each line of *pattern* must appear
- as a whole word in the corresponding line of *lines*).
-
- Delegates to the core ``search_in_lines`` in
- ``cecli.helpers.hashpos.transformations`` for unified matching.
- """
-
- return search_in_lines(lines, pattern)
-
- @staticmethod
- def _extract_l_hint(
- pattern: str, lines: list[str] | None = None
- ) -> tuple[str, int | None, str | None]:
- """Extract a hint suffix from a pattern string.
-
- Supports @L (direct line number), @A{{regex}} (filter to matches AFTER
- the first regex match), and @B{{regex}} (filter to matches BEFORE the last
- regex match) hints.
-
- Returns (stripped_pattern, hint_value, hint_type) where:
- - hint_value is a 0-based line number or None
- - hint_type is 'L', 'A', 'B', or None
- """
-
- return extract_hint(pattern, lines)
-
- @staticmethod
- def _narrow_by_proximity(indices: list[int], target: int, max_results: int = 5) -> list[int]:
- """Return up to *max_results* indices closest to *target* (0-based)."""
-
- return narrow_by_proximity(indices, target, max_results=max_results)
-
- def _validate_pattern_uniqueness(
- self,
- pattern: str,
- hint: int | None,
- boundary: str,
- name: str,
- lines: list[str],
- hint_type: str | None = None,
- ) -> None:
- """Raise ValueError if *pattern* matches multiple locations.
-
- Only validates text patterns — content IDs and special markers
- (@000 / 000@) are inherently unique and are skipped.
- """
-
- if self._looks_like_content_id(pattern):
- return
- if pattern in ("@000", "000@"):
- return
-
- matches = self._search_in_lines(lines, pattern)
-
- # Apply @A/@B directional filtering — keep only closest match in direction
- if hint_type == "A" and hint is not None:
- after = [m for m in matches if m > hint]
- matches = [min(after)] if after else []
- elif hint_type == "B" and hint is not None:
- before = [m for m in matches if m < hint]
- matches = [max(before)] if before else []
-
- if len(matches) <= 1:
- return
-
- # Try proximity narrowing — find the unique closest match (only for @L hints)
- if hint is not None and hint_type == "L":
- best = min(matches, key=lambda i: abs(i - hint))
- best_dist = abs(best - hint)
- conflicts = [i for i in matches if i != best and abs(i - hint) == best_dist]
- if not conflicts:
- return # Unique closest match found
- matches_for_display = [best] + conflicts
- else:
- matches_for_display = matches
-
- # Build detailed match display with line content
- max_preview = 10
- sorted_matches = sorted(matches_for_display)
- all_line_nums = [str(i + 1) for i in sorted_matches]
- line_nums_display = ", ".join(all_line_nums)
- if len(all_line_nums) > 50:
- line_nums_display = (
- ", ".join(all_line_nums[:50]) + f", ... ({len(all_line_nums)} total)"
- )
-
- # Only show content previews for the first 10 matches
- match_lines = []
- for i in sorted_matches[:max_preview]:
- line_content = lines[i]
- if len(line_content) > 120:
- line_content = line_content[:117] + "..."
- match_lines.append(f" @L{i + 1}: {line_content}")
- match_details = "\n".join(match_lines)
- if len(sorted_matches) > max_preview:
- match_details += f"\n ... and {len(sorted_matches) - max_preview} more match(es)"
-
- if hint is not None:
- raise ValueError(
- f"{boundary.capitalize()} pattern '{pattern}' for region "
- f"'{name}' has {len(matches)} matches (lines {line_nums_display}); "
- f"@L{hint + 1} hint ties between "
- f"{len(matches_for_display)} equally-close locations:\n"
- f"{match_details}\n"
- f"Use a more specific pattern."
- )
-
- raise ValueError(
- f"{boundary.capitalize()} pattern '{pattern}' for region "
- f"'{name}' matches {len(matches)} locations (lines {line_nums_display}):\n"
- f"{match_details}\n"
- f"Use a more specific pattern or append ' @L' to "
- f"disambiguate (e.g., '{pattern} @L{all_line_nums[0]}')."
- )
-
- @staticmethod
- def _looks_like_content_id(value: str) -> bool:
- """Return True if *value* appears to be a content ID rather than text."""
-
- return is_content_id(value)
-
- def _resolve_pattern(
- self,
- hp,
- lines: list[str],
- spec: dict[str, str],
- key: str,
- normalize_hashline,
- ContentHashError,
- ) -> str:
- """
- Resolve a single boundary pattern.
-
- Content-ID patterns have their referenced line content snapshotted
- so stale IDs can be recovered via content matching.
- """
-
- pattern = spec[key]
-
- # Special markers never go stale
- if pattern in ("@000", "000@"):
- return pattern
-
- # @L{num} notation — resolve via shared utility
- import re as _re
-
- _m = _re.match(r"^@L(\d+)$", pattern)
- if _m:
- return resolve_at_l(pattern, hp, lines)
-
- if not self._looks_like_content_id(pattern):
- return pattern
-
- # Content ID — try to resolve and snapshot the line content
- content_key = f"_{key}_content"
-
- try:
- normalized = normalize_hashline(pattern)
- candidates = hp.resolve_to_lines(normalized)
-
- if candidates and candidates[0] < len(lines):
- spec[content_key] = lines[candidates[0]]
-
- return pattern
- except (ContentHashError, ValueError):
- pass
-
- # Content ID may be stale — fall back to snapshotted line content
- cached = spec.get(content_key)
-
- if cached:
- return cached
-
- # No fallback available; return the (stale) ID and let the
- # caller's resolution logic handle the error
- return pattern
diff --git a/cecli/helpers/sessions/manager.py b/cecli/helpers/sessions/manager.py
index 974baacdf31..bfe69dc8c8b 100644
--- a/cecli/helpers/sessions/manager.py
+++ b/cecli/helpers/sessions/manager.py
@@ -269,10 +269,12 @@ async def _reload_sub_agent(self, service, sub_file: Path) -> None:
return
independent = bool(sub_data.get("independent", True))
+ spawn_kwargs = {"auto_reap": False, "independent": independent}
+ resume_worktree = sub_data.get("worktree")
+ if resume_worktree is not None:
+ spawn_kwargs["resume_worktree"] = resume_worktree
- new_coder, _info = await service.spawn(
- name, parent=self.coder, auto_reap=False, independent=independent
- )
+ new_coder, _info = await service.spawn(name, parent=self.coder, **spawn_kwargs)
sub_manager = SessionManager(new_coder, self.io)
applied, _edit_format = await sub_manager._apply_session_data(
diff --git a/cecli/helpers/sessions/payload.py b/cecli/helpers/sessions/payload.py
index 7d08d9e9df1..f5e12cc921f 100644
--- a/cecli/helpers/sessions/payload.py
+++ b/cecli/helpers/sessions/payload.py
@@ -41,6 +41,7 @@ def build_payload(coder, io, session_name: str, agent_name: Optional[str] = None
"session_name": session_name,
"agent_name": agent_name,
"agent_root": str(coder.root) if agent_name and agent_name.startswith("ws:") else None,
+ "worktree": _worktree_data(coder),
"independent": independent,
"status": status,
"model": coder.main_model.name,
@@ -261,6 +262,20 @@ def _agent_config_data(coder) -> Optional[Dict]:
}
+def _worktree_data(coder) -> Optional[Dict]:
+ """Return a JSON-serializable worktree dict, or ``None`` when absent."""
+ record = getattr(coder, "worktree", None)
+ if record is None or not hasattr(record, "to_dict"):
+ return None
+
+ try:
+ data = record.to_dict()
+ except Exception:
+ return None
+
+ return data if isinstance(data, dict) else None
+
+
def _restore_files(coder, io, target: set, rel_fnames: list) -> None:
"""Add every existing ``rel_fnames`` entry to ``target``."""
for rel_fname in rel_fnames:
diff --git a/cecli/helpers/skills.py b/cecli/helpers/skills.py
index 84e632c9e32..f20c722b02a 100644
--- a/cecli/helpers/skills.py
+++ b/cecli/helpers/skills.py
@@ -347,7 +347,7 @@ def _parse_skill_metadata(self, skill_md_path: Path) -> SkillMetadata:
description=description,
path=skill_md_path.parent,
license=frontmatter.get("license"),
- allowed_tools=frontmatter.get("allowed-tools", []),
+ allowed_tools=_normalize_allowed_tools(frontmatter.get("allowed-tools")),
metadata=frontmatter.get("metadata", {}),
)
@@ -1092,3 +1092,58 @@ def get_skills_context(self) -> Optional[str]:
# We can't use io.tool_error here since we don't have access to io
# The caller should handle the exception
raise
+
+
+def _normalize_allowed_tools(value: Any) -> List[str]:
+ """Normalize an ``allowed-tools`` frontmatter value into a list of strings.
+
+ YAML permits ``allowed-tools`` to be either a sequence or a single scalar;
+ the latter is split on top-level commas so that constructs such as
+ ``Bash(git commit -m "a, b"), Read`` are not broken apart. Missing values
+ become an empty list. Raises ``ValueError`` for unsupported types.
+ """
+ if value is None:
+ return []
+
+ if isinstance(value, str):
+ return _split_top_level_commas(value)
+
+ if not isinstance(value, (list, tuple)):
+ raise ValueError(
+ "'allowed-tools' must be a string or a list of strings, " f"got {type(value).__name__}"
+ )
+
+ tools: List[str] = []
+
+ for item in value:
+ if not isinstance(item, str):
+ raise ValueError(f"'allowed-tools' entries must be strings, got {type(item).__name__}")
+
+ tool = item.strip()
+ if tool:
+ tools.append(tool)
+
+ return tools
+
+
+def _split_top_level_commas(value: str) -> List[str]:
+ """Split ``value`` on commas that are not nested inside parentheses."""
+ parts: List[str] = []
+ current: List[str] = []
+ depth = 0
+
+ for char in value:
+ if char == "(":
+ depth += 1
+ elif char == ")" and depth > 0:
+ depth -= 1
+ elif char == "," and depth == 0:
+ parts.append("".join(current))
+ current = []
+ continue
+
+ current.append(char)
+
+ parts.append("".join(current))
+
+ return [part.strip() for part in parts if part.strip()]
diff --git a/cecli/helpers/workflows/config.py b/cecli/helpers/workflows/config.py
index b6c04f6dc5f..4056816475e 100644
--- a/cecli/helpers/workflows/config.py
+++ b/cecli/helpers/workflows/config.py
@@ -163,6 +163,29 @@ def validate_workflow_config(wf: WorkflowConfig, raw: Optional[Dict[str, Any]] =
if norm.get("persist") is not None and not isinstance(norm["persist"], bool):
warnings.append("'persist' is not a boolean")
+ if wf.repo is not None and not isinstance(wf.repo, str):
+ raise ValueError(f"{where}: 'repo' must be a string")
+
+ if wf.worktree is not None:
+ if not isinstance(wf.worktree, dict):
+ raise ValueError(f"{where}: 'worktree' must be a boolean or a mapping")
+
+ unknown = sorted(set(wf.worktree) - {"branch"})
+ if unknown:
+ warnings.append(f"unknown 'worktree' key(s) ignored: {', '.join(unknown)}")
+
+ branch = wf.worktree.get("branch")
+ if branch is not None and not isinstance(branch, str):
+ raise ValueError(f"{where}: 'worktree.branch' must be a string")
+
+ from .subagents import _worktree_source
+
+ if _worktree_source(wf) is None:
+ warnings.append(
+ "'worktree' is enabled but no source repository could be inferred; "
+ "the dispatching agent's root will be used"
+ )
+
wf.lease_timeout = _positive_number(wf.lease_timeout, "lease_timeout", where, default=30.0)
_check_schedule(wf.schedule, where)
diff --git a/cecli/helpers/workflows/logs.py b/cecli/helpers/workflows/logs.py
index 4d246b49c91..0446f4ca2ce 100644
--- a/cecli/helpers/workflows/logs.py
+++ b/cecli/helpers/workflows/logs.py
@@ -86,6 +86,7 @@ def finish_log(handle: Optional[LogHandle], result) -> None:
"summary": getattr(result, "summary", ""),
"error_message": getattr(result, "error_message", ""),
"run_id": getattr(result, "run_id", None),
+ "worktree": getattr(result, "worktree", None),
},
)
except Exception:
diff --git a/cecli/helpers/workflows/models.py b/cecli/helpers/workflows/models.py
index 5a0f02fca00..59ebb6445e5 100644
--- a/cecli/helpers/workflows/models.py
+++ b/cecli/helpers/workflows/models.py
@@ -55,6 +55,8 @@ class WorkflowConfig:
lease_timeout: float = 30.0 # minutes
model: Optional[str] = None
root: Optional[str] = None
+ repo: Optional[str] = None
+ worktree: Optional[Dict[str, Any]] = None
hooks: Dict[str, Any] = field(default_factory=dict)
skills: List[str] = field(default_factory=list)
servers: List[str] = field(default_factory=list)
@@ -64,13 +66,22 @@ class WorkflowConfig:
catch_up: Optional[bool] = None # None: use the scheduler default
source_path: Optional[str] = None
+ def __post_init__(self) -> None:
+ """Normalize the worktree field for programmatic construction."""
+ if self.worktree is True:
+ self.worktree = {}
+ elif self.worktree is False:
+ self.worktree = None
+ elif isinstance(self.worktree, dict):
+ self.worktree = dict(self.worktree)
+
@property
def agent_name(self) -> str:
return f"wf:{self.name}"
def to_dict(self) -> Dict[str, Any]:
"""Canonical dict form used for hashing and ``/workflows show``."""
- return {
+ data = {
"name": self.name,
"description": self.description,
"schedule": self.schedule,
@@ -89,6 +100,14 @@ def to_dict(self) -> Dict[str, Any]:
"tasks": [t.to_dict() for t in self.tasks],
}
+ if self.repo is not None:
+ data["repo"] = self.repo
+
+ if self.worktree is not None:
+ data["worktree"] = self.worktree
+
+ return data
+
def definition_hash(self) -> str:
"""sha256 of the normalized definition (key-order independent)."""
canonical = json.dumps(self.to_dict(), sort_keys=True, separators=(",", ":"), default=str)
@@ -136,6 +155,10 @@ def from_dict(cls, raw: Dict[str, Any], source_path: Optional[str] = None) -> "W
)
hooks = data.get("hooks") or {}
+ worktree = _normalize_worktree(data.get("worktree"))
+ if isinstance(worktree, dict) and isinstance(worktree.get("branch"), str):
+ worktree["branch"] = interpolate_env_vars(worktree["branch"])
+
return cls(
name=str(data.get("name") or ""),
tasks=tasks,
@@ -156,6 +179,12 @@ def from_dict(cls, raw: Dict[str, Any], source_path: Optional[str] = None) -> "W
if data.get("root") is not None
else None
),
+ repo=(
+ _interp(interpolate_env_vars, data["repo"])
+ if data.get("repo") is not None
+ else None
+ ),
+ worktree=worktree,
hooks=hooks if isinstance(hooks, dict) else {},
skills=[_interp(interpolate_env_vars, s) for s in data.get("skills") or []],
servers=[_interp(interpolate_env_vars, s) for s in data.get("servers") or []],
@@ -199,6 +228,23 @@ class RunResult:
tasks: List[TaskResult] = field(default_factory=list)
run_id: Optional[int] = None
log_path: Optional[str] = None
+ worktree: Optional[Dict[str, Any]] = None
+
+ def to_dict(self) -> Dict[str, Any]:
+ """Serialize the run outcome, including the worker's worktree record."""
+ return {
+ "name": self.name,
+ "status": self.status,
+ "source": self.source,
+ "summary": self.summary,
+ "error_message": self.error_message,
+ "started_at": self.started_at,
+ "finished_at": self.finished_at,
+ "tasks": [t.to_dict() for t in self.tasks],
+ "run_id": self.run_id,
+ "log_path": self.log_path,
+ "worktree": self.worktree,
+ }
@classmethod
def error(cls, exc: BaseException, name: str = "", source: str = "scheduler") -> "RunResult":
@@ -207,6 +253,25 @@ def error(cls, exc: BaseException, name: str = "", source: str = "scheduler") ->
)
+def _normalize_worktree(value: Any) -> Any:
+ """Normalize the ``worktree`` field: true -> {}, false/None -> None, mapping -> dict.
+
+ Other values are left untouched so validation can reject them with a useful
+ error.
+ """
+
+ if value is None or value is False:
+ return None
+
+ if value is True:
+ return {}
+
+ if isinstance(value, dict):
+ return dict(value)
+
+ return value
+
+
def _as_bool(value: Any, default: bool = False) -> bool:
"""Coerce a config value to a boolean without treating "false" as truthy."""
diff --git a/cecli/helpers/workflows/runner.py b/cecli/helpers/workflows/runner.py
index 6bcb5599d59..9f0b47574b8 100644
--- a/cecli/helpers/workflows/runner.py
+++ b/cecli/helpers/workflows/runner.py
@@ -112,6 +112,11 @@ async def execute_workflow(
result.error_message = str(exc) or exc.__class__.__name__
finally:
if worker is not None:
+ record = getattr(worker, "worktree", None)
+ if record is not None:
+ to_dict = getattr(record, "to_dict", None)
+ result.worktree = to_dict() if callable(to_dict) else record
+
_save_session(worker, coder, wf, result)
if not wf.persist:
_reap_worker(service, worker)
diff --git a/cecli/helpers/workflows/subagents.py b/cecli/helpers/workflows/subagents.py
index 09e82dd6132..43a338714d8 100644
--- a/cecli/helpers/workflows/subagents.py
+++ b/cecli/helpers/workflows/subagents.py
@@ -16,8 +16,9 @@
from __future__ import annotations
import logging
+import subprocess
from pathlib import Path
-from typing import Any, Dict, List
+from typing import Any, Dict, List, Optional
logger = logging.getLogger(__name__)
@@ -110,6 +111,19 @@ def register_workflow_subagents(workflow_configs) -> List[str]:
if wf.root:
metadata["root"] = str(Path(wf.root).expanduser().resolve())
+ if wf.worktree is not None:
+ worktree = wf.worktree if isinstance(wf.worktree, dict) else {}
+ metadata["worktree"] = dict(worktree)
+
+ source = _worktree_source(wf)
+ if source:
+ metadata["worktree_source"] = source
+ else:
+ logger.warning(
+ "Workflow '%s' enables worktree but no source repository could be inferred",
+ wf.name,
+ )
+
metadata["agent-config"] = agent_config
metadata["description"] = f"Workflow sub-agent for '{wf.name}'"
# ``allow_nested_delegation`` is intentionally NOT forced here: the
@@ -176,3 +190,45 @@ def ensure_wf_agent_registered(name: str) -> Any:
def get_workflow_configs() -> List[Any]:
"""Return the cached WorkflowConfigs from the most recent registrations."""
return list(_workflow_configs_by_agent.values())
+
+
+def _worktree_source(wf: Any) -> Optional[str]:
+ """Resolve the worktree source repository for *wf*.
+
+ Prefer an explicit ``repo``; otherwise use the normalized ``root``; as a
+ last resort use the git top-level of the workflow definition file's
+ directory. Returns None when nothing can be inferred.
+ """
+
+ if wf.repo:
+ return str(wf.repo)
+
+ if wf.root:
+ return str(Path(wf.root).expanduser().resolve())
+
+ return _git_toplevel(wf.source_path)
+
+
+def _git_toplevel(source_path: Optional[str]) -> Optional[str]:
+ """Return the git top-level containing *source_path*, if any."""
+
+ if not source_path:
+ return None
+
+ directory = Path(source_path).expanduser().resolve().parent
+
+ try:
+ completed = subprocess.run(
+ ["git", "-C", str(directory), "rev-parse", "--show-toplevel"],
+ capture_output=True,
+ text=True,
+ timeout=5,
+ check=False,
+ )
+ except (OSError, subprocess.SubprocessError):
+ return None
+
+ if completed.returncode != 0:
+ return None
+
+ return completed.stdout.strip() or None
diff --git a/cecli/helpers/worktrees/__init__.py b/cecli/helpers/worktrees/__init__.py
new file mode 100644
index 00000000000..b5cf9cc4e7c
--- /dev/null
+++ b/cecli/helpers/worktrees/__init__.py
@@ -0,0 +1,19 @@
+"""Git worktree isolation helpers for agent workspaces.
+
+Public entry points live in :mod:`cecli.helpers.worktrees.manager`; this
+package re-exports them for convenient imports.
+"""
+
+from cecli.helpers.worktrees.git import WorktreeError
+from cecli.helpers.worktrees.manager import (
+ WorktreeManager,
+ WorktreeRecord,
+ WorktreeReleaseResult,
+)
+
+__all__ = [
+ "WorktreeError",
+ "WorktreeManager",
+ "WorktreeRecord",
+ "WorktreeReleaseResult",
+]
diff --git a/cecli/helpers/worktrees/git.py b/cecli/helpers/worktrees/git.py
new file mode 100644
index 00000000000..924df7fe10f
--- /dev/null
+++ b/cecli/helpers/worktrees/git.py
@@ -0,0 +1,204 @@
+"""Thin subprocess wrappers around the git CLI.
+
+Every wrapper captures output, disables the pager and interactive credential
+prompts, and reports fatal failures as :class:`WorktreeError` carrying git's
+stderr. Read-only probes accept ``check=False`` so callers can treat a
+non-zero exit code as a falsy answer instead of an exception.
+"""
+
+from __future__ import annotations
+
+import os
+import subprocess
+from pathlib import Path
+
+
+class WorktreeError(RuntimeError):
+ """Raised when a worktree operation or git invocation cannot complete."""
+
+
+def run_git(
+ *args: str,
+ cwd: str | Path | None = None,
+ check: bool = True,
+ timeout: int = 120,
+ env: dict[str, str] | None = None,
+) -> subprocess.CompletedProcess:
+ """Run ``git`` with *args* and capture its output.
+
+ ``GIT_PAGER=cat`` keeps output machine-readable and
+ ``GIT_TERMINAL_PROMPT=0`` prevents a hung process waiting on credentials.
+ When *check* is true a non-zero exit code raises :class:`WorktreeError`
+ with git's stderr attached.
+ """
+ command = ["git", *(_as_text(arg) for arg in args)]
+ full_env = os.environ.copy()
+ full_env["GIT_PAGER"] = "cat"
+ full_env.setdefault("GIT_TERMINAL_PROMPT", "0")
+
+ if env:
+ full_env.update(env)
+
+ try:
+ result = subprocess.run(
+ command,
+ cwd=_as_text(cwd) if cwd is not None else None,
+ capture_output=True,
+ text=True,
+ encoding="utf-8",
+ errors="replace",
+ env=full_env,
+ timeout=timeout,
+ )
+ except FileNotFoundError as exc:
+ raise WorktreeError("git executable not found") from exc
+ except OSError as exc:
+ raise WorktreeError(f"failed to run {' '.join(command)}: {exc}") from exc
+ except subprocess.TimeoutExpired as exc:
+ raise WorktreeError(f"git command timed out: {' '.join(command)}") from exc
+
+ if check and result.returncode != 0:
+ raise WorktreeError(_format_error(command, result))
+
+ return result
+
+
+def rev_parse(ref: str, cwd: str | Path | None = None, *, check: bool = True) -> str | None:
+ """Resolve *ref* to a commit SHA, or return ``None`` when it is unknown."""
+ result = run_git("rev-parse", "--verify", ref, cwd=cwd, check=False)
+
+ if result.returncode != 0:
+ if check:
+ raise WorktreeError(_format_error(["git", "rev-parse", "--verify", ref], result))
+
+ return None
+
+ value = result.stdout.strip()
+
+ return value or None
+
+
+def git_root(path: str | Path) -> str | None:
+ """Return the absolute top-level of the worktree containing *path*.
+
+ A path pointing directly at a ``.git`` file (as used by linked worktrees)
+ is resolved through its parent. Bare repositories are returned as-is.
+ """
+ target = Path(path).expanduser()
+
+ if target.is_file():
+ if target.name != ".git":
+ return None
+
+ target = target.parent
+
+ result = run_git("rev-parse", "--show-toplevel", cwd=target, check=False)
+
+ if result.returncode == 0:
+ value = result.stdout.strip()
+
+ if value:
+ return str(Path(value).resolve())
+
+ if is_bare_repo(target):
+ return str(target.resolve())
+
+ return None
+
+
+def is_git_repo(path: str | Path) -> bool:
+ """Return whether *path* is inside a git repository (bare included)."""
+ result = run_git("rev-parse", "--git-dir", cwd=path, check=False)
+
+ return result.returncode == 0 and bool(result.stdout.strip())
+
+
+def is_bare_repo(path: str | Path) -> bool:
+ """Return whether *path* is a bare git repository."""
+ result = run_git("rev-parse", "--is-bare-repository", cwd=path, check=False)
+
+ return result.returncode == 0 and result.stdout.strip() == "true"
+
+
+def default_branch(cwd: str | Path | None = None) -> str | None:
+ """Best-effort default base branch, preferring ``origin/HEAD``.
+
+ Falls back to local ``main``, then ``master``, then the currently checked
+ out branch. Returns ``None`` for an empty repository with no commits.
+ """
+ result = run_git(
+ "symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD", cwd=cwd, check=False
+ )
+
+ if result.returncode == 0 and result.stdout.strip():
+ return result.stdout.strip()
+
+ for candidate in ("main", "master"):
+ if rev_parse(candidate, cwd=cwd, check=False):
+ return candidate
+
+ return current_branch(cwd)
+
+
+def current_branch(cwd: str | Path | None = None) -> str | None:
+ """Return the short name of the checked out branch, if any."""
+ result = run_git("rev-parse", "--abbrev-ref", "HEAD", cwd=cwd, check=False)
+
+ if result.returncode != 0:
+ return None
+
+ name = result.stdout.strip()
+
+ return name if name and name != "HEAD" else None
+
+
+def status_porcelain(cwd: str | Path) -> str:
+ """Return V1 porcelain status, listing untracked files individually."""
+ result = run_git("status", "--porcelain=v1", "--untracked-files=all", cwd=cwd, check=False)
+
+ return result.stdout
+
+
+def branch_exists(branch: str, cwd: str | Path | None = None) -> bool:
+ """Return whether a local branch named *branch* exists."""
+ name = branch.removeprefix("refs/heads/")
+ result = run_git("show-ref", "--verify", "--quiet", f"refs/heads/{name}", cwd=cwd, check=False)
+
+ return result.returncode == 0
+
+
+def branch_merged(branch: str, base: str, cwd: str | Path | None = None) -> bool:
+ """Return whether *branch* is an ancestor of (fully merged into) *base*."""
+ if not branch or not base:
+ return False
+
+ result = run_git("merge-base", "--is-ancestor", branch, base, cwd=cwd, check=False)
+
+ return result.returncode == 0
+
+
+def ahead_count(base_sha: str, branch: str, cwd: str | Path | None = None) -> int:
+ """Return the number of commits on *branch* not reachable from *base_sha*."""
+ if not base_sha or not branch:
+ return 0
+
+ result = run_git("rev-list", "--count", f"{base_sha}..{branch}", cwd=cwd, check=False)
+
+ if result.returncode != 0:
+ return 0
+
+ try:
+ return int(result.stdout.strip())
+ except ValueError:
+ return 0
+
+
+def _format_error(command: list[str], result: subprocess.CompletedProcess) -> str:
+ detail = (result.stderr or result.stdout or "").strip()
+ prefix = f"git command failed (exit {result.returncode}): {' '.join(command)}"
+
+ return f"{prefix}: {detail}" if detail else prefix
+
+
+def _as_text(value: object) -> str:
+ return str(value)
diff --git a/cecli/helpers/worktrees/manager.py b/cecli/helpers/worktrees/manager.py
new file mode 100644
index 00000000000..e0372ebdf13
--- /dev/null
+++ b/cecli/helpers/worktrees/manager.py
@@ -0,0 +1,980 @@
+"""Lifecycle management for isolated agent git worktrees.
+
+A worktree is created from a local repository or a cached bare clone of a
+remote. Each worktree gets its own generated branch and lives under
+``~/.cecli/worktrees`` (see :mod:`cecli.helpers.worktrees.paths`). Records are
+tracked in an atomically-written JSON registry so they can be recreated or
+released later, even from a different process.
+
+Releasing a worktree deliberately commits any uncommitted work first
+("commit-on-release"), then removes the worktree. Only branches with no
+commits ahead of their base -- or branches fully merged into their base -- are
+deleted; otherwise the branch is kept so the work is not lost. Ignored files
+are intentionally discarded by ``git worktree remove --force``.
+"""
+
+from __future__ import annotations
+
+import json
+import os
+import re
+import shutil
+import socket
+import tempfile
+import threading
+from contextlib import contextmanager
+from dataclasses import asdict, dataclass, fields
+from datetime import datetime, timezone
+from pathlib import Path
+from typing import Iterator
+
+from cecli.helpers.worktrees import paths
+from cecli.helpers.worktrees.git import (
+ WorktreeError,
+ ahead_count,
+ branch_exists,
+ branch_merged,
+ current_branch,
+ default_branch,
+ git_root,
+ is_git_repo,
+ rev_parse,
+ run_git,
+ status_porcelain,
+)
+
+__all__ = [
+ "WorktreeError",
+ "WorktreeManager",
+ "WorktreeRecord",
+ "WorktreeReleaseResult",
+]
+
+_SCHEME_URL = re.compile(r"^[A-Za-z][A-Za-z0-9+.-]*://")
+_SCP_URL = re.compile(r"^[^/@\s]+@[^:\s]+:.+")
+
+
+@dataclass(frozen=True)
+class WorktreeRecord:
+ """A managed worktree and the metadata needed to recreate or release it."""
+
+ source: str
+ repo: str
+ path: str
+ branch: str
+ base_ref: str
+ base_sha: str
+ agent: str
+ uuid: str
+ pid: int
+ host: str
+ created_at: str
+
+ def to_dict(self) -> dict:
+ """Return a JSON-serializable representation of the record."""
+ return asdict(self)
+
+ @classmethod
+ def from_dict(cls, data: dict) -> "WorktreeRecord":
+ """Build a record from a mapping, ignoring unknown keys."""
+ known = {field.name for field in fields(cls)}
+ values = {name: data[name] for name in known if name in data}
+
+ return cls(**values)
+
+
+@dataclass(frozen=True)
+class WorktreeReleaseResult:
+ """Outcome of releasing a single worktree."""
+
+ uuid: str
+ branch: str
+ committed: bool = False
+ removed: bool = False
+ branch_kept: bool = False
+ branch_deleted: bool = False
+ reason: str = ""
+
+ def to_dict(self) -> dict:
+ """Return a JSON-serializable representation of the result."""
+ return asdict(self)
+
+
+class WorktreeManager:
+ """Create, recreate, release, and sweep agent worktrees."""
+
+ BASE: Path = paths.managed_root()
+ REGISTRY_FILENAME = "registry.json"
+ MAX_UNTRACKED_FILE_BYTES = 10 * 1024 * 1024
+ MAX_UNTRACKED_TOTAL_BYTES = 100 * 1024 * 1024
+ MAX_NAME_ATTEMPTS = 8
+ _THREAD_LOCK = threading.RLock()
+
+ @classmethod
+ def create(
+ cls,
+ *,
+ agent_name: str,
+ uuid: str,
+ source: str | Path,
+ base_ref: str | None = None,
+ base_root: str | Path | None = None,
+ reuse_existing: bool = True,
+ ) -> WorktreeRecord:
+ """Create a new worktree for *agent_name* and return its record.
+
+ *source* may be a local repository path or a remote URL. Remotes are
+ cloned into a shared bare cache and fetched on reuse. The default base
+ branch is resolved from ``origin/HEAD``, ``main``, ``master``, or the
+ current ``HEAD`` in that order.
+
+ By default an already-recorded checkout for the same agent and
+ repository is adopted (its owner is refreshed) instead of creating a
+ duplicate; workflows never run the same pair concurrently. Pass
+ ``reuse_existing=False`` only when deliberately creating a second
+ independent checkout for the same agent.
+ """
+ base = cls._base()
+ repo = cls._prepare_source(source, base_root=base_root)
+
+ # Reuse and creation are serialized per repository so concurrent runs of
+ # the same workflow cannot each create their own checkout.
+ with cls._repo_lock(base, repo):
+ if reuse_existing:
+ existing = cls._find_reusable(repo, agent_name)
+
+ if existing is not None:
+ return cls._adopt(existing)
+
+ base_ref_name, base_sha = cls._resolve_base(repo, base_ref)
+ branch = paths.branch_name(agent_name, uuid)
+ path = paths.worktree_path(base, repo, agent_name, uuid)
+ path.parent.mkdir(parents=True, exist_ok=True)
+ branch, path = cls._add_worktree(repo, branch, path, base_ref_name, base_sha)
+
+ record = WorktreeRecord(
+ source=str(source),
+ repo=str(Path(repo).resolve()),
+ path=str(path.resolve()),
+ branch=branch,
+ base_ref=base_ref_name,
+ base_sha=base_sha,
+ agent=str(agent_name),
+ uuid=str(uuid),
+ pid=os.getpid(),
+ host=socket.gethostname(),
+ created_at=_now(),
+ )
+ cls._save_record(record)
+
+ return record
+
+ @classmethod
+ def recreate(
+ cls,
+ record: WorktreeRecord | dict,
+ *,
+ agent_name: str | None = None,
+ uuid: str | None = None,
+ path: str | Path | None = None,
+ ) -> WorktreeRecord:
+ """Reattach or rebuild the worktree described by *record*.
+
+ An existing recorded path is reused when it is still a valid worktree.
+ Otherwise the existing branch is attached, the recorded base SHA is
+ checked out onto a new branch, or the base is resolved again as a last
+ resort. ``agent_name``, ``uuid``, and ``path`` may be overridden.
+ """
+ original = cls._coerce_record(record)
+ base = cls._base()
+ repo = Path(original.repo)
+ new_agent = str(agent_name) if agent_name is not None else original.agent
+ new_uuid = str(uuid) if uuid is not None else original.uuid
+ recorded_path = Path(original.path)
+ base_ref = original.base_ref
+ base_sha = original.base_sha
+
+ if path is None and recorded_path.exists() and cls._is_worktree(recorded_path, repo):
+ target_path = recorded_path
+ branch = current_branch(target_path) or original.branch
+ else:
+ branch = original.branch or paths.branch_name(new_agent, new_uuid)
+
+ if path is not None:
+ target_path = Path(path).expanduser().resolve()
+ elif new_agent == original.agent and new_uuid == original.uuid:
+ target_path = recorded_path
+ else:
+ target_path = paths.worktree_path(base, repo, new_agent, new_uuid)
+
+ target_path.parent.mkdir(parents=True, exist_ok=True)
+
+ with cls._repo_lock(base, repo):
+ reusable = cls._find_reusable(repo, new_agent) if path is None else None
+
+ if reusable is not None:
+ adopted = cls._adopt(reusable)
+
+ if original.uuid != adopted.uuid:
+ cls._discard_record(original.uuid)
+
+ return adopted
+
+ if branch_exists(branch, repo):
+ try:
+ run_git("worktree", "add", str(target_path), branch, cwd=repo)
+ except WorktreeError:
+ # The saved branch may still be checked out elsewhere.
+ # Preserve its tip under the new generated name instead.
+ tip = rev_parse(branch, cwd=repo, check=False)
+ branch = paths.branch_name(new_agent, new_uuid)
+
+ if tip:
+ run_git(
+ "worktree", "add", "-b", branch, str(target_path), tip, cwd=repo
+ )
+ else:
+ raise
+ elif base_sha and rev_parse(base_sha, cwd=repo, check=False):
+ run_git("worktree", "add", "-b", branch, str(target_path), base_sha, cwd=repo)
+ else:
+ fallback_ref = base_ref if rev_parse(base_ref, cwd=repo, check=False) else None
+ base_ref, base_sha = cls._resolve_base(repo, fallback_ref)
+ branch, target_path = cls._add_worktree(
+ repo, branch, target_path, base_ref, base_sha
+ )
+
+ record = WorktreeRecord(
+ source=original.source,
+ repo=str(repo.resolve()),
+ path=str(target_path.resolve()),
+ branch=branch,
+ base_ref=base_ref,
+ base_sha=base_sha,
+ agent=new_agent,
+ uuid=new_uuid,
+ pid=os.getpid(),
+ host=socket.gethostname(),
+ created_at=original.created_at,
+ )
+ cls._replace_record(original.uuid, record)
+
+ return record
+
+ @classmethod
+ def release(
+ cls,
+ record_or_uuid: WorktreeRecord | dict | str,
+ *,
+ force: bool = False,
+ ) -> WorktreeReleaseResult:
+ """Commit dirty work, remove the worktree, and clean up its branch.
+
+ Uncommitted changes are committed with the cecli identity before the
+ worktree is removed. Oversized untracked files abort the release and
+ leave everything in place. *force* bypasses that size guard. A branch
+ is deleted only when it has no commits ahead of its base or is fully
+ merged; otherwise it is kept.
+ """
+ record = cls._coerce_record(record_or_uuid)
+ repo = Path(record.repo)
+ path = Path(record.path)
+
+ if not path.exists():
+ run_git("worktree", "prune", cwd=repo, check=False)
+ ahead = ahead_count(record.base_sha, record.branch, cwd=repo)
+ branch_deleted = cls._drop_empty_branch(repo, record, ahead)
+ cls._discard_record(record.uuid)
+
+ return _release_result(
+ record,
+ removed=False,
+ branch_kept=not branch_deleted,
+ branch_deleted=branch_deleted,
+ reason="worktree path missing",
+ )
+
+ if not cls._is_worktree(path, repo):
+ cls._discard_record(record.uuid)
+
+ return _release_result(
+ record,
+ branch_kept=True,
+ reason="path is not a managed worktree",
+ )
+
+ try:
+ status = status_porcelain(path)
+ except WorktreeError as exc:
+ return _release_result(record, branch_kept=True, reason=f"status failed: {exc}")
+
+ committed = False
+
+ if status.strip():
+ if not force and cls._untracked_too_large(path, status):
+ return _release_result(
+ record,
+ branch_kept=True,
+ reason="untracked files too large",
+ )
+
+ try:
+ cls._commit_dirty(record, path, status)
+ except WorktreeError as exc:
+ return _release_result(record, branch_kept=True, reason=f"commit failed: {exc}")
+
+ committed = True
+
+ active_branch = current_branch(path)
+ if active_branch is None and (
+ status.strip() or ahead_count(record.base_sha, "HEAD", cwd=repo) > 0
+ ):
+ run_git("branch", "-f", f"{record.branch}-detached", "HEAD", cwd=path, check=False)
+
+ ahead = ahead_count(record.base_sha, record.branch, cwd=repo)
+
+ with cls._repo_lock(cls._base(), repo):
+ try:
+ run_git("worktree", "remove", "--force", str(path), cwd=repo)
+ except WorktreeError as exc:
+ return _release_result(
+ record,
+ committed=committed,
+ branch_kept=True,
+ reason=f"worktree remove failed: {exc}",
+ )
+
+ run_git("worktree", "prune", cwd=repo, check=False)
+ branch_deleted = cls._drop_empty_branch(
+ repo, record, ahead, allow_delete=active_branch == record.branch
+ )
+
+ branch_kept = not branch_deleted
+ cls._discard_record(record.uuid)
+
+ return _release_result(
+ record,
+ committed=committed,
+ removed=True,
+ branch_kept=branch_kept,
+ branch_deleted=branch_deleted,
+ reason="" if branch_deleted else "branch kept (unmerged commits)",
+ )
+
+ @classmethod
+ def release_all(cls, *, pid: int | None = None) -> list[WorktreeReleaseResult]:
+ """Release every recorded worktree belonging to *pid* (default: us)."""
+ target = os.getpid() if pid is None else int(pid)
+ host = socket.gethostname()
+ results = []
+
+ for record in cls._records():
+ if record.pid != target or record.host != host:
+ continue
+
+ results.append(cls._release_safely(record))
+
+ return results
+
+ @classmethod
+ def sweep_stale(cls) -> list[WorktreeReleaseResult]:
+ """Release worktrees from this host whose owning process is gone."""
+ host = socket.gethostname()
+ results = []
+
+ for record in cls._records():
+ if record.host != host or _pid_alive(record.pid):
+ continue
+
+ results.append(cls._release_safely(record))
+
+ return results
+
+ @classmethod
+ def get(cls, uuid: str) -> WorktreeRecord | None:
+ """Return the registry record for *uuid*, or ``None`` if unknown."""
+ data = cls._read_registry().get(str(uuid))
+
+ if not data:
+ return None
+
+ try:
+ return WorktreeRecord.from_dict(data)
+ except (TypeError, KeyError):
+ return None
+
+ @classmethod
+ def resolve_source(
+ cls,
+ source: str | Path,
+ *,
+ base_root: str | Path | None = None,
+ ) -> str:
+ """Resolve *source* to an absolute repository path or a remote URL.
+
+ Existing paths are normalized to their git top-level; relative paths
+ are resolved against *base_root* (or the current directory). Strings
+ that do not exist but look like URLs are returned unchanged. A missing
+ non-remote path raises :class:`WorktreeError`.
+ """
+ raw = str(source)
+ candidate = Path(raw).expanduser()
+
+ if candidate.is_absolute():
+ if candidate.exists():
+ return _normalize_local(candidate)
+ elif base_root is not None:
+ candidate = Path(base_root).expanduser() / candidate
+
+ if candidate.exists():
+ return _normalize_local(candidate)
+ elif candidate.exists():
+ return _normalize_local(candidate)
+
+ if _looks_remote(raw):
+ return raw
+
+ raise WorktreeError(f"source path does not exist: {raw}")
+
+ @classmethod
+ def _base(cls) -> Path:
+ override = os.environ.get("CECLI_WORKTREES_DIR")
+
+ if override:
+ return Path(override).expanduser().resolve()
+
+ return Path(cls.BASE).expanduser().resolve()
+
+ @classmethod
+ def _prepare_source(
+ cls,
+ source: str | Path,
+ *,
+ base_root: str | Path | None = None,
+ ) -> str:
+ resolved = cls.resolve_source(source, base_root=base_root)
+
+ if _looks_remote(resolved):
+ return cls._cache_remote(cls._base(), resolved)
+
+ return resolved
+
+ @classmethod
+ def _cache_remote(cls, base: Path, source: str) -> str:
+ digest = paths.short_digest(source, 8)
+ slug = paths.source_slug(source)
+ cache = paths.sources_dir(base) / f"{slug}-{digest}"
+ cache.parent.mkdir(parents=True, exist_ok=True)
+ lock = paths.lock_path(base, f"source-{digest}")
+
+ with _file_lock(lock):
+ if cache.exists() and is_git_repo(cache):
+ run_git(
+ "fetch",
+ "--prune",
+ "origin",
+ "+refs/heads/*:refs/heads/*",
+ cwd=cache,
+ check=False,
+ )
+ else:
+ if cache.exists():
+ shutil.rmtree(cache, ignore_errors=True)
+
+ run_git("clone", "--bare", source, str(cache))
+
+ return str(cache.resolve())
+
+ @classmethod
+ def _resolve_base(cls, repo: str | Path, base_ref: str | None) -> tuple[str, str]:
+ if base_ref is not None:
+ ref = str(base_ref)
+ sha = rev_parse(ref, cwd=repo, check=False)
+
+ if not sha:
+ raise WorktreeError(f"unknown base ref {ref!r} in {repo}")
+
+ return ref, sha
+
+ name = default_branch(cwd=repo)
+
+ if name is None:
+ name = "HEAD"
+
+ sha = rev_parse(name, cwd=repo, check=False)
+
+ if not sha:
+ raise WorktreeError(f"repository has no resolvable base commit: {repo}")
+
+ return name, sha
+
+ @classmethod
+ def _add_worktree(
+ cls,
+ repo: str | Path,
+ branch: str,
+ path: Path,
+ base_ref: str,
+ base_sha: str,
+ ) -> tuple[str, Path]:
+ start = base_ref if rev_parse(base_ref, cwd=repo, check=False) else base_sha
+
+ if path.exists():
+ existing = cls._existing_branch(repo, path, branch)
+
+ if existing is not None:
+ return existing, path
+
+ raise WorktreeError(f"worktree path already exists: {path}")
+
+ last_error: WorktreeError | None = None
+
+ for attempt in range(cls.MAX_NAME_ATTEMPTS):
+ candidate_branch = branch if attempt == 0 else f"{branch}-{attempt + 1}"
+
+ try:
+ if branch_exists(candidate_branch, repo):
+ run_git("worktree", "add", str(path), candidate_branch, cwd=repo)
+ else:
+ run_git(
+ "worktree",
+ "add",
+ "-b",
+ candidate_branch,
+ str(path),
+ start,
+ cwd=repo,
+ )
+ except WorktreeError as exc:
+ last_error = exc
+
+ if path.exists():
+ existing = cls._existing_branch(repo, path, candidate_branch)
+
+ if existing is not None:
+ return existing, path
+
+ break
+
+ continue
+
+ return candidate_branch, path
+
+ raise WorktreeError(
+ f"could not create worktree for branch {branch!r} in {repo}: {last_error}"
+ )
+
+ @classmethod
+ def _commit_dirty(cls, record: WorktreeRecord, path: Path, status: str) -> None:
+ subject = f"Unfinished Dirty Worktree from agent {record.uuid}"
+ summary = status.rstrip()
+ body = "\n".join(
+ [
+ f"agent: {record.agent}",
+ f"worktree: {record.path}",
+ f"base ref: {record.base_ref}",
+ f"base sha: {record.base_sha}",
+ "",
+ "status:",
+ summary[:4000],
+ ]
+ )
+ run_git("add", "-A", cwd=path)
+ run_git(
+ "-c",
+ "user.name=cecli",
+ "-c",
+ "user.email=cecli@localhost",
+ "-c",
+ "commit.gpgsign=false",
+ "commit",
+ "--no-verify",
+ "-m",
+ subject,
+ "-m",
+ body,
+ cwd=path,
+ )
+
+ @classmethod
+ def _untracked_too_large(cls, path: Path, status: str) -> bool:
+ total = 0
+
+ for line in status.splitlines():
+ if not line.startswith("?? "):
+ continue
+
+ relative = line[3:].strip().strip('"')
+ candidate = path / relative
+
+ try:
+ size = candidate.stat().st_size
+ except OSError:
+ continue
+
+ if size > cls.MAX_UNTRACKED_FILE_BYTES:
+ return True
+
+ total += size
+
+ if total > cls.MAX_UNTRACKED_TOTAL_BYTES:
+ return True
+
+ return False
+
+ @classmethod
+ def _drop_empty_branch(
+ cls, repo: Path, record: WorktreeRecord, ahead: int, *, allow_delete: bool = True
+ ) -> bool:
+ """Delete a managed branch when it has no unique commits."""
+ if not branch_exists(record.branch, repo):
+ return True
+
+ if not allow_delete:
+ return False
+
+ if not record.branch.startswith(f"{paths.BRANCH_PREFIX}/"):
+ return False
+
+ if not record.base_sha or not rev_parse(record.base_sha, cwd=repo, check=False):
+ return False
+
+ if ahead == 0 or cls._branch_merged(repo, record):
+ delete = run_git("branch", "-D", record.branch, cwd=repo, check=False)
+ return delete.returncode == 0
+
+ return False
+
+ @classmethod
+ def _branch_merged(cls, repo: Path, record: WorktreeRecord) -> bool:
+ if record.base_ref and branch_merged(record.branch, record.base_ref, cwd=repo):
+ return True
+
+ if record.base_sha and branch_merged(record.branch, record.base_sha, cwd=repo):
+ return True
+
+ return False
+
+ @classmethod
+ def _is_worktree(cls, path: Path, repo: Path) -> bool:
+ if not path.is_dir():
+ return False
+
+ root = git_root(path)
+
+ if root is None:
+ return False
+
+ try:
+ if Path(root).resolve() != path.resolve():
+ return False
+ except OSError:
+ return False
+
+ path_common = _git_common_dir(path)
+ repo_common = _git_common_dir(repo)
+
+ return bool(path_common and repo_common and path_common == repo_common)
+
+ @classmethod
+ def _release_safely(cls, record: WorktreeRecord) -> WorktreeReleaseResult:
+ try:
+ return cls.release(record)
+ except Exception as exc:
+ return _release_result(record, branch_kept=True, reason=str(exc))
+
+ @classmethod
+ def _coerce_record(cls, value: WorktreeRecord | dict | str) -> WorktreeRecord:
+ if isinstance(value, WorktreeRecord):
+ return value
+
+ if isinstance(value, dict):
+ return WorktreeRecord.from_dict(value)
+
+ record = cls.get(str(value))
+
+ if record is None:
+ raise WorktreeError(f"unknown worktree uuid: {value}")
+
+ return record
+
+ @classmethod
+ def _save_record(cls, record: WorktreeRecord) -> None:
+ with cls._registry_lock():
+ data = cls._read_registry()
+ data[record.uuid] = record.to_dict()
+ cls._write_registry(data)
+
+ @classmethod
+ def _replace_record(cls, old_uuid: str, record: WorktreeRecord) -> None:
+ with cls._registry_lock():
+ data = cls._read_registry()
+
+ if old_uuid != record.uuid:
+ data.pop(old_uuid, None)
+
+ data[record.uuid] = record.to_dict()
+ cls._write_registry(data)
+
+ @classmethod
+ def _discard_record(cls, uuid: str) -> None:
+ with cls._registry_lock():
+ data = cls._read_registry()
+
+ if data.pop(str(uuid), None) is not None:
+ cls._write_registry(data)
+
+ @classmethod
+ def _records(cls) -> list[WorktreeRecord]:
+ records = []
+
+ for value in cls._read_registry().values():
+ try:
+ records.append(WorktreeRecord.from_dict(value))
+ except (TypeError, KeyError):
+ continue
+
+ return records
+
+ @classmethod
+ def _read_registry(cls) -> dict[str, dict]:
+ path = paths.registry_path(cls._base())
+
+ try:
+ with open(path, "r", encoding="utf-8") as handle:
+ data = json.load(handle)
+ except FileNotFoundError:
+ return {}
+ except (OSError, json.JSONDecodeError):
+ return {}
+
+ if not isinstance(data, dict):
+ return {}
+
+ return {str(key): value for key, value in data.items() if isinstance(value, dict)}
+
+ @classmethod
+ def _write_registry(cls, data: dict[str, dict]) -> None:
+ path = paths.registry_path(cls._base())
+ path.parent.mkdir(parents=True, exist_ok=True)
+ fd, temp_name = tempfile.mkstemp(prefix=".registry-", suffix=".tmp", dir=str(path.parent))
+
+ try:
+ with os.fdopen(fd, "w", encoding="utf-8") as handle:
+ json.dump(data, handle, indent=2, sort_keys=True)
+ handle.write("\n")
+ handle.flush()
+ os.fsync(handle.fileno())
+
+ os.replace(temp_name, path)
+ except BaseException:
+ try:
+ os.unlink(temp_name)
+ except OSError:
+ pass
+
+ raise
+
+ @classmethod
+ @contextmanager
+ def _registry_lock(cls) -> Iterator[None]:
+ with cls._THREAD_LOCK:
+ with _file_lock(paths.lock_path(cls._base(), "registry")):
+ yield
+
+ @classmethod
+ @contextmanager
+ def _repo_lock(cls, base: Path, repo: str | Path) -> Iterator[None]:
+ digest = paths.short_digest(str(Path(repo).resolve()), 12)
+
+ with _file_lock(paths.lock_path(base, f"repo-{digest}")):
+ yield
+
+ @classmethod
+ def _find_reusable(cls, repo: str | Path, agent_name: str) -> WorktreeRecord | None:
+ """Return a recorded checkout for *agent_name* in *repo*, if usable."""
+ resolved_repo = str(Path(repo).resolve())
+
+ for record in sorted(cls._records(), key=lambda item: item.created_at, reverse=True):
+ if record.agent != agent_name:
+ continue
+
+ if str(Path(record.repo).resolve()) != resolved_repo:
+ continue
+
+ if cls._is_worktree(Path(record.path), Path(repo)):
+ return record
+
+ return None
+
+ @classmethod
+ def _adopt(cls, record: WorktreeRecord) -> WorktreeRecord:
+ """Refresh *record* ownership and persist it."""
+ adopted = WorktreeRecord(
+ source=record.source,
+ repo=record.repo,
+ path=record.path,
+ branch=record.branch,
+ base_ref=record.base_ref,
+ base_sha=record.base_sha,
+ agent=record.agent,
+ uuid=record.uuid,
+ pid=os.getpid(),
+ host=socket.gethostname(),
+ created_at=record.created_at,
+ )
+ cls._save_record(adopted)
+
+ return adopted
+
+ @classmethod
+ def _existing_branch(cls, repo: str | Path, path: Path, fallback: str) -> str | None:
+ """Return the branch checked out at an existing managed worktree."""
+ if not cls._is_worktree(path, Path(repo)):
+ return None
+
+ return current_branch(path) or fallback
+
+
+@contextmanager
+def _file_lock(path: Path) -> Iterator[None]:
+ path.parent.mkdir(parents=True, exist_ok=True)
+ handle = open(path, "a+", encoding="utf-8")
+ fcntl = None
+ msvcrt = None
+
+ try:
+ try:
+ import fcntl as _fcntl
+ except ImportError:
+ _fcntl = None
+
+ fcntl = _fcntl
+
+ if fcntl is not None:
+ fcntl.flock(handle.fileno(), fcntl.LOCK_EX)
+ else:
+ try:
+ import msvcrt as _msvcrt
+ except ImportError:
+ _msvcrt = None
+
+ msvcrt = _msvcrt
+
+ if msvcrt is not None:
+ handle.seek(0)
+ msvcrt.locking(handle.fileno(), msvcrt.LK_LOCK, 1)
+
+ yield
+ finally:
+ if fcntl is not None:
+ try:
+ fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
+ except OSError:
+ pass
+ elif msvcrt is not None:
+ try:
+ handle.seek(0)
+ msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1)
+ except OSError:
+ pass
+
+ handle.close()
+
+
+def _normalize_local(path: Path) -> str:
+ root = git_root(path)
+
+ if root is None:
+ raise WorktreeError(f"not a git repository: {path}")
+
+ return root
+
+
+def _looks_remote(value: str) -> bool:
+ text = str(value)
+
+ return bool(_SCHEME_URL.match(text) or _SCP_URL.match(text))
+
+
+def _git_common_dir(path: str | Path) -> str | None:
+ """Return a canonical git common directory for *path*, or ``None``."""
+ result = run_git("rev-parse", "--git-common-dir", cwd=path, check=False)
+
+ if result.returncode != 0:
+ return None
+
+ value = result.stdout.strip()
+
+ if not value:
+ return None
+
+ common = Path(value)
+
+ if not common.is_absolute():
+ common = Path(path).expanduser() / common
+
+ try:
+ return str(common.resolve())
+ except OSError:
+ return None
+
+
+def _release_result(
+ record: WorktreeRecord,
+ *,
+ committed: bool = False,
+ removed: bool = False,
+ branch_kept: bool = False,
+ branch_deleted: bool = False,
+ reason: str = "",
+) -> WorktreeReleaseResult:
+ return WorktreeReleaseResult(
+ uuid=record.uuid,
+ branch=record.branch,
+ committed=committed,
+ removed=removed,
+ branch_kept=branch_kept,
+ branch_deleted=branch_deleted,
+ reason=reason,
+ )
+
+
+def _pid_alive(pid: int) -> bool:
+ if pid <= 0:
+ return False
+
+ try:
+ import psutil
+
+ return bool(psutil.pid_exists(int(pid)))
+ except ImportError:
+ pass
+
+ if os.name == "nt":
+ import ctypes
+
+ process_query_limited_information = 0x1000
+ kernel32 = ctypes.windll.kernel32
+ handle = kernel32.OpenProcess(process_query_limited_information, False, int(pid))
+
+ if handle:
+ kernel32.CloseHandle(handle)
+ return True
+
+ return kernel32.GetLastError() == 5
+
+ try:
+ os.kill(pid, 0)
+ except PermissionError:
+ return True
+ except ProcessLookupError:
+ return False
+ except OSError:
+ return False
+
+ return True
+
+
+def _now() -> str:
+ return datetime.now(timezone.utc).isoformat()
diff --git a/cecli/helpers/worktrees/paths.py b/cecli/helpers/worktrees/paths.py
new file mode 100644
index 00000000000..c49a25cccb6
--- /dev/null
+++ b/cecli/helpers/worktrees/paths.py
@@ -0,0 +1,131 @@
+"""Filesystem layout and naming helpers for managed git worktrees.
+
+Managed layout::
+
+ /
+ registry.json
+ .locks/
+ _sources/-/ # bare caches for remote sources
+ /-/ # one directory per worktree
+
+``BASE`` defaults to ``~/.cecli/worktrees`` and can be overridden with the
+``CECLI_WORKTREES_DIR`` environment variable.
+"""
+
+from __future__ import annotations
+
+import hashlib
+import os
+import re
+from pathlib import Path
+
+DEFAULT_BASE = "~/.cecli/worktrees"
+BRANCH_PREFIX = "cecli"
+AGENT_SLUG_MAX = 40
+REPO_SLUG_MAX = 48
+LOCK_NAME_MAX = 80
+
+_SLUG_SEPARATORS = re.compile(r"[:\\/\s]+")
+_SLUG_INVALID = re.compile(r"[^A-Za-z0-9._-]+")
+_SLUG_DASHES = re.compile(r"-{2,}")
+
+
+def managed_root() -> Path:
+ """Return the managed worktree root, honoring ``CECLI_WORKTREES_DIR``."""
+ override = os.environ.get("CECLI_WORKTREES_DIR")
+ raw = override if override else DEFAULT_BASE
+ return Path(raw).expanduser().resolve()
+
+
+def registry_path(base: str | Path | None = None) -> Path:
+ """Return the path of the JSON registry for managed worktrees."""
+ return _base_path(base) / "registry.json"
+
+
+def lock_dir(base: str | Path | None = None) -> Path:
+ """Return the directory holding inter-process lock files."""
+ return _base_path(base) / ".locks"
+
+
+def lock_path(base: str | Path | None, name: str) -> Path:
+ """Return a lock file path for *name* under the managed lock directory."""
+ slug = slugify(name, max_length=LOCK_NAME_MAX, fallback="lock")
+ return lock_dir(base) / f"{slug}.lock"
+
+
+def sources_dir(base: str | Path | None = None) -> Path:
+ """Return the directory holding bare caches for remote sources."""
+ return _base_path(base) / "_sources"
+
+
+def slugify(value: str, *, max_length: int | None = None, fallback: str = "item") -> str:
+ """Turn *value* into a filesystem- and branch-safe slug.
+
+ Colons, whitespace, and path separators become dashes; other characters
+ outside ``[A-Za-z0-9._-]`` become dashes as well. Leading and trailing
+ punctuation is stripped, and the result is truncated to *max_length*.
+ """
+ text = str(value).strip()
+ text = _SLUG_SEPARATORS.sub("-", text)
+ text = _SLUG_INVALID.sub("-", text)
+ text = _SLUG_DASHES.sub("-", text).strip("-._")
+
+ if max_length is not None and len(text) > max_length:
+ text = text[:max_length].rstrip("-._")
+
+ return text or fallback
+
+
+def short_digest(value: str, length: int = 8) -> str:
+ """Return a stable short SHA-1 digest of *value*."""
+ return hashlib.sha1(str(value).encode("utf-8")).hexdigest()[:length]
+
+
+def repo_slug(repo: str | Path) -> str:
+ """Return the directory slug used for the repository at *repo*."""
+ name = Path(str(repo)).name.removesuffix(".git")
+ fallback = f"repo-{short_digest(str(repo))}"
+
+ return slugify(name, max_length=REPO_SLUG_MAX, fallback=fallback)
+
+
+def source_slug(source: str) -> str:
+ """Return a human-readable slug for a remote source URL."""
+ text = str(source).rstrip("/")
+ text = text.removesuffix(".git")
+ name = re.split(r"[/:]", text)[-1]
+ fallback = f"source-{short_digest(str(source))}"
+
+ return slugify(name, max_length=REPO_SLUG_MAX, fallback=fallback)
+
+
+def agent_slug(agent_name: str) -> str:
+ """Return the slug used for *agent_name* in branches and paths."""
+ return slugify(agent_name, max_length=AGENT_SLUG_MAX, fallback="agent")
+
+
+def branch_name(agent_name: str, uuid: str) -> str:
+ """Return the generated branch name for an agent worktree."""
+ return f"{BRANCH_PREFIX}/{worktree_dir_name(agent_name, uuid)}"
+
+
+def worktree_dir_name(agent_name: str, uuid: str) -> str:
+ """Return the generated directory name for an agent worktree."""
+ return f"{agent_slug(agent_name)}-{_uuid_slug(uuid)}"
+
+
+def worktree_path(base: str | Path | None, repo: str | Path, agent_name: str, uuid: str) -> Path:
+ """Return the full managed path for an agent worktree."""
+ return _base_path(base) / repo_slug(repo) / worktree_dir_name(agent_name, uuid)
+
+
+def _uuid_slug(uuid: str) -> str:
+ text = slugify(uuid, max_length=40, fallback="")
+ return text or short_digest(uuid, 8)
+
+
+def _base_path(base: str | Path | None) -> Path:
+ if base is None:
+ return managed_root()
+
+ return Path(base).expanduser().resolve()
diff --git a/cecli/io.py b/cecli/io.py
index d2f49aa4d93..2def2478ffd 100644
--- a/cecli/io.py
+++ b/cecli/io.py
@@ -777,6 +777,13 @@ def write_text(self, filename, content, max_retries=5, initial_delay=0.1):
if self.line_endings == "preserve":
newline = self._detect_newline(filename) or self.newline
+ # Content that already carries CRLF must not be translated again,
+ # which would double the carriage returns on Windows.
+ # ``None`` is the platform default, which is CRLF on Windows, so it
+ # must be handled alongside an explicit CRLF target.
+ if newline in (None, "\r\n") and "\r\n" in content:
+ newline = ""
+
delay = initial_delay
for attempt in range(max_retries):
try:
diff --git a/cecli/main.py b/cecli/main.py
index cac438c1a72..bd210673b04 100644
--- a/cecli/main.py
+++ b/cecli/main.py
@@ -1515,6 +1515,15 @@ def get_io(pretty):
except Exception:
logging.exception("workflow sub-agent registration failed")
+ # Sweep worktrees left behind by dead processes before the session starts.
+ # Best-effort: a worktree failure must never block startup.
+ try:
+ from cecli.helpers.worktrees import WorktreeManager
+
+ WorktreeManager.sweep_stale()
+ except Exception:
+ logging.warning("worktree sweep failed", exc_info=True)
+
# Creator ``agents:`` map: re-read it so the spawn-time override index is
# fresh, then register definition entries as dynamic ``creator:``
# sub-agents. The map never flows through agent_config.
@@ -2018,10 +2027,24 @@ async def graceful_exit(coder=None, exit_code=0):
if info.generate_task is not None
]
await coroutines.cancel_and_abandon(agent_tasks)
- agent_service.cleanup_all_for_parent()
+ # Cancel background tasks again before the blocking worktree release:
+ # abandoned tasks can outlive their loop, and re-cancelling them after a
+ # long git operation can hit "Event loop is closed".
await coroutines.cancel_and_abandon(list(coroutines.background_tasks))
+ agent_service.cleanup_all_for_parent()
await _stop_scheduler_quietly(coder)
+ # Release every worktree owned by this process, including ones that were
+ # never attached to a tracked sub-agent. Best-effort on the way out.
+ try:
+ import logging
+
+ from cecli.helpers.worktrees import WorktreeManager
+
+ WorktreeManager.release_all(pid=os.getpid())
+ except Exception:
+ logging.warning("worktree release on exit failed", exc_info=True)
+
if coder.mcp_manager and coder.mcp_manager.is_connected:
await coder.mcp_manager.disconnect_all()
# Cleanup old agent directories
diff --git a/cecli/tools/delegate.py b/cecli/tools/delegate.py
index e5650f1b8f8..b0dafaf95d1 100644
--- a/cecli/tools/delegate.py
+++ b/cecli/tools/delegate.py
@@ -49,6 +49,13 @@ class Tool(BaseTool):
" If false, wait for the result."
),
},
+ "working_dir": {
+ "type": "string",
+ "description": (
+ "Optional working directory for this delegation. "
+ "Relative paths resolve against the dispatching agent's root."
+ ),
+ },
"persist": {
"type": "boolean",
"default": False,
@@ -76,7 +83,8 @@ async def execute(cls, coder, **kwargs):
if not delegations or not isinstance(delegations, list):
response.append_error(
- "'delegations' parameter must be a non-empty array of {name, prompt, async, persist} objects."
+ "'delegations' parameter must be a non-empty array of "
+ "{name, prompt, async, working_dir, persist} objects."
)
return response
@@ -106,33 +114,39 @@ async def execute(cls, coder, **kwargs):
# Separate async (fire-and-forget) and sync (blocking) delegations
async_delegations = [
- (d["name"], d["prompt"], d.get("persist", False))
+ (d["name"], d["prompt"], d.get("persist", False), d.get("working_dir"))
for d in delegations
if d.get("async", True)
]
sync_delegations = [
- (d["name"], d["prompt"], d.get("persist", False))
+ (d["name"], d["prompt"], d.get("persist", False), d.get("working_dir"))
for d in delegations
if not d.get("async", True)
]
- async def _spawn_one(name: str, prompt: str, persist: bool) -> tuple:
+ async def _spawn_one(name: str, prompt: str, persist: bool, working_dir=None) -> tuple:
"""Spawn a single sub-agent (fire-and-forget). Returns (name, uuid_or_error, error)."""
auto_reap = None if not persist else False
try:
+ spawn_kwargs = {}
+ if working_dir:
+ spawn_kwargs["root"] = working_dir
new_coder, info = await agent_service.spawn(
- name, prompt, parent=coder, auto_reap=auto_reap
+ name, prompt, parent=coder, auto_reap=auto_reap, **spawn_kwargs
)
return name, info.coder.uuid, None
except Exception as e:
return name, None, f"failed: {e}"
- async def _invoke_one(name: str, prompt: str, persist: bool) -> tuple:
+ async def _invoke_one(name: str, prompt: str, persist: bool, working_dir=None) -> tuple:
"""Invoke a single sub-agent (blocking). Returns (name, summary_or_error, error)."""
auto_reap = None if not persist else False
try:
+ invoke_kwargs = {}
+ if working_dir:
+ invoke_kwargs["root"] = working_dir
summary = await agent_service.invoke(
- name, prompt, parent=coder, auto_reap=auto_reap
+ name, prompt, parent=coder, auto_reap=auto_reap, **invoke_kwargs
)
return name, summary or "(no summary)", None
except Exception as e:
@@ -141,13 +155,19 @@ async def _invoke_one(name: str, prompt: str, persist: bool) -> tuple:
# Process async delegations (fire-and-forget spawn)
async_results = []
if async_delegations:
- tasks = [_spawn_one(n, p, persist) for n, p, persist in async_delegations]
+ tasks = [
+ _spawn_one(n, p, persist, working_dir)
+ for n, p, persist, working_dir in async_delegations
+ ]
async_results = list(await asyncio.gather(*tasks))
# Process sync delegations (blocking invoke)
sync_results = []
if sync_delegations:
- tasks = [_invoke_one(n, p, persist) for n, p, persist in sync_delegations]
+ tasks = [
+ _invoke_one(n, p, persist, working_dir)
+ for n, p, persist, working_dir in sync_delegations
+ ]
sync_results = list(await asyncio.gather(*tasks))
# Build response
@@ -213,10 +233,13 @@ def format_output(cls, coder, mcp_server, tool_response):
prompt = d.get("prompt", "")
is_async = d.get("async", True)
is_persist = d.get("persist", False)
+ working_dir = d.get("working_dir")
coder.io.tool_output(f"{color_start}delegation_{i + 1}:{color_end}")
coder.io.tool_output(f"agent: {name}")
coder.io.tool_output(f"mode: {'async' if is_async else 'sync'}")
coder.io.tool_output(f"persist: {'true' if is_persist else 'false'}")
+ if working_dir:
+ coder.io.tool_output(f"working_dir: {working_dir}")
coder.io.tool_output(f"task: {prompt}")
if i < len(delegations) - 1:
coder.io.tool_output("")
diff --git a/cecli/tools/edit_file.py b/cecli/tools/edit_file.py
index c9bc930e43d..82c1750c64d 100644
--- a/cecli/tools/edit_file.py
+++ b/cecli/tools/edit_file.py
@@ -1,3 +1,5 @@
+import re
+
from cecli.helpers import nested
from cecli.helpers.hashline import (
HASH_DELIMITER,
@@ -9,11 +11,16 @@
strip_hashline,
)
from cecli.helpers.hashpos.hashpos import HashPos
-from cecli.helpers.hashpos.transformations import resolve_at_l, strip_hashline_prefix
+from cecli.helpers.hashpos.transformations import (
+ extract_hint,
+ resolve_at_l,
+ strip_hashline_prefix,
+)
from cecli.tools.utils.base_tool import BaseTool
from cecli.tools.utils.helpers import (
ToolError,
apply_change,
+ read_text_preserving_newlines,
validate_file_for_edit,
)
from cecli.tools.utils.output import color_markers, tool_footer, tool_header
@@ -36,6 +43,10 @@
}
+READFILE_ONLY_HINT_RE = re.compile(r"(?:^|[ \t])@[CPN][0-9]*[ \t]*$")
+BARE_AB_HINT_RE = re.compile(r"(?:^|[ \t])@[AB][ \t]*$")
+
+
class Tool(BaseTool):
NORM_NAME = "editfile"
RESULT_TYPE = "list"
@@ -49,20 +60,27 @@ class Tool(BaseTool):
"function": {
"name": "EditFile",
"description": (
- "Modify text in one or more files by targeting lines with their virtual identifiers "
- "(as returned by ReadFile). You can batch multiple operations across files in one call."
- ""
- "Operations:"
- " - 'replace' — swap the targeted range with new text"
- " - 'delete' — remove the targeted range"
- ""
- "Start and end markers are inclusive: both referenced lines are modified or removed. "
- f"Reference unique lines (prefixed with '{UNIQUE_HASH_DELIMITER}') by their exact text, and "
- 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 automatically; overlapping or contained ranges are merged "
- "or rejected automatically."
+ "Edit lines in one or more files by targeting lines with their virtual identifiers "
+ "(as returned by ReadFile); batch across files in one call. "
+ "'replace' swaps the range, 'delete' removes it, "
+ "'insert' adds text before/after the target line.\n"
+ "\n"
+ "start_line and end_line are inclusive; each is one single-line anchor:\n"
+ f"- unique lines (prefixed with '{UNIQUE_HASH_DELIMITER}') by exact text, "
+ "or duplicate lines by their hashed prefix "
+ f"'{HASH_DELIMITER}WecX{HASH_DELIMITER}' (text after it ignored)\n"
+ "- '@L' (1-based; '@L0' = top)\n"
+ "- 'text @L', 'text @A{{regex}}' (anchor text required; after regex hit), "
+ "or 'text @B{{regex}}' (before it); bare '@A'/'@B' is rejected\n"
+ "- '@C', '@P', and '@N' are ReadFile-only range markers; "
+ "EditFile rejects them (use '@L' or a content ID instead)\n"
+ "\n"
+ "Ambiguous text fails. '@000' = empty/top; '000@' = EOF. "
+ "Use '@000' for both start_line and end_line to prepend. "
+ "Use '000@' for both start_line and end_line to append at end of file. "
+ "end_line resolves nearest start_line. "
+ "Edits apply bottom-to-top; overlaps merge or reject. "
+ "Re-read before reusing IDs (they encode position)."
),
"parameters": {
"type": "object",
@@ -84,36 +102,39 @@ class Tool(BaseTool):
},
"operation": {
"type": "string",
- "enum": ["replace", "delete"],
+ "enum": ["replace", "delete", "insert"],
"description": (
- "The kind of edit: 'replace' swaps the targeted range with new text, "
- "'delete' removes it entirely."
+ "'replace' swaps the range with text; 'delete' removes it; "
+ "'insert' adds text before/after the target line."
),
},
"text": {
"type": "string",
"description": (
- "The replacement text for 'replace'. "
- "For 'delete' leave this as an empty string (\"\"). "
- "Supplied as-is; do not include identifier prefixes."
+ "Replacement/insertion text (non-empty); \"\" for 'delete'. "
+ "Prefixes are stripped."
+ ),
+ },
+ "position": {
+ "type": "string",
+ "enum": ["before", "after"],
+ "description": (
+ "For 'insert': place text before or after the target line. "
+ "Defaults to 'after'."
),
},
"start_line": {
"type": "string",
"description": (
- "The first line of the edit (inclusive): "
- "its exact text if unique, its hashed prefix "
- f"(e.g., '{HASH_DELIMITER}WecX{HASH_DELIMITER}') if duplicated, "
- "or '@000' for empty files."
+ "First line (inclusive). See anchor forms above; "
+ "'@000'/'@L0' = empty/top."
),
},
"end_line": {
"type": "string",
"description": (
- "The last line of the edit (inclusive): "
- "its exact text if unique, its hashed "
- f"prefix (e.g., '{HASH_DELIMITER}WecX{HASH_DELIMITER}') if duplicated, "
- "or '000@' for the end of the file."
+ "Last line (inclusive). Same forms; '000@' = EOF. "
+ "Optional for 'insert'."
),
},
},
@@ -122,7 +143,6 @@ class Tool(BaseTool):
"operation",
"text",
"start_line",
- "end_line",
],
},
},
@@ -225,7 +245,7 @@ def execute(
if operation not in VALID_OPERATIONS:
raise ToolError(
f"Edit {edit_index + 1}: Invalid operation '{operation}'. "
- "Must be 'replace' or 'delete'"
+ "Must be 'replace', 'delete', or 'insert'"
)
edit_file_raw = edit.get("text")
@@ -236,6 +256,19 @@ def execute(
edit, ["end_line", "range_end", "line_end", "end"]
)
+ for anchor_name, anchor_value in (
+ ("start_line", edit_start_line),
+ ("end_line", edit_end_line),
+ ):
+ if isinstance(anchor_value, str) and (
+ "\n" in anchor_value or "\r" in anchor_value
+ ):
+ raise ToolError(
+ f"Edit {edit_index + 1}: '{anchor_name}' must be a "
+ "single-line anchor; multi-line text is only supported "
+ "in 'text'. Use a content ID or '@L' to target one line."
+ )
+
# ---------------------------------------------------------
# DEFENSIVE FALLBACKS
# ---------------------------------------------------------
@@ -245,21 +278,25 @@ def execute(
edit_start_line = "@000"
edit_end_line = "@000"
- # Resolve @L{num} notation directly to content ID
- edit_start_line = cls._resolve_at_l_num(
- edit_start_line, hp, source_lines, file_path_key
+ # Resolve line hints (@L/@A/@B) and content references
+ edit_start_line, start_hint_line = cls._resolve_line_hint(
+ edit_start_line,
+ hp,
+ source_lines,
+ file_path_key,
)
- edit_end_line = cls._resolve_at_l_num(
- edit_end_line, hp, source_lines, file_path_key
+ edit_end_line, _ = cls._resolve_line_hint(
+ edit_end_line,
+ hp,
+ source_lines,
+ file_path_key,
)
- # Strip virtual prefixes from ReadFile output lines
- edit_start_line = cls._strip_readfile_prefix(edit_start_line)
- edit_end_line = cls._strip_readfile_prefix(edit_end_line)
-
- # Resolve remaining non-hashline content values to content IDs
edit_start_line, edit_end_line = resolve_content_to_hashline_ids(
- original_content, edit_start_line, edit_end_line
+ original_content,
+ edit_start_line,
+ edit_end_line,
+ start_hint_line=start_hint_line,
)
# ---------------------------------------------------------
@@ -299,6 +336,18 @@ def execute(
f"Edit {edit_index + 1}: 'start_line' parameter is required"
" for 'insert' operation"
)
+
+ position = nested.getter(edit, ["position"])
+ if position is None:
+ position = "after"
+ if isinstance(position, str):
+ position = position.lower()
+ if position not in ("before", "after"):
+ raise ToolError(
+ f"Edit {edit_index + 1}: 'position' must be "
+ "'before' or 'after'"
+ )
+
# For insert, end_line defaults to start_line
edit_end_line = edit_end_line or edit_start_line
@@ -310,6 +359,8 @@ def execute(
}
if edit_file is not None:
op_dict["text"] = edit_file
+ if operation == "insert":
+ op_dict["position"] = position
operations.append(op_dict)
@@ -320,6 +371,9 @@ def execute(
"end_line": edit_end_line,
"text": edit_file,
}
+ if operation == "insert":
+ metadata["position"] = position
+
file_metadata.append(metadata)
except Exception as e:
@@ -419,6 +473,8 @@ def execute(
)
coder.files_edited_by_tools.add(rel_path)
+ if getattr(coder, "uuid", None):
+ ConversationService.get_chunks(coder).notify_file_edited(abs_path)
all_results.append(
{
@@ -578,7 +634,7 @@ def format_output(cls, coder, mcp_server, tool_response):
if file_path_key and start_line and end_line:
try:
abs_path = coder.abs_root_path(file_path_key)
- original_content = coder.io.read_text(abs_path)
+ original_content = read_text_preserving_newlines(coder, abs_path)
if original_content is not None:
# Mirror execute()'s preprocessing so the preview
@@ -590,17 +646,23 @@ def format_output(cls, coder, mcp_server, tool_response):
if original_content and original_content.strip()
else []
)
- start_line = cls._resolve_at_l_num(
- start_line, hp, source_lines, file_path_key
+ start_line, start_hint_line = cls._resolve_line_hint(
+ start_line,
+ hp,
+ source_lines,
+ file_path_key,
)
- end_line = cls._resolve_at_l_num(
- end_line, hp, source_lines, file_path_key
+ end_line, _ = cls._resolve_line_hint(
+ end_line,
+ hp,
+ source_lines,
+ file_path_key,
)
- start_line = cls._strip_readfile_prefix(start_line)
- end_line = cls._strip_readfile_prefix(end_line)
-
start_line, end_line = resolve_content_to_hashline_ids(
- original_content, start_line, end_line
+ original_content,
+ start_line,
+ end_line,
+ start_hint_line=start_hint_line,
)
diff_output = get_hashline_diff(
original_content=strip_hashline(original_content),
@@ -621,8 +683,12 @@ def format_output(cls, coder, mcp_server, tool_response):
coder.io.tool_output("")
elif operation == "insert":
- # Show inserted text
+ # Inserted text is additive, so render it like added diff lines.
if text:
+ lines = [f"+{line}" for line in text.splitlines()]
+ if coder.agent_config.get("diff_colors", True):
+ lines = [f"\033[32m{line}\x1b[0m" for line in lines]
+ text = "\n".join(lines)
coder.io.tool_output(text)
coder.io.tool_output("")
@@ -650,6 +716,91 @@ def _strip_readfile_prefix(value):
return strip_hashline_prefix(value)
+ @classmethod
+ def _resolve_line_hint(cls, line_spec, hp, source_lines, file_path):
+ """Resolve a hint suffix on a line spec to a content ID when possible.
+
+ Supports ``@L``, ``@A{regex}``/``@A{{regex}}``, and
+ ``@B{regex}``/``@B{{regex}}`` suffixes.
+ Returns ``(resolved_spec, hint_line)``. ``hint_line`` is retained only
+ when the hint could not be resolved locally, so the caller can fall
+ back to proximity resolution.
+ """
+
+ if not isinstance(line_spec, str):
+ return line_spec, None
+
+ line_spec = cls._strip_readfile_prefix(line_spec)
+
+ if READFILE_ONLY_HINT_RE.search(line_spec):
+ raise ToolError(
+ "ReadFile-only range markers ('@C', '@P', '@N') are not "
+ "supported by EditFile; use a content ID or '@L' line reference."
+ )
+
+ if BARE_AB_HINT_RE.search(line_spec):
+ raise ToolError(
+ "Bare '@A'/'@B' hints are not supported; include anchor text before "
+ "the hint, e.g. 'my line @A{regex}'."
+ )
+
+ stripped, hint_line, hint_type = extract_hint(line_spec, source_lines)
+
+ if hint_type is None or hint_line is None:
+ return stripped, None
+
+ if hint_type in ("A", "B") and not stripped.strip():
+ raise ToolError(
+ "Bare '@" + hint_type + "' hints are not supported; include anchor text "
+ "before the hint (e.g., 'my line @" + hint_type + "{regex}' or "
+ "'my line @" + hint_type + "{{regex}}')."
+ )
+
+ if not stripped.strip() or (hint_type == "L" and stripped.strip() == line_spec.strip()):
+ return cls._resolve_at_l_num(f"@L{hint_line + 1}", hp, source_lines, file_path), None
+
+ indices = cls._find_line_indices(stripped, source_lines)
+
+ if hint_type == "A":
+ candidates = [i for i in indices if i > hint_line]
+ target = min(candidates) if candidates else None
+ elif hint_type == "B":
+ candidates = [i for i in indices if i < hint_line]
+ target = max(candidates) if candidates else None
+ else:
+ target = min(indices, key=lambda i: abs(i - hint_line)) if indices else None
+
+ if target is None:
+ return stripped, hint_line if hint_type == "L" else None
+
+ line_text = source_lines[target]
+ occurrence = 1 + sum(1 for line in source_lines[:target] if line == line_text)
+
+ return hp.get_wrapped_id(hp.generate_public_id(line_text, target, occurrence)), None
+
+ @staticmethod
+ def _find_line_indices(value, lines):
+ """Return indices of lines matching *value* or its first line.
+
+ Single-line values are matched after trimming indentation.
+ """
+
+ value_lines = value.splitlines()
+
+ if len(value_lines) > 1:
+ matches = []
+
+ for i in range(len(lines) - len(value_lines) + 1):
+ if all(lines[i + j] == value_lines[j] for j in range(len(value_lines))):
+ matches.append(i)
+
+ return matches
+
+ first_line = value_lines[0] if value_lines else value
+ first_line_stripped = first_line.strip()
+
+ return [i for i, line in enumerate(lines) if line.strip() == first_line_stripped]
+
@classmethod
def _categorize_edit_error(cls, error_msg: str) -> str:
"""Categorize an edit error message into a user-friendly display category."""
diff --git a/cecli/tools/grep.py b/cecli/tools/grep.py
index 3a22dc448aa..b9181f242c3 100644
--- a/cecli/tools/grep.py
+++ b/cecli/tools/grep.py
@@ -35,18 +35,83 @@
# Output-shaping limits. These bound what is sent to the model while keeping
# match locations intact (counts and line numbers survive truncation).
MAX_MATCHES_PER_FILE = 10
+MAX_ADDITIONAL_LINE_NUMBERS = 10
MAX_FILES = 20
MAX_TOTAL_SIZE = 50000
MAX_LINE_LENGTH = 256
MAX_LINE_NUMBERS = 50
+MAX_CONTEXT_LINES = 5
def _should_use_regex(pattern, requested):
- """Auto-enable regex for alternation unless the caller explicitly disables it."""
+ """Choose regex mode, auto-detecting common regex syntax only for auto mode."""
if requested is False:
return False
- return bool(requested) or "|" in pattern
+ if requested is True:
+ return True
+
+ return _looks_like_regex(pattern)
+
+
+def _has_nonempty_alternation(pattern):
+ """Return True when every alternation branch in *pattern* is non-empty."""
+ parts = re.split(r"(? 1 and all(parts)
+
+
+def _has_regex_meta(pattern):
+ """Return True when a literal search might have been intended as regex."""
+ if re.search(r"\\|[.^$\[\](){}*+?]", pattern):
+ return True
+
+ return _has_nonempty_alternation(pattern)
+
+
+def _looks_like_regex(pattern):
+ """Detect strong regex signals without treating ordinary code punctuation as regex."""
+ if _has_nonempty_alternation(pattern):
+ return True
+
+ if pattern.startswith("^") or pattern.endswith("$"):
+ return True
+
+ if re.search(r"\(\?|\.\*|\.\+|\.\?|\{\d+(?:,\d*)?\}", pattern):
+ return True
+
+ return bool(re.search(r"\\(?:[AbBdDsSwWZz]|p\{|P\{|[.^$*+?()\[\]{}|\\/-])", pattern))
+
+
+def _needs_pcre2(pattern):
+ """Return True for regex constructs unsupported by ripgrep's default engine."""
+ return bool(re.search(r"\(\?(?:=|!|<=| 0:
+ counts[filepath] = count_val
+ except ValueError:
+ pass
+ continue
+
# Format: filepath:count
# Use rsplit to handle paths that may contain colons
idx = line.rfind(":")
@@ -86,18 +207,20 @@ def _parse_count_output(output):
filepath = line[:idx]
try:
count_val = int(line[idx + 1 :])
- counts[filepath] = count_val
+ if count_val > 0:
+ counts[filepath] = count_val
except (ValueError, IndexError):
pass
return counts
-def _parse_content_into_files(output):
+def _parse_content_into_files(output, null_separated=False):
"""Parse grep -rn output into per-file groups.
Returns list of dicts with keys: path, match_count, content_lines
Content lines preserve the original grep format (with : for matches, - for context).
Merges entries for the same file that appear in non-contiguous match groups.
+ When *null_separated* is set, each line is ``path\\0line:content``.
"""
if not output:
return []
@@ -143,36 +266,52 @@ def _flush_current():
if line == "--":
continue
- # Try to extract filename from the line prefix
- # Match lines: path:line:content
- # Context lines: path-line-content (with - hyphen after line number)
- m = re.match(r"^(.+?)[:-](\d+)[:-]", line)
- if m:
+ if null_separated:
+ if "\0" not in line:
+ continue
+
+ filepath, rest = line.split("\0", 1)
+ entry = re.match(r"^(\d+)([:-])(.*)$", rest)
+ if not entry:
+ continue
+
+ line_text = (
+ f"{filepath}{entry.group(2)}{entry.group(1)}{entry.group(2)}{entry.group(3)}"
+ )
+ is_match_line = entry.group(2) == ":"
+ else:
+ # Try to extract filename from the line prefix
+ # Match lines: path:line:content
+ # Context lines: path-line-content (with - hyphen after line number)
+ m = re.match(r"^(.+?)[:-](\d+)[:-]", line)
+ if not m:
+ # Line that doesn't match the pattern (e.g. rg --heading output)
+ if current_file is not None:
+ current_lines.append(line)
+ continue
+
filepath = m.group(1)
# Actually check: match lines have :LINE:, context lines have -LINE-
# The format is: path:line:content or path-line-content
# Check whether the char after line num is : or -
line_num_end = len(filepath) + 1 + len(m.group(2))
is_match_line = line_num_end < len(line) and line[line_num_end] == ":"
+ line_text = line
- if current_file is None:
- current_file = filepath
- match_count = 1 if is_match_line else 0
- current_lines = [line]
- elif filepath == current_file:
- current_lines.append(line)
- if is_match_line:
- match_count += 1
- else:
- # Different file - flush current block before switching
- _flush_current()
- current_file = filepath
- match_count = 1 if is_match_line else 0
- current_lines = [line]
+ if current_file is None:
+ current_file = filepath
+ match_count = 1 if is_match_line else 0
+ current_lines = [line_text]
+ elif filepath == current_file:
+ current_lines.append(line_text)
+ if is_match_line:
+ match_count += 1
else:
- # Line that doesn't match the pattern (e.g. rg --heading output)
- if current_file is not None:
- current_lines.append(line)
+ # Different file - flush current block before switching
+ _flush_current()
+ current_file = filepath
+ match_count = 1 if is_match_line else 0
+ current_lines = [line_text]
# Flush the last file's accumulated block
_flush_current()
@@ -192,6 +331,34 @@ def _flush_current():
return files
+def _extract_warnings(output, null_separated=False):
+ """Return diagnostic lines that are not match or context records."""
+ warnings = []
+ if not output:
+ return warnings
+
+ for raw_line in output.splitlines():
+ line = raw_line.strip()
+ if not line or line == "--":
+ continue
+
+ if null_separated:
+ if "\0" not in line:
+ warnings.append(line)
+ elif re.match(r"^(?:rg|ag|grep)(?:\.exe)?:\s", line, re.IGNORECASE):
+ warnings.append(line)
+
+ return warnings
+
+
+def _append_warnings(op_result, warnings):
+ """Add warnings to an operation result without duplicating messages."""
+ bucket = op_result.setdefault("warnings", [])
+ for warning in warnings:
+ if warning not in bucket:
+ bucket.append(warning)
+
+
def _cap_line(text, max_length=MAX_LINE_LENGTH):
"""Truncate a single line, marking how many characters were omitted."""
if len(text) <= max_length:
@@ -450,7 +617,11 @@ class Tool(BaseTool):
"file_glob": {
"type": "string",
"default": "*",
- "description": "Glob pattern for files to search.",
+ "description": (
+ "Glob pattern(s) for files to search. "
+ "Comma-separated lists are supported; use ** for "
+ "recursive matches and ! for ripgrep negation."
+ ),
},
"directory": {
"type": "string",
@@ -459,12 +630,19 @@ class Tool(BaseTool):
},
"use_regex": {
"type": "boolean",
- "description": "Whether to use regex search or literal text.",
+ "description": (
+ "Whether to use regex search or literal text. "
+ "Omit to auto-detect from the pattern and retry "
+ "the other interpretation when one finds nothing."
+ ),
},
"case_insensitive": {
"type": "boolean",
"default": True,
- "description": "Whether to perform a case-insensitive search.",
+ "description": (
+ "Whether to perform a case-insensitive search "
+ "(default true)."
+ ),
},
"mode": {
"type": "string",
@@ -480,21 +658,27 @@ class Tool(BaseTool):
"type": "boolean",
"default": True,
"description": (
- "Whether to run a fast count pass for exact totals."
+ "Whether to run a fast count pass for exact "
+ "totals. When false, per-file totals are capped "
+ f"at {MAX_LINE_NUMBERS} and may be approximate."
),
},
"context_before": {
"type": "integer",
"default": 0,
"description": (
- "Number of context lines to show before each match. Max 5"
+ "Number of context lines to show before each "
+ f"match. Max {MAX_CONTEXT_LINES}; larger values "
+ "are clamped with a warning."
),
},
"context_after": {
"type": "integer",
"default": 0,
"description": (
- "Number of context lines to show after each match. Max 5"
+ "Number of context lines to show after each "
+ f"match. Max {MAX_CONTEXT_LINES}; larger values "
+ "are clamped with a warning."
),
},
},
@@ -641,28 +825,42 @@ def execute(
pattern = strip_hashline(search_op.get("pattern", ""))
file_pattern = search_op.get("file_glob", "*")
directory = search_op.get("directory", search_op.get("path", "."))
- use_regex = _should_use_regex(pattern, search_op.get("use_regex"))
+ requested_regex = search_op.get("use_regex")
+ use_regex = _should_use_regex(pattern, requested_regex)
case_insensitive = search_op.get("case_insensitive", True)
mode = search_op.get("mode", "matches")
- if mode not in ("files", "matches"):
- mode = "matches"
# Context is an orthogonal knob, disabled by default for compact output.
- context_before = max(min(int(search_op.get("context_before", 0)), 5), 0)
- context_after = max(min(int(search_op.get("context_after", 0)), 5), 0)
+ context_before, context_before_warning = _parse_context_line_count(
+ search_op.get("context_before", 0), "context_before"
+ )
+ context_after, context_after_warning = _parse_context_line_count(
+ search_op.get("context_after", 0), "context_after"
+ )
count_enabled = search_op.get("count", True)
op_result = {
"pattern": pattern,
+ "regex": use_regex,
"mode": mode,
"total_matches": 0,
"total_files": 0,
"has_more_files": False,
"error": None,
- "files": [],
}
+ context_warnings = [
+ warning for warning in (context_before_warning, context_after_warning) if warning
+ ]
+ if context_warnings:
+ _append_warnings(op_result, context_warnings)
+
+ if mode not in ("files", "matches"):
+ op_result["error"] = f"Invalid mode '{mode}'. Expected 'files' or 'matches'."
+ all_operation_results.append(op_result)
+ continue
+
if not pattern:
op_result["error"] = "Search operation requires a non-empty 'pattern'."
all_operation_results.append(op_result)
@@ -676,6 +874,8 @@ def execute(
else:
search_dir_path = Path(repo.root) / expanded_directory
+ null_separated = tool_name in ("rg", "grep")
+
if tool_name == "powershell":
# PowerShell (Select-String) backend: build a pipeline script and
# invoke it via -EncodedCommand to avoid shell quoting issues.
@@ -694,94 +894,121 @@ def execute(
tool_path,
)
else:
- # 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"]
- else:
- if tool_name == "rg":
- pattern_flag = ["-F"]
- elif tool_name == "ag":
- pattern_flag = ["-Q"]
+
+ def _build_commands(regex_enabled):
+ pattern_flag = []
+ if regex_enabled:
+ if tool_name == "grep":
+ pattern_flag = ["-P"] if _grep_supports_pcre(tool_path) else ["-E"]
+ elif tool_name == "rg" and _needs_pcre2(pattern):
+ pattern_flag = ["--pcre2"]
+ 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 = []
+ glob_patterns = _split_glob_patterns(file_pattern)
+ if glob_patterns != ["*"]:
+ if tool_name == "rg":
+ for glob_pattern in glob_patterns:
+ file_filter.extend(["-g", glob_pattern])
+ elif tool_name == "ag":
+ for glob_pattern in glob_patterns:
+ file_filter.extend(["-G", glob_pattern])
+ elif tool_name == "grep":
+ file_filter = ["-r"]
+ for glob_pattern in glob_patterns:
+ file_filter.append(f"--include={glob_pattern}")
elif tool_name == "grep":
- pattern_flag = ["-F"]
+ file_filter = ["-r"]
- # Case sensitivity
- case_flag = ["-i"] if case_insensitive else []
+ # Exclusions
+ exclude_args = []
+ _build_exclude_args(tool_name, exclude_args, exclude_cecli=not creator)
- # File filtering
- file_filter = []
- if file_pattern != "*":
+ null_flag = []
if tool_name == "rg":
- file_filter = ["-g", file_pattern]
+ null_flag = ["--null"]
+ elif tool_name == "grep":
+ null_flag = ["-Z"]
+
+ # --- PASS1: Build count command (fast, no context) ---
+ count_cmd_parts = [tool_path]
+ if tool_name == "rg":
+ count_cmd_parts.extend(["-c", "--no-config"])
elif tool_name == "ag":
- file_filter = ["-G", file_pattern]
+ count_cmd_parts.append("-rc")
+ else:
+ # -I skips binary files, matching the content pass.
+ count_cmd_parts.extend(["-rnc", "-I"])
+ count_cmd = (
+ count_cmd_parts
+ + case_flag
+ + pattern_flag
+ + file_filter
+ + exclude_args
+ + null_flag
+ + ["--", pattern, str(search_dir_path)]
+ )
+ count_string = oslex.join(count_cmd)
+
+ # Source-level caps: bound matches per file and line length so
+ # post-processing never sees unbounded content.
+ output_cap_args = []
+ if tool_name == "rg":
+ output_cap_args = [
+ "-m",
+ str(MAX_LINE_NUMBERS),
+ "--max-columns",
+ str(MAX_LINE_LENGTH),
+ "--max-columns-preview",
+ ]
elif tool_name == "grep":
- file_filter = ["-r", f"--include={file_pattern}"]
- elif tool_name == "grep":
- file_filter = ["-r"]
-
- # Exclusions
- exclude_args = []
- _build_exclude_args(tool_name, exclude_args, exclude_cecli=not creator)
-
- # --- PASS1: Build count command (fast, no context) ---
- count_cmd_parts = [tool_path]
- if tool_name == "rg":
- count_cmd_parts.append("-c")
- elif tool_name == "ag":
- count_cmd_parts.append("-rc")
- else:
- # -I skips binary files, matching the content pass.
- count_cmd_parts.append("-rnc")
- count_cmd_parts.append("-I")
- count_cmd = (
- count_cmd_parts
- + case_flag
- + pattern_flag
- + exclude_args
- + file_filter
- + ["--", pattern, str(search_dir_path)]
- )
- count_string = oslex.join(count_cmd)
-
- # Source-level caps: bound matches per file and line length so
- # post-processing never sees unbounded content.
- output_cap_args = []
- if tool_name == "rg":
- output_cap_args = [
- "-m",
- str(MAX_LINE_NUMBERS),
- "--max-columns",
- str(MAX_LINE_LENGTH),
- "--max-columns-preview",
- ]
- elif tool_name == "grep":
- # -I skips binary files; -m bounds matches per file.
- output_cap_args = ["-I", "-m", str(MAX_LINE_NUMBERS)]
-
- # --- PASS2: Build content with context ---
- content_cmd = (
- base_cmd
- + output_cap_args
- + (["-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)
+ # -I skips binary files; -m bounds matches per file.
+ output_cap_args = ["-I", "-m", str(MAX_LINE_NUMBERS)]
+
+ # --- PASS2: Build content with context ---
+ base_cmd = [tool_path, "-n"]
+ if tool_name == "rg":
+ base_cmd.extend(
+ [
+ "--with-filename",
+ "--no-heading",
+ "--color=never",
+ "--no-config",
+ ]
+ )
+
+ content_cmd = (
+ base_cmd
+ + output_cap_args
+ + null_flag
+ + (["-B", str(context_before)] if context_before > 0 else [])
+ + (["-A", str(context_after)] if context_after > 0 else [])
+ + case_flag
+ + pattern_flag
+ + file_filter
+ + exclude_args
+ + ["--", pattern, str(search_dir_path)]
+ )
+ content_string = oslex.join(content_cmd)
+
+ return count_string, content_string
+
+ count_string, content_string = _build_commands(use_regex)
# --- PASS1: Get match counts (fast, no context) ---
counts = {}
+ count_status = 0
+ count_output = ""
if count_enabled and tool_name != "powershell":
coder.io.tool_output(
f"⛭ Counting matches with {tool_name}: '{pattern}' in {directory}",
@@ -793,8 +1020,54 @@ def execute(
cwd=coder.root,
should_print=False,
)
- if count_status == 0:
- counts = _parse_count_output(count_output)
+ if count_status in (0, 1, 2):
+ counts = _parse_count_output(count_output, null_separated=null_separated)
+
+ # Auto mode retries the other interpretation when one comes back
+ # empty or invalid. This is what makes unescaped regex syntax work
+ # without turning ordinary literal patterns into regexes.
+ should_try_alternate = False
+ if requested_regex is None and not counts and _has_regex_meta(pattern):
+ should_try_alternate = True
+ elif requested_regex is True and not counts and count_status == 2:
+ should_try_alternate = True
+
+ if should_try_alternate:
+ alternate_regex = not use_regex
+ alternate_count_string, alternate_content_string = _build_commands(
+ alternate_regex
+ )
+ alternate_status, alternate_output = run_cmd_subprocess(
+ alternate_count_string,
+ verbose=coder.verbose,
+ cwd=coder.root,
+ should_print=False,
+ )
+ alternate_counts = {}
+ if alternate_status in (0, 1, 2):
+ alternate_counts = _parse_count_output(
+ alternate_output, null_separated=null_separated
+ )
+
+ if alternate_counts or (not alternate_regex and alternate_status in (0, 1)):
+ use_regex = alternate_regex
+ counts = alternate_counts
+ count_status = alternate_status
+ count_output = alternate_output
+ count_string = alternate_count_string
+ content_string = alternate_content_string
+ op_result["regex"] = use_regex
+
+ if requested_regex is True:
+ _append_warnings(
+ op_result,
+ ["Regex search failed; retried as a literal string."],
+ )
+
+ if count_status == 2 and null_separated:
+ count_warnings = _extract_warnings(count_output, null_separated=True)
+ if count_warnings:
+ _append_warnings(op_result, count_warnings)
# "files" mode only needs counts, so skip the content pass entirely.
if mode == "files" and counts:
@@ -812,23 +1085,10 @@ def execute(
op_result["total_matches"] = sum(count for _, count in rel_files)
op_result["total_files"] = len(rel_files)
op_result["has_more_files"] = len(rel_files) > MAX_FILES
- op_result["files"] = [
- {
- "file": rel_path,
- "match_count": file_count,
- "shown": 0,
- "truncated": False,
- "additional_line_numbers": [],
- }
- for rel_path, file_count in shown_files
- ]
op_result["_lines"] = [
{
"file": rel_path,
"match_count": file_count,
- "shown": 0,
- "truncated": False,
- "additional_line_numbers": [],
"lines": [f"{rel_path}: {file_count}"],
}
for rel_path, file_count in shown_files
@@ -850,11 +1110,23 @@ def execute(
output_content = content_output or ""
- if content_status == 0 and output_content:
+ parsed_files = []
+ if 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)
+ parsed_files = _parse_content_into_files(
+ output_content, null_separated=null_separated
+ )
+
+ if content_status == 2 and parsed_files:
+ content_warnings = _extract_warnings(
+ output_content, null_separated=null_separated
+ )
+ if content_warnings:
+ _append_warnings(op_result, content_warnings)
+
+ if parsed_files:
# Merge in counts from pass 1 if available
if counts:
@@ -877,6 +1149,7 @@ def execute(
has_more = total_files > MAX_FILES
rendered = []
+ count_capped = False
for pf in parsed_files[:MAX_FILES]:
rel_path = safe_relpath(pf["path"], repo.root)
count = pf.get("count_from_pass", 0)
@@ -886,10 +1159,15 @@ def execute(
matches = [entry for entry in entries if entry["is_match"]]
extra_line_numbers = [
entry["line"] for entry in matches[MAX_MATCHES_PER_FILE:]
- ]
+ ][:MAX_ADDITIONAL_LINE_NUMBERS]
+ count_capped_here = False
if mode == "files":
lines = [f"{rel_path}: {count}"]
+ if not count_enabled and len(matches) >= MAX_LINE_NUMBERS:
+ count_capped = True
+ count_capped_here = True
+ match_seen = 0
else:
lines = [f"{rel_path}: {count} match(es)"]
match_seen = 0
@@ -905,49 +1183,94 @@ def execute(
f" {entry['line']}{marker} {_cap_line(entry['text'])}"
)
+ if not count_enabled and len(matches) >= MAX_LINE_NUMBERS:
+ count_capped = True
+ count_capped_here = True
+
shown_count = match_seen if mode == "matches" else 0
- rendered.append(
- {
- "file": rel_path,
- "match_count": count,
- "shown": shown_count,
- "truncated": mode == "matches" and count > shown_count,
- "additional_line_numbers": extra_line_numbers,
- "lines": lines,
- }
- )
+ truncated = mode == "matches" and count > shown_count
+ if truncated:
+ lines.append(f" … showing {shown_count} of {count} matches")
+ if extra_line_numbers:
+ extra = ", ".join(str(num) for num in extra_line_numbers)
+ lines.append(f" … +{count - shown_count} more locations: {extra}")
+ if count_capped_here:
+ lines.append(f" … count capped at {MAX_LINE_NUMBERS} per file")
+
+ item = {
+ "file": rel_path,
+ "match_count": count,
+ "lines": lines,
+ }
+ if shown_count:
+ item["shown"] = shown_count
+ if truncated:
+ item["truncated"] = True
+ if extra_line_numbers:
+ item["additional_line_numbers"] = extra_line_numbers
+ rendered.append(item)
if has_more:
for pf in parsed_files[MAX_FILES:]:
total_matches += pf.get("count_from_pass", 0)
+ if count_capped:
+ _append_warnings(
+ op_result,
+ [
+ "count=false: per-file totals are capped at "
+ f"{MAX_LINE_NUMBERS}; enable count for exact totals."
+ ],
+ )
+
# Byte-aware eviction: drop the largest blocks until under budget so
# a single pathological file cannot crowd out many small ones.
+ files_omitted_for_size = 0
while rendered and (
sum(len("\n".join(item["lines"])) + 1 for item in rendered) > MAX_TOTAL_SIZE
):
largest = max(rendered, key=lambda item: len("\n".join(item["lines"])))
rendered.remove(largest)
+ files_omitted_for_size += 1
+
+ if files_omitted_for_size:
has_more = True
+ op_result["files_omitted_for_size"] = files_omitted_for_size
op_result["total_matches"] = total_matches
op_result["total_files"] = total_files
op_result["has_more_files"] = has_more
- op_result["files"] = [
- {key: value for key, value in item.items() if key != "lines"}
- for item in rendered
- ]
op_result["_lines"] = rendered
- elif content_status == 1 or not output_content:
+ elif content_status in (0, 1) or not output_content:
op_result["total_matches"] = 0
op_result["total_files"] = 0
+ if output_content:
+ content_warnings = _extract_warnings(
+ output_content, null_separated=null_separated
+ )
+ if content_warnings:
+ _append_warnings(op_result, content_warnings)
else:
op_result["error"] = output_content
except Exception as e:
op_result["error"] = f"Error executing search: {str(e)}"
+ if (
+ op_result.get("total_matches", 0) == 0
+ and not op_result.get("error")
+ and file_pattern
+ and file_pattern != "*"
+ ):
+ _append_warnings(
+ op_result,
+ [
+ f"No matches with file_glob={file_pattern!r}; "
+ "verify the glob matches files (comma lists are supported)."
+ ],
+ )
+
all_operation_results.append(op_result)
# Cap total output across operations, dropping the largest rendered blocks
@@ -975,8 +1298,19 @@ def execute(
break
largest_op["_lines"].pop(largest_idx)
- largest_op["files"].pop(largest_idx)
largest_op["has_more_files"] = True
+ largest_op["files_omitted_for_size"] = largest_op.get("files_omitted_for_size", 0) + 1
+
+ for op_result in all_operation_results:
+ omitted = op_result.get("files_omitted_for_size", 0)
+ if omitted:
+ _append_warnings(
+ op_result,
+ [
+ f"{omitted} file result block(s) omitted to stay within "
+ f"the {MAX_TOTAL_SIZE} byte output budget."
+ ],
+ )
# TUI summary
if coder.tui and coder.tui():
@@ -998,21 +1332,37 @@ def execute(
response = ToolResponse(cls.NORM_NAME, result_type=cls.RESULT_TYPE)
for op_result in all_operation_results:
pattern = op_result.get("pattern", "")
+ pattern_label = pattern or ""
if op_result.get("error"):
- body = [f"[{pattern}]", f"Error: {op_result['error']}"]
+ body = [
+ f"[{pattern_label}]",
+ f"Error: {_cap_line(str(op_result['error']).strip(), 1000)}",
+ ]
elif op_result.get("total_matches", 0) == 0:
- body = [f"[{pattern}]", "No matches found."]
+ body = [f"[{pattern_label}]", "No matches found."]
else:
- body = [f"[{pattern}]"]
+ body = [f"[{pattern_label}]"]
for item in op_result.get("_lines", []):
body.extend(item["lines"])
if op_result.get("has_more_files"):
body.append(
f"... ({op_result['total_files']} files total, "
- f"showing {len(op_result['files'])})"
+ f"showing {len(op_result.get('_lines', []))})"
)
+ warnings = op_result.get("warnings", [])
+ if op_result.get("error"):
+ warnings = [
+ warning for warning in warnings if warning not in str(op_result["error"])
+ ]
+ if warnings:
+ op_result["warnings"] = warnings
+ else:
+ op_result.pop("warnings", None)
+
+ for warning in warnings[:5]:
+ body.append(f"Warning: {_cap_line(warning)}")
metadata = {key: value for key, value in op_result.items() if key != "_lines"}
response.append_result(content="\n".join(body), metadata=metadata)
@@ -1044,8 +1394,12 @@ def format_output(cls, coder, mcp_server, tool_response):
use_regex = _should_use_regex(pattern, search_op.get("use_regex"))
case_insensitive = search_op.get("case_insensitive", True)
mode = search_op.get("mode", "matches")
- context_before = search_op.get("context_before", 0)
- context_after = search_op.get("context_after", 0)
+ context_before, _ = _parse_context_line_count(
+ search_op.get("context_before", 0), "context_before"
+ )
+ context_after, _ = _parse_context_line_count(
+ search_op.get("context_after", 0), "context_after"
+ )
formatted_query = (
f"{color_start}search_{i + 1}:{color_end} {pattern} • {file_pattern} •"
diff --git a/cecli/tools/shell.py b/cecli/tools/shell.py
index fca3f48a54e..c0c5b12feff 100644
--- a/cecli/tools/shell.py
+++ b/cecli/tools/shell.py
@@ -519,6 +519,7 @@ async def _execute_with_timeout(cls, coder, command_string, timeout, use_pty=Non
return response
# Timeout elapsed, process continues in background
+ BackgroundCommandManager.mark_continued_in_background(command_key)
coder.io.tool_output(
f"\u23f1\ufe0f Command exceeded {timeout}s timeout, continuing in background...",
type="tool-result",
diff --git a/cecli/tools/undo_change.py b/cecli/tools/undo_change.py
index 34c099b22ac..80f1bb0e87d 100644
--- a/cecli/tools/undo_change.py
+++ b/cecli/tools/undo_change.py
@@ -36,6 +36,8 @@ def execute(cls, coder, change_id=None, file_path=None, **kwargs):
Returns a result message.
"""
+ from cecli.helpers.conversation import ConversationService
+
# Note: Undo does not have a dry_run parameter as it's inherently about reverting a previous action.
coder.edit_allowed = False
response = ToolResponse(cls.NORM_NAME)
@@ -72,6 +74,8 @@ def execute(cls, coder, change_id=None, file_path=None, **kwargs):
abs_path = coder.abs_root_path(file_path)
# Write the original content back to the file
coder.io.write_text(abs_path, change_info["original"])
+ if getattr(coder, "uuid", None):
+ ConversationService.get_chunks(coder).notify_file_edited(abs_path)
coder.coder_edited_files.add(
file_path
) # Track that the file was modified by the undo
diff --git a/cecli/tools/utils/helpers.py b/cecli/tools/utils/helpers.py
index ba749024d17..5bc20995aa1 100644
--- a/cecli/tools/utils/helpers.py
+++ b/cecli/tools/utils/helpers.py
@@ -75,6 +75,19 @@ def resolve_paths(coder, file_path):
raise ToolError(f"Error resolving path '{file_path}': {e}")
+def read_text_preserving_newlines(coder, abs_path):
+ """Read a file while retaining its detected CRLF line endings."""
+ content = coder.io.read_text(abs_path)
+ if content is None:
+ return None
+
+ detect_newline = getattr(coder.io, "_detect_newline", None)
+ if detect_newline and detect_newline(abs_path) == "\r\n":
+ content = content.replace("\r\n", "\n").replace("\n", "\r\n")
+
+ return content
+
+
def validate_file_for_edit(coder, file_path):
"""
Validates if a file exists, is in context, and is editable.
@@ -101,7 +114,7 @@ def validate_file_for_edit(coder, file_path):
# )
# Reread content immediately before potential modification
- content = coder.io.read_text(abs_path)
+ content = read_text_preserving_newlines(coder, abs_path)
if content is None:
# This indicates an issue reading a file we know exists and is in context
coder.io.tool_error(
diff --git a/cecli/tui/app.py b/cecli/tui/app.py
index 7a6bdc825cb..3fc6d89d3ee 100644
--- a/cecli/tui/app.py
+++ b/cecli/tui/app.py
@@ -16,6 +16,7 @@
from textual import events
from textual.app import App, ComposeResult
from textual.containers import Horizontal, Vertical
+from textual.css.query import NoMatches
from textual.theme import Theme
from textual.widgets import Static
@@ -1679,7 +1680,10 @@ def remove_sub_agent_container(self, uuid: str) -> None:
# reset foreground_uuid by the time we run here.
agent_service = AgentService.get_instance(self.worker.coder)
agent_service.foreground_uuid = None
- primary = self.query_one("#output", OutputContainer)
+ try:
+ primary = self.query_one("#output", OutputContainer)
+ except NoMatches:
+ return
primary.display = True
self._refresh_footer()
@@ -1757,7 +1761,12 @@ def _sync_sub_agent_display(self) -> None:
Delegates to the InputContainer itself, which queries AgentService
via self.app to build the pill indicators.
"""
- input_container = self.query_one("#input-container", InputContainer)
+ try:
+ input_container = self.query_one("#input-container", InputContainer)
+ except NoMatches:
+ # The DOM is already torn down when a notification arrives during
+ # shutdown; there is nothing left to sync.
+ return
coder = self.worker.coder
mode = getattr(coder, "edit_format", "code") or "code"
input_container.update_mode(mode)
diff --git a/cecli/tui/widgets/background_commands_panel.py b/cecli/tui/widgets/background_commands_panel.py
index 27bf4ef51c8..51745de4d07 100644
--- a/cecli/tui/widgets/background_commands_panel.py
+++ b/cecli/tui/widgets/background_commands_panel.py
@@ -18,6 +18,9 @@ class BackgroundCommandsPanel(Vertical):
the selected command's output is re-rendered only when its stream offset
changes. Reading output is non-destructive so the agent keeps its own
incremental view of the same buffer.
+
+ Commands that exceed their timeout and keep running appear here once the
+ timeout fires, so the user can watch them continue.
"""
REFRESH_INTERVAL = 0.5
@@ -112,14 +115,16 @@ def _is_shown(self) -> bool:
return self.parent is None or self.parent.display
def _active_commands(self) -> Dict[str, dict]:
- """Return only running commands started in the background."""
+ """Return running commands, including timeouts that continued in the background."""
commands = {}
for command_key, info in BackgroundCommandManager.list_background_commands().items():
if not info.get("running"):
continue
- if info.get("persist"):
+ # Commands started with background=True are always listed; timeout-managed
+ # commands only once their timeout elapsed and they kept running.
+ if info.get("persist") and not info.get("continued_in_background"):
continue
commands[command_key] = info
@@ -127,22 +132,30 @@ def _active_commands(self) -> Dict[str, dict]:
return commands
def _sync_tabs(self, commands: Dict[str, dict]) -> None:
- """Rebuild the tab bar when the set of active commands changes."""
+ """Add and remove tabs so they match the active command set.
+
+ Tabs must be updated incrementally: ``Tabs.clear`` removes its children
+ asynchronously, so clearing and immediately re-adding the same IDs
+ races with the pending removal and raises ``DuplicateIds``.
+ """
tabs = self.query_one("#bg-command-tabs", Tabs)
+ previous_keys = list(self._keys)
self._keys = list(commands)
with self.prevent(Tabs.TabActivated):
- tabs.clear()
+ for command_key in previous_keys:
+ if command_key not in commands:
+ tabs.remove_tab(command_key)
for command_key in commands:
- tabs.add_tab(Tab(command_key, id=command_key, classes="bg-tab"))
+ if command_key not in previous_keys:
+ tabs.add_tab(Tab(command_key, id=command_key, classes="bg-tab"))
- if self._active_key in commands:
- tabs.active = self._active_key
- else:
+ if self._active_key not in commands:
self._active_key = next(iter(commands), None)
- if self._active_key is not None:
- tabs.active = self._active_key
+
+ if self._active_key is not None:
+ tabs.active = self._active_key
def _select_tab(self, command_key: str) -> None:
"""Mark a tab active without relying on a user click."""
@@ -163,6 +176,10 @@ def _set_command_line(self, info: dict) -> None:
"""Show the selected command, truncated to the panel width."""
command = " ".join((info.get("command") or "").split())
text = f"command: {command}" if command else "command: (none)"
+
+ if info.get("continued_in_background"):
+ text += " (timed out, still running)"
+
width = max(20, self.size.width - 6)
if len(text) > width:
diff --git a/cecli/website/docs/config/agent-mode.md b/cecli/website/docs/config/agent-mode.md
index 26ea45c5b26..11d3339baae 100644
--- a/cecli/website/docs/config/agent-mode.md
+++ b/cecli/website/docs/config/agent-mode.md
@@ -287,7 +287,7 @@ Tools are loaded automatically when the registry is built and will be available
#### Sub-agent Behavior
-`Delegate` accepts one or more delegation objects. Each delegation includes a registered sub-agent `name`, a `prompt`, and optional `async` and `persist` flags. Asynchronous delegations run in the background; synchronous delegations wait for the sub-agent result. The system uses `Yield` to wait for outstanding child tasks when finishing the parent task.
+`Delegate` accepts one or more delegation objects. Each delegation includes a registered sub-agent `name`, a `prompt`, and optional `working_dir`, `async` and `persist` properties. `working_dir` sets that delegation's working directory and resolves relative paths against the dispatching agent's root. Asynchronous delegations run in the background; synchronous delegations wait for the sub-agent result. The system uses `Yield` to wait for outstanding child tasks when finishing the parent task.
Sub-agent results are reported back to the parent conversation as summaries or errors. Sub-agents use `auto_reap: true` by default, so completed agents can be removed automatically after their work and descendants finish. Independent agents may be cleaned up shortly after completion, while the service also reaps completed agents when the configured limit requires space. Set `max_sub_agents` to `-1` to remove the limit on simultaneous sub agents.
diff --git a/cecli/website/docs/config/subagents.md b/cecli/website/docs/config/subagents.md
index 60773b0fd2f..ec643638601 100644
--- a/cecli/website/docs/config/subagents.md
+++ b/cecli/website/docs/config/subagents.md
@@ -112,8 +112,29 @@ The primary agent can also delegate work using the `Delegate` tool. This enables
4. Sub-agents work independently and return their summaries
5. The primary agent synthesizes the results
+#### Delegation Working Directory
+
+Each entry in the `Delegate` tool's `delegations` array accepts an optional `working_dir` string. It sets the working directory (the sub-agent coder's root) for that delegation only.
+
+- Relative paths resolve against the dispatching agent's root.
+- Absolute paths are used as-is.
+- Omitting `working_dir` keeps the normal inheritance behavior: the sub-agent uses the parent agent's root, or the workflow/workspace/worktree root when applicable.
+
+```json
+{
+ "delegations": [
+ {
+ "name": "reviewer",
+ "prompt": "Review the API package",
+ "working_dir": "packages/api"
+ }
+ ]
+}
+```
+
### Spawning a Sub-Agent Without a Prompt
+
Creates a sub-agent that waits for you to interact with it directly:
```
diff --git a/cecli/website/docs/config/workflows.md b/cecli/website/docs/config/workflows.md
index 24711f8d05f..1bad2dc7eee 100644
--- a/cecli/website/docs/config/workflows.md
+++ b/cecli/website/docs/config/workflows.md
@@ -40,6 +40,8 @@ enabled: true # optional (default: true)
lease_timeout: 30 # optional minutes (default: 30)
model: # optional model override for the worker
root: ~/repos/my-project # optional working directory
+repo: /repos/app # optional worktree source (local path or remote URL)
+worktree: true # optional isolated git worktree; or {branch: main}
hooks: # optional, same schema as Hooks
start: [{ name: audit, command: "echo start >> ~/.cecli/workflows/audit.log" }]
skills: [gitlab-ci] # optional -> preloaded as skills_init
@@ -61,6 +63,8 @@ tasks:
Keys are `snake_case` or `hyphen-case`; both accepted. `${ENV_VAR}` / `$VAR` inside any string is interpolated from the environment (after `.env` files load).
+Validation notes: `repo` must be a string. `worktree` must be a boolean or a mapping; a mapping currently supports only `branch`, which must be a string (unknown keys warn and are ignored). When `worktree` is enabled, the source repository is inferred from `repo`, then `root`, then the git top-level containing the workflow file; if none can be found a warning is logged and the dispatching agent's root is used at run time.
+
Field meanings:
| field | default | meaning |
@@ -74,6 +78,8 @@ Field meanings:
| `lease_timeout` | 30 | Minutes a scheduler instance holds the job lock before peers may steal an abandoned run |
| `model` | inherited | Model (or sentinel) for the worker sub-agent |
| `root` | session root | Working directory the worker operates in |
+| `repo` | — | Source repository for the optional worktree: a local path or remote URL. When the worktree is enabled, the run's root becomes the generated worktree checkout |
+| `worktree` | disabled | Set `true` to run the worker in an isolated git worktree, or `{branch: [}` to choose the base ref to start from. See [Worktrees](worktrees.html) |
| `hooks` | {} | Per-workflow hooks (start/end/pre_tool/…); run on the worker |
| `skills` | [] | Skills loaded into the worker's context at start |
| `servers` | [] | MCP servers made available to the worker |
@@ -102,6 +108,12 @@ Run-time placeholders are expanded in task prompts in addition to load-time env
Because a run is a sub-agent, it doesn't block the main conversation — watch it with `/agent-tree` or the TUI sub-agent pane. Every run also appends a JSON-lines audit log to `~/.cecli/workflows/runs//..jsonl`: a `start` line (with the definition hash), one `task` line per task, and a `finish` line. Before the worker is reaped, its full session is archived to `~/.cecli/workflows/outputs///session.json` using the standard session format (encrypted when session encryption is enabled) — so every run, including replayed or tool-less replies, is completely reviewable on disk without ever touching the parent's context.
+## Isolated worktrees
+
+Set `worktree: true` to give the run its own git worktree instead of operating directly in `root`. The worker starts on a generated branch `cecli/-` (for example `cecli/wf-nightly-tidy-`) based on `branch` (the starting base ref) or the source repository's default branch. `repo` selects the source repository; if it is omitted, `root` (or the workflow file's git root) is used. The agent's actual root is the generated worktree checkout.
+
+When the run finishes, a dirty worktree is committed with the subject `Unfinished Dirty Worktree from agent ` before the worktree is released. Branches with unmerged commits are kept for review; empty or already-merged branches are deleted. The worktree checkout under `~/.cecli/worktrees/` is removed after the commit/release step. See [Worktrees](worktrees.html) for the full lifecycle and on-disk layout.
+
## Commands
```
@@ -124,6 +136,9 @@ Because a run is a sub-agent, it doesn't block the main conversation — watch i
name: nightly-tidy
schedule: "0 3 * * *"
timezone: America/New_York
+repo: /repos/app
+worktree:
+ branch: main
skills: [code-review]
tasks:
- prompt: "Run the linter over the repo and fix obvious issues."
diff --git a/cecli/website/docs/config/worktrees.md b/cecli/website/docs/config/worktrees.md
new file mode 100644
index 00000000000..ef091cd63a7
--- /dev/null
+++ b/cecli/website/docs/config/worktrees.md
@@ -0,0 +1,40 @@
+---
+parent: Configuration
+nav_order: 49
+description: Isolated git worktrees give each agent or workflow run its own checkout and branch.
+---
+# Worktrees
+
+Worktrees let an agent or workflow run modify a repository without touching the main checkout. cecli creates a linked git worktree, runs the agent inside it, and releases it afterwards — committing unfinished changes so nothing is lost.
+
+## Enabling
+
+Workflows opt in with `worktree: true` (or `worktree: {branch: ][}`); see [Workflows](workflows.html). A workflow's `repo` is the source repository; `root` selects the base working directory. When a worktree is enabled, the agent's actual root is the generated worktree checkout. `branch` selects the starting base ref; when omitted the source repository's default branch is used.
+
+## Branch namespace
+
+Every worktree gets a generated branch named `cecli/-`. Agent names are sanitized for git, so `wf:nightly-tidy` becomes `cecli/wf-nightly-tidy-`. Generated branches live alongside your normal branches and can be inspected, pushed, or merged like any other branch.
+
+## Lifecycle
+
+1. **Create** — a worktree is created from the source repository on the generated branch. An existing checkout for the same agent and repository is reused (ownership is refreshed) instead of creating a duplicate, so repeated or concurrent workflow runs share one checkout. Remote sources are cached as bare repositories first.
+2. **Run** — the agent works inside the worktree; its `root` points at the checkout.
+3. **Release** — on cleanup, a dirty worktree is committed with the subject `Unfinished Dirty Worktree from agent ` so uncommitted work survives. Branches with unmerged commits are kept; empty or already-merged branches are deleted.
+4. **Remove** — the worktree checkout under `~/.cecli/worktrees/` is removed after the commit/release step. Kept branches remain in the source repository.
+
+## Session restore
+
+A workflow or delegated agent restored from a saved session keeps its worktree record (`branch`, `path` and related metadata). On restore, cecli recreates or re-attaches the worktree from that record when possible; if the checkout is gone but the branch still contains work, the branch is the durable artifact to recover.
+
+## Disk layout
+
+`CECLI_WORKTREES_DIR` overrides the base directory; the default is `~/.cecli/worktrees/`.
+
+```
+~/.cecli/worktrees/
+ _sources/-/ # cached bare clones for remote sources
+ /
+ -/ # one linked worktree checkout per agent
+```
+
+Local `repo:` paths are used in place as the source repository; the checkout itself still lives under the worktrees base directory. Removing a checkout or the whole base directory is safe once a run has finished: commits are on the kept branch, and empty branches are disposable.
\ No newline at end of file
diff --git a/tests/basic/test_background_commands.py b/tests/basic/test_background_commands.py
index cdf9abb7e82..0770c68a40b 100644
--- a/tests/basic/test_background_commands.py
+++ b/tests/basic/test_background_commands.py
@@ -402,3 +402,24 @@ def test_get_background_command_output_stops_finished_commands(monkeypatch):
assert "finished" in output
assert stopped == ["bg_3_0001"]
+
+
+def test_mark_continued_in_background_flags_registered_command(monkeypatch):
+ """Timeout-managed commands are flagged so the panel can list them."""
+ process = types.SimpleNamespace(continued_in_background=False)
+ monkeypatch.setattr(BackgroundCommandManager, "_background_commands", {"bg_1_1234": process})
+
+ assert BackgroundCommandManager.mark_continued_in_background("bg_1_1234") is True
+ assert process.continued_in_background is True
+ assert BackgroundCommandManager.mark_continued_in_background("bg_9_9999") is False
+
+
+def test_mark_continued_in_background_flags_command(monkeypatch):
+ """A timeout-managed command can be flagged as continuing in the background."""
+ process = types.SimpleNamespace(continued_in_background=False)
+ monkeypatch.setattr(BackgroundCommandManager, "_background_commands", {"bg_1_1234": process})
+
+ assert BackgroundCommandManager.mark_continued_in_background("bg_1_1234") is True
+ assert process.continued_in_background is True
+
+ assert BackgroundCommandManager.mark_continued_in_background("bg_9_9999") is False
diff --git a/tests/basic/test_file_system_service.py b/tests/basic/test_file_system_service.py
index a154dab43e9..77e564df33c 100644
--- a/tests/basic/test_file_system_service.py
+++ b/tests/basic/test_file_system_service.py
@@ -8,7 +8,7 @@
from cecli.helpers.file_system import FileSystemService
from cecli.io import InputOutput
from cecli.repo import GitRepoProxy
-from cecli.utils import GitTemporaryDirectory
+from cecli.utils import ChdirTemporaryDirectory, GitTemporaryDirectory
@pytest.fixture(autouse=True)
@@ -156,6 +156,23 @@ async def test_coder_gets_per_root_fs_and_repo(self, gpt35_model):
class TestRootOverride:
"""Validates the sub-agent root override and primary_root retention."""
+ async def test_root_falls_back_to_cwd_outside_git(self, gpt35_model):
+ with ChdirTemporaryDirectory() as root:
+ io = InputOutput(pretty=False, yes=True)
+ coder = await Coder.create(gpt35_model, None, io)
+
+ assert os.path.normpath(coder.root) == os.path.normpath(root)
+ assert os.path.normpath(coder.primary_root) == os.path.normpath(root)
+
+ async def test_sub_agent_root_falls_back_to_primary_root_outside_git(self, gpt35_model):
+ with ChdirTemporaryDirectory():
+ io = InputOutput(pretty=False, yes=True)
+ parent = await Coder.create(gpt35_model, None, io)
+ child = await Coder.create(from_coder=parent, io=io)
+
+ assert os.path.normpath(child.root) == os.path.normpath(parent.root)
+ assert os.path.normpath(child.primary_root) == os.path.normpath(parent.root)
+
async def test_root_kwarg_overrides_working_dir(self, gpt35_model):
with GitTemporaryDirectory() as root_a:
os.chdir(root_a)
diff --git a/tests/basic/test_hashline.py b/tests/basic/test_hashline.py
index f2eefaa8b08..cb69025642b 100644
--- a/tests/basic/test_hashline.py
+++ b/tests/basic/test_hashline.py
@@ -2,6 +2,7 @@
from cecli.helpers.hashline import (
ContentHashError,
+ _detect_overlapping_ranges,
apply_hashline_operations,
get_hashline_diff,
hashline,
@@ -9,6 +10,7 @@
resolve_content_to_hashline_ids,
strip_hashline,
)
+from cecli.helpers.hashpos.hashpos import HashPos
def test_hashline_basic():
@@ -359,3 +361,289 @@ def test_empty_insert_is_a_noop():
assert new_content == original_content
assert successful == []
assert failed[0]["failure_type"] == "no_change"
+
+
+def test_append_at_eof_with_000_markers():
+ """Using 000@ for both boundaries appends without replacing the last line."""
+ original_content = "first\nsecond\n"
+
+ new_content, successful, failed = apply_hashline_operations(
+ original_content,
+ [
+ {
+ "start_line_hash": "000@",
+ "end_line_hash": "000@",
+ "operation": "replace",
+ "text": "appended",
+ }
+ ],
+ )
+
+ assert new_content == "first\nsecond\nappended\n"
+ assert successful == [0]
+ assert failed == []
+
+
+def test_multiline_anchor_is_rejected():
+ """Anchors must be single-line; multiline values fail clearly."""
+ original_content = "alpha\nbeta\ngamma\n"
+
+ new_content, successful, failed = apply_hashline_operations(
+ original_content,
+ [
+ {
+ "start_line_hash": "alpha\nbeta",
+ "end_line_hash": "alpha\nbeta",
+ "operation": "replace",
+ "text": "changed",
+ }
+ ],
+ )
+
+ assert new_content == original_content
+ assert successful == []
+ assert "single-line" in failed[0]["error"]
+
+
+def test_partial_line_anchor_error_is_explicit():
+ """A substring anchor must explain that the complete line is required."""
+ original_content = "def main():\n pass\n"
+
+ new_content, successful, failed = apply_hashline_operations(
+ original_content,
+ [
+ {
+ "start_line_hash": "def main",
+ "end_line_hash": "def main",
+ "operation": "replace",
+ "text": "changed",
+ }
+ ],
+ )
+
+ assert new_content == original_content
+ assert successful == []
+ assert "only partially matches" in failed[0]["error"]
+ assert "complete line" in failed[0]["error"]
+
+
+def test_ambiguous_anchor_reports_line_numbers():
+ """Ambiguity errors should identify candidate lines and suggest @L."""
+ original_content = "target\nmiddle\ntarget\n"
+
+ new_content, successful, failed = apply_hashline_operations(
+ original_content,
+ [
+ {
+ "start_line_hash": "target",
+ "end_line_hash": "target",
+ "operation": "replace",
+ "text": "changed",
+ }
+ ],
+ )
+
+ assert new_content == original_content
+ assert successful == []
+ assert "lines 1, 3" in failed[0]["error"]
+ assert "@L" in failed[0]["error"]
+
+
+def test_stale_id_error_suggests_reread():
+ """Stale IDs should tell the caller to re-read rather than echo hashes."""
+ original_content = "first\nsecond\nthird\n"
+ stale_id, _ = resolve_content_to_hashline_ids(original_content, "second", "second")
+
+ new_content, successful, failed = apply_hashline_operations(
+ "changed\nother\nthird\n",
+ [
+ {
+ "start_line_hash": stale_id,
+ "end_line_hash": stale_id,
+ "operation": "replace",
+ "text": "X",
+ }
+ ],
+ )
+
+ assert successful == []
+ assert "re-read" in failed[0]["error"]
+ assert "IDs" in failed[0]["error"]
+
+
+def test_crlf_line_endings_are_preserved():
+ """A CRLF file should stay CRLF after an edit."""
+ original_content = "first\r\nsecond\r\n"
+ second_id, _ = resolve_content_to_hashline_ids(original_content, "second", "second")
+
+ new_content, successful, failed = apply_hashline_operations(
+ original_content,
+ [
+ {
+ "start_line_hash": second_id,
+ "end_line_hash": second_id,
+ "operation": "replace",
+ "text": "SECOND",
+ }
+ ],
+ )
+
+ assert new_content == "first\r\nSECOND\r\n"
+ assert successful == [0]
+ assert failed == []
+
+
+def test_stale_content_id_error_recommends_reread():
+ """Stale IDs should tell the caller to re-read instead of echoing hashes."""
+ original_content = "first\nsecond\nthird\n"
+ stale_id, _ = resolve_content_to_hashline_ids(original_content, "second", "second")
+ modified_content = "changed\nother\nthird\n"
+
+ new_content, successful, failed = apply_hashline_operations(
+ modified_content,
+ [
+ {
+ "start_line_hash": stale_id,
+ "end_line_hash": stale_id,
+ "operation": "replace",
+ "text": "changed again",
+ }
+ ],
+ )
+
+ assert new_content == modified_content
+ assert successful == []
+ assert "may have changed" in failed[0]["error"]
+ assert "re-read" in failed[0]["error"]
+
+
+def test_insert_preserves_crlf_line_endings():
+ """Inserting into a CRLF file must keep CRLF line endings."""
+ original_content = "first\r\nsecond\r\n"
+ second_id, _ = resolve_content_to_hashline_ids(original_content, "second", "second")
+
+ new_content, successful, failed = apply_hashline_operations(
+ original_content,
+ [
+ {
+ "start_line_hash": second_id,
+ "end_line_hash": second_id,
+ "operation": "insert",
+ "text": "middle",
+ "position": "before",
+ }
+ ],
+ )
+
+ assert new_content == "first\r\nmiddle\r\nsecond\r\n"
+ assert successful == [0]
+ assert failed == []
+
+
+def test_insert_around_empty_line_preserves_target_line():
+ """Inserting before/after a blank line must keep that blank line."""
+ original_content = "a\n\nb\n"
+ empty_id, _ = resolve_content_to_hashline_ids(original_content, "", "")
+
+ def run(position):
+ new_content, successful, failed = apply_hashline_operations(
+ original_content,
+ [
+ {
+ "start_line_hash": empty_id,
+ "end_line_hash": empty_id,
+ "operation": "insert",
+ "text": "X",
+ "position": position,
+ }
+ ],
+ )
+ assert successful == [0]
+ assert failed == []
+ return new_content
+
+ assert run("before") == "a\nX\n\nb\n"
+ assert run("after") == "a\n\nX\nb\n"
+
+
+def test_overlap_error_reports_one_based_line_numbers():
+ """Overlap diagnostics must use file line numbers, not zero-based indices."""
+ resolved_ops = [
+ {
+ "index": 0,
+ "start_idx": 4,
+ "end_idx": 6,
+ "op": {"operation": "replace"},
+ },
+ {
+ "index": 1,
+ "start_idx": 5,
+ "end_idx": 7,
+ "op": {"operation": "replace"},
+ },
+ ]
+
+ failed, indices = _detect_overlapping_ranges(resolved_ops)
+
+ assert indices == {0, 1}
+ assert "lines 5-7" in failed[0]["error"]
+ assert "lines 6-8" in failed[0]["error"]
+
+
+def test_public_ids_use_local_anchor_and_occurrence():
+ """Duplicate lines get distinct IDs from local anchors and local occurrences."""
+ content = "anchor_a\ndup\ndup\nanchor_b\ndup\n"
+ hp = HashPos(content)
+
+ first = hp.generate_public_id("dup", 1, 1)
+ second = hp.generate_public_id("dup", 2, 2)
+ third = hp.generate_public_id("dup", 4, 1)
+
+ assert len({first, second, third}) == 3
+ _, first_anchor, first_occurrence = hp.unpack_public_id(first)
+ _, second_anchor, second_occurrence = hp.unpack_public_id(second)
+ _, third_anchor, third_occurrence = hp.unpack_public_id(third)
+
+ assert first_anchor == second_anchor
+ assert first_anchor != third_anchor
+ assert first_occurrence == 1
+ assert second_occurrence == 2
+ assert third_occurrence == 1
+
+ for idx, public_id in ((1, first), (2, second), (4, third)):
+ assert hp.resolve_to_lines(hp.get_wrapped_id(public_id)) == [idx]
+
+
+def test_public_ids_are_stable_when_unrelated_lines_shift():
+ """Inserting lines before the local anchor must not change duplicate IDs."""
+ original = "top\ndup\nend\nmid\ndup\n"
+ edited = "new_a\nnew_b\n" + original
+
+ original_hp = HashPos(original)
+ edited_hp = HashPos(edited)
+
+ original_ids = [
+ original_hp.generate_public_id(line, idx, 1)
+ for idx, line in enumerate(original_hp.lines)
+ if line == "dup"
+ ]
+ edited_ids = [
+ edited_hp.generate_public_id(line, idx, 1)
+ for idx, line in enumerate(edited_hp.lines)
+ if line == "dup"
+ ]
+
+ assert original_ids == edited_ids
+ assert edited_hp.resolve_to_lines(edited_hp.get_wrapped_id(edited_ids[0])) == [3]
+ assert edited_hp.resolve_to_lines(edited_hp.get_wrapped_id(edited_ids[1])) == [6]
+
+
+def test_public_ids_change_only_when_local_anchor_changes():
+ """A new unique line before a duplicate changes that local ID segment."""
+ original_hp = HashPos("anchor\ndup\n")
+ edited_hp = HashPos("anchor\nnew_unique\ndup\n")
+
+ original_id = original_hp.generate_public_id("dup", 1, 1)
+ edited_id = edited_hp.generate_public_id("dup", 2, 1)
+
+ assert original_id != edited_id
diff --git a/tests/basic/test_hashline_closure.py b/tests/basic/test_hashline_closure.py
index 3551218d3b3..8be8bd82eec 100644
--- a/tests/basic/test_hashline_closure.py
+++ b/tests/basic/test_hashline_closure.py
@@ -733,3 +733,33 @@ def test_merge_single_op_returns_as_is():
result = _merge_replace_operations(ops)
assert len(result) == 1
assert result[0]["op"]["text"] == "text"
+
+
+def test_closure_safeguard_accepts_batch_valid_combination():
+ """Individually invalid edits should survive when the batch is valid."""
+ source = "if True:\n if False:\n x = 1\n"
+ ops = [
+ _make_closure_op("replace", " x = 1", 2, 2, index=0),
+ _make_closure_op("delete", "", 1, 1, index=1),
+ ]
+
+ result, rejected = _apply_closure_safeguard(source, ops, file_path="test.py")
+
+ assert len(result) == 2
+ assert rejected == []
+
+
+def test_fix_boundaries_does_not_cross_sibling_op():
+ """Boundary expansion must stop before another operation's range."""
+ source = ["dup", "old1", "dup", "X", "Y"]
+ ops = [
+ _make_op("replace", "dup\nnew", 2, 2, index=0),
+ _make_op("replace", "KEPT", 1, 1, index=1),
+ ]
+
+ result = _fix_duplicate_content_boundaries(source, ops)
+
+ assert result[0]["start_idx"] == 2
+ assert result[0]["end_idx"] == 2
+ assert result[1]["start_idx"] == 1
+ assert result[1]["end_idx"] == 1
diff --git a/tests/basic/test_skills.py b/tests/basic/test_skills.py
index a77621edcd7..77d9ab33420 100644
--- a/tests/basic/test_skills.py
+++ b/tests/basic/test_skills.py
@@ -9,7 +9,7 @@
import pytest
-from cecli.helpers.skills import SkillsManager
+from cecli.helpers.skills import SkillsManager, _normalize_allowed_tools
class TestSkills:
@@ -213,6 +213,83 @@ def test_create_and_parse_skill(self):
assert "test-skill" not in manager._loaded_skills
assert manager._loaded_skills == set()
+ def test_allowed_tools_scalar_is_split(self):
+ """A comma-separated allowed-tools scalar is normalized into a list."""
+ skill_dir = Path(self.temp_dir) / "scalar-tools"
+ skill_dir.mkdir()
+
+ (skill_dir / "SKILL.md").write_text("""---
+name: scalar-tools
+description: Skill with a scalar allowed-tools value
+allowed-tools: Bash(git commit -m "a, b"), Read, Bash(spark-ssh.sh:*)
+---
+
+# Scalar Tools
+""")
+
+ manager = SkillsManager([self.temp_dir])
+ metadata = manager._parse_skill_metadata(skill_dir / "SKILL.md")
+
+ assert metadata.allowed_tools == [
+ 'Bash(git commit -m "a, b")',
+ "Read",
+ "Bash(spark-ssh.sh:*)",
+ ]
+
+ summary = manager.get_skill_summary("scalar-tools")
+ assert 'Allowed tools: Bash(git commit -m "a, b"), Read, Bash(spark-ssh.sh:*)' in summary
+
+ def test_allowed_tools_list_is_preserved(self):
+ """A YAML sequence for allowed-tools is preserved as a list."""
+ skill_dir = Path(self.temp_dir) / "list-tools"
+ skill_dir.mkdir()
+
+ (skill_dir / "SKILL.md").write_text("""---
+name: list-tools
+description: Skill with a list allowed-tools value
+allowed-tools:
+ - Read
+ - Bash(ls:*)
+---
+
+# List Tools
+""")
+
+ manager = SkillsManager([self.temp_dir])
+ metadata = manager._parse_skill_metadata(skill_dir / "SKILL.md")
+
+ assert metadata.allowed_tools == ["Read", "Bash(ls:*)"]
+
+ def test_allowed_tools_normalization_validates_types(self):
+ """Unsupported allowed-tools values raise and missing values become empty."""
+ assert _normalize_allowed_tools(None) == []
+ assert _normalize_allowed_tools(" ") == []
+ assert _normalize_allowed_tools(["Read", " "]) == ["Read"]
+
+ with pytest.raises(ValueError):
+ _normalize_allowed_tools(42)
+
+ with pytest.raises(ValueError):
+ _normalize_allowed_tools(["Read", 42])
+
+ def test_skill_with_invalid_allowed_tools_is_skipped(self):
+ """A skill whose allowed-tools has an invalid type is not loaded."""
+ skill_dir = Path(self.temp_dir) / "bad-tools"
+ skill_dir.mkdir()
+
+ (skill_dir / "SKILL.md").write_text("""---
+name: bad-tools
+description: Skill with invalid allowed-tools
+allowed-tools: 42
+---
+
+# Bad Tools
+""")
+
+ manager = SkillsManager([self.temp_dir])
+
+ assert "bad-tools" not in [skill.name for skill in manager.find_skills(reload=True)]
+
def test_skill_summary_loader(self, monkeypatch):
"""Test the skill_summary_loader function."""
# Isolate from the real ~/.cecli/skills directory so the test is
diff --git a/tests/conversations/test_conversation_system.py b/tests/conversations/test_conversation_system.py
index 94b3ef074e5..6ad50d259e8 100644
--- a/tests/conversations/test_conversation_system.py
+++ b/tests/conversations/test_conversation_system.py
@@ -290,6 +290,23 @@ def side_effect(fname, **kwargs):
# Clean up
os.unlink(temp_file)
+ def test_notify_file_edited_clears_cached_ranges(self, tmp_path):
+ """An edit must invalidate ReadFile's cached line ranges for the file."""
+ import os
+
+ abs_fname = os.path.abspath(tmp_path / "example.txt")
+ self.files._numbered_contexts[abs_fname] = [(1, 5)]
+ self.files._last_merged_ranges[abs_fname] = [(1, 5)]
+ self.files._emitted_contexts[abs_fname] = [(1, 5)]
+ self.files._file_context_versions[abs_fname] = 1
+
+ self.chunks.notify_file_edited(abs_fname)
+
+ assert abs_fname not in self.files._numbered_contexts
+ assert abs_fname not in self.files._last_merged_ranges
+ assert abs_fname not in self.files._emitted_contexts
+ assert abs_fname not in self.files._file_context_versions
+
def test_has_file_changed(self, mocker):
"""Test file change detection."""
import os
diff --git a/tests/helpers/observations/test_observation_service.py b/tests/helpers/observations/test_observation_service.py
index 79a70aaece2..a3658ccf7b6 100644
--- a/tests/helpers/observations/test_observation_service.py
+++ b/tests/helpers/observations/test_observation_service.py
@@ -263,10 +263,10 @@ async def test_compaction_bails_out_when_fixed_context_dominates():
coder.gpt_prompts = MagicMock()
coder._rate_limit_sleep = AsyncMock()
- # done/cur/diff/file_context are empty, so the entire context is the fixed
- # context (chat files, repo map, prompts). Summarizing cannot shrink it.
+ # A little compactable history plus a large fixed context (files in the
+ # chat, repo map, prompts). Summarizing cannot shrink the fixed part.
coder.summarizer = MagicMock()
- coder.summarizer.count_tokens.side_effect = [0, 0, 0, 0, 6000]
+ coder.summarizer.count_tokens.side_effect = [1000, 0, 0, 0, 6000]
coder.summarizer.summarize_all_as_text = AsyncMock(return_value="Summary Text")
mock_conv_manager = MagicMock()
@@ -285,10 +285,55 @@ async def test_compaction_bails_out_when_fixed_context_dominates():
return_value=obs,
),
):
- await Coder.compact_context_if_needed(coder, force=True)
+ await Coder.compact_context_if_needed(coder)
# Compaction must not summarize anything...
coder.summarizer.summarize_all_as_text.assert_not_called()
# ...and must tell the user why, with a remedy.
coder.io.tool_warning.assert_called_once()
coder.io.tool_output.assert_any_call("- Use /drop to remove unneeded files from the chat")
+
+
+@pytest.mark.asyncio
+async def test_compaction_force_runs_when_fixed_context_dominates():
+ """force=True must bypass the fixed-context bail-out and compact anyway."""
+ from cecli.coders.base_coder import Coder
+
+ coder = MagicMock(spec=Coder)
+ coder.uuid = "test-coder-force-fixed-context"
+ coder.enable_context_compaction = True
+ coder.context_compaction_max_tokens = 1000
+ coder.context_compaction_summary_tokens = 100
+ coder.io = MagicMock()
+ coder.gpt_prompts = MagicMock()
+ coder._rate_limit_sleep = AsyncMock()
+ coder.auto_memory = False
+ coder.edit_format = "whole"
+
+ # Fixed context dominates, but there is still compactable history to summarize.
+ coder.summarizer = MagicMock()
+ coder.summarizer.count_tokens.side_effect = [1000, 0, 0, 0, 6000, 500]
+ coder.summarizer.summarize_all_as_text = AsyncMock(return_value="Summary Text")
+
+ mock_conv_manager = MagicMock()
+ mock_conv_manager.get_messages_dict.return_value = []
+
+ obs = MagicMock()
+ obs.check_and_trigger = AsyncMock()
+
+ with (
+ patch(
+ "cecli.coders.base_coder.ConversationService.get_manager",
+ return_value=mock_conv_manager,
+ ),
+ patch(
+ "cecli.coders.base_coder.ObservationService.get_instance",
+ return_value=obs,
+ ),
+ ):
+ await Coder.compact_context_if_needed(coder, force=True)
+
+ # The bail-out warning must not fire; compaction proceeds and summarizes.
+ coder.io.tool_warning.assert_not_called()
+ coder.summarizer.summarize_all_as_text.assert_called_once()
+ coder.io.tool_output.assert_any_call("Forcing compaction of chat history...")
diff --git a/tests/helpers/test_coroutines.py b/tests/helpers/test_coroutines.py
new file mode 100644
index 00000000000..4eed6f0619b
--- /dev/null
+++ b/tests/helpers/test_coroutines.py
@@ -0,0 +1,20 @@
+import asyncio
+
+from cecli.helpers import coroutines
+
+
+def test_cancel_and_abandon_skips_tasks_from_closed_loop():
+ loop = asyncio.new_event_loop()
+
+ async def sleeper():
+ await asyncio.sleep(10)
+
+ task = loop.create_task(sleeper())
+ loop.run_until_complete(asyncio.sleep(0))
+ loop.close()
+ task._log_destroy_pending = False
+ coroutines.background_tasks.add(task)
+
+ asyncio.run(coroutines.cancel_and_abandon([task]))
+
+ assert task not in coroutines.background_tasks
diff --git a/tests/helpers/test_orchestration.py b/tests/helpers/test_orchestration.py
index 9a278c5211e..1d782752f2c 100644
--- a/tests/helpers/test_orchestration.py
+++ b/tests/helpers/test_orchestration.py
@@ -864,499 +864,6 @@ def test_agent_proxy_local_prefix_tool():
assert tool._mcp_server is None
-# ===================================================================
-# AgentRegion
-# ===================================================================
-
-
-def test_agent_region_basic(tmp_path):
- """AgentRegion stores patterns and resolves lazily via get_start/get_end."""
-
- import os
-
- from cecli.helpers.orchestration.region_resolver import AgentRegion
-
- # Create a real file so lazy resolution works
- source = "def foo():\n return 42\n"
- test_file = os.path.join(str(tmp_path), "basic.py")
- with open(test_file, "w") as f:
- f.write(source)
-
- coder = _make_coder_with_io(str(tmp_path))
-
- regions = AgentRegion(
- "basic.py",
- coder,
- [
- {"name": "foo", "start": "def foo():", "end": " return 42"},
- ],
- )
-
- assert "foo" in regions
- assert "missing" not in regions
- assert len(regions) == 1
- assert "AgentRegion(1" in repr(regions)
-
- # Lazy resolution on access
- start_id = regions.get_start("foo")
- end_id = regions.get_end("foo")
-
- assert "—" in start_id
- assert "—" in end_id
-
- start_line = regions.get_start_line("foo")
- end_line = regions.get_end_line("foo")
-
- assert start_line > 0
- assert end_line > 0
- assert start_line <= end_line
-
-
-# ===================================================================
-# AgentProxy.resolve_regions
-# ===================================================================
-
-
-def _make_coder_with_io(tmp_path):
- """Build a coder mock that supports resolve_paths + file I/O."""
-
- import os
-
- class _MockIO:
- def read_text(self, abs_path):
- if os.path.isfile(abs_path):
- with open(abs_path, "r") as f:
- return f.read()
-
- return None
-
- class _MockCoder:
- root = tmp_path
- io = _MockIO()
- registered_tools = {"included": set(), "excluded": set()}
- mcp_tools = []
-
- def abs_root_path(self, file_path):
- import os
-
- return os.path.join(self.root, file_path)
-
- def get_rel_fname(self, abs_path):
- import os
-
- return os.path.relpath(abs_path, self.root)
-
- return _MockCoder()
-
-
-def test_resolve_regions_basic(tmp_path):
- """AgentProxy.resolve_regions resolves text patterns to content IDs."""
-
- import os
-
- from cecli.helpers.orchestration.environment import AgentProxy
-
- # Create a test file with known regions
- source = """\
-def helper_one():
- \"\"\"Returns 'one'.\"\"\"
- return "one"
-
-
-def helper_two():
- \"\"\"Returns 'two'.\"\"\"
- return "two"
-
-
-def main():
- result = helper_one() + helper_two()
- return result
-"""
-
- test_file = os.path.join(str(tmp_path), "test_regions.py")
-
- with open(test_file, "w") as f:
- f.write(source)
-
- coder = _make_coder_with_io(str(tmp_path))
- proxy = AgentProxy(coder)
-
- regions = proxy.resolve_regions(
- "test_regions.py",
- [
- {"name": "helper_one", "start": "def helper_one():", "end": ' return "one"'},
- {"name": "helper_two", "start": "def helper_two():", "end": ' return "two"'},
- {"name": "main", "start": "def main():", "end": " return result"},
- ],
- )
-
- from cecli.helpers.orchestration.region_resolver import AgentRegion
-
- assert isinstance(regions, AgentRegion)
- assert len(regions) == 3
-
- # Each region should have valid content IDs
- for name in ("helper_one", "helper_two", "main"):
- start_id = regions.get_start(name)
- end_id = regions.get_end(name)
-
- assert "—" in start_id, f"{name} start_id should be a content ID: {start_id}"
- assert "—" in end_id, f"{name} end_id should be a content ID: {end_id}"
-
- assert regions.get_start_line(name) > 0
- assert regions.get_end_line(name) > 0
- assert regions.get_start_line(name) <= regions.get_end_line(name)
-
-
-def test_resolve_regions_file_not_found(tmp_path):
- """Eager validation raises ValueError at resolve_regions time when file doesn't exist."""
-
- import pytest
-
- from cecli.helpers.orchestration.environment import AgentProxy
-
- coder = _make_coder_with_io(str(tmp_path))
-
- proxy = AgentProxy(coder)
-
- # Eager validation surfaces errors at construction time
- with pytest.raises(ValueError, match="File not found"):
- proxy.resolve_regions(
- "nonexistent.py",
- [{"name": "x", "start": "def x", "end": "return"}],
- )
-
-
-def test_resolve_regions_empty_regions_list(tmp_path):
- """AgentProxy.resolve_regions handles empty region list."""
-
- import os
-
- from cecli.helpers.orchestration.environment import AgentProxy
- from cecli.helpers.orchestration.region_resolver import AgentRegion
-
- source = "x = 1\n"
-
- test_file = os.path.join(str(tmp_path), "empty_test.py")
-
- with open(test_file, "w") as f:
- f.write(source)
-
- coder = _make_coder_with_io(str(tmp_path))
- proxy = AgentProxy(coder)
-
- regions = proxy.resolve_regions("empty_test.py", [])
-
- assert isinstance(regions, AgentRegion)
- assert len(regions) == 0
-
-
-def test_agent_region_content_id_fallback(tmp_path):
- """AgentRegion snapshots line content from content-ID patterns for stale-ID fallback."""
-
- import os
-
- from cecli.helpers.orchestration.region_resolver import AgentRegion
-
- source = """\
-def greet(name):
- return f"hello {name}"
-
-
-def farewell(name):
- return f"goodbye {name}"
-"""
-
- test_file = os.path.join(str(tmp_path), "fallback_test.py")
-
- with open(test_file, "w") as f:
- f.write(source)
-
- coder = _make_coder_with_io(str(tmp_path))
-
- # First, resolve using the Agent to get a real content ID
- from cecli.helpers.orchestration.environment import AgentProxy
-
- proxy = AgentProxy(coder)
- regions = proxy.resolve_regions(
- "fallback_test.py",
- [{"name": "greet", "start": "def greet(name):", "end": ' return f"hello {name}"'}],
- )
-
- # Get the content ID for the start of "greet"
- start_id = regions.get_start("greet")
-
- # Now create a NEW AgentRegion that uses the raw content ID as the pattern.
- # This simulates an agent that captured content IDs from a previous turn.
- regions2 = AgentRegion(
- "fallback_test.py",
- coder,
- [{"name": "greet", "start": start_id, "end": start_id}],
- )
-
- # First resolution should snapshot the line content
- result_id = regions2.get_start("greet")
- assert "—" in result_id
-
- # Now modify the file (simulating an edit that shifts hashlines)
- new_source = """\
-# header comment added
-def greet(name):
- return f"hello {name}"
-
-
-def farewell(name):
- return f"goodbye {name}"
-"""
-
- with open(test_file, "w") as f:
- f.write(new_source)
-
- # The original content ID is now stale, but the snapshot should let
- # us resolve via content matching
- result_id2 = regions2.get_start("greet")
- assert "—" in result_id2, f"Should resolve via fallback content match, got: {result_id2!r}"
-
-
-def test_agent_region_rejects_ambiguous_pattern(tmp_path):
- """AgentRegion raises ValueError eagerly when a text pattern matches multiple locations."""
-
- import os
-
- import pytest
-
- from cecli.helpers.orchestration.region_resolver import AgentRegion
-
- # Create a file where "return x" appears multiple times
- source = """\
-def foo():
- return x
-
-
-def bar():
- return x
-
-
-def baz():
- return x
-"""
-
- test_file = os.path.join(str(tmp_path), "ambiguous.py")
- with open(test_file, "w") as f:
- f.write(source)
-
- coder = _make_coder_with_io(str(tmp_path))
-
- # start is unique, but end matches 3 locations — error surfaces eagerly
- with pytest.raises(ValueError, match="End pattern"):
- AgentRegion(
- "ambiguous.py",
- coder,
- [
- {"name": "bar", "start": "def bar", "end": "return x"},
- ],
- )
-
-
-def test_agent_region_disambiguates_with_l_hint(tmp_path):
- """AgentRegion uses @L hint to disambiguate when a pattern matches multiple locations."""
-
- import os
-
- from cecli.helpers.orchestration.region_resolver import AgentRegion
-
- source = """\
-def foo():
- return x
-
-def bar():
- return y
-
-def baz():
- return z
-"""
-
- test_file = os.path.join(str(tmp_path), "hint_test.py")
- with open(test_file, "w") as f:
- f.write(source)
-
- coder = _make_coder_with_io(str(tmp_path))
-
- # "return" appears on lines 2, 5, 8 — use @L hint to target bar's return
- regions = AgentRegion(
- "hint_test.py",
- coder,
- [
- {"name": "bar", "start": "def bar():", "end": " return y @L6"},
- ],
- )
-
- start_id = regions.get_start("bar")
- end_id = regions.get_end("bar")
-
- assert "—" in start_id
- assert "—" in end_id
- assert regions.get_start_line("bar") == 4
- assert regions.get_end_line("bar") == 5
-
-
-def test_agent_region_rejects_l_hint_still_ambiguous(tmp_path):
- """AgentRegion raises ValueError eagerly when @L hint has equally-close matches (tie)."""
-
- import os
-
- import pytest
-
- from cecli.helpers.orchestration.region_resolver import AgentRegion
-
- source = """\
-return x
-
-return y
-middle
-return x
-return z
-"""
-
- test_file = os.path.join(str(tmp_path), "bad_hint.py")
- with open(test_file, "w") as f:
- f.write(source)
-
- coder = _make_coder_with_io(str(tmp_path))
-
- # "return x" at lines 1 and 4 (0-based: 0 and 3), hint @L3 (0-based 2)
- # Both are distance 1 away from the hint — error surfaces eagerly
- with pytest.raises(ValueError, match="End pattern.*@L3 hint ties"):
- AgentRegion(
- "bad_hint.py",
- coder,
- [
- {"name": "top", "start": "return x @L1", "end": "return x @L3"},
- ],
- )
-
-
-def test_agent_region_explicit_line_hints(tmp_path):
- """Explicit start_line_hint / end_line_hint fields disambiguate patterns."""
-
- import os
-
- from cecli.helpers.orchestration.region_resolver import AgentRegion
-
- source = """\
-def foo():
- return x
-
-def bar():
- return y
-
-def baz():
- return z
-"""
-
- test_file = os.path.join(str(tmp_path), "explicit_hint.py")
- with open(test_file, "w") as f:
- f.write(source)
-
- coder = _make_coder_with_io(str(tmp_path))
-
- # Use explicit end_line_hint instead of @L in pattern
- regions = AgentRegion(
- "explicit_hint.py",
- coder,
- [
- {"name": "bar", "start": "def bar():", "end": " return y", "end_line_hint": 6},
- ],
- )
-
- start_id = regions.get_start("bar")
- end_id = regions.get_end("bar")
-
- assert "—" in start_id
- assert "—" in end_id
- assert regions.get_start_line("bar") == 4
- assert regions.get_end_line("bar") == 5
-
-
-def test_agent_region_explicit_hint_overrides_at_syntax(tmp_path):
- """Explicit line_hint fields override @L hints embedded in patterns."""
-
- import os
-
- from cecli.helpers.orchestration.region_resolver import AgentRegion
-
- source = """\
-def foo():
- return x
-
-def bar():
- return y
-
-def baz():
- return z
-"""
-
- test_file = os.path.join(str(tmp_path), "override_hint.py")
- with open(test_file, "w") as f:
- f.write(source)
-
- coder = _make_coder_with_io(str(tmp_path))
-
- # @L2 points to foo's return, but explicit end_line_hint=3 points to bar's
- regions = AgentRegion(
- "override_hint.py",
- coder,
- [
- {
- "name": "bar",
- "start": "def bar():",
- "end": " return y @L3",
- "end_line_hint": 6,
- },
- ],
- )
-
- # Explicit hint (6, 1-based) disambiguates; pattern must be the full exact line
- assert regions.get_end_line("bar") == 5
-
-
-def test_resolve_regions_rejects_ambiguous_via_proxy(tmp_path):
- """resolve_regions() through AgentProxy surfaces ambiguity errors eagerly at construction time."""
-
- import os
-
- import pytest
-
- from cecli.helpers.orchestration.environment import AgentProxy
-
- source = """\
-def one():
- pass
-
-
-def two():
- pass
-
-
-def three():
- pass
-"""
-
- test_file = os.path.join(str(tmp_path), "proxy_ambig.py")
- with open(test_file, "w") as f:
- f.write(source)
-
- coder = _make_coder_with_io(str(tmp_path))
- proxy = AgentProxy(coder)
-
- # "pass" appears 3 times — error surfaces eagerly at resolve_regions time
- with pytest.raises(ValueError, match="End pattern.*pass.*matches 3"):
- proxy.resolve_regions(
- "proxy_ambig.py",
- [{"name": "two", "start": "def two", "end": "pass"}],
- )
-
-
def test_gather_result_attribute_access():
"""GatherResult supports attribute access for named results."""
from cecli.helpers.orchestration.environment import GatherResult
diff --git a/tests/helpers/workflows/test_config.py b/tests/helpers/workflows/test_config.py
index a5c82cb9928..4201f50cceb 100644
--- a/tests/helpers/workflows/test_config.py
+++ b/tests/helpers/workflows/test_config.py
@@ -657,3 +657,115 @@ def test_persist_string_false_is_not_truthy():
)
assert wf.persist is False
+
+
+# ── repo + worktree ────────────────────────────────────────────────────────
+
+
+@pytest.mark.parametrize(
+ ("raw", "expected"),
+ [
+ (True, {}),
+ (None, None),
+ (False, None),
+ ({"branch": "main"}, {"branch": "main"}),
+ ],
+)
+def test_worktree_normalized(raw, expected):
+ wf = models.WorkflowConfig.from_dict(
+ {"name": "wt", "worktree": raw, "tasks": [{"prompt": "x"}]}
+ )
+
+ assert wf.worktree == expected
+
+
+def test_repo_and_worktree_round_trip(isolated):
+ _home_workflow(
+ isolated["wdir"],
+ "wt.yml",
+ "name: wt\n"
+ "repo: /repos/app\n"
+ "worktree:\n"
+ " branch: main\n"
+ "tasks:\n"
+ " - prompt: x\n",
+ )
+
+ wf = load_workflows()[0]
+
+ assert wf.repo == "/repos/app"
+ assert wf.worktree == {"branch": "main"}
+ assert wf.to_dict()["repo"] == "/repos/app"
+ assert wf.to_dict()["worktree"] == {"branch": "main"}
+
+
+def test_worktree_true_is_enabled_with_defaults(isolated):
+ _home_workflow(
+ isolated["wdir"],
+ "wt-true.yml",
+ "name: wt-true\nrepo: /repos/app\nworktree: true\ntasks:\n - prompt: x\n",
+ )
+
+ wf = load_workflows()[0]
+
+ assert wf.worktree == {}
+
+
+@pytest.mark.parametrize("bad", ["yes", 1, ["main"]])
+def test_worktree_wrong_type_raises(bad):
+ wf = models.WorkflowConfig(name="wt", tasks=[models.TaskConfig(prompt="p")], worktree=bad)
+
+ with pytest.raises(ValueError, match="'worktree' must be a boolean or a mapping"):
+ validate_workflow_config(wf, {"name": "wt", "worktree": bad, "tasks": ["p"]})
+
+
+def test_worktree_branch_must_be_string():
+ wf = models.WorkflowConfig(
+ name="wt", tasks=[models.TaskConfig(prompt="p")], worktree={"branch": 3}
+ )
+
+ with pytest.raises(ValueError, match="'worktree.branch' must be a string"):
+ validate_workflow_config(wf)
+
+
+def test_unknown_worktree_key_warns():
+ wf = models.WorkflowConfig(
+ name="wt",
+ tasks=[models.TaskConfig(prompt="p")],
+ worktree={"branch": "main", "remote": "origin"},
+ )
+
+ warnings = validate_workflow_config(wf)
+
+ assert any("unknown 'worktree' key(s) ignored" in w and "remote" in w for w in warnings)
+
+
+def test_repo_must_be_string():
+ wf = models.WorkflowConfig(name="wt", tasks=[models.TaskConfig(prompt="p")], repo=123)
+
+ with pytest.raises(ValueError, match="'repo' must be a string"):
+ validate_workflow_config(wf)
+
+
+def test_worktree_without_source_warns():
+ wf = models.WorkflowConfig(name="wt", tasks=[models.TaskConfig(prompt="p")], worktree={})
+
+ warnings = validate_workflow_config(wf)
+
+ assert any("no source repository could be inferred" in w for w in warnings)
+
+
+def test_worktree_true_is_normalized_programmatically():
+ wf = models.WorkflowConfig(name="wt", tasks=[models.TaskConfig(prompt="p")], worktree=True)
+
+ assert wf.worktree == {}
+ assert wf.to_dict()["worktree"] == {}
+
+
+def test_to_dict_omits_absent_repo_and_worktree():
+ wf = models.WorkflowConfig(name="wt", tasks=[models.TaskConfig(prompt="p")])
+
+ data = wf.to_dict()
+
+ assert "repo" not in data
+ assert "worktree" not in data
diff --git a/tests/helpers/workflows/test_runner.py b/tests/helpers/workflows/test_runner.py
index 6105f365d8d..e5b3ccf7eb5 100644
--- a/tests/helpers/workflows/test_runner.py
+++ b/tests/helpers/workflows/test_runner.py
@@ -34,6 +34,7 @@ def make_worker():
worker.prompt_queue = []
worker._queue_counter = 0
worker._queue_lock = threading.Lock()
+ worker.worktree = None
return worker
@@ -476,3 +477,26 @@ async def test_persist_reuses_existing_worker(coder):
get_files.return_value.reset.assert_called_once()
get_chunks.return_value.add_system_message.assert_called_once()
assert service.reaped == []
+
+
+@pytest.mark.asyncio
+async def test_worktree_recorded_and_logged(coder):
+ worker = make_worker()
+ worker.received = []
+
+ class Record:
+ def to_dict(self):
+ return {"branch": "cecli/wf-demo-1234", "path": "/tmp/wt"}
+
+ worker.worktree = Record()
+ service = FakeService(worker, _drain)
+ wf = _wf(TaskConfig(prompt="x"))
+
+ result = await _execute(service, wf, coder)
+
+ assert result.worktree == {"branch": "cecli/wf-demo-1234", "path": "/tmp/wt"}
+
+ from cecli.helpers.workflows.logs import read_log_lines
+
+ finish = [e for e in read_log_lines(result.log_path) if e.get("event") == "finish"][-1]
+ assert finish["worktree"] == result.worktree
diff --git a/tests/helpers/workflows/test_subagents.py b/tests/helpers/workflows/test_subagents.py
index 164a32ed669..53b62bda8a4 100644
--- a/tests/helpers/workflows/test_subagents.py
+++ b/tests/helpers/workflows/test_subagents.py
@@ -183,3 +183,56 @@ def test_workflow_metadata_overrides_subagent_config():
assert agent_config["servers_includelist"] == ["gitlab"]
assert agent_config["skip_cli_confirmations"] is True
assert agent_config["detached"] is True
+
+
+# ---------------------------------------------------------------- worktree --
+
+
+def test_worktree_metadata_uses_repo():
+ wf = _wf(repo="/repos/app", worktree={"branch": "main"})
+
+ register_workflow_subagents([wf])
+ cfg = AgentService.get_registry()["wf:check-pipeline"]
+
+ assert cfg.metadata["worktree"] == {"branch": "main"}
+ assert cfg.metadata["worktree_source"] == "/repos/app"
+
+
+def test_worktree_metadata_uses_normalized_root(tmp_path, monkeypatch):
+ monkeypatch.setenv("HOME", str(tmp_path))
+ monkeypatch.setenv("USERPROFILE", str(tmp_path))
+ root = tmp_path / "repo"
+ root.mkdir()
+
+ wf = _wf(root="~/repo", worktree=True)
+
+ register_workflow_subagents([wf])
+ cfg = AgentService.get_registry()["wf:check-pipeline"]
+
+ assert cfg.metadata["worktree"] == {}
+ assert cfg.metadata["worktree_source"] == str(root.expanduser().resolve())
+
+
+def test_worktree_metadata_uses_source_path_git_root(tmp_path, monkeypatch):
+ repo = tmp_path / "repo"
+ repo.mkdir()
+ source = repo / "workflows" / "nightly.yml"
+ monkeypatch.setattr(wf_subagents, "_git_toplevel", lambda path: str(repo))
+
+ wf = _wf(name="nightly", source_path=str(source), worktree=True)
+
+ register_workflow_subagents([wf])
+ cfg = AgentService.get_registry()["wf:nightly"]
+
+ assert cfg.metadata["worktree"] == {}
+ assert cfg.metadata["worktree_source"] == str(repo)
+
+
+def test_no_worktree_metadata_when_disabled():
+ wf = _wf(root="/repos/app")
+
+ register_workflow_subagents([wf])
+ cfg = AgentService.get_registry()["wf:check-pipeline"]
+
+ assert "worktree" not in cfg.metadata
+ assert "worktree_source" not in cfg.metadata
diff --git a/tests/helpers/worktrees/__init__.py b/tests/helpers/worktrees/__init__.py
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/tests/helpers/worktrees/test_manager.py b/tests/helpers/worktrees/test_manager.py
new file mode 100644
index 00000000000..2fd119e132b
--- /dev/null
+++ b/tests/helpers/worktrees/test_manager.py
@@ -0,0 +1,492 @@
+"""End-to-end tests for worktree creation, recreation, and release."""
+
+from __future__ import annotations
+
+import json
+import os
+import shutil
+import subprocess
+import sys
+from concurrent.futures import ThreadPoolExecutor
+from dataclasses import replace
+from pathlib import Path
+
+import pytest
+
+from cecli.helpers.worktrees import (
+ WorktreeError,
+ WorktreeManager,
+ WorktreeRecord,
+ WorktreeReleaseResult,
+)
+
+
+@pytest.fixture(autouse=True)
+def isolated_base(tmp_path, monkeypatch):
+ base = tmp_path / "worktrees"
+ monkeypatch.setenv("CECLI_WORKTREES_DIR", str(base))
+ return base
+
+
+def _git(repo, *args):
+ result = subprocess.run(
+ ["git", "-C", str(repo), *args],
+ capture_output=True,
+ text=True,
+ )
+ assert result.returncode == 0, result.stderr
+ return result.stdout.strip()
+
+
+def _init_repo(path: Path, branch: str = "main") -> Path:
+ path.mkdir(parents=True, exist_ok=True)
+ subprocess.run(["git", "init", str(path)], check=True, capture_output=True)
+ subprocess.run(
+ ["git", "-C", str(path), "symbolic-ref", "HEAD", f"refs/heads/{branch}"],
+ check=True,
+ capture_output=True,
+ )
+ subprocess.run(
+ ["git", "-C", str(path), "config", "user.name", "Test User"],
+ check=True,
+ capture_output=True,
+ )
+ subprocess.run(
+ ["git", "-C", str(path), "config", "user.email", "test@example.com"],
+ check=True,
+ capture_output=True,
+ )
+ (path / "README.md").write_text("hello\n", encoding="utf-8")
+ _git(path, "add", "-A")
+ _git(path, "commit", "-m", "initial")
+
+ return path
+
+
+def _create(repo: Path, uuid: str = "456abcde", agent: str = "wf:nightly-tidy", **kwargs):
+ source = kwargs.pop("source", repo)
+
+ return WorktreeManager.create(agent_name=agent, uuid=uuid, source=source, **kwargs)
+
+
+def test_create_from_local_repo(tmp_path, isolated_base):
+ repo = _init_repo(tmp_path / "repo")
+
+ record = _create(repo)
+
+ assert record.repo == str(repo.resolve())
+ assert record.branch == "cecli/wf-nightly-tidy-456abcde"
+ assert record.path == str((isolated_base / "repo" / "wf-nightly-tidy-456abcde").resolve())
+ assert Path(record.path).is_dir()
+ assert record.base_ref == "main"
+ assert record.base_sha == _git(repo, "rev-parse", "main")
+ assert record.agent == "wf:nightly-tidy"
+ assert record.uuid == "456abcde"
+ assert record.pid == os.getpid()
+ assert record.created_at
+ assert WorktreeManager.get(record.uuid) == record
+
+
+def test_default_branch_master(tmp_path):
+ repo = _init_repo(tmp_path / "repo", branch="master")
+
+ record = _create(repo)
+
+ assert record.base_ref == "master"
+
+
+def test_explicit_base_ref(tmp_path):
+ repo = _init_repo(tmp_path / "repo")
+ _git(repo, "branch", "feature")
+ feature_sha = _git(repo, "rev-parse", "feature")
+
+ record = _create(repo, base_ref="feature")
+
+ assert record.base_ref == "feature"
+ assert record.base_sha == feature_sha
+
+
+def test_unknown_base_ref_raises(tmp_path):
+ repo = _init_repo(tmp_path / "repo")
+
+ with pytest.raises(WorktreeError):
+ _create(repo, base_ref="does-not-exist")
+
+
+def test_branch_and_path_naming_are_sanitized(tmp_path):
+ repo = _init_repo(tmp_path / "repo")
+
+ record = _create(repo, agent="wf:nightly tidy", uuid="abc/def")
+
+ assert record.branch == "cecli/wf-nightly-tidy-abc-def"
+ assert "/" not in Path(record.path).name
+ assert Path(record.path).name == "wf-nightly-tidy-abc-def"
+
+
+def test_two_creates_produce_distinct_paths_and_branches(tmp_path):
+ repo = _init_repo(tmp_path / "repo")
+
+ first = _create(repo, uuid="aaaa1111", reuse_existing=False)
+ second = _create(repo, uuid="bbbb2222", reuse_existing=False)
+
+ assert first.path != second.path
+ assert first.branch != second.branch
+ assert Path(first.path).is_dir()
+ assert Path(second.path).is_dir()
+
+
+def test_create_reuses_existing_worktree_for_same_agent(tmp_path):
+ repo = _init_repo(tmp_path / "repo")
+
+ first = _create(repo, uuid="aaaa1111")
+ second = _create(repo, uuid="bbbb2222")
+
+ assert first.uuid == second.uuid
+ assert first.path == second.path
+ assert first.branch == second.branch
+ assert len(list((tmp_path / "worktrees" / "repo").iterdir())) == 1
+
+
+def test_create_same_uuid_does_not_create_suffixed_copies(tmp_path):
+ repo = _init_repo(tmp_path / "repo")
+
+ first = _create(repo, uuid="abcd1234", reuse_existing=False)
+ second = _create(repo, uuid="abcd1234", reuse_existing=False)
+
+ assert first.path == second.path
+ names = sorted(item.name for item in (tmp_path / "worktrees" / "repo").iterdir())
+ assert names == ["wf-nightly-tidy-abcd1234"]
+
+
+def test_concurrent_creates_reuse_a_single_worktree(tmp_path):
+ repo = _init_repo(tmp_path / "repo")
+ uuids = [f"aaaa{index:04d}" for index in range(8)]
+
+ with ThreadPoolExecutor(max_workers=len(uuids)) as pool:
+ records = list(pool.map(lambda value: _create(repo, uuid=value), uuids))
+
+ assert {record.path for record in records} == {records[0].path}
+ assert {record.uuid for record in records} == {records[0].uuid}
+ assert len(list((tmp_path / "worktrees" / "repo").iterdir())) == 1
+
+
+def test_clean_release_deletes_branch(tmp_path):
+ repo = _init_repo(tmp_path / "repo")
+ record = _create(repo)
+
+ result = WorktreeManager.release(record)
+
+ assert isinstance(result, WorktreeReleaseResult)
+ assert result.removed is True
+ assert result.committed is False
+ assert result.branch_deleted is True
+ assert result.branch_kept is False
+ assert not Path(record.path).exists()
+ assert WorktreeManager.get(record.uuid) is None
+ assert record.branch not in _git(repo, "branch", "--list")
+
+
+def test_dirty_release_commits_and_keeps_branch(tmp_path):
+ repo = _init_repo(tmp_path / "repo")
+ record = _create(repo)
+ (Path(record.path) / "README.md").write_text("changed\n", encoding="utf-8")
+
+ result = WorktreeManager.release(record)
+
+ subject = f"Unfinished Dirty Worktree from agent {record.uuid}"
+ body = _git(repo, "log", "-1", "--format=%b", record.branch)
+
+ assert result.committed is True
+ assert result.removed is True
+ assert result.branch_kept is True
+ assert result.branch_deleted is False
+ assert not Path(record.path).exists()
+ assert _git(repo, "log", "-1", "--format=%s", record.branch) == subject
+ assert record.agent in body
+ assert record.base_sha in body
+ assert "README.md" in body
+ assert WorktreeManager.get(record.uuid) is None
+
+
+def test_release_commits_untracked_file(tmp_path):
+ repo = _init_repo(tmp_path / "repo")
+ record = _create(repo)
+ (Path(record.path) / "notes.txt").write_text("scratch\n", encoding="utf-8")
+
+ result = WorktreeManager.release(record)
+
+ assert result.committed is True
+ assert result.branch_kept is True
+ assert _git(repo, "show", f"{record.branch}:notes.txt") == "scratch"
+
+
+def test_release_keeps_branch_with_agent_commits(tmp_path):
+ repo = _init_repo(tmp_path / "repo")
+ record = _create(repo)
+ (Path(record.path) / "agent.txt").write_text("work\n", encoding="utf-8")
+ _git(Path(record.path), "add", "-A")
+ _git(Path(record.path), "commit", "-m", "agent work")
+
+ result = WorktreeManager.release(record)
+
+ assert result.committed is False
+ assert result.removed is True
+ assert result.branch_kept is True
+ assert result.branch_deleted is False
+ assert not Path(record.path).exists()
+ assert record.branch in _git(repo, "branch", "--list")
+
+
+def test_release_with_missing_path(tmp_path):
+ repo = _init_repo(tmp_path / "repo")
+ record = _create(repo)
+ shutil.rmtree(record.path)
+
+ result = WorktreeManager.release(record)
+
+ assert result.removed is False
+ assert result.branch_kept is False
+ assert result.branch_deleted is True
+ assert result.reason == "worktree path missing"
+ assert WorktreeManager.get(record.uuid) is None
+ assert record.branch not in _git(repo, "branch", "--list")
+
+
+def test_release_oversized_untracked_guard(tmp_path):
+ repo = _init_repo(tmp_path / "repo")
+ record = _create(repo)
+ big = Path(record.path) / "big.bin"
+ big.write_bytes(b"x" * (WorktreeManager.MAX_UNTRACKED_FILE_BYTES + 1))
+
+ result = WorktreeManager.release(record)
+
+ assert result.removed is False
+ assert result.committed is False
+ assert result.branch_kept is True
+ assert "untracked files too large" in result.reason
+ assert Path(record.path).is_dir()
+ assert record.branch in _git(repo, "branch", "--list")
+ assert WorktreeManager.get(record.uuid) == record
+
+
+def test_release_oversized_guard_can_be_forced(tmp_path):
+ repo = _init_repo(tmp_path / "repo")
+ record = _create(repo)
+ (Path(record.path) / "big.bin").write_bytes(
+ b"x" * (WorktreeManager.MAX_UNTRACKED_FILE_BYTES + 1)
+ )
+
+ result = WorktreeManager.release(record, force=True)
+
+ assert result.removed is True
+ assert result.committed is True
+ assert not Path(record.path).exists()
+
+
+def test_is_worktree_rejects_other_repo(tmp_path):
+ repo_one = _init_repo(tmp_path / "repo-one")
+ repo_two = _init_repo(tmp_path / "repo-two")
+ record = _create(repo_one)
+
+ assert WorktreeManager._is_worktree(Path(record.path), repo_one)
+ assert not WorktreeManager._is_worktree(Path(record.path), repo_two)
+
+
+def test_recreate_from_branch(tmp_path):
+ repo = _init_repo(tmp_path / "repo")
+ record = _create(repo)
+ _git(repo, "worktree", "remove", "--force", record.path)
+
+ recreated = WorktreeManager.recreate(record)
+
+ assert recreated.path == record.path
+ assert recreated.branch == record.branch
+ assert Path(recreated.path).is_dir()
+ assert WorktreeManager.get(record.uuid) == recreated
+
+
+def test_recreate_from_base_sha(tmp_path):
+ repo = _init_repo(tmp_path / "repo")
+ record = _create(repo)
+ _git(repo, "worktree", "remove", "--force", record.path)
+ _git(repo, "branch", "-D", record.branch)
+
+ recreated = WorktreeManager.recreate(record)
+
+ assert recreated.path == record.path
+ assert recreated.branch == record.branch
+ assert Path(recreated.path).is_dir()
+ assert _git(repo, "rev-parse", recreated.branch) == record.base_sha
+
+
+def test_recreate_with_path_override(tmp_path):
+ repo = _init_repo(tmp_path / "repo")
+ record = _create(repo)
+ _git(repo, "worktree", "remove", "--force", record.path)
+ new_path = tmp_path / "elsewhere" / "worktree"
+
+ recreated = WorktreeManager.recreate(record, path=new_path)
+
+ assert recreated.path == str(new_path.resolve())
+ assert Path(recreated.path).is_dir()
+ assert WorktreeManager.get(record.uuid) == recreated
+
+
+def test_recreate_with_new_uuid_attaches_saved_branch(tmp_path):
+ repo = _init_repo(tmp_path / "repo")
+ record = _create(repo)
+ work = Path(record.path) / "work.txt"
+ work.write_text("saved work\n", encoding="utf-8")
+ _git(record.path, "add", "-A")
+ _git(record.path, "commit", "-m", "saved work")
+ _git(repo, "worktree", "remove", "--force", record.path)
+
+ recreated = WorktreeManager.recreate(record, agent_name="wf:nightly-tidy", uuid="new99999")
+
+ assert recreated.branch == record.branch
+ assert recreated.uuid == "new99999"
+ assert (Path(recreated.path) / "work.txt").read_text(encoding="utf-8") == "saved work\n"
+ assert WorktreeManager.get(record.uuid) is None
+ assert WorktreeManager.get("new99999") == recreated
+
+
+def test_recreate_reuses_another_checkout_for_same_agent(tmp_path):
+ repo = _init_repo(tmp_path / "repo")
+ missing = _create(repo, uuid="aaaa1111", reuse_existing=False)
+ _git(repo, "worktree", "remove", "--force", missing.path)
+ live = _create(repo, uuid="bbbb2222", reuse_existing=False)
+
+ recreated = WorktreeManager.recreate(missing, uuid="cccc3333")
+
+ assert recreated.path == live.path
+ assert recreated.uuid == live.uuid
+ assert WorktreeManager.get(missing.uuid) is None
+
+
+def test_release_keeps_saved_branch_when_agent_switched_branches(tmp_path):
+ repo = _init_repo(tmp_path / "repo")
+ record = _create(repo)
+ path = Path(record.path)
+ _git(path, "checkout", "-b", "agent-work")
+ (path / "work.txt").write_text("switched branch work\n", encoding="utf-8")
+ _git(path, "add", "-A")
+ _git(path, "commit", "-m", "agent work")
+
+ result = WorktreeManager.release(record)
+
+ assert result.removed is True
+ assert result.branch_kept is True
+ assert result.branch_deleted is False
+ assert record.branch in _git(repo, "branch", "--list")
+ assert "agent-work" in _git(repo, "branch", "--list")
+ assert _git(repo, "show", "agent-work:work.txt") == "switched branch work"
+
+
+def test_registry_round_trip(tmp_path):
+ repo = _init_repo(tmp_path / "repo")
+ record = _create(repo)
+
+ data = record.to_dict()
+ assert json.loads(json.dumps(data)) == data
+ assert WorktreeRecord.from_dict(data) == record
+
+ result = WorktreeManager.release(record)
+ result_data = result.to_dict()
+ assert WorktreeReleaseResult(**result_data) == result
+
+
+def test_get_unknown_uuid_returns_none():
+ assert WorktreeManager.get("missing") is None
+
+
+def test_release_all_releases_matching_pid(tmp_path):
+ repo = _init_repo(tmp_path / "repo")
+ first = _create(repo, uuid="aaaa1111", reuse_existing=False)
+ second = _create(repo, uuid="bbbb2222", reuse_existing=False)
+
+ results = WorktreeManager.release_all()
+
+ assert {result.uuid for result in results} == {first.uuid, second.uuid}
+ assert not Path(first.path).exists()
+ assert not Path(second.path).exists()
+ assert WorktreeManager.get(first.uuid) is None
+ assert WorktreeManager.get(second.uuid) is None
+
+
+def test_release_all_skips_other_pids(tmp_path):
+ repo = _init_repo(tmp_path / "repo")
+ record = _create(repo)
+ other = replace(record, pid=os.getpid() + 1)
+ WorktreeManager._replace_record(record.uuid, other)
+
+ results = WorktreeManager.release_all()
+
+ assert results == []
+ assert Path(record.path).is_dir()
+
+
+def test_sweep_stale_releases_dead_pid(tmp_path):
+ repo = _init_repo(tmp_path / "repo")
+ record = _create(repo)
+ process = subprocess.Popen([sys.executable, "-c", "pass"])
+ process.wait()
+ dead = replace(record, pid=process.pid)
+ WorktreeManager._replace_record(record.uuid, dead)
+
+ results = WorktreeManager.sweep_stale()
+
+ assert len(results) == 1
+ assert results[0].removed is True
+ assert not Path(record.path).exists()
+ assert WorktreeManager.get(record.uuid) is None
+
+
+def test_sweep_stale_skips_alive_pid(tmp_path):
+ repo = _init_repo(tmp_path / "repo")
+ record = _create(repo)
+
+ assert WorktreeManager.sweep_stale() == []
+ assert Path(record.path).is_dir()
+
+
+def test_sweep_stale_skips_other_hosts(tmp_path):
+ repo = _init_repo(tmp_path / "repo")
+ record = _create(repo)
+ other_host = replace(record, host="some-other-host", pid=1)
+ WorktreeManager._replace_record(record.uuid, other_host)
+
+ assert WorktreeManager.sweep_stale() == []
+ assert Path(record.path).is_dir()
+
+
+def test_resolve_source_variants(tmp_path):
+ repo = _init_repo(tmp_path / "repo")
+ nested = repo / "nested"
+ nested.mkdir()
+
+ assert WorktreeManager.resolve_source(nested) == str(repo.resolve())
+ assert WorktreeManager.resolve_source("repo", base_root=tmp_path) == str(repo.resolve())
+ assert WorktreeManager.resolve_source("file:///tmp/example.git") == "file:///tmp/example.git"
+ assert (
+ WorktreeManager.resolve_source("https://example.com/org/repo.git")
+ == "https://example.com/org/repo.git"
+ )
+
+ with pytest.raises(WorktreeError):
+ WorktreeManager.resolve_source(tmp_path / "missing")
+
+
+def test_create_from_file_url_remote(tmp_path, isolated_base):
+ repo = _init_repo(tmp_path / "repo")
+ source = repo.as_uri()
+
+ first = _create(repo, uuid="aaaa1111", source=source, reuse_existing=False)
+ second = _create(repo, uuid="bbbb2222", source=source, reuse_existing=False)
+
+ assert first.repo == second.repo
+ assert Path(first.repo).is_dir()
+ assert Path(first.repo).parent.name == "_sources"
+ assert first.path != second.path
+ assert first.base_ref == "main"
+ assert Path(first.path).is_dir()
+ assert Path(second.path).is_dir()
diff --git a/tests/helpers/worktrees/test_paths.py b/tests/helpers/worktrees/test_paths.py
new file mode 100644
index 00000000000..2e9f2564717
--- /dev/null
+++ b/tests/helpers/worktrees/test_paths.py
@@ -0,0 +1,70 @@
+"""Tests for worktree path layout and naming helpers."""
+
+from __future__ import annotations
+
+import re
+
+import pytest
+
+from cecli.helpers.worktrees import paths
+
+
+@pytest.fixture(autouse=True)
+def isolated_base(tmp_path, monkeypatch):
+ base = tmp_path / "worktrees"
+ monkeypatch.setenv("CECLI_WORKTREES_DIR", str(base))
+ return base
+
+
+def test_managed_root_honors_env(isolated_base):
+ assert paths.managed_root() == isolated_base.resolve()
+
+
+def test_registry_and_lock_paths(isolated_base):
+ assert paths.registry_path() == isolated_base.resolve() / "registry.json"
+ assert paths.lock_dir() == isolated_base.resolve() / ".locks"
+ assert paths.lock_path(None, "My Lock") == isolated_base.resolve() / ".locks" / "My-Lock.lock"
+
+
+def test_slugify_sanitizes_colons_whitespace_and_slashes():
+ assert paths.slugify("wf:nightly tidy") == "wf-nightly-tidy"
+ assert paths.slugify("a/b\\c:d") == "a-b-c-d"
+ assert paths.slugify(" ??? ") == "item"
+ assert paths.slugify("x" * 80, max_length=10) == "x" * 10
+
+
+def test_agent_slug_truncates():
+ slug = paths.agent_slug("wf:" + "a" * 100)
+ assert len(slug) == paths.AGENT_SLUG_MAX
+ assert not slug.endswith("-")
+
+
+def test_branch_and_dir_names():
+ assert paths.branch_name("wf:nightly-tidy", "456abcde") == "cecli/wf-nightly-tidy-456abcde"
+ assert paths.worktree_dir_name("wf:nightly-tidy", "456abcde") == "wf-nightly-tidy-456abcde"
+
+
+def test_branch_name_has_no_slash_after_prefix():
+ branch = paths.branch_name("wf:a/b", "u/u")
+ assert branch.startswith("cecli/")
+ assert "/" not in branch[len("cecli/") :]
+
+
+def test_worktree_path_layout(tmp_path):
+ repo = tmp_path / "my-repo"
+ path = paths.worktree_path(None, repo, "wf:nightly-tidy", "456abcde")
+ assert path.parent.name == "my-repo"
+ assert path.name == "wf-nightly-tidy-456abcde"
+ assert path.is_relative_to(paths.managed_root())
+
+
+def test_repo_and_source_slugs():
+ assert paths.repo_slug("/tmp/My Repo.git") == "My-Repo"
+ assert paths.source_slug("https://example.com/org/Cool-Repo.git") == "Cool-Repo"
+ assert paths.source_slug("git@github.com:org/Cool-Repo.git") == "Cool-Repo"
+
+
+def test_short_digest_is_stable():
+ assert paths.short_digest("value") == paths.short_digest("value")
+ assert len(paths.short_digest("value", 12)) == 12
+ assert re.fullmatch(r"[0-9a-f]+", paths.short_digest("value"))
diff --git a/tests/subagents/test_delegate.py b/tests/subagents/test_delegate.py
index 67097fdc1f6..31a8dbfdea7 100644
--- a/tests/subagents/test_delegate.py
+++ b/tests/subagents/test_delegate.py
@@ -2,6 +2,8 @@
Tests for cecli/tools/delegate.py — Delegate tool execution.
"""
+import json
+from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
@@ -193,3 +195,111 @@ async def test_persist_true_sets_auto_reap_false_invoke(self):
"reviewer", "blocking", parent=mock_coder, auto_reap=False
)
assert "agent completed" in str(result)
+
+ def test_schema_declares_working_dir(self):
+ """The delegation schema accepts an optional per-agent working directory."""
+ from cecli.tools.delegate import Tool
+
+ item = Tool.SCHEMA["function"]["parameters"]["properties"]["delegations"]["items"]
+ assert item["properties"]["working_dir"]["type"] == "string"
+
+ @pytest.mark.asyncio
+ async def test_working_dir_is_passed_to_spawn(self):
+ """working_dir is passed as root for async delegations."""
+ from cecli.tools.delegate import Tool
+
+ mock_coder = MagicMock()
+ mock_coder.uuid = "parent-uuid"
+
+ with patch("cecli.helpers.agents.service.AgentService") as MockService:
+ mock_instance = MagicMock()
+ mock_info = MagicMock()
+ mock_info.coder.uuid = "child-uuid"
+ mock_instance.spawn = AsyncMock(return_value=(MagicMock(), mock_info))
+ MockService.get_instance.return_value = mock_instance
+
+ await Tool.execute(
+ mock_coder,
+ delegations=[
+ {
+ "name": "reviewer",
+ "prompt": "review",
+ "working_dir": "packages/api",
+ }
+ ],
+ )
+
+ mock_instance.spawn.assert_awaited_once_with(
+ "reviewer",
+ "review",
+ parent=mock_coder,
+ auto_reap=None,
+ root="packages/api",
+ )
+
+ @pytest.mark.asyncio
+ async def test_working_dir_is_passed_to_invoke(self):
+ """working_dir is passed as root for sync delegations."""
+ from cecli.tools.delegate import Tool
+
+ mock_coder = MagicMock()
+ mock_coder.uuid = "parent-uuid"
+
+ with patch("cecli.helpers.agents.service.AgentService") as MockService:
+ mock_instance = MagicMock()
+ mock_instance.invoke = AsyncMock(return_value="done")
+ MockService.get_instance.return_value = mock_instance
+
+ await Tool.execute(
+ mock_coder,
+ delegations=[
+ {
+ "name": "reviewer",
+ "prompt": "review",
+ "working_dir": "/tmp/project",
+ "async": False,
+ }
+ ],
+ )
+
+ mock_instance.invoke.assert_awaited_once_with(
+ "reviewer",
+ "review",
+ parent=mock_coder,
+ auto_reap=None,
+ root="/tmp/project",
+ )
+
+ def test_format_output_shows_working_dir(self):
+ """format_output displays an explicit delegation working_dir."""
+ from cecli.tools.delegate import Tool
+
+ coder = MagicMock()
+ coder.pretty = False
+ coder.verbose = False
+ tool_response = SimpleNamespace(
+ function=SimpleNamespace(
+ name="Delegate",
+ arguments=json.dumps(
+ {
+ "delegations": [
+ {
+ "name": "reviewer",
+ "prompt": "review",
+ "working_dir": "packages/api",
+ }
+ ]
+ }
+ ),
+ )
+ )
+
+ with (
+ patch("cecli.tools.delegate.tool_header"),
+ patch("cecli.tools.delegate.tool_footer"),
+ patch("cecli.tools.delegate.color_markers", return_value=("", "")),
+ ):
+ Tool.format_output(coder, mcp_server=MagicMock(), tool_response=tool_response)
+
+ rendered = "\n".join(str(call.args[0]) for call in coder.io.tool_output.call_args_list)
+ assert "working_dir: packages/api" in rendered
diff --git a/tests/subagents/test_service.py b/tests/subagents/test_service.py
index 71f9fbd3c36..300d5932548 100644
--- a/tests/subagents/test_service.py
+++ b/tests/subagents/test_service.py
@@ -5,6 +5,7 @@
import asyncio
import threading
import time
+from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
@@ -732,7 +733,13 @@ async def test_attached_result_is_injected(self, service, mock_coder):
) as get_manager:
await service._inject_sub_agent_result(info)
- assert get_manager.return_value.add_message.call_count == 2
+ add_message = get_manager.return_value.add_message
+ assert add_message.call_count == 1
+
+ message_dict = add_message.call_args.kwargs["message_dict"]
+ assert message_dict["role"] == "user"
+ assert '' in message_dict["content"]
+ assert "all done" in message_dict["content"]
@pytest.mark.asyncio
async def test_wait_skips_detached_children(self, service):
@@ -807,6 +814,48 @@ def call_from_thread(self, callback, *args):
assert tui.via_thread == [("u2",)]
+ def test_stopped_app_drops_notification(self):
+ """Notifications must not touch the DOM of an already stopped app."""
+ from cecli.helpers.agents.service import _tui_notify
+
+ hits = []
+
+ class StoppedTui:
+ is_running = False
+
+ def call_from_thread(self, callback, *args):
+ raise RuntimeError("App is not running")
+
+ def container(self, *args):
+ hits.append(args)
+
+ tui = StoppedTui()
+ _tui_notify(tui, tui.container, "u3")
+
+ assert hits == []
+
+ def test_app_stopping_mid_call_drops_notification(self):
+ """A RuntimeError from an app that stops mid-call is not retried inline."""
+ from cecli.helpers.agents.service import _tui_notify
+
+ hits = []
+
+ class StoppingTui:
+ def __init__(self):
+ self.is_running = True
+
+ def call_from_thread(self, callback, *args):
+ self.is_running = False
+ raise RuntimeError("App is not running")
+
+ def container(self, *args):
+ hits.append(args)
+
+ tui = StoppingTui()
+ _tui_notify(tui, tui.container, "u4")
+
+ assert hits == []
+
# ================================================================== #
# Foreground tracking
@@ -1231,3 +1280,239 @@ async def raise_switch(**kwargs):
service.switch_sub_agent_coder.assert_awaited_once()
assert service.switch_sub_agent_coder.call_args.args[0] is info
assert info.status == SubAgentStatus.FINISHED
+
+
+class TestWorktreeRoots:
+ """Working-directory and worktree resolution in _create_sub_agent_coder()."""
+
+ def _register(self, name, metadata):
+ from cecli.helpers.agents.config import SubAgentConfig
+
+ AgentService.register_subagent(
+ name, SubAgentConfig(name=name, prompt="", metadata=metadata)
+ )
+
+ async def _spawn(self, service, name, **kwargs):
+ mock_new_coder = MagicMock()
+ mock_new_coder.tui = None
+
+ with patch("cecli.coders.Coder") as MockCoder:
+ MockCoder.create = AsyncMock(return_value=mock_new_coder)
+ with patch("cecli.helpers.conversation.service.ConversationService") as MockConv:
+ MockConv.get_chunks.return_value = MagicMock()
+ coder, info = await service.spawn(name, parent=service.coder, **kwargs)
+
+ return MockCoder, mock_new_coder, coder, info
+
+ def _record(self, path):
+ return SimpleNamespace(path=str(path), to_dict=lambda: {"path": str(path)})
+
+ def test_resolve_root_resolves_relative_against_base(self, tmp_path):
+ base = tmp_path / "project"
+ child = base / "packages" / "api"
+ child.mkdir(parents=True)
+
+ assert AgentService._resolve_root("packages/api", str(base)) == str(child.resolve())
+
+ def test_resolve_root_rejects_missing_directory(self, tmp_path):
+ with pytest.raises(ValueError, match="not an existing directory"):
+ AgentService._resolve_root(str(tmp_path / "missing"))
+
+ @pytest.mark.asyncio
+ async def test_explicit_root_wins_over_worktree(self, service, tmp_path):
+ self._register("reviewer", {"worktree": {}})
+ explicit = tmp_path / "explicit"
+ explicit.mkdir()
+
+ with patch("cecli.helpers.worktrees.WorktreeManager.create") as create:
+ MockCoder, _, _, info = await self._spawn(service, "reviewer", root=str(explicit))
+
+ create.assert_not_called()
+ assert MockCoder.create.call_args[1]["root"] == str(explicit.resolve())
+ assert info.worktree is None
+
+ @pytest.mark.asyncio
+ async def test_non_workflow_agent_ignores_worktree_metadata(self, service, tmp_path):
+ service.coder.root = str(tmp_path)
+ source = tmp_path / "source"
+ source.mkdir()
+ self._register(
+ "reviewer",
+ {"worktree": {}, "worktree_source": str(source), "root": str(source)},
+ )
+
+ with patch("cecli.helpers.worktrees.WorktreeManager.create") as create:
+ _, _, _, info = await self._spawn(service, "reviewer")
+
+ create.assert_not_called()
+ assert info.worktree is None
+
+ @pytest.mark.asyncio
+ async def test_override_root_beats_resume_and_spec(self, service, tmp_path, monkeypatch):
+ override_root = tmp_path / "override"
+ override_root.mkdir()
+ monkeypatch.setattr(
+ AgentService, "_agent_overrides", {"reviewer": {"root": str(override_root)}}
+ )
+ self._register("reviewer", {"worktree": {}})
+
+ with (
+ patch("cecli.helpers.worktrees.WorktreeManager.create") as create,
+ patch("cecli.helpers.worktrees.WorktreeManager.recreate") as recreate,
+ ):
+ MockCoder, _, _, info = await self._spawn(
+ service,
+ "reviewer",
+ resume_worktree=self._record(tmp_path / "saved"),
+ )
+
+ create.assert_not_called()
+ recreate.assert_not_called()
+ assert MockCoder.create.call_args[1]["root"] == str(override_root.resolve())
+ assert info.worktree is None
+
+ @pytest.mark.asyncio
+ async def test_resume_worktree_recreates_and_attaches_record(self, service, tmp_path):
+ service.coder.root = str(tmp_path)
+ self._register("wf:demo", {"worktree": {}})
+ saved = {"uuid": "old", "path": str(tmp_path / "old-path")}
+ record = self._record(tmp_path / "restored")
+
+ with (
+ patch(
+ "cecli.helpers.worktrees.WorktreeManager.recreate", return_value=record
+ ) as recreate,
+ patch("cecli.helpers.worktrees.WorktreeManager.create") as create,
+ ):
+ MockCoder, new_coder, _, info = await self._spawn(
+ service, "wf:demo", resume_worktree=saved
+ )
+
+ create.assert_not_called()
+ recreate.assert_called_once()
+ assert recreate.call_args.args[0] == saved
+ assert recreate.call_args.kwargs["agent_name"] == "wf:demo"
+ assert MockCoder.create.call_args[1]["root"] == record.path
+ assert new_coder.worktree is record
+ assert info.worktree is record
+
+ @pytest.mark.asyncio
+ async def test_enabled_worktree_spec_is_a_dict(self, service, tmp_path):
+ """worktree: true normalises to {} and must still create a worktree."""
+ service.coder.root = str(tmp_path)
+ source = tmp_path / "source"
+ source.mkdir()
+ self._register("wf:demo", {"worktree": {}, "worktree_source": str(source)})
+ record = self._record(tmp_path / "created")
+
+ with patch("cecli.helpers.worktrees.WorktreeManager.create", return_value=record) as create:
+ MockCoder, _, _, info = await self._spawn(service, "wf:demo")
+
+ create.assert_called_once()
+ assert create.call_args.kwargs["source"] == str(source)
+ assert create.call_args.kwargs["base_ref"] is None
+ assert create.call_args.kwargs["base_root"] == str(tmp_path)
+ assert MockCoder.create.call_args[1]["root"] == record.path
+ assert info.worktree is record
+
+ @pytest.mark.asyncio
+ async def test_worktree_spec_passes_branch_and_parent_root(self, service, tmp_path):
+ service.coder.root = str(tmp_path)
+ self._register("wf:branch", {"worktree": {"branch": "feature"}})
+ record = self._record(tmp_path / "branch-worktree")
+
+ with patch("cecli.helpers.worktrees.WorktreeManager.create", return_value=record) as create:
+ await self._spawn(service, "wf:branch")
+
+ assert create.call_args.kwargs["base_ref"] == "feature"
+ assert create.call_args.kwargs["source"] == str(tmp_path)
+
+ @pytest.mark.asyncio
+ async def test_worktree_creation_failure_raises(self, service, tmp_path):
+ service.coder.root = str(tmp_path)
+ self._register("wf:fail", {"worktree": {}})
+
+ with patch(
+ "cecli.helpers.worktrees.WorktreeManager.create", side_effect=RuntimeError("boom")
+ ):
+ with pytest.raises(RuntimeError, match="Failed to create worktree"):
+ await self._spawn(service, "wf:fail")
+
+ @pytest.mark.asyncio
+ async def test_coder_creation_failure_releases_worktree(self, service, tmp_path):
+ service.coder.root = str(tmp_path)
+ self._register("wf:fail", {"worktree": {}})
+ record = self._record(tmp_path / "created")
+
+ with (
+ patch("cecli.helpers.worktrees.WorktreeManager.create", return_value=record),
+ patch("cecli.helpers.worktrees.WorktreeManager.release") as release,
+ patch("cecli.coders.Coder") as MockCoder,
+ patch("cecli.helpers.conversation.service.ConversationService"),
+ ):
+ MockCoder.create = AsyncMock(side_effect=RuntimeError("coder failed"))
+ with pytest.raises(RuntimeError, match="coder failed"):
+ await service.spawn("wf:fail", parent=service.coder)
+
+ release.assert_called_once_with(record)
+
+ def test_cleanup_releases_finished_worktree(self, service, tmp_path):
+ path = tmp_path / "finished"
+ record = self._record(path)
+ info = SubAgentInfo(name="reviewer", coder=MagicMock(), parent_uuid="parent-uuid")
+ info.coder.uuid = "sub-uuid"
+ info.worktree = record
+ service.sub_agents["agent"] = info
+ service._sub_agent_order.append("agent")
+ result = SimpleNamespace(to_dict=lambda: {"path": str(path), "branch": "cecli/x"})
+
+ with (
+ patch(
+ "cecli.helpers.worktrees.WorktreeManager.release", return_value=result
+ ) as release,
+ patch("cecli.repo.GitRepoProxy.evict") as evict,
+ ):
+ service._cleanup_sub_agent("agent")
+
+ release.assert_called_once_with(record)
+ evict.assert_called_once_with(str(path))
+ events = [
+ call
+ for call in service.coder.io.subagent_event.call_args_list
+ if call.args[1] == "worktree"
+ ]
+ assert events and events[0].kwargs["path"] == str(path)
+
+ def test_cleanup_leaves_running_worktree_to_release_all(self, service, tmp_path):
+ record = self._record(tmp_path / "running")
+ info = SubAgentInfo(name="reviewer", coder=MagicMock(), parent_uuid="parent-uuid")
+ info.coder.uuid = "sub-uuid"
+ info.worktree = record
+ info.generate_task = MagicMock()
+ info.generate_task.done.return_value = False
+ service.sub_agents["agent"] = info
+ service._sub_agent_order.append("agent")
+
+ with patch("cecli.helpers.worktrees.WorktreeManager.release") as release:
+ service._cleanup_sub_agent("agent")
+
+ release.assert_not_called()
+
+ def test_cleanup_survives_release_failure(self, service, tmp_path):
+ record = self._record(tmp_path / "broken")
+ info = SubAgentInfo(name="reviewer", coder=MagicMock(), parent_uuid="parent-uuid")
+ info.coder.uuid = "sub-uuid"
+ info.worktree = record
+ service.sub_agents["agent"] = info
+ service._sub_agent_order.append("agent")
+
+ with (
+ patch(
+ "cecli.helpers.worktrees.WorktreeManager.release",
+ side_effect=RuntimeError("release failed"),
+ ),
+ patch("cecli.repo.GitRepoProxy.evict"),
+ ):
+ service._cleanup_sub_agent("agent")
+
+ assert "agent" not in service.sub_agents
diff --git a/tests/tools/test_edit_file_format_output.py b/tests/tools/test_edit_file_format_output.py
index 5adc9e1ee1d..01f1a4b2a5c 100644
--- a/tests/tools/test_edit_file_format_output.py
+++ b/tests/tools/test_edit_file_format_output.py
@@ -33,8 +33,19 @@ def tool_output(self, msg, type=None):
def read_text(self, path):
return Path(path).read_text()
+ def _detect_newline(self, path):
+ data = Path(path).read_bytes()
+ if b"\r\n" in data:
+ return "\r\n"
+ if b"\n" in data:
+ return "\n"
+ return None
+
def write_text(self, path, content):
- Path(path).write_text(content)
+ # Mirror the real IO: an already-CRLF payload must not be translated
+ # again, which would double carriage returns on Windows.
+ newline = "" if "\r\n" in content else None
+ Path(path).write_text(content, newline=newline)
class DummyChangeTracker:
@@ -267,3 +278,356 @@ def test_format_output_unwraps_double_wrapped_arguments(coder_with_file):
result = edit_file.Tool.execute(coder, edits=edits)
assert result.to_dict()["errors"] == []
+
+
+def test_description_documents_brace_hint_syntax():
+ """The description must document the canonical double-brace hint syntax."""
+ description = edit_file.Tool.SCHEMA["function"]["description"]
+
+ assert "@A{{regex}}" in description
+ assert "@B{{regex}}" in description
+ assert "@A{regex}" not in description
+ assert "virtual identifiers" in description
+ assert "——" in description
+ assert "ReadFile-only" in description
+ assert "@C" in description
+ assert "@P" in description
+ assert "@N" in description
+ assert "bare '@A'/'@B' is rejected" in description
+ assert "EditFile rejects them" in description
+ assert "indentation may be trimmed" not in description
+
+
+def test_execute_rejects_multiline_anchor(coder_with_file):
+ """EditFile must reject multiline start_line/end_line values."""
+ coder, file_path = coder_with_file
+ original_content = file_path.read_text()
+
+ result = edit_file.Tool.execute(
+ coder,
+ edits=[
+ {
+ "file_path": "example.txt",
+ "operation": "replace",
+ "start_line": "def foo():\n print('hello')",
+ "end_line": "def foo():\n print('hello')",
+ "text": "changed",
+ }
+ ],
+ ).to_dict()
+
+ assert result["errors"]
+ assert "single-line anchor" in result["errors"][0]
+ assert file_path.read_text() == original_content
+
+
+def test_execute_appends_with_000_markers(coder_with_file):
+ """EditFile supports 000@ for both boundaries as an EOF append."""
+ coder, file_path = coder_with_file
+
+ result = edit_file.Tool.execute(
+ coder,
+ edits=[
+ {
+ "file_path": "example.txt",
+ "operation": "replace",
+ "start_line": "000@",
+ "end_line": "000@",
+ "text": "appended",
+ }
+ ],
+ ).to_dict()
+
+ assert result["errors"] == []
+ assert file_path.read_text().endswith(" return foo()\nappended\n")
+
+
+def test_execute_hint_disambiguates_trimmed_duplicate(coder_with_file):
+ """A trimmed anchor plus @L must resolve duplicate lines correctly."""
+ coder, file_path = coder_with_file
+ file_path.write_text(
+ "def alpha():\n" " return total\n" "\n" "\n" "def beta():\n" " return total\n"
+ )
+
+ result = edit_file.Tool.execute(
+ coder,
+ edits=[
+ {
+ "file_path": "example.txt",
+ "operation": "replace",
+ "start_line": "return total @L6",
+ "end_line": "return total @L6",
+ "text": " return 99",
+ }
+ ],
+ ).to_dict()
+
+ assert result["errors"] == []
+ lines = file_path.read_text().splitlines()
+ assert lines[1] == " return total"
+ assert lines[5] == " return 99"
+
+
+@pytest.mark.parametrize(
+ "hint",
+ ["return total @B{def beta}", "return total @B{{def beta}}"],
+)
+def test_execute_at_b_disambiguates_trimmed_duplicate(coder_with_file, hint):
+ """A trimmed anchor plus @B must pick the occurrence before the regex hit."""
+ coder, file_path = coder_with_file
+ file_path.write_text(
+ "def alpha():\n"
+ " return total\n"
+ "\n"
+ "\n"
+ "def beta():\n"
+ " return total\n"
+ "\n"
+ "\n"
+ "def gamma():\n"
+ " return total\n"
+ )
+
+ result = edit_file.Tool.execute(
+ coder,
+ edits=[
+ {
+ "file_path": "example.txt",
+ "operation": "replace",
+ "start_line": hint,
+ "end_line": hint,
+ "text": " return 1",
+ }
+ ],
+ ).to_dict()
+
+ assert result["errors"] == []
+ lines = file_path.read_text().splitlines()
+ assert lines[1] == " return 1"
+ assert lines[5] == " return total"
+ assert lines[9] == " return total"
+
+
+@pytest.mark.parametrize(
+ "hint",
+ ["return total @A{def beta}", "return total @A{{def beta}}"],
+)
+def test_execute_at_a_accepts_single_or_double_braces(coder_with_file, hint):
+ """@A must pick the occurrence after the regex hit with either brace style."""
+ coder, file_path = coder_with_file
+ file_path.write_text(
+ "def alpha():\n" " return total\n" "\n" "\n" "def beta():\n" " return total\n"
+ )
+
+ result = edit_file.Tool.execute(
+ coder,
+ edits=[
+ {
+ "file_path": "example.txt",
+ "operation": "replace",
+ "start_line": hint,
+ "end_line": hint,
+ "text": " return 1",
+ }
+ ],
+ ).to_dict()
+
+ assert result["errors"] == []
+ lines = file_path.read_text().splitlines()
+ assert lines[1] == " return total"
+ assert lines[5] == " return 1"
+
+
+@pytest.mark.parametrize(
+ "hint",
+ ["@A{{def describe}}", "@B{{def describe}}", "@A", "@B"],
+)
+def test_execute_rejects_bare_at_hints(coder_with_file, hint):
+ """Bare @A/@B hints must be rejected instead of resolving to the hit line."""
+ coder, file_path = coder_with_file
+ file_path.write_text("def describe(self):\n return 1\n")
+
+ result = edit_file.Tool.execute(
+ coder,
+ edits=[
+ {
+ "file_path": "example.txt",
+ "operation": "replace",
+ "start_line": hint,
+ "end_line": hint,
+ "text": "REPLACED",
+ }
+ ],
+ ).to_dict()
+
+ assert result["errors"]
+ assert "Bare '@" in result["errors"][0]
+ assert file_path.read_text() == "def describe(self):\n return 1\n"
+
+
+@pytest.mark.parametrize("hint", ["@C1", "@P1", "@N1", "some text @C1"])
+def test_execute_rejects_readfile_only_markers(coder_with_file, hint):
+ """@C/@P/@N markers must fail with an EditFile-specific message."""
+ coder, file_path = coder_with_file
+ original_content = file_path.read_text()
+
+ result = edit_file.Tool.execute(
+ coder,
+ edits=[
+ {
+ "file_path": "example.txt",
+ "operation": "replace",
+ "start_line": hint,
+ "end_line": hint,
+ "text": "REPLACED",
+ }
+ ],
+ ).to_dict()
+
+ assert result["errors"]
+ assert "ReadFile-only" in result["errors"][0]
+ assert "not supported by EditFile" in result["errors"][0]
+ assert file_path.read_text() == original_content
+
+
+def test_execute_prepends_with_at_000_markers(coder_with_file):
+ """Using @000 for both boundaries inserts at the top of the file."""
+ coder, file_path = coder_with_file
+ original = file_path.read_text()
+
+ result = edit_file.Tool.execute(
+ coder,
+ edits=[
+ {
+ "file_path": "example.txt",
+ "operation": "replace",
+ "start_line": "@000",
+ "end_line": "@000",
+ "text": "header",
+ }
+ ],
+ ).to_dict()
+
+ assert result["errors"] == []
+ assert file_path.read_text() == "header\n" + original
+
+
+def test_execute_allows_at_l0_prepend(coder_with_file):
+ """@L0 is treated as the top-of-file marker."""
+ coder, file_path = coder_with_file
+ original = file_path.read_text()
+
+ result = edit_file.Tool.execute(
+ coder,
+ edits=[
+ {
+ "file_path": "example.txt",
+ "operation": "replace",
+ "start_line": "@L0",
+ "end_line": "@L0",
+ "text": "header",
+ }
+ ],
+ ).to_dict()
+
+ assert result["errors"] == []
+ assert file_path.read_text() == "header\n" + original
+
+
+def test_execute_preserves_crlf_line_endings(coder_with_file):
+ """Editing a CRLF file must keep CRLF line endings."""
+ coder, file_path = coder_with_file
+ file_path.write_bytes(b"first\r\nsecond\r\n")
+
+ result = edit_file.Tool.execute(
+ coder,
+ edits=[
+ {
+ "file_path": "example.txt",
+ "operation": "replace",
+ "start_line": "second",
+ "end_line": "second",
+ "text": "SECOND",
+ }
+ ],
+ ).to_dict()
+
+ assert result["errors"] == []
+ assert file_path.read_bytes() == b"first\r\nSECOND\r\n"
+
+
+def test_execute_invalidates_cached_read_ranges(coder_with_file):
+ """A successful edit must clear ReadFile's cached ranges for the file."""
+ import uuid
+
+ from cecli.helpers.conversation import ConversationService
+
+ coder, file_path = coder_with_file
+ coder.uuid = str(uuid.uuid4())
+ abs_path = str(file_path.resolve())
+ files = ConversationService.get_files(coder)
+ files._numbered_contexts[abs_path] = [(1, 1)]
+ files._last_merged_ranges[abs_path] = [(1, 1)]
+ files._emitted_contexts[abs_path] = [(1, 1)]
+ files._file_context_versions[abs_path] = 1
+
+ try:
+ result = edit_file.Tool.execute(
+ coder,
+ edits=[
+ {
+ "file_path": "example.txt",
+ "operation": "replace",
+ "start_line": "@L2",
+ "end_line": "@L2",
+ "text": " print('edited')",
+ }
+ ],
+ ).to_dict()
+
+ assert result["errors"] == []
+ assert abs_path not in files._numbered_contexts
+ assert abs_path not in files._last_merged_ranges
+ assert abs_path not in files._emitted_contexts
+ assert abs_path not in files._file_context_versions
+ finally:
+ ConversationService.destroy_instances(coder.uuid)
+
+
+def test_format_output_colors_inserted_text_green(coder_with_file):
+ """Inserted text is additive, so preview it green like added diff lines."""
+ coder, _ = coder_with_file
+ coder.agent_config["diff_colors"] = True
+ edits = [
+ {
+ "file_path": "example.txt",
+ "operation": "insert",
+ "text": " print('inserted')",
+ "start_line": "@L1",
+ "position": "after",
+ }
+ ]
+
+ output = preview_output(coder, edits)
+
+ assert "\033[32m+ print('inserted')\x1b[0m" in output
+
+
+def test_format_output_leaves_inserted_text_plain_without_diff_colors(coder_with_file):
+ """Inserted text stays uncolored when diff colors are disabled."""
+ coder, _ = coder_with_file
+ coder.agent_config["diff_colors"] = False
+ edits = [
+ {
+ "file_path": "example.txt",
+ "operation": "insert",
+ "text": " print('inserted')",
+ "start_line": "@L1",
+ "position": "after",
+ }
+ ]
+
+ output = preview_output(coder, edits)
+
+ assert "+ print('inserted')" in output
+ assert "\033[32m" not in output
diff --git a/tests/tools/test_get_lines.py b/tests/tools/test_get_lines.py
index 286e04e2556..dd3866f8ee7 100644
--- a/tests/tools/test_get_lines.py
+++ b/tests/tools/test_get_lines.py
@@ -182,3 +182,29 @@ def test_empty_file_includes_edit_hint(tmp_path):
assert "EditFile" in str(result)
assert "readfile again" in str(result).lower()
coder.io.tool_error.assert_not_called()
+
+
+def test_read_after_edit_resends_changed_range(coder_with_file):
+ """ReadFile must not claim cached context is current after the file is edited."""
+ from cecli.helpers.conversation import ConversationService
+
+ coder, file_path = coder_with_file
+ params = [
+ {
+ "file_path": "example.txt",
+ "range_start": "alpha",
+ "range_end": "gamma",
+ "padding": 0,
+ }
+ ]
+
+ first = str(read_file.Tool.execute(coder, read=params))
+ assert "Retrieved context" in first
+
+ file_path.write_text("alpha\nBETA\ngamma\n")
+ ConversationService.get_chunks(coder).notify_file_edited(str(file_path))
+
+ second = str(read_file.Tool.execute(coder, read=params))
+
+ assert "still valid from previous read" not in second
+ assert "BETA" in second
diff --git a/tests/tools/test_grep.py b/tests/tools/test_grep.py
index db5da5e3506..43db59f5b29 100644
--- a/tests/tools/test_grep.py
+++ b/tests/tools/test_grep.py
@@ -113,7 +113,7 @@ def test_powershell_dash_prefixed_pattern_is_searched_literally(search_term, tmp
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"])
+ assert "example.txt" in _files_from_content(op)
coder.io.tool_error.assert_not_called()
@@ -157,8 +157,7 @@ def test_powershell_counts_and_context(tmp_path, monkeypatch):
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
+ assert "sample.txt" in _files_from_content(op)
coder.io.tool_error.assert_not_called()
@@ -172,6 +171,16 @@ def _grep_coder(root):
)
+def _files_from_content(op):
+ """Return the file paths listed in a rendered grep response."""
+ files = set()
+ for line in op["content"].splitlines():
+ path, sep, rest = line.rpartition(": ")
+ if sep and rest.split(" ", 1)[0].isdigit():
+ files.add(path)
+ return files
+
+
@pytest.mark.skipif(shutil.which("rg") is None, reason="rg is required")
@pytest.mark.parametrize(
("options", "expected_matches"),
@@ -217,7 +226,7 @@ def test_matches_mode_is_compact_and_relative(tmp_path, monkeypatch):
assert op["_"]["mode"] == "matches"
assert "file_glob" not in op["_"] # echoed query params are dropped
- assert all("content" not in entry for entry in op["_"]["files"])
+ assert "files" not in op["_"]
coder.io.tool_error.assert_not_called()
@@ -242,7 +251,7 @@ def test_files_mode_skips_content_pass(tmp_path, monkeypatch):
@pytest.mark.skipif(shutil.which("rg") is None, reason="rg is required")
-def test_invalid_mode_falls_back_to_matches(tmp_path, monkeypatch):
+def test_invalid_mode_is_rejected(tmp_path, monkeypatch):
sample = tmp_path / "sample.txt"
sample.write_text("alpha\nbeta\n")
coder = _grep_coder(tmp_path)
@@ -253,8 +262,9 @@ def test_invalid_mode_falls_back_to_matches(tmp_path, monkeypatch):
searches=[{"pattern": "alpha", "file_glob": "*.txt", "directory": ".", "mode": "bogus"}],
)
op = result.to_dict()["result"][0]
- assert op["_"]["mode"] == "matches"
- assert "1: alpha" in op["content"]
+ assert op["_"]["mode"] == "bogus"
+ assert "Invalid mode 'bogus'" in op["_"]["error"]
+ assert "No matches found" not in op["content"]
coder.io.tool_error.assert_not_called()
@@ -299,9 +309,285 @@ def test_history_files_are_excluded(tmp_path, monkeypatch):
coder, searches=[{"pattern": "needle", "directory": ".", "mode": "files"}]
)
op = result.to_dict()["result"][0]
- files = [entry["file"] for entry in op["_"]["files"]]
+ files = _files_from_content(op)
assert "keep.txt" in files
assert all("chat-history" not in name for name in files)
assert all("history.md" not in name for name in files)
coder.io.tool_error.assert_not_called()
+
+
+@pytest.mark.skipif(shutil.which("rg") is None, reason="rg is required")
+def test_regex_syntax_is_auto_detected(tmp_path, monkeypatch):
+ sample = tmp_path / "sample.py"
+ sample.write_text("def foo(x):\n return x\n")
+ coder = _grep_coder(tmp_path)
+ monkeypatch.setattr(grep.Tool, "_find_search_tool", lambda: ("rg", shutil.which("rg")))
+
+ result = grep.Tool.execute(coder, searches=[{"pattern": "^def ", "directory": "."}])
+ op = result.to_dict()["result"][0]
+
+ assert op["_"]["regex"] is True
+ assert op["_"]["total_matches"] == 1
+ assert "def foo(x)" in op["content"]
+
+
+@pytest.mark.skipif(shutil.which("rg") is None, reason="rg is required")
+def test_literal_miss_retries_as_regex(tmp_path, monkeypatch):
+ sample = tmp_path / "sample.txt"
+ sample.write_text("alpha\nbeta\n")
+ coder = _grep_coder(tmp_path)
+ monkeypatch.setattr(grep.Tool, "_find_search_tool", lambda: ("rg", shutil.which("rg")))
+
+ result = grep.Tool.execute(coder, searches=[{"pattern": "(alpha)", "directory": "."}])
+ op = result.to_dict()["result"][0]
+
+ assert op["_"]["regex"] is True
+ assert op["_"]["total_matches"] == 1
+ assert "1: alpha" in op["content"]
+
+
+@pytest.mark.skipif(shutil.which("rg") is None, reason="rg is required")
+def test_empty_alternation_stays_literal(tmp_path, monkeypatch):
+ sample = tmp_path / "sample.txt"
+ sample.write_text("alpha\nbeta\nfoo||bar\n")
+ coder = _grep_coder(tmp_path)
+ monkeypatch.setattr(grep.Tool, "_find_search_tool", lambda: ("rg", shutil.which("rg")))
+
+ result = grep.Tool.execute(coder, searches=[{"pattern": "foo||bar", "directory": "."}])
+ op = result.to_dict()["result"][0]
+
+ assert op["_"]["regex"] is False
+ assert op["_"]["total_matches"] == 1
+ assert "3: foo||bar" in op["content"]
+
+
+@pytest.mark.skipif(shutil.which("rg") is None, reason="rg is required")
+def test_invalid_regex_retries_literal(tmp_path, monkeypatch):
+ sample = tmp_path / "sample.txt"
+ sample.write_text("foo(\nbar\n")
+ coder = _grep_coder(tmp_path)
+ monkeypatch.setattr(grep.Tool, "_find_search_tool", lambda: ("rg", shutil.which("rg")))
+
+ result = grep.Tool.execute(
+ coder, searches=[{"pattern": "foo(", "directory": ".", "use_regex": True}]
+ )
+ op = result.to_dict()["result"][0]
+
+ assert op["_"]["error"] is None
+ assert op["_"]["regex"] is False
+ assert op["_"]["total_matches"] == 1
+ assert op["_"]["warnings"]
+
+
+@pytest.mark.skipif(shutil.which("rg") is None, reason="rg is required")
+def test_lookahead_retries_with_pcre2(tmp_path, monkeypatch):
+ sample = tmp_path / "sample.txt"
+ sample.write_text("foo123\nfoo\n")
+ coder = _grep_coder(tmp_path)
+ monkeypatch.setattr(grep.Tool, "_find_search_tool", lambda: ("rg", shutil.which("rg")))
+
+ result = grep.Tool.execute(
+ coder,
+ searches=[{"pattern": "foo(?=123)", "directory": ".", "use_regex": True}],
+ )
+ op = result.to_dict()["result"][0]
+
+ assert op["_"]["error"] is None
+ assert op["_"]["total_matches"] == 1
+ assert "1: foo123" in op["content"]
+
+
+@pytest.mark.skipif(shutil.which("rg") is None, reason="rg is required")
+def test_backreference_retries_with_pcre2(tmp_path, monkeypatch):
+ sample = tmp_path / "sample.txt"
+ sample.write_text("aa\nab\n")
+ coder = _grep_coder(tmp_path)
+ monkeypatch.setattr(grep.Tool, "_find_search_tool", lambda: ("rg", shutil.which("rg")))
+
+ result = grep.Tool.execute(
+ coder,
+ searches=[{"pattern": r"(a)\1", "directory": ".", "use_regex": True}],
+ )
+ op = result.to_dict()["result"][0]
+
+ assert op["_"]["error"] is None
+ assert op["_"]["total_matches"] == 1
+ assert "1: aa" in op["content"]
+
+
+@pytest.mark.skipif(shutil.which("rg") is None, reason="rg is required")
+def test_paths_with_digit_dash_segments_are_parsed(tmp_path, monkeypatch):
+ (tmp_path / "proj-1-x" / "src").mkdir(parents=True)
+ sample = tmp_path / "proj-1-x" / "src" / "main.py"
+ sample.write_text("alpha\nalpha\n")
+ root = tmp_path / "proj-1-x"
+ coder = _grep_coder(root)
+ monkeypatch.setattr(grep.Tool, "_find_search_tool", lambda: ("rg", shutil.which("rg")))
+
+ result = grep.Tool.execute(coder, searches=[{"pattern": "alpha", "directory": "."}])
+ op = result.to_dict()["result"][0]
+
+ assert op["_"]["total_matches"] == 2
+ assert "src/main.py: 2 match(es)" in op["content"]
+
+
+@pytest.mark.skipif(shutil.which("rg") is None, reason="rg is required")
+def test_colon_in_path_is_parsed(tmp_path, monkeypatch):
+ sample = tmp_path / "a:1:b.txt"
+ sample.write_text("needle\n")
+ coder = _grep_coder(tmp_path)
+ monkeypatch.setattr(grep.Tool, "_find_search_tool", lambda: ("rg", shutil.which("rg")))
+
+ result = grep.Tool.execute(coder, searches=[{"pattern": "needle", "directory": "."}])
+ op = result.to_dict()["result"][0]
+
+ assert op["_"]["total_matches"] == 1
+ assert "a:1:b.txt: 1 match(es)" in op["content"]
+
+
+@pytest.mark.skipif(shutil.which("rg") is None, reason="rg is required")
+def test_ripgrep_config_does_not_change_output_shape(tmp_path, monkeypatch):
+ sample = tmp_path / "sample.txt"
+ sample.write_text("alpha\n")
+ config = tmp_path / "ripgreprc"
+ config.write_text("--heading\n--color=always\n")
+ monkeypatch.setenv("RIPGREP_CONFIG_PATH", str(config))
+ coder = _grep_coder(tmp_path)
+ monkeypatch.setattr(grep.Tool, "_find_search_tool", lambda: ("rg", shutil.which("rg")))
+
+ result = grep.Tool.execute(coder, searches=[{"pattern": "alpha", "directory": "."}])
+ op = result.to_dict()["result"][0]
+
+ assert op["_"]["total_matches"] == 1
+ assert "sample.txt: 1 match(es)" in op["content"]
+
+
+@pytest.mark.skipif(
+ shutil.which("grep") is None or not grep._grep_supports_pcre(shutil.which("grep")),
+ reason="grep with PCRE support is required",
+)
+def test_grep_regex_uses_pcre_when_available(tmp_path, monkeypatch):
+ sample = tmp_path / "sample.txt"
+ sample.write_text("foo123\nfoo\n")
+ coder = _grep_coder(tmp_path)
+ monkeypatch.setattr(grep.Tool, "_find_search_tool", lambda: ("grep", shutil.which("grep")))
+
+ result = grep.Tool.execute(
+ coder,
+ searches=[{"pattern": r"foo\d+", "directory": ".", "use_regex": True}],
+ )
+ op = result.to_dict()["result"][0]
+
+ assert op["_"]["error"] is None
+ assert op["_"]["total_matches"] == 1
+ assert "1: foo123" in op["content"]
+
+
+@pytest.mark.skipif(shutil.which("rg") is None, reason="rg is required")
+def test_matches_mode_shows_truncation_marker(tmp_path, monkeypatch):
+ sample = tmp_path / "sample.txt"
+ sample.write_text("".join(f"alpha{i}\n" for i in range(15)))
+ coder = _grep_coder(tmp_path)
+ monkeypatch.setattr(grep.Tool, "_find_search_tool", lambda: ("rg", shutil.which("rg")))
+
+ result = grep.Tool.execute(coder, searches=[{"pattern": "alpha", "directory": "."}])
+ op = result.to_dict()["result"][0]
+
+ assert "showing 10 of 15 matches" in op["content"]
+ assert "+5 more locations" in op["content"]
+ assert "more locations: 11, 12, 13, 14, 15" in op["content"]
+
+
+@pytest.mark.skipif(shutil.which("rg") is None, reason="rg is required")
+def test_count_false_warns_about_capped_totals(tmp_path, monkeypatch):
+ sample = tmp_path / "sample.txt"
+ sample.write_text("".join("alpha\n" for _ in range(55)))
+ coder = _grep_coder(tmp_path)
+ monkeypatch.setattr(grep.Tool, "_find_search_tool", lambda: ("rg", shutil.which("rg")))
+
+ result = grep.Tool.execute(
+ coder, searches=[{"pattern": "alpha", "directory": ".", "count": False}]
+ )
+ op = result.to_dict()["result"][0]
+
+ assert op["_"]["total_matches"] == grep.MAX_LINE_NUMBERS
+ assert "count capped at 50" in op["content"]
+ assert any("count=false" in warning for warning in op["_"]["warnings"])
+
+
+@pytest.mark.skipif(shutil.which("rg") is None, reason="rg is required")
+def test_context_is_clamped_with_warning(tmp_path, monkeypatch):
+ sample = tmp_path / "sample.txt"
+ sample.write_text("alpha\nbeta\ngamma\n")
+ coder = _grep_coder(tmp_path)
+ monkeypatch.setattr(grep.Tool, "_find_search_tool", lambda: ("rg", shutil.which("rg")))
+
+ result = grep.Tool.execute(
+ coder, searches=[{"pattern": "beta", "directory": ".", "context_before": 9}]
+ )
+ op = result.to_dict()["result"][0]
+
+ assert any("clamped from 9 to 5" in warning for warning in op["_"]["warnings"])
+ assert "clamped from 9 to 5" in op["content"]
+
+
+@pytest.mark.skipif(shutil.which("rg") is None, reason="rg is required")
+def test_comma_separated_globs_are_supported(tmp_path, monkeypatch):
+ (tmp_path / "a.py").write_text("needle\n")
+ (tmp_path / "b.md").write_text("needle\n")
+ (tmp_path / "c.txt").write_text("needle\n")
+ coder = _grep_coder(tmp_path)
+ monkeypatch.setattr(grep.Tool, "_find_search_tool", lambda: ("rg", shutil.which("rg")))
+
+ result = grep.Tool.execute(
+ coder,
+ searches=[{"pattern": "needle", "directory": ".", "file_glob": "*.py,*.md"}],
+ )
+ op = result.to_dict()["result"][0]
+
+ assert op["_"]["total_matches"] == 2
+ assert _files_from_content(op) == {"a.py", "b.md"}
+
+
+@pytest.mark.skipif(shutil.which("rg") is None, reason="rg is required")
+def test_zero_match_glob_gets_warning(tmp_path, monkeypatch):
+ (tmp_path / "sample.py").write_text("needle\n")
+ coder = _grep_coder(tmp_path)
+ monkeypatch.setattr(grep.Tool, "_find_search_tool", lambda: ("rg", shutil.which("rg")))
+
+ result = grep.Tool.execute(
+ coder,
+ searches=[{"pattern": "needle", "directory": ".", "file_glob": "*.nope"}],
+ )
+ op = result.to_dict()["result"][0]
+
+ assert op["_"]["total_matches"] == 0
+ assert any("file_glob='*.nope'" in warning for warning in op["_"]["warnings"])
+
+
+def test_empty_pattern_body_uses_label(tmp_path):
+ coder = _grep_coder(tmp_path)
+ result = grep.Tool.execute(coder, searches=[{"pattern": "", "directory": "."}])
+ op = result.to_dict()["result"][0]
+
+ assert op["content"].startswith("[]")
+ assert op["_"]["error"]
+
+
+@pytest.mark.skipif(shutil.which("grep") is None, reason="grep is required")
+def test_grep_comma_separated_globs_are_supported(tmp_path, monkeypatch):
+ (tmp_path / "a.py").write_text("needle\n")
+ (tmp_path / "b.md").write_text("needle\n")
+ (tmp_path / "c.txt").write_text("needle\n")
+ coder = _grep_coder(tmp_path)
+ monkeypatch.setattr(grep.Tool, "_find_search_tool", lambda: ("grep", shutil.which("grep")))
+
+ result = grep.Tool.execute(
+ coder,
+ searches=[{"pattern": "needle", "directory": ".", "file_glob": "*.py,*.md"}],
+ )
+ op = result.to_dict()["result"][0]
+
+ assert op["_"]["total_matches"] == 2
+ assert _files_from_content(op) == {"a.py", "b.md"}
diff --git a/tests/tools/test_insert_block.py b/tests/tools/test_insert_block.py
index 5c8a692e0ca..6b66401a31d 100644
--- a/tests/tools/test_insert_block.py
+++ b/tests/tools/test_insert_block.py
@@ -259,3 +259,105 @@ def test_line_number_beyond_file_length_appends_no_trailing_newline(coder_with_f
# Insert respects the original trailing newline: no trailing newline in the result
assert content == "first line\nsecond line\nappended line"
coder.io.tool_error.assert_not_called()
+
+
+def test_insert_before_target(coder_with_file):
+ coder, file_path = coder_with_file
+
+ result = edit_file.Tool.execute(
+ coder,
+ edits=[
+ {
+ "file_path": "example.txt",
+ "operation": "insert",
+ "text": "before line",
+ "start_line": "second line",
+ "position": "before",
+ }
+ ],
+ )
+
+ assert "Applied" in result.to_dict()["result"][0]["content"]
+ assert file_path.read_text() == "first line\nbefore line\nsecond line\n"
+
+
+def test_insert_after_target_explicit(coder_with_file):
+ coder, file_path = coder_with_file
+
+ result = edit_file.Tool.execute(
+ coder,
+ edits=[
+ {
+ "file_path": "example.txt",
+ "operation": "insert",
+ "text": "after line",
+ "start_line": "second line",
+ "position": "after",
+ }
+ ],
+ )
+
+ assert "Applied" in result.to_dict()["result"][0]["content"]
+ assert file_path.read_text() == "first line\nsecond line\nafter line\n"
+
+
+def test_insert_invalid_position_fails(coder_with_file):
+ coder, file_path = coder_with_file
+ original = file_path.read_text()
+
+ result = edit_file.Tool.execute(
+ coder,
+ edits=[
+ {
+ "file_path": "example.txt",
+ "operation": "insert",
+ "text": "bad",
+ "start_line": "second line",
+ "position": "middle",
+ }
+ ],
+ ).to_dict()
+
+ assert result["errors"]
+ assert "position" in result["errors"][0]
+ assert file_path.read_text() == original
+
+
+def test_insert_before_top_marker_prepends(coder_with_file):
+ coder, file_path = coder_with_file
+
+ result = edit_file.Tool.execute(
+ coder,
+ edits=[
+ {
+ "file_path": "example.txt",
+ "operation": "insert",
+ "text": "header",
+ "start_line": "@000",
+ "position": "before",
+ }
+ ],
+ )
+
+ assert "Applied" in result.to_dict()["result"][0]["content"]
+ assert file_path.read_text() == "header\nfirst line\nsecond line\n"
+
+
+def test_insert_after_eof_marker_appends(coder_with_file):
+ coder, file_path = coder_with_file
+
+ result = edit_file.Tool.execute(
+ coder,
+ edits=[
+ {
+ "file_path": "example.txt",
+ "operation": "insert",
+ "text": "footer",
+ "start_line": "000@",
+ "position": "after",
+ }
+ ],
+ )
+
+ assert "Applied" in result.to_dict()["result"][0]["content"]
+ assert file_path.read_text() == "first line\nsecond line\nfooter\n"
diff --git a/tests/tools/test_shell_timeout_paging.py b/tests/tools/test_shell_timeout_paging.py
index 2abf4e42204..dacc4b5707c 100644
--- a/tests/tools/test_shell_timeout_paging.py
+++ b/tests/tools/test_shell_timeout_paging.py
@@ -56,8 +56,10 @@ def make_buffer(page_size=4096, pages_dir=None):
monkeypatch.setattr(manager, "_generate_command_key", Mock(return_value=target))
start = Mock(return_value=target)
stop = Mock()
+ mark = Mock(return_value=True)
monkeypatch.setattr(manager, "start_background_command", start)
monkeypatch.setattr(manager, "stop_background_command", stop)
+ monkeypatch.setattr(manager, "mark_continued_in_background", mark)
pending_tasks = []
async def pending_wait(*args, **kwargs):
@@ -85,6 +87,7 @@ async def execute(output, threshold=8, enabled=True):
assert not coder.interrupt_event.is_set()
popen.assert_called_once()
start.assert_called_once()
+ mark.assert_called_once_with(target)
assert start.call_args.kwargs["existing_process"] is process
assert start.call_args.kwargs["existing_buffer"] is state["buffer"]
assert start.call_args.kwargs["persist"] is True
diff --git a/tests/tui/test_background_commands_panel.py b/tests/tui/test_background_commands_panel.py
index 20a080d6d75..74c925f26bb 100644
--- a/tests/tui/test_background_commands_panel.py
+++ b/tests/tui/test_background_commands_panel.py
@@ -32,9 +32,18 @@
"total_chars": 10,
},
"bg_3_4444": {
+ "command": "timeout-tool",
+ "running": True,
+ "persist": True,
+ "continued_in_background": True,
+ "buffer_size": 10,
+ "total_chars": 10,
+ },
+ "bg_4_5555": {
"command": "foreground-tool",
"running": True,
"persist": True,
+ "continued_in_background": False,
"buffer_size": 10,
"total_chars": 10,
},
@@ -43,6 +52,7 @@
OUTPUTS = {
"bg_0_1111": "ready in 231 ms\nlistening on 5173",
"bg_1_2222": "14 passed\n2 skipped",
+ "bg_3_4444": "still compiling",
}
@@ -93,7 +103,7 @@ async def _run_panel_checks():
panel.refresh_data()
await pilot.pause()
- assert _tab_ids(panel) == ["bg_0_1111", "bg_1_2222"]
+ assert _tab_ids(panel) == ["bg_0_1111", "bg_1_2222", "bg_3_4444"]
tabs = panel.query_one("#bg-command-tabs", Tabs)
log = panel.query_one("#bg-command-output", RichLog)
@@ -103,7 +113,7 @@ async def _run_panel_checks():
command_line = panel.query_one("#bg-command-line", Static)
assert "npm run dev" in str(command_line.render())
- assert panel.border_title == "background commands (2)"
+ assert panel.border_title == "background commands (3)"
assert panel._active_key == "bg_0_1111"
assert "listening on 5173" in _log_text(panel)
@@ -126,6 +136,14 @@ async def _run_panel_checks():
await pilot.pause()
assert "listening on 5173" in _log_text(panel)
+ tabs.active = "bg_3_4444"
+ await pilot.pause()
+
+ assert panel._active_key == "bg_3_4444"
+ assert "timeout-tool" in str(command_line.render())
+ assert "timed out, still running" in str(command_line.render())
+ assert "still compiling" in _log_text(panel)
+
def test_background_commands_panel_tabs_output_and_follow():
asyncio.run(_run_panel_checks())
@@ -299,3 +317,43 @@ def test_open_command_without_tui():
asyncio.run(OpenCommand.execute(io, coder, "commands"))
assert messages == ["/open commands is only available in the TUI."]
+
+
+def test_background_commands_panel_adds_tabs_incrementally():
+ """A command appearing after the initial render must not duplicate tab IDs."""
+ app = _PanelApp()
+ commands = {"bg_0_1111": COMMANDS["bg_0_1111"]}
+
+ with (
+ patch.object(
+ BackgroundCommandManager,
+ "list_background_commands",
+ side_effect=lambda: dict(commands),
+ ),
+ patch.object(
+ BackgroundCommandManager,
+ "get_command_output",
+ side_effect=lambda key: OUTPUTS.get(key, ""),
+ ),
+ patch.object(
+ BackgroundCommandManager,
+ "get_new_command_output",
+ side_effect=AssertionError("panel must not consume agent output"),
+ ),
+ ):
+
+ async def run():
+ async with app.run_test(size=(120, 40)) as pilot:
+ panel = app.query_one("#background-commands-panel", BackgroundCommandsPanel)
+ panel.refresh_data()
+ await pilot.pause()
+
+ assert _tab_ids(panel) == ["bg_0_1111"]
+
+ commands["bg_3_4444"] = COMMANDS["bg_3_4444"]
+ panel.refresh_data()
+ await pilot.pause()
+
+ assert _tab_ids(panel) == ["bg_0_1111", "bg_3_4444"]
+
+ asyncio.run(run())
]