From d6d3cdf45cabea30915e45c0aa01487caeeae585 Mon Sep 17 00:00:00 2001 From: Your Name Date: Mon, 15 Jun 2026 18:41:59 -0400 Subject: [PATCH 01/13] #576: Fix JSON parsing error message for agent-config --- cecli/coders/agent_coder.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/cecli/coders/agent_coder.py b/cecli/coders/agent_coder.py index 91ab4d7cdc6..e3fa3c992d1 100644 --- a/cecli/coders/agent_coder.py +++ b/cecli/coders/agent_coder.py @@ -51,6 +51,7 @@ def __init__(self, *args, **kwargs): if kwargs.get("uuid", None): self.uuid = kwargs.get("uuid") + self.start_up_errors = [] self.recently_removed = {} self.tool_usage_history = [] self.loaded_custom_tools = [] @@ -123,6 +124,11 @@ def post_init(self): map(str.lower, self.agent_config.get("servers_excludelist", [])) ) + for err in self.start_up_errors: + self.io.tool_warning(err) + + self.start_up_errors = [] + def _setup_agent(self): os.makedirs(".cecli/temp", exist_ok=True) @@ -143,7 +149,7 @@ def _get_agent_config(self): try: config = json.loads(self.args.agent_config) except (json.JSONDecodeError, TypeError) as e: - self.io.tool_warning(f"Failed to parse agent-config JSON: {e}") + self.start_up_errors.append(f"Failed to parse agent-config JSON: {e}") return {} config["large_file_token_threshold"] = nested.getter( From 71e90de6d2a2a38a198fcf94446a63669dff5ba2 Mon Sep 17 00:00:00 2001 From: Your Name Date: Mon, 15 Jun 2026 18:46:06 -0400 Subject: [PATCH 02/13] Bump Version --- cecli/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cecli/__init__.py b/cecli/__init__.py index 1f6990a2630..6ea9f35ef0a 100644 --- a/cecli/__init__.py +++ b/cecli/__init__.py @@ -1,6 +1,6 @@ from packaging import version -__version__ = "0.100.6.dev" +__version__ = "0.100.8.dev" safe_version = __version__ try: From c24cb3a4fe00d1768820b4dbf065adf8e09e46b8 Mon Sep 17 00:00:00 2001 From: Your Name Date: Mon, 15 Jun 2026 20:34:12 -0400 Subject: [PATCH 03/13] Add `/hot-reload` command to dynamically update configuration --- cecli/commands/__init__.py | 6 ++- cecli/commands/core.py | 19 +++++++++ cecli/commands/hot_reload.py | 39 +++++++++++++++++ cecli/main.py | 83 ++++++++++++++++++++++++++++++++---- cecli/tui/__init__.py | 16 ++++++- cecli/tui/worker.py | 8 +++- 6 files changed, 158 insertions(+), 13 deletions(-) create mode 100644 cecli/commands/hot_reload.py diff --git a/cecli/commands/__init__.py b/cecli/commands/__init__.py index 05e352a66ea..ae4e83a84cd 100644 --- a/cecli/commands/__init__.py +++ b/cecli/commands/__init__.py @@ -20,7 +20,7 @@ from .context_management import ContextManagementCommand from .copy import CopyCommand from .copy_context import CopyContextCommand -from .core import Commands, SwitchCoderSignal +from .core import Commands, ReloadProgramSignal, SwitchCoderSignal from .diff import DiffCommand from .drop import DropCommand from .editor import EditCommand, EditorCommand @@ -32,6 +32,7 @@ from .help import HelpCommand from .history_search import HistorySearchCommand from .hooks import HooksCommand +from .hot_reload import HotReloadCommand from .include_skill import IncludeSkillCommand from .lint import LintCommand from .list_sessions import ListSessionsCommand @@ -116,6 +117,7 @@ CommandRegistry.register(HelpCommand) CommandRegistry.register(HistorySearchCommand) CommandRegistry.register(HooksCommand) +CommandRegistry.register(HotReloadCommand) CommandRegistry.register(ReapAgentCommand) CommandRegistry.register(SpawnAgentCommand) CommandRegistry.register(SwitchAgentCommand) @@ -197,6 +199,7 @@ "HelpCommand", "HistorySearchCommand", "HooksCommand", + "HotReloadCommand", "IncludeSkillCommand", "ReapAgentCommand", "SpawnAgentCommand", @@ -223,6 +226,7 @@ "ReadOnlyCommand", "ReadOnlyStubCommand", "ReasoningEffortCommand", + "ReloadProgramSignal", "RemoveHookCommand", "RemoveMcpCommand", "RemoveSkillCommand", diff --git a/cecli/commands/core.py b/cecli/commands/core.py index 5242b73397a..53aabd70e8c 100644 --- a/cecli/commands/core.py +++ b/cecli/commands/core.py @@ -33,6 +33,25 @@ def __init__(self, placeholder=None, **kwargs): super().__init__() +class ReloadProgramSignal(BaseException): + """ + Signal to reload the entire program configuration. + + This is NOT an error - it's a control flow signal used to trigger + a full program reload, re-parsing config files and re-initializing + all components. Useful for hot-reloading when configuration files + change. + + Note: Inherits from BaseException (like KeyboardInterrupt and SystemExit) + to avoid being caught by generic `except Exception` handlers. + """ + + def __init__(self, message="Reloading program configuration...", **kwargs): + self.kwargs = kwargs + self.message = message + super().__init__(self.message) + + class Commands: scraper = None diff --git a/cecli/commands/hot_reload.py b/cecli/commands/hot_reload.py new file mode 100644 index 00000000000..acc8f2e0521 --- /dev/null +++ b/cecli/commands/hot_reload.py @@ -0,0 +1,39 @@ +from typing import List + +from cecli.commands.core import ReloadProgramSignal +from cecli.commands.utils.base_command import BaseCommand + + +class HotReloadCommand(BaseCommand): + NORM_NAME = "hot-reload" + DESCRIPTION = "Hot-reload all configuration and restart the program" + show_completion_notification = False + + @classmethod + async def execute(cls, io, coder, args, **kwargs): + """Raise ReloadProgramSignal to trigger a full program hot-reload. + + Passes the current coder as from_coder so the new coder + preserves its UUID, edit_format, and other state across + the reload cycle. + """ + io.tool_output("Hot-reloading program configuration...") + raise ReloadProgramSignal( + "User requested configuration reload", + from_coder=coder, + ) + + @classmethod + def get_completions(cls, io, coder, args) -> List[str]: + """Get completion options for hot-reload command.""" + return [] + + @classmethod + def get_help(cls) -> str: + """Get help text for the hot-reload command.""" + help_text = super().get_help() + help_text += "\nUsage:\n" + help_text += " /hot-reload # Hot-reload all configuration files and restart\n" + help_text += "\nThis will re-read config files, reinitialize the connection," + help_text += " and restart the chat session with the updated configuration." + return help_text diff --git a/cecli/main.py b/cecli/main.py index 85893163897..65913f886be 100644 --- a/cecli/main.py +++ b/cecli/main.py @@ -48,7 +48,7 @@ from cecli.args import get_parser from cecli.coders import AgentCoder, Coder from cecli.coders.base_coder import UnknownEditFormat -from cecli.commands import Commands, SwitchCoderSignal +from cecli.commands import Commands, ReloadProgramSignal, SwitchCoderSignal from cecli.deprecated_args import handle_deprecated_model_args from cecli.format_settings import format_settings, scrub_sensitive_info from cecli.helpers.conversation import ConversationService, MessageTag @@ -471,16 +471,54 @@ def custom_tracer(frame, event, arg): def main(argv=None, input=None, output=None, force_git_root=None, return_coder=False): - if sys.platform == "win32": - if sys.version_info >= (3, 12) and hasattr(asyncio, "SelectorEventLoop"): + # Tracks the coder instance from a ReloadProgramSignal so the new + # main_async() can pass it as from_coder to Coder.create(), preserving + # UUID, edit_format, and other state across the reload cycle. + reload_from_coder = None + + while True: + try: + if sys.platform == "win32": + if sys.version_info >= (3, 12) and hasattr(asyncio, "SelectorEventLoop"): + return asyncio.run( + main_async( + argv, + input, + output, + force_git_root, + return_coder, + from_coder=reload_from_coder, + ), + loop_factory=asyncio.SelectorEventLoop, + ) return asyncio.run( - main_async(argv, input, output, force_git_root, return_coder), - loop_factory=asyncio.SelectorEventLoop, + main_async( + argv, + input, + output, + force_git_root, + return_coder, + from_coder=reload_from_coder, + ) ) - return asyncio.run(main_async(argv, input, output, force_git_root, return_coder)) + except ReloadProgramSignal as sig: + reload_from_coder = sig.kwargs.get("from_coder") + # Clear hook registries to prevent 'already exists' warnings on reload. + # The old HookManager and HookRegistry instances are cached by UUID and + # would be reused by the new coder, causing hook registration failures. + if reload_from_coder: + HookService.destroy_instances(reload_from_coder.uuid) + continue -async def main_async(argv=None, input=None, output=None, force_git_root=None, return_coder=False): +async def main_async( + argv=None, + input=None, + output=None, + force_git_root=None, + return_coder=False, + from_coder=None, +): report_uncaught_exceptions() if argv is None: argv = sys.argv[1:] @@ -1016,7 +1054,12 @@ def get_io(pretty): ) mcp_manager = await McpServerManager.from_servers(mcp_servers, io, args.verbose) + if from_coder: + from_coder.tui = None + from_coder.io = None + coder = await Coder.create( + from_coder=from_coder, main_model=main_model, edit_format=args.edit_format, io=io, @@ -1233,8 +1276,23 @@ def get_io(pretty): from cecli.tui import launch_tui del pre_init_io - print("Starting cecli TUI...", flush=True) - return_code = await launch_tui(coder, output_queue, input_queue, args) + try: + return_code = await launch_tui(coder, output_queue, input_queue, args) + except ReloadProgramSignal: + # Clean up before full program reload (mirrors while True loop below) + sys.settrace(None) + await coder.auto_save_session(force=True) + if coder.mcp_manager and coder.mcp_manager.is_connected: + await coder.mcp_manager.disconnect_all() + + # Clean up stale TUI per-coder queues from previous sessions + # to prevent stale queue entries from accumulating across + # reload cycles. + from cecli.tui.io import TextualInputOutput as _TuiIO + + _TuiIO._per_coder_queues.clear() + + raise return await graceful_exit(coder, return_code) while True: try: @@ -1275,6 +1333,13 @@ def get_io(pretty): sys.settrace(None) await coder.auto_save_session(force=True) return await graceful_exit(coder) + except ReloadProgramSignal: + # Clean up before full program reload + sys.settrace(None) + await coder.auto_save_session(force=True) + if coder.mcp_manager and coder.mcp_manager.is_connected: + await coder.mcp_manager.disconnect_all() + raise def is_first_run_of_new_version(io, verbose=False): diff --git a/cecli/tui/__init__.py b/cecli/tui/__init__.py index f2854c2602d..d0723373302 100644 --- a/cecli/tui/__init__.py +++ b/cecli/tui/__init__.py @@ -7,6 +7,8 @@ import queue import weakref +from cecli.commands import ReloadProgramSignal + from .app import TUI from .io import TextualInputOutput from .worker import CoderWorker @@ -71,6 +73,8 @@ async def launch_tui(coder, output_queue, input_queue, args): Returns: Exit code from TUI """ + worker = None + return_code = 0 try: worker = CoderWorker(coder, output_queue, input_queue) app = TUI(worker, output_queue, input_queue, args) @@ -79,8 +83,16 @@ async def launch_tui(coder, output_queue, input_queue, args): coder.tui = weakref.ref(app) return_code = await app.run_async() - - return return_code if return_code else 0 + return_code = return_code if return_code else 0 finally: if worker: worker.stop() + + # After clean shutdown, check if a reload was signaled + # by the worker thread (ReloadProgramSignal caught in _async_run) + if worker and getattr(worker, "_reload_signal", False): + raise ReloadProgramSignal( + "Reloading program configuration after TUI exit", from_coder=worker.coder + ) + + return return_code diff --git a/cecli/tui/worker.py b/cecli/tui/worker.py index 9da5439dcbf..a233df24243 100644 --- a/cecli/tui/worker.py +++ b/cecli/tui/worker.py @@ -7,7 +7,7 @@ from typing import Optional from cecli.coders import Coder -from cecli.commands import SwitchCoderSignal +from cecli.commands import ReloadProgramSignal, SwitchCoderSignal from cecli.helpers.conversation import ConversationService, MessageTag logger = logging.getLogger(__name__) @@ -97,6 +97,12 @@ async def _async_run(self): break except KeyboardInterrupt: continue + except ReloadProgramSignal: + # Store the signal and tell the TUI to exit so the + # full program reload can propagate to main() + self._reload_signal = True + self.output_queue.put({"type": "exit"}) + break except SwitchCoderSignal as switch: await self._handle_switch_coder_signal(switch) # Continue the loop with the new coder From b3991539c47b62ef375c1b5111999b9c086137de Mon Sep 17 00:00:00 2001 From: Your Name Date: Mon, 15 Jun 2026 20:57:24 -0400 Subject: [PATCH 04/13] Fix adjacent replace operations in applying hashline/hashpos operations --- cecli/helpers/hashline.py | 45 +++++- tests/basic/test_hashline_closure.py | 221 +++++++++++++++++++++++++++ 2 files changed, 262 insertions(+), 4 deletions(-) diff --git a/cecli/helpers/hashline.py b/cecli/helpers/hashline.py index 197930a9e25..4d985578b20 100644 --- a/cecli/helpers/hashline.py +++ b/cecli/helpers/hashline.py @@ -1056,17 +1056,54 @@ def _merge_replace_operations(resolved_ops): curr_lines = curr_text.splitlines(keepends=True) # Find longest overlap between suffix of prev and prefix of current + # Normalize trailing newlines for comparison so that + # e.g. ["c"] matches ["c\n"] when the last line of prev + # doesn't have a trailing newline but the first line of curr does. max_check = min(len(prev_lines), len(curr_lines)) overlap_len = 0 for i in range(1, max_check + 1): - if prev_lines[-i:] == curr_lines[:i]: + prev_suffix = [line.rstrip("\n") for line in prev_lines[-i:]] + curr_prefix = [line.rstrip("\n") for line in curr_lines[:i]] + if prev_suffix == curr_prefix: overlap_len = i if overlap_len > 0: - new_text = "".join(prev_lines) + "".join(curr_lines[overlap_len:]) + # Build merged result: + # Take all of prev's lines, then curr's remaining lines. + # Ensure the last line of prev and first line of curr + # are properly separated by a newline. + result_lines = list(prev_lines) + remaining = list(curr_lines[overlap_len:]) + if remaining: + # Ensure proper newline separation between the overlapping + # content and the remaining lines from curr. + # If the last overlapping line in prev doesn't end with \n + # and the first remaining line doesn't start with \n, + # add a newline to keep them on separate lines. + if ( + result_lines + and not result_lines[-1].endswith("\n") + and not remaining[0].startswith("\n") + ): + result_lines[-1] = result_lines[-1] + "\n" + result_lines.extend(remaining) + new_text = "".join(result_lines) else: - # No overlap, just concatenate - new_text = prev_text + curr_text + # No overlap, concatenate with newline separator if needed + # Adjacent operations that replace consecutive line ranges + # must keep their content on separate lines. + is_adjacent = prev["end_idx"] + 1 == current["start_idx"] + needs_newline = ( + is_adjacent + and prev_text + and curr_text + and not prev_text.endswith("\n") + and not curr_text.startswith("\n") + ) + if needs_newline: + new_text = prev_text + "\n" + curr_text + else: + new_text = prev_text + curr_text # Update prev prev["end_idx"] = max(prev["end_idx"], current["end_idx"]) diff --git a/tests/basic/test_hashline_closure.py b/tests/basic/test_hashline_closure.py index 174a24fb248..ca2587dc138 100644 --- a/tests/basic/test_hashline_closure.py +++ b/tests/basic/test_hashline_closure.py @@ -3,6 +3,7 @@ from cecli.helpers.hashline import ( _apply_closure_safeguard, _fix_duplicate_content_boundaries, + _merge_replace_operations, _would_create_duplicate_content, ) @@ -421,3 +422,223 @@ def test_closure_safeguard_heals_broken_dict(): assert ( not tree.root_node.has_error ), f"Healed source still has tree-sitter errors: {new_source!r}" + + +# ============================================================================= +# Tests for _merge_replace_operations +# ============================================================================= + + +def _make_merge_op(operation, text, start_idx, end_idx, index=0): + """Helper to create a resolved operation dict for merge tests.""" + return { + "index": index, + "start_idx": start_idx, + "end_idx": end_idx, + "op": { + "operation": operation, + "text": text, + }, + } + + +def test_merge_non_adjacent_ops(): + """Non-adjacent operations with a gap should NOT be merged.""" + ops = [ + _make_merge_op("replace", "first block", 0, 1, index=0), + _make_merge_op("replace", "second block", 3, 4, index=1), + ] + result = _merge_replace_operations(ops) + # Should remain separate (2 items) + assert len(result) == 2 + assert result[0]["op"]["text"] == "first block" + assert result[1]["op"]["text"] == "second block" + + +def test_merge_overlapping_with_text_overlap(): + """Overlapping ops with overlapping text should merge and deduplicate.""" + ops = [ + _make_merge_op("replace", "a\nb\nc", 0, 2, index=0), + _make_merge_op("replace", "c\nd\ne", 2, 4, index=1), + ] + result = _merge_replace_operations(ops) + assert len(result) == 1 + # The overlapping line "c" should appear only once + assert result[0]["op"]["text"] == "a\nb\nc\nd\ne" + # Range should cover both + assert result[0]["start_idx"] == 0 + assert result[0]["end_idx"] == 4 + + +def test_merge_adjacent_no_overlap_with_trailing_newline(): + """ + Adjacent ops where prev_text ends with \\n. + This should merge correctly without needing an extra newline. + """ + ops = [ + _make_merge_op("replace", "def foo():\n pass\n", 0, 1, index=0), + _make_merge_op("replace", "def bar():\n pass", 2, 3, index=1), + ] + result = _merge_replace_operations(ops) + assert len(result) == 1 + merged_text = result[0]["op"]["text"] + assert "def foo():" in merged_text + assert "def bar():" in merged_text + # The trailing \n on prev_text provides the necessary separator + assert " pass\ndef bar():" in merged_text or "pass\ndef bar():" in merged_text + + +def test_merge_adjacent_no_overlap_leading_newline_in_curr(): + """ + Adjacent ops where curr_text starts with \\n. + This should merge correctly without needing an extra newline. + """ + ops = [ + _make_merge_op("replace", "def foo():\n pass", 0, 1, index=0), + _make_merge_op("replace", "\ndef bar():\n pass", 2, 3, index=1), + ] + result = _merge_replace_operations(ops) + assert len(result) == 1 + merged_text = result[0]["op"]["text"] + assert "def foo():" in merged_text + assert "def bar():" in merged_text + + +def test_merge_adjacent_no_overlap_missing_newline_separator(): + """ + ADJACENT ops where NEITHER text provides the newline separator. + This is the bug case: " pass" + "def bar():" should have a newline between them. + Expected: "def foo():\n pass\ndef bar():\n pass" + """ + ops = [ + _make_merge_op("replace", "def foo():\n pass", 0, 1, index=0), + _make_merge_op("replace", "def bar():\n pass", 2, 3, index=1), + ] + result = _merge_replace_operations(ops) + assert len(result) == 1 + merged_text = result[0]["op"]["text"] + # Expect content to be on separate lines + expected = "def foo():\n pass\ndef bar():\n pass" + assert merged_text == expected, ( + f"Expected lines to be separated by newline.\n" + f" Expected: {expected!r}\n" + f" Got: {merged_text!r}\n" + f" Problem: 'pass' and 'def bar()' are smushed together" + ) + + +def test_merge_adjacent_single_line_texts_no_newline_separator(): + """ + Adjacent ops with single-line texts and no newline separator. + If one op replaces line 1 with 'line_a' and another replaces line 2 with 'line_b', + the merged result should be 'line_a\nline_b'. + """ + ops = [ + _make_merge_op("replace", "line_a", 0, 0, index=0), + _make_merge_op("replace", "line_b", 1, 1, index=1), + ] + result = _merge_replace_operations(ops) + assert len(result) == 1 + merged_text = result[0]["op"]["text"] + expected = "line_a\nline_b" + assert merged_text == expected, ( + f"Expected single-line texts to be separated by newline.\n" + f" Expected: {expected!r}\n" + f" Got: {merged_text!r}\n" + ) + + +def test_merge_adjacent_multi_line_without_separator(): + """ + Adjacent ops, each with multi-line text, where neither provides the + newline separator between the two blocks. + """ + ops = [ + _make_merge_op("replace", "line1\nline2", 0, 1, index=0), + _make_merge_op("replace", "line3\nline4", 2, 3, index=1), + ] + result = _merge_replace_operations(ops) + assert len(result) == 1 + merged_text = result[0]["op"]["text"] + expected = "line1\nline2\nline3\nline4" + assert merged_text == expected, ( + f"Expected all 4 lines to be on separate lines.\n" + f" Expected: {expected!r}\n" + f" Got: {merged_text!r}\n" + f" Problem: 'line2' and 'line3' are likely on the same line" + ) + + +def test_merge_adjacent_complete_lines_with_separator(): + """ + Adjacent ops where both texts end with \\n (complete lines). + This should already work correctly. + """ + ops = [ + _make_merge_op("replace", "line1\nline2\n", 0, 1, index=0), + _make_merge_op("replace", "line3\nline4\n", 2, 3, index=1), + ] + result = _merge_replace_operations(ops) + assert len(result) == 1 + merged_text = result[0]["op"]["text"] + expected = "line1\nline2\nline3\nline4\n" + assert merged_text == expected + + +def test_merge_three_adjacent_ops_no_separator(): + """ + Three adjacent ops where none provides the newline separator between blocks. + """ + ops = [ + _make_merge_op("replace", "block1", 0, 0, index=0), + _make_merge_op("replace", "block2", 1, 1, index=1), + _make_merge_op("replace", "block3", 2, 2, index=2), + ] + result = _merge_replace_operations(ops) + assert len(result) == 1 + merged_text = result[0]["op"]["text"] + expected = "block1\nblock2\nblock3" + assert merged_text == expected, ( + f"Expected 3 blocks on separate lines.\n" + f" Expected: {expected!r}\n" + f" Got: {merged_text!r}" + ) + + +def test_merge_overlapping_no_text_overlap(): + """ + Overlapping ops (same or overlapping line ranges) but with no + overlapping text. This shouldn't normally happen, but the function + should handle it gracefully. + """ + ops = [ + _make_merge_op("replace", "aaa\nbbb", 0, 1, index=0), + _make_merge_op("replace", "ccc\nddd", 1, 2, index=1), + ] + result = _merge_replace_operations(ops) + assert len(result) == 1 + merged_text = result[0]["op"]["text"] + # No overlap in text, so they get concatenated + # Since they overlap on line 1, we need a newline + assert "aaa\nbbb" in merged_text + assert "ccc\nddd" in merged_text + + +def test_merge_different_op_types_not_merged(): + """ + Adjacent ops of different types (replace vs insert) should NOT be merged. + """ + ops = [ + _make_merge_op("replace", "replacement", 0, 0, index=0), + _make_merge_op("insert", "insertion", 1, 1, index=1), + ] + result = _merge_replace_operations(ops) + assert len(result) == 2 + + +def test_merge_single_op_returns_as_is(): + """A single operation should be returned unchanged.""" + ops = [_make_merge_op("replace", "text", 0, 0, index=0)] + result = _merge_replace_operations(ops) + assert len(result) == 1 + assert result[0]["op"]["text"] == "text" From 2b2d0c1bd125684640374c3a39fd773c335494d2 Mon Sep 17 00:00:00 2001 From: Your Name Date: Mon, 15 Jun 2026 21:26:33 -0400 Subject: [PATCH 05/13] Make sure text area is scrollable to see last line when completions are active --- cecli/tui/widgets/completion_bar.py | 1 + cecli/tui/widgets/input_area.py | 18 ++++++++++++++++-- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/cecli/tui/widgets/completion_bar.py b/cecli/tui/widgets/completion_bar.py index 187d2a16917..24ea86e11d8 100644 --- a/cecli/tui/widgets/completion_bar.py +++ b/cecli/tui/widgets/completion_bar.py @@ -16,6 +16,7 @@ class CompletionBar(Widget, can_focus=False): DEFAULT_CSS = """ CompletionBar { + dock: top; height: 1; background: $surface; margin: 0 0; diff --git a/cecli/tui/widgets/input_area.py b/cecli/tui/widgets/input_area.py index 4d59e66246d..7a3ee54cc05 100644 --- a/cecli/tui/widgets/input_area.py +++ b/cecli/tui/widgets/input_area.py @@ -73,7 +73,7 @@ def __init__(self, history_file: str = None, **kwargs): self.files = [] self.commands = [] - self.completion_active = False + self._completion_active = False self._cycling = False self._completion_prefix = "" @@ -125,6 +125,20 @@ def cursor_position(self, pos: int): col = len(lines[row]) self.cursor_location = (row, col) + @property + def completion_active(self) -> bool: + """Whether completion suggestions are currently active.""" + return self._completion_active + + @completion_active.setter + def completion_active(self, value: bool) -> None: + """Set completion active state and update CSS class.""" + self._completion_active = value + if value: + self.add_class("completion-active") + else: + self.remove_class("completion-active") + def _ensure_history_loaded(self) -> list[str]: """Lazily load history on first access. @@ -236,7 +250,7 @@ def on_key(self, event) -> None: self.completion_active = False self.post_message(self.CompletionDismiss()) - if self.app.is_key_for("cancel", event.key): + if self.app.is_key_for("cancel", event.key) and not self.selected_text: event.stop() event.prevent_default() if self.text.strip(): From 8eab522905c371b8627ee6c02a7e8ad9516ab6d3 Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 17 Jun 2026 07:57:43 -0400 Subject: [PATCH 06/13] #554: Allow management of MCP servers in spawned agents --- cecli/coders/agent_coder.py | 28 ++++++----- cecli/coders/base_coder.py | 12 +++++ cecli/commands/load_mcp.py | 28 ++++++++++- cecli/commands/remove_mcp.py | 47 ++++++++++++------ cecli/commands/utils/helpers.py | 88 ++++++++++++++++++++++++++++++++- cecli/helpers/agents/service.py | 13 +++++ 6 files changed, 187 insertions(+), 29 deletions(-) diff --git a/cecli/coders/agent_coder.py b/cecli/coders/agent_coder.py index e3fa3c992d1..cc96e424e04 100644 --- a/cecli/coders/agent_coder.py +++ b/cecli/coders/agent_coder.py @@ -110,19 +110,21 @@ def __init__(self, *args, **kwargs): def post_init(self): super().post_init() - # Populate per-instance tool and server filters from config - self.registered_tools["included"] = set( - map(str.lower, self.agent_config.get("tools_includelist", [])) - ) - self.registered_tools["excluded"] = set( - map(str.lower, self.agent_config.get("tools_excludelist", [])) - ) - self.registered_servers["included"] = set( - map(str.lower, self.agent_config.get("servers_includelist", [])) - ) - self.registered_servers["excluded"] = set( - map(str.lower, self.agent_config.get("servers_excludelist", [])) - ) + + if not self._inherited_tools: + # Populate per-instance tool and server filters from config + self.registered_tools["included"] = set( + map(str.lower, self.agent_config.get("tools_includelist", [])) + ) + self.registered_tools["excluded"] = set( + map(str.lower, self.agent_config.get("tools_excludelist", [])) + ) + self.registered_servers["included"] = set( + map(str.lower, self.agent_config.get("servers_includelist", [])) + ) + self.registered_servers["excluded"] = set( + map(str.lower, self.agent_config.get("servers_excludelist", [])) + ) for err in self.start_up_errors: self.io.tool_warning(err) diff --git a/cecli/coders/base_coder.py b/cecli/coders/base_coder.py index 431f0b5c8eb..1738092fb6e 100755 --- a/cecli/coders/base_coder.py +++ b/cecli/coders/base_coder.py @@ -2,6 +2,7 @@ import asyncio import base64 +import copy import hashlib import json import locale @@ -314,6 +315,8 @@ async def create( ignore_mentions=from_coder.ignore_mentions, file_watcher=from_coder.file_watcher, mcp_manager=from_coder.mcp_manager, + registered_tools=copy.deepcopy(from_coder.registered_tools), + registered_servers=copy.deepcopy(from_coder.registered_servers), uuid=from_coder.uuid, parent_uuid=from_coder.parent_uuid, repo=from_coder.repo, @@ -416,6 +419,8 @@ def __init__( repomap_in_memory=False, linear_output=False, security_config=None, + registered_tools=None, + registered_servers=None, uuid: str = "", parent_uuid: str = "", ): @@ -427,6 +432,13 @@ def __init__( # Each contains "included" and "excluded" sets that filter from the global singletons self.registered_tools = {"included": set(), "excluded": set()} self.registered_servers = {"included": set(), "excluded": set()} + self._inherited_tools = False + + if registered_tools is not None or registered_servers is not None: + self.registered_tools = registered_tools + self.registered_servers = registered_servers + self._inherited_tools = True + self.interrupt_event = ThreadSafeEvent() self.uuid = str(generate_unique_id()) self.reflected_message = None diff --git a/cecli/commands/load_mcp.py b/cecli/commands/load_mcp.py index 302d568640f..3f7259ddfbd 100644 --- a/cecli/commands/load_mcp.py +++ b/cecli/commands/load_mcp.py @@ -1,7 +1,11 @@ from typing import List from cecli.commands.utils.base_command import BaseCommand -from cecli.commands.utils.helpers import format_command_result +from cecli.commands.utils.helpers import ( + format_command_result, + iter_all_coders, + update_server_registration, +) class LoadMcpCommand(BaseCommand): @@ -51,6 +55,20 @@ async def execute(cls, io, coder, args, **kwargs): if not servers_to_load and results: return format_command_result(io, cls.NORM_NAME, "", "\n".join(results)) + # Before connecting any new server, convert coders with empty included sets + # to explicit include lists of all currently connected MCP servers. + # This moves them from "implicitly include all" to explicit state-machine + # management, preventing the new server from being implicitly available + # to all coders. + connected_names = {s.name for s in coder.mcp_manager.connected_servers} + if connected_names: + for c in iter_all_coders(coder): + if not c.registered_servers["included"]: + included = set(connected_names) - c.registered_servers["excluded"] + if c.edit_format in ("agent", "subagent"): + included.add("Local") # "local" is always available + c.registered_servers["included"] = included + # Process connections with interrupt support for server in servers_to_load: server_name = server.name @@ -67,6 +85,14 @@ async def execute(cls, io, coder, args, **kwargs): continue if did_connect: + # Force-include on the primary (active) coder + update_server_registration(coder, server_name, "include", force=True) + + # Safe-exclude on all other coders (respects existing inclusions) + for other_coder in iter_all_coders(coder): + if other_coder is coder: + continue + update_server_registration(other_coder, server_name, "exclude", force=False) results.append(f"Loaded server: {server_name}") else: results.append(f"Unable to load server: {server_name}") diff --git a/cecli/commands/remove_mcp.py b/cecli/commands/remove_mcp.py index ad212da4051..6a08ef33a5f 100644 --- a/cecli/commands/remove_mcp.py +++ b/cecli/commands/remove_mcp.py @@ -1,7 +1,11 @@ from typing import List from cecli.commands.utils.base_command import BaseCommand -from cecli.commands.utils.helpers import format_command_result +from cecli.commands.utils.helpers import ( + format_command_result, + is_server_globally_excluded, + update_server_registration, +) class RemoveMcpCommand(BaseCommand): @@ -44,22 +48,37 @@ async def execute(cls, io, coder, args, **kwargs): for item in servers_to_disconnect: server_name = item.name if hasattr(item, "name") else item - coder.interrupt_event.clear() - - was_disconnected, interrupted = await coder.coroutines.interruptible( - coder.mcp_manager.disconnect_server(server_name), - coder.interrupt_event, - ) - - if interrupted: - io.tool_warning(f"MCP disconnection interrupted: {server_name}") - results.append(f"Interrupted: {server_name}") + # Never remove the "local" server + if server_name == "Local": + results.append("Cannot remove 'Local' server") continue - if was_disconnected: - results.append(f"Removed server: {server_name}") + # Force-exclude on the primary (active) coder + update_server_registration(coder, server_name, "exclude", force=True) + # Check if all coders in the hierarchy have this server excluded + all_excluded = is_server_globally_excluded(coder, server_name) + + if all_excluded: + was_disconnected, interrupted = await coder.coroutines.interruptible( + coder.mcp_manager.disconnect_server(server_name), + coder.interrupt_event, + ) + + if interrupted: + io.tool_warning(f"MCP disconnection interrupted: {server_name}") + results.append(f"Interrupted: {server_name}") + continue + + if was_disconnected: + results.append(f"Removed server: {server_name}") + else: + results.append(f"Unable to remove server: {server_name}") else: - results.append(f"Unable to remove server: {server_name}") + io.tool_output( + f"Server '{server_name}' still in use by other coders, " + f"keeping connection active." + ) + results.append(f"Removed from active coder, still active for others: {server_name}") io.tool_output("\n".join(results)) diff --git a/cecli/commands/utils/helpers.py b/cecli/commands/utils/helpers.py index 0fb5ec6ee18..d2fa2b4184c 100644 --- a/cecli/commands/utils/helpers.py +++ b/cecli/commands/utils/helpers.py @@ -304,6 +304,92 @@ def expand_subdir(file_path: Path) -> List[Path]: for file in file_path.rglob("*"): if file.is_file(): files.append(file) - return files return [] + + +def iter_all_coders(root_coder): + """ + Recursively iterate over all coders in the agent/sub-agent hierarchy. + + Yields the root coder first, then all sub-agents (and their sub-agents) + at every nesting level. + + Args: + root_coder: The top-level coder instance to start from. + + Yields: + Coder instances from the entire hierarchy. + """ + + try: + from cecli.helpers.agents.service import AgentService + + for coder in AgentService.get_all_agents(): + yield coder + except Exception: + pass + + +def update_server_registration(coder, server_name, operation, force=False): + """ + Unify include/exclude server registration with optional force. + + When *force* is True, the operation always succeeds and the + opposing set is cleaned up to be non-conflicting. When *force* + is False (safe mode), the operation is a no-op if the server + is already present in the opposing set. + + Args: + coder: A coder instance with ``registered_servers`` attribute. + server_name: Name of the server to register. + operation: ``"include"`` or ``"exclude"``. + force: When True, always perform the operation. + When False, respect the opposing set (excluded wins + for include, included wins for exclude). + """ + name = server_name + included = coder.registered_servers["included"] + excluded = coder.registered_servers["excluded"] + + if operation == "include": + if force or name not in excluded: + included.add(name) + excluded.discard(name) + elif operation == "exclude": + if force or name not in included: + excluded.add(name) + included.discard(name) + + +def is_server_globally_excluded(coder, server_name): + """ + Check whether *server_name* is excluded from *all* coders in the hierarchy. + + A server is considered "globally excluded" when every coder in the tree + either: + - has it in ``registered_servers["excluded"]``, or + - does **not** have it in ``registered_servers["included"]`` + (empty included means "include all", so the server would be available). + + Args: + coder: Any coder in the hierarchy (used to discover the full tree). + server_name: Name of the MCP server to check. + + Returns: + True if the server is excluded from every coder. + """ + name = server_name + for other in iter_all_coders(coder): + incl = other.registered_servers["included"] + excl = other.registered_servers["excluded"] + + # Empty included => implicitly includes all servers + if not incl: + if name not in excl: + return False + else: + if name in incl: + return False + # Non-empty included means it's an allowlist: not in included => excluded + return True diff --git a/cecli/helpers/agents/service.py b/cecli/helpers/agents/service.py index f62b2950540..4d0f0528c33 100644 --- a/cecli/helpers/agents/service.py +++ b/cecli/helpers/agents/service.py @@ -126,6 +126,19 @@ def get_registry(cls) -> Dict[str, Any]: """Return the global sub-agent registry (name -> config).""" return cls._global_registry + @classmethod + def get_all_agents(cls) -> List[Any]: + """Return all live coder instances tracked in the global uuid map. + + Dereferences weakrefs, skipping any that have been garbage-collected. + """ + agents = [] + for ref in cls._uuid_coder_map.values(): + coder = ref() + if coder is not None: + agents.append(coder) + return agents + @classmethod def register_subagent(cls, name: str, config: Any) -> None: """Register a sub-agent config by name.""" From fe509c23cfe59c13d11e037aef1e6eb27c2d16b4 Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 17 Jun 2026 08:47:37 -0400 Subject: [PATCH 07/13] Manually spawned agents should be independent of parent context-wise, reap children when parent agent yield finishes so no agent is dangling and polluting context --- cecli/coders/agent_coder.py | 11 +++++--- cecli/commands/spawn_agent.py | 4 ++- cecli/helpers/agents/service.py | 18 ++++++++----- cecli/tools/_yield.py | 46 +++++++++++++++++++++++++++++++- tests/subagents/test_commands.py | 4 ++- 5 files changed, 71 insertions(+), 12 deletions(-) diff --git a/cecli/coders/agent_coder.py b/cecli/coders/agent_coder.py index cc96e424e04..2f558dcb8fe 100644 --- a/cecli/coders/agent_coder.py +++ b/cecli/coders/agent_coder.py @@ -1539,15 +1539,20 @@ def get_child_agent_states(self): try: service = AgentService.get_instance(self) children = service.get_children(self) - if not children: return None + # Filter to non-independent children only + dependent_children = [info for info in children if not info.independent] + + if not dependent_children: + return None + result = '\n' result += "## Active Sub-Agent States\n\n" - result += f"Found {len(children)} active child sub-agent(s):\n\n" + result += f"Found {len(dependent_children)} active child sub-agent(s):\n\n" - for info in children: + for info in dependent_children: result += f"**{info.name}**:\n" result += f" - UUID: `{info.coder.uuid}`\n" result += f" - Status: {info.status.value}\n" diff --git a/cecli/commands/spawn_agent.py b/cecli/commands/spawn_agent.py index 6b1c185d17d..97617f6b71e 100644 --- a/cecli/commands/spawn_agent.py +++ b/cecli/commands/spawn_agent.py @@ -27,7 +27,9 @@ async def execute(cls, io, coder, args, **kwargs): try: agent_service = AgentService.get_instance(coder) - new_coder, info = await agent_service.spawn(name, prompt, parent=coder, auto_reap=False) + new_coder, info = await agent_service.spawn( + name, prompt, parent=coder, auto_reap=False, independent=True + ) # Set the newly spawned agent as the foreground agent agent_service.foreground_uuid = info.coder.uuid diff --git a/cecli/helpers/agents/service.py b/cecli/helpers/agents/service.py index 4d0f0528c33..16369dfbad6 100644 --- a/cecli/helpers/agents/service.py +++ b/cecli/helpers/agents/service.py @@ -48,6 +48,7 @@ class SubAgentInfo: None # Track the generate() task for cancellation/monitoring ) auto_reap: bool = True # If True, agent may be automatically reaped when FINISHED + independent: bool = False class AgentService: @@ -418,7 +419,11 @@ def _check_max_sub_agents(self) -> None: ) async def _create_sub_agent_coder( - self, name: str, parent: Any = None, auto_reap: Optional[bool] = None + self, + name: str, + parent: Any = None, + auto_reap: Optional[bool] = None, + independent: bool = False, ) -> Tuple[Any, SubAgentInfo]: """Create a sub-agent coder, register it, and set up its container and prompt. @@ -502,6 +507,7 @@ async def _create_sub_agent_coder( parent_uuid=parent_coder.uuid, status=SubAgentStatus.CREATED, auto_reap=auto_reap, + independent=independent, ) self.sub_agents[new_coder.uuid] = info self._sub_agent_order.append(new_coder.uuid) @@ -590,18 +596,18 @@ async def _run_generate(): info.status = SubAgentStatus.RUNNING try: await info.coder.generate(user_message=user_message, preproc=True) - + await self._inject_sub_agent_result(info) if info.status == SubAgentStatus.RUNNING: info.status = SubAgentStatus.FINISHED info.summary = info.summary or DEFAULT_SUMMARY_COMPLETED - await self._inject_sub_agent_result(info) except asyncio.CancelledError: + await self._inject_sub_agent_result(info) info.status = SubAgentStatus.FINISHED info.summary = info.summary or DEFAULT_SUMMARY_INTERRUPTED logger.debug("Sub-agent %s generate cancelled (interrupted)", info.name) - await self._inject_sub_agent_result(info) raise except Exception as exc: + await self._inject_sub_agent_result(info) info.status = SubAgentStatus.ERROR info.error = str(exc) logger.error( @@ -610,7 +616,6 @@ async def _run_generate(): exc, exc_info=True, ) - await self._inject_sub_agent_result(info) raise except SwitchCoderSignal: raise @@ -736,6 +741,7 @@ async def spawn( prompt: Optional[str] = None, parent: Any = None, auto_reap: Optional[bool] = None, + independent: bool = False, ) -> Tuple[Any, SubAgentInfo]: """Spawn a sub-agent (non-blocking) that waits for user input. @@ -751,7 +757,7 @@ async def spawn( 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 + name, auto_reap=auto_reap, parent=parent, independent=independent ) if prompt: self.start_generate_task(info, prompt) diff --git a/cecli/tools/_yield.py b/cecli/tools/_yield.py index 158b72bf32d..57d5860b200 100644 --- a/cecli/tools/_yield.py +++ b/cecli/tools/_yield.py @@ -45,7 +45,7 @@ async def execute(cls, coder, **kwargs): This gives the LLM explicit control over when it can stop looping """ - from cecli.helpers.agents.service import AgentService + from cecli.helpers.agents.service import AgentService, SubAgentStatus cls.clear_invocation_cache() @@ -109,6 +109,50 @@ async def execute(cls, coder, **kwargs): except asyncio.CancelledError: pass + # Wait for non-independent child agents to reach a terminal status + children = agent_service.get_children(coder) + non_independent_children = [info for info in children if not info.independent] + + if non_independent_children: + interrupt_event = coder.interrupt_event + if interrupt_event is None: + interrupt_event = ThreadSafeEvent() + + interrupt_task = asyncio.create_task(interrupt_event.wait()) + + while True: + refreshed_children = agent_service.get_children(coder) + non_dependent_active = [ + info + for info in refreshed_children + if not info.independent + and info.status + not in (SubAgentStatus.FINISHED, SubAgentStatus.ERROR) + ] + + if not non_dependent_active: + break + + done, _ = await asyncio.wait( + [interrupt_task], + timeout=2, + return_when=asyncio.FIRST_COMPLETED, + ) + + if interrupt_task in done: + # Interrupted — stop waiting + if not interrupt_task.done(): + interrupt_task.cancel() + break + + if not interrupt_task.done(): + interrupt_task.cancel() + try: + await interrupt_task + except asyncio.CancelledError: + pass + + await agent_service.reap_all_finished_agents(parent=coder) # Don't mark as finished — the coder should review sub-agent # outputs and decide how to proceed return ( diff --git a/tests/subagents/test_commands.py b/tests/subagents/test_commands.py index 3413cd44867..800601d9a25 100644 --- a/tests/subagents/test_commands.py +++ b/tests/subagents/test_commands.py @@ -36,7 +36,9 @@ async def test_valid_name_calls_spawn(self): await SpawnAgentCommand.execute(io, coder, "reviewer") - mock_instance.spawn.assert_called_once_with("reviewer", None, parent=coder, auto_reap=False) + mock_instance.spawn.assert_called_once_with( + "reviewer", None, parent=coder, auto_reap=False, independent=True + ) io.tool_output.assert_called_once() assert "spawned" in io.tool_output.call_args[0][0] From 4d9e4927896f5178e3ad08ebd92531a754c3d593 Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 17 Jun 2026 22:22:39 -0400 Subject: [PATCH 08/13] Let litellm set github copilot headers, give tool_choice it's proper default value when tools are present --- cecli/models.py | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/cecli/models.py b/cecli/models.py index 8ace100ef39..190757a4dc4 100644 --- a/cecli/models.py +++ b/cecli/models.py @@ -1227,6 +1227,7 @@ async def send_completion( pass kwargs["tools"] = sorted_tools + kwargs["tool_choice"] = "auto" if functions and len(functions) == 1: function = functions[0] @@ -1234,6 +1235,7 @@ async def send_completion( tool_name = function.get("name") if tool_name: kwargs["tool_choice"] = {"type": "function", "function": {"name": tool_name}} + if self.extra_params: kwargs.update(self.extra_params) if max_tokens: @@ -1266,13 +1268,6 @@ async def send_completion( {"location": "message", "index": -2}, ] - if "GITHUB_COPILOT_TOKEN" in os.environ or self.name.startswith("github_copilot/"): - if "extra_headers" not in kwargs: - kwargs["extra_headers"] = { - "Editor-Version": f"cecli/{__version__}", - "Copilot-Integration-Id": "vscode-chat", - } - if kwargs.get("headers", None): kwargs["headers"].update( { From 07b326dbc2c42e3da273922eae2ac34e4e3896aa Mon Sep 17 00:00:00 2001 From: Your Name Date: Thu, 18 Jun 2026 01:34:22 -0400 Subject: [PATCH 09/13] Temp original hashpos --- cecli/helpers/hashpos/hashpos.py | 154 +++++++++++++++++-------------- 1 file changed, 83 insertions(+), 71 deletions(-) diff --git a/cecli/helpers/hashpos/hashpos.py b/cecli/helpers/hashpos/hashpos.py index 516052012c9..63897932ee6 100644 --- a/cecli/helpers/hashpos/hashpos.py +++ b/cecli/helpers/hashpos/hashpos.py @@ -5,6 +5,8 @@ class HashPos: B64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789~_" + # The actual coprime period (64 * 63) + PERIOD = 4032 # Regex pattern for HashPos format: {4-char-hash}:: HASH_PREFIX_RE = re.compile(r"^([0-9a-zA-Z\~_@]{4})::") # Regex for normalization: 4 hash chars optionally followed by '::' @@ -16,53 +18,71 @@ def __init__(self, source_text: str = ""): self.lines = source_text.splitlines() self.total = len(self.lines) - def _get_region_bits(self, line_idx: int) -> tuple[int, int]: - """ - Uses line_idx modulo 16 (4 bits) to get two 2-bit flags (b1, b2). - This guarantees up to 16 consecutive repeating lines get unique spatial anchors. - """ - mod_val = line_idx % 16 + def _get_content_bits(self, text: str) -> int: + return xxhash.xxh3_64_intdigest(text.encode("utf-8")) & 0xFFF - # Split the 4-bit modulo value into two separate 2-bit flags - b1 = (mod_val >> 2) & 3 # Top 2 bits (mask with 0b11) - b2 = mod_val & 3 # Bottom 2 bits - return b1, b2 + def _get_anchor_bits(self, line_idx: int) -> int: + a1 = (line_idx * 53 + 13) % 64 + a2 = (line_idx * 59 + 31) % 63 + return (a1 << 6) | a2 - def _get_neighborhood_hash(self, line_idx: int) -> int: + def _spread_bits(self, x: int) -> int: """ - Creates a 20-bit digest using the current line and the 3 lines - before and after it. + Spreads 12 bits of x into 24 bits by inserting a 0 between each bit. + Input: 000000000000abcdefghijkl (12 bits) + Output: 0a0b0c0d0e0f0g0h0i0j0k0l (24 bits) """ - start = max(0, line_idx - 3) - end = min(self.total, line_idx + 4) - - context_window = "\n".join(self.lines[start:end]) - full_hash = xxhash.xxh3_64_intdigest(context_window.encode("utf-8")) + x &= 0xFFF # Ensure we only have 12 bits + # Shift bits by 8, mask keeps the blocks separated + # x starts: 000000000000 abcdefgh ijkl + x = (x | (x << 8)) & 0x00FF00FF # 0000abcd efgh0000 00000000 ijkl... + # Shift by 4, then 2, then 1 to create 1-bit gaps + x = (x | (x << 4)) & 0x0F0F0F0F + x = (x | (x << 2)) & 0x33333333 + x = (x | (x << 1)) & 0x55555555 # Result: 0a0b0c0d0e0f0g0h0i0j0k0l + return x - # Isolate exactly 20 bits - return full_hash & 0xFFFFF + def _compact_bits(self, x: int) -> int: + """ + The inverse of spread: pulls every other bit back together. + Input: 0a0b0c0d0e0f0g0h0i0j0k0l (24 bits) + Output: 000000000000abcdefghijkl (12 bits) + """ + x &= 0x55555555 # Mask to ensure we only look at the "active" bits + x = (x | (x >> 1)) & 0x33333333 + x = (x | (x >> 2)) & 0x0F0F0F0F + x = (x | (x >> 4)) & 0x00FF00FF + x = (x | (x >> 8)) & 0x0000FFFF # Result: abcdefghijkl + return x - def generate_private_id(self, text: str) -> str: + def _interleave(self, content: int, anchor: int) -> int: """ - Generates a fast 12-bit (3 hex chars) hash based purely on the line text. + Weaves content and anchor bits together. + Content bits occupy the 'odd' positions, Anchor bits occupy the 'even'. """ - bits = xxhash.xxh3_64_intdigest(text.encode("utf-8")) & 0xFFF - return f"{bits:03x}" + # Spread content bits and shift by 1 to put them in positions 1, 3, 5... + # Spread anchor bits and leave them in positions 0, 2, 4... + return (self._spread_bits(content) << 1) | self._spread_bits(anchor) - def generate_public_id(self, text: str, line_idx: int) -> str: + def _deinterleave(self, mixed: int) -> tuple[int, int]: """ - Generates a 4-char Base64 ID combining modulo buckets and context hash. - Layout: [2-bit b1] [2-bit b2] [10-bit Hash A] [10-bit Hash B] + Extracts content and anchor bits from a 24-bit interleaved integer. """ - b1, b2 = self._get_region_bits(line_idx) - neighborhood_hash = self._get_neighborhood_hash(line_idx) + # To get content: shift right by 1, then compact + content = self._compact_bits(mixed >> 1) + # To get anchor: just compact (the mask inside _compact_bits handles the rest) + anchor = self._compact_bits(mixed) + return content, anchor - # Split the 20-bit hash into two 10-bit halves - hash_a = (neighborhood_hash >> 10) & 0x3FF - hash_b = neighborhood_hash & 0x3FF + def generate_private_id(self, text: str) -> str: + bits = self._get_content_bits(text) + return f"{bits:03x}" + + def generate_public_id(self, text: str, line_idx: int) -> str: + content_bits = self._get_content_bits(text) + anchor_bits = self._get_anchor_bits(line_idx) + packed = self._interleave(content_bits, anchor_bits) - # Construct the mixed 24-bit integer - packed = (b1 << 22) | (b2 << 20) | (hash_a << 10) | hash_b res = "" for _ in range(4): res += self.B64[packed % 64] @@ -70,21 +90,11 @@ def generate_public_id(self, text: str, line_idx: int) -> str: return res def unpack_public_id(self, public_id: str) -> tuple[int, int]: - """ - Reverses the Public ID back into its (Modulo 16, Neighborhood Hash) values. - """ packed = 0 for i, char in enumerate(public_id): packed |= self.B64.index(char) << (6 * i) - b1 = (packed >> 22) & 3 - b2 = (packed >> 20) & 3 - hash_a = (packed >> 10) & 0x3FF - hash_b = packed & 0x3FF - mod_val = (b1 << 2) | b2 - neighborhood_hash = (hash_a << 10) | hash_b - - return mod_val, neighborhood_hash + return self._deinterleave(packed) def format_content(self, use_private_ids: bool = False, start_line: int = 1) -> str: formatted_lines = [] @@ -92,46 +102,44 @@ def format_content(self, use_private_ids: bool = False, start_line: int = 1) -> prefix = ( self.generate_private_id(line) if use_private_ids - else self.generate_public_id(line, i) + else self.generate_public_id(line, i + start_line) ) formatted_lines.append(f"{prefix}::{line}") return "\n".join(formatted_lines) def resolve_to_lines(self, public_id: str, start_line: int = 1) -> list[int]: - target_mod, target_hash = self.unpack_public_id(public_id) - matches = [] + target_content, target_anchor = self.unpack_public_id(public_id) + content_matches = [] + perfect_matches = [] - # Find all lines whose neighborhood hash matches our target for i, line in enumerate(self.lines): - if self._get_neighborhood_hash(i) == target_hash: - matches.append(i) - - if not matches: - return [] - - # If perfectly unique, return it immediately - if len(matches) == 1: - return matches + if self._get_content_bits(line) == target_content: + current_anchor = self._get_anchor_bits(i + start_line) + if current_anchor == target_anchor: + perfect_matches.append(i) + else: + dist = abs(current_anchor - target_anchor) + # Use the actual coprime period for the circular logic + dist = min(dist, self.PERIOD - dist) - # Distance Heuristic: If multiple matches exist (e.g. repeated code blocks), - # prioritize the one whose modulo is closest to the target modulo. - # We use circular distance since mod 16 wraps around (0 is adjacent to 15). - def modulo_distance(idx: int) -> int: - current_mod = idx % 16 - dist = abs(current_mod - target_mod) - return min(dist, 16 - dist) + # ~1% chance of collision around 10 items + if dist <= 1: + content_matches.append((dist, i)) - matches.sort(key=modulo_distance) + if perfect_matches: + return perfect_matches - return matches + content_matches.sort(key=lambda x: x[0]) + return [match[1] for match in content_matches] def resolve_range(self, start_id: str, end_id: str) -> tuple[int, int]: """ Resolves a block range from two Public IDs. Logic: - 1. Resolve all candidates for both IDs (sorted by best match). - 2. Find the pair of (start, end) that are logically ordered. + 1. Resolve all candidates for both IDs. + 2. Find the pair of (start, end) that are logically ordered and + have the lowest combined distance score. 3. Returns (start_index, end_index) """ starts = self.resolve_to_lines(start_id) @@ -140,9 +148,13 @@ def resolve_range(self, start_id: str, end_id: str) -> tuple[int, int]: if not starts or not ends: raise ValueError(f"Could not resolve IDs: {start_id}..{end_id}") + # If both have 'perfect' matches that are logically ordered, use them immediately + # Note: resolve_to_lines returns perfect matches first. for s in starts: for e in ends: if s <= e: + # Return the first logical pair found + # (This prioritizes perfect matches or closest heuristics) return s, e raise ValueError( @@ -221,6 +233,6 @@ def normalize(hashpos_str: str) -> str: # If no pattern matches, raise error raise ValueError( f"Invalid HashPos format '{hashpos_str}'. " - r"Expected \"{content ID}\" " - r"where content ID is exactly 4 characters from the set [0-9a-zA-Z\~_@]." + r"Expected \"{hash_prefix}\" " + r"where hash_prefix is exactly 4 characters from the set [0-9a-zA-Z\~_@]." ) From 90029e4581c7a4f6bd574abb02108b8c4bf1186f Mon Sep 17 00:00:00 2001 From: Your Name Date: Thu, 18 Jun 2026 02:25:02 -0400 Subject: [PATCH 10/13] Edit Updates: - Remove insert operation from edit_text, since it can be accomplished by replace --- cecli/tools/edit_text.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/cecli/tools/edit_text.py b/cecli/tools/edit_text.py index 3b1ae9e58f8..a22e96dcd57 100644 --- a/cecli/tools/edit_text.py +++ b/cecli/tools/edit_text.py @@ -37,14 +37,14 @@ class Tool(BaseTool): "name": "EditText", "description": ( "Edit text in one or more files using content ID markers. " - "Supports replace, delete, and insert operations in a single call. " + "Supports replace and delete operations in a single call. " "Can handle an array of edits across multiple files. " "Each edit must include its own file_path and operation type. " "Use content ID ranges with the start_line and end_line parameters with format " "`content_id::` (the content id with the :: demarcator). For empty files, use `@000` as the " "content ID references. " "Start and end values are inclusive: start and end content IDs both count as " - "part of the range to replace, insert at, or delete. " + "part of the range to replace or delete. " "Edits within a file must not be adjacent or overlapping." ), "parameters": { @@ -61,11 +61,10 @@ class Tool(BaseTool): }, "operation": { "type": "string", - "enum": ["replace", "delete", "insert"], + "enum": ["replace", "delete"], "description": ( "The type of operation: 'replace' (replace range with" - " text), 'delete' (remove range), or 'insert' (insert text" - " at start_line, replacing the start line)." + " text) or 'delete' (remove range)." ), }, "start_line": { @@ -178,7 +177,7 @@ def execute( if operation not in VALID_OPERATIONS: raise ToolError( f"Edit {edit_index + 1}: Invalid operation '{operation}'. " - "Must be 'replace', 'delete', or 'insert'" + "Must be 'replace' or 'delete'" ) edit_text_raw = edit.get("text") From d5f7bd6737f1adfd74482872fd61004d73ca7d43 Mon Sep 17 00:00:00 2001 From: Your Name Date: Thu, 18 Jun 2026 02:33:02 -0400 Subject: [PATCH 11/13] Silent file content gathering in context file manager --- cecli/helpers/conversation/files.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/cecli/helpers/conversation/files.py b/cecli/helpers/conversation/files.py index cb29ccdd0a8..8b703e674ae 100644 --- a/cecli/helpers/conversation/files.py +++ b/cecli/helpers/conversation/files.py @@ -107,7 +107,7 @@ def add_file( # Use coder.io.read_text() - coder should always be available coder = self.get_coder() try: - content = coder.io.read_text(abs_fname) + content = coder.io.read_text(abs_fname, silent=True) if coder.hashlines: content = hashline(content) except Exception: @@ -224,7 +224,7 @@ def generate_diff(self, fname: str) -> Optional[str]: coder = self.get_coder() rel_fname = coder.get_rel_fname(fname) try: - current_content = coder.io.read_text(abs_fname) + current_content = coder.io.read_text(abs_fname, silent=True) if coder.hashlines: current_content = hashline(current_content) except Exception: @@ -525,7 +525,7 @@ def get_file_context(self, file_path: str, all_ranges=False) -> str: # Read file content try: - content = coder.io.read_text(abs_fname) + content = coder.io.read_text(abs_fname, silent=True) if not content: return "" except Exception: From 4b3109851d7e767679473ee64e7fae937eee4e00 Mon Sep 17 00:00:00 2001 From: Your Name Date: Thu, 18 Jun 2026 07:47:22 -0400 Subject: [PATCH 12/13] Revert hashpos to local hashing --- cecli/helpers/hashpos/hashpos.py | 154 ++++++++++++++----------------- 1 file changed, 71 insertions(+), 83 deletions(-) diff --git a/cecli/helpers/hashpos/hashpos.py b/cecli/helpers/hashpos/hashpos.py index 63897932ee6..516052012c9 100644 --- a/cecli/helpers/hashpos/hashpos.py +++ b/cecli/helpers/hashpos/hashpos.py @@ -5,8 +5,6 @@ class HashPos: B64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789~_" - # The actual coprime period (64 * 63) - PERIOD = 4032 # Regex pattern for HashPos format: {4-char-hash}:: HASH_PREFIX_RE = re.compile(r"^([0-9a-zA-Z\~_@]{4})::") # Regex for normalization: 4 hash chars optionally followed by '::' @@ -18,71 +16,53 @@ def __init__(self, source_text: str = ""): self.lines = source_text.splitlines() self.total = len(self.lines) - def _get_content_bits(self, text: str) -> int: - return xxhash.xxh3_64_intdigest(text.encode("utf-8")) & 0xFFF - - def _get_anchor_bits(self, line_idx: int) -> int: - a1 = (line_idx * 53 + 13) % 64 - a2 = (line_idx * 59 + 31) % 63 - return (a1 << 6) | a2 - - def _spread_bits(self, x: int) -> int: + def _get_region_bits(self, line_idx: int) -> tuple[int, int]: """ - Spreads 12 bits of x into 24 bits by inserting a 0 between each bit. - Input: 000000000000abcdefghijkl (12 bits) - Output: 0a0b0c0d0e0f0g0h0i0j0k0l (24 bits) + Uses line_idx modulo 16 (4 bits) to get two 2-bit flags (b1, b2). + This guarantees up to 16 consecutive repeating lines get unique spatial anchors. """ - x &= 0xFFF # Ensure we only have 12 bits - # Shift bits by 8, mask keeps the blocks separated - # x starts: 000000000000 abcdefgh ijkl - x = (x | (x << 8)) & 0x00FF00FF # 0000abcd efgh0000 00000000 ijkl... - # Shift by 4, then 2, then 1 to create 1-bit gaps - x = (x | (x << 4)) & 0x0F0F0F0F - x = (x | (x << 2)) & 0x33333333 - x = (x | (x << 1)) & 0x55555555 # Result: 0a0b0c0d0e0f0g0h0i0j0k0l - return x + mod_val = line_idx % 16 - def _compact_bits(self, x: int) -> int: - """ - The inverse of spread: pulls every other bit back together. - Input: 0a0b0c0d0e0f0g0h0i0j0k0l (24 bits) - Output: 000000000000abcdefghijkl (12 bits) - """ - x &= 0x55555555 # Mask to ensure we only look at the "active" bits - x = (x | (x >> 1)) & 0x33333333 - x = (x | (x >> 2)) & 0x0F0F0F0F - x = (x | (x >> 4)) & 0x00FF00FF - x = (x | (x >> 8)) & 0x0000FFFF # Result: abcdefghijkl - return x + # Split the 4-bit modulo value into two separate 2-bit flags + b1 = (mod_val >> 2) & 3 # Top 2 bits (mask with 0b11) + b2 = mod_val & 3 # Bottom 2 bits + return b1, b2 - def _interleave(self, content: int, anchor: int) -> int: + def _get_neighborhood_hash(self, line_idx: int) -> int: """ - Weaves content and anchor bits together. - Content bits occupy the 'odd' positions, Anchor bits occupy the 'even'. + Creates a 20-bit digest using the current line and the 3 lines + before and after it. """ - # Spread content bits and shift by 1 to put them in positions 1, 3, 5... - # Spread anchor bits and leave them in positions 0, 2, 4... - return (self._spread_bits(content) << 1) | self._spread_bits(anchor) + start = max(0, line_idx - 3) + end = min(self.total, line_idx + 4) - def _deinterleave(self, mixed: int) -> tuple[int, int]: - """ - Extracts content and anchor bits from a 24-bit interleaved integer. - """ - # To get content: shift right by 1, then compact - content = self._compact_bits(mixed >> 1) - # To get anchor: just compact (the mask inside _compact_bits handles the rest) - anchor = self._compact_bits(mixed) - return content, anchor + context_window = "\n".join(self.lines[start:end]) + full_hash = xxhash.xxh3_64_intdigest(context_window.encode("utf-8")) + + # Isolate exactly 20 bits + return full_hash & 0xFFFFF def generate_private_id(self, text: str) -> str: - bits = self._get_content_bits(text) + """ + Generates a fast 12-bit (3 hex chars) hash based purely on the line text. + """ + bits = xxhash.xxh3_64_intdigest(text.encode("utf-8")) & 0xFFF return f"{bits:03x}" def generate_public_id(self, text: str, line_idx: int) -> str: - content_bits = self._get_content_bits(text) - anchor_bits = self._get_anchor_bits(line_idx) - packed = self._interleave(content_bits, anchor_bits) + """ + Generates a 4-char Base64 ID combining modulo buckets and context hash. + Layout: [2-bit b1] [2-bit b2] [10-bit Hash A] [10-bit Hash B] + """ + b1, b2 = self._get_region_bits(line_idx) + neighborhood_hash = self._get_neighborhood_hash(line_idx) + # Split the 20-bit hash into two 10-bit halves + hash_a = (neighborhood_hash >> 10) & 0x3FF + hash_b = neighborhood_hash & 0x3FF + + # Construct the mixed 24-bit integer + packed = (b1 << 22) | (b2 << 20) | (hash_a << 10) | hash_b res = "" for _ in range(4): res += self.B64[packed % 64] @@ -90,11 +70,21 @@ def generate_public_id(self, text: str, line_idx: int) -> str: return res def unpack_public_id(self, public_id: str) -> tuple[int, int]: + """ + Reverses the Public ID back into its (Modulo 16, Neighborhood Hash) values. + """ packed = 0 for i, char in enumerate(public_id): packed |= self.B64.index(char) << (6 * i) - return self._deinterleave(packed) + b1 = (packed >> 22) & 3 + b2 = (packed >> 20) & 3 + hash_a = (packed >> 10) & 0x3FF + hash_b = packed & 0x3FF + mod_val = (b1 << 2) | b2 + neighborhood_hash = (hash_a << 10) | hash_b + + return mod_val, neighborhood_hash def format_content(self, use_private_ids: bool = False, start_line: int = 1) -> str: formatted_lines = [] @@ -102,44 +92,46 @@ def format_content(self, use_private_ids: bool = False, start_line: int = 1) -> prefix = ( self.generate_private_id(line) if use_private_ids - else self.generate_public_id(line, i + start_line) + else self.generate_public_id(line, i) ) formatted_lines.append(f"{prefix}::{line}") return "\n".join(formatted_lines) def resolve_to_lines(self, public_id: str, start_line: int = 1) -> list[int]: - target_content, target_anchor = self.unpack_public_id(public_id) - content_matches = [] - perfect_matches = [] + target_mod, target_hash = self.unpack_public_id(public_id) + matches = [] + # Find all lines whose neighborhood hash matches our target for i, line in enumerate(self.lines): - if self._get_content_bits(line) == target_content: - current_anchor = self._get_anchor_bits(i + start_line) - if current_anchor == target_anchor: - perfect_matches.append(i) - else: - dist = abs(current_anchor - target_anchor) - # Use the actual coprime period for the circular logic - dist = min(dist, self.PERIOD - dist) + if self._get_neighborhood_hash(i) == target_hash: + matches.append(i) + + if not matches: + return [] + + # If perfectly unique, return it immediately + if len(matches) == 1: + return matches - # ~1% chance of collision around 10 items - if dist <= 1: - content_matches.append((dist, i)) + # Distance Heuristic: If multiple matches exist (e.g. repeated code blocks), + # prioritize the one whose modulo is closest to the target modulo. + # We use circular distance since mod 16 wraps around (0 is adjacent to 15). + def modulo_distance(idx: int) -> int: + current_mod = idx % 16 + dist = abs(current_mod - target_mod) + return min(dist, 16 - dist) - if perfect_matches: - return perfect_matches + matches.sort(key=modulo_distance) - content_matches.sort(key=lambda x: x[0]) - return [match[1] for match in content_matches] + return matches def resolve_range(self, start_id: str, end_id: str) -> tuple[int, int]: """ Resolves a block range from two Public IDs. Logic: - 1. Resolve all candidates for both IDs. - 2. Find the pair of (start, end) that are logically ordered and - have the lowest combined distance score. + 1. Resolve all candidates for both IDs (sorted by best match). + 2. Find the pair of (start, end) that are logically ordered. 3. Returns (start_index, end_index) """ starts = self.resolve_to_lines(start_id) @@ -148,13 +140,9 @@ def resolve_range(self, start_id: str, end_id: str) -> tuple[int, int]: if not starts or not ends: raise ValueError(f"Could not resolve IDs: {start_id}..{end_id}") - # If both have 'perfect' matches that are logically ordered, use them immediately - # Note: resolve_to_lines returns perfect matches first. for s in starts: for e in ends: if s <= e: - # Return the first logical pair found - # (This prioritizes perfect matches or closest heuristics) return s, e raise ValueError( @@ -233,6 +221,6 @@ def normalize(hashpos_str: str) -> str: # If no pattern matches, raise error raise ValueError( f"Invalid HashPos format '{hashpos_str}'. " - r"Expected \"{hash_prefix}\" " - r"where hash_prefix is exactly 4 characters from the set [0-9a-zA-Z\~_@]." + r"Expected \"{content ID}\" " + r"where content ID is exactly 4 characters from the set [0-9a-zA-Z\~_@]." ) From ac62b709a4c5bff61542e99e212a54e1f4a17f9e Mon Sep 17 00:00:00 2001 From: Your Name Date: Thu, 18 Jun 2026 08:32:19 -0400 Subject: [PATCH 13/13] Count tokens, not lines in read_range --- cecli/tools/read_range.py | 14 +++++++++++--- tests/tools/test_get_lines.py | 10 ++++++++++ tests/tools/test_read_range_execute.py | 11 +++++++++-- 3 files changed, 30 insertions(+), 5 deletions(-) diff --git a/cecli/tools/read_range.py b/cecli/tools/read_range.py index 809e0b6de97..cba2bfd6f29 100644 --- a/cecli/tools/read_range.py +++ b/cecli/tools/read_range.py @@ -445,14 +445,22 @@ def _is_valid_int(s): # For structured searches (line numbers, special markers) or mixed searches # (one special marker, one text pattern), cap large ranges with preview # Text pattern searches are not subject to capping - if both_structured or (mixed_special_search and (e_idx - s_idx > 200)): + sliced_contents = "\n".join(content.splitlines()[s_idx:e_idx]) + token_count = coder.main_model.token_count(content) + sliced_token_count = coder.main_model.token_count(sliced_contents) + is_small_file = token_count <= min(coder.large_file_token_threshold / 4, 2048) + is_small_range = sliced_token_count <= min( + coder.large_file_token_threshold / 8, 1024 + ) + if ( + both_structured or (mixed_special_search and is_small_file) + ) and not is_small_range: preview, has_stub = cls._get_range_preview( coder, abs_path, start_idx=s_idx, end_idx=e_idx, line_numbers=True ) - if has_stub and abs_path not in coder.abs_fnames: - token_count = coder.main_model.token_count(content) + if abs_path not in coder.abs_fnames: # Track special marker usage for auto-editable detection if token_count <= coder.large_file_token_threshold: cls._special_marker_count[abs_path] = ( diff --git a/tests/tools/test_get_lines.py b/tests/tools/test_get_lines.py index 686146a817c..a7abbfb0666 100644 --- a/tests/tools/test_get_lines.py +++ b/tests/tools/test_get_lines.py @@ -30,6 +30,16 @@ def __init__(self, root): self.uuid = str(uuid.uuid4()) # Generate unique UUID for each instance self.turn_count = 0 + self.edit_allowed = True + self.abs_fnames = set() + self.abs_read_only_fnames = set() + self.large_file_token_threshold = 25000 + + from unittest.mock import MagicMock + + self.main_model = MagicMock() + self.main_model.token_count.side_effect = lambda x: len(x) // 4 + self.turn_count = 0 def abs_root_path(self, file_path): path = Path(file_path) diff --git a/tests/tools/test_read_range_execute.py b/tests/tools/test_read_range_execute.py index 3b8326fd0db..d64d46beca6 100644 --- a/tests/tools/test_read_range_execute.py +++ b/tests/tools/test_read_range_execute.py @@ -41,6 +41,12 @@ def mock_coder(): coder.io.tool_output = MagicMock() coder.io.tool_error = MagicMock() coder.io.tool_warning = MagicMock() + coder.edit_allowed = True + coder.abs_fnames = set() + coder.abs_read_only_fnames = set() + coder.large_file_token_threshold = 25000 + coder.main_model = MagicMock() + coder.main_model.token_count.side_effect = lambda x: len(x) // 4 return coder @@ -162,7 +168,7 @@ def test_both_digits_valid_range( try: show = [{"file_path": self.test_file, "range_start": "5", "range_end": "10"}] result = self.Tool.execute(self.coder, show) - assert "File range too large" in result + assert "Snapshot" in result assert "line5" in result assert "line10" in result finally: @@ -456,7 +462,8 @@ def resolve_side_effect(coder, file_path): cs_patch = patch("cecli.helpers.conversation.ConversationService", mock_cs) cs_patch.start() - mock_coder.io.read_text.side_effect = [content1, content1, content2, content2] + content_map = {test_file1: content1, test_file2: content2} + mock_coder.io.read_text.side_effect = lambda path: content_map.get(path, "") try: from cecli.tools.read_range import Tool