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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 63 additions & 15 deletions cecli/coders/agent_coder.py
Original file line number Diff line number Diff line change
Expand Up @@ -652,6 +652,10 @@ def format_chat_chunks(self):
# Add post-message context blocks (priority 250 - between CUR and REMINDER)
ConversationService.get_chunks(self).add_post_message_context_blocks()

# Background command output is debounced independently so it is not
# re-dumped every turn as its contents mutate slightly
ConversationService.get_chunks(self).add_background_command_output()

# Add sub-agent states context block (same priority as post-message blocks)
ConversationService.get_chunks(self).add_sub_agent_states()

Expand Down Expand Up @@ -935,7 +939,11 @@ async def gather_and_await():
lint_coro = self.lint_edited(edited, show_output=False)
lint_errors, interrupted = await interruptible(lint_coro, self.interrupt_event)
if interrupted:
raise KeyboardInterrupt("Interrupted during linting")
# Abort the turn with CancelledError, which the linear run loop
# handles by re-prompting for input. KeyboardInterrupt is a
# BaseException that would escape the worker's event loop and
# leave it unable to accept further prompts.
raise asyncio.CancelledError("Interrupted during linting")

has_errors = False

Expand Down Expand Up @@ -1074,7 +1082,10 @@ async def reply_completed(self):
sleep_coro = asyncio.sleep(command_timeout / 2)
_res, interrupted = await interruptible(sleep_coro, self.interrupt_event)
if interrupted:
raise KeyboardInterrupt("Interrupted while waiting for background commands")
# Use CancelledError (not KeyboardInterrupt) so the interrupt
# stays inside the worker loop and re-prompts, matching the
# lint interrupt path above.
raise asyncio.CancelledError("Interrupted while waiting for background commands")
return True

# Check for recently finished commands that need reflection
Expand Down Expand Up @@ -1845,25 +1856,44 @@ def get_background_command_output(self):
"""
Get background command output to append after the main message.

Returns:
String containing formatted background command output, or empty string if none
Emits a roster of active commands (keeping command keys in context)
plus any new incremental output. Output that has been paged to disk is
advertised by command key and read on demand through ``ResourceManager``
paging.
"""
# Get output from all running background commands
bg_outputs = BackgroundCommandManager.get_all_command_outputs(clear=True)
command_info = BackgroundCommandManager.list_background_commands()

if not bg_outputs:
if not command_info:
return ""

# Get command info to show actual command strings
command_info = BackgroundCommandManager.list_background_commands()
new_outputs = {}
for command_key in command_info:
output = BackgroundCommandManager.get_new_command_output(command_key)
if output.strip():
new_outputs[command_key] = output

# Create formatted output for background commands
output = "--- Background Commands Output ---\n"
for command_key, cmd_output in bg_outputs.items():
if cmd_output.strip(): # Only add if there's output
# Get the actual command string if available
command_str = command_info.get(command_key, {}).get("command", command_key)
output += f"\n[bg: {command_str}]\n{cmd_output}\n"
output += "Commands:\n"

paged_keys = []
for command_key, info in sorted(command_info.items()):
status = "running" if info.get("running", False) else "finished"
pages = info.get("pages", 0)
page_note = f"pages 1-{pages}" if pages else "no pages yet"
output += (
f"- {command_key} [{status}] `{info.get('command', command_key)}`"
f" — {info.get('total_chars', 0):,} chars, {page_note}\n"
)
if pages:
paged_keys.append(command_key)

for command_key, cmd_output in new_outputs.items():
output += f"\nNew output ({command_key}):\n{cmd_output}\n"

if paged_keys:
output += "\nPaged output is available via `ResourceManager` (up to 3 pages):\n"
for command_key in paged_keys:
output += f'{{"paging": [{{"target": "{command_key}", "page": 1}}]}}\n'

# Clean up stale (finished) background commands after reading their output
for command_key, info in command_info.items():
Expand All @@ -1872,6 +1902,24 @@ def get_background_command_output(self):

return output

def get_background_command_state(self):
"""
Return a lightweight status snapshot of tracked background commands.

Maps each command key to its running state and page count so the
injection layer can detect finish and page-flush transitions cheaply
without consuming any output.
"""
command_info = BackgroundCommandManager.list_background_commands()

return {
key: {
"running": bool(info.get("running", False)),
"pages": info.get("pages", 0),
}
for key, info in command_info.items()
}

def get_git_status(self):
"""
Generate a git status context block for repository information.
Expand Down
11 changes: 9 additions & 2 deletions cecli/coders/base_coder.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
from cecli.helpers.memory_control import trim_memory
from cecli.helpers.observations.service import ObservationService
from cecli.helpers.profiler import TokenProfiler
from cecli.helpers.sessions import SessionManager
from cecli.helpers.threading import ThreadSafeEvent
from cecli.history import ChatSummary
from cecli.hooks import HookIntegration
Expand All @@ -64,7 +65,6 @@
from cecli.repomap import RepoMap
from cecli.report import update_error_prefix
from cecli.run_cmd import run_cmd_async
from cecli.sessions import SessionManager
from cecli.tools.utils.output import print_tool_response
from cecli.tools.utils.registry import ToolRegistry
from cecli.utils import copy_tool_call, format_tokens, is_image_file
Expand Down Expand Up @@ -1999,7 +1999,13 @@ async def preproc_user_input(self, inp):
if self.commands.is_run_command(inp):
self.commands.cmd_running_event.clear() # Command is running

return await self.commands.run(inp, coder=self, **run_kwargs)
try:
return await self.commands.run(inp, coder=self, **run_kwargs)
finally:
# Dispatch can return early (unknown/ambiguous command) or
# raise without ever running a command; the gate must still be
# reopened or the input/output loops park forever.
self.commands.cmd_running_event.set()

await self.check_for_file_mentions(inp)
inp = await self.check_for_urls(inp)
Expand Down Expand Up @@ -5228,6 +5234,7 @@ async def auto_save_session(self, force=False):
session_manager.save_session,
getattr(self.args, "auto_save_session_name", "auto-save"),
False,
True,
)
except Exception:
# Don't show errors for auto-save to avoid interrupting the user experience
Expand Down
2 changes: 1 addition & 1 deletion cecli/commands/list_sessions.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ class ListSessionsCommand(BaseCommand):
@classmethod
async def execute(cls, io, coder, args, **kwargs):
"""Execute the list-sessions command with given parameters."""
from cecli import sessions
from cecli.helpers import sessions

session_manager = sessions.SessionManager(coder, io)
sessions_list = session_manager.list_sessions()
Expand Down
9 changes: 6 additions & 3 deletions cecli/commands/load_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ async def execute(cls, io, coder, args, **kwargs):
io.tool_output("Usage: /load-session <session-name>")
return format_command_result(io, "load-session", "No session name provided")

from cecli import sessions
from cecli.helpers import sessions

session_manager = sessions.SessionManager(coder, io)
await session_manager.load_session(args.strip())
Expand All @@ -26,7 +26,7 @@ async def execute(cls, io, coder, args, **kwargs):
def get_completions(cls, io, coder, args) -> List[str]:
"""Get completion options for load-session command."""
# Return available session names for completion
from cecli import sessions
from cecli.helpers import sessions

session_manager = sessions.SessionManager(coder, io)
sessions_list = session_manager.list_sessions()
Expand All @@ -41,7 +41,10 @@ def get_help(cls) -> str:
help_text += "\nExamples:\n"
help_text += " /load-session my-feature # Load session 'my-feature'\n"
help_text += " /load-session bug-fix # Load session 'bug-fix'\n"
help_text += "\nSessions are loaded from the .cecli/sessions/ directory.\n"
help_text += (
"\nSessions are loaded from the .cecli/sessions/ directory. Loading a session"
" also restores any sub-agents saved with it.\n"
)
help_text += (
"Use /list-sessions to see saved sessions and /save-session to save a session.\n"
)
Expand Down
8 changes: 6 additions & 2 deletions cecli/commands/save_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ async def execute(cls, io, coder, args, **kwargs):
io.tool_error("Please provide a session name to save.")
return format_command_result(io, "save-session", "No session name provided")

from cecli import sessions
from cecli.helpers import sessions

session_manager = sessions.SessionManager(coder, io)
session_manager.save_session(args.strip())
Expand All @@ -26,7 +26,7 @@ async def execute(cls, io, coder, args, **kwargs):
def get_completions(cls, io, coder, args) -> List[str]:
"""Get completion options for save-session command."""
# Return existing session names for completion to prevent accidental overwrites
from cecli import sessions
from cecli.helpers import sessions

session_manager = sessions.SessionManager(coder, io)
sessions_list = session_manager.list_sessions()
Expand All @@ -42,6 +42,10 @@ def get_help(cls) -> str:
help_text += " /save-session my-feature # Save session as 'my-feature'\n"
help_text += " /save-session bug-fix # Save session as 'bug-fix'\n"
help_text += "\nSessions are saved in the .cecli/sessions/ directory as JSON files.\n"
help_text += (
"When the session has sub-agents it is saved as a folder holding the primary"
" and each sub-agent payload.\n"
)
help_text += "Use /list-sessions to see saved sessions and /load-session to load them.\n"
help_text += (
"\nNote: Existing session names will be shown for tab completion to help prevent"
Expand Down
Loading
Loading