strix-agent 0.1.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. strix/__init__.py +0 -0
  2. strix/agents/StrixAgent/__init__.py +4 -0
  3. strix/agents/StrixAgent/strix_agent.py +60 -0
  4. strix/agents/StrixAgent/system_prompt.jinja +504 -0
  5. strix/agents/__init__.py +10 -0
  6. strix/agents/base_agent.py +394 -0
  7. strix/agents/state.py +139 -0
  8. strix/cli/__init__.py +4 -0
  9. strix/cli/app.py +1124 -0
  10. strix/cli/assets/cli.tcss +680 -0
  11. strix/cli/main.py +542 -0
  12. strix/cli/tool_components/__init__.py +39 -0
  13. strix/cli/tool_components/agents_graph_renderer.py +129 -0
  14. strix/cli/tool_components/base_renderer.py +61 -0
  15. strix/cli/tool_components/browser_renderer.py +107 -0
  16. strix/cli/tool_components/file_edit_renderer.py +95 -0
  17. strix/cli/tool_components/finish_renderer.py +32 -0
  18. strix/cli/tool_components/notes_renderer.py +108 -0
  19. strix/cli/tool_components/proxy_renderer.py +255 -0
  20. strix/cli/tool_components/python_renderer.py +34 -0
  21. strix/cli/tool_components/registry.py +72 -0
  22. strix/cli/tool_components/reporting_renderer.py +53 -0
  23. strix/cli/tool_components/scan_info_renderer.py +58 -0
  24. strix/cli/tool_components/terminal_renderer.py +99 -0
  25. strix/cli/tool_components/thinking_renderer.py +29 -0
  26. strix/cli/tool_components/user_message_renderer.py +43 -0
  27. strix/cli/tool_components/web_search_renderer.py +28 -0
  28. strix/cli/tracer.py +308 -0
  29. strix/llm/__init__.py +14 -0
  30. strix/llm/config.py +19 -0
  31. strix/llm/llm.py +310 -0
  32. strix/llm/memory_compressor.py +206 -0
  33. strix/llm/request_queue.py +63 -0
  34. strix/llm/utils.py +84 -0
  35. strix/prompts/__init__.py +113 -0
  36. strix/prompts/coordination/root_agent.jinja +41 -0
  37. strix/prompts/vulnerabilities/authentication_jwt.jinja +129 -0
  38. strix/prompts/vulnerabilities/business_logic.jinja +143 -0
  39. strix/prompts/vulnerabilities/csrf.jinja +168 -0
  40. strix/prompts/vulnerabilities/idor.jinja +164 -0
  41. strix/prompts/vulnerabilities/race_conditions.jinja +194 -0
  42. strix/prompts/vulnerabilities/rce.jinja +222 -0
  43. strix/prompts/vulnerabilities/sql_injection.jinja +216 -0
  44. strix/prompts/vulnerabilities/ssrf.jinja +168 -0
  45. strix/prompts/vulnerabilities/xss.jinja +221 -0
  46. strix/prompts/vulnerabilities/xxe.jinja +276 -0
  47. strix/runtime/__init__.py +19 -0
  48. strix/runtime/docker_runtime.py +298 -0
  49. strix/runtime/runtime.py +25 -0
  50. strix/runtime/tool_server.py +97 -0
  51. strix/tools/__init__.py +64 -0
  52. strix/tools/agents_graph/__init__.py +16 -0
  53. strix/tools/agents_graph/agents_graph_actions.py +610 -0
  54. strix/tools/agents_graph/agents_graph_actions_schema.xml +223 -0
  55. strix/tools/argument_parser.py +120 -0
  56. strix/tools/browser/__init__.py +4 -0
  57. strix/tools/browser/browser_actions.py +236 -0
  58. strix/tools/browser/browser_actions_schema.xml +183 -0
  59. strix/tools/browser/browser_instance.py +533 -0
  60. strix/tools/browser/tab_manager.py +342 -0
  61. strix/tools/executor.py +302 -0
  62. strix/tools/file_edit/__init__.py +4 -0
  63. strix/tools/file_edit/file_edit_actions.py +141 -0
  64. strix/tools/file_edit/file_edit_actions_schema.xml +128 -0
  65. strix/tools/finish/__init__.py +4 -0
  66. strix/tools/finish/finish_actions.py +167 -0
  67. strix/tools/finish/finish_actions_schema.xml +45 -0
  68. strix/tools/notes/__init__.py +14 -0
  69. strix/tools/notes/notes_actions.py +191 -0
  70. strix/tools/notes/notes_actions_schema.xml +150 -0
  71. strix/tools/proxy/__init__.py +20 -0
  72. strix/tools/proxy/proxy_actions.py +101 -0
  73. strix/tools/proxy/proxy_actions_schema.xml +267 -0
  74. strix/tools/proxy/proxy_manager.py +785 -0
  75. strix/tools/python/__init__.py +4 -0
  76. strix/tools/python/python_actions.py +47 -0
  77. strix/tools/python/python_actions_schema.xml +131 -0
  78. strix/tools/python/python_instance.py +172 -0
  79. strix/tools/python/python_manager.py +131 -0
  80. strix/tools/registry.py +196 -0
  81. strix/tools/reporting/__init__.py +6 -0
  82. strix/tools/reporting/reporting_actions.py +63 -0
  83. strix/tools/reporting/reporting_actions_schema.xml +30 -0
  84. strix/tools/terminal/__init__.py +4 -0
  85. strix/tools/terminal/terminal_actions.py +53 -0
  86. strix/tools/terminal/terminal_actions_schema.xml +114 -0
  87. strix/tools/terminal/terminal_instance.py +231 -0
  88. strix/tools/terminal/terminal_manager.py +191 -0
  89. strix/tools/thinking/__init__.py +4 -0
  90. strix/tools/thinking/thinking_actions.py +18 -0
  91. strix/tools/thinking/thinking_actions_schema.xml +52 -0
  92. strix/tools/web_search/__init__.py +4 -0
  93. strix/tools/web_search/web_search_actions.py +80 -0
  94. strix/tools/web_search/web_search_actions_schema.xml +83 -0
  95. strix_agent-0.1.1.dist-info/LICENSE +201 -0
  96. strix_agent-0.1.1.dist-info/METADATA +200 -0
  97. strix_agent-0.1.1.dist-info/RECORD +99 -0
  98. strix_agent-0.1.1.dist-info/WHEEL +4 -0
  99. strix_agent-0.1.1.dist-info/entry_points.txt +3 -0
strix/cli/app.py ADDED
@@ -0,0 +1,1124 @@
1
+ import argparse
2
+ import asyncio
3
+ import atexit
4
+ import logging
5
+ import random
6
+ import signal
7
+ import sys
8
+ import threading
9
+ from collections.abc import Callable
10
+ from typing import Any, ClassVar
11
+
12
+ from textual import events, on
13
+ from textual.app import App, ComposeResult
14
+ from textual.binding import Binding
15
+ from textual.containers import Grid, Horizontal, Vertical, VerticalScroll
16
+ from textual.reactive import reactive
17
+ from textual.screen import ModalScreen
18
+ from textual.widgets import Button, Label, Static, TextArea, Tree
19
+ from textual.widgets.tree import TreeNode
20
+
21
+ from strix.agents.StrixAgent import StrixAgent
22
+ from strix.cli.tracer import Tracer, set_global_tracer
23
+ from strix.llm.config import LLMConfig
24
+
25
+
26
+ def escape_markup(text: str) -> str:
27
+ return text.replace("[", "\\[").replace("]", "\\]")
28
+
29
+
30
+ class ChatTextArea(TextArea): # type: ignore[misc]
31
+ def __init__(self, *args: Any, **kwargs: Any) -> None:
32
+ super().__init__(*args, **kwargs)
33
+ self._app_reference: StrixCLIApp | None = None
34
+
35
+ def set_app_reference(self, app: "StrixCLIApp") -> None:
36
+ self._app_reference = app
37
+
38
+ def _on_key(self, event: events.Key) -> None:
39
+ if event.key == "enter" and self._app_reference:
40
+ text_content = str(self.text) # type: ignore[has-type]
41
+ message = text_content.strip()
42
+ if message:
43
+ self.text = ""
44
+
45
+ self._app_reference._send_user_message(message)
46
+
47
+ event.prevent_default()
48
+ return
49
+
50
+ super()._on_key(event)
51
+
52
+
53
+ class SplashScreen(Static): # type: ignore[misc]
54
+ def compose(self) -> ComposeResult:
55
+ ascii_art = r"""
56
+ [bright_green]
57
+ ⠰⢿⣿⡶⢤⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⡤⢶⣿⡿⠆
58
+ ⠀⠀⢻⣿⠀⠈⠓⠤⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠠⠚⠁⠀⣿⡟⠀⠀
59
+ ⠀⠀⠠⣻⡆⠀⠀⠀⠠⣄⡀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣠⠄⠀⠀⠀⢰⣟⠄⠀⠀
60
+ ███████╗████████╗██████╗ ██╗██╗ ██╗ ⠀⠀⠀⢼⣷⠀⠀⠀⠀⠀⠙⠛⠶⣶⣶⣶⣶⠶⠛⠋⠀⠀⠀⠀⠀⣾⡧⠀⠀⠀
61
+ ██╔════╝╚══██╔══╝██╔══██╗██║╚██╗██╔╝ ⠀⠀⢀⣾⠃⢳⣧⡀⠀⠠⡀⢀⠀⠀⠉⠉⠀⠀⡀⢀⠄⠀⢀⣼⡞⠘⣷⡀⠀⠀
62
+ ███████╗ ██║ ██████╔╝██║ ╚███╔╝ ⠀⠀⡾⠃⠀⠲⡾⢿⣓⣢⡀⠻⢷⣄⡀⢀⣠⡾⠟⢀⣔⣚⡿⢷⠖⠀⠘⢷⠀⠀
63
+ ╚════██║ ██║ ██╔══██╗██║ ██╔██╗ ⠀⢠⠃⣰⠁⠀⠐⣿⢫⣿⣿⣆⠀⢈⡻⢟⣉⠀⣰⣿⣿⡝⣿⠂⠀⠈⣆⠘⡄⠀
64
+ ███████║ ██║ ██║ ██║██║██╔╝ ██╗ ⠀⠀⠀⣽⠀⠀⠀⠘⣄⠙⣋⣼⠆⠈⢛⡛⠁⠰⣧⣙⠋⣠⠃⠀⠀⠀⣿⠀⠀⠀
65
+ ╚══════╝ ╚═╝ ╚═╝ ╚═╝╚═╝╚═╝ ╚═╝ ⠀⠀⠀⢻⡀⠀⠀⠀⠀⠀⠉⠀⠀⠀⣾⢱⠀⠀⠀⠉⠀⠀⠀⠀⠀⢀⡟⠀⠀⠀
66
+ ⠀⠀⠀⠈⠻⣆⡀⠀⠀⠀⢀⡀⠀⠀⢿⡸⠀⠀⢀⡀⠀⠀⠀⢀⣰⠟⠃⠀⠀⠀
67
+ ⠀⠀⠀⠀⠀⠉⠉⠂⠀⠀⠉⠛⢦⡀⠸⠇⢀⡴⠛⠉⠀⠀⠐⠉⠉⠀⠀⠀⠀⠀
68
+ ⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠙⠶⠶⠋⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
69
+ [/bright_green]
70
+
71
+ [bright_green]Starting Strix Cybersecurity Agent...[/bright_green]
72
+ """
73
+ yield Static(ascii_art, id="splash_content")
74
+
75
+
76
+ class HelpScreen(ModalScreen): # type: ignore[misc]
77
+ def compose(self) -> ComposeResult:
78
+ yield Grid(
79
+ Label("🦉 Strix Help", id="help_title"),
80
+ Label(
81
+ "F1 Help\nCtrl+Q/C Quit\nESC Stop Agent\n"
82
+ "Enter Send message to agent\nTab Switch panels\n↑/↓ Navigate tree",
83
+ id="help_content",
84
+ ),
85
+ id="dialog",
86
+ )
87
+
88
+ def on_key(self, _event: events.Key) -> None:
89
+ self.app.pop_screen()
90
+
91
+
92
+ class StopAgentScreen(ModalScreen): # type: ignore[misc]
93
+ def __init__(self, agent_name: str, agent_id: str):
94
+ super().__init__()
95
+ self.agent_name = agent_name
96
+ self.agent_id = agent_id
97
+
98
+ def compose(self) -> ComposeResult:
99
+ yield Grid(
100
+ Label(f"🛑 Stop '{self.agent_name}'?", id="stop_agent_title"),
101
+ Grid(
102
+ Button("Yes", variant="error", id="stop_agent"),
103
+ Button("No", variant="default", id="cancel_stop"),
104
+ id="stop_agent_buttons",
105
+ ),
106
+ id="stop_agent_dialog",
107
+ )
108
+
109
+ def on_mount(self) -> None:
110
+ cancel_button = self.query_one("#cancel_stop", Button)
111
+ cancel_button.focus()
112
+
113
+ def on_key(self, event: events.Key) -> None:
114
+ if event.key in ("left", "right", "up", "down"):
115
+ focused = self.focused
116
+
117
+ if focused and focused.id == "stop_agent":
118
+ cancel_button = self.query_one("#cancel_stop", Button)
119
+ cancel_button.focus()
120
+ else:
121
+ stop_button = self.query_one("#stop_agent", Button)
122
+ stop_button.focus()
123
+
124
+ event.prevent_default()
125
+ elif event.key == "enter":
126
+ focused = self.focused
127
+ if focused and isinstance(focused, Button):
128
+ focused.press()
129
+ event.prevent_default()
130
+ elif event.key == "escape":
131
+ self.app.pop_screen()
132
+ event.prevent_default()
133
+
134
+ def on_button_pressed(self, event: Button.Pressed) -> None:
135
+ if event.button.id == "stop_agent":
136
+ self.app.action_confirm_stop_agent(self.agent_id)
137
+ else:
138
+ self.app.pop_screen()
139
+
140
+
141
+ class QuitScreen(ModalScreen): # type: ignore[misc]
142
+ def compose(self) -> ComposeResult:
143
+ yield Grid(
144
+ Label("🦉 Quit Strix? ", id="quit_title"),
145
+ Grid(
146
+ Button("Yes", variant="error", id="quit"),
147
+ Button("No", variant="default", id="cancel"),
148
+ id="quit_buttons",
149
+ ),
150
+ id="quit_dialog",
151
+ )
152
+
153
+ def on_mount(self) -> None:
154
+ cancel_button = self.query_one("#cancel", Button)
155
+ cancel_button.focus()
156
+
157
+ def on_key(self, event: events.Key) -> None:
158
+ if event.key in ("left", "right", "up", "down"):
159
+ focused = self.focused
160
+
161
+ if focused and focused.id == "quit":
162
+ cancel_button = self.query_one("#cancel", Button)
163
+ cancel_button.focus()
164
+ else:
165
+ quit_button = self.query_one("#quit", Button)
166
+ quit_button.focus()
167
+
168
+ event.prevent_default()
169
+ elif event.key == "enter":
170
+ focused = self.focused
171
+ if focused and isinstance(focused, Button):
172
+ focused.press()
173
+ event.prevent_default()
174
+ elif event.key == "escape":
175
+ self.app.pop_screen()
176
+ event.prevent_default()
177
+
178
+ def on_button_pressed(self, event: Button.Pressed) -> None:
179
+ if event.button.id == "quit":
180
+ self.app.action_custom_quit()
181
+ else:
182
+ self.app.pop_screen()
183
+
184
+
185
+ class StrixCLIApp(App): # type: ignore[misc]
186
+ CSS_PATH = "assets/cli.tcss"
187
+
188
+ selected_agent_id: reactive[str | None] = reactive(default=None)
189
+ show_splash: reactive[bool] = reactive(default=True)
190
+
191
+ BINDINGS: ClassVar[list[Binding]] = [
192
+ Binding("f1", "toggle_help", "Help", priority=True),
193
+ Binding("ctrl+q", "request_quit", "Quit", priority=True),
194
+ Binding("ctrl+c", "request_quit", "Quit", priority=True),
195
+ Binding("escape", "stop_selected_agent", "Stop Agent", priority=True),
196
+ ]
197
+
198
+ def __init__(self, args: argparse.Namespace):
199
+ super().__init__()
200
+ self.args = args
201
+ self.scan_config = self._build_scan_config(args)
202
+ self.agent_config = self._build_agent_config(args)
203
+
204
+ self.tracer = Tracer(self.scan_config["run_name"])
205
+ self.tracer.set_scan_config(self.scan_config)
206
+ set_global_tracer(self.tracer)
207
+
208
+ self.agent_nodes: dict[str, TreeNode] = {}
209
+
210
+ self._displayed_agents: set[str] = set()
211
+ self._displayed_events: list[str] = []
212
+
213
+ self._scan_thread: threading.Thread | None = None
214
+ self._scan_stop_event = threading.Event()
215
+ self._scan_completed = threading.Event()
216
+
217
+ self._action_verbs = [
218
+ "Generating",
219
+ "Scanning",
220
+ "Analyzing",
221
+ "Probing",
222
+ "Hacking",
223
+ "Testing",
224
+ "Exploiting",
225
+ "Investigating",
226
+ ]
227
+ self._agent_verbs: dict[str, str] = {} # agent_id -> current_verb
228
+ self._agent_verb_timers: dict[str, Any] = {} # agent_id -> timer
229
+ self._agent_dot_states: dict[str, int] = {} # agent_id -> dot_count (0-3)
230
+ self._dot_animation_timer: Any | None = None
231
+
232
+ self._setup_cleanup_handlers()
233
+
234
+ def _build_scan_config(self, args: argparse.Namespace) -> dict[str, Any]:
235
+ return {
236
+ "scan_id": args.run_name,
237
+ "scan_type": args.target_type,
238
+ "target": args.target_dict,
239
+ "user_instructions": args.instruction or "",
240
+ "run_name": args.run_name,
241
+ }
242
+
243
+ def _build_agent_config(self, args: argparse.Namespace) -> dict[str, Any]:
244
+ llm_config = LLMConfig()
245
+
246
+ config = {
247
+ "llm_config": llm_config,
248
+ "max_iterations": 200,
249
+ }
250
+
251
+ if args.target_type == "local_code" and "target_path" in args.target_dict:
252
+ config["local_source_path"] = args.target_dict["target_path"]
253
+
254
+ return config
255
+
256
+ def _setup_cleanup_handlers(self) -> None:
257
+ def cleanup_on_exit() -> None:
258
+ self.tracer.cleanup()
259
+
260
+ def signal_handler(_signum: int, _frame: Any) -> None:
261
+ self.tracer.cleanup()
262
+ sys.exit(0)
263
+
264
+ atexit.register(cleanup_on_exit)
265
+ signal.signal(signal.SIGINT, signal_handler)
266
+ signal.signal(signal.SIGTERM, signal_handler)
267
+ if hasattr(signal, "SIGHUP"):
268
+ signal.signal(signal.SIGHUP, signal_handler)
269
+
270
+ def compose(self) -> ComposeResult:
271
+ if self.show_splash:
272
+ yield SplashScreen(id="splash_screen")
273
+
274
+ def watch_show_splash(self, show_splash: bool) -> None:
275
+ if not show_splash and self.is_mounted:
276
+ try:
277
+ splash = self.query_one("#splash_screen")
278
+ splash.remove()
279
+ except ValueError:
280
+ pass
281
+
282
+ main_container = Vertical(id="main_container")
283
+
284
+ self.mount(main_container)
285
+
286
+ content_container = Horizontal(id="content_container")
287
+ main_container.mount(content_container)
288
+
289
+ chat_area_container = Vertical(id="chat_area_container")
290
+
291
+ chat_display = Static("", id="chat_display")
292
+ chat_history = VerticalScroll(chat_display, id="chat_history")
293
+ chat_history.can_focus = True
294
+
295
+ status_text = Static("", id="status_text")
296
+ keymap_indicator = Static("", id="keymap_indicator")
297
+
298
+ agent_status_display = Horizontal(
299
+ status_text, keymap_indicator, id="agent_status_display", classes="hidden"
300
+ )
301
+
302
+ chat_prompt = Static("> ", id="chat_prompt")
303
+ chat_input = ChatTextArea(
304
+ "",
305
+ id="chat_input",
306
+ show_line_numbers=False,
307
+ )
308
+ chat_input.set_app_reference(self)
309
+ chat_input_container = Horizontal(chat_prompt, chat_input, id="chat_input_container")
310
+
311
+ agents_tree = Tree("🤖 Active Agents", id="agents_tree")
312
+ agents_tree.root.expand()
313
+ agents_tree.show_root = False
314
+
315
+ agents_tree.show_guide = True
316
+ agents_tree.guide_depth = 3
317
+ agents_tree.guide_style = "dashed"
318
+
319
+ content_container.mount(chat_area_container)
320
+ content_container.mount(agents_tree)
321
+
322
+ chat_area_container.mount(chat_history)
323
+ chat_area_container.mount(agent_status_display)
324
+ chat_area_container.mount(chat_input_container)
325
+
326
+ self.call_after_refresh(self._focus_chat_input)
327
+
328
+ def _focus_chat_input(self) -> None:
329
+ if len(self.screen_stack) > 1 or self.show_splash:
330
+ return
331
+
332
+ if not self.is_mounted:
333
+ return
334
+
335
+ try:
336
+ chat_input = self.query_one("#chat_input", ChatTextArea)
337
+ chat_input.show_vertical_scrollbar = False
338
+ chat_input.show_horizontal_scrollbar = False
339
+ chat_input.focus()
340
+ except (ValueError, Exception):
341
+ self.call_after_refresh(self._focus_chat_input)
342
+
343
+ def _focus_agents_tree(self) -> None:
344
+ if len(self.screen_stack) > 1 or self.show_splash:
345
+ return
346
+
347
+ if not self.is_mounted:
348
+ return
349
+
350
+ try:
351
+ agents_tree = self.query_one("#agents_tree", Tree)
352
+ agents_tree.focus()
353
+
354
+ if agents_tree.root.children:
355
+ first_node = agents_tree.root.children[0]
356
+ agents_tree.select_node(first_node)
357
+ except (ValueError, Exception):
358
+ self.call_after_refresh(self._focus_agents_tree)
359
+
360
+ def on_mount(self) -> None:
361
+ self.title = "strix"
362
+
363
+ self.set_timer(3.0, self._hide_splash_screen)
364
+
365
+ def _hide_splash_screen(self) -> None:
366
+ self.show_splash = False
367
+
368
+ self._start_scan_thread()
369
+
370
+ self.set_interval(0.5, self._update_ui_from_tracer)
371
+
372
+ def _update_ui_from_tracer(self) -> None:
373
+ if self.show_splash:
374
+ return
375
+
376
+ if len(self.screen_stack) > 1:
377
+ return
378
+
379
+ if not self.is_mounted:
380
+ return
381
+
382
+ try:
383
+ chat_history = self.query_one("#chat_history", VerticalScroll)
384
+ agents_tree = self.query_one("#agents_tree", Tree)
385
+
386
+ if not self._is_widget_safe(chat_history) or not self._is_widget_safe(agents_tree):
387
+ return
388
+ except (ValueError, Exception):
389
+ return
390
+
391
+ agent_updates = False
392
+ for agent_id, agent_data in self.tracer.agents.items():
393
+ if agent_id not in self._displayed_agents:
394
+ self._add_agent_node(agent_data)
395
+ self._displayed_agents.add(agent_id)
396
+ agent_updates = True
397
+ elif self._update_agent_node(agent_id, agent_data):
398
+ agent_updates = True
399
+
400
+ if agent_updates:
401
+ self._expand_all_agent_nodes()
402
+
403
+ self._update_chat_view()
404
+
405
+ self._update_agent_status_display()
406
+
407
+ def _update_agent_node(self, agent_id: str, agent_data: dict[str, Any]) -> bool:
408
+ if agent_id not in self.agent_nodes:
409
+ return False
410
+
411
+ try:
412
+ agent_node = self.agent_nodes[agent_id]
413
+ agent_name_raw = agent_data.get("name", "Agent")
414
+ status = agent_data.get("status", "running")
415
+
416
+ status_indicators = {
417
+ "running": "🟢",
418
+ "waiting": "⏸️",
419
+ "completed": "✅",
420
+ "failed": "❌",
421
+ "stopped": "⏹️",
422
+ "stopping": "⏸️",
423
+ }
424
+
425
+ status_icon = status_indicators.get(status, "🔵")
426
+ agent_name = f"{status_icon} {escape_markup(agent_name_raw)}"
427
+
428
+ if status == "running":
429
+ self._start_agent_verb_timer(agent_id)
430
+ elif status == "waiting":
431
+ self._stop_agent_verb_timer(agent_id)
432
+ else:
433
+ self._stop_agent_verb_timer(agent_id)
434
+
435
+ if agent_node.label != agent_name:
436
+ agent_node.set_label(agent_name)
437
+ return True
438
+
439
+ except (KeyError, AttributeError, ValueError) as e:
440
+ import logging
441
+
442
+ logging.warning(f"Failed to update agent node label: {e}")
443
+
444
+ return False
445
+
446
+ def _update_chat_view(self) -> None:
447
+ if len(self.screen_stack) > 1 or self.show_splash:
448
+ return
449
+
450
+ if not self.is_mounted:
451
+ return
452
+
453
+ try:
454
+ chat_history = self.query_one("#chat_history", VerticalScroll)
455
+ except (ValueError, Exception):
456
+ return
457
+
458
+ if not self._is_widget_safe(chat_history):
459
+ return
460
+
461
+ try:
462
+ is_at_bottom = chat_history.scroll_y >= chat_history.max_scroll_y
463
+ except (AttributeError, ValueError):
464
+ is_at_bottom = True
465
+
466
+ if not self.selected_agent_id:
467
+ content, css_class = self._get_chat_placeholder_content(
468
+ "Select an agent from the tree to see its activity.", "placeholder-no-agent"
469
+ )
470
+ else:
471
+ events = self._gather_agent_events(self.selected_agent_id)
472
+ if not events:
473
+ content, css_class = self._get_chat_placeholder_content(
474
+ "Starting agent...", "placeholder-no-activity"
475
+ )
476
+ else:
477
+ current_event_ids = [e["id"] for e in events]
478
+ if current_event_ids == self._displayed_events:
479
+ return
480
+ content = self._get_rendered_events_content(events)
481
+ css_class = "chat-content"
482
+ self._displayed_events = current_event_ids
483
+
484
+ chat_display = self.query_one("#chat_display", Static)
485
+ chat_display.update(content)
486
+
487
+ chat_display.set_classes(css_class)
488
+
489
+ if is_at_bottom:
490
+ self.call_later(chat_history.scroll_end, animate=False)
491
+
492
+ def _get_chat_placeholder_content(
493
+ self, message: str, placeholder_class: str
494
+ ) -> tuple[str, str]:
495
+ self._displayed_events = [placeholder_class]
496
+ return message, f"chat-placeholder {placeholder_class}"
497
+
498
+ def _get_rendered_events_content(self, events: list[dict[str, Any]]) -> str:
499
+ if not events:
500
+ return ""
501
+
502
+ content_lines = []
503
+ for event in events:
504
+ if event["type"] == "chat":
505
+ chat_content = self._render_chat_content(event["data"])
506
+ if chat_content:
507
+ content_lines.append(chat_content)
508
+ elif event["type"] == "tool":
509
+ tool_content = self._render_tool_content_simple(event["data"])
510
+ if tool_content:
511
+ content_lines.append(tool_content)
512
+
513
+ return "\n\n".join(content_lines)
514
+
515
+ def _update_agent_status_display(self) -> None:
516
+ try:
517
+ status_display = self.query_one("#agent_status_display", Horizontal)
518
+ status_text = self.query_one("#status_text", Static)
519
+ keymap_indicator = self.query_one("#keymap_indicator", Static)
520
+ except (ValueError, Exception):
521
+ return
522
+
523
+ widgets = [status_display, status_text, keymap_indicator]
524
+ if not all(self._is_widget_safe(w) for w in widgets):
525
+ return
526
+
527
+ if not self.selected_agent_id:
528
+ self._safe_widget_operation(status_display.add_class, "hidden")
529
+ return
530
+
531
+ try:
532
+ agent_data = self.tracer.agents[self.selected_agent_id]
533
+ status = agent_data.get("status", "running")
534
+
535
+ if status == "stopping":
536
+ self._safe_widget_operation(status_text.update, "Agent stopping...")
537
+ self._safe_widget_operation(keymap_indicator.update, "")
538
+ self._safe_widget_operation(status_display.remove_class, "hidden")
539
+ elif status == "stopped":
540
+ self._safe_widget_operation(status_text.update, "Agent stopped")
541
+ self._safe_widget_operation(keymap_indicator.update, "")
542
+ self._safe_widget_operation(status_display.remove_class, "hidden")
543
+ elif status == "completed":
544
+ self._safe_widget_operation(status_text.update, "Agent completed")
545
+ self._safe_widget_operation(keymap_indicator.update, "")
546
+ self._safe_widget_operation(status_display.remove_class, "hidden")
547
+ elif status == "waiting":
548
+ animated_text = self._get_animated_waiting_text(self.selected_agent_id)
549
+ self._safe_widget_operation(status_text.update, animated_text)
550
+ self._safe_widget_operation(
551
+ keymap_indicator.update, "[dim]Send message to resume[/dim]"
552
+ )
553
+ self._safe_widget_operation(status_display.remove_class, "hidden")
554
+ self._start_dot_animation()
555
+ elif status == "running":
556
+ current_verb = self._get_agent_verb(self.selected_agent_id)
557
+ animated_text = self._get_animated_verb_text(self.selected_agent_id, current_verb)
558
+ self._safe_widget_operation(status_text.update, animated_text)
559
+ self._safe_widget_operation(keymap_indicator.update, "[dim]ESC to stop agent[/dim]")
560
+ self._safe_widget_operation(status_display.remove_class, "hidden")
561
+ self._start_dot_animation()
562
+ else:
563
+ self._safe_widget_operation(status_display.add_class, "hidden")
564
+
565
+ except (KeyError, Exception):
566
+ self._safe_widget_operation(status_display.add_class, "hidden")
567
+
568
+ def _get_agent_verb(self, agent_id: str) -> str:
569
+ if agent_id not in self._agent_verbs:
570
+ self._agent_verbs[agent_id] = random.choice(self._action_verbs) # nosec B311 # noqa: S311
571
+ return self._agent_verbs[agent_id]
572
+
573
+ def _start_agent_verb_timer(self, agent_id: str) -> None:
574
+ if agent_id not in self._agent_verb_timers:
575
+ self._agent_verb_timers[agent_id] = self.set_interval(
576
+ 30.0, lambda: self._change_agent_action_verb(agent_id)
577
+ )
578
+
579
+ def _stop_agent_verb_timer(self, agent_id: str) -> None:
580
+ if agent_id in self._agent_verb_timers:
581
+ self._agent_verb_timers[agent_id].stop()
582
+ del self._agent_verb_timers[agent_id]
583
+
584
+ def _change_agent_action_verb(self, agent_id: str) -> None:
585
+ if agent_id not in self._agent_verbs:
586
+ self._agent_verbs[agent_id] = random.choice(self._action_verbs) # nosec B311 # noqa: S311
587
+ return
588
+
589
+ current_verb = self._agent_verbs[agent_id]
590
+ available_verbs = [verb for verb in self._action_verbs if verb != current_verb]
591
+ self._agent_verbs[agent_id] = random.choice(available_verbs) # nosec B311 # noqa: S311
592
+
593
+ if self.selected_agent_id == agent_id:
594
+ self._update_agent_status_display()
595
+
596
+ def _get_animated_verb_text(self, agent_id: str, verb: str) -> str:
597
+ if agent_id not in self._agent_dot_states:
598
+ self._agent_dot_states[agent_id] = 0
599
+
600
+ dot_count = self._agent_dot_states[agent_id]
601
+ dots = "." * dot_count
602
+ return f"{verb}{dots}"
603
+
604
+ def _get_animated_waiting_text(self, agent_id: str) -> str:
605
+ if agent_id not in self._agent_dot_states:
606
+ self._agent_dot_states[agent_id] = 0
607
+
608
+ dot_count = self._agent_dot_states[agent_id]
609
+ dots = "." * dot_count
610
+
611
+ return f"Waiting{dots}"
612
+
613
+ def _start_dot_animation(self) -> None:
614
+ if self._dot_animation_timer is None:
615
+ self._dot_animation_timer = self.set_interval(0.6, self._animate_dots)
616
+
617
+ def _stop_dot_animation(self) -> None:
618
+ if self._dot_animation_timer is not None:
619
+ self._dot_animation_timer.stop()
620
+ self._dot_animation_timer = None
621
+
622
+ def _animate_dots(self) -> None:
623
+ has_active_agents = False
624
+
625
+ for agent_id, agent_data in self.tracer.agents.items():
626
+ status = agent_data.get("status", "running")
627
+ if status in ["running", "waiting"]:
628
+ has_active_agents = True
629
+ current_dots = self._agent_dot_states.get(agent_id, 0)
630
+ self._agent_dot_states[agent_id] = (current_dots + 1) % 4
631
+
632
+ if (
633
+ has_active_agents
634
+ and self.selected_agent_id
635
+ and self.selected_agent_id in self.tracer.agents
636
+ ):
637
+ selected_status = self.tracer.agents[self.selected_agent_id].get("status", "running")
638
+ if selected_status in ["running", "waiting"]:
639
+ self._update_agent_status_display()
640
+
641
+ if not has_active_agents:
642
+ self._stop_dot_animation()
643
+ for agent_id in list(self._agent_dot_states.keys()):
644
+ if agent_id not in self.tracer.agents or self.tracer.agents[agent_id].get(
645
+ "status"
646
+ ) not in ["running", "waiting"]:
647
+ del self._agent_dot_states[agent_id]
648
+
649
+ def _gather_agent_events(self, agent_id: str) -> list[dict[str, Any]]:
650
+ chat_events = [
651
+ {
652
+ "type": "chat",
653
+ "timestamp": msg["timestamp"],
654
+ "id": f"chat_{msg['message_id']}",
655
+ "data": msg,
656
+ }
657
+ for msg in self.tracer.chat_messages
658
+ if msg.get("agent_id") == agent_id
659
+ ]
660
+
661
+ tool_events = [
662
+ {
663
+ "type": "tool",
664
+ "timestamp": tool_data["timestamp"],
665
+ "id": f"tool_{exec_id}",
666
+ "data": tool_data,
667
+ }
668
+ for exec_id, tool_data in self.tracer.tool_executions.items()
669
+ if tool_data.get("agent_id") == agent_id
670
+ ]
671
+
672
+ events = chat_events + tool_events
673
+ events.sort(key=lambda e: (e["timestamp"], e["id"]))
674
+ return events
675
+
676
+ def watch_selected_agent_id(self, _agent_id: str | None) -> None:
677
+ if len(self.screen_stack) > 1 or self.show_splash:
678
+ return
679
+
680
+ if not self.is_mounted:
681
+ return
682
+
683
+ self._displayed_events.clear()
684
+
685
+ self.call_later(self._update_chat_view)
686
+ self._update_agent_status_display()
687
+
688
+ def _start_scan_thread(self) -> None:
689
+ def scan_target() -> None:
690
+ try:
691
+ loop = asyncio.new_event_loop()
692
+ asyncio.set_event_loop(loop)
693
+
694
+ try:
695
+ agent = StrixAgent(self.agent_config)
696
+
697
+ if not self._scan_stop_event.is_set():
698
+ loop.run_until_complete(agent.execute_scan(self.scan_config))
699
+
700
+ except (KeyboardInterrupt, asyncio.CancelledError):
701
+ logging.info("Scan interrupted by user")
702
+ except (ConnectionError, TimeoutError):
703
+ logging.exception("Network error during scan")
704
+ except RuntimeError:
705
+ logging.exception("Runtime error during scan")
706
+ except Exception:
707
+ logging.exception("Unexpected error during scan")
708
+ finally:
709
+ loop.close()
710
+ self._scan_completed.set()
711
+
712
+ except Exception:
713
+ logging.exception("Error setting up scan thread")
714
+ self._scan_completed.set()
715
+
716
+ self._scan_thread = threading.Thread(target=scan_target, daemon=True)
717
+ self._scan_thread.start()
718
+
719
+ def _add_agent_node(self, agent_data: dict[str, Any]) -> None:
720
+ if len(self.screen_stack) > 1 or self.show_splash:
721
+ return
722
+
723
+ if not self.is_mounted:
724
+ return
725
+
726
+ agent_id = agent_data["id"]
727
+ parent_id = agent_data.get("parent_id")
728
+ status = agent_data.get("status", "running")
729
+
730
+ try:
731
+ agents_tree = self.query_one("#agents_tree", Tree)
732
+ except (ValueError, Exception):
733
+ return
734
+
735
+ agent_name_raw = agent_data.get("name", "Agent")
736
+
737
+ status_indicators = {
738
+ "running": "🟢",
739
+ "waiting": "🟡",
740
+ "completed": "✅",
741
+ "failed": "❌",
742
+ "stopped": "⏹️",
743
+ "stopping": "⏸️",
744
+ }
745
+
746
+ status_icon = status_indicators.get(status, "🔵")
747
+ agent_name = f"{status_icon} {escape_markup(agent_name_raw)}"
748
+
749
+ if status in ["running", "waiting"]:
750
+ self._start_agent_verb_timer(agent_id)
751
+
752
+ try:
753
+ if parent_id and parent_id in self.agent_nodes:
754
+ parent_node = self.agent_nodes[parent_id]
755
+ agent_node = parent_node.add(
756
+ agent_name,
757
+ data={"agent_id": agent_id},
758
+ )
759
+ parent_node.allow_expand = True
760
+ else:
761
+ agent_node = agents_tree.root.add(
762
+ agent_name,
763
+ data={"agent_id": agent_id},
764
+ )
765
+
766
+ agent_node.allow_expand = False
767
+ agent_node.expand()
768
+ self.agent_nodes[agent_id] = agent_node
769
+
770
+ if len(self.agent_nodes) == 1:
771
+ agents_tree.select_node(agent_node)
772
+ self.selected_agent_id = agent_id
773
+
774
+ self._reorganize_orphaned_agents(agent_id)
775
+ except (AttributeError, ValueError, RuntimeError) as e:
776
+ import logging
777
+
778
+ logging.warning(f"Failed to add agent node {agent_id}: {e}")
779
+
780
+ def _expand_all_agent_nodes(self) -> None:
781
+ if len(self.screen_stack) > 1 or self.show_splash:
782
+ return
783
+
784
+ if not self.is_mounted:
785
+ return
786
+
787
+ try:
788
+ agents_tree = self.query_one("#agents_tree", Tree)
789
+ self._expand_node_recursively(agents_tree.root)
790
+ except (ValueError, Exception):
791
+ logging.debug("Tree not ready for expanding nodes")
792
+
793
+ def _expand_node_recursively(self, node: TreeNode) -> None:
794
+ if not node.is_expanded:
795
+ node.expand()
796
+ for child in node.children:
797
+ self._expand_node_recursively(child)
798
+
799
+ def _copy_node_under(self, node_to_copy: TreeNode, new_parent: TreeNode) -> None:
800
+ agent_id = node_to_copy.data["agent_id"]
801
+ agent_data = self.tracer.agents.get(agent_id, {})
802
+ agent_name_raw = agent_data.get("name", "Agent")
803
+ status = agent_data.get("status", "running")
804
+
805
+ status_indicators = {
806
+ "running": "🟢",
807
+ "waiting": "🟡",
808
+ "completed": "✅",
809
+ "failed": "❌",
810
+ "stopped": "⏹️",
811
+ "stopping": "⏸️",
812
+ }
813
+
814
+ status_icon = status_indicators.get(status, "🔵")
815
+ agent_name = f"{status_icon} {escape_markup(agent_name_raw)}"
816
+
817
+ new_node = new_parent.add(
818
+ agent_name,
819
+ data=node_to_copy.data,
820
+ )
821
+ new_node.allow_expand = node_to_copy.allow_expand
822
+
823
+ self.agent_nodes[agent_id] = new_node
824
+
825
+ for child in node_to_copy.children:
826
+ self._copy_node_under(child, new_node)
827
+
828
+ if node_to_copy.is_expanded:
829
+ new_node.expand()
830
+
831
+ def _reorganize_orphaned_agents(self, new_parent_id: str) -> None:
832
+ agents_to_move = []
833
+
834
+ for agent_id, agent_data in self.tracer.agents.items():
835
+ if (
836
+ agent_data.get("parent_id") == new_parent_id
837
+ and agent_id in self.agent_nodes
838
+ and agent_id != new_parent_id
839
+ ):
840
+ agents_to_move.append(agent_id)
841
+
842
+ if not agents_to_move:
843
+ return
844
+
845
+ parent_node = self.agent_nodes[new_parent_id]
846
+
847
+ for child_agent_id in agents_to_move:
848
+ if child_agent_id in self.agent_nodes:
849
+ old_node = self.agent_nodes[child_agent_id]
850
+
851
+ if old_node.parent is parent_node:
852
+ continue
853
+
854
+ self._copy_node_under(old_node, parent_node)
855
+
856
+ old_node.remove()
857
+
858
+ parent_node.allow_expand = True
859
+ self._expand_all_agent_nodes()
860
+
861
+ def _render_chat_content(self, msg_data: dict[str, Any]) -> str:
862
+ role = msg_data.get("role")
863
+ content = escape_markup(msg_data.get("content", ""))
864
+
865
+ if not content:
866
+ return ""
867
+
868
+ if role == "user":
869
+ from strix.cli.tool_components.user_message_renderer import UserMessageRenderer
870
+
871
+ return UserMessageRenderer.render_simple(content)
872
+ return content
873
+
874
+ def _render_tool_content_simple(self, tool_data: dict[str, Any]) -> str:
875
+ tool_name = tool_data.get("tool_name", "Unknown Tool")
876
+ args = tool_data.get("args", {})
877
+ status = tool_data.get("status", "unknown")
878
+ result = tool_data.get("result")
879
+
880
+ tool_colors = {
881
+ "terminal_action": "#22c55e",
882
+ "browser_action": "#06b6d4",
883
+ "python_action": "#3b82f6",
884
+ "agents_graph_action": "#fbbf24",
885
+ "file_edit_action": "#10b981",
886
+ "proxy_action": "#06b6d4",
887
+ "notes_action": "#fbbf24",
888
+ "thinking_action": "#a855f7",
889
+ "web_search_action": "#22c55e",
890
+ "finish_action": "#dc2626",
891
+ "reporting_action": "#ea580c",
892
+ "scan_start_info": "#22c55e",
893
+ "subagent_start_info": "#22c55e",
894
+ }
895
+
896
+ color = tool_colors.get(tool_name, "#737373")
897
+
898
+ from strix.cli.tool_components.registry import get_tool_renderer
899
+
900
+ renderer = get_tool_renderer(tool_name)
901
+
902
+ if renderer:
903
+ widget = renderer.render(tool_data)
904
+ content = str(widget.renderable)
905
+ else:
906
+ status_icons = {
907
+ "running": "[yellow]●[/yellow]",
908
+ "completed": "[green]✓[/green]",
909
+ "failed": "[red]✗[/red]",
910
+ "error": "[red]✗[/red]",
911
+ }
912
+ status_icon = status_icons.get(status, "[dim]○[/dim]")
913
+
914
+ lines = [f"→ Using tool [bold blue]{escape_markup(tool_name)}[/] {status_icon}"]
915
+
916
+ if args:
917
+ for k, v in list(args.items())[:2]:
918
+ str_v = str(v)
919
+ if len(str_v) > 80:
920
+ str_v = str_v[:77] + "..."
921
+ lines.append(f" [dim]{k}:[/] {escape_markup(str_v)}")
922
+
923
+ if status in ["completed", "failed", "error"] and result:
924
+ result_str = str(result)
925
+ if len(result_str) > 150:
926
+ result_str = result_str[:147] + "..."
927
+ lines.append(f"[bold]Result:[/] {escape_markup(result_str)}")
928
+
929
+ content = "\n".join(lines)
930
+
931
+ lines = content.split("\n")
932
+ bordered_lines = [f"[{color}]▍[/{color}] {line}" for line in lines]
933
+ return "\n".join(bordered_lines)
934
+
935
+ @on(Tree.NodeHighlighted) # type: ignore[misc]
936
+ def handle_tree_highlight(self, event: Tree.NodeHighlighted) -> None:
937
+ if len(self.screen_stack) > 1 or self.show_splash:
938
+ return
939
+
940
+ if not self.is_mounted:
941
+ return
942
+
943
+ node = event.node
944
+
945
+ try:
946
+ agents_tree = self.query_one("#agents_tree", Tree)
947
+ except (ValueError, Exception):
948
+ return
949
+
950
+ if self.focused == agents_tree and node.data:
951
+ agent_id = node.data.get("agent_id")
952
+ if agent_id:
953
+ self.selected_agent_id = agent_id
954
+
955
+ def _send_user_message(self, message: str) -> None:
956
+ if not self.selected_agent_id:
957
+ return
958
+
959
+ if self.tracer:
960
+ self.tracer.log_chat_message(
961
+ content=message,
962
+ role="user",
963
+ agent_id=self.selected_agent_id,
964
+ )
965
+
966
+ try:
967
+ from strix.tools.agents_graph.agents_graph_actions import send_user_message_to_agent
968
+
969
+ send_user_message_to_agent(self.selected_agent_id, message)
970
+
971
+ except (ImportError, AttributeError) as e:
972
+ import logging
973
+
974
+ logging.warning(f"Failed to send message to agent {self.selected_agent_id}: {e}")
975
+
976
+ self._displayed_events.clear()
977
+ self._update_chat_view()
978
+
979
+ self.call_after_refresh(self._focus_chat_input)
980
+
981
+ def _get_agent_name(self, agent_id: str) -> str:
982
+ try:
983
+ if self.tracer and agent_id in self.tracer.agents:
984
+ agent_name = self.tracer.agents[agent_id].get("name")
985
+ if isinstance(agent_name, str):
986
+ return agent_name
987
+ except (KeyError, AttributeError) as e:
988
+ logging.warning(f"Could not retrieve agent name for {agent_id}: {e}")
989
+ return "Unknown Agent"
990
+
991
+ def action_toggle_help(self) -> None:
992
+ if self.show_splash or not self.is_mounted:
993
+ return
994
+
995
+ try:
996
+ self.query_one("#main_container")
997
+ except (ValueError, Exception):
998
+ return
999
+
1000
+ if isinstance(self.screen, HelpScreen):
1001
+ self.pop_screen()
1002
+ return
1003
+
1004
+ if len(self.screen_stack) > 1:
1005
+ return
1006
+
1007
+ self.push_screen(HelpScreen())
1008
+
1009
+ def action_request_quit(self) -> None:
1010
+ if self.show_splash or not self.is_mounted:
1011
+ self.action_custom_quit()
1012
+ return
1013
+
1014
+ if len(self.screen_stack) > 1:
1015
+ return
1016
+
1017
+ try:
1018
+ self.query_one("#main_container")
1019
+ except (ValueError, Exception):
1020
+ self.action_custom_quit()
1021
+ return
1022
+
1023
+ self.push_screen(QuitScreen())
1024
+
1025
+ def action_stop_selected_agent(self) -> None:
1026
+ if (
1027
+ self.show_splash
1028
+ or not self.is_mounted
1029
+ or len(self.screen_stack) > 1
1030
+ or not self.selected_agent_id
1031
+ ):
1032
+ return
1033
+
1034
+ agent_name, should_stop = self._validate_agent_for_stopping()
1035
+ if not should_stop:
1036
+ return
1037
+
1038
+ try:
1039
+ self.query_one("#main_container")
1040
+ except (ValueError, Exception):
1041
+ return
1042
+
1043
+ self.push_screen(StopAgentScreen(agent_name, self.selected_agent_id))
1044
+
1045
+ def _validate_agent_for_stopping(self) -> tuple[str, bool]:
1046
+ agent_name = "Unknown Agent"
1047
+
1048
+ try:
1049
+ if self.tracer and self.selected_agent_id in self.tracer.agents:
1050
+ agent_data = self.tracer.agents[self.selected_agent_id]
1051
+ agent_name = agent_data.get("name", "Unknown Agent")
1052
+
1053
+ agent_status = agent_data.get("status", "running")
1054
+ if agent_status not in ["running"]:
1055
+ return agent_name, False
1056
+
1057
+ agent_events = self._gather_agent_events(self.selected_agent_id)
1058
+ if not agent_events:
1059
+ return agent_name, False
1060
+
1061
+ return agent_name, True
1062
+
1063
+ except (KeyError, AttributeError, ValueError) as e:
1064
+ import logging
1065
+
1066
+ logging.warning(f"Failed to gather agent events: {e}")
1067
+
1068
+ return agent_name, False
1069
+
1070
+ def action_confirm_stop_agent(self, agent_id: str) -> None:
1071
+ self.pop_screen()
1072
+
1073
+ try:
1074
+ from strix.tools.agents_graph.agents_graph_actions import stop_agent
1075
+
1076
+ result = stop_agent(agent_id)
1077
+
1078
+ import logging
1079
+
1080
+ if result.get("success"):
1081
+ logging.info(f"Stop request sent to agent: {result.get('message', 'Unknown')}")
1082
+ else:
1083
+ logging.warning(f"Failed to stop agent: {result.get('error', 'Unknown error')}")
1084
+
1085
+ except Exception:
1086
+ import logging
1087
+
1088
+ logging.exception(f"Failed to stop agent {agent_id}")
1089
+
1090
+ def action_custom_quit(self) -> None:
1091
+ for agent_id in list(self._agent_verb_timers.keys()):
1092
+ self._stop_agent_verb_timer(agent_id)
1093
+
1094
+ if self._scan_thread and self._scan_thread.is_alive():
1095
+ self._scan_stop_event.set()
1096
+
1097
+ self._scan_thread.join(timeout=1.0)
1098
+
1099
+ self.tracer.cleanup()
1100
+
1101
+ self.exit()
1102
+
1103
+ def _is_widget_safe(self, widget: Any) -> bool:
1104
+ try:
1105
+ _ = widget.screen
1106
+ except (AttributeError, ValueError, Exception):
1107
+ return False
1108
+ else:
1109
+ return bool(widget.is_mounted)
1110
+
1111
+ def _safe_widget_operation(
1112
+ self, operation: Callable[..., Any], *args: Any, **kwargs: Any
1113
+ ) -> bool:
1114
+ try:
1115
+ operation(*args, **kwargs)
1116
+ except (AttributeError, ValueError, Exception):
1117
+ return False
1118
+ else:
1119
+ return True
1120
+
1121
+
1122
+ async def run_strix_cli(args: argparse.Namespace) -> None:
1123
+ app = StrixCLIApp(args)
1124
+ await app.run_async()