diff --git a/README.md b/README.md index 5f21568..ed61f45 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@

-# Connpy (v6.2.0) +# Connpy (v6.3.0) [![](https://img.shields.io/pypi/v/connpy.svg?style=flat-square)](https://pypi.org/pypi/connpy/) [![](https://img.shields.io/pypi/pyversions/connpy.svg?style=flat-square)](https://pypi.org/pypi/connpy/) [![](https://img.shields.io/pypi/dm/connpy.svg?style=flat-square&cacheSeconds=86400)](https://pypi.org/pypi/connpy/) diff --git a/connpy/__init__.py b/connpy/__init__.py index 734aa00..6f6f89a 100644 --- a/connpy/__init__.py +++ b/connpy/__init__.py @@ -5,7 +5,7 @@

-# Connpy (v6.2.0) +# Connpy (v6.3.0) [![](https://img.shields.io/pypi/v/connpy.svg?style=flat-square)](https://pypi.org/pypi/connpy/) [![](https://img.shields.io/pypi/pyversions/connpy.svg?style=flat-square)](https://pypi.org/pypi/connpy/) [![](https://img.shields.io/pypi/dm/connpy.svg?style=flat-square&cacheSeconds=86400)](https://pypi.org/pypi/connpy/) diff --git a/connpy/_version.py b/connpy/_version.py index 0a895f3..833eacf 100644 --- a/connpy/_version.py +++ b/connpy/_version.py @@ -1 +1 @@ -__version__ = "6.2.0" +__version__ = "6.3.0" diff --git a/connpy/cli/terminal_ui.py b/connpy/cli/terminal_ui.py index 6cd529a..006a469 100644 --- a/connpy/cli/terminal_ui.py +++ b/connpy/cli/terminal_ui.py @@ -560,14 +560,13 @@ class CopilotInterface: mission.setdefault('scratchpad_notes', []).append(notes) if guide: mission['last_guide'] = guide - - if guide or notes: - asst_msg = f"Notes: {notes}\nGuide: {guide}" if notes else guide - if not asst_msg and guide: asst_msg = guide - hist = self.session_state.setdefault("copilot_chat_history", []) - hist.append({"role": "user", "content": clean_question}) - hist.append({"role": "assistant", "content": asst_msg}) - self.session_state["copilot_chat_history"] = hist[-10:] + else: + if guide or notes: + asst_msg = f"Notes: {notes}\nGuide: {guide}" if notes else guide + hist = self.session_state.setdefault("copilot_chat_history", []) + hist.append({"role": "user", "content": clean_question}) + hist.append({"role": "assistant", "content": asst_msg}) + self.session_state["copilot_chat_history"] = hist[-10:] commands = result.get("commands", []) mission = self.session_state.get('mission', {}) if not commands: diff --git a/connpy/grpc_layer/server.py b/connpy/grpc_layer/server.py index 19774be..f384125 100644 --- a/connpy/grpc_layer/server.py +++ b/connpy/grpc_layer/server.py @@ -277,7 +277,8 @@ class NodeServicer(connpy_pb2_grpc.NodeServiceServicer): is_single = saved_mode in (1, 'SINGLE', 'single') if is_range or is_single: - if last_total_cmds is not None and total_cmds > last_total_cmds and saved_cmd > 1: + is_mission = session_state.get('mission', {}).get('active', False) or (isinstance(node_info, dict) and node_info.get('mission', {}).get('active', False)) + if last_total_cmds is not None and total_cmds > last_total_cmds and (saved_cmd > 1 or is_mission): new_cmds = total_cmds - last_total_cmds initial_cmd = saved_cmd + new_cmds else: @@ -321,6 +322,14 @@ class NodeServicer(connpy_pb2_grpc.NodeServiceServicer): )) while True: + # Refresh latest buffer from terminal log + raw_bytes = n.mylog.getvalue() if hasattr(n, 'mylog') else buffer + if not isinstance(raw_bytes, bytes): + raw_bytes = str(raw_bytes).encode() + from connpy.utils import log_cleaner + cleaned_buffer = log_cleaner(raw_bytes.decode(errors='replace')) + buffer = cleaned_buffer + # 0. Drain the queue of any stale messages before starting a new interaction while not remote_stream.copilot_queue.empty(): try: @@ -454,16 +463,74 @@ class NodeServicer(connpy_pb2_grpc.NodeServiceServicer): if action == "send_all": commands = result.get("commands", []) await n.inject_commands(commands, child_fd, on_inject=on_inject) - return elif action.startswith("custom:"): custom_cmds_raw = action[7:] custom_cmds = [cmd.strip() for cmd in custom_cmds_raw.split('\n') if cmd.strip()] await n.inject_commands(custom_cmds, child_fd, on_inject=on_inject) - return else: os.write(child_fd, b'\x15\r') return + # Dynamic Wait for Device Prompt Settle + prompt_pattern = node_info.get("prompt", r'>$|#$|\$$|>.$|#.$|\$.$') + import time + import re + start_t = time.time() + last_len = 0 + quiet_count = 0 + while time.time() - start_t < 60: + await asyncio.sleep(0.15) + cur_bytes = n.mylog.getvalue() + if len(cur_bytes) == last_len: + quiet_count += 1 + else: + quiet_count = 0 + last_len = len(cur_bytes) + + clean_txt = n._logclean(cur_bytes.decode(errors='replace'), True).strip() + lines = [l.strip() for l in clean_txt.split('\n') if l.strip()] + last_line = lines[-1] if lines else "" + if quiet_count >= 2 and re.search(prompt_pattern, last_line): + break + + # Recalculate context blocks with updated buffer + raw_bytes = n.mylog.getvalue() + from connpy.utils import log_cleaner + cleaned_buffer = log_cleaner(raw_bytes.decode(errors='replace')) + last_line = cleaned_buffer.split('\n')[-1].strip() if cleaned_buffer.strip() else "(prompt)" + blocks = service.build_context_blocks(raw_bytes, n.cmd_byte_positions, node_info, last_line=last_line) + node_info["context_blocks"] = blocks + + total_cmds = len(blocks) + total_lines = len(cleaned_buffer.split('\n')) + + last_total_cmds = session_state.get('last_total_cmds', None) + saved_cmd = session_state.get('context_cmd', 1) + is_mission = session_state.get('mission', {}).get('active', False) or (isinstance(node_info, dict) and node_info.get('mission', {}).get('active', False)) + if last_total_cmds is not None and total_cmds > last_total_cmds and (saved_cmd > 1 or is_mission): + new_cmds = total_cmds - last_total_cmds + initial_cmd = saved_cmd + new_cmds + else: + initial_cmd = saved_cmd + + session_state['context_cmd'] = max(1, initial_cmd) + session_state['last_total_cmds'] = total_cmds + session_state['last_total_lines'] = total_lines + + node_info['context_cmd'] = min(session_state['context_cmd'], max(1, total_cmds)) + node_info['context_lines'] = min(session_state.get('context_lines', 50), max(1, total_lines)) + node_info_json = json.dumps(node_info) + + preview_str = raw_bytes[-200:].decode(errors='replace') if isinstance(raw_bytes, bytes) else str(raw_bytes)[-200:] + + # Signal prompt settle and send updated blocks to client + response_queue.put(connpy_pb2.InteractResponse( + copilot_prompt=True, + copilot_buffer_preview=preview_str, + copilot_node_info_json=node_info_json + )) + continue + asyncio.run(n._async_interact_loop(remote_stream, resize_callback, copilot_handler=remote_copilot_handler)) except Exception as e: import traceback diff --git a/connpy/grpc_layer/stubs.py b/connpy/grpc_layer/stubs.py index 7a0450e..4cf9341 100644 --- a/connpy/grpc_layer/stubs.py +++ b/connpy/grpc_layer/stubs.py @@ -44,7 +44,7 @@ class NodeStub: self.config = config def _handle_remote_copilot(self, res, request_queue, response_queue, client_buffer_bytes, pause_generator, resume_generator, old_tty): - import json, asyncio, termios, sys, tty, queue + import json, asyncio, termios, sys, tty, queue, os from ..core import copilot_terminal_mode from . import connpy_pb2 @@ -62,6 +62,7 @@ class NodeStub: ) self.copilot_history = interface.history self.copilot_state = interface.session_state + interface.session_state['banner_shown'] = False async def on_ai_call_remote(active_buffer, question, chunk_callback, merged_node_info): # Send request to server @@ -84,6 +85,7 @@ class NodeStub: # Wrap in async loop async def run_remote_copilot(): + nonlocal blocks, node_info while True: action, commands, custom_cmd = await interface.run_session( raw_bytes=bytes(client_buffer_bytes), @@ -97,6 +99,32 @@ class NodeStub: request_queue.put(connpy_pb2.InteractRequest(copilot_action="continue")) continue + if action in ("send_all", "custom"): + action_sent = "cancel" + if action == "send_all" and commands: + action_sent = f"custom:{chr(10).join(commands)}" + elif action == "custom" and custom_cmd: + action_sent = f"custom:{chr(10).join(custom_cmd)}" + request_queue.put(connpy_pb2.InteractRequest(copilot_action=action_sent)) + + print("\r\n\033[2m [Remote] Ejecutando comandos y esperando al prompt...\033[0m\r\n", flush=True) + while True: + try: + prompt_res = response_queue.get(timeout=0.1) + if prompt_res is None: + return "cancel", None, None + if prompt_res.stdout_data: + os.write(sys.stdout.fileno(), prompt_res.stdout_data) + client_buffer_bytes.extend(prompt_res.stdout_data) + if prompt_res.copilot_prompt: + if prompt_res.copilot_node_info_json: + node_info = json.loads(prompt_res.copilot_node_info_json) + blocks = node_info.get("context_blocks", []) + break + except queue.Empty: + await asyncio.sleep(0.05) + continue + return action, commands, custom_cmd with copilot_terminal_mode(): @@ -104,14 +132,7 @@ class NodeStub: print("\033[2m Returning to session...\033[0m", flush=True) # Prepare final action for server - action_sent = "cancel" - if action == "send_all" and commands: - # In remote mode, send the selected commands as a custom block - # so the server executes exactly what the user picked (e.g., selection '1') - action_sent = f"custom:{chr(10).join(commands)}" - elif action == "custom" and custom_cmd: - action_sent = f"custom:{chr(10).join(custom_cmd)}" - request_queue.put(connpy_pb2.InteractRequest(copilot_action=action_sent)) + request_queue.put(connpy_pb2.InteractRequest(copilot_action="cancel")) resume_generator() tty.setraw(sys.stdin.fileno()) diff --git a/connpy/tests/test_grpc_layer.py b/connpy/tests/test_grpc_layer.py index 1290b0b..3580615 100644 --- a/connpy/tests/test_grpc_layer.py +++ b/connpy/tests/test_grpc_layer.py @@ -211,3 +211,80 @@ class TestGRPCIntegration: finally: if os.path.exists(temp_path): os.remove(temp_path) + + @patch("sys.stdin.fileno", return_value=0) + @patch("termios.tcsetattr") + @patch("termios.tcgetattr") + @patch("tty.setraw") + @patch("os.write") + def test_handle_remote_copilot_command_execution_loop(self, mock_os_write, mock_setraw, mock_getattr, mock_setattr, mock_fileno): + import queue + from connpy.grpc_layer.stubs import NodeStub + from connpy.cli.terminal_ui import CopilotInterface + + mock_getattr.return_value = [0, 0, 0, 0, 0, 0, [0] * 32] + mock_channel = MagicMock() + stub = NodeStub(mock_channel, "localhost:8048") + + initial_node_info = { + "name": "r1", + "context_blocks": [[0, 10, "router#"]] + } + res = connpy_pb2.InteractResponse( + copilot_prompt=True, + copilot_node_info_json=json.dumps(initial_node_info) + ) + + request_queue = queue.Queue() + response_queue = queue.Queue() + client_buffer_bytes = bytearray(b"router# ") + pause_gen = MagicMock() + resume_gen = MagicMock() + old_tty = [0] * 7 + + call_count = 0 + received_blocks_per_call = [] + + async def mock_run_session(raw_bytes, node_info, on_ai_call, blocks): + nonlocal call_count + call_count += 1 + received_blocks_per_call.append(list(blocks)) + if call_count == 1: + # Step 1: User approves commands + # Simulate server putting stdout data and next copilot prompt + updated_node_info = { + "name": "r1", + "context_blocks": [[0, 10, "router#"], [10, 30, "router# show ip route"]] + } + response_queue.put(connpy_pb2.InteractResponse(stdout_data=b"show ip route\r\n10.0.0.0/24\r\nrouter# ")) + response_queue.put(connpy_pb2.InteractResponse( + copilot_prompt=True, + copilot_node_info_json=json.dumps(updated_node_info) + )) + return "send_all", ["show ip route"], None + else: + # Step 2: Second turn, mission completes or user cancels + return "cancel", None, None + + with patch.object(CopilotInterface, "run_session", side_effect=mock_run_session): + stub._handle_remote_copilot( + res, request_queue, response_queue, client_buffer_bytes, + pause_gen, resume_gen, old_tty + ) + + # Assert that run_session was called twice + assert call_count == 2 + # First call should have initial blocks + assert len(received_blocks_per_call[0]) == 1 + # Second call should have updated blocks received from server + assert len(received_blocks_per_call[1]) == 2 + assert received_blocks_per_call[1][1][2] == "router# show ip route" + # Assert client_buffer_bytes was updated with stdout_data + assert b"10.0.0.0/24" in bytes(client_buffer_bytes) + # Assert action sent to server + reqs = [] + while not request_queue.empty(): + reqs.append(request_queue.get_nowait()) + assert reqs[0].copilot_action == "custom:show ip route" + assert reqs[1].copilot_action == "cancel" + diff --git a/docs/connpy/ai.html b/docs/connpy/ai.html index 43930c3..9f71e5f 100644 --- a/docs/connpy/ai.html +++ b/docs/connpy/ai.html @@ -3,7 +3,7 @@ - + connpy.ai API documentation @@ -2101,11 +2101,11 @@ Node: {node_name}"""
var SAFE_COMMANDS
-
+

The type of the None singleton.

var deferred_class_hooks
-
+

The type of the None singleton.

Instance variables

@@ -3234,7 +3234,7 @@ def confirm(self, user_input): return True diff --git a/docs/connpy/cli/ai_handler.html b/docs/connpy/cli/ai_handler.html index 8827e33..f7beb7a 100644 --- a/docs/connpy/cli/ai_handler.html +++ b/docs/connpy/cli/ai_handler.html @@ -3,7 +3,7 @@ - + connpy.cli.ai_handler API documentation @@ -670,7 +670,7 @@ el.replaceWith(d); diff --git a/docs/connpy/cli/api_handler.html b/docs/connpy/cli/api_handler.html index 1263f6e..29eb836 100644 --- a/docs/connpy/cli/api_handler.html +++ b/docs/connpy/cli/api_handler.html @@ -3,7 +3,7 @@ - + connpy.cli.api_handler API documentation @@ -193,7 +193,7 @@ el.replaceWith(d); diff --git a/docs/connpy/cli/config_handler.html b/docs/connpy/cli/config_handler.html index 64bdff7..88cb308 100644 --- a/docs/connpy/cli/config_handler.html +++ b/docs/connpy/cli/config_handler.html @@ -3,7 +3,7 @@ - + connpy.cli.config_handler API documentation @@ -600,7 +600,7 @@ el.replaceWith(d); diff --git a/docs/connpy/cli/context_handler.html b/docs/connpy/cli/context_handler.html index a6b3dfb..11e2a86 100644 --- a/docs/connpy/cli/context_handler.html +++ b/docs/connpy/cli/context_handler.html @@ -3,7 +3,7 @@ - + connpy.cli.context_handler API documentation @@ -249,7 +249,7 @@ el.replaceWith(d); diff --git a/docs/connpy/cli/forms.html b/docs/connpy/cli/forms.html index b2875a6..b2d1a73 100644 --- a/docs/connpy/cli/forms.html +++ b/docs/connpy/cli/forms.html @@ -3,7 +3,7 @@ - + connpy.cli.forms API documentation @@ -700,7 +700,7 @@ el.replaceWith(d); diff --git a/docs/connpy/cli/help_text.html b/docs/connpy/cli/help_text.html index 1fe8afd..1440196 100644 --- a/docs/connpy/cli/help_text.html +++ b/docs/connpy/cli/help_text.html @@ -3,7 +3,7 @@ - + connpy.cli.help_text API documentation @@ -303,7 +303,7 @@ tasks: diff --git a/docs/connpy/cli/helpers.html b/docs/connpy/cli/helpers.html index 28da9cf..c7a840c 100644 --- a/docs/connpy/cli/helpers.html +++ b/docs/connpy/cli/helpers.html @@ -3,7 +3,7 @@ - + connpy.cli.helpers API documentation @@ -319,7 +319,7 @@ el.replaceWith(d); diff --git a/docs/connpy/cli/import_export_handler.html b/docs/connpy/cli/import_export_handler.html index 10698db..0d2ef16 100644 --- a/docs/connpy/cli/import_export_handler.html +++ b/docs/connpy/cli/import_export_handler.html @@ -3,7 +3,7 @@ - + connpy.cli.import_export_handler API documentation @@ -304,7 +304,7 @@ def forms(self): diff --git a/docs/connpy/cli/index.html b/docs/connpy/cli/index.html index 3d58a06..9c7427c 100644 --- a/docs/connpy/cli/index.html +++ b/docs/connpy/cli/index.html @@ -3,7 +3,7 @@ - + connpy.cli API documentation @@ -162,7 +162,7 @@ el.replaceWith(d); diff --git a/docs/connpy/cli/login_handler.html b/docs/connpy/cli/login_handler.html index f1209eb..b569345 100644 --- a/docs/connpy/cli/login_handler.html +++ b/docs/connpy/cli/login_handler.html @@ -3,7 +3,7 @@ - + connpy.cli.login_handler API documentation @@ -611,7 +611,7 @@ el.replaceWith(d); diff --git a/docs/connpy/cli/node_handler.html b/docs/connpy/cli/node_handler.html index 1448004..c900c80 100644 --- a/docs/connpy/cli/node_handler.html +++ b/docs/connpy/cli/node_handler.html @@ -3,7 +3,7 @@ - + connpy.cli.node_handler API documentation @@ -675,7 +675,7 @@ def forms(self): diff --git a/docs/connpy/cli/plugin_handler.html b/docs/connpy/cli/plugin_handler.html index 76ede05..09bed1d 100644 --- a/docs/connpy/cli/plugin_handler.html +++ b/docs/connpy/cli/plugin_handler.html @@ -3,7 +3,7 @@ - + connpy.cli.plugin_handler API documentation @@ -397,7 +397,7 @@ el.replaceWith(d); diff --git a/docs/connpy/cli/profile_handler.html b/docs/connpy/cli/profile_handler.html index 8f81379..de2100a 100644 --- a/docs/connpy/cli/profile_handler.html +++ b/docs/connpy/cli/profile_handler.html @@ -3,7 +3,7 @@ - + connpy.cli.profile_handler API documentation @@ -346,7 +346,7 @@ def forms(self): diff --git a/docs/connpy/cli/run_handler.html b/docs/connpy/cli/run_handler.html index 7018ef5..b842927 100644 --- a/docs/connpy/cli/run_handler.html +++ b/docs/connpy/cli/run_handler.html @@ -3,7 +3,7 @@ - + connpy.cli.run_handler API documentation @@ -1163,7 +1163,7 @@ el.replaceWith(d); diff --git a/docs/connpy/cli/shell_handler.html b/docs/connpy/cli/shell_handler.html index 71185eb..5a32dcf 100644 --- a/docs/connpy/cli/shell_handler.html +++ b/docs/connpy/cli/shell_handler.html @@ -3,7 +3,7 @@ - + connpy.cli.shell_handler API documentation @@ -181,7 +181,7 @@ el.replaceWith(d); diff --git a/docs/connpy/cli/sso_handler.html b/docs/connpy/cli/sso_handler.html index cb9e10f..273c4cf 100644 --- a/docs/connpy/cli/sso_handler.html +++ b/docs/connpy/cli/sso_handler.html @@ -3,7 +3,7 @@ - + connpy.cli.sso_handler API documentation @@ -457,7 +457,7 @@ el.replaceWith(d); diff --git a/docs/connpy/cli/sync_handler.html b/docs/connpy/cli/sync_handler.html index 4ddd115..4c751be 100644 --- a/docs/connpy/cli/sync_handler.html +++ b/docs/connpy/cli/sync_handler.html @@ -3,7 +3,7 @@ - + connpy.cli.sync_handler API documentation @@ -427,7 +427,7 @@ el.replaceWith(d); diff --git a/docs/connpy/cli/terminal_ui.html b/docs/connpy/cli/terminal_ui.html index 0bc0315..122a609 100644 --- a/docs/connpy/cli/terminal_ui.html +++ b/docs/connpy/cli/terminal_ui.html @@ -3,7 +3,7 @@ - + connpy.cli.terminal_ui API documentation @@ -138,7 +138,8 @@ el.replaceWith(d); is_single = saved_mode in (self.mode_single, 1, 'SINGLE', 'single') if is_range or is_single: - if last_total_cmds is not None and total_cmds > last_total_cmds and saved_cmd > 1: + is_mission = self.session_state.get('mission', {}).get('active', False) + if last_total_cmds is not None and total_cmds > last_total_cmds and (saved_cmd > 1 or is_mission): new_cmds = total_cmds - last_total_cmds initial_cmd = saved_cmd + new_cmds else: @@ -171,15 +172,17 @@ el.replaceWith(d); self.session_state['last_total_cmds'] = total_cmds self.session_state['last_total_lines'] = total_lines - # 1. Visual Separation + # 1. Visual Separation (Only show help banner on initial entry) self.console.print("") # Real line break self.console.print(Rule(title="[bold cyan] AI TERMINAL COPILOT [/bold cyan]", style="cyan")) - self.console.print(Panel( - "[dim]Type your question. Enter to send, Escape/Ctrl+C to cancel. Type / for commands.\n" - "Tab to change context mode. Ctrl+\u2191/\u2193 to adjust context. \u2191\u2193 for question history.[/dim]", - border_style="cyan" - )) - self.console.print("\n") # Small space before the copilot prompt + if not self.session_state.get('banner_shown', False): + self.console.print(Panel( + "[dim]Type your question. Enter to send, Escape/Ctrl+C to cancel. Type / for commands.\n" + "Tab to change context mode. Ctrl+\u2191/\u2193 to adjust context. \u2191\u2193 for question history.[/dim]", + border_style="cyan" + )) + self.session_state['banner_shown'] = True + self.console.print("") # Small space before the copilot prompt bindings = KeyBindings() @bindings.add('c-up') @@ -261,7 +264,7 @@ el.replaceWith(d); text = app.current_buffer.text # Only show command help if typing the first command and there are no spaces if text.startswith('/') and ' ' not in text: - commands = ['/os', '/prompt', '/architect', '/engineer', '/trust', '/untrust', '/memorize', '/clear'] + commands = ['/os', '/prompt', '/architect', '/engineer', '/trust', '/untrust', '/memorize', '/clear', '/mission', '/cancel'] matches = [c for c in commands if c.startswith(text.lower())] if matches: m_text = html.escape(f"Available: {' '.join(matches)}") @@ -269,7 +272,7 @@ el.replaceWith(d); m_label = {self.mode_range: "RANGE", self.mode_single: "SINGLE", self.mode_lines: "LINES"}[state['context_mode']] if state['context_mode'] == self.mode_lines: - base_str = f'\u25b6 Ctrl+\u2191/\u2193 adjusts by 50 lines [Tab: {m_label}]' + base_str = f'\u25b6 [Tab: {m_label}] {state["context_lines"]}/{state["total_lines"]}L (Ctrl+\u2191/\u2193 adjusts lines)' else: idx = max(0, state['total_cmds'] - state['context_cmd']) @@ -292,7 +295,7 @@ el.replaceWith(d); p = clean_preview(b[2]) if p: # Truncar comandos individuales largos - if len(p) > 25: p = p[:22] + "..." + if len(p) > 25: p = p[:22].rstrip(' .,-_') + "..." previews.append(p) if not previews: @@ -300,12 +303,12 @@ el.replaceWith(d); elif len(previews) <= 3: desc = " + ".join(previews) else: - desc = f"{previews[0]} + {previews[1]} + {previews[2]} ... (+{len(previews)-3})" + desc = f"{previews[0]} + {previews[1]} + {previews[2]} (+{len(previews)-3})" else: # Modo SINGLE original desc = clean_preview(blocks[idx][2]) - base_str = f'\u25b6 {desc} [Tab: {m_label}]' + base_str = f'\u25b6 [Tab: {m_label}] {desc}' # Wrap base_str in a style to maintain consistency and avoid glitches # The fg color will be inherited from bottom-toolbar global style if not specified here @@ -339,7 +342,9 @@ el.replaceWith(d); ('/trust', 'Enable auto-execute'), ('/untrust', 'Disable auto-execute'), ('/memorize', 'Add fact to memory'), - ('/clear', 'Clear memory') + ('/clear', 'Clear memory'), + ('/mission', 'Start autonomous mission'), + ('/cancel', 'Cancel active mission') ] for cmd, desc in commands: if cmd.startswith(cmd_part.lower()): @@ -347,68 +352,162 @@ el.replaceWith(d); copilot_completer = SlashCommandCompleter() + def _finalize_mission(reason="completed", final_guide=""): + mission = self.session_state.get('mission', {}) + if not mission or not mission.get('active', False): + return + mission['active'] = False + if reason == "completed": + self.console.print("\n[bold green]πŸŽ‰ Mission Completed[/bold green]") + elif reason == "user_cancelled": + self.console.print("\n[yellow]Mission cancelled by user.[/yellow]") + elif reason == "limit_reached": + self.console.print("\n[yellow]Mission step limit reached.[/yellow]") + + final_notes = "\n".join(mission.get('scratchpad_notes', [])) + guide = final_guide or mission.get('last_guide', '') + asst_msg = f"Notes: {final_notes}\nGuide: {guide}" if final_notes else guide + hist = self.session_state.setdefault("copilot_chat_history", []) + hist.append({"role": "user", "content": f"/mission {mission.get('goal', '')}"}) + hist.append({"role": "assistant", "content": asst_msg}) + self.session_state["copilot_chat_history"] = hist[-10:] + while True: - # 2. Ask question - from prompt_toolkit.styles import Style - c_contrast = self._get_theme_color("contrast", "gray") - ui_style = Style.from_dict({ - 'bottom-toolbar': f'fg:{c_contrast}', - }) - - session = PromptSession( - history=self.history, - input=self.pt_input, - output=self.pt_output, - completer=copilot_completer, - reserve_space_for_menu=0, - style=ui_style - ) - try: - # We use an internal try/finally to ensure that if something fails in prompt_async, - # we don't leave the terminal in a strange state. - question = await session.prompt_async( - get_prompt_text, - key_bindings=bindings, - bottom_toolbar=get_toolbar, - multiline=True - ) - except (KeyboardInterrupt, EOFError): - state['cancelled'] = True - question = "" - - if state['cancelled'] or not question.strip() or question.strip().lower() in ['cancel', 'exit', 'quit']: - return "cancel", None, None + overrides = {} + # Check for active mission auto-looping + mission = self.session_state.get('mission', {}) + is_mission = mission.get('active', False) and not mission.get('paused', False) - # 3. Process Input via AIService - directive = self.ai_service.process_copilot_input(question, self.session_state) - - if directive["action"] == "state_update": - msg = directive['message'] - state['toolbar_msg'] = msg - state['msg_expiry'] = time.time() + 3 # 3 seconds timeout + if is_mission: + # Force mode_range for mission mode + state['context_mode'] = self.mode_range + self.session_state['context_mode'] = self.mode_range + + if mission.get('start_block_idx') is None: + mission['start_block_idx'] = state['total_cmds'] + + start_idx = mission.get('start_block_idx', state['total_cmds']) + cmds_since_start = max(1, (state['total_cmds'] - start_idx) + 1) + state['context_cmd'] = max(state.get('context_cmd', 1), cmds_since_start) + self.session_state['context_cmd'] = state['context_cmd'] + + step = mission.get('step', 1) + max_steps = mission.get('max_steps', 10) + + if step > max_steps: + ext_session = PromptSession(input=self.pt_input, output=self.pt_output) + c_warn = self._get_theme_color("warning", "yellow") + import html + p_warn = html.escape(f"[Mission Limit Reached ({max_steps} steps)] Extend mission for 10 more steps? (y/n) [y]: ") + try: + ext_ans = await ext_session.prompt_async(HTML(f'<style fg="{c_warn}" bold="true">{p_warn}</style>')) + except (KeyboardInterrupt, EOFError): + ext_ans = 'n' + + if (ext_ans or 'y').lower().strip() in ('y', 'yes'): + mission['max_steps'] += 10 + else: + goal = mission.get('goal', '') + question = f"[MISSION SUMMARY]: Step limit reached ({max_steps} steps). Provide a concise summary of all findings and current status for: {goal}" + clean_question = question + mission['active'] = False + is_mission = False + self.console.print(f"\n[bold cyan]πŸ€– Generating Final Mission Summary ({max_steps} steps reached)...[/bold cyan]") + + if is_mission: + goal = mission.get('goal', '') + step = mission.get('step', 1) + question = f"[MISSION STEP {step}]: Continue analysis of: {goal}" - async def delayed_refresh(): - await asyncio.sleep(3.1) - # Only invalidate if the message hasn't been replaced by a newer one - if state.get('toolbar_msg') == msg: - state['toolbar_msg'] = '' # Explicitly clear - try: - from prompt_toolkit.application.current import get_app - app = get_app() - if app: app.invalidate() - except: pass - asyncio.create_task(delayed_refresh()) - - # Move the cursor up and clean the line so the new prompt replaces the previous one - sys.stdout.write('\x1b[1A\x1b[2K') - sys.stdout.flush() - continue + scratchpad = mission.get('scratchpad_notes', []) + if scratchpad: + notes_text = "\n".join(f"- {n}" for n in scratchpad) + question += f"\n\nPast Mission Notes:\n{notes_text}" + + clean_question = question + self.console.print(f"\n[bold cyan]πŸ€– Executing Mission Step {step}: {goal}[/bold cyan]") + elif not is_mission and 'clean_question' in locals() and clean_question.startswith("[MISSION SUMMARY]"): + # Pass-through to AI execution for generating final summary + pass else: - # Clean the toolbar message when a real question is asked - state['toolbar_msg'] = '' - - clean_question = directive.get("clean_prompt", question) - overrides = directive.get("overrides", {}) + # 2. Ask question + from prompt_toolkit.styles import Style + c_contrast = self._get_theme_color("contrast", "gray") + ui_style = Style.from_dict({ + 'bottom-toolbar': f'fg:{c_contrast}', + }) + + session = PromptSession( + history=self.history, + input=self.pt_input, + output=self.pt_output, + completer=copilot_completer, + reserve_space_for_menu=0, + style=ui_style + ) + try: + question = await session.prompt_async( + get_prompt_text, + key_bindings=bindings, + bottom_toolbar=get_toolbar, + multiline=True + ) + except (KeyboardInterrupt, EOFError): + state['cancelled'] = True + question = "" + + if state['cancelled'] or not question.strip() or question.strip().lower() in ['cancel', 'exit', 'quit']: + _finalize_mission("user_cancelled") + return "cancel", None, None + + # 3. Process Input via AIService + directive = self.ai_service.process_copilot_input(question, self.session_state) + + if directive["action"] == "mission_start": + mission = self.session_state.get('mission', {}) + mission['start_block_idx'] = state['total_cmds'] + state['context_mode'] = self.mode_range + self.session_state['context_mode'] = self.mode_range + state['context_cmd'] = 1 + self.session_state['context_cmd'] = 1 + clean_question = f"[MISSION STEP 1]: {directive['clean_prompt']}" + overrides = directive.get("overrides", {}) + self.console.print(f"\n[bold cyan]πŸ€– Starting Mission: {directive['clean_prompt']}[/bold cyan]") + elif directive["action"] == "mission_cancel": + _finalize_mission("user_cancelled") + state['toolbar_msg'] = 'Mission cancelled' + continue + elif directive["action"] == "state_update": + msg = directive['message'] + state['toolbar_msg'] = msg + state['msg_expiry'] = time.time() + 3 # 3 seconds timeout + + async def delayed_refresh(): + await asyncio.sleep(3.1) + if state.get('toolbar_msg') == msg: + state['toolbar_msg'] = '' + try: + from prompt_toolkit.application.current import get_app + app = get_app() + if app: app.invalidate() + except: pass + asyncio.create_task(delayed_refresh()) + + sys.stdout.write('\x1b[1A\x1b[2K') + sys.stdout.flush() + continue + else: + state['toolbar_msg'] = '' + if mission.get('active', False): + mission['paused'] = False + step = mission.get('step', 1) + goal = mission.get('goal', '') + clean_question = f"[MISSION FEEDBACK (Step {step})]: User provided guidance: {question}. Continue the mission for goal: {goal}" + self.console.print(f"\n[bold cyan]πŸ€– Continuing Mission with User Feedback: {question}[/bold cyan]") + overrides = directive.get("overrides", {}) + else: + clean_question = directive.get("clean_prompt", question) + overrides = directive.get("overrides", {}) # Merge node_info with session_state and overrides merged_node_info = node_info.copy() @@ -423,7 +522,6 @@ el.replaceWith(d); merged_node_info[k] = v # 3. AI Execution - # Use persona from overrides (one-shot) or from session state active_persona = merged_node_info.get('persona', self.session_state.get('persona', 'engineer')) persona_color = self._get_theme_color(active_persona, fallback="cyan") persona_title = "Network Architect" if active_persona == "architect" else "Network Engineer" @@ -450,7 +548,6 @@ el.replaceWith(d); nonlocal live_text, first_chunk if first_chunk: status_spinner.stop() - # Print header rule before first chunk arrives self.console.print(Rule( f"[bold {persona_color}]{persona_title}[/bold {persona_color}]", style=persona_color @@ -459,7 +556,6 @@ el.replaceWith(d); live_text += text md_parser.feed(text) - # Check for interruption during AI call ai_task = asyncio.create_task(on_ai_call(active_buffer, clean_question, on_chunk, merged_node_info)) try: @@ -468,13 +564,12 @@ el.replaceWith(d); result = await ai_task except asyncio.CancelledError: status_spinner.stop() + _finalize_mission("user_cancelled") return "cancel", None, None - # Ensure spinner is stopped if no chunks arrived if first_chunk: status_spinner.stop() - # Close the streamed output with a Rule if not first_chunk: md_parser.flush() self.console.print(Rule(style=persona_color)) @@ -482,26 +577,35 @@ el.replaceWith(d); if not result or result.get("error"): if first_chunk and result and result.get("error"): self.console.print(f"[red]Error: {result['error']}[/red]") + _finalize_mission("user_cancelled") return "cancel", None, None - # If no chunks were streamed but we have a guide, print it as a panel if first_chunk and result and result.get("guide"): from rich.markdown import Markdown self.console.print(Panel(Markdown(result["guide"]), title=f"[bold {persona_color}]{persona_title}[/bold {persona_color}]", border_style=persona_color)) - # Update copilot_chat_history with clean Q&A turn + # Update copilot_chat_history or mission scratchpad if result and not result.get("error"): guide = result.get("guide", "") notes = result.get("notes", "") - if guide: - asst_msg = f"Notes: {notes}\nGuide: {guide}" if notes else guide - hist = self.session_state.setdefault("copilot_chat_history", []) - hist.append({"role": "user", "content": clean_question}) - hist.append({"role": "assistant", "content": asst_msg}) - self.session_state["copilot_chat_history"] = hist[-10:] - + mission = self.session_state.get('mission', {}) + if mission.get('active', False): + if notes: + mission.setdefault('scratchpad_notes', []).append(notes) + if guide: + mission['last_guide'] = guide + else: + if guide or notes: + asst_msg = f"Notes: {notes}\nGuide: {guide}" if notes else guide + hist = self.session_state.setdefault("copilot_chat_history", []) + hist.append({"role": "user", "content": clean_question}) + hist.append({"role": "assistant", "content": asst_msg}) + self.session_state["copilot_chat_history"] = hist[-10:] commands = result.get("commands", []) + mission = self.session_state.get('mission', {}) if not commands: + if mission.get('active', False): + _finalize_mission("completed", result.get("guide", "")) self.console.print("") return "continue", None, None @@ -510,11 +614,12 @@ el.replaceWith(d); style_color = self._get_theme_color(risk_style, fallback="green") cmd_text = "\n".join(f" {i+1}. {c}" for i, c in enumerate(commands)) - # Explicitly use 'bold style_color' for both TITLE and BORDER to ensure maximum consistency self.console.print(Panel(cmd_text, title=f"[bold {style_color}]Suggested Commands [{risk.upper()}][/bold {style_color}]", border_style=f"bold {style_color}")) if merged_node_info.get('trust', False) and risk != "destructive": self.console.print(f"[dim]βš™οΈ Auto-executing (Trust Mode)[/dim]") + if mission.get('active', False): + mission['step'] = mission.get('step', 1) + 1 return "send_all", commands, None confirm_session = PromptSession(input=self.pt_input, output=self.pt_output) @@ -526,41 +631,39 @@ el.replaceWith(d); import html try: p_text = html.escape(f"Send? (y/n/e/range) [n]: ") - # Use the EXACT same style_color and force bold="true" for Prompt-Toolkit action = await confirm_session.prompt_async(HTML(f'<style fg="{style_color}" bold="true">{p_text}</style>'), key_bindings=c_bindings) except (KeyboardInterrupt, EOFError): + _finalize_mission("user_cancelled", result.get("guide", "")) self.console.print("") return "continue", None, None def parse_indices(text, max_len): - """Helper to parse '1-3, 5, 7' into [0, 1, 2, 4, 6].""" indices = [] - # Replace commas with spaces and split parts = text.replace(',', ' ').split() for part in parts: if '-' in part: try: start, end = map(int, part.split('-')) - # Ensure inclusive and 0-indexed indices.extend(range(start-1, end)) except: continue elif part.isdigit(): indices.append(int(part)-1) - # Filter valid indices and remove duplicates return [i for i in sorted(set(indices)) if 0 <= i < max_len] action_l = (action or "n").lower().strip() if action_l in ('y', 'yes', 'all'): + if mission.get('active', False): + mission['step'] = mission.get('step', 1) + 1 return "send_all", commands, None - # Check for numeric selection (e.g., "1, 2-4") if re.match(r'^[0-9,\-\s]+$', action_l): selected_idxs = parse_indices(action_l, len(commands)) if selected_idxs: + if mission.get('active', False): + mission['step'] = mission.get('step', 1) + 1 return "send_all", [commands[i] for i in selected_idxs], None elif action_l.startswith('e'): - # Check if it's a selective edit like 'e1-2' selection_str = action_l[1:].strip() if selection_str: idxs = parse_indices(selection_str, len(commands)) @@ -586,13 +689,22 @@ el.replaceWith(d); default=target, multiline=True, key_bindings=e_bindings ) except (KeyboardInterrupt, EOFError): + if mission.get('active', False): + mission['paused'] = True + self.console.print("\n[yellow]⏸️ Mission Paused β€” Provide feedback to redirect, or type /cancel to abort.[/yellow]") self.console.print("") return "continue", None, None if edited and edited.strip(): - # Split by lines to ensure core.py applies delay between each command lines = [l.strip() for l in edited.split('\n') if l.strip()] + if mission.get('active', False): + mission['step'] = mission.get('step', 1) + 1 return "custom", None, lines + + # User rejected/cancelled the commands + if mission.get('active', False): + mission['paused'] = True + self.console.print("\n[yellow]⏸️ Mission Paused β€” Provide feedback to redirect, or type /cancel to abort.[/yellow]") self.console.print("") return "continue", None, None @@ -648,7 +760,8 @@ el.replaceWith(d); is_single = saved_mode in (self.mode_single, 1, 'SINGLE', 'single') if is_range or is_single: - if last_total_cmds is not None and total_cmds > last_total_cmds and saved_cmd > 1: + is_mission = self.session_state.get('mission', {}).get('active', False) + if last_total_cmds is not None and total_cmds > last_total_cmds and (saved_cmd > 1 or is_mission): new_cmds = total_cmds - last_total_cmds initial_cmd = saved_cmd + new_cmds else: @@ -681,15 +794,17 @@ el.replaceWith(d); self.session_state['last_total_cmds'] = total_cmds self.session_state['last_total_lines'] = total_lines - # 1. Visual Separation + # 1. Visual Separation (Only show help banner on initial entry) self.console.print("") # Real line break self.console.print(Rule(title="[bold cyan] AI TERMINAL COPILOT [/bold cyan]", style="cyan")) - self.console.print(Panel( - "[dim]Type your question. Enter to send, Escape/Ctrl+C to cancel. Type / for commands.\n" - "Tab to change context mode. Ctrl+\u2191/\u2193 to adjust context. \u2191\u2193 for question history.[/dim]", - border_style="cyan" - )) - self.console.print("\n") # Small space before the copilot prompt + if not self.session_state.get('banner_shown', False): + self.console.print(Panel( + "[dim]Type your question. Enter to send, Escape/Ctrl+C to cancel. Type / for commands.\n" + "Tab to change context mode. Ctrl+\u2191/\u2193 to adjust context. \u2191\u2193 for question history.[/dim]", + border_style="cyan" + )) + self.session_state['banner_shown'] = True + self.console.print("") # Small space before the copilot prompt bindings = KeyBindings() @bindings.add('c-up') @@ -771,7 +886,7 @@ el.replaceWith(d); text = app.current_buffer.text # Only show command help if typing the first command and there are no spaces if text.startswith('/') and ' ' not in text: - commands = ['/os', '/prompt', '/architect', '/engineer', '/trust', '/untrust', '/memorize', '/clear'] + commands = ['/os', '/prompt', '/architect', '/engineer', '/trust', '/untrust', '/memorize', '/clear', '/mission', '/cancel'] matches = [c for c in commands if c.startswith(text.lower())] if matches: m_text = html.escape(f"Available: {' '.join(matches)}") @@ -779,7 +894,7 @@ el.replaceWith(d); m_label = {self.mode_range: "RANGE", self.mode_single: "SINGLE", self.mode_lines: "LINES"}[state['context_mode']] if state['context_mode'] == self.mode_lines: - base_str = f'\u25b6 Ctrl+\u2191/\u2193 adjusts by 50 lines [Tab: {m_label}]' + base_str = f'\u25b6 [Tab: {m_label}] {state["context_lines"]}/{state["total_lines"]}L (Ctrl+\u2191/\u2193 adjusts lines)' else: idx = max(0, state['total_cmds'] - state['context_cmd']) @@ -802,7 +917,7 @@ el.replaceWith(d); p = clean_preview(b[2]) if p: # Truncar comandos individuales largos - if len(p) > 25: p = p[:22] + "..." + if len(p) > 25: p = p[:22].rstrip(' .,-_') + "..." previews.append(p) if not previews: @@ -810,12 +925,12 @@ el.replaceWith(d); elif len(previews) <= 3: desc = " + ".join(previews) else: - desc = f"{previews[0]} + {previews[1]} + {previews[2]} ... (+{len(previews)-3})" + desc = f"{previews[0]} + {previews[1]} + {previews[2]} (+{len(previews)-3})" else: # Modo SINGLE original desc = clean_preview(blocks[idx][2]) - base_str = f'\u25b6 {desc} [Tab: {m_label}]' + base_str = f'\u25b6 [Tab: {m_label}] {desc}' # Wrap base_str in a style to maintain consistency and avoid glitches # The fg color will be inherited from bottom-toolbar global style if not specified here @@ -849,7 +964,9 @@ el.replaceWith(d); ('/trust', 'Enable auto-execute'), ('/untrust', 'Disable auto-execute'), ('/memorize', 'Add fact to memory'), - ('/clear', 'Clear memory') + ('/clear', 'Clear memory'), + ('/mission', 'Start autonomous mission'), + ('/cancel', 'Cancel active mission') ] for cmd, desc in commands: if cmd.startswith(cmd_part.lower()): @@ -857,68 +974,162 @@ el.replaceWith(d); copilot_completer = SlashCommandCompleter() + def _finalize_mission(reason="completed", final_guide=""): + mission = self.session_state.get('mission', {}) + if not mission or not mission.get('active', False): + return + mission['active'] = False + if reason == "completed": + self.console.print("\n[bold green]πŸŽ‰ Mission Completed[/bold green]") + elif reason == "user_cancelled": + self.console.print("\n[yellow]Mission cancelled by user.[/yellow]") + elif reason == "limit_reached": + self.console.print("\n[yellow]Mission step limit reached.[/yellow]") + + final_notes = "\n".join(mission.get('scratchpad_notes', [])) + guide = final_guide or mission.get('last_guide', '') + asst_msg = f"Notes: {final_notes}\nGuide: {guide}" if final_notes else guide + hist = self.session_state.setdefault("copilot_chat_history", []) + hist.append({"role": "user", "content": f"/mission {mission.get('goal', '')}"}) + hist.append({"role": "assistant", "content": asst_msg}) + self.session_state["copilot_chat_history"] = hist[-10:] + while True: - # 2. Ask question - from prompt_toolkit.styles import Style - c_contrast = self._get_theme_color("contrast", "gray") - ui_style = Style.from_dict({ - 'bottom-toolbar': f'fg:{c_contrast}', - }) - - session = PromptSession( - history=self.history, - input=self.pt_input, - output=self.pt_output, - completer=copilot_completer, - reserve_space_for_menu=0, - style=ui_style - ) - try: - # We use an internal try/finally to ensure that if something fails in prompt_async, - # we don't leave the terminal in a strange state. - question = await session.prompt_async( - get_prompt_text, - key_bindings=bindings, - bottom_toolbar=get_toolbar, - multiline=True - ) - except (KeyboardInterrupt, EOFError): - state['cancelled'] = True - question = "" - - if state['cancelled'] or not question.strip() or question.strip().lower() in ['cancel', 'exit', 'quit']: - return "cancel", None, None + overrides = {} + # Check for active mission auto-looping + mission = self.session_state.get('mission', {}) + is_mission = mission.get('active', False) and not mission.get('paused', False) - # 3. Process Input via AIService - directive = self.ai_service.process_copilot_input(question, self.session_state) - - if directive["action"] == "state_update": - msg = directive['message'] - state['toolbar_msg'] = msg - state['msg_expiry'] = time.time() + 3 # 3 seconds timeout + if is_mission: + # Force mode_range for mission mode + state['context_mode'] = self.mode_range + self.session_state['context_mode'] = self.mode_range + + if mission.get('start_block_idx') is None: + mission['start_block_idx'] = state['total_cmds'] + + start_idx = mission.get('start_block_idx', state['total_cmds']) + cmds_since_start = max(1, (state['total_cmds'] - start_idx) + 1) + state['context_cmd'] = max(state.get('context_cmd', 1), cmds_since_start) + self.session_state['context_cmd'] = state['context_cmd'] + + step = mission.get('step', 1) + max_steps = mission.get('max_steps', 10) + + if step > max_steps: + ext_session = PromptSession(input=self.pt_input, output=self.pt_output) + c_warn = self._get_theme_color("warning", "yellow") + import html + p_warn = html.escape(f"[Mission Limit Reached ({max_steps} steps)] Extend mission for 10 more steps? (y/n) [y]: ") + try: + ext_ans = await ext_session.prompt_async(HTML(f'<style fg="{c_warn}" bold="true">{p_warn}</style>')) + except (KeyboardInterrupt, EOFError): + ext_ans = 'n' + + if (ext_ans or 'y').lower().strip() in ('y', 'yes'): + mission['max_steps'] += 10 + else: + goal = mission.get('goal', '') + question = f"[MISSION SUMMARY]: Step limit reached ({max_steps} steps). Provide a concise summary of all findings and current status for: {goal}" + clean_question = question + mission['active'] = False + is_mission = False + self.console.print(f"\n[bold cyan]πŸ€– Generating Final Mission Summary ({max_steps} steps reached)...[/bold cyan]") + + if is_mission: + goal = mission.get('goal', '') + step = mission.get('step', 1) + question = f"[MISSION STEP {step}]: Continue analysis of: {goal}" - async def delayed_refresh(): - await asyncio.sleep(3.1) - # Only invalidate if the message hasn't been replaced by a newer one - if state.get('toolbar_msg') == msg: - state['toolbar_msg'] = '' # Explicitly clear - try: - from prompt_toolkit.application.current import get_app - app = get_app() - if app: app.invalidate() - except: pass - asyncio.create_task(delayed_refresh()) - - # Move the cursor up and clean the line so the new prompt replaces the previous one - sys.stdout.write('\x1b[1A\x1b[2K') - sys.stdout.flush() - continue + scratchpad = mission.get('scratchpad_notes', []) + if scratchpad: + notes_text = "\n".join(f"- {n}" for n in scratchpad) + question += f"\n\nPast Mission Notes:\n{notes_text}" + + clean_question = question + self.console.print(f"\n[bold cyan]πŸ€– Executing Mission Step {step}: {goal}[/bold cyan]") + elif not is_mission and 'clean_question' in locals() and clean_question.startswith("[MISSION SUMMARY]"): + # Pass-through to AI execution for generating final summary + pass else: - # Clean the toolbar message when a real question is asked - state['toolbar_msg'] = '' - - clean_question = directive.get("clean_prompt", question) - overrides = directive.get("overrides", {}) + # 2. Ask question + from prompt_toolkit.styles import Style + c_contrast = self._get_theme_color("contrast", "gray") + ui_style = Style.from_dict({ + 'bottom-toolbar': f'fg:{c_contrast}', + }) + + session = PromptSession( + history=self.history, + input=self.pt_input, + output=self.pt_output, + completer=copilot_completer, + reserve_space_for_menu=0, + style=ui_style + ) + try: + question = await session.prompt_async( + get_prompt_text, + key_bindings=bindings, + bottom_toolbar=get_toolbar, + multiline=True + ) + except (KeyboardInterrupt, EOFError): + state['cancelled'] = True + question = "" + + if state['cancelled'] or not question.strip() or question.strip().lower() in ['cancel', 'exit', 'quit']: + _finalize_mission("user_cancelled") + return "cancel", None, None + + # 3. Process Input via AIService + directive = self.ai_service.process_copilot_input(question, self.session_state) + + if directive["action"] == "mission_start": + mission = self.session_state.get('mission', {}) + mission['start_block_idx'] = state['total_cmds'] + state['context_mode'] = self.mode_range + self.session_state['context_mode'] = self.mode_range + state['context_cmd'] = 1 + self.session_state['context_cmd'] = 1 + clean_question = f"[MISSION STEP 1]: {directive['clean_prompt']}" + overrides = directive.get("overrides", {}) + self.console.print(f"\n[bold cyan]πŸ€– Starting Mission: {directive['clean_prompt']}[/bold cyan]") + elif directive["action"] == "mission_cancel": + _finalize_mission("user_cancelled") + state['toolbar_msg'] = 'Mission cancelled' + continue + elif directive["action"] == "state_update": + msg = directive['message'] + state['toolbar_msg'] = msg + state['msg_expiry'] = time.time() + 3 # 3 seconds timeout + + async def delayed_refresh(): + await asyncio.sleep(3.1) + if state.get('toolbar_msg') == msg: + state['toolbar_msg'] = '' + try: + from prompt_toolkit.application.current import get_app + app = get_app() + if app: app.invalidate() + except: pass + asyncio.create_task(delayed_refresh()) + + sys.stdout.write('\x1b[1A\x1b[2K') + sys.stdout.flush() + continue + else: + state['toolbar_msg'] = '' + if mission.get('active', False): + mission['paused'] = False + step = mission.get('step', 1) + goal = mission.get('goal', '') + clean_question = f"[MISSION FEEDBACK (Step {step})]: User provided guidance: {question}. Continue the mission for goal: {goal}" + self.console.print(f"\n[bold cyan]πŸ€– Continuing Mission with User Feedback: {question}[/bold cyan]") + overrides = directive.get("overrides", {}) + else: + clean_question = directive.get("clean_prompt", question) + overrides = directive.get("overrides", {}) # Merge node_info with session_state and overrides merged_node_info = node_info.copy() @@ -933,7 +1144,6 @@ el.replaceWith(d); merged_node_info[k] = v # 3. AI Execution - # Use persona from overrides (one-shot) or from session state active_persona = merged_node_info.get('persona', self.session_state.get('persona', 'engineer')) persona_color = self._get_theme_color(active_persona, fallback="cyan") persona_title = "Network Architect" if active_persona == "architect" else "Network Engineer" @@ -960,7 +1170,6 @@ el.replaceWith(d); nonlocal live_text, first_chunk if first_chunk: status_spinner.stop() - # Print header rule before first chunk arrives self.console.print(Rule( f"[bold {persona_color}]{persona_title}[/bold {persona_color}]", style=persona_color @@ -969,7 +1178,6 @@ el.replaceWith(d); live_text += text md_parser.feed(text) - # Check for interruption during AI call ai_task = asyncio.create_task(on_ai_call(active_buffer, clean_question, on_chunk, merged_node_info)) try: @@ -978,13 +1186,12 @@ el.replaceWith(d); result = await ai_task except asyncio.CancelledError: status_spinner.stop() + _finalize_mission("user_cancelled") return "cancel", None, None - # Ensure spinner is stopped if no chunks arrived if first_chunk: status_spinner.stop() - # Close the streamed output with a Rule if not first_chunk: md_parser.flush() self.console.print(Rule(style=persona_color)) @@ -992,26 +1199,35 @@ el.replaceWith(d); if not result or result.get("error"): if first_chunk and result and result.get("error"): self.console.print(f"[red]Error: {result['error']}[/red]") + _finalize_mission("user_cancelled") return "cancel", None, None - # If no chunks were streamed but we have a guide, print it as a panel if first_chunk and result and result.get("guide"): from rich.markdown import Markdown self.console.print(Panel(Markdown(result["guide"]), title=f"[bold {persona_color}]{persona_title}[/bold {persona_color}]", border_style=persona_color)) - # Update copilot_chat_history with clean Q&A turn + # Update copilot_chat_history or mission scratchpad if result and not result.get("error"): guide = result.get("guide", "") notes = result.get("notes", "") - if guide: - asst_msg = f"Notes: {notes}\nGuide: {guide}" if notes else guide - hist = self.session_state.setdefault("copilot_chat_history", []) - hist.append({"role": "user", "content": clean_question}) - hist.append({"role": "assistant", "content": asst_msg}) - self.session_state["copilot_chat_history"] = hist[-10:] - + mission = self.session_state.get('mission', {}) + if mission.get('active', False): + if notes: + mission.setdefault('scratchpad_notes', []).append(notes) + if guide: + mission['last_guide'] = guide + else: + if guide or notes: + asst_msg = f"Notes: {notes}\nGuide: {guide}" if notes else guide + hist = self.session_state.setdefault("copilot_chat_history", []) + hist.append({"role": "user", "content": clean_question}) + hist.append({"role": "assistant", "content": asst_msg}) + self.session_state["copilot_chat_history"] = hist[-10:] commands = result.get("commands", []) + mission = self.session_state.get('mission', {}) if not commands: + if mission.get('active', False): + _finalize_mission("completed", result.get("guide", "")) self.console.print("") return "continue", None, None @@ -1020,11 +1236,12 @@ el.replaceWith(d); style_color = self._get_theme_color(risk_style, fallback="green") cmd_text = "\n".join(f" {i+1}. {c}" for i, c in enumerate(commands)) - # Explicitly use 'bold style_color' for both TITLE and BORDER to ensure maximum consistency self.console.print(Panel(cmd_text, title=f"[bold {style_color}]Suggested Commands [{risk.upper()}][/bold {style_color}]", border_style=f"bold {style_color}")) if merged_node_info.get('trust', False) and risk != "destructive": self.console.print(f"[dim]βš™οΈ Auto-executing (Trust Mode)[/dim]") + if mission.get('active', False): + mission['step'] = mission.get('step', 1) + 1 return "send_all", commands, None confirm_session = PromptSession(input=self.pt_input, output=self.pt_output) @@ -1036,41 +1253,39 @@ el.replaceWith(d); import html try: p_text = html.escape(f"Send? (y/n/e/range) [n]: ") - # Use the EXACT same style_color and force bold="true" for Prompt-Toolkit action = await confirm_session.prompt_async(HTML(f'<style fg="{style_color}" bold="true">{p_text}</style>'), key_bindings=c_bindings) except (KeyboardInterrupt, EOFError): + _finalize_mission("user_cancelled", result.get("guide", "")) self.console.print("") return "continue", None, None def parse_indices(text, max_len): - """Helper to parse '1-3, 5, 7' into [0, 1, 2, 4, 6].""" indices = [] - # Replace commas with spaces and split parts = text.replace(',', ' ').split() for part in parts: if '-' in part: try: start, end = map(int, part.split('-')) - # Ensure inclusive and 0-indexed indices.extend(range(start-1, end)) except: continue elif part.isdigit(): indices.append(int(part)-1) - # Filter valid indices and remove duplicates return [i for i in sorted(set(indices)) if 0 <= i < max_len] action_l = (action or "n").lower().strip() if action_l in ('y', 'yes', 'all'): + if mission.get('active', False): + mission['step'] = mission.get('step', 1) + 1 return "send_all", commands, None - # Check for numeric selection (e.g., "1, 2-4") if re.match(r'^[0-9,\-\s]+$', action_l): selected_idxs = parse_indices(action_l, len(commands)) if selected_idxs: + if mission.get('active', False): + mission['step'] = mission.get('step', 1) + 1 return "send_all", [commands[i] for i in selected_idxs], None elif action_l.startswith('e'): - # Check if it's a selective edit like 'e1-2' selection_str = action_l[1:].strip() if selection_str: idxs = parse_indices(selection_str, len(commands)) @@ -1096,13 +1311,22 @@ el.replaceWith(d); default=target, multiline=True, key_bindings=e_bindings ) except (KeyboardInterrupt, EOFError): + if mission.get('active', False): + mission['paused'] = True + self.console.print("\n[yellow]⏸️ Mission Paused β€” Provide feedback to redirect, or type /cancel to abort.[/yellow]") self.console.print("") return "continue", None, None if edited and edited.strip(): - # Split by lines to ensure core.py applies delay between each command lines = [l.strip() for l in edited.split('\n') if l.strip()] + if mission.get('active', False): + mission['step'] = mission.get('step', 1) + 1 return "custom", None, lines + + # User rejected/cancelled the commands + if mission.get('active', False): + mission['paused'] = True + self.console.print("\n[yellow]⏸️ Mission Paused β€” Provide feedback to redirect, or type /cancel to abort.[/yellow]") self.console.print("") return "continue", None, None @@ -1144,7 +1368,7 @@ on_ai_call: async function(active_buffer, question) -> result_dict

diff --git a/docs/connpy/cli/user_handler.html b/docs/connpy/cli/user_handler.html index 2bef829..4fcbb7d 100644 --- a/docs/connpy/cli/user_handler.html +++ b/docs/connpy/cli/user_handler.html @@ -3,7 +3,7 @@ - + connpy.cli.user_handler API documentation @@ -516,7 +516,7 @@ el.replaceWith(d); diff --git a/docs/connpy/cli/validators.html b/docs/connpy/cli/validators.html index a88da20..c797b53 100644 --- a/docs/connpy/cli/validators.html +++ b/docs/connpy/cli/validators.html @@ -3,7 +3,7 @@ - + connpy.cli.validators API documentation @@ -508,7 +508,7 @@ el.replaceWith(d); diff --git a/docs/connpy/grpc_layer/connpy_pb2.html b/docs/connpy/grpc_layer/connpy_pb2.html index 0511c34..25db16e 100644 --- a/docs/connpy/grpc_layer/connpy_pb2.html +++ b/docs/connpy/grpc_layer/connpy_pb2.html @@ -3,7 +3,7 @@ - + connpy.grpc_layer.connpy_pb2 API documentation @@ -61,7 +61,7 @@ el.replaceWith(d); diff --git a/docs/connpy/grpc_layer/connpy_pb2_grpc.html b/docs/connpy/grpc_layer/connpy_pb2_grpc.html index c6b6408..81dc2f5 100644 --- a/docs/connpy/grpc_layer/connpy_pb2_grpc.html +++ b/docs/connpy/grpc_layer/connpy_pb2_grpc.html @@ -3,7 +3,7 @@ - + connpy.grpc_layer.connpy_pb2_grpc API documentation @@ -7006,7 +7006,7 @@ def stop_api(request, diff --git a/docs/connpy/grpc_layer/index.html b/docs/connpy/grpc_layer/index.html index b5ce506..5d44551 100644 --- a/docs/connpy/grpc_layer/index.html +++ b/docs/connpy/grpc_layer/index.html @@ -3,7 +3,7 @@ - + connpy.grpc_layer API documentation @@ -107,7 +107,7 @@ el.replaceWith(d); diff --git a/docs/connpy/grpc_layer/remote_plugin_pb2.html b/docs/connpy/grpc_layer/remote_plugin_pb2.html index 6e7bb97..c841aa0 100644 --- a/docs/connpy/grpc_layer/remote_plugin_pb2.html +++ b/docs/connpy/grpc_layer/remote_plugin_pb2.html @@ -3,7 +3,7 @@ - + connpy.grpc_layer.remote_plugin_pb2 API documentation @@ -62,7 +62,7 @@ el.replaceWith(d);
var DESCRIPTOR
-
+

The type of the None singleton.

@@ -81,7 +81,7 @@ el.replaceWith(d);
var DESCRIPTOR
-
+

The type of the None singleton.

@@ -100,7 +100,7 @@ el.replaceWith(d);
var DESCRIPTOR
-
+

The type of the None singleton.

@@ -119,7 +119,7 @@ el.replaceWith(d);
var DESCRIPTOR
-
+

The type of the None singleton.

@@ -168,7 +168,7 @@ el.replaceWith(d); diff --git a/docs/connpy/grpc_layer/remote_plugin_pb2_grpc.html b/docs/connpy/grpc_layer/remote_plugin_pb2_grpc.html index 6372fcd..61ed251 100644 --- a/docs/connpy/grpc_layer/remote_plugin_pb2_grpc.html +++ b/docs/connpy/grpc_layer/remote_plugin_pb2_grpc.html @@ -3,7 +3,7 @@ - + connpy.grpc_layer.remote_plugin_pb2_grpc API documentation @@ -366,7 +366,7 @@ def invoke_plugin(request, diff --git a/docs/connpy/grpc_layer/server.html b/docs/connpy/grpc_layer/server.html index 5a94c52..162c657 100644 --- a/docs/connpy/grpc_layer/server.html +++ b/docs/connpy/grpc_layer/server.html @@ -3,7 +3,7 @@ - + connpy.grpc_layer.server API documentation @@ -604,7 +604,7 @@ def service(self):
var OPEN_METHODS
-
+

The type of the None singleton.

Methods

@@ -1583,7 +1583,8 @@ interceptor chooses to service this RPC, or None otherwise.

is_single = saved_mode in (1, 'SINGLE', 'single') if is_range or is_single: - if last_total_cmds is not None and total_cmds > last_total_cmds and saved_cmd > 1: + is_mission = session_state.get('mission', {}).get('active', False) or (isinstance(node_info, dict) and node_info.get('mission', {}).get('active', False)) + if last_total_cmds is not None and total_cmds > last_total_cmds and (saved_cmd > 1 or is_mission): new_cmds = total_cmds - last_total_cmds initial_cmd = saved_cmd + new_cmds else: @@ -1627,6 +1628,14 @@ interceptor chooses to service this RPC, or None otherwise.

)) while True: + # Refresh latest buffer from terminal log + raw_bytes = n.mylog.getvalue() if hasattr(n, 'mylog') else buffer + if not isinstance(raw_bytes, bytes): + raw_bytes = str(raw_bytes).encode() + from connpy.utils import log_cleaner + cleaned_buffer = log_cleaner(raw_bytes.decode(errors='replace')) + buffer = cleaned_buffer + # 0. Drain the queue of any stale messages before starting a new interaction while not remote_stream.copilot_queue.empty(): try: @@ -1760,16 +1769,74 @@ interceptor chooses to service this RPC, or None otherwise.

if action == "send_all": commands = result.get("commands", []) await n.inject_commands(commands, child_fd, on_inject=on_inject) - return elif action.startswith("custom:"): custom_cmds_raw = action[7:] custom_cmds = [cmd.strip() for cmd in custom_cmds_raw.split('\n') if cmd.strip()] await n.inject_commands(custom_cmds, child_fd, on_inject=on_inject) - return else: os.write(child_fd, b'\x15\r') return + # Dynamic Wait for Device Prompt Settle + prompt_pattern = node_info.get("prompt", r'>$|#$|\$$|>.$|#.$|\$.$') + import time + import re + start_t = time.time() + last_len = 0 + quiet_count = 0 + while time.time() - start_t < 60: + await asyncio.sleep(0.15) + cur_bytes = n.mylog.getvalue() + if len(cur_bytes) == last_len: + quiet_count += 1 + else: + quiet_count = 0 + last_len = len(cur_bytes) + + clean_txt = n._logclean(cur_bytes.decode(errors='replace'), True).strip() + lines = [l.strip() for l in clean_txt.split('\n') if l.strip()] + last_line = lines[-1] if lines else "" + if quiet_count >= 2 and re.search(prompt_pattern, last_line): + break + + # Recalculate context blocks with updated buffer + raw_bytes = n.mylog.getvalue() + from connpy.utils import log_cleaner + cleaned_buffer = log_cleaner(raw_bytes.decode(errors='replace')) + last_line = cleaned_buffer.split('\n')[-1].strip() if cleaned_buffer.strip() else "(prompt)" + blocks = service.build_context_blocks(raw_bytes, n.cmd_byte_positions, node_info, last_line=last_line) + node_info["context_blocks"] = blocks + + total_cmds = len(blocks) + total_lines = len(cleaned_buffer.split('\n')) + + last_total_cmds = session_state.get('last_total_cmds', None) + saved_cmd = session_state.get('context_cmd', 1) + is_mission = session_state.get('mission', {}).get('active', False) or (isinstance(node_info, dict) and node_info.get('mission', {}).get('active', False)) + if last_total_cmds is not None and total_cmds > last_total_cmds and (saved_cmd > 1 or is_mission): + new_cmds = total_cmds - last_total_cmds + initial_cmd = saved_cmd + new_cmds + else: + initial_cmd = saved_cmd + + session_state['context_cmd'] = max(1, initial_cmd) + session_state['last_total_cmds'] = total_cmds + session_state['last_total_lines'] = total_lines + + node_info['context_cmd'] = min(session_state['context_cmd'], max(1, total_cmds)) + node_info['context_lines'] = min(session_state.get('context_lines', 50), max(1, total_lines)) + node_info_json = json.dumps(node_info) + + preview_str = raw_bytes[-200:].decode(errors='replace') if isinstance(raw_bytes, bytes) else str(raw_bytes)[-200:] + + # Signal prompt settle and send updated blocks to client + response_queue.put(connpy_pb2.InteractResponse( + copilot_prompt=True, + copilot_buffer_preview=preview_str, + copilot_node_info_json=node_info_json + )) + continue + asyncio.run(n._async_interact_loop(remote_stream, resize_callback, copilot_handler=remote_copilot_handler)) except Exception as e: import traceback @@ -2516,7 +2583,7 @@ def service(self): diff --git a/docs/connpy/grpc_layer/stubs.html b/docs/connpy/grpc_layer/stubs.html index 0746793..e446c7f 100644 --- a/docs/connpy/grpc_layer/stubs.html +++ b/docs/connpy/grpc_layer/stubs.html @@ -3,7 +3,7 @@ - + connpy.grpc_layer.stubs API documentation @@ -1373,7 +1373,7 @@ def set_reserved_names(self, names): self.config = config def _handle_remote_copilot(self, res, request_queue, response_queue, client_buffer_bytes, pause_generator, resume_generator, old_tty): - import json, asyncio, termios, sys, tty, queue + import json, asyncio, termios, sys, tty, queue, os from ..core import copilot_terminal_mode from . import connpy_pb2 @@ -1391,6 +1391,7 @@ def set_reserved_names(self, names): ) self.copilot_history = interface.history self.copilot_state = interface.session_state + interface.session_state['banner_shown'] = False async def on_ai_call_remote(active_buffer, question, chunk_callback, merged_node_info): # Send request to server @@ -1413,6 +1414,7 @@ def set_reserved_names(self, names): # Wrap in async loop async def run_remote_copilot(): + nonlocal blocks, node_info while True: action, commands, custom_cmd = await interface.run_session( raw_bytes=bytes(client_buffer_bytes), @@ -1426,6 +1428,32 @@ def set_reserved_names(self, names): request_queue.put(connpy_pb2.InteractRequest(copilot_action="continue")) continue + if action in ("send_all", "custom"): + action_sent = "cancel" + if action == "send_all" and commands: + action_sent = f"custom:{chr(10).join(commands)}" + elif action == "custom" and custom_cmd: + action_sent = f"custom:{chr(10).join(custom_cmd)}" + request_queue.put(connpy_pb2.InteractRequest(copilot_action=action_sent)) + + print("\r\n\033[2m [Remote] Ejecutando comandos y esperando al prompt...\033[0m\r\n", flush=True) + while True: + try: + prompt_res = response_queue.get(timeout=0.1) + if prompt_res is None: + return "cancel", None, None + if prompt_res.stdout_data: + os.write(sys.stdout.fileno(), prompt_res.stdout_data) + client_buffer_bytes.extend(prompt_res.stdout_data) + if prompt_res.copilot_prompt: + if prompt_res.copilot_node_info_json: + node_info = json.loads(prompt_res.copilot_node_info_json) + blocks = node_info.get("context_blocks", []) + break + except queue.Empty: + await asyncio.sleep(0.05) + continue + return action, commands, custom_cmd with copilot_terminal_mode(): @@ -1433,14 +1461,7 @@ def set_reserved_names(self, names): print("\033[2m Returning to session...\033[0m", flush=True) # Prepare final action for server - action_sent = "cancel" - if action == "send_all" and commands: - # In remote mode, send the selected commands as a custom block - # so the server executes exactly what the user picked (e.g., selection '1') - action_sent = f"custom:{chr(10).join(commands)}" - elif action == "custom" and custom_cmd: - action_sent = f"custom:{chr(10).join(custom_cmd)}" - request_queue.put(connpy_pb2.InteractRequest(copilot_action=action_sent)) + request_queue.put(connpy_pb2.InteractRequest(copilot_action="cancel")) resume_generator() tty.setraw(sys.stdin.fileno()) @@ -2967,7 +2988,7 @@ def stop_api(self): diff --git a/docs/connpy/grpc_layer/user_registry.html b/docs/connpy/grpc_layer/user_registry.html index 9fc72d2..289860b 100644 --- a/docs/connpy/grpc_layer/user_registry.html +++ b/docs/connpy/grpc_layer/user_registry.html @@ -3,7 +3,7 @@ - + connpy.grpc_layer.user_registry API documentation @@ -312,7 +312,7 @@ el.replaceWith(d); diff --git a/docs/connpy/grpc_layer/utils.html b/docs/connpy/grpc_layer/utils.html index 571fbd2..da5286b 100644 --- a/docs/connpy/grpc_layer/utils.html +++ b/docs/connpy/grpc_layer/utils.html @@ -3,7 +3,7 @@ - + connpy.grpc_layer.utils API documentation @@ -138,7 +138,7 @@ el.replaceWith(d); diff --git a/docs/connpy/index.html b/docs/connpy/index.html index 18d5c23..6b2c44d 100644 --- a/docs/connpy/index.html +++ b/docs/connpy/index.html @@ -3,7 +3,7 @@ - + connpy API documentation App Logo

-

Connpy (v6.2.0)

+

Connpy (v6.3.0)

@@ -57,7 +57,9 @@ el.replaceWith(d);

1a. Terminal Copilot (Ctrl+Space)

Invoke the context-aware AI Copilot directly inside any active terminal session by pressing Ctrl + Space. * Context Modes: Cycles through LINES (sends raw scroll buffer), SINGLE (captures exactly one command + output block), and RANGE (logical group of recent commands) using Ctrl+Up/Down. -* Slash Commands (/): Control the AI persona and safety settings: +* Slash Commands (/): Control the AI persona, safety settings, and mission mode: +* /mission [objective]: Start an autonomous multi-step investigation mission with human approval. +* /cancel: Abort active mission. * /architect / /engineer: Swaps the agent between high-level strategist and technical executor. * /trust / /untrust: Configures auto-run behavior for suggested non-destructive commands. * /os [system]: Manually overrides target OS parsing rules (e.g. /os cisco_ios). @@ -2421,6 +2423,7 @@ class node: # Save history back to stream for persistence in current session stream.copilot_history = interface.history stream.copilot_state = interface.session_state + interface.session_state['banner_shown'] = False ai_service = AIService(config) @@ -2451,6 +2454,32 @@ class node: node_info=node_info, on_ai_call=on_ai_call ) + if action in ("send_all", "custom"): + cmds_to_send = commands if action == "send_all" else custom_cmd + await self.inject_commands(cmds_to_send, child_fd) + + # Dynamic Wait for Device Prompt Settle + prompt_pattern = node_info.get("prompt", r'>$|#$|\$$|>.$|#.$|\$.$') + start_t = time() + last_len = 0 + quiet_count = 0 + while time() - start_t < 60: + await asyncio.sleep(0.15) + cur_bytes = self.mylog.getvalue() + if len(cur_bytes) == last_len: + quiet_count += 1 + else: + quiet_count = 0 + last_len = len(cur_bytes) + + clean_txt = self._logclean(cur_bytes.decode(errors='replace'), True).strip() + lines = [l.strip() for l in clean_txt.split('\n') if l.strip()] + last_line = lines[-1] if lines else "" + if quiet_count >= 2 and re.search(prompt_pattern, last_line): + break + + raw_bytes = self.mylog.getvalue() + continue if action == "continue": continue break @@ -2462,11 +2491,7 @@ class node: elif hasattr(stream, '_loop') and hasattr(stream, 'stdin_fd'): stream._loop.add_reader(stream.stdin_fd, stream._read_ready) - if action in ("send_all", "custom"): - cmds_to_send = commands if action == "send_all" else custom_cmd - await self.inject_commands(cmds_to_send, child_fd) - else: - os.write(child_fd, b'\x15\r') + os.write(child_fd, b'\x15\r') except Exception as e: import traceback print(f"\n[ERROR in Copilot Handler] {e}", flush=True) @@ -4087,7 +4112,7 @@ def test(self, commands, expected, vars = None,*, folder = None, prompt = None,

diff --git a/docs/connpy/mcp_client.html b/docs/connpy/mcp_client.html index d84cd94..44ccdf6 100644 --- a/docs/connpy/mcp_client.html +++ b/docs/connpy/mcp_client.html @@ -3,7 +3,7 @@ - + connpy.mcp_client API documentation @@ -349,7 +349,7 @@ el.replaceWith(d); diff --git a/docs/connpy/proto/index.html b/docs/connpy/proto/index.html index 573e196..0fc7ddf 100644 --- a/docs/connpy/proto/index.html +++ b/docs/connpy/proto/index.html @@ -3,7 +3,7 @@ - + connpy.proto API documentation @@ -60,7 +60,7 @@ el.replaceWith(d); diff --git a/docs/connpy/services/ai_service.html b/docs/connpy/services/ai_service.html index 3997f2c..d774054 100644 --- a/docs/connpy/services/ai_service.html +++ b/docs/connpy/services/ai_service.html @@ -3,7 +3,7 @@ - + connpy.services.ai_service API documentation @@ -259,6 +259,26 @@ el.replaceWith(d); else: return {"action": "execute", "clean_prompt": args, "overrides": {"trust": False}} + elif cmd == "/mission": + if args: + session_state['mission'] = { + 'active': True, + 'goal': args, + 'step': 1, + 'max_steps': 10, + 'start_block_idx': None, + 'scratchpad_notes': [] + } + return {"action": "mission_start", "clean_prompt": args, "overrides": {}} + else: + return {"action": "state_update", "message": "Usage: /mission <objective_description>"} + + elif cmd in ("/cancel", "/abort"): + if session_state.get('mission', {}).get('active'): + session_state['mission']['active'] = False + return {"action": "mission_cancel", "message": "Mission cancelled"} + return {"action": "state_update", "message": "No active mission to cancel"} + # Unknown command, execute normally return {"action": "execute", "clean_prompt": text, "overrides": {}} @@ -877,6 +897,26 @@ el.replaceWith(d); else: return {"action": "execute", "clean_prompt": args, "overrides": {"trust": False}} + elif cmd == "/mission": + if args: + session_state['mission'] = { + 'active': True, + 'goal': args, + 'step': 1, + 'max_steps': 10, + 'start_block_idx': None, + 'scratchpad_notes': [] + } + return {"action": "mission_start", "clean_prompt": args, "overrides": {}} + else: + return {"action": "state_update", "message": "Usage: /mission <objective_description>"} + + elif cmd in ("/cancel", "/abort"): + if session_state.get('mission', {}).get('active'): + session_state['mission']['active'] = False + return {"action": "mission_cancel", "message": "Mission cancelled"} + return {"action": "state_update", "message": "No active mission to cancel"} + # Unknown command, execute normally return {"action": "execute", "clean_prompt": text, "overrides": {}} @@ -933,7 +973,7 @@ el.replaceWith(d); diff --git a/docs/connpy/services/base.html b/docs/connpy/services/base.html index 2ff6902..e72b7ab 100644 --- a/docs/connpy/services/base.html +++ b/docs/connpy/services/base.html @@ -3,7 +3,7 @@ - + connpy.services.base API documentation @@ -152,7 +152,7 @@ el.replaceWith(d); diff --git a/docs/connpy/services/config_service.html b/docs/connpy/services/config_service.html index 4156c44..ad87411 100644 --- a/docs/connpy/services/config_service.html +++ b/docs/connpy/services/config_service.html @@ -3,7 +3,7 @@ - + connpy.services.config_service API documentation @@ -319,7 +319,7 @@ el.replaceWith(d); diff --git a/docs/connpy/services/context_service.html b/docs/connpy/services/context_service.html index 2161ebb..0a772f7 100644 --- a/docs/connpy/services/context_service.html +++ b/docs/connpy/services/context_service.html @@ -3,7 +3,7 @@ - + connpy.services.context_service API documentation @@ -370,7 +370,7 @@ def current_context(self) -> str: diff --git a/docs/connpy/services/exceptions.html b/docs/connpy/services/exceptions.html index 164cec5..459d464 100644 --- a/docs/connpy/services/exceptions.html +++ b/docs/connpy/services/exceptions.html @@ -3,7 +3,7 @@ - + connpy.services.exceptions API documentation @@ -268,7 +268,7 @@ el.replaceWith(d); diff --git a/docs/connpy/services/execution_service.html b/docs/connpy/services/execution_service.html index be3a20a..73aaa95 100644 --- a/docs/connpy/services/execution_service.html +++ b/docs/connpy/services/execution_service.html @@ -3,7 +3,7 @@ - + connpy.services.execution_service API documentation @@ -340,7 +340,7 @@ el.replaceWith(d); diff --git a/docs/connpy/services/import_export_service.html b/docs/connpy/services/import_export_service.html index 4c98e1b..58e0736 100644 --- a/docs/connpy/services/import_export_service.html +++ b/docs/connpy/services/import_export_service.html @@ -3,7 +3,7 @@ - + connpy.services.import_export_service API documentation @@ -361,7 +361,7 @@ el.replaceWith(d); diff --git a/docs/connpy/services/index.html b/docs/connpy/services/index.html index 84abee5..d71b754 100644 --- a/docs/connpy/services/index.html +++ b/docs/connpy/services/index.html @@ -3,7 +3,7 @@ - + connpy.services API documentation @@ -318,6 +318,26 @@ el.replaceWith(d); else: return {"action": "execute", "clean_prompt": args, "overrides": {"trust": False}} + elif cmd == "/mission": + if args: + session_state['mission'] = { + 'active': True, + 'goal': args, + 'step': 1, + 'max_steps': 10, + 'start_block_idx': None, + 'scratchpad_notes': [] + } + return {"action": "mission_start", "clean_prompt": args, "overrides": {}} + else: + return {"action": "state_update", "message": "Usage: /mission <objective_description>"} + + elif cmd in ("/cancel", "/abort"): + if session_state.get('mission', {}).get('active'): + session_state['mission']['active'] = False + return {"action": "mission_cancel", "message": "Mission cancelled"} + return {"action": "state_update", "message": "No active mission to cancel"} + # Unknown command, execute normally return {"action": "execute", "clean_prompt": text, "overrides": {}} @@ -936,6 +956,26 @@ el.replaceWith(d); else: return {"action": "execute", "clean_prompt": args, "overrides": {"trust": False}} + elif cmd == "/mission": + if args: + session_state['mission'] = { + 'active': True, + 'goal': args, + 'step': 1, + 'max_steps': 10, + 'start_block_idx': None, + 'scratchpad_notes': [] + } + return {"action": "mission_start", "clean_prompt": args, "overrides": {}} + else: + return {"action": "state_update", "message": "Usage: /mission <objective_description>"} + + elif cmd in ("/cancel", "/abort"): + if session_state.get('mission', {}).get('active'): + session_state['mission']['active'] = False + return {"action": "mission_cancel", "message": "Mission cancelled"} + return {"action": "state_update", "message": "No active mission to cancel"} + # Unknown command, execute normally return {"action": "execute", "clean_prompt": text, "overrides": {}} @@ -5858,7 +5898,7 @@ Mode B: config_path set -> Reuses existing directory after validating its str diff --git a/docs/connpy/services/node_service.html b/docs/connpy/services/node_service.html index 0e53a2a..dc1092d 100644 --- a/docs/connpy/services/node_service.html +++ b/docs/connpy/services/node_service.html @@ -3,7 +3,7 @@ - + connpy.services.node_service API documentation @@ -790,7 +790,7 @@ el.replaceWith(d); diff --git a/docs/connpy/services/plugin_service.html b/docs/connpy/services/plugin_service.html index 5d72aa8..06f0497 100644 --- a/docs/connpy/services/plugin_service.html +++ b/docs/connpy/services/plugin_service.html @@ -3,7 +3,7 @@ - + connpy.services.plugin_service API documentation @@ -838,7 +838,7 @@ el.replaceWith(d); diff --git a/docs/connpy/services/profile_service.html b/docs/connpy/services/profile_service.html index e3f746c..568aec0 100644 --- a/docs/connpy/services/profile_service.html +++ b/docs/connpy/services/profile_service.html @@ -3,7 +3,7 @@ - + connpy.services.profile_service API documentation @@ -429,7 +429,7 @@ el.replaceWith(d); diff --git a/docs/connpy/services/provider.html b/docs/connpy/services/provider.html index da74f04..1d461f0 100644 --- a/docs/connpy/services/provider.html +++ b/docs/connpy/services/provider.html @@ -3,7 +3,7 @@ - + connpy.services.provider API documentation @@ -351,7 +351,7 @@ def users(self): diff --git a/docs/connpy/services/sync_service.html b/docs/connpy/services/sync_service.html index 45f8091..f69ec6c 100644 --- a/docs/connpy/services/sync_service.html +++ b/docs/connpy/services/sync_service.html @@ -3,7 +3,7 @@ - + connpy.services.sync_service API documentation @@ -978,7 +978,7 @@ el.replaceWith(d); diff --git a/docs/connpy/services/system_service.html b/docs/connpy/services/system_service.html index 95f059e..ded62e1 100644 --- a/docs/connpy/services/system_service.html +++ b/docs/connpy/services/system_service.html @@ -3,7 +3,7 @@ - + connpy.services.system_service API documentation @@ -325,7 +325,7 @@ el.replaceWith(d); diff --git a/docs/connpy/services/user_service.html b/docs/connpy/services/user_service.html index 8dbb283..4ae6d10 100644 --- a/docs/connpy/services/user_service.html +++ b/docs/connpy/services/user_service.html @@ -3,7 +3,7 @@ - + connpy.services.user_service API documentation @@ -894,7 +894,7 @@ Mode B: config_path set -> Reuses existing directory after validating its str diff --git a/docs/connpy/tunnels.html b/docs/connpy/tunnels.html index 0787e86..db675e1 100644 --- a/docs/connpy/tunnels.html +++ b/docs/connpy/tunnels.html @@ -3,7 +3,7 @@ - + connpy.tunnels API documentation @@ -549,7 +549,7 @@ Bridges the blocking gRPC iterators with the async _async_interact_loop.

diff --git a/docs/connpy/utils.html b/docs/connpy/utils.html index ac7a610..4584ba1 100644 --- a/docs/connpy/utils.html +++ b/docs/connpy/utils.html @@ -3,7 +3,7 @@ - + connpy.utils API documentation @@ -147,7 +147,7 @@ el.replaceWith(d);