kollabor 0.4.9__py3-none-any.whl → 0.4.15__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 (192) hide show
  1. agents/__init__.py +2 -0
  2. agents/coder/__init__.py +0 -0
  3. agents/coder/agent.json +4 -0
  4. agents/coder/api-integration.md +2150 -0
  5. agents/coder/cli-pretty.md +765 -0
  6. agents/coder/code-review.md +1092 -0
  7. agents/coder/database-design.md +1525 -0
  8. agents/coder/debugging.md +1102 -0
  9. agents/coder/dependency-management.md +1397 -0
  10. agents/coder/git-workflow.md +1099 -0
  11. agents/coder/refactoring.md +1454 -0
  12. agents/coder/security-hardening.md +1732 -0
  13. agents/coder/system_prompt.md +1448 -0
  14. agents/coder/tdd.md +1367 -0
  15. agents/creative-writer/__init__.py +0 -0
  16. agents/creative-writer/agent.json +4 -0
  17. agents/creative-writer/character-development.md +1852 -0
  18. agents/creative-writer/dialogue-craft.md +1122 -0
  19. agents/creative-writer/plot-structure.md +1073 -0
  20. agents/creative-writer/revision-editing.md +1484 -0
  21. agents/creative-writer/system_prompt.md +690 -0
  22. agents/creative-writer/worldbuilding.md +2049 -0
  23. agents/data-analyst/__init__.py +30 -0
  24. agents/data-analyst/agent.json +4 -0
  25. agents/data-analyst/data-visualization.md +992 -0
  26. agents/data-analyst/exploratory-data-analysis.md +1110 -0
  27. agents/data-analyst/pandas-data-manipulation.md +1081 -0
  28. agents/data-analyst/sql-query-optimization.md +881 -0
  29. agents/data-analyst/statistical-analysis.md +1118 -0
  30. agents/data-analyst/system_prompt.md +928 -0
  31. agents/default/__init__.py +0 -0
  32. agents/default/agent.json +4 -0
  33. agents/default/dead-code.md +794 -0
  34. agents/default/explore-agent-system.md +585 -0
  35. agents/default/system_prompt.md +1448 -0
  36. agents/kollabor/__init__.py +0 -0
  37. agents/kollabor/analyze-plugin-lifecycle.md +175 -0
  38. agents/kollabor/analyze-terminal-rendering.md +388 -0
  39. agents/kollabor/code-review.md +1092 -0
  40. agents/kollabor/debug-mcp-integration.md +521 -0
  41. agents/kollabor/debug-plugin-hooks.md +547 -0
  42. agents/kollabor/debugging.md +1102 -0
  43. agents/kollabor/dependency-management.md +1397 -0
  44. agents/kollabor/git-workflow.md +1099 -0
  45. agents/kollabor/inspect-llm-conversation.md +148 -0
  46. agents/kollabor/monitor-event-bus.md +558 -0
  47. agents/kollabor/profile-performance.md +576 -0
  48. agents/kollabor/refactoring.md +1454 -0
  49. agents/kollabor/system_prompt copy.md +1448 -0
  50. agents/kollabor/system_prompt.md +757 -0
  51. agents/kollabor/trace-command-execution.md +178 -0
  52. agents/kollabor/validate-config.md +879 -0
  53. agents/research/__init__.py +0 -0
  54. agents/research/agent.json +4 -0
  55. agents/research/architecture-mapping.md +1099 -0
  56. agents/research/codebase-analysis.md +1077 -0
  57. agents/research/dependency-audit.md +1027 -0
  58. agents/research/performance-profiling.md +1047 -0
  59. agents/research/security-review.md +1359 -0
  60. agents/research/system_prompt.md +492 -0
  61. agents/technical-writer/__init__.py +0 -0
  62. agents/technical-writer/agent.json +4 -0
  63. agents/technical-writer/api-documentation.md +2328 -0
  64. agents/technical-writer/changelog-management.md +1181 -0
  65. agents/technical-writer/readme-writing.md +1360 -0
  66. agents/technical-writer/style-guide.md +1410 -0
  67. agents/technical-writer/system_prompt.md +653 -0
  68. agents/technical-writer/tutorial-creation.md +1448 -0
  69. core/__init__.py +0 -2
  70. core/application.py +343 -88
  71. core/cli.py +229 -10
  72. core/commands/menu_renderer.py +463 -59
  73. core/commands/registry.py +14 -9
  74. core/commands/system_commands.py +2461 -14
  75. core/config/loader.py +151 -37
  76. core/config/service.py +18 -6
  77. core/events/bus.py +29 -9
  78. core/events/executor.py +205 -75
  79. core/events/models.py +27 -8
  80. core/fullscreen/command_integration.py +20 -24
  81. core/fullscreen/components/__init__.py +10 -1
  82. core/fullscreen/components/matrix_components.py +1 -2
  83. core/fullscreen/components/space_shooter_components.py +654 -0
  84. core/fullscreen/plugin.py +5 -0
  85. core/fullscreen/renderer.py +52 -13
  86. core/fullscreen/session.py +52 -15
  87. core/io/__init__.py +29 -5
  88. core/io/buffer_manager.py +6 -1
  89. core/io/config_status_view.py +7 -29
  90. core/io/core_status_views.py +267 -347
  91. core/io/input/__init__.py +25 -0
  92. core/io/input/command_mode_handler.py +711 -0
  93. core/io/input/display_controller.py +128 -0
  94. core/io/input/hook_registrar.py +286 -0
  95. core/io/input/input_loop_manager.py +421 -0
  96. core/io/input/key_press_handler.py +502 -0
  97. core/io/input/modal_controller.py +1011 -0
  98. core/io/input/paste_processor.py +339 -0
  99. core/io/input/status_modal_renderer.py +184 -0
  100. core/io/input_errors.py +5 -1
  101. core/io/input_handler.py +211 -2452
  102. core/io/key_parser.py +7 -0
  103. core/io/layout.py +15 -3
  104. core/io/message_coordinator.py +111 -2
  105. core/io/message_renderer.py +129 -4
  106. core/io/status_renderer.py +147 -607
  107. core/io/terminal_renderer.py +97 -51
  108. core/io/terminal_state.py +21 -4
  109. core/io/visual_effects.py +816 -165
  110. core/llm/agent_manager.py +1063 -0
  111. core/llm/api_adapters/__init__.py +44 -0
  112. core/llm/api_adapters/anthropic_adapter.py +432 -0
  113. core/llm/api_adapters/base.py +241 -0
  114. core/llm/api_adapters/openai_adapter.py +326 -0
  115. core/llm/api_communication_service.py +167 -113
  116. core/llm/conversation_logger.py +322 -16
  117. core/llm/conversation_manager.py +556 -30
  118. core/llm/file_operations_executor.py +84 -32
  119. core/llm/llm_service.py +934 -103
  120. core/llm/mcp_integration.py +541 -57
  121. core/llm/message_display_service.py +135 -18
  122. core/llm/plugin_sdk.py +1 -2
  123. core/llm/profile_manager.py +1183 -0
  124. core/llm/response_parser.py +274 -56
  125. core/llm/response_processor.py +16 -3
  126. core/llm/tool_executor.py +6 -1
  127. core/logging/__init__.py +2 -0
  128. core/logging/setup.py +34 -6
  129. core/models/resume.py +54 -0
  130. core/plugins/__init__.py +4 -2
  131. core/plugins/base.py +127 -0
  132. core/plugins/collector.py +23 -161
  133. core/plugins/discovery.py +37 -3
  134. core/plugins/factory.py +6 -12
  135. core/plugins/registry.py +5 -17
  136. core/ui/config_widgets.py +128 -28
  137. core/ui/live_modal_renderer.py +2 -1
  138. core/ui/modal_actions.py +5 -0
  139. core/ui/modal_overlay_renderer.py +0 -60
  140. core/ui/modal_renderer.py +268 -7
  141. core/ui/modal_state_manager.py +29 -4
  142. core/ui/widgets/base_widget.py +7 -0
  143. core/updates/__init__.py +10 -0
  144. core/updates/version_check_service.py +348 -0
  145. core/updates/version_comparator.py +103 -0
  146. core/utils/config_utils.py +685 -526
  147. core/utils/plugin_utils.py +1 -1
  148. core/utils/session_naming.py +111 -0
  149. fonts/LICENSE +21 -0
  150. fonts/README.md +46 -0
  151. fonts/SymbolsNerdFont-Regular.ttf +0 -0
  152. fonts/SymbolsNerdFontMono-Regular.ttf +0 -0
  153. fonts/__init__.py +44 -0
  154. {kollabor-0.4.9.dist-info → kollabor-0.4.15.dist-info}/METADATA +54 -4
  155. kollabor-0.4.15.dist-info/RECORD +228 -0
  156. {kollabor-0.4.9.dist-info → kollabor-0.4.15.dist-info}/top_level.txt +2 -0
  157. plugins/agent_orchestrator/__init__.py +39 -0
  158. plugins/agent_orchestrator/activity_monitor.py +181 -0
  159. plugins/agent_orchestrator/file_attacher.py +77 -0
  160. plugins/agent_orchestrator/message_injector.py +135 -0
  161. plugins/agent_orchestrator/models.py +48 -0
  162. plugins/agent_orchestrator/orchestrator.py +403 -0
  163. plugins/agent_orchestrator/plugin.py +976 -0
  164. plugins/agent_orchestrator/xml_parser.py +191 -0
  165. plugins/agent_orchestrator_plugin.py +9 -0
  166. plugins/enhanced_input/box_styles.py +1 -0
  167. plugins/enhanced_input/color_engine.py +19 -4
  168. plugins/enhanced_input/config.py +2 -2
  169. plugins/enhanced_input_plugin.py +61 -11
  170. plugins/fullscreen/__init__.py +6 -2
  171. plugins/fullscreen/example_plugin.py +1035 -222
  172. plugins/fullscreen/setup_wizard_plugin.py +592 -0
  173. plugins/fullscreen/space_shooter_plugin.py +131 -0
  174. plugins/hook_monitoring_plugin.py +436 -78
  175. plugins/query_enhancer_plugin.py +66 -30
  176. plugins/resume_conversation_plugin.py +1494 -0
  177. plugins/save_conversation_plugin.py +98 -32
  178. plugins/system_commands_plugin.py +70 -56
  179. plugins/tmux_plugin.py +154 -78
  180. plugins/workflow_enforcement_plugin.py +94 -92
  181. system_prompt/default.md +952 -886
  182. core/io/input_mode_manager.py +0 -402
  183. core/io/modal_interaction_handler.py +0 -315
  184. core/io/raw_input_processor.py +0 -946
  185. core/storage/__init__.py +0 -5
  186. core/storage/state_manager.py +0 -84
  187. core/ui/widget_integration.py +0 -222
  188. core/utils/key_reader.py +0 -171
  189. kollabor-0.4.9.dist-info/RECORD +0 -128
  190. {kollabor-0.4.9.dist-info → kollabor-0.4.15.dist-info}/WHEEL +0 -0
  191. {kollabor-0.4.9.dist-info → kollabor-0.4.15.dist-info}/entry_points.txt +0 -0
  192. {kollabor-0.4.9.dist-info → kollabor-0.4.15.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,976 @@
1
+ """Agent Orchestrator Plugin for spawning and managing parallel kollab sub-agents.
2
+
3
+ This plugin enables the LLM to spawn sub-agents via XML commands in its responses.
4
+ Sub-agents run in tmux sessions and are monitored for completion via MD5 hashing.
5
+ """
6
+
7
+ import argparse
8
+ import asyncio
9
+ import logging
10
+ import re
11
+ import sys
12
+ import tempfile
13
+ import json
14
+ from pathlib import Path
15
+ from typing import Dict, Any, Optional
16
+
17
+ from core.plugins.base import BasePlugin
18
+ from core.events.models import EventType, Hook, HookPriority
19
+ from core.io.visual_effects import ColorPalette
20
+ from core.io.message_renderer import DisplayFilterRegistry, MessageType
21
+
22
+ from .xml_parser import XMLCommandParser
23
+ from .orchestrator import AgentOrchestrator
24
+ from .activity_monitor import ActivityMonitor
25
+ from .message_injector import MessageInjector
26
+ from .models import AgentTask
27
+
28
+ logger = logging.getLogger(__name__)
29
+
30
+ # Agent orchestration instructions for the LLM
31
+ AGENT_INSTRUCTIONS = """
32
+ ## Agent Orchestration
33
+
34
+ You can spawn parallel sub-agents to work on tasks concurrently. Each agent runs in a separate tmux session.
35
+
36
+ IMPORTANT: When using agent commands, output ONLY the XML tag. Do NOT add any explanation text before or after the command. The system will display the command execution result automatically.
37
+
38
+ ### Spawn Agents
39
+
40
+ ```xml
41
+ <agent>
42
+ <agent-name>
43
+ <task>
44
+ objective: What to accomplish
45
+
46
+ context:
47
+ - Relevant constraints
48
+ - Background info
49
+
50
+ todo:
51
+ [ ] Step 1
52
+ [ ] Step 2
53
+
54
+ success: How to verify completion
55
+ </task>
56
+ <files>
57
+ <file>path/to/file.py</file>
58
+ </files>
59
+ </agent-name>
60
+ </agent>
61
+ ```
62
+
63
+ ### Other Commands
64
+
65
+ ```xml
66
+ <message to="agent-name">Send instruction to running agent</message>
67
+ <capture>agent-name 200</capture>
68
+ <stop>agent-name</stop>
69
+ <status></status>
70
+ ```
71
+
72
+ ### Command Behavior
73
+
74
+ These commands work like tool calls:
75
+ 1. You output the XML command (no additional text)
76
+ 2. System executes and shows: `⏺ spawn_agent(name)` or `⏺ status()`
77
+ 3. Result is injected back to you
78
+ 4. You can then respond to the result if needed
79
+
80
+ Do NOT say things like "I'll spawn an agent" or "I've spawned..." - just output the XML.
81
+
82
+ Files in `<files>` are auto-attached to agent context.
83
+ Agents complete when idle for 6 seconds (MD5 hash unchanged).
84
+ """.strip()
85
+
86
+ # Keywords that trigger instruction injection
87
+ TRIGGER_KEYWORDS = [
88
+ "spawn agent",
89
+ "spawn agents",
90
+ "spawn an agent",
91
+ "spawn one agent",
92
+ "spawn multiple",
93
+ "parallel agent",
94
+ "parallel agents",
95
+ "sub-agent",
96
+ "sub-agents",
97
+ "subagent",
98
+ "subagents",
99
+ "<agent>",
100
+ "tmux agent",
101
+ "agent orchestrat",
102
+ "launch agent",
103
+ "create agent",
104
+ ]
105
+
106
+
107
+ class AgentOrchestratorPlugin(BasePlugin):
108
+ """Plugin for spawning and managing parallel kollab sub-agents."""
109
+
110
+ def __init__(
111
+ self,
112
+ name: str = "agent_orchestrator",
113
+ event_bus=None,
114
+ renderer=None,
115
+ config=None,
116
+ ):
117
+ """Initialize the agent orchestrator plugin.
118
+
119
+ Args:
120
+ name: Plugin name.
121
+ event_bus: Event bus for hook registration.
122
+ renderer: Terminal renderer.
123
+ config: Configuration manager.
124
+ """
125
+ self.name = name
126
+ self.version = "1.0.0"
127
+ self.description = "Spawn and manage parallel kollab sub-agents"
128
+ self.enabled = True
129
+
130
+ self.event_bus = event_bus
131
+ self.renderer = renderer
132
+ self.config = config
133
+ self.command_registry = None
134
+ self.conversation_manager = None
135
+
136
+ # Components (initialized in initialize())
137
+ self.xml_parser = XMLCommandParser()
138
+ self.orchestrator: Optional[AgentOrchestrator] = None
139
+ self.activity_monitor: Optional[ActivityMonitor] = None
140
+ self.message_injector: Optional[MessageInjector] = None
141
+
142
+ self._monitor_task: Optional[asyncio.Task] = None
143
+ self._args: Optional[argparse.Namespace] = None
144
+
145
+ # Tracking for keyword trigger mode
146
+ self._last_injection_time: float = 0.0
147
+ self._message_count_since_injection: int = 0
148
+
149
+ self.logger = logger
150
+
151
+ # -------------------------------------------------------------------------
152
+ # CLI Args (static, called before app init)
153
+ # -------------------------------------------------------------------------
154
+
155
+ @staticmethod
156
+ def register_cli_args(parser: argparse.ArgumentParser) -> None:
157
+ """Register CLI arguments for agent management."""
158
+ group = parser.add_argument_group("Agent Orchestrator")
159
+ group.add_argument(
160
+ "--session",
161
+ type=str,
162
+ metavar="NAME",
163
+ help="Agent session name to interact with",
164
+ )
165
+ group.add_argument(
166
+ "--capture",
167
+ type=int,
168
+ metavar="LINES",
169
+ default=None,
170
+ help="Capture N lines from session (requires --session)",
171
+ )
172
+ group.add_argument(
173
+ "--list-agents",
174
+ action="store_true",
175
+ help="List all active agents and exit",
176
+ )
177
+
178
+ @staticmethod
179
+ def handle_early_args(args: argparse.Namespace) -> bool:
180
+ """Handle args that exit before app starts."""
181
+ project_name = Path.cwd().name
182
+
183
+ if getattr(args, "list_agents", False):
184
+ orchestrator = AgentOrchestrator(project_name=project_name)
185
+ agents = orchestrator.list_agents()
186
+ if agents:
187
+ print("[agents]")
188
+ for agent in agents:
189
+ print(f" {agent.name:<20} {agent.status:<10} {agent.duration}")
190
+ else:
191
+ print("No active agents")
192
+ return True # exit
193
+
194
+ session = getattr(args, "session", None)
195
+ capture = getattr(args, "capture", None)
196
+
197
+ if session and capture:
198
+ orchestrator = AgentOrchestrator(project_name=project_name)
199
+ output = orchestrator.capture_output(session, capture)
200
+ print(output)
201
+ return True # exit
202
+
203
+ return False # continue normal startup
204
+
205
+ # -------------------------------------------------------------------------
206
+ # Plugin Lifecycle
207
+ # -------------------------------------------------------------------------
208
+
209
+ async def initialize(self, args: argparse.Namespace = None, **kwargs) -> None:
210
+ """Initialize plugin components.
211
+
212
+ Args:
213
+ args: Parsed CLI arguments.
214
+ **kwargs: Additional initialization parameters including:
215
+ - event_bus: Event bus for hook registration
216
+ - config: Configuration manager
217
+ - command_registry: Command registry for slash commands
218
+ - conversation_manager: Conversation manager instance
219
+ """
220
+ self._args = args
221
+
222
+ # Get dependencies from kwargs
223
+ if "event_bus" in kwargs:
224
+ self.event_bus = kwargs["event_bus"]
225
+ if "config" in kwargs:
226
+ self.config = kwargs["config"]
227
+ if "command_registry" in kwargs:
228
+ self.command_registry = kwargs["command_registry"]
229
+ if "conversation_manager" in kwargs:
230
+ self.conversation_manager = kwargs["conversation_manager"]
231
+
232
+ # Initialize orchestrator
233
+ self.orchestrator = AgentOrchestrator(project_name=Path.cwd().name)
234
+
235
+ # Initialize message injector if we have conversation manager
236
+ if self.conversation_manager and self.event_bus:
237
+ self.message_injector = MessageInjector(
238
+ event_bus=self.event_bus,
239
+ conversation_manager=self.conversation_manager,
240
+ )
241
+
242
+ # Get config values
243
+ poll_interval = 2
244
+ idle_threshold = 3
245
+ capture_lines = 500
246
+
247
+ if self.config:
248
+ poll_interval = self.config.get("plugins.agent_orchestrator.poll_interval", 2)
249
+ idle_threshold = self.config.get("plugins.agent_orchestrator.idle_threshold", 3)
250
+ capture_lines = self.config.get("plugins.agent_orchestrator.capture_lines", 500)
251
+
252
+ # Initialize activity monitor
253
+ self.activity_monitor = ActivityMonitor(
254
+ orchestrator=self.orchestrator,
255
+ on_agent_complete=self._on_agent_complete,
256
+ poll_interval=poll_interval,
257
+ idle_threshold=idle_threshold,
258
+ capture_lines=capture_lines,
259
+ )
260
+
261
+ # Register display filter to strip orchestrator XML from displayed messages
262
+ DisplayFilterRegistry.register(
263
+ name="agent_orchestrator",
264
+ filter_fn=self._strip_orchestrator_xml,
265
+ message_types=[MessageType.ASSISTANT],
266
+ priority=100,
267
+ )
268
+
269
+ logger.info("Agent orchestrator plugin initialized")
270
+
271
+ async def start_monitor(self) -> None:
272
+ """Start the activity monitor background task."""
273
+ if self.activity_monitor and not self._monitor_task:
274
+ self._monitor_task = asyncio.create_task(self.activity_monitor.start())
275
+ logger.info("Activity monitor started")
276
+
277
+ def shutdown(self) -> None:
278
+ """Cleanup on shutdown."""
279
+ if self._monitor_task:
280
+ self._monitor_task.cancel()
281
+ try:
282
+ # Can't await in sync method, just cancel
283
+ pass
284
+ except Exception:
285
+ pass
286
+
287
+ # Unregister display filter
288
+ DisplayFilterRegistry.unregister("agent_orchestrator")
289
+
290
+ logger.info("Agent orchestrator plugin shutdown")
291
+
292
+ # -------------------------------------------------------------------------
293
+ # Display Filter
294
+ # -------------------------------------------------------------------------
295
+
296
+ def _strip_orchestrator_xml(self, content: str, message_type: MessageType) -> str:
297
+ """Strip orchestrator XML commands from content before display.
298
+
299
+ These commands are displayed separately as tool indicators,
300
+ so we strip them from the prose to avoid duplication.
301
+
302
+ Args:
303
+ content: Message content to filter.
304
+ message_type: Type of message (only ASSISTANT messages filtered).
305
+
306
+ Returns:
307
+ Content with orchestrator XML stripped.
308
+ """
309
+ # Strip <agent>...</agent> blocks
310
+ content = re.sub(r"<agent>.*?</agent>\s*", "", content, flags=re.DOTALL)
311
+
312
+ # Strip <status></status>
313
+ content = re.sub(r"<status>\s*</status>\s*", "", content)
314
+
315
+ # Strip <capture>...</capture>
316
+ content = re.sub(r"<capture>.*?</capture>\s*", "", content, flags=re.DOTALL)
317
+
318
+ # Strip <stop>...</stop>
319
+ content = re.sub(r"<stop>.*?</stop>\s*", "", content, flags=re.DOTALL)
320
+
321
+ # Strip <message to="...">...</message>
322
+ content = re.sub(
323
+ r'<message\s+to=["\'][^"\']+["\']>.*?</message>\s*',
324
+ "",
325
+ content,
326
+ flags=re.DOTALL,
327
+ )
328
+
329
+ # Strip <clone>...</clone>
330
+ content = re.sub(r"<clone>.*?</clone>\s*", "", content, flags=re.DOTALL)
331
+
332
+ # Strip <team ...>...</team>
333
+ content = re.sub(r"<team\s+[^>]*>.*?</team>\s*", "", content, flags=re.DOTALL)
334
+
335
+ # Strip <broadcast ...>...</broadcast>
336
+ content = re.sub(
337
+ r"<broadcast\s+[^>]*>.*?</broadcast>\s*", "", content, flags=re.DOTALL
338
+ )
339
+
340
+ return content
341
+
342
+ # -------------------------------------------------------------------------
343
+ # Hook Registration
344
+ # -------------------------------------------------------------------------
345
+
346
+ async def register_hooks(self) -> None:
347
+ """Register hooks for LLM response processing and keyword triggers."""
348
+ if not self.event_bus:
349
+ logger.warning("No event bus available for hook registration")
350
+ return
351
+
352
+ # Hook for processing XML commands in LLM responses
353
+ response_hook = Hook(
354
+ name="agent_orchestrator_response",
355
+ plugin_name=self.name,
356
+ event_type=EventType.LLM_RESPONSE_POST,
357
+ callback=self._on_llm_response,
358
+ priority=HookPriority.POSTPROCESSING.value,
359
+ )
360
+ await self.event_bus.register_hook(response_hook)
361
+
362
+ # Hook for keyword-triggered instruction injection
363
+ keyword_hook = Hook(
364
+ name="agent_orchestrator_keyword_trigger",
365
+ plugin_name=self.name,
366
+ event_type=EventType.USER_INPUT_PRE,
367
+ callback=self._on_user_input,
368
+ priority=HookPriority.PREPROCESSING.value,
369
+ )
370
+ await self.event_bus.register_hook(keyword_hook)
371
+
372
+ logger.info("Registered agent orchestration hooks")
373
+
374
+ # -------------------------------------------------------------------------
375
+ # Default Config
376
+ # -------------------------------------------------------------------------
377
+
378
+ @staticmethod
379
+ def get_default_config() -> Dict[str, Any]:
380
+ """Get default configuration for this plugin."""
381
+ return {
382
+ "plugins": {
383
+ "agent_orchestrator": {
384
+ "enabled": True,
385
+ "poll_interval": 2,
386
+ "idle_threshold": 3,
387
+ "capture_lines": 500,
388
+ "max_concurrent": 10,
389
+ # How to enable agent instructions for LLM:
390
+ # "startup" = inject into system prompt at startup
391
+ # "keyword" = inject when keywords detected in user input
392
+ # "disabled" = don't inject (LLM won't know about agents)
393
+ "enable_mode": "keyword",
394
+ # Trigger delay for keyword mode:
395
+ # "0" = inject once (first trigger only)
396
+ # "60s" = inject every 60 seconds
397
+ # "5m" = inject every 5 messages/turns
398
+ "trigger_delay": "0",
399
+ }
400
+ }
401
+ }
402
+
403
+ def get_system_prompt_addition(self) -> Optional[str]:
404
+ """Get system prompt addition if enable_mode is 'startup'.
405
+
406
+ Returns:
407
+ Agent instructions string, or None if not startup mode.
408
+ """
409
+ if not self.config:
410
+ return None
411
+
412
+ enable_mode = self.config.get("plugins.agent_orchestrator.enable_mode", "keyword")
413
+ if enable_mode == "startup":
414
+ return AGENT_INSTRUCTIONS
415
+ return None
416
+
417
+ def _parse_trigger_delay(self, delay_str: str) -> tuple:
418
+ """Parse trigger delay string.
419
+
420
+ Args:
421
+ delay_str: Delay string like "0", "60s", "5m"
422
+
423
+ Returns:
424
+ Tuple of (delay_type, delay_value) where:
425
+ - ("once", 0) for "0"
426
+ - ("seconds", N) for "Ns"
427
+ - ("messages", N) for "Nm"
428
+ """
429
+ delay_str = delay_str.strip().lower()
430
+
431
+ if delay_str == "0":
432
+ return ("once", 0)
433
+
434
+ if delay_str.endswith("s"):
435
+ try:
436
+ return ("seconds", int(delay_str[:-1]))
437
+ except ValueError:
438
+ return ("once", 0)
439
+
440
+ if delay_str.endswith("m"):
441
+ try:
442
+ return ("messages", int(delay_str[:-1]))
443
+ except ValueError:
444
+ return ("once", 0)
445
+
446
+ return ("once", 0)
447
+
448
+ def _should_inject(self) -> bool:
449
+ """Check if we should inject instructions based on trigger_delay.
450
+
451
+ Returns:
452
+ True if injection should happen.
453
+ """
454
+ import time
455
+
456
+ trigger_delay = self.config.get("plugins.agent_orchestrator.trigger_delay", "0") if self.config else "0"
457
+ delay_type, delay_value = self._parse_trigger_delay(trigger_delay)
458
+
459
+ if delay_type == "once":
460
+ # Only inject if never injected before
461
+ return self._last_injection_time == 0.0
462
+
463
+ elif delay_type == "seconds":
464
+ # Inject if enough time has passed
465
+ elapsed = time.time() - self._last_injection_time
466
+ return elapsed >= delay_value
467
+
468
+ elif delay_type == "messages":
469
+ # Inject if enough messages have passed
470
+ return self._message_count_since_injection >= delay_value
471
+
472
+ return False
473
+
474
+ async def _on_user_input(self, data: dict, event) -> dict:
475
+ """Check for trigger keywords and inject instructions.
476
+
477
+ Args:
478
+ data: Event data with user input.
479
+ event: Event object.
480
+
481
+ Returns:
482
+ Modified data with injected instructions.
483
+ """
484
+ logger.info(f"[AGENT_ORCH] _on_user_input called with data keys: {list(data.keys())}")
485
+ context = data # Alias for compatibility
486
+ import time
487
+
488
+ if not self.config:
489
+ logger.info("[AGENT_ORCH] No config, returning early")
490
+ return context
491
+
492
+ enable_mode = self.config.get("plugins.agent_orchestrator.enable_mode", "keyword")
493
+ if enable_mode != "keyword":
494
+ return context
495
+
496
+ # Increment message count for tracking
497
+ self._message_count_since_injection += 1
498
+
499
+ # The event data uses "message" key, not "input"
500
+ user_input = context.get("message", context.get("input", "")).lower()
501
+ logger.info(f"[AGENT_ORCH] User input: {user_input[:50]}...")
502
+
503
+ # Check for trigger keywords
504
+ triggered = any(kw in user_input for kw in TRIGGER_KEYWORDS)
505
+ logger.info(f"[AGENT_ORCH] Triggered: {triggered}")
506
+
507
+ if triggered and self._should_inject():
508
+ # Inject instructions as sys_msg (hidden from user display)
509
+ original_input = context.get("message", context.get("input", ""))
510
+ injected = f"""<sys_msg>
511
+ {AGENT_INSTRUCTIONS}
512
+ </sys_msg>
513
+
514
+ {original_input}"""
515
+ context["message"] = injected
516
+ if "input" in context:
517
+ context["input"] = injected
518
+
519
+ # Update tracking
520
+ self._last_injection_time = time.time()
521
+ self._message_count_since_injection = 0
522
+
523
+ logger.info("Injected agent orchestration instructions (keyword trigger)")
524
+
525
+ return context
526
+
527
+ # -------------------------------------------------------------------------
528
+ # Core Logic
529
+ # -------------------------------------------------------------------------
530
+
531
+ async def _on_llm_response(self, data: dict, event) -> dict:
532
+ """Process LLM response for agent XML commands.
533
+
534
+ Args:
535
+ data: Event data with response data.
536
+ event: Event object.
537
+
538
+ Returns:
539
+ Modified data.
540
+ """
541
+ context = data # Alias for compatibility
542
+ response_text = context.get("response_text", "")
543
+ if not response_text:
544
+ response_text = context.get("content", "")
545
+
546
+ if not response_text:
547
+ return context
548
+
549
+ # Parse XML commands from response
550
+ commands = self.xml_parser.parse(response_text)
551
+
552
+ if not commands:
553
+ return context # no agent commands
554
+
555
+ logger.info(f"Found {len(commands)} agent command(s) in LLM response")
556
+
557
+ results = []
558
+
559
+ for cmd in commands:
560
+ try:
561
+ # Display tool indicator before execution (like real tool calls)
562
+ self._display_tool_indicator(cmd)
563
+
564
+ result = await self._execute_command(cmd)
565
+ if result:
566
+ results.append(result)
567
+ # No result display - matches tool call pattern where only indicator shows
568
+ # Result gets injected to conversation for LLM to process
569
+ except Exception as e:
570
+ logger.error(f"Error executing agent command: {e}")
571
+ results.append(f"[error: {e}]")
572
+ # Only show errors to the user
573
+ self._display_tool_result(cmd, f"[error: {e}]", is_error=True)
574
+
575
+ # Inject results back to LLM (like tool call results)
576
+ if results:
577
+ result_text = "\n".join(results)
578
+
579
+ # Check if any result is an error (don't continue on errors to prevent loops)
580
+ has_error = any(r.startswith("[error") or "already exists" in r.lower() for r in results)
581
+
582
+ # Inject to conversation history for LLM context
583
+ if self.message_injector:
584
+ await self.message_injector.inject(
585
+ source="agent_orchestrator",
586
+ content=result_text,
587
+ trigger_llm=False, # we'll force continue via context flag
588
+ )
589
+
590
+ # Force continuation for all successful results (spawn, status, capture, etc.)
591
+ # LLM will respond naturally to the tool result
592
+ # Only skip continuation on errors to prevent infinite loops
593
+ if not has_error:
594
+ context["force_continue"] = True
595
+ logger.info("Forcing continuation for orchestrator result")
596
+
597
+ return context
598
+
599
+ def _display_tool_indicator(self, cmd) -> None:
600
+ """Display tool call indicator like real tool calls.
601
+
602
+ Args:
603
+ cmd: Parsed command object.
604
+ """
605
+ if not self.renderer:
606
+ return
607
+
608
+ indicator = f"{ColorPalette.BRIGHT_LIME}⏺{ColorPalette.RESET}"
609
+
610
+ if cmd.type == "agent":
611
+ # Get agent names from the agents list
612
+ names = [a.name for a in cmd.agents] if cmd.agents else []
613
+ args = ", ".join(names) if names else ""
614
+ tool_line = f"{indicator} spawn_agent({args})"
615
+ elif cmd.type == "status":
616
+ tool_line = f"{indicator} status()"
617
+ elif cmd.type == "capture":
618
+ tool_line = f"{indicator} capture({cmd.target}, {cmd.lines})"
619
+ elif cmd.type == "stop":
620
+ targets = ", ".join(cmd.targets) if cmd.targets else ""
621
+ tool_line = f"{indicator} stop({targets})"
622
+ elif cmd.type == "message":
623
+ tool_line = f"{indicator} message({cmd.target})"
624
+ elif cmd.type == "clone":
625
+ names = [a.name for a in cmd.agents] if cmd.agents else []
626
+ tool_line = f"{indicator} clone({names[0] if names else ''})"
627
+ elif cmd.type == "team":
628
+ tool_line = f"{indicator} spawn_team({cmd.lead})"
629
+ elif cmd.type == "broadcast":
630
+ tool_line = f"{indicator} broadcast({cmd.pattern})"
631
+ else:
632
+ tool_line = f"{indicator} {cmd.type}()"
633
+
634
+ # Display using message coordinator
635
+ self.renderer.message_coordinator.display_message_sequence([
636
+ ("system", tool_line, {})
637
+ ])
638
+
639
+ def _display_tool_result(self, cmd, result: str, is_error: bool = False) -> None:
640
+ """Display tool execution result like real tool calls.
641
+
642
+ Args:
643
+ cmd: Parsed command object.
644
+ result: Result string from execution.
645
+ is_error: Whether this is an error result.
646
+ """
647
+ if not self.renderer:
648
+ return
649
+
650
+ if is_error:
651
+ result_line = f"\033[31m ▮ {result}\033[0m"
652
+ else:
653
+ # Format based on command type
654
+ if cmd.type == "agent":
655
+ result_line = f"\033[32m ▮ {result}\033[0m"
656
+ elif cmd.type == "status":
657
+ # Status returns multi-line, show summary
658
+ line_count = result.count('\n') + 1
659
+ result_line = f"\033[32m ▮ Retrieved status ({line_count} lines)\033[0m"
660
+ elif cmd.type == "capture":
661
+ line_count = result.count('\n') + 1
662
+ result_line = f"\033[32m ▮ Captured {line_count} lines\033[0m"
663
+ else:
664
+ result_line = f"\033[32m ▮ {result}\033[0m"
665
+
666
+ self.renderer.message_coordinator.display_message_sequence([
667
+ ("system", result_line, {})
668
+ ])
669
+
670
+ async def _execute_command(self, cmd) -> str:
671
+ """Execute a parsed agent command.
672
+
673
+ Args:
674
+ cmd: ParsedCommand instance.
675
+
676
+ Returns:
677
+ Result string.
678
+ """
679
+ if cmd.type == "agent":
680
+ return await self._spawn_agents(cmd.agents)
681
+ elif cmd.type == "message":
682
+ return await self._message_agent(cmd.target, cmd.content)
683
+ elif cmd.type == "stop":
684
+ return await self._stop_agents(cmd.targets)
685
+ elif cmd.type == "status":
686
+ return await self._get_status()
687
+ elif cmd.type == "capture":
688
+ return await self._capture_output(cmd.target, cmd.lines)
689
+ elif cmd.type == "clone":
690
+ return await self._clone_agent(cmd.agents[0] if cmd.agents else None)
691
+ elif cmd.type == "team":
692
+ return await self._spawn_team(cmd.lead, cmd.workers, cmd.agents)
693
+ elif cmd.type == "broadcast":
694
+ return await self._broadcast(cmd.pattern, cmd.content)
695
+ else:
696
+ return f"[error: unknown command type '{cmd.type}']"
697
+
698
+ async def _spawn_agents(self, agents: list) -> str:
699
+ """Spawn multiple agents.
700
+
701
+ Args:
702
+ agents: List of AgentTask instances.
703
+
704
+ Returns:
705
+ Result string.
706
+ """
707
+ if not self.orchestrator:
708
+ return "[error: orchestrator not initialized]"
709
+
710
+ spawned = []
711
+
712
+ for agent in agents:
713
+ success = await self.orchestrator.spawn(
714
+ name=agent.name, task=agent.task, files=agent.files
715
+ )
716
+ if success:
717
+ spawned.append(agent.name)
718
+ if self.activity_monitor:
719
+ self.activity_monitor.track(agent.name)
720
+
721
+ # Emit plugin-specific event (string literal, not in core EventType)
722
+ if self.event_bus:
723
+ await self.event_bus.emit_with_hooks(
724
+ "agent_spawned",
725
+ {"name": agent.name, "task": agent.task},
726
+ "agent_orchestrator"
727
+ )
728
+
729
+ if spawned:
730
+ return f"[spawned: {', '.join(spawned)}]"
731
+ return "[error: no agents spawned]"
732
+
733
+ async def _message_agent(self, target: str, content: str) -> str:
734
+ """Send message to agent.
735
+
736
+ Args:
737
+ target: Agent name.
738
+ content: Message content.
739
+
740
+ Returns:
741
+ Result string.
742
+ """
743
+ if not self.orchestrator:
744
+ return "[error: orchestrator not initialized]"
745
+
746
+ success = await self.orchestrator.message(target, content)
747
+
748
+ # Reset activity state so monitor waits for new activity
749
+ if success and self.activity_monitor:
750
+ self.activity_monitor.reset_agent_state(target)
751
+
752
+ if success:
753
+ return f"[message sent: {target}]"
754
+ return f"[error: agent {target} not found]"
755
+
756
+ async def _stop_agents(self, targets: list) -> str:
757
+ """Stop agents and capture final output.
758
+
759
+ Args:
760
+ targets: List of agent names to stop.
761
+
762
+ Returns:
763
+ Result string.
764
+ """
765
+ if not self.orchestrator:
766
+ return "[error: orchestrator not initialized]"
767
+
768
+ results = []
769
+
770
+ for target in targets:
771
+ if self.activity_monitor:
772
+ self.activity_monitor.untrack(target)
773
+
774
+ output, duration = await self.orchestrator.stop(target)
775
+
776
+ # Emit plugin-specific event (string literal, not in core EventType)
777
+ if self.event_bus:
778
+ await self.event_bus.emit_with_hooks(
779
+ "agent_stopped",
780
+ {"name": target, "duration": duration, "output": output},
781
+ "agent_orchestrator"
782
+ )
783
+
784
+ # Truncate output for display
785
+ output_lines = output.strip().split("\n")
786
+ if len(output_lines) > 10:
787
+ output_preview = "\n".join(output_lines[-10:])
788
+ else:
789
+ output_preview = output.strip()
790
+
791
+ results.append(f"[stopped: {target} @ {duration}]\n{output_preview}")
792
+
793
+ return "\n".join(results)
794
+
795
+ async def _get_status(self) -> str:
796
+ """Get status of all agents.
797
+
798
+ Returns:
799
+ Status string.
800
+ """
801
+ if not self.orchestrator:
802
+ return "[error: orchestrator not initialized]"
803
+
804
+ agents = self.orchestrator.list_agents()
805
+
806
+ if not agents:
807
+ return "[agents]\n (none active)"
808
+
809
+ lines = ["[agents]"]
810
+ for agent in agents:
811
+ lines.append(f" {agent.name:<20} {agent.status:<10} {agent.duration}")
812
+
813
+ return "\n".join(lines)
814
+
815
+ async def _capture_output(self, target: str, lines: int) -> str:
816
+ """Capture output from agent.
817
+
818
+ Args:
819
+ target: Agent name.
820
+ lines: Number of lines to capture.
821
+
822
+ Returns:
823
+ Captured output string.
824
+ """
825
+ if not self.orchestrator:
826
+ return "[error: orchestrator not initialized]"
827
+
828
+ output = self.orchestrator.capture_output(target, lines)
829
+ agent = self.orchestrator.get_agent(target)
830
+ duration = agent.duration if agent else "?"
831
+
832
+ return f"[capture: {target} @ {duration}, {lines} lines]\n{output}"
833
+
834
+ async def _clone_agent(self, agent: AgentTask) -> str:
835
+ """Clone agent with conversation context.
836
+
837
+ Args:
838
+ agent: AgentTask to clone.
839
+
840
+ Returns:
841
+ Result string.
842
+ """
843
+ if not agent:
844
+ return "[error: no agent specified for clone]"
845
+
846
+ if not self.orchestrator:
847
+ return "[error: orchestrator not initialized]"
848
+
849
+ # Export conversation
850
+ conv_file = await self._export_conversation()
851
+ if not conv_file:
852
+ return "[error: could not export conversation for clone]"
853
+
854
+ success = await self.orchestrator.spawn_clone(
855
+ name=agent.name,
856
+ task=agent.task,
857
+ files=agent.files,
858
+ conversation_file=conv_file,
859
+ )
860
+
861
+ if success:
862
+ if self.activity_monitor:
863
+ self.activity_monitor.track(agent.name)
864
+ return f"[cloned: {agent.name} with conversation context]"
865
+ return f"[error: failed to clone {agent.name}]"
866
+
867
+ async def _spawn_team(self, lead: str, workers: int, agents: list) -> str:
868
+ """Spawn team lead agent.
869
+
870
+ Args:
871
+ lead: Lead agent name.
872
+ workers: Max number of workers.
873
+ agents: List of agent tasks (should have one for the lead).
874
+
875
+ Returns:
876
+ Result string.
877
+ """
878
+ if not self.orchestrator:
879
+ return "[error: orchestrator not initialized]"
880
+
881
+ task = agents[0] if agents else AgentTask(name=lead, task="", files=[])
882
+
883
+ success = await self.orchestrator.spawn_team_lead(
884
+ lead_name=lead, max_workers=workers, task=task
885
+ )
886
+
887
+ if success:
888
+ if self.activity_monitor:
889
+ self.activity_monitor.track(lead)
890
+ return f"[team spawned: {lead} (max {workers} workers)]"
891
+ return f"[error: failed to spawn team {lead}]"
892
+
893
+ async def _broadcast(self, pattern: str, content: str) -> str:
894
+ """Broadcast message to agents matching pattern.
895
+
896
+ Args:
897
+ pattern: Glob pattern for matching agents.
898
+ content: Message content.
899
+
900
+ Returns:
901
+ Result string.
902
+ """
903
+ if not self.orchestrator:
904
+ return "[error: orchestrator not initialized]"
905
+
906
+ targets = self.orchestrator.find_agents(pattern)
907
+ count = 0
908
+
909
+ for target in targets:
910
+ if await self.orchestrator.message(target, content):
911
+ count += 1
912
+ # Reset activity state
913
+ if self.activity_monitor:
914
+ self.activity_monitor.reset_agent_state(target)
915
+
916
+ return f"[broadcast: sent to {count} agents matching '{pattern}']"
917
+
918
+ # -------------------------------------------------------------------------
919
+ # Callbacks
920
+ # -------------------------------------------------------------------------
921
+
922
+ async def _on_agent_complete(
923
+ self, name: str, duration: str, output: str
924
+ ) -> None:
925
+ """Called when activity monitor detects agent completion.
926
+
927
+ Args:
928
+ name: Agent name.
929
+ duration: Duration string.
930
+ output: Captured output.
931
+ """
932
+ logger.info(f"Agent completed: {name} @ {duration}")
933
+
934
+ # Emit plugin-specific event (string literal, not in core EventType)
935
+ if self.event_bus:
936
+ await self.event_bus.emit_with_hooks(
937
+ "agent_completed",
938
+ {"name": name, "duration": duration, "output": output},
939
+ "agent_orchestrator"
940
+ )
941
+
942
+ # Inject completion message
943
+ if self.message_injector:
944
+ # Truncate output for summary
945
+ output_lines = output.strip().split("\n")
946
+ if len(output_lines) > 20:
947
+ summary = "\n".join(output_lines[-20:])
948
+ else:
949
+ summary = output.strip()
950
+
951
+ await self.message_injector.inject(
952
+ source=name,
953
+ content=f"[done: {name} @ {duration}]\n{summary}",
954
+ trigger_llm=True, # auto-continue conversation
955
+ )
956
+
957
+ async def _export_conversation(self) -> Optional[str]:
958
+ """Export current conversation to temp file.
959
+
960
+ Returns:
961
+ Path to temp file, or None on error.
962
+ """
963
+ if not self.conversation_manager:
964
+ return None
965
+
966
+ try:
967
+ messages = self.conversation_manager.get_messages()
968
+
969
+ fd, path = tempfile.mkstemp(suffix=".json", prefix="conv-")
970
+ with open(path, "w") as f:
971
+ json.dump(messages, f)
972
+
973
+ return path
974
+ except Exception as e:
975
+ logger.error(f"Failed to export conversation: {e}")
976
+ return None